From f0c39b53a99ef321f26c579114d7cb6ed1a266d3 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 09:22:19 -0500 Subject: [PATCH 01/19] docs: Add the plan for praxis mode step 5 (agentop and install) The plan covers #1722. It adds the praxis.enabled field to the /config decode, one refusal helper, agentop setup --praxis, the systemd --supervise unit in praxis mode, update, install.sh --praxis and the gateway version check. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- ...11-10-praxis-mode-5-agentop-and-install.md | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-10-praxis-mode-5-agentop-and-install.md 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..6668042eb --- /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 `runServer([]string{"add", …, "--config", path}, …)`. Assert exit 1, the refusal on stderr, and the file bytes unchanged. + - `TestScriptList_RefusedInPraxisMode`: the same config, `runScript([]string{"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 take the same path as a start error. +- [ ] **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`: the existing setup scene with no `cortex-gateway` anywhere and no flag. Assert success (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, a praxis edit that is pending makes the plan `update` (not `done`). 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: none. 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. From 47f27d89d7b28b5b678ab1f64b464dae7d1a25d5 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 09:24:11 -0500 Subject: [PATCH 02/19] docs: Make the praxis mode step 5 plan steps exact Add the T7 dependency on T1, because both tasks edit docs/proxy-contract.md. Name the test helpers and the exact code path for the gateway version check. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../2026-11-10-praxis-mode-5-agentop-and-install.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 index 6668042eb..08080e1a5 100644 --- 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 @@ -110,8 +110,8 @@ Depends on: T1. Estimate: 170 lines. - `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 `runServer([]string{"add", …, "--config", path}, …)`. Assert exit 1, the refusal on stderr, and the file bytes unchanged. - - `TestScriptList_RefusedInPraxisMode`: the same config, `runScript([]string{"list", "--config", path}, …)`. Assert exit 1. + - `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). @@ -136,7 +136,7 @@ Depends on: none. Estimate: 140 lines. - 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 take the same path as a start error. +- [ ] **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`. @@ -169,10 +169,10 @@ Depends on: none. Estimate: 420 lines. - `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`: the existing setup scene with no `cortex-gateway` anywhere and no flag. Assert success (pins the Global Constraint). + - `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, a praxis edit that is pending makes the plan `update` (not `done`). 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 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`. @@ -214,7 +214,7 @@ Depends on: T5. Estimate: 50 lines. ### Task 7: `install.sh --praxis` -Depends on: none. Estimate: 270 lines. +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`) From ca156e9e35d734fb6b9714451b7498313bca34e6 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 09:25:35 -0500 Subject: [PATCH 03/19] feat: Read praxis.enabled from the running Cortex config runningConfig now decodes praxis.enabled from GET /config. When the value is true, cfg.Praxis is set. Otherwise cfg.Praxis stays nil. The wire fixture holds the praxis block, so a renamed key on either side fails a test. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/local_endpoint.go | 9 +++++++- cmd/agentop/local_endpoint_test.go | 34 +++++++++++++++++++++++++++++- core/observe/testdata/config.json | 3 +++ core/observe/wire_fixture_test.go | 1 + docs/proxy-contract.md | 2 +- 5 files changed, 46 insertions(+), 3 deletions(-) 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/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/proxy-contract.md b/docs/proxy-contract.md index ac1b9e205..a7ee135ca 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 | From b33d9e71219a75eeda1481899714473e2e497b24 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 09:34:09 -0500 Subject: [PATCH 04/19] feat: Refuse a cortex-gateway whose version differs from cortex cortex-gateway --version prints "cortex-gateway " and exits before it loads a config. The version is the build-time CORTEX_VERSION, or "dev". Before each gateway start, the supervisor runs the gateway with --version. When the version is not the cortex version, the supervisor does not start it, and retries with the normal backoff. Thus a later "agentop update" fixes a mismatch without a supervisor restart. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/cortex/restart_test.go | 8 +++++- cmd/cortex/supervise.go | 42 ++++++++++++++++++++++++++----- cmd/cortex/supervise_test.go | 48 ++++++++++++++++++++++++++++++++++++ praxis/server/src/main.rs | 25 ++++++++++++++++++- 4 files changed, 115 insertions(+), 8 deletions(-) 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..21feb937a 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,24 @@ 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] } // supervisorChildren returns the children to run. Direct mode is cortex alone, with @@ -203,11 +223,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 +318,16 @@ 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. + if got := gatewayVersionOf(spec.path); got != spec.wantVersion { + log.Error("supervisor: cortex-gateway version differs from cortex, so it does not start", + "want", spec.wantVersion, "got", got) + err = fmt.Errorf("cortex-gateway version %q differs from cortex %q", got, 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..4d555c0c9 100644 --- a/cmd/cortex/supervise_test.go +++ b/cmd/cortex/supervise_test.go @@ -328,3 +328,51 @@ 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") + } +} 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")) + ); + } } From 3a8dd5bd202181c6056bf28011f0f478bc046073 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 09:32:25 -0500 Subject: [PATCH 05/19] feat: Add the praxis block from agentop setup --praxis agentop setup --praxis sets praxis.enabled to true in the config. It uses a line edit, so the file keeps its comments, its order, its plugins and its servers. The edit is written only when the result loads and passes the checks that cortex makes at start. A flow-style praxis block is a problem with a fix to edit the file by hand. Setup with --praxis needs cortex-gateway, staged in --from or installed in the bin dir. The port preflight of a fresh install also probes 47605 and 47606. A change to the praxis block restarts the service, as a pin change does. insertListenerKeys is now a wrapper of insertBlockKeys. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/cmd_config_migrate.go | 25 ++- cmd/agentop/cmd_setup.go | 4 +- cmd/agentop/setup_praxis.go | 130 ++++++++++++++ cmd/agentop/setup_praxis_test.go | 279 ++++++++++++++++++++++++++++++ cmd/agentop/setup_runner.go | 5 +- cmd/agentop/setup_step_config.go | 35 +++- cmd/agentop/setup_step_service.go | 7 +- 7 files changed, 470 insertions(+), 15 deletions(-) create mode 100644 cmd/agentop/setup_praxis.go create mode 100644 cmd/agentop/setup_praxis_test.go diff --git a/cmd/agentop/cmd_config_migrate.go b/cmd/agentop/cmd_config_migrate.go index e7c7f138b..e4815c00d 100644 --- a/cmd/agentop/cmd_config_migrate.go +++ b/cmd/agentop/cmd_config_migrate.go @@ -263,22 +263,29 @@ 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) +} + +// 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), "#") { + if strings.HasPrefix(strings.TrimSpace(l), head) && !strings.HasPrefix(strings.TrimSpace(l), "#") { listenerAt = i break } } 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 +313,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 != "" { + 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 +330,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_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/setup_praxis.go b/cmd/agentop/setup_praxis.go new file mode 100644 index 000000000..b914e15f7 --- /dev/null +++ b/cmd/agentop/setup_praxis.go @@ -0,0 +1,130 @@ +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") + docIndent := -1 + praxisAt := -1 + for i, l := range lines { + trimmed := strings.TrimSpace(l) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + indent := len(l) - len(strings.TrimLeft(l, " ")) + if docIndent < 0 { + docIndent = indent + } + if indent == docIndent && strings.HasPrefix(trimmed, "praxis:") { + praxisAt = i + break + } + } + 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 + } + 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..eb266b435 --- /dev/null +++ b/cmd/agentop/setup_praxis_test.go @@ -0,0 +1,279 @@ +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) + } +} 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..6920bc687 100644 --- a/cmd/agentop/setup_step_config.go +++ b/cmd/agentop/setup_step_config.go @@ -22,6 +22,11 @@ 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) @@ -47,11 +52,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 +142,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) } From 99d9c06e9b769c621a9e4d7582e3072101952e61 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 09:26:22 -0500 Subject: [PATCH 06/19] feat: Supervise the gateway under systemd in praxis mode The systemd unit now runs cortex --supervise when the config file sets praxis.enabled. Only the supervisor starts cortex-gateway and writes gateway.token, so without it cortex refused to start in praxis mode. Direct mode keeps the unit as it was. agentop service install writes the unit again when its --supervise does not agree with the config file. configPraxisEnabled reads the flag and is false when the file does not load. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/cmd_service.go | 18 +++++++++ cmd/agentop/cmd_service_platform.go | 12 ++++-- cmd/agentop/cmd_service_test.go | 63 +++++++++++++++++++++++++++++ docs/laptop-service.md | 30 +++++++++++--- 4 files changed, 115 insertions(+), 8 deletions(-) diff --git a/cmd/agentop/cmd_service.go b/cmd/agentop/cmd_service.go index 3fc8b5de8..b23362fe0 100644 --- a/cmd/agentop/cmd_service.go +++ b/cmd/agentop/cmd_service.go @@ -111,6 +111,10 @@ type servicePaths struct { // than returned so uninstall and status still work on a broken config. configErr error home string // set explicitly: a supervisor's environment is minimal + // supervise makes the systemd unit run cortex --supervise. It is set from the + // config file (praxis.enabled) right before the unit is written. The plist + // always supervises. + supervise bool } func runService(args []string, stdout, stderr io.Writer) int { @@ -147,6 +151,7 @@ func runService(args []string, stdout, stderr io.Writer) int { if *printUnit { // Inspect before installing. Also the only way to exercise rendering without // registering a real agent in the caller's session. + p.supervise = configPraxisEnabled(p.configFile) fmt.Fprint(stdout, renderUnit(p)) return 0 } @@ -470,6 +475,7 @@ func runServiceInstall(p servicePaths, adopt int, forceRestart, announceUnit boo fmt.Fprintf(stderr, "agentop: %v\n", err) return installResult{exit: 1} } + p.supervise = configPraxisEnabled(p.configFile) if err := writeFileAtomic(p.unitFile, []byte(renderUnit(p)), 0o644); err != nil { fmt.Fprintf(stderr, "agentop: writing %s: %v\n", p.unitFile, err) return installResult{exit: 1} @@ -1207,6 +1213,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 +1227,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_platform.go b/cmd/agentop/cmd_service_platform.go index 40a8ee449..84dcec1a3 100644 --- a/cmd/agentop/cmd_service_platform.go +++ b/cmd/agentop/cmd_service_platform.go @@ -90,8 +90,10 @@ 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 (p.supervise) it uses --supervise, because only the supervisor +// starts cortex-gateway and writes gateway.token. func renderUnitFor(goos string, p servicePaths) string { if goos == "darwin" { // HOME is set explicitly: the config interpolates ${HOME} at load, and an @@ -148,7 +150,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 p.supervise { + supervise = " --supervise" + } return `[Unit] Description=Cortex local proxy (cortex) Documentation=https://github.com/rossoctl/cortex @@ -158,7 +164,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..bb7036980 100644 --- a/cmd/agentop/cmd_service_test.go +++ b/cmd/agentop/cmd_service_test.go @@ -802,3 +802,66 @@ 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. +func TestRenderUnit_LinuxPraxisSupervises(t *testing.T) { + p := servicePathsFixture(t) + p.supervise = true + 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/docs/laptop-service.md b/docs/laptop-service.md index 2e615da82..caff9ffba 100644 --- a/docs/laptop-service.md +++ b/docs/laptop-service.md @@ -45,11 +45,31 @@ 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 +``` + +To turn it off, set `praxis.enabled: false` in `~/.cortex/config.yaml` and run +`agentop service restart`. If you set `praxis.enabled: true` by hand on Linux, run +`agentop service install`, not `restart`: only `install` writes the unit with +`--supervise`. + +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 From 3c00edeb0fa4879a2e54392ab58bb9e058bf2003 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 09:53:48 -0500 Subject: [PATCH 07/19] test: Pin the Linux praxis clause of serviceIsCurrent Add two cases to TestServiceIsCurrent_BothPlatforms. With praxis.enabled true, a unit without --supervise is not current, and a unit rendered with supervise set is current. The second case stops a change that makes each Linux praxis install write the unit again at each run. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/cmd_service_goos_test.go | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/cmd/agentop/cmd_service_goos_test.go b/cmd/agentop/cmd_service_goos_test.go index b637a09f1..cf4c1fc52 100644 --- a/cmd/agentop/cmd_service_goos_test.go +++ b/cmd/agentop/cmd_service_goos_test.go @@ -114,6 +114,36 @@ 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) + } + p.supervise = true + 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. From 15878641b2b04e0485ebf1e9abb72210e5b5b9a0 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 10:05:17 -0500 Subject: [PATCH 08/19] feat: Refuse plugin edits in praxis mode In praxis mode Cortex runs no plugins, so "agentop server", "agentop script" and "agentop tools scan --write" have no effect. The new praxisRefusal helper refuses these commands when the config file or the running Cortex sets praxis.enabled. The message names the source and tells how to turn the mode off. serverTarget calls the helper, so each server and script action gets the check. Help output and "tools scan" without --write still work. Refs #1722 Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/cmd_server.go | 3 + cmd/agentop/cmd_tools.go | 15 ++++ cmd/agentop/praxis_mode.go | 34 ++++++++ cmd/agentop/praxis_mode_test.go | 148 ++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 cmd/agentop/praxis_mode.go create mode 100644 cmd/agentop/praxis_mode_test.go diff --git a/cmd/agentop/cmd_server.go b/cmd/agentop/cmd_server.go index e77ad2b18..bee8e5d72 100644 --- a/cmd/agentop/cmd_server.go +++ b/cmd/agentop/cmd_server.go @@ -151,6 +151,9 @@ func serverTarget(configPath string) (cfg *config.Config, path, statsURL string, if statsURL = dialURL(cfg.Stats.StatsAddress); statsURL != "" && !localStatsUp(statsURL) { statsURL = "" } + if err := praxisRefusal(cfg, path, statsURL); err != nil { + return nil, "", "", err + } return cfg, path, statsURL, nil } diff --git a/cmd/agentop/cmd_tools.go b/cmd/agentop/cmd_tools.go index 2cb9a962b..ed422f360 100644 --- a/cmd/agentop/cmd_tools.go +++ b/cmd/agentop/cmd_tools.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/rossoctl/cortex/cmd/agentop/toolscan" + "github.com/rossoctl/cortex/core/config" ) const toolsUsage = `agentop tools — read agent logs to measure tool use @@ -122,6 +123,20 @@ 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, err := config.Load(*write); err == nil { + statsURL := dialURL(cfg.Stats.StatsAddress) + if statsURL != "" && !localStatsUp(statsURL) { + statsURL = "" + } + if err := praxisRefusal(cfg, *write, 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/praxis_mode.go b/cmd/agentop/praxis_mode.go new file mode 100644 index 000000000..04955a1c9 --- /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 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 plugins and this command has no effect. To turn it off, set praxis.enabled: false in %s and run: agentop service 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..ff39202a2 --- /dev/null +++ b/cmd/agentop/praxis_mode_test.go @@ -0,0 +1,148 @@ +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 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 TestScriptList_RefusedInPraxisMode(t *testing.T) { + path, _ := praxisConfigFile(t) + code, _, errOut := runScriptCmd(t, "list", "--config", path) + if code != 1 || !strings.Contains(errOut, "praxis mode is on") { + t.Errorf("exit %d, stderr: %s", code, 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()) + } +} From 0b4f6298358a8cab2794de7c37e7720e08d8a309 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 10:23:05 -0500 Subject: [PATCH 09/19] feat: Keep praxis mode across agentop update When the config file sets praxis.enabled, agentop update gives --praxis to the installer. A config that does not load gives no --praxis. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/cmd_update.go | 13 ++++++++---- cmd/agentop/cmd_update_test.go | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) 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) + } +} From 6df8173e0b922fbea9e4eb550c5601ad086c2c48 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 10:09:58 -0500 Subject: [PATCH 10/19] feat: Add install.sh --praxis With --praxis, install.sh also downloads the cortex-gateway archive, checks it against checksums.txt, stages cortex-gateway and passes --praxis to agentop setup. It refuses darwin/amd64, AUTHBRIDGE_SKIP_DOWNLOAD=1 and each release that has no cortex-gateway archive. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- docs/proxy-contract.md | 1 + scripts/install.sh | 76 ++++++++++++++++++++++++++++++++++++----- scripts/install_test.sh | 72 +++++++++++++++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 9 deletions(-) diff --git a/docs/proxy-contract.md b/docs/proxy-contract.md index a7ee135ca..0c3c4b8c2 100644 --- a/docs/proxy-contract.md +++ b/docs/proxy-contract.md @@ -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/scripts/install.sh b/scripts/install.sh index 4e010d01b..e31e2d2aa 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 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 From fa977f5bfd2ff713affdd7cf9fb52814b34431d6 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 10:46:21 -0500 Subject: [PATCH 11/19] fix: Refuse setup without --praxis when a praxis config has no staged gateway A plain re-run of the installer on a praxis install stages no cortex-gateway. The new cortex then refuses the old gateway, and the proxy answers 503 to every request. Setup now stops with a problem that tells the user to run the installer with --praxis. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/setup_praxis_test.go | 15 +++++++++++++++ cmd/agentop/setup_step_config.go | 11 ++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/cmd/agentop/setup_praxis_test.go b/cmd/agentop/setup_praxis_test.go index eb266b435..85e71ce59 100644 --- a/cmd/agentop/setup_praxis_test.go +++ b/cmd/agentop/setup_praxis_test.go @@ -277,3 +277,18 @@ func TestPreflightPorts_PraxisProbesGatewayPorts(t *testing.T) { 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) + } +} diff --git a/cmd/agentop/setup_step_config.go b/cmd/agentop/setup_step_config.go index 6920bc687..aafa10ba2 100644 --- a/cmd/agentop/setup_step_config.go +++ b/cmd/agentop/setup_step_config.go @@ -32,12 +32,21 @@ func (configStep) plan(env *setupEnv) (stepPlan, *problem) { 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()} From 7b58eeee099ce8da0d71612905dcd564e2101e3f Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 11:05:19 -0500 Subject: [PATCH 12/19] fix: Log a gateway version refusal one time, and name a missing answer The supervisor logged an ERROR line for a gateway version mismatch on each retry. The same error then also went to the start-failure log. Now the error goes only to the start-failure log, which logs ERROR one time and Debug after. An empty --version answer (a missing gateway, or one from before --version) now has its own message, not "version differs". Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/cortex/supervise.go | 21 +++++++++++++++------ cmd/cortex/supervise_test.go | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/cmd/cortex/supervise.go b/cmd/cortex/supervise.go index 21feb937a..fd568b98c 100644 --- a/cmd/cortex/supervise.go +++ b/cmd/cortex/supervise.go @@ -197,6 +197,18 @@ var gatewayVersionOf = func(path string) string { 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 // today's args and env, and touches no file. Praxis mode first writes the gateway // files next to the config, then adds cortex-gateway, found next to this binary. The @@ -320,12 +332,9 @@ func superviseChild(spec childSpec, stop <-chan struct{}) error { var err error 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. - if got := gatewayVersionOf(spec.path); got != spec.wantVersion { - log.Error("supervisor: cortex-gateway version differs from cortex, so it does not start", - "want", spec.wantVersion, "got", got) - err = fmt.Errorf("cortex-gateway version %q differs from cortex %q", got, spec.wantVersion) - } + // 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() diff --git a/cmd/cortex/supervise_test.go b/cmd/cortex/supervise_test.go index 4d555c0c9..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" @@ -376,3 +377,16 @@ func TestSuperviseChild_GatewayVersionMatchStarts(t *testing.T) { 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) + } +} From e02bd5ed6cb5c3c7ba0e2a5113fb28187262286c Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 11:05:20 -0500 Subject: [PATCH 13/19] fix: Let agentop server and script list work in praxis mode A list changes nothing, so "this command has no effect" was not true for it. Only the actions that change the router or the scripts refuse now: server add, remove, use, reset and move, and script install and remove. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/cmd_script.go | 6 ++++++ cmd/agentop/cmd_server.go | 12 +++++++++++- cmd/agentop/cmd_server_write.go | 10 +++++----- cmd/agentop/praxis_mode_test.go | 18 +++++++++++++++--- 4 files changed, 37 insertions(+), 9 deletions(-) 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 bee8e5d72..a30622660 100644 --- a/cmd/agentop/cmd_server.go +++ b/cmd/agentop/cmd_server.go @@ -151,7 +151,17 @@ func serverTarget(configPath string) (cfg *config.Config, path, statsURL string, if statsURL = dialURL(cfg.Stats.StatsAddress); statsURL != "" && !localStatsUp(statsURL) { statsURL = "" } - if err := praxisRefusal(cfg, path, statsURL); err != nil { + 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 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/praxis_mode_test.go b/cmd/agentop/praxis_mode_test.go index ff39202a2..56876eb80 100644 --- a/cmd/agentop/praxis_mode_test.go +++ b/cmd/agentop/praxis_mode_test.go @@ -111,12 +111,24 @@ func TestServerAdd_RefusedInPraxisMode(t *testing.T) { assertFile(t, path, src) } -func TestScriptList_RefusedInPraxisMode(t *testing.T) { - path, _ := praxisConfigFile(t) - code, _, errOut := runScriptCmd(t, "list", "--config", path) +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) + } + if _, _, errOut := runServerCmd(t, "", "--config", path); strings.Contains(errOut, "praxis mode is on") { + t.Errorf("server list refused: %s", errOut) + } } func praxisTranscripts(t *testing.T) string { From 428153069bf531f79d321f8598556517b484f872 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 11:26:39 -0500 Subject: [PATCH 14/19] fix: Add block keys only to a top-level block insertBlockKeys took the first "praxis:" line at any depth. A praxis: key nested in a plugin config, before the top-level block, got enabled: true. The verify refused the result, so setup failed at apply on a valid config. Now only a key at the document's top level matches. This also applies to listener:. A comment after the block key ("praxis: # note") is no longer read as flow style. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/cmd_config_migrate.go | 15 ++++++++++++--- cmd/agentop/setup_praxis_test.go | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/cmd/agentop/cmd_config_migrate.go b/cmd/agentop/cmd_config_migrate.go index e4815c00d..98f0646d2 100644 --- a/cmd/agentop/cmd_config_migrate.go +++ b/cmd/agentop/cmd_config_migrate.go @@ -277,9 +277,18 @@ func insertBlockKeys(src, block string, pins []pinnedListener) (string, error) { lines := strings.Split(src, "\n") head := block + ":" - listenerAt := -1 + // Only a top-level key: the same name nested in a plugin's config is another block. + listenerAt, docIndent := -1, -1 for i, l := range lines { - if strings.HasPrefix(strings.TrimSpace(l), head) && !strings.HasPrefix(strings.TrimSpace(l), "#") { + trimmed := strings.TrimSpace(l) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + indent := len(l) - len(strings.TrimLeft(l, " ")) + if docIndent < 0 { + docIndent = indent + } + if indent == docIndent && strings.HasPrefix(trimmed, head) { listenerAt = i break } @@ -313,7 +322,7 @@ func insertBlockKeys(src, block 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]), head)); 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) } diff --git a/cmd/agentop/setup_praxis_test.go b/cmd/agentop/setup_praxis_test.go index 85e71ce59..158d5c9ce 100644 --- a/cmd/agentop/setup_praxis_test.go +++ b/cmd/agentop/setup_praxis_test.go @@ -292,3 +292,26 @@ func TestConfigStep_PraxisConfigNeedsStagedGateway(t *testing.T) { 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) + } +} From fd2473af17fd11a5c1afbf0816cb1e839e0271d0 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 11:26:48 -0500 Subject: [PATCH 15/19] docs: Say that praxis mode skips only outbound plugins, and the Linux off step The refusal said "Cortex runs no plugins", but only the outbound pipeline is not built. On Linux, turning praxis mode off by hand needs agentop service install, because restart does not write the unit again. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/praxis_mode.go | 4 ++-- docs/laptop-service.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/agentop/praxis_mode.go b/cmd/agentop/praxis_mode.go index 04955a1c9..d15cb1490 100644 --- a/cmd/agentop/praxis_mode.go +++ b/cmd/agentop/praxis_mode.go @@ -7,7 +7,7 @@ import ( ) // 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 plugins, so a +// 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". @@ -30,5 +30,5 @@ func praxisRefusal(cfg *config.Config, path, statsURL string) error { default: return nil } - return fmt.Errorf("praxis mode is on (%s), so Cortex runs no plugins and this command has no effect. To turn it off, set praxis.enabled: false in %s and run: agentop service restart", where, path) + 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 restart", where, path) } diff --git a/docs/laptop-service.md b/docs/laptop-service.md index caff9ffba..7ae6bded0 100644 --- a/docs/laptop-service.md +++ b/docs/laptop-service.md @@ -59,9 +59,9 @@ curl -fsSL https://www.rossoctl.dev/cortex/install.sh | sh -s -- --praxis ``` To turn it off, set `praxis.enabled: false` in `~/.cortex/config.yaml` and run -`agentop service restart`. If you set `praxis.enabled: true` by hand on Linux, run -`agentop service install`, not `restart`: only `install` writes the unit with -`--supervise`. +`agentop service restart`. On Linux, when you change `praxis.enabled` by hand in +either direction, run `agentop service install`, not `restart`: only `install` writes +the unit again, with or without `--supervise`. From a source tree, build the gateway and give it to setup: From bd7423cc0569abe2e63b6a69282c3f7a6fc91822 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 11:57:05 -0500 Subject: [PATCH 16/19] refactor: Read praxis.enabled in the unit, and share the top-level key scan The systemd unit now reads praxis.enabled from the config file itself. Before, each caller had to set servicePaths.supervise before it wrote the unit, and no test failed when a caller did not. The field is gone. TestRenderUnit_LinuxPraxisSupervises now fails when the unit does not read the file. insertBlockKeys and enablePraxisText had the same scan for a top-level key. Both now call topLevelKey. agentop tools scan --write gets the config and the stats URL from serverTarget, and not from its own copy of that code. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/cmd_config_migrate.go | 34 ++++++++++++++++------------ cmd/agentop/cmd_service.go | 6 ----- cmd/agentop/cmd_service_goos_test.go | 1 - cmd/agentop/cmd_service_platform.go | 7 +++--- cmd/agentop/cmd_service_test.go | 7 ++++-- cmd/agentop/cmd_tools.go | 9 ++------ cmd/agentop/setup_praxis.go | 17 +------------- 7 files changed, 32 insertions(+), 49 deletions(-) diff --git a/cmd/agentop/cmd_config_migrate.go b/cmd/agentop/cmd_config_migrate.go index 98f0646d2..9ad7b0b55 100644 --- a/cmd/agentop/cmd_config_migrate.go +++ b/cmd/agentop/cmd_config_migrate.go @@ -267,18 +267,11 @@ func insertListenerKeys(src string, pins []pinnedListener) (string, error) { return insertBlockKeys(src, "listener", pins) } -// 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 insertBlockKeys(src, block string, pins []pinnedListener) (string, error) { - lines := strings.Split(src, "\n") - head := block + ":" - - // Only a top-level key: the same name nested in a plugin's config is another block. - listenerAt, docIndent := -1, -1 +// 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, "#") { @@ -289,10 +282,23 @@ func insertBlockKeys(src, block string, pins []pinnedListener) (string, error) { docIndent = indent } if indent == docIndent && strings.HasPrefix(trimmed, head) { - listenerAt = i - break + return i, docIndent } } + return -1, docIndent +} + +// 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 insertBlockKeys(src, block string, pins []pinnedListener) (string, error) { + lines := strings.Split(src, "\n") + head := block + ":" + + listenerAt, _ := topLevelKey(lines, head) if listenerAt < 0 { return "", fmt.Errorf("no %s block found", head) } diff --git a/cmd/agentop/cmd_service.go b/cmd/agentop/cmd_service.go index b23362fe0..14ef4b557 100644 --- a/cmd/agentop/cmd_service.go +++ b/cmd/agentop/cmd_service.go @@ -111,10 +111,6 @@ type servicePaths struct { // than returned so uninstall and status still work on a broken config. configErr error home string // set explicitly: a supervisor's environment is minimal - // supervise makes the systemd unit run cortex --supervise. It is set from the - // config file (praxis.enabled) right before the unit is written. The plist - // always supervises. - supervise bool } func runService(args []string, stdout, stderr io.Writer) int { @@ -151,7 +147,6 @@ func runService(args []string, stdout, stderr io.Writer) int { if *printUnit { // Inspect before installing. Also the only way to exercise rendering without // registering a real agent in the caller's session. - p.supervise = configPraxisEnabled(p.configFile) fmt.Fprint(stdout, renderUnit(p)) return 0 } @@ -475,7 +470,6 @@ func runServiceInstall(p servicePaths, adopt int, forceRestart, announceUnit boo fmt.Fprintf(stderr, "agentop: %v\n", err) return installResult{exit: 1} } - p.supervise = configPraxisEnabled(p.configFile) if err := writeFileAtomic(p.unitFile, []byte(renderUnit(p)), 0o644); err != nil { fmt.Fprintf(stderr, "agentop: writing %s: %v\n", p.unitFile, err) return installResult{exit: 1} diff --git a/cmd/agentop/cmd_service_goos_test.go b/cmd/agentop/cmd_service_goos_test.go index cf4c1fc52..2e4a9ec37 100644 --- a/cmd/agentop/cmd_service_goos_test.go +++ b/cmd/agentop/cmd_service_goos_test.go @@ -131,7 +131,6 @@ func TestServiceIsCurrent_BothPlatforms(t *testing.T) { if err := os.WriteFile(p.configFile, []byte("praxis:\n enabled: true\n"), 0o600); err != nil { t.Fatal(err) } - p.supervise = true unit := renderUnitFor("linux", p) if !strings.Contains(unit, "--supervise") { t.Fatal("a supervising systemd unit has no --supervise; this case says nothing") diff --git a/cmd/agentop/cmd_service_platform.go b/cmd/agentop/cmd_service_platform.go index 84dcec1a3..ea32ad6f6 100644 --- a/cmd/agentop/cmd_service_platform.go +++ b/cmd/agentop/cmd_service_platform.go @@ -92,8 +92,9 @@ func shQuote(v string) string { // // 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 (p.supervise) it uses --supervise, because only the supervisor -// starts cortex-gateway and writes gateway.token. +// 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 @@ -152,7 +153,7 @@ func renderUnitFor(goos string, p servicePaths) string { // systemd's own default would let hold a restart for 90s. There is no supervisor in // between here in direct mode: KillMode=control-group signals the proxy directly. supervise := "" - if p.supervise { + if configPraxisEnabled(p.configFile) { supervise = " --supervise" } return `[Unit] diff --git a/cmd/agentop/cmd_service_test.go b/cmd/agentop/cmd_service_test.go index bb7036980..90ceeac9c 100644 --- a/cmd/agentop/cmd_service_test.go +++ b/cmd/agentop/cmd_service_test.go @@ -804,10 +804,13 @@ func TestReportNotAnswering_LeadsWithTheHolder(t *testing.T) { } // TestRenderUnit_LinuxPraxisSupervises: in praxis mode the supervisor must start the -// gateway and write gateway.token, so the systemd unit runs cortex --supervise. +// 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) - p.supervise = true + 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) { diff --git a/cmd/agentop/cmd_tools.go b/cmd/agentop/cmd_tools.go index ed422f360..cf3433c24 100644 --- a/cmd/agentop/cmd_tools.go +++ b/cmd/agentop/cmd_tools.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/rossoctl/cortex/cmd/agentop/toolscan" - "github.com/rossoctl/cortex/core/config" ) const toolsUsage = `agentop tools — read agent logs to measure tool use @@ -125,12 +124,8 @@ func runTools(args []string, stdout, stderr io.Writer) int { // A file that does not load keeps today's path: PatchConfig reports the error. if *write != "" { - if cfg, err := config.Load(*write); err == nil { - statsURL := dialURL(cfg.Stats.StatsAddress) - if statsURL != "" && !localStatsUp(statsURL) { - statsURL = "" - } - if err := praxisRefusal(cfg, *write, statsURL); err != nil { + 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 } diff --git a/cmd/agentop/setup_praxis.go b/cmd/agentop/setup_praxis.go index b914e15f7..05e448a67 100644 --- a/cmd/agentop/setup_praxis.go +++ b/cmd/agentop/setup_praxis.go @@ -25,22 +25,7 @@ const praxisHandFix = "write `enabled: true` under praxis: by hand" // it. A flow-style praxis: block is an error. func enablePraxisText(src string) (out string, changed bool, err error) { lines := strings.Split(src, "\n") - docIndent := -1 - praxisAt := -1 - for i, l := range lines { - trimmed := strings.TrimSpace(l) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - indent := len(l) - len(strings.TrimLeft(l, " ")) - if docIndent < 0 { - docIndent = indent - } - if indent == docIndent && strings.HasPrefix(trimmed, "praxis:") { - praxisAt = i - break - } - } + praxisAt, docIndent := topLevelKey(lines, "praxis:") if praxisAt < 0 { pad := strings.Repeat(" ", max(docIndent, 0)) if src != "" && !strings.HasSuffix(src, "\n") { From ad6231de0a6d74e30d5b78cb1896b8cdaee2c5d8 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 12:47:12 -0500 Subject: [PATCH 17/19] fix: Say service install to leave praxis mode, and skip YAML markers in the key scan Fixes review: the praxis refusal and install.sh --help said `agentop service restart`, which on Linux leaves --supervise in the unit Fixes review: a "---" or "%" line set the document indentation, so an indented document lost its top-level keys Fixes review: an empty `server list` in praxis mode suggested `server add`, which praxis mode refuses Fixes review: the praxis list test did not check the server list exit code Files: - cmd/agentop/cmd_config_migrate.go - cmd/agentop/cmd_server.go - cmd/agentop/praxis_mode.go - cmd/agentop/praxis_mode_test.go - cmd/agentop/setup_praxis_test.go - scripts/install.sh Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/cmd_config_migrate.go | 8 +++++++- cmd/agentop/cmd_server.go | 6 +++++- cmd/agentop/praxis_mode.go | 2 +- cmd/agentop/praxis_mode_test.go | 7 ++++--- cmd/agentop/setup_praxis_test.go | 32 +++++++++++++++++++++++++++++++ scripts/install.sh | 2 +- 6 files changed, 50 insertions(+), 7 deletions(-) diff --git a/cmd/agentop/cmd_config_migrate.go b/cmd/agentop/cmd_config_migrate.go index 9ad7b0b55..a588efdda 100644 --- a/cmd/agentop/cmd_config_migrate.go +++ b/cmd/agentop/cmd_config_migrate.go @@ -274,7 +274,7 @@ 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, "#") { + if trimmed == "" || strings.HasPrefix(trimmed, "#") || isDocumentMarker(trimmed) { continue } indent := len(l) - len(strings.TrimLeft(l, " ")) @@ -288,6 +288,12 @@ func topLevelKey(lines []string, head string) (at, docIndent int) { 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. // diff --git a/cmd/agentop/cmd_server.go b/cmd/agentop/cmd_server.go index a30622660..78f315f2b 100644 --- a/cmd/agentop/cmd_server.go +++ b/cmd/agentop/cmd_server.go @@ -225,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 @@ -236,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/praxis_mode.go b/cmd/agentop/praxis_mode.go index d15cb1490..b2e0e52fa 100644 --- a/cmd/agentop/praxis_mode.go +++ b/cmd/agentop/praxis_mode.go @@ -30,5 +30,5 @@ func praxisRefusal(cfg *config.Config, path, statsURL string) error { 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 restart", where, path) + 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", where, path) } diff --git a/cmd/agentop/praxis_mode_test.go b/cmd/agentop/praxis_mode_test.go index 56876eb80..bfa269beb 100644 --- a/cmd/agentop/praxis_mode_test.go +++ b/cmd/agentop/praxis_mode_test.go @@ -78,7 +78,7 @@ func TestPraxisRefusal_FileOnly(t *testing.T) { 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 restart"} { + for _, want := range []string{"praxis mode is on (in /c.yaml)", "set praxis.enabled: false in /c.yaml", "agentop service install"} { if !strings.Contains(err.Error(), want) { t.Errorf("%q does not contain %q", err, want) } @@ -126,8 +126,9 @@ func TestLists_WorkInPraxisMode(t *testing.T) { if code, _, errOut := runScriptCmd(t, "list", "--config", path); code != 0 { t.Errorf("script list: exit %d, stderr: %s", code, errOut) } - if _, _, errOut := runServerCmd(t, "", "--config", path); strings.Contains(errOut, "praxis mode is on") { - t.Errorf("server list refused: %s", 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) } } diff --git a/cmd/agentop/setup_praxis_test.go b/cmd/agentop/setup_praxis_test.go index 158d5c9ce..ea41937fa 100644 --- a/cmd/agentop/setup_praxis_test.go +++ b/cmd/agentop/setup_praxis_test.go @@ -315,3 +315,35 @@ func TestEnablePraxisText_CommentOnlyBlock(t *testing.T) { 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) + } +} diff --git a/scripts/install.sh b/scripts/install.sh index e31e2d2aa..a81dbbcd9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -183,7 +183,7 @@ Options: --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 + agentop service install --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 From 42a90b8940138e907677cdbd5b8446ac4bfd3d90 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 12:51:52 -0500 Subject: [PATCH 18/19] fix: Say service install --restart to leave praxis mode Fixes review: `agentop service install` reports "Already current" on macOS, because serviceIsCurrent checks praxis only on Linux, so Cortex stayed in praxis mode Files: - cmd/agentop/praxis_mode.go - cmd/agentop/praxis_mode_test.go - docs/laptop-service.md - scripts/install.sh Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/praxis_mode.go | 2 +- cmd/agentop/praxis_mode_test.go | 2 +- docs/laptop-service.md | 9 +++++---- scripts/install.sh | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cmd/agentop/praxis_mode.go b/cmd/agentop/praxis_mode.go index b2e0e52fa..2161a8f29 100644 --- a/cmd/agentop/praxis_mode.go +++ b/cmd/agentop/praxis_mode.go @@ -30,5 +30,5 @@ func praxisRefusal(cfg *config.Config, path, statsURL string) error { 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", where, path) + 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 index bfa269beb..ff6586e69 100644 --- a/cmd/agentop/praxis_mode_test.go +++ b/cmd/agentop/praxis_mode_test.go @@ -78,7 +78,7 @@ func TestPraxisRefusal_FileOnly(t *testing.T) { 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"} { + 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) } diff --git a/docs/laptop-service.md b/docs/laptop-service.md index 7ae6bded0..b3e425940 100644 --- a/docs/laptop-service.md +++ b/docs/laptop-service.md @@ -58,10 +58,11 @@ else in the file: curl -fsSL https://www.rossoctl.dev/cortex/install.sh | sh -s -- --praxis ``` -To turn it off, set `praxis.enabled: false` in `~/.cortex/config.yaml` and run -`agentop service restart`. On Linux, when you change `praxis.enabled` by hand in -either direction, run `agentop service install`, not `restart`: only `install` writes -the unit again, with or without `--supervise`. +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: diff --git a/scripts/install.sh b/scripts/install.sh index a81dbbcd9..03ef9a29e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -183,7 +183,7 @@ Options: --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 + 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 From d7e707cbd2f948b8b95800736c898eee621f0314 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 13:03:11 -0500 Subject: [PATCH 19/19] fix: Keep the previous config when setup turns praxis mode on Fixes review: the setup plan said "(previous kept)" for a praxis-only edit, but enablePraxis wrote no .before-agentop-migrate copy Files: - cmd/agentop/cmd_config_migrate.go - cmd/agentop/setup_praxis.go - cmd/agentop/setup_praxis_test.go Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/cmd_config_migrate.go | 22 +++++++++++++++------- cmd/agentop/setup_praxis.go | 3 +++ cmd/agentop/setup_praxis_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/cmd/agentop/cmd_config_migrate.go b/cmd/agentop/cmd_config_migrate.go index a588efdda..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 diff --git a/cmd/agentop/setup_praxis.go b/cmd/agentop/setup_praxis.go index 05e448a67..2bbfe6f88 100644 --- a/cmd/agentop/setup_praxis.go +++ b/cmd/agentop/setup_praxis.go @@ -84,6 +84,9 @@ func enablePraxis(path string) (changed bool, err error) { 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") diff --git a/cmd/agentop/setup_praxis_test.go b/cmd/agentop/setup_praxis_test.go index ea41937fa..74e7a58c9 100644 --- a/cmd/agentop/setup_praxis_test.go +++ b/cmd/agentop/setup_praxis_test.go @@ -347,3 +347,31 @@ func TestInsertListenerKeys_AfterDocumentMarker(t *testing.T) { 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) + } +}