From 4714ceb3fd3e67078b732d3a4ea5c1e985be4cfe Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 14:23:18 -0400 Subject: [PATCH 1/4] fix(mcp): close the agent E2E product gaps (P7-SHIP-02) The MCP-only agent harness (P7-SHIP-02) stops at five cli gaps. This closes them. 1. nself mcp starts outside a project. It then lists nself_init and the read-only tools; once a project exists it lists every tool. 2. A first build can be bound by a plan id. A render that generates secrets gets an id its inputs reproduce: the content id of a second, read-only render with a fixed public seed, used only when both renders have the same plan. The plan declares the secrets-persist step, the apply writes fresh secrets, drift is still E450, and E451 remains for a render whose random values change the plan itself. The machine gate always returns a plan confirmation for a plan with secrets-persist, so an agent sees "build (plan, confirm)". 3. The tool list is live. After a successful call, mcpgen.Surface checks the project root and the installed plugin set; when they changed it rebuilds the machine registry and replaces the tools, which sends notifications/tools/list_changed (so the ci tools appear after add ci). 4. deploy promote declares two optional args, source and target, so the generated tool accepts ["qa", "prod"]. 5. nself mcp --timeout (default 5m) bounds each tool call (E423 after it). mcp now rejects positional args (cobra.NoArgs). Generated registry, inventory, OpenAPI, TS SDK types, MCP tool golden and wiki pages are regenerated; the hand docs (MCP-Server, Guide-Agents-MCP, Safe-On-Live, cmd-build, cmd-mcp) describe the new behaviour. --- .github/command-inventory.json | 1 + .github/command-registry.json | 25 ++- .github/openapi.json | 8 +- .github/wiki/Guide-Agents-MCP.md | 2 +- .github/wiki/MCP-Server.md | 6 +- .github/wiki/MCP-Tools.md | 164 +++++++++---------- .github/wiki/Safe-On-Live.md | 4 +- .github/wiki/cmd-build.md | 2 +- .github/wiki/cmd-mcp.md | 9 +- .github/wiki/commands/mcp-serve.md | 5 +- .github/wiki/error-codes.md | 2 +- .github/wiki/llms.txt | 1 + cmd/commands/build_plan_test.go | 39 +++-- cmd/commands/canon_engine_test.go | 2 +- cmd/commands/deploy.go | 2 +- cmd/commands/error_harness_test.go | 3 +- cmd/commands/mcp.go | 30 ++-- cmd/commands/mcp_adversarial_test.go | 2 +- cmd/commands/mcp_generated.go | 64 ++++---- cmd/commands/mcp_generated_test.go | 22 +-- cmd/commands/mcp_integration_test.go | 9 +- cmd/commands/mcp_surface_test.go | 161 +++++++++++++++++++ cmd/commands/mcp_test.go | 2 +- cmd/commands/surface_identity_test.go | 4 +- internal/errs/codes_reconcile.go | 2 +- internal/invoke/gate.go | 48 +----- internal/invoke/plan_facts.go | 70 ++++++++ internal/invoke/plan_facts_test.go | 42 +++++ internal/mcpgen/surface.go | 178 +++++++++++++++++++++ internal/mcpgen/surface_test.go | 32 ++++ internal/mcpgen/testdata/tools.golden.json | 8 +- internal/reconcile/apply.go | 4 +- internal/reconcile/apply_test.go | 60 +++++-- internal/reconcile/binding.go | 63 ++++++++ internal/reconcile/planner.go | 13 +- internal/reconcile/request.go | 2 + sdk/ts-cli-api/src/generated/types.ts | 2 +- 37 files changed, 850 insertions(+), 243 deletions(-) create mode 100644 cmd/commands/mcp_surface_test.go create mode 100644 internal/invoke/plan_facts.go create mode 100644 internal/invoke/plan_facts_test.go create mode 100644 internal/mcpgen/surface.go create mode 100644 internal/mcpgen/surface_test.go create mode 100644 internal/reconcile/binding.go diff --git a/.github/command-inventory.json b/.github/command-inventory.json index fd49f7d14..c475ac286 100644 --- a/.github/command-inventory.json +++ b/.github/command-inventory.json @@ -1128,6 +1128,7 @@ "--allowed-host", "--host", "--port", + "--timeout", "--transport" ], "subcommands": [ diff --git a/.github/command-registry.json b/.github/command-registry.json index 811d3a57e..bf5e2d342 100644 --- a/.github/command-registry.json +++ b/.github/command-registry.json @@ -14208,7 +14208,13 @@ "runnable": true, "args": [ { - "name": "source target", + "name": "source", + "required": false, + "variadic": false, + "secret": false + }, + { + "name": "target", "required": false, "variadic": false, "secret": false @@ -18317,6 +18323,23 @@ "cli_only": false, "secret": false }, + { + "name": "timeout", + "shorthand": null, + "type": "duration", + "default": "5m0s", + "usage": "Longest one tool call may run before its command is stopped, such as 15m", + "hidden": false, + "deprecated": null, + "required": false, + "persistent": false, + "env": null, + "side_effect": null, + "json": null, + "output": null, + "cli_only": false, + "secret": false + }, { "name": "transport", "shorthand": "t", diff --git a/.github/openapi.json b/.github/openapi.json index 434bea4af..400fe9805 100644 --- a/.github/openapi.json +++ b/.github/openapi.json @@ -41231,11 +41231,15 @@ "properties": { "args": { "items": false, - "maxItems": 1, + "maxItems": 2, "minItems": 0, "prefixItems": [ { - "description": "source target", + "description": "source", + "type": "string" + }, + { + "description": "target", "type": "string" } ], diff --git a/.github/wiki/Guide-Agents-MCP.md b/.github/wiki/Guide-Agents-MCP.md index ac818e13a..5bd75ccdd 100644 --- a/.github/wiki/Guide-Agents-MCP.md +++ b/.github/wiki/Guide-Agents-MCP.md @@ -16,7 +16,7 @@ nself mcp setup --client claude-code --write # doc-check: skip registered in cmd ## One server per project -Run one MCP server per project. `nself mcp` must start inside a directory that holds an nSelf project, and its tools act on that project only. Do not point one server at several projects. If you work on two projects, register two servers, each started from its own project directory. Tool results then never mix up which stack was changed. +Run one MCP server per project. Start `nself mcp` in the project's directory; its tools act on that project only. Started in a directory with no project yet, it serves `nself_init` and the read-only tools; once `init` has created the project it lists every tool and sends `notifications/tools/list_changed`, and it does the same after a plugin is added or removed (for example the `ci` tools after `add ci`). Do not point one server at several projects. If you work on two projects, register two servers, each started from its own project directory. Tool results then never mix up which stack was changed. ## The confirm and dry-run gate diff --git a/.github/wiki/MCP-Server.md b/.github/wiki/MCP-Server.md index 15d21691c..48cbc523d 100644 --- a/.github/wiki/MCP-Server.md +++ b/.github/wiki/MCP-Server.md @@ -23,7 +23,7 @@ After the path matches, the child rebuilds the registry from its mounted command Running as a child keeps global state, `os.Exit` and the project operation lock identical to a typed command. There is no shell anywhere: every request value is exactly one argv element. -The **machine registry** is the v1.5 registry of the running binary with installed plugin commands mounted. It is the same in both compat modes ([[Compat-V15]]), so tool names and routes are the v1.5 shape in 1.4.x too. The plugin set is read when the server starts; install a plugin, then restart the server. +The **machine registry** is the v1.5 registry of the running binary with installed plugin commands mounted. It is the same in both compat modes ([[Compat-V15]]), so tool names and routes are the v1.5 shape in 1.4.x too. The plugin set is read when the server starts and again after any successful call that changed it: `nself mcp` then rebuilds the registry, replaces its tools and sends `notifications/tools/list_changed`, so tools of a plugin added in the session (`add ci`) appear without a restart. Started with no project, `nself mcp` lists `nself_init` and the read-only tools only, and lists the rest the same way once a project exists. Resources keep the registry of the server start. Plugin commands are listed as tools like any other command, but a call that arrives while a secret bundle was restored is refused with `E421` because plugins do not yet support the secret-handoff protocol (D-0651). A machine-invoked plugin call that carries no secrets runs normally. ## Exposure rules @@ -100,7 +100,7 @@ Any inherited value of these names is dropped first. The working directory is th ## Timeout and output limits -A document request times out after 300 seconds by default (the invoker takes the timeout as an option; the servers expose it as `--timeout`). The child leads its own process group. The group gets SIGTERM, and SIGKILL five seconds later or as soon as the child is gone, so grandchildren (docker compose, ssh, helpers) do not outlive a timed-out or cancelled request; the result is `E423`. An NDJSON request has no timeout and ends when the client disconnects. A long-polling command such as `nself ci runs wait` reads `NSELF_INVOKE_DEADLINE_MS` and returns before the kill. +A document request times out after 300 seconds by default (`nself mcp --timeout 15m` raises it for that server; the invoker takes the timeout as an option). The child leads its own process group. The group gets SIGTERM, and SIGKILL five seconds later or as soon as the child is gone, so grandchildren (docker compose, ssh, helpers) do not outlive a timed-out or cancelled request; the result is `E423`. An NDJSON request has no timeout and ends when the client disconnects. A long-polling command such as `nself ci runs wait` reads `NSELF_INVOKE_DEADLINE_MS` and returns before the kill. The child's standard output is capped at 32 MiB and must be exactly one JSON object. Anything else is `E422` (exit class infra); its `cause` is the last 2 KiB of the child's standard error, with the request's secret values replaced and the usual redaction applied. A child envelope that carries `meta.deprecations` or `meta.warnings` passes through byte for byte. @@ -141,7 +141,7 @@ The gate decides on the effective side effect of the request (flag escalation ap - Consent and plan-id flags belong to the invoker. A request that sets one is refused with `E425` before anything runs. - `read` runs. - `write` without a plan form runs, with no consent flags. -- A command with a plan form and no `confirm`: the invoker runs the plan form once (` --json -- ...`) and reads `data.plan_id` and `data.requires_confirmation` from the child's change plan. When `requires_confirmation` is true, or the class is `remote` or `destructive`, it returns a confirmation of kind `plan` whose `confirm` is the plan id. Otherwise it runs the command once with `--=`. A plan exec that fails returns the child's error envelope unchanged; a plan without a valid `plan_id` or `requires_confirmation` is `E422`. The plan exec and the run are two children, each with the full invocation timeout. +- A command with a plan form and no `confirm`: the invoker runs the plan form once (` --json -- ...`) and reads `data.plan_id` and `data.requires_confirmation` from the child's change plan. When `requires_confirmation` is true, the class is `remote` or `destructive`, or the plan declares the `secrets-persist` effect (a first build that generates secrets), it returns a confirmation of kind `plan` whose `confirm` is the plan id. Otherwise it runs the command once with `--=`. A plan exec that fails returns the child's error envelope unchanged; a plan without a valid `plan_id` or `requires_confirmation` is `E422`. The plan exec and the run are two children, each with the full invocation timeout. - `remote` or `destructive` without a plan form and no `confirm`: a confirmation of kind `request`, whose `confirm` is a nonce. - A server without a confirmation store refuses every remote and destructive command with `E421`. diff --git a/.github/wiki/MCP-Tools.md b/.github/wiki/MCP-Tools.md index 6c42572fb..dfaa9ae8b 100644 --- a/.github/wiki/MCP-Tools.md +++ b/.github/wiki/MCP-Tools.md @@ -221,75 +221,75 @@ The tools `nself mcp` serves in v1.5 mode, one per exposed command of the machin | `nself_deploy_health` | Run health checks against a target deployment | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L20118) | | `nself_deploy_ops` | Deploy with the ops profile to the ops server | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L20182) | | `nself_deploy_promote` | Promote a canary or an environment | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L20299) | -| `nself_deploy_promote_env` | Promote one environment to another (e.g. staging to prod) | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L20498) | -| `nself_deploy_promote_env_rollback` | Rollback to pre-promotion backup | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L20711) | -| `nself_deploy_rollback` | Roll back the last deployment for a target | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L20828) | -| `nself_deploy_security_setup` | Apply baseline server hardening | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L20951) | -| `nself_deploy_status` | Show deployment status for a target | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21101) | -| `nself_deploy_targets` | List deploy targets without connecting to them | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21202) | -| `nself_deploy_web` | Build web apps locally and deploy prebuilt output to Vercel | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21337) | -| `nself_doctor` | Run comprehensive system diagnostics | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21476) | -| `nself_doctor_heal` | Run targeted self-healing routines for nSelf components | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21670) | -| `nself_doctor_images` | Check availability of every locked image | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21833) | -| `nself_doctor_report` | Write a redacted support bundle (tar.gz) to share in an issue | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21911) | -| `nself_doctor_security_audit` | Run a read-only security audit | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21978) | -| `nself_doctor_security_status` | Show current security posture | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22053) | -| `nself_help_capabilities` | Search commands and plugins by what you want to do | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22391) | -| `nself_help_topics` | Moved to nself help topics | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22485) | -| `nself_init` | Initialize a new nSelf project | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22561) | -| `nself_init_template` | Browse and publish full-stack app templates | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22725) | -| `nself_init_template_info` | Show detail for a single template | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22788) | -| `nself_init_template_list` | List available app templates | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22900) | -| `nself_init_template_publish` | Submit a template to the nSelf registry | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23074) | -| `nself_init_template_update` | Apply incremental migrations for the installed template | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23196) | -| `nself_license` | Manage license keys for nSelf product bundles | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23259) | -| `nself_license_account` | Manage your nSelf account, sessions, licenses, team, and devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23322) | -| `nself_license_account_devices` | List and revoke registered devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23400) | -| `nself_license_account_licenses` | List and manage account licenses | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23480) | -| `nself_license_account_status` | Show current account summary | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23576) | -| `nself_license_account_team` | Manage team members | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23654) | -| `nself_license_add` | Add one or more license keys | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23724) | -| `nself_license_clear` | Remove all saved license keys | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23798) | -| `nself_license_health` | Validate format, ping server, and report cache integrity | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23909) | -| `nself_license_import` | Import a previously exported license cache file | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23976) | -| `nself_license_list` | Show all configured licenses (alias for status) | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24034) | -| `nself_license_migrate` | Migrate legacy license key to a nSelf account | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24160) | -| `nself_license_refresh` | Force-refresh license validation against ping.nself.org | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24336) | -| `nself_license_remove` | Remove a license key by value or product name | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24442) | -| `nself_license_restore` | Reactivate dormant plugins with a new license key | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24551) | -| `nself_license_revalidate` | Force a fresh validation against ping.nself.org and update the cache | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24663) | -| `nself_license_revoke` | Mark local license as revoked and wipe the stored key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24710) | -| `nself_license_set` | Replace all keys with a single key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24813) | -| `nself_license_show` | Display license tier, bundles, plugins, and cache expiry | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24930) | -| `nself_license_simulate_offline` | Simulate being offline for N days (testing only) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25005) | -| `nself_license_status` | Show all configured licenses and plugin coverage | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25083) | -| `nself_license_upgrade` | Open pricing page in browser | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25209) | -| `nself_license_validate` | Validate key against ping.nself.org | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25257) | -| `nself_logs` | View and filter service logs | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25307) | -| `nself_mcp_setup` | Print or write the MCP client config and AGENTS.md for this project | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25467) | -| `nself_remove` | Remove an installed plugin or bundle | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25754) | -| `nself_reset` | Stop containers, remove all data volumes, and clean generated files | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25918) | -| `nself_restart` | Smart restart with config change detection | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26042) | -| `nself_start` | Boot your nSelf stack | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26234) | -| `nself_status` | Show health status of all services | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26401) | -| `nself_status_health` | Health check management with continuous monitoring | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26530) | -| `nself_status_health_check` | Run all health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26649) | -| `nself_status_health_config` | Show health check settings | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26736) | -| `nself_status_health_endpoint` | Check an HTTP endpoint | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26800) | -| `nself_status_health_history` | Show last 20 health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26862) | -| `nself_status_health_service` | Check a single service | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26964) | -| `nself_status_urls` | Display all service URLs with route conflict detection | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27026) | -| `nself_stop` | Gracefully shut down all services or specific services | `destructive` | with `--volumes` | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27479) | -| `nself_update` | Update the nSelf CLI and admin UI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27630) | -| `nself_update_images` | Refresh Docker image digests from registries | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27913) | -| `nself_update_project_detect` | Detect v1 artifacts in the current project | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28004) | -| `nself_update_project_from_bash` | Migrate a v0.9.9 Bash-era project to the current nSelf CLI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28056) | -| `nself_update_project_from_v099` | Migrate v0.9.9 home-level state (license key, channel, ssh keys) to v1.x layout | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28119) | -| `nself_update_project_rollback` | Restore a v1 backup created by migrate run | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28182) | -| `nself_update_project_run` | Migrate v1 project to v2 | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28304) | -| `nself_update_stack` | Upgrade the stack with a verified backup and resumable steps | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28356) | -| `nself_update_upgrade` | Upgrade the nSelf CLI (detects install method) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28508) | -| `nself_update_verify_sbom` | Verify the SBOM signature for a CLI release | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28594) | +| `nself_deploy_promote_env` | Promote one environment to another (e.g. staging to prod) | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L20502) | +| `nself_deploy_promote_env_rollback` | Rollback to pre-promotion backup | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L20715) | +| `nself_deploy_rollback` | Roll back the last deployment for a target | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L20832) | +| `nself_deploy_security_setup` | Apply baseline server hardening | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L20955) | +| `nself_deploy_status` | Show deployment status for a target | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21105) | +| `nself_deploy_targets` | List deploy targets without connecting to them | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21206) | +| `nself_deploy_web` | Build web apps locally and deploy prebuilt output to Vercel | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21341) | +| `nself_doctor` | Run comprehensive system diagnostics | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21480) | +| `nself_doctor_heal` | Run targeted self-healing routines for nSelf components | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21674) | +| `nself_doctor_images` | Check availability of every locked image | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21837) | +| `nself_doctor_report` | Write a redacted support bundle (tar.gz) to share in an issue | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21915) | +| `nself_doctor_security_audit` | Run a read-only security audit | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21982) | +| `nself_doctor_security_status` | Show current security posture | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22057) | +| `nself_help_capabilities` | Search commands and plugins by what you want to do | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22395) | +| `nself_help_topics` | Moved to nself help topics | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22489) | +| `nself_init` | Initialize a new nSelf project | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22565) | +| `nself_init_template` | Browse and publish full-stack app templates | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22729) | +| `nself_init_template_info` | Show detail for a single template | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22792) | +| `nself_init_template_list` | List available app templates | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22904) | +| `nself_init_template_publish` | Submit a template to the nSelf registry | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23078) | +| `nself_init_template_update` | Apply incremental migrations for the installed template | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23200) | +| `nself_license` | Manage license keys for nSelf product bundles | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23263) | +| `nself_license_account` | Manage your nSelf account, sessions, licenses, team, and devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23326) | +| `nself_license_account_devices` | List and revoke registered devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23404) | +| `nself_license_account_licenses` | List and manage account licenses | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23484) | +| `nself_license_account_status` | Show current account summary | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23580) | +| `nself_license_account_team` | Manage team members | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23658) | +| `nself_license_add` | Add one or more license keys | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23728) | +| `nself_license_clear` | Remove all saved license keys | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23802) | +| `nself_license_health` | Validate format, ping server, and report cache integrity | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23913) | +| `nself_license_import` | Import a previously exported license cache file | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23980) | +| `nself_license_list` | Show all configured licenses (alias for status) | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24038) | +| `nself_license_migrate` | Migrate legacy license key to a nSelf account | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24164) | +| `nself_license_refresh` | Force-refresh license validation against ping.nself.org | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24340) | +| `nself_license_remove` | Remove a license key by value or product name | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24446) | +| `nself_license_restore` | Reactivate dormant plugins with a new license key | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24555) | +| `nself_license_revalidate` | Force a fresh validation against ping.nself.org and update the cache | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24667) | +| `nself_license_revoke` | Mark local license as revoked and wipe the stored key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24714) | +| `nself_license_set` | Replace all keys with a single key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24817) | +| `nself_license_show` | Display license tier, bundles, plugins, and cache expiry | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24934) | +| `nself_license_simulate_offline` | Simulate being offline for N days (testing only) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25009) | +| `nself_license_status` | Show all configured licenses and plugin coverage | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25087) | +| `nself_license_upgrade` | Open pricing page in browser | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25213) | +| `nself_license_validate` | Validate key against ping.nself.org | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25261) | +| `nself_logs` | View and filter service logs | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25311) | +| `nself_mcp_setup` | Print or write the MCP client config and AGENTS.md for this project | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25471) | +| `nself_remove` | Remove an installed plugin or bundle | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25758) | +| `nself_reset` | Stop containers, remove all data volumes, and clean generated files | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25922) | +| `nself_restart` | Smart restart with config change detection | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26046) | +| `nself_start` | Boot your nSelf stack | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26238) | +| `nself_status` | Show health status of all services | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26405) | +| `nself_status_health` | Health check management with continuous monitoring | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26534) | +| `nself_status_health_check` | Run all health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26653) | +| `nself_status_health_config` | Show health check settings | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26740) | +| `nself_status_health_endpoint` | Check an HTTP endpoint | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26804) | +| `nself_status_health_history` | Show last 20 health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26866) | +| `nself_status_health_service` | Check a single service | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26968) | +| `nself_status_urls` | Display all service URLs with route conflict detection | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27030) | +| `nself_stop` | Gracefully shut down all services or specific services | `destructive` | with `--volumes` | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27483) | +| `nself_update` | Update the nSelf CLI and admin UI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27634) | +| `nself_update_images` | Refresh Docker image digests from registries | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27917) | +| `nself_update_project_detect` | Detect v1 artifacts in the current project | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28008) | +| `nself_update_project_from_bash` | Migrate a v0.9.9 Bash-era project to the current nSelf CLI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28060) | +| `nself_update_project_from_v099` | Migrate v0.9.9 home-level state (license key, channel, ssh keys) to v1.x layout | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28123) | +| `nself_update_project_rollback` | Restore a v1 backup created by migrate run | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28186) | +| `nself_update_project_run` | Migrate v1 project to v2 | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28308) | +| `nself_update_stack` | Upgrade the stack with a verified backup and resumable steps | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28360) | +| `nself_update_upgrade` | Upgrade the nSelf CLI (detects install method) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28512) | +| `nself_update_verify_sbom` | Verify the SBOM signature for a CLI release | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28598) | ## Legacy aliases @@ -297,19 +297,19 @@ Deprecated names from v1.4, removed in v1.6.0. Each maps its parameters onto the | Name | Summary | Side effect | Confirm required | Input schema | |---|---|---|---|---| -| `nself_env_list` | Deprecated: use nself_config_env_list; removed in v1.6.0. List available environments. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22132) | -| `nself_get_permissions` | Deprecated: use nself_db_hasura_permissions; removed in v1.6.0. Read Hasura table permissions. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22183) | -| `nself_get_schema` | Deprecated: use nself_db_hasura_schema; removed in v1.6.0. Read the Hasura GraphQL schema. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22235) | -| `nself_plugin_install` | Deprecated: use nself_add; removed in v1.6.0. Install a plugin or bundle. | `remote` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25539) | -| `nself_plugin_list` | Deprecated: use nself_config_plugins_list; removed in v1.6.0. List available and installed plugins. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25662) | -| `nself_run_migration` | Deprecated: removed in v1.5. nself_run_migration ran raw SQL outside the migration ledger and is removed in v1.5; use nself db migrate create then nself db migrate up. | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26129) | -| `nself_service_list` | Deprecated: use nself_config_services_list; removed in v1.6.0. List all optional services with enabled/disabled status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26158) | -| `nself_urls` | Deprecated: use nself_status_urls; removed in v1.6.0. Display all service URLs with route conflict detection. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28657) | -| `sentry_incidents_ack` | Deprecated: use nself_sentry_incidents_ack; removed in v1.6.0. Acknowledge an ɳSentry incident. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29077) | -| `sentry_incidents_list` | Deprecated: use nself_sentry_incidents_list; removed in v1.6.0. List ɳSentry incidents. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29105) | -| `sentry_monitors_add` | Deprecated: use nself_sentry_monitors_add; removed in v1.6.0. Add an ɳSentry uptime monitor. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29130) | -| `sentry_monitors_list` | Deprecated: use nself_sentry_monitors_list; removed in v1.6.0. List ɳSentry uptime monitors. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29170) | -| `sentry_status` | Deprecated: use nself_sentry_status; removed in v1.6.0. Read the public ɳSentry status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29190) | +| `nself_env_list` | Deprecated: use nself_config_env_list; removed in v1.6.0. List available environments. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22136) | +| `nself_get_permissions` | Deprecated: use nself_db_hasura_permissions; removed in v1.6.0. Read Hasura table permissions. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22187) | +| `nself_get_schema` | Deprecated: use nself_db_hasura_schema; removed in v1.6.0. Read the Hasura GraphQL schema. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22239) | +| `nself_plugin_install` | Deprecated: use nself_add; removed in v1.6.0. Install a plugin or bundle. | `remote` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25543) | +| `nself_plugin_list` | Deprecated: use nself_config_plugins_list; removed in v1.6.0. List available and installed plugins. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25666) | +| `nself_run_migration` | Deprecated: removed in v1.5. nself_run_migration ran raw SQL outside the migration ledger and is removed in v1.5; use nself db migrate create then nself db migrate up. | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26133) | +| `nself_service_list` | Deprecated: use nself_config_services_list; removed in v1.6.0. List all optional services with enabled/disabled status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26162) | +| `nself_urls` | Deprecated: use nself_status_urls; removed in v1.6.0. Display all service URLs with route conflict detection. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28661) | +| `sentry_incidents_ack` | Deprecated: use nself_sentry_incidents_ack; removed in v1.6.0. Acknowledge an ɳSentry incident. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29081) | +| `sentry_incidents_list` | Deprecated: use nself_sentry_incidents_list; removed in v1.6.0. List ɳSentry incidents. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29109) | +| `sentry_monitors_add` | Deprecated: use nself_sentry_monitors_add; removed in v1.6.0. Add an ɳSentry uptime monitor. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29134) | +| `sentry_monitors_list` | Deprecated: use nself_sentry_monitors_list; removed in v1.6.0. List ɳSentry uptime monitors. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29174) | +| `sentry_status` | Deprecated: use nself_sentry_status; removed in v1.6.0. Read the public ɳSentry status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29194) | **Plugin commands.** Installed plugins add tools by the same `ToolName` rule. This page is generated from the builtin registry alone, so it never lists them; each plugin's wiki page lists its own in its machine-surface region, for example the [CI plugin page](https://github.com/nself-org/plugins/wiki/CI). **Plugin commands.** Installed plugins add tools by the same `ToolName` rule. This page is generated from the builtin registry alone, so it never lists them; each plugin's wiki page lists its own in its machine-surface region, for example the [CI plugin page](https://github.com/nself-org/plugins/wiki/CI). Plugin tools are advertised on the MCP surface like any other command, but a call that arrives while a secret bundle was restored is refused with `E421` because plugins do not yet support the secret-handoff protocol (D-0651); a machine-invoked plugin call that carries no secrets runs normally. diff --git a/.github/wiki/Safe-On-Live.md b/.github/wiki/Safe-On-Live.md index 329b91f0e..f232b4c2f 100644 --- a/.github/wiki/Safe-On-Live.md +++ b/.github/wiki/Safe-On-Live.md @@ -83,7 +83,7 @@ The plan is the `contract:cli.change-plan` v1 document (schema `schemas/commands The plan and the id cover rendered artifacts: project files, plugin compose fragments rewritten in place (shown as `@plugins//docker-compose.plugin.yml`), `.env.secrets` with the planned bytes, and permission-only changes (a `0644` `.env` is listed as a change with `diff_lines` 0). Certificate bytes and host trust state are runtime effects outside artifact byte confirmation. A plan with `certificates` or `trust-store` effects refuses `--plan-id` with E454 before any effects or artifact writes. Human plan and apply output disclose this exclusion, including on dev. Confirm at the prompt or use `--yes` without `--plan-id`, or provision certificates and host trust first. -A build that generates secrets (a first build, or a missing `*_INTERNAL_SECRET`) renders random values, which no earlier `--plan` run can reproduce. `--plan-id` is refused for it with E451 (exit 1, nothing written). Confirm at the prompt, or run `--yes` without `--plan-id`: the apply renders once and writes exactly that render. +A build that generates secrets (a first build, or a missing `*_INTERNAL_SECRET`) renders random values, which no earlier `--plan` run can reproduce. Its `plan_id` therefore binds the inputs, not the random bytes: it is the id of a second, read-only render of the same inputs with a fixed public seed, and the plan lists the `secrets-persist` effect. `--plan-id` binds such a build (E450 when an input changed); the apply draws fresh secrets and writes exactly its own render. The machine gate always asks for a confirmation of such a plan. Only when the random values would change the plan itself (not just the secret bytes) is `--plan-id` refused with E451 (exit 1, nothing written); then confirm at the prompt, or run `--yes` without `--plan-id`. ### One render, held to the end @@ -115,7 +115,7 @@ A prod-class env is `ENV` `prod` or `staging` (a running stack does not make an | Code | Exit | Meaning | |---|---|---| | E450 | 1 | `--plan-id` does not match the plan this project produces now, or the project changed while the confirmation was pending. Re-run `nself build --plan` and pass the new id. | -| E451 | 1 | The build generates secrets, so `--plan-id` cannot bind it. | +| E451 | 1 | The build generates secrets whose random values change the plan itself, so `--plan-id` cannot bind it. | | E403 | 4 | A prod-class or hand-edited change was not confirmed (v1.5). | ## Hand-edit policy diff --git a/.github/wiki/cmd-build.md b/.github/wiki/cmd-build.md index 57f17df56..10a7bee18 100644 --- a/.github/wiki/cmd-build.md +++ b/.github/wiki/cmd-build.md @@ -134,7 +134,7 @@ When it does write, the build logs exactly which entries it's about to add befor Non-env diffs also redact secret-named keys, pairs and flags and their repeated values on either side of the diff. A secret flag or pair that reaches the end of a YAML block scalar line keeps its continuation lines hidden until the next flag or the end of the scalar. Secret values containing the literal text `[REDACTED]` receive the same protection. -A first build generates secrets, so `--plan-id` is refused for it (E451); confirm at the prompt or use `--yes` without it. A change on a prod-class env needs `--yes` or an interactive yes. In v1.5 mode (`NSELF_V15=1`) a non-interactive build without `--yes` is refused with E403 (exit 4). In the default v1.4 mode it prints the plan summary and a one-line notice on stderr, then proceeds as before. A dev env never asks. `nself build --json` without `--plan` applies and prints the applied plan as the envelope data. +A first build generates secrets; its `plan_id` binds the inputs and the plan (the `secrets-persist` effect), not the random values, so `--plan-id` binds it and the apply writes fresh secrets. E451 is left for the rare render whose random values change the plan itself; then confirm at the prompt or use `--yes` without `--plan-id`. A change on a prod-class env needs `--yes` or an interactive yes. In v1.5 mode (`NSELF_V15=1`) a non-interactive build without `--yes` is refused with E403 (exit 4). In the default v1.4 mode it prints the plan summary and a one-line notice on stderr, then proceeds as before. A dev env never asks. `nself build --json` without `--plan` applies and prints the applied plan as the envelope data. Certificate bytes and host trust state are runtime effects outside artifact byte confirmation. A plan that generates or copies certificates, or installs the mkcert CA, refuses `--plan-id` with E454 before applying anything; its human plan and apply output disclose this exclusion, including on dev. Confirm at the prompt or use `--yes` without `--plan-id`, or provision certificates and host trust first. Plugin installation and removal similarly refuse `--plan-id` (E453): the build shows and, interactively on prod-class envs, confirms the render again after those effects. Downloaded plugin bytes are checked against the registry checksum rather than bound by the plan id; edits during that install window are reflected in the second render. diff --git a/.github/wiki/cmd-mcp.md b/.github/wiki/cmd-mcp.md index 9373cfb79..b16c04b10 100644 --- a/.github/wiki/cmd-mcp.md +++ b/.github/wiki/cmd-mcp.md @@ -26,8 +26,12 @@ By default the server runs over `stdio`, which is the correct mode for Claude Co to expose the server over a local port instead — set `NSELF_MCP_TOKEN` to require a bearer token on those two transports (stdio has no network exposure, so it never needs one). -`nself mcp` must be run from inside a directory that contains an nSelf project (i.e. one -initialised by `nself init`). The server exits immediately if no project is found. +Run `nself mcp` from inside an nSelf project. Started in a directory with no project, it +serves `nself_init` and the read-only tools only; once `init` has created the project it +lists every tool and sends `notifications/tools/list_changed`. It does the same after a +call that adds or removes a plugin, so the `ci` tools appear after `add ci` without a +restart. `--timeout` (default `5m0s`) bounds each tool call; a call that runs longer is +stopped and answers `E423`. ### Tools @@ -142,6 +146,7 @@ For blocked statement types, run `nself db migrate` directly from the CLI. | `--allowed-host` | | `stringSlice` | `[]` | | | | Host name this server is reachable under; required (repeatable) for a non-loopback --host | | `--host` | | `string` | `127.0.0.1` | | | | Bind address for the sse/http transports | | `--port` | `-p` | `int` | `3825` | | | | Port for the sse/http transports | +| `--timeout` | | `duration` | `5m0s` | | | | Longest one tool call may run before its command is stopped, such as 15m | | `--transport` | `-t` | `string` | `stdio` | | | | Transport: stdio, sse, or http | | `--help` | `-h` | `bool` | `false` | | | | Show help | diff --git a/.github/wiki/commands/mcp-serve.md b/.github/wiki/commands/mcp-serve.md index 92f7848b2..1e8efbf7d 100644 --- a/.github/wiki/commands/mcp-serve.md +++ b/.github/wiki/commands/mcp-serve.md @@ -5,7 +5,7 @@ Start a Model Context Protocol (MCP) server for the local ɳSelf instance. Enabl ## Synopsis ``` -nself mcp [--transport ] [--port ] +nself mcp [--transport ] [--port ] [--timeout ] ``` There is no separate `serve` or `status` subcommand — `nself mcp` itself starts the server. @@ -14,7 +14,7 @@ There is no separate `serve` or `status` subcommand — `nself mcp` itself start `nself mcp` starts the MCP server in **stdio mode** by default, the native transport that Claude Code expects. Pass `--transport sse` or `--transport http` to expose it over a local HTTP port instead; set `NSELF_MCP_TOKEN` to require a bearer token on those transports. -Run `nself mcp` from inside an nSelf project directory — it fails fast if one isn't found. +Run `nself mcp` from inside an nSelf project directory. Started where no project exists, it serves `nself_init` and the read-only tools until `init` creates one, then lists every tool and sends `notifications/tools/list_changed` (it does the same after a plugin is added or removed). ## Options @@ -22,6 +22,7 @@ Run `nself mcp` from inside an nSelf project directory — it fails fast if one |---|---|---| | `--transport`, `-t` | `stdio` | Transport: `stdio`, `sse`, or `http`. | | `--port`, `-p` | `3825` | Port for the `sse`/`http` transports. | +| `--timeout` | `5m0s` | Longest one tool call may run before its command is stopped (`E423`). | ## Environment variables diff --git a/.github/wiki/error-codes.md b/.github/wiki/error-codes.md index 131692e6f..d6e3cf050 100644 --- a/.github/wiki/error-codes.md +++ b/.github/wiki/error-codes.md @@ -261,7 +261,7 @@ Change-plan (E450) and operation-lock (E460) codes. The other codes in this bloc | Code | Exit | Summary | Why | Fix | |------|------|---------|-----|-----| | E450 | 1 | Plan id does not match | The plan you confirmed no longer matches what this command would change, because an input changed or the plan id is not one this project produces. | Re-run nself build --plan and pass the new plan_id. | -| E451 | 1 | Plan id cannot bind generated secrets | This build generates secrets, and random values cannot be reproduced from a plan id. | Set the secrets in .env.secrets first, or run nself build and confirm at the prompt (or with --yes, without --plan-id). | +| E451 | 1 | Plan id cannot bind generated secrets | This build generates secrets, and the random values change the plan itself, so they cannot be reproduced from a plan id. | Set the secrets in .env.secrets first, or run nself build and confirm at the prompt (or with --yes, without --plan-id). | | E452 | 1 | Planned files were not written | After the build, a file the confirmed plan listed is missing, has other content, or has another mode. | Re-run nself build --plan to see what differs, then run nself build again; check that the project is writable. | | E453 | 1 | Plan id cannot bind plugin changes | The plan installs or removes plugins, and what those change is only known after they run, so a plan id cannot describe the final render. | Run nself build --yes without --plan-id (the render after the plugin changes is printed and held), or install or remove the plugins first. | | E454 | 1 | Plan id cannot bind certificate or trust effects | Certificate bytes are generated at runtime and host trust state can change independently; artifact byte confirmation excludes these effects. | Run nself build and confirm at the prompt (or with --yes, without --plan-id), or provision certificates and host trust first. | diff --git a/.github/wiki/llms.txt b/.github/wiki/llms.txt index 824bf0a1c..cb4be32d6 100644 --- a/.github/wiki/llms.txt +++ b/.github/wiki/llms.txt @@ -443,6 +443,7 @@ Flags: - `--allowed-host` (default []) — Host name this server is reachable under; required (repeatable) for a non-loopback --host - `--host` (default 127.0.0.1) — Bind address for the sse/http transports - `--port` (default 3825) — Port for the sse/http transports +- `--timeout` (default 5m0s) — Longest one tool call may run before its command is stopped, such as 15m - `--transport` (default stdio) — Transport: stdio, sse, or http Full page: [[cmd-mcp]] diff --git a/cmd/commands/build_plan_test.go b/cmd/commands/build_plan_test.go index a2a007515..3d8524d97 100644 --- a/cmd/commands/build_plan_test.go +++ b/cmd/commands/build_plan_test.go @@ -597,10 +597,11 @@ func TestBuildProjectEnvCannotToggleGate(t *testing.T) { } } -// TestBuildFreshProjectPlanIDRefused: a first build generates secrets, so -// --plan-id is refused with E451; confirming without it applies and persists -// .env.secrets with the bytes that were planned. -func TestBuildFreshProjectPlanIDRefused(t *testing.T) { +// TestBuildFreshProjectPlanIDBinds (P7-SHIP-02 gap 2): a first build +// generates secrets; its plan declares secrets-persist and its plan_id binds +// the inputs, so --plan-id applies it and persists fresh .env.secrets. A plan +// id from before an input changed is E450 and writes nothing. +func TestBuildFreshProjectPlanIDBinds(t *testing.T) { p := newL03Project(t, "prod-ssl") env, _ := os.ReadFile(filepath.Join(p.project, ".env")) var keep []string @@ -612,22 +613,34 @@ func TestBuildFreshProjectPlanIDRefused(t *testing.T) { } l03Write(t, filepath.Join(p.project, ".env"), strings.Join(keep, "\n"), 0o600) data := p.planData(t, true) - var listed bool + var listed, declared bool for _, a := range data["artifacts"].([]any) { listed = listed || a.(map[string]any)["path"] == ".env.secrets" } - if !listed { - t.Fatal(".env.secrets is not in the plan") + for _, e := range data["effects"].([]any) { + declared = declared || e.(map[string]any)["kind"] == "secrets-persist" + } + if !listed || !declared { + t.Fatalf(".env.secrets or secrets-persist is not in the plan: %v", data) + } + if again := p.planData(t, true); again["plan_id"] != data["plan_id"] { + t.Fatalf("two plans of the same first build have different ids: %v vs %v", data["plan_id"], again["plan_id"]) } - r := p.run(t, true, "build", "--yes", "--plan-id", data["plan_id"].(string), "--json") - if code, exit := errorEnvelope(t, r.stdout); r.code != 1 || code != "E451" || exit != 1 { - t.Fatalf("exit %d %s: want E451\n%s", r.code, code, r.stderr) + stale := data["plan_id"].(string) + l03Write(t, filepath.Join(p.project, ".env"), strings.Replace(strings.Join(keep, "\n"), "POSTGRES_PASSWORD=Kq7vZp3Lw9XnR2tYb8MdQa4s", "POSTGRES_PASSWORD=Kq7vZp3Lw9XnR2tYb8MdQa4t", 1), 0o600) + r := p.run(t, true, "build", "--yes", "--plan-id", stale, "--json") + if code, _ := errorEnvelope(t, r.stdout); r.code != 1 || code != "E450" { + t.Fatalf("exit %d %s: want E450\n%s", r.code, code, r.stderr) } if _, err := os.Stat(filepath.Join(p.project, ".env.secrets")); err == nil { - t.Fatal("E451 wrote") + t.Fatal("E450 wrote") } - if r := p.run(t, true, "build", "--yes"); r.code != 0 { - t.Fatalf("build --yes exited %d\n%s", r.code, r.stderr) + fresh := p.planData(t, true)["plan_id"].(string) + if r := p.run(t, true, "build", "--yes", "--plan-id", fresh, "--json"); r.code != 0 { + t.Fatalf("build --plan-id exited %d\n%s", r.code, r.stderr) + } + if _, err := os.Stat(filepath.Join(p.project, ".env.secrets")); err != nil { + t.Fatal("the bound first build did not persist .env.secrets") } } diff --git a/cmd/commands/canon_engine_test.go b/cmd/commands/canon_engine_test.go index b8577b1d5..0ec6f30d0 100644 --- a/cmd/commands/canon_engine_test.go +++ b/cmd/commands/canon_engine_test.go @@ -840,7 +840,7 @@ func TestCanonEnginePrepare(t *testing.T) { // takesArgsParents are the real parents with subcommands, a body and no Args // validator that read arguments or do real work: they are NOT help-only. Every // other such parent must be in helpOnlyParents (TestHelpOnlyParentsComplete). -var takesArgsParents = []string{"account", "admin", "bundle", "ci", "ci eval", "db drift", "doctor", "generate", "health", "mcp", "migrate", "plugin", "trust", "update"} +var takesArgsParents = []string{"account", "admin", "bundle", "ci", "ci eval", "db drift", "doctor", "generate", "health", "migrate", "plugin", "trust", "update"} // unclassifiedParents returns every parent under root that has subcommands, a // body and no Args validator but is in neither list, plus every list entry that diff --git a/cmd/commands/deploy.go b/cmd/commands/deploy.go index 0d09c9e8b..2c95d5f0f 100644 --- a/cmd/commands/deploy.go +++ b/cmd/commands/deploy.go @@ -123,7 +123,7 @@ The output format and exit code semantics are preserved for backward compatibili // deployPromoteCmd promotes a canary deploy to 100% green traffic. var deployPromoteCmd = &cobra.Command{ - Use: "promote [source target]", + Use: "promote [source] [target]", Short: "Promote a canary or an environment", Long: `Flip Nginx upstream weights from the current canary split to 100% green. Use after manual inspection of a canary deploy. diff --git a/cmd/commands/error_harness_test.go b/cmd/commands/error_harness_test.go index 01acc3cac..2c363ce12 100644 --- a/cmd/commands/error_harness_test.go +++ b/cmd/commands/error_harness_test.go @@ -306,7 +306,8 @@ var errorHarnessCases = []errorHarnessCase{ // maintenance root returns cmd.Help() (nil) — soft case. // ── mcp ──────────────────────────────────────────────────────────────── - {"mcp", []string{"mcp"}, "(a) no project dir"}, + // mcp starts without a project (P7-SHIP-02 gap 1), so its (a) case is a bad --timeout. + {"mcp", []string{"mcp", "--timeout", "0s"}, "(a) no project dir"}, {"mcp", []string{"mcp", "--no-such-flag-xyz"}, "(b) invalid flag"}, {"mcp", []string{"mcp", "unknownsub_xyz"}, "(c) unknown sub"}, diff --git a/cmd/commands/mcp.go b/cmd/commands/mcp.go index f130d59f0..88d05b03c 100644 --- a/cmd/commands/mcp.go +++ b/cmd/commands/mcp.go @@ -8,7 +8,7 @@ package commands // plugin list/install) plus the pre-existing Hasura data tools, as MCP // tools with output schemas and annotations, so an agent can plan around // them instead of guessing from prose. -// Inputs: --transport (stdio|sse|http), --port, --host, --allowed-host, +// Inputs: --transport (stdio|sse|http), --port, --host, --allowed-host, --timeout, // NSELF_MCP_TOKEN or ~/.nself/mcp-token (bearer auth on sse/http — see // mcp_auth.go for the bind and token policy). // Outputs: an MCP server over the chosen transport. @@ -25,12 +25,14 @@ package commands import ( "fmt" "os" + "time" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/nself-org/cli/internal/compat" - "github.com/nself-org/cli/internal/config" + "github.com/nself-org/cli/internal/errs" "github.com/nself-org/cli/internal/health" + "github.com/nself-org/cli/internal/invoke" "github.com/spf13/cobra" ) @@ -54,9 +56,10 @@ when no token is set.) --allowed-host (repeatable) plus a configured token on every start, and prints a warning that the token crosses the network over cleartext HTTP. -Run 'nself mcp' from inside an nSelf project directory. See the generated -tool list at .github/wiki/MCP-Tools.md and the resources and prompts at -.github/wiki/cmd-mcp.md. +Run 'nself mcp' from inside an nSelf project directory (elsewhere it serves +init and the read-only tools until one exists). --timeout bounds each call. +See the generated tool list at .github/wiki/MCP-Tools.md and the resources and +prompts at .github/wiki/cmd-mcp.md. Claude Code config (.claude/settings.json): "mcpServers": { @@ -65,6 +68,7 @@ Claude Code config (.claude/settings.json): "args": ["mcp"] } }`, + Args: cobra.NoArgs, RunE: runMCPServe, } @@ -73,6 +77,7 @@ func init() { mcpCmd.Flags().IntP("port", "p", mcpServerPort, "Port for the sse/http transports") mcpCmd.Flags().String("host", "127.0.0.1", "Bind address for the sse/http transports") mcpCmd.Flags().StringSlice("allowed-host", nil, "Host name this server is reachable under; required (repeatable) for a non-loopback --host") + mcpCmd.Flags().Duration("timeout", invoke.DefaultTimeout, "Longest one tool call may run before its command is stopped, such as 15m") RootCmd.AddCommand(mcpCmd) } @@ -82,15 +87,12 @@ func runMCPServe(cmd *cobra.Command, args []string) error { host, _ := cmd.Flags().GetString("host") allowed, _ := cmd.Flags().GetStringSlice("allowed-host") - // Require an nSelf project directory so tool handlers that assume one - // (nearly all of them) fail fast with a clear message instead of an - // obscure error three calls deep. - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("getting working directory: %w", err) + timeout, _ := cmd.Flags().GetDuration("timeout") + if timeout < time.Second { + return errs.Newf("E401", "--timeout %s is too short", timeout).WithFix("Pass a duration of at least 1s, such as 15m.") } - if _, findErr := config.FindNSelfRoot(cwd); findErr != nil { - return fmt.Errorf("no nself project found in %s — run 'nself init' first", cwd) + if _, err := resolveProjectDir(); err != nil { // an MCP-only agent must reach init (mcpgen/surface.go) + fmt.Fprintln(os.Stderr, "nself mcp: no nself project here; serving init and the read-only tools until one exists") } // Bind and token policy first: a refused start must not build anything or @@ -101,7 +103,7 @@ func runMCPServe(cmd *cobra.Command, args []string) error { } // compat.V15(P7-SURF-03): 26 hand tools primary -> generated tools, merged same-name tools, deprecated aliases - s, err := newMCPServer(transport, compat.V15()) + s, err := newMCPServer(transport, compat.V15(), timeout) if err != nil { return err } diff --git a/cmd/commands/mcp_adversarial_test.go b/cmd/commands/mcp_adversarial_test.go index 0fdc6b165..90c778c10 100644 --- a/cmd/commands/mcp_adversarial_test.go +++ b/cmd/commands/mcp_adversarial_test.go @@ -98,7 +98,7 @@ func TestMCPAdversarial(t *testing.T) { // advSession is a stdio session of a fresh v1.5 server in the fixture project. func advSession(t *testing.T) *stdioSession { t.Helper() - s, err := newMCPServer("stdio", true) + s, err := newMCPServer("stdio", true, 0) if err != nil { t.Fatal(err) } diff --git a/cmd/commands/mcp_generated.go b/cmd/commands/mcp_generated.go index 4bfc715f9..fcdf73bf5 100644 --- a/cmd/commands/mcp_generated.go +++ b/cmd/commands/mcp_generated.go @@ -8,17 +8,20 @@ package commands // registered in v1.4 mode only (P7-SHIP-09 deletes them at the flip). // Inputs: the transport name (stdio|sse|http), the compat mode. // Outputs: tools, resources and resource templates on the MCP server. -// Constraints: machineRegistry is built once, at server start, before the -// transport serves; the invoker runs every call as a child of this binary -// through the confirmation gate, with one Guard per server process. +// Constraints: machineRegistry is built at server start, before the transport +// serves, and again when mcpgen.Surface sees the project or the plugin set +// change; the invoker runs every call as a child of this binary through the +// confirmation gate, with one Guard per server process. import ( "context" "fmt" "os" "path/filepath" + "time" "github.com/mark3labs/mcp-go/server" + "github.com/nself-org/cli/internal/cmdregistry" "github.com/nself-org/cli/internal/errs" "github.com/nself-org/cli/internal/invoke" "github.com/nself-org/cli/internal/mcpgen" @@ -28,14 +31,18 @@ import ( // newMCPServer builds the MCP server for the compat mode: v1.4 registers the // 26 hand tools and the four hand resources first and the generated ones on -// top; v1.5 registers the generated surface only. Prompts are authored. -func newMCPServer(transport string, v15 bool) (*server.MCPServer, error) { +// top; v1.5 the generated surface only (mcpgen.Surface keeps the list live). +// timeout bounds each call; 0 is invoke.DefaultTimeout. +func newMCPServer(transport string, v15 bool, timeout time.Duration) (*server.MCPServer, error) { s := server.NewMCPServer(mcpgen.ServerName, mcpgen.ServerVersion, mcpgen.ServerOptions()...) + o := mcpgen.SurfaceOptions{Server: s, V15: v15, Schema: schemas.Lookup, PluginDir: resolvePluginDir, + Project: func() string { d, _ := resolveProjectDir(); return d }, + Warn: func(msg string) { fmt.Fprintln(os.Stderr, "nself mcp: "+msg) }} if !v15 { - registerMCPTools(s) + o.Hand = registerMCPTools registerMCPResources(s) } - if err := registerGeneratedMCP(s, transport, v15); err != nil { + if err := registerGeneratedMCP(o, transport, timeout); err != nil { if v15 { return nil, err } @@ -51,42 +58,37 @@ func newMCPServer(transport string, v15 bool) (*server.MCPServer, error) { // mcpAuditSurface is the audit label of an MCP transport. func mcpAuditSurface(transport string) string { return "mcp-" + transport } -// registerGeneratedMCP attaches the generated tools and resources to s. -func registerGeneratedMCP(s *server.MCPServer, transport string, v15 bool) error { - reg, err := machineRegistry() - if err != nil { - return err - } - dir, err := resolveProjectDir() - if err != nil { - return err - } - auditPath, err := invoke.DefaultAuditPath() - if err != nil { - return fmt.Errorf("machine audit log path: %w", err) - } +// registerGeneratedMCP lists the tools (the hand tools stay listed on an +// error) and attaches the generated resources. +func registerGeneratedMCP(o mcpgen.SurfaceOptions, transport string, timeout time.Duration) error { + auditPath, auditErr := invoke.DefaultAuditPath() guard := invoke.NewGuard(auditPath) - inv := func(ctx context.Context, path string, req invoke.Request) (invoke.Result, error) { - return invoke.Invoke(ctx, reg, path, req, invoke.Options{ - Transport: invoke.TransportMCP, Surface: mcpAuditSurface(transport), Dir: dir, Guard: guard, - }) + o.Registry = func(rebuild bool) (*cmdregistry.Registry, error) { + if auditErr != nil { + return nil, fmt.Errorf("machine audit log path: %w", auditErr) + } else if rebuild { + resetMachineRegistry() + } + return machineRegistry() + } + o.Run = func(ctx context.Context, reg *cmdregistry.Registry, path string, req invoke.Request, dir string) (invoke.Result, error) { + return invoke.Invoke(ctx, reg, path, req, invoke.Options{Transport: invoke.TransportMCP, Surface: mcpAuditSurface(transport), Dir: dir, Guard: guard, Timeout: timeout}) } - warn := func(msg string) { fmt.Fprintln(os.Stderr, "nself mcp: "+msg) } - tools, err := mcpgen.Tools(mcpgen.Options{Registry: reg, Invoke: inv, Schema: schemas.Lookup, V15: v15, Warn: warn}) + surf := mcpgen.NewSurface(o) + reg, err := surf.Start() if err != nil { return err } - s.AddTools(tools...) resources, templates := mcpgen.Resources(mcpgen.ResourceOptions{ - Registry: reg, Invoke: inv, Schema: schemas.Lookup, Catalog: cachedCatalogJSON, V15: v15, + Registry: reg, Invoke: surf.Invoker(reg), Schema: schemas.Lookup, Catalog: cachedCatalogJSON, V15: o.V15, // A legacy URI whose command has no envelope yet keeps its hand handler. Fallback: map[string]server.ResourceHandlerFunc{ "nself://config": mcpConfigResourceHandler(), "nself://services": mcpServicesResourceHandler(), "nself://env": mcpEnvResourceHandler(), "nself://urls": mcpURLsResourceHandler(), }, }) - s.AddResources(resources...) - s.AddResourceTemplates(templates...) + o.Server.AddResources(resources...) + o.Server.AddResourceTemplates(templates...) return nil } diff --git a/cmd/commands/mcp_generated_test.go b/cmd/commands/mcp_generated_test.go index add576cdc..a127e3d3e 100644 --- a/cmd/commands/mcp_generated_test.go +++ b/cmd/commands/mcp_generated_test.go @@ -39,6 +39,8 @@ type stdioSession struct { in io.WriteCloser out *bufio.Reader id int + // notes are the methods of the notifications read so far. + notes []string } func startStdio(t *testing.T, s *server.MCPServer) *stdioSession { @@ -78,11 +80,13 @@ func (s *stdioSession) call(method, params string) json.RawMessage { } var msg struct { ID json.RawMessage `json:"id"` + Method string `json:"method"` Result json.RawMessage `json:"result"` Error json.RawMessage `json:"error"` } if json.Unmarshal(raw, &msg) != nil || len(msg.ID) == 0 { - continue // a notification + s.notes = append(s.notes, msg.Method) // a notification + continue } if len(msg.Error) > 0 { return append(json.RawMessage(`{"jsonrpc_error":`), append(msg.Error, '}')...) @@ -195,7 +199,7 @@ func TestMCPGeneratedStdio(t *testing.T) { return } stdioFixture(t, true) - s, err := newMCPServer("stdio", true) + s, err := newMCPServer("stdio", true, 0) if err != nil { t.Fatal(err) } @@ -331,7 +335,7 @@ func TestMCPResourcesV15(t *testing.T) { if err := os.WriteFile(filepath.Join(home, "cache", "catalog.json"), cat, 0o600); err != nil { t.Fatal(err) } - s, err := newMCPServer("stdio", true) + s, err := newMCPServer("stdio", true, 0) if err != nil { t.Fatal(err) } @@ -428,7 +432,7 @@ func TestMCPAliasesV15(t *testing.T) { } // Without the plugin the five sentry names answer E404 naming nself add sentry-cli. - s, err := newMCPServer("stdio", true) + s, err := newMCPServer("stdio", true, 0) if err != nil { t.Fatal(err) } @@ -459,7 +463,7 @@ func TestMCPAliasesV15(t *testing.T) { // With a fixture sentry-cli plugin installed the aliases run its commands. installSentryFixture(t, filepath.Join(home, "plugins")) resetMachineRegistry() - s, err = newMCPServer("stdio", true) + s, err = newMCPServer("stdio", true, 0) if err != nil { t.Fatal(err) } @@ -538,14 +542,14 @@ func TestMCPGeneratedFailureByMode(t *testing.T) { machineCache.mu.Lock() machineCache.done, machineCache.reg, machineCache.err = true, nil, fmt.Errorf("fixture: broken plugin manifest") machineCache.mu.Unlock() - s, err := newMCPServer("stdio", false) + s, err := newMCPServer("stdio", false, 0) if err != nil { t.Fatalf("v1.4 mode must keep serving: %v", err) } if n := len(s.ListTools()); n != 26 { t.Errorf("v1.4 fallback serves %d tools, want the 26 hand tools", n) } - if _, err := newMCPServer("stdio", true); err == nil { + if _, err := newMCPServer("stdio", true, 0); err == nil { t.Error("v1.5 mode started without a registry") } t.Log("CHECKED-TestMCPGeneratedFailureByMode") @@ -556,7 +560,7 @@ func TestMCPV14KeepsHandTools(t *testing.T) { return } stdioFixture(t, false) - s, err := newMCPServer("stdio", false) + s, err := newMCPServer("stdio", false, 0) if err != nil { t.Fatal(err) } @@ -637,7 +641,7 @@ func TestMCPPromptSpellings(t *testing.T) { if len(texts) != 3 { t.Fatalf("%d prompts", len(texts)) } - s, err := newMCPServer("stdio", true) + s, err := newMCPServer("stdio", true, 0) if err != nil { t.Fatal(err) } diff --git a/cmd/commands/mcp_integration_test.go b/cmd/commands/mcp_integration_test.go index b65ff1b09..848019889 100644 --- a/cmd/commands/mcp_integration_test.go +++ b/cmd/commands/mcp_integration_test.go @@ -10,11 +10,10 @@ package commands // and test this repo. // Outputs: a temp project directory, built via `nself init`, then advanced // through nself_build / nself_start / nself_status tool calls. -// Constraints: `nself init` itself is NOT an MCP tool (the MCP server -// requires a project to already exist before it will even start — see -// runMCPServe's FindNSelfRoot guard) so it runs as a direct subprocess of -// the built binary, matching how any agent would have to bootstrap a new -// project before pointing an MCP client at it. Everything after that +// Constraints: these are the v1.4 hand handlers, which have no init tool, so +// `nself init` runs as a direct subprocess of the built binary (the v1.5 +// generated surface serves nself_init, even before a project exists: see +// internal/mcpgen/surface.go). Everything after that // (build, and — gated behind INTEGRATION=1 — start/status/stop) goes // through the exact tool handler functions the server registers, not // through a re-implementation. The Docker-dependent half is gated because diff --git a/cmd/commands/mcp_surface_test.go b/cmd/commands/mcp_surface_test.go new file mode 100644 index 000000000..8845096da --- /dev/null +++ b/cmd/commands/mcp_surface_test.go @@ -0,0 +1,161 @@ +package commands + +// Purpose: prove the P7-SHIP-02 product gaps of `nself mcp` are closed: the +// server starts without a project and lists init plus the read-only tools, +// lists everything after a project exists, lists a plugin's tools after the +// plugin is installed (with tools/list_changed each time), bounds calls by +// --timeout, and takes `deploy promote `. +// Constraints: each case runs in a fresh copy of this test binary (the +// machine registry mutates the shared command tree); the child is the stub +// from internal/invoke/testdata/stubcli; no network, no sleeps. + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/nself-org/cli/internal/errs" + "github.com/nself-org/cli/internal/invoke" +) + +// waitListChanged reads until the server has sent tools/list_changed, with +// ping round trips (a notification and a response may interleave). +func waitListChanged(t *testing.T, sess *stdioSession) { + t.Helper() + for i := 0; i < 20; i++ { + if slices.Contains(sess.notes, "notifications/tools/list_changed") { + sess.notes = nil + return + } + sess.call("ping", `{}`) + } + t.Fatalf("no notifications/tools/list_changed; notifications seen: %v", sess.notes) +} + +func TestMCPSurfaceWithoutProjectThenRefresh(t *testing.T) { + if !inFreshProcess(t, "TestMCPSurfaceWithoutProjectThenRefresh") { + return + } + home := stdioFixture(t, true) + proj := filepath.Join(home, "proj") + if err := os.Remove(filepath.Join(proj, ".env")); err != nil { + t.Fatal(err) + } + s, err := newMCPServer("stdio", true, 0) + if err != nil { + t.Fatalf("the server must start without a project: %v", err) + } + sess := startStdio(t, s) + listed := toolsOf(t, sess) + if _, ok := listed["nself_init"]; !ok { + t.Fatal("nself_init is not listed without a project") + } + if _, ok := listed["nself_status"]; !ok { + t.Error("the read-only nself_status is not listed without a project") + } + for _, name := range []string{"nself_build", "nself_start", "nself_reset", "nself_deploy_promote"} { + if _, ok := listed[name]; ok { + t.Errorf("%s is listed without a project", name) + } + } + for name, raw := range listed { + if name != "nself_init" && !strings.Contains(raw, `"readOnlyHint":true`) { + t.Errorf("%s is listed without a project but is not read-only", name) + } + } + + // init creates the project (the stub only answers; the test writes what init writes). + if err := os.WriteFile(filepath.Join(proj, ".env"), []byte("PROJECT_NAME=fixture\n"), 0o600); err != nil { + t.Fatal(err) + } + if res := callTool(t, sess, "nself_init", `{}`); res.IsError { + t.Fatalf("nself_init: %+v", res) + } + waitListChanged(t, sess) + listed = toolsOf(t, sess) + for _, name := range []string{"nself_build", "nself_start", "nself_deploy_promote"} { + if _, ok := listed[name]; !ok { + t.Errorf("%s is not listed after the project exists", name) + } + } + + // A plugin installed during the session: its tools appear after the next call. + if _, ok := listed["nself_sentry_status"]; ok { + t.Fatal("the fixture plugin is listed before it is installed") + } + installSentryFixture(t, filepath.Join(home, "plugins")) + if res := callTool(t, sess, "nself_status", `{}`); res.IsError { + t.Fatalf("nself_status: %+v", res) + } + waitListChanged(t, sess) + if _, ok := toolsOf(t, sess)["nself_sentry_status"]; !ok { + t.Fatal("the plugin installed in this session is not listed") + } + // Nothing changed: no rebuild, no notification. + callTool(t, sess, "nself_status", `{}`) + sess.call("ping", `{}`) + if slices.Contains(sess.notes, "notifications/tools/list_changed") { + t.Error("tools/list_changed was sent although nothing changed") + } + t.Log("CHECKED-TestMCPSurfaceWithoutProjectThenRefresh") +} + +func TestMCPTimeoutBoundsCalls(t *testing.T) { + if !inFreshProcess(t, "TestMCPTimeoutBoundsCalls") { + return + } + stdioFixture(t, true) + t.Setenv("STUBCLI_MODE", "sleep") + s, err := newMCPServer("stdio", true, time.Second) + if err != nil { + t.Fatal(err) + } + res := callTool(t, startStdio(t, s), "nself_status", `{}`) + if !res.IsError || !strings.Contains(res.Content[0].Text, "E423") || !strings.Contains(res.Content[0].Text, "within 1s") { + t.Fatalf("a call longer than --timeout: %+v", res) + } + t.Log("CHECKED-TestMCPTimeoutBoundsCalls") +} + +func TestMCPTimeoutFlag(t *testing.T) { + f := mcpCmd.Flags().Lookup("timeout") + if f == nil || f.DefValue != invoke.DefaultTimeout.String() { + t.Fatalf("--timeout: %+v", f) + } + t.Cleanup(func() { _ = f.Value.Set(f.DefValue); f.Changed = false }) + if err := mcpCmd.Flags().Set("timeout", "0s"); err != nil { + t.Fatal(err) + } + if d := errs.Describe(runMCPServe(mcpCmd, nil)); d == nil || d.Code != "E401" { + t.Fatalf("--timeout 0s: want E401, got %v", d) + } +} + +func TestMCPDeployPromoteTakesSourceAndTarget(t *testing.T) { + if !inFreshProcess(t, "TestMCPDeployPromoteTakesSourceAndTarget") { + return + } + stdioFixture(t, true) + reg, err := machineRegistry() + if err != nil { + t.Fatal(err) + } + cmd, ok := reg.Lookup("deploy promote") + if !ok { + t.Fatal("deploy promote is not in the machine registry") + } + argv, err := invoke.BuildArgvFor(cmd, invoke.Request{Args: []string{"qa", "prod"}, Flags: map[string]any{"dry-run": true}}, invoke.TransportMCP) + if err != nil { + t.Fatalf("deploy promote qa prod: %v", err) + } + if got := strings.Join(argv[len(argv)-2:], " "); got != "qa prod" { + t.Errorf("argv %q does not end with the two environments", argv) + } + if _, err := invoke.BuildArgvFor(cmd, invoke.Request{Args: []string{"qa", "prod", "extra"}}, invoke.TransportMCP); err == nil { + t.Error("three args were accepted") + } + t.Log("CHECKED-TestMCPDeployPromoteTakesSourceAndTarget") +} diff --git a/cmd/commands/mcp_test.go b/cmd/commands/mcp_test.go index 179433d1b..1e25eeec9 100644 --- a/cmd/commands/mcp_test.go +++ b/cmd/commands/mcp_test.go @@ -35,7 +35,7 @@ func TestMCPCmdRegistered(t *testing.T) { // TestMCPCmdFlags verifies the expected flags are declared (no --no-mdns: // the mDNS feature was removed, see mcp.go's header comment). func TestMCPCmdFlags(t *testing.T) { - flags := []string{"transport", "port"} + flags := []string{"transport", "port", "timeout"} for _, name := range flags { if mcpCmd.Flags().Lookup(name) == nil { t.Errorf("flag --%s not declared on mcp command", name) diff --git a/cmd/commands/surface_identity_test.go b/cmd/commands/surface_identity_test.go index 0a4fea984..b8046cbde 100644 --- a/cmd/commands/surface_identity_test.go +++ b/cmd/commands/surface_identity_test.go @@ -582,7 +582,7 @@ func TestSurfaceIdentity(t *testing.T) { e := &surfaceEnv{bin: bin, proj: proj, token: "surface-identity-token", stubs: stubs} e.resetProject(t) // the MCP server refuses to start outside a project e.base = startSurfaceHTTP(t, proj, e.token) - s, err := newMCPServer("stdio", true) + s, err := newMCPServer("stdio", true, 0) if err != nil { t.Fatal(err) } @@ -614,7 +614,7 @@ var ( // handToolAllowed are the only files that may register an MCP tool: the // generator, its wiring, and the legacy hand registrations P7-SHIP-09 deletes. var handToolAllowed = map[string]bool{ - "internal/mcpgen/tools.go": true, "cmd/commands/mcp_generated.go": true, + "internal/mcpgen/tools.go": true, "internal/mcpgen/surface.go": true, "cmd/commands/mcp_generated.go": true, "cmd/commands/mcp.go": true, "cmd/commands/mcp_sentry.go": true, } diff --git a/internal/errs/codes_reconcile.go b/internal/errs/codes_reconcile.go index 7872940c5..60aea3485 100644 --- a/internal/errs/codes_reconcile.go +++ b/internal/errs/codes_reconcile.go @@ -18,7 +18,7 @@ func init() { Code: "E451", Category: "reconcile", Summary: "Plan id cannot bind generated secrets", - DefaultWhy: "This build generates secrets, and random values cannot be reproduced from a plan id.", + DefaultWhy: "This build generates secrets, and the random values change the plan itself, so they cannot be reproduced from a plan id.", DefaultFix: "Set the secrets in .env.secrets first, or run nself build and confirm at the prompt (or with --yes, without --plan-id).", DocsPath: "reference/error-codes#e451", Exit: 1, diff --git a/internal/invoke/gate.go b/internal/invoke/gate.go index babdfed5e..8af82beac 100644 --- a/internal/invoke/gate.go +++ b/internal/invoke/gate.go @@ -12,14 +12,14 @@ package invoke // runs, with no consent flags. A plan form without confirm is executed once // (--); the child's data.plan_id and data.requires_confirmation // decide between a kind plan confirmation (confirm = plan_id) and one run with -// --=. remote and destructive without a plan form get a +// --=; a plan that declares generated secrets is always a +// confirmation (plan_facts.go). remote and destructive without a plan form get a // kind request confirmation backed by a Guard nonce. A nil Guard refuses // remote and destructive commands (E421, fail closed). import ( "bytes" "context" - "encoding/json" "io" "sort" @@ -232,7 +232,7 @@ func planGate(ctx context.Context, cmd *cmdregistry.Command, req Request, o Opti d.Kind, d.Result = DecisionRefuse, &Result{Stdout: ran.Stdout, ExitCode: ran.ExitCode} case problem != "": d = refused(d, errs.Newf("E422", "%s produced no usable plan", quoteName(barePath(cmd.Path))).WithWhy(problem)) - case facts.requires || gated: + case facts.requires || gated || facts.secrets: // secrets: plan_facts.go d.Kind, d.ConfirmKind = DecisionConfirm, ConfirmKindPlan d.Confirmation = &Confirmation{ConfirmationRequired: true, Kind: ConfirmKindPlan, Confirm: facts.id, SideEffect: d.SideEffect, Reason: reasonFor(d.SideEffect, ConfirmKindPlan), Plan: facts.data} @@ -242,48 +242,6 @@ func planGate(ctx context.Context, cmd *cmdregistry.Command, req Request, o Opti return d, nil } -// planFacts is what the gate reads from the child's plan document. -type planFacts struct { - id string - requires bool - data json.RawMessage -} - -// parsePlan reads the plan exec's envelope. childErr is true when it is the -// child's own error envelope; problem is a short cause when plan_id or -// requires_confirmation is missing or invalid. -func parsePlan(stdout []byte) (f planFacts, childErr bool, problem string) { - var doc struct { - Data json.RawMessage `json:"data"` - Error json.RawMessage `json:"error"` - } - if err := json.Unmarshal(bytes.TrimSpace(stdout), &doc); err != nil { - return f, false, "the plan output is not a JSON envelope" - } - if len(doc.Error) > 0 && string(doc.Error) != "null" { - return f, true, "" - } - if d := bytes.TrimSpace(doc.Data); len(d) == 0 || d[0] != '{' { - return f, false, "the plan output has no data object" - } - var data struct { - PlanID *string `json:"plan_id"` - Requires *bool `json:"requires_confirmation"` - } - if err := json.Unmarshal(doc.Data, &data); err != nil { - return f, false, "the plan output has no data object" - } - switch { - case data.PlanID == nil: - return f, false, "the plan output has no data.plan_id" - case !confirmPattern.MatchString(*data.PlanID): - return f, false, "data.plan_id is not 64 lowercase hex characters" - case data.Requires == nil: - return f, false, "the plan output has no data.requires_confirmation boolean" - } - return planFacts{id: *data.PlanID, requires: *data.Requires, data: doc.Data}, false, "" -} - // confirmationResult writes c as a success envelope for command into res. func confirmationResult(res Result, command string, c *Confirmation) Result { var buf bytes.Buffer diff --git a/internal/invoke/plan_facts.go b/internal/invoke/plan_facts.go new file mode 100644 index 000000000..99b21becc --- /dev/null +++ b/internal/invoke/plan_facts.go @@ -0,0 +1,70 @@ +package invoke + +// Purpose: read the facts the confirmation gate needs from a command's plan +// document (contract:cli.change-plan v1 inside the v1 envelope). +// Inputs: the plan exec's stdout. Outputs: planFacts (plan_id, +// requires_confirmation, whether the plan generates secrets, the raw data), or +// whether the child failed, or a short problem. +// Constraints: the plan is data from the child; a missing or malformed member +// is a problem, never a default. A plan that declares the secrets-persist +// effect generates random values its plan id binds only by their inputs +// (reconcile binding.go), so the gate always shows it to the caller (P7-SHIP-02). + +import ( + "bytes" + "encoding/json" +) + +// effectSecretsPersist is the change-plan effect kind of a render that +// generates and persists secrets (reconcile.EffectSecretsPersist). +const effectSecretsPersist = "secrets-persist" + +// planFacts is what the gate reads from the child's plan document. +type planFacts struct { + id string + requires bool + secrets bool // the plan declares the secrets-persist step + data json.RawMessage +} + +// parsePlan reads the plan exec's envelope. childErr is true when it is the +// child's own error envelope; problem is a short cause when plan_id or +// requires_confirmation is missing or invalid. +func parsePlan(stdout []byte) (f planFacts, childErr bool, problem string) { + var doc struct { + Data json.RawMessage `json:"data"` + Error json.RawMessage `json:"error"` + } + if err := json.Unmarshal(bytes.TrimSpace(stdout), &doc); err != nil { + return f, false, "the plan output is not a JSON envelope" + } + if len(doc.Error) > 0 && string(doc.Error) != "null" { + return f, true, "" + } + if d := bytes.TrimSpace(doc.Data); len(d) == 0 || d[0] != '{' { + return f, false, "the plan output has no data object" + } + var data struct { + PlanID *string `json:"plan_id"` + Requires *bool `json:"requires_confirmation"` + Effects []struct { + Kind string `json:"kind"` + } `json:"effects"` + } + if err := json.Unmarshal(doc.Data, &data); err != nil { + return f, false, "the plan output has no data object" + } + switch { + case data.PlanID == nil: + return f, false, "the plan output has no data.plan_id" + case !confirmPattern.MatchString(*data.PlanID): + return f, false, "data.plan_id is not 64 lowercase hex characters" + case data.Requires == nil: + return f, false, "the plan output has no data.requires_confirmation boolean" + } + secrets := false + for _, e := range data.Effects { + secrets = secrets || e.Kind == effectSecretsPersist + } + return planFacts{id: *data.PlanID, requires: *data.Requires, secrets: secrets, data: doc.Data}, false, "" +} diff --git a/internal/invoke/plan_facts_test.go b/internal/invoke/plan_facts_test.go new file mode 100644 index 000000000..d24023fd2 --- /dev/null +++ b/internal/invoke/plan_facts_test.go @@ -0,0 +1,42 @@ +package invoke + +import ( + "strings" + "testing" +) + +// TestGatePlanDeclaringSecretsConfirms (P7-SHIP-02 gap 2): a write plan that +// needs no confirmation by its own rules but declares the secrets-persist step +// is returned as a kind plan confirmation, and only the plan exec ran. +func TestGatePlanDeclaringSecretsConfirms(t *testing.T) { + id := shaHex("first build") + e := newGateEnv(t, "STUBCLI_MODE", "raw", "STUBCLI_STDOUT", + `{"schema_version":"1","command":"gx apply","data":{"plan_id":"`+id+`","requires_confirmation":false,`+ + `"effects":[{"kind":"secrets-persist","target":".env.secrets","detail":""}]}}`) + conf := confirmationOf(t, e.call("gx apply", `{"flags":{"target":"x"}}`)) + if conf.Kind != ConfirmKindPlan || conf.Confirm != id || conf.SideEffect != "write" { + t.Fatalf("confirmation: %+v", conf) + } + if av := e.argvs(); len(av) != 1 || !hasArg(av[0], "--plan=true") { + t.Fatalf("only the plan exec runs: %q", av) + } +} + +// TestParsePlanSecrets: the secrets fact comes from data.effects only. +func TestParsePlanSecrets(t *testing.T) { + id := strings.Repeat("a", 64) + for _, tc := range []struct { + effects string + want bool + }{ + {`[]`, false}, + {`[{"kind":"hosts"}]`, false}, + {`[{"kind":"hosts"},{"kind":"secrets-persist"}]`, true}, + } { + doc := `{"data":{"plan_id":"` + id + `","requires_confirmation":false,"effects":` + tc.effects + `}}` + f, childErr, problem := parsePlan([]byte(doc)) + if childErr || problem != "" || f.secrets != tc.want || f.id != id { + t.Errorf("%s: %+v %v %q", tc.effects, f, childErr, problem) + } + } +} diff --git a/internal/mcpgen/surface.go b/internal/mcpgen/surface.go new file mode 100644 index 000000000..5b939e407 --- /dev/null +++ b/internal/mcpgen/surface.go @@ -0,0 +1,178 @@ +package mcpgen + +// surface.go — the live tool set of one `nself mcp` process (P7-SHIP-02). +// +// Purpose: the server starts with or without a project. Without one it lists +// `nself_init` and the read-only tools, so an agent that only has MCP can +// create the project. After every successful call the Surface checks whether +// the inputs of the tool set changed (a project now exists, or the installed +// plugin set changed, as after `add ci`); if so it rebuilds the registry and +// replaces the tools, which sends notifications/tools/list_changed to every +// client (ServerOptions declares tools.listChanged). +// Inputs: SurfaceOptions (server, hand tools, registry source, runner, plugin dir). +// Outputs: the server's tools; the project dir each call runs in. +// Constraints: the runner owns the confirmation Guard, so a confirmation +// issued before a refresh is still valid after it. Every call runs as a child +// process and reads only its own registry value, so a rebuild while calls are +// in flight is safe. A failed rebuild keeps the previous tools and is retried +// after the next successful call. + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/mark3labs/mcp-go/server" + "github.com/nself-org/cli/internal/cmdregistry" + "github.com/nself-org/cli/internal/invoke" +) + +// noProjectTools are listed, besides the read-only tools, before a project exists. +var noProjectTools = map[string]bool{"nself_init": true} + +// SurfaceOptions configures a Surface. +type SurfaceOptions struct { + Server *server.MCPServer + // Hand registers the v1.4 hand tools on a server; nil in v1.5 mode. + Hand func(*server.MCPServer) + V15 bool + // Schema returns a committed schema by its path relative to schemas/. + Schema func(name string) ([]byte, bool) + // Registry returns the machine registry; rebuild drops the memoised one first. + Registry func(rebuild bool) (*cmdregistry.Registry, error) + // Run invokes one command of reg in dir ("" is the working directory). + Run func(ctx context.Context, reg *cmdregistry.Registry, path string, req invoke.Request, dir string) (invoke.Result, error) + // PluginDir returns the installed plugin directory. + PluginDir func() string + // Project returns the project root above the working directory, or "". + Project func() string + // Warn receives one line per problem that does not stop the server. + Warn func(string) +} + +// Surface owns the tool set of one server. +type Surface struct { + o SurfaceOptions + hand []server.ServerTool + mu sync.Mutex + dir string // the project root; "" before a project exists + seen string // the inputs the current tools were built from +} + +// NewSurface returns a Surface; Start lists the first tools. +func NewSurface(o SurfaceOptions) *Surface { + m := &Surface{o: o} + if o.Hand != nil { + scratch := server.NewMCPServer(ServerName, ServerVersion) + o.Hand(scratch) + for _, t := range scratch.ListTools() { + m.hand = append(m.hand, *t) + } + sort.Slice(m.hand, func(i, j int) bool { return m.hand[i].Tool.Name < m.hand[j].Tool.Name }) + } + return m +} + +// inputs names what the tool set is built from: the project root and every +// installed plugin entry with its manifest state. +func (m *Surface) inputs(dir string) string { + var b strings.Builder + b.WriteString(dir) + plugins := m.o.PluginDir() + entries, _ := os.ReadDir(plugins) // no plugin dir yet: no plugins + for _, e := range entries { + fmt.Fprintf(&b, "\x00%s", e.Name()) + for _, name := range []string{"", "plugin.json", "plugin.yaml"} { + if info, err := os.Stat(filepath.Join(plugins, e.Name(), name)); err == nil { + fmt.Fprintf(&b, "/%s:%d:%d", name, info.Size(), info.ModTime().UnixNano()) + } + } + } + return b.String() +} + +// Start lists the hand tools (v1.4), then the generated ones, and returns the +// registry they came from. On an error the hand tools stay listed. +func (m *Surface) Start() (*cmdregistry.Registry, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.dir = m.o.Project() + m.seen = m.inputs(m.dir) + m.o.Server.SetTools(visible(m.hand, m.dir)...) + reg, err := m.o.Registry(false) + if err != nil { + return nil, err + } + return reg, m.install(reg) +} + +// Invoker runs one command of reg through the runner, then refreshes the +// tools when the call succeeded. +func (m *Surface) Invoker(reg *cmdregistry.Registry) Invoker { + return func(ctx context.Context, path string, req invoke.Request) (invoke.Result, error) { + m.mu.Lock() + dir := m.dir + m.mu.Unlock() + res, err := m.o.Run(ctx, reg, path, req, dir) + if err == nil && res.ExitCode == 0 { + m.refresh() + } + return res, err + } +} + +// refresh rebuilds the tools when their inputs changed since the last build. +func (m *Surface) refresh() { + m.mu.Lock() + defer m.mu.Unlock() + m.dir = m.o.Project() + now := m.inputs(m.dir) + if now == m.seen { + return + } + reg, err := m.o.Registry(true) + if err == nil { + err = m.install(reg) + } + if err != nil { + m.warn("the tool list was not refreshed, keeping the previous one: " + err.Error()) + return + } + m.seen = now +} + +// install generates the tools from reg and replaces the server's set. +func (m *Surface) install(reg *cmdregistry.Registry) error { + tools, err := Tools(Options{Registry: reg, Invoke: m.Invoker(reg), Schema: m.o.Schema, V15: m.o.V15, Warn: m.o.Warn}) + if err != nil { + return err + } + m.o.Server.SetTools(visible(append(append([]server.ServerTool{}, m.hand...), tools...), m.dir)...) + return nil +} + +func (m *Surface) warn(msg string) { + if m.o.Warn != nil { + m.o.Warn(msg) + } +} + +// visible is every tool with a project; without one, init and the tools +// annotated read-only. +func visible(all []server.ServerTool, dir string) []server.ServerTool { + if dir != "" { + return all + } + var out []server.ServerTool + for _, t := range all { + ro := t.Tool.Annotations.ReadOnlyHint + if noProjectTools[t.Tool.Name] || (ro != nil && *ro) { + out = append(out, t) + } + } + return out +} diff --git a/internal/mcpgen/surface_test.go b/internal/mcpgen/surface_test.go new file mode 100644 index 000000000..a3c59e278 --- /dev/null +++ b/internal/mcpgen/surface_test.go @@ -0,0 +1,32 @@ +package mcpgen + +import ( + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// TestVisibleWithoutProject: without a project only init and the read-only +// tools are listed; with one, every tool. +func TestVisibleWithoutProject(t *testing.T) { + tool := func(name string, ro *bool) server.ServerTool { + t := mcp.NewTool(name) + t.Annotations.ReadOnlyHint = ro + return server.ServerTool{Tool: t} + } + all := []server.ServerTool{ + tool("nself_init", mcp.ToBoolPtr(false)), tool("nself_status", mcp.ToBoolPtr(true)), + tool("nself_build", mcp.ToBoolPtr(false)), tool("nself_unannotated", nil), + } + var names []string + for _, x := range visible(all, "") { + names = append(names, x.Tool.Name) + } + if len(names) != 2 || names[0] != "nself_init" || names[1] != "nself_status" { + t.Fatalf("without a project: %v", names) + } + if got := visible(all, "/p"); len(got) != len(all) { + t.Fatalf("with a project %d of %d tools", len(got), len(all)) + } +} diff --git a/internal/mcpgen/testdata/tools.golden.json b/internal/mcpgen/testdata/tools.golden.json index 0eace7df6..3ca9bf799 100644 --- a/internal/mcpgen/testdata/tools.golden.json +++ b/internal/mcpgen/testdata/tools.golden.json @@ -20302,11 +20302,15 @@ "properties": { "args": { "items": false, - "maxItems": 1, + "maxItems": 2, "minItems": 0, "prefixItems": [ { - "description": "source target", + "description": "source", + "type": "string" + }, + { + "description": "target", "type": "string" } ], diff --git a/internal/reconcile/apply.go b/internal/reconcile/apply.go index 3532c9b20..5e3a023e2 100644 --- a/internal/reconcile/apply.go +++ b/internal/reconcile/apply.go @@ -104,9 +104,9 @@ func ApplyBuild(ctx context.Context, req Request, opt ApplyOptions) (applied *Pl return nil, nil, err } if opt.PlanID != "" { - if c.generates { + if c.generates && !c.bindable { // binding.go: the generated values changed the plan's shape return nil, nil, errs.New("E451", "this build generates secrets, so --plan-id cannot bind it"). - WithWhy("random values cannot be reproduced from a plan id, so a plan id would not describe what is written"). + WithWhy("the generated random values change the plan itself, not only the secret bytes, so a plan id would not describe what is written"). WithFix("set the secrets in .env.secrets first, or run nself build and confirm at the prompt (or with --yes, without --plan-id)") } if hasEffect(p, EffectPluginInstall) || hasEffect(p, EffectPluginRemove) { diff --git a/internal/reconcile/apply_test.go b/internal/reconcile/apply_test.go index 072ab2390..d6af12463 100644 --- a/internal/reconcile/apply_test.go +++ b/internal/reconcile/apply_test.go @@ -368,10 +368,11 @@ func dropSecrets(t *testing.T, f *fixture) { "HASURA_GRAPHQL_ADMIN_SECRET="+fixtureValue(t, "HASURA_GRAPHQL_ADMIN_SECRET")+"\n", 0o600) } -// TestPlanCoversGeneratedSecrets (review M2a, Codex 2): a build that generates -// secrets lists .env.secrets as an artifact with planned bytes, refuses -// --plan-id with E451 (random values cannot be bound), and an apply without -// --plan-id writes exactly the confirmed render. +// TestPlanCoversGeneratedSecrets (review M2a, Codex 2; P7-SHIP-02 gap 2): a +// build that generates secrets lists .env.secrets and the secrets-persist +// effect, its plan id is the binding id (the same for every seed), a plan id +// from one render binds an apply with another seed, and the apply persists +// fresh secrets. func TestPlanCoversGeneratedSecrets(t *testing.T) { f := loadFixture(t, "dev-minimal") dropSecrets(t, f) @@ -380,22 +381,55 @@ func TestPlanCoversGeneratedSecrets(t *testing.T) { for _, a := range p.Artifacts { listed = listed || (a.Path == ".env.secrets" && a.Action == ActionAdd && a.DiffLines > 0) } - if !listed { - t.Fatalf(".env.secrets is not a planned artifact: %+v", p.Artifacts) + if !listed || !hasEffect(p, EffectSecretsPersist) { + t.Fatalf(".env.secrets or secrets-persist is not planned: %+v %+v", p.Artifacts, p.Effects) } + if other := planOf(t, f, func(r *Request) { r.Seed = []byte("another seed") }); other.PlanID != p.PlanID { + t.Fatalf("the plan id of a generating render depends on the seed: %s vs %s", p.PlanID, other.PlanID) + } + bc, err := compute(context.Background(), Request{Runtime: &fakeRuntime{}, ProjectDir: f.project, Seed: bindingSeed[:], binding: true}) + if err != nil { + t.Fatal(err) + } + if _, err := Apply(context.Background(), Request{Runtime: &fakeRuntime{}, ProjectDir: f.project}, ApplyOptions{PlanID: p.PlanID, Yes: true}); err != nil { + t.Fatalf("a plan id must bind a first build: %v", err) + } + if got := readFile(t, f.project+"/.env.secrets"); got == "" || got == string(bc.after[".env.secrets"].Data) { + t.Fatal("apply wrote no secrets, or the binding render's values") + } + if data := planOf(t, f, nil); !data.Empty { + t.Fatalf("the apply wrote something other than the plan: %+v", data.Artifacts) + } +} + +// TestGeneratedPlanIDRefusesDrift: the binding id still binds the inputs. An +// input changed after the plan is E450 and nothing is written. +func TestGeneratedPlanIDRefusesDrift(t *testing.T) { + f := loadFixture(t, "dev-minimal") + dropSecrets(t, f) + p := planOf(t, f, nil) + writeFile(t, f.project+"/.env", readFile(t, f.project+"/.env")+"MONITORING_ENABLED=true\n", 0o600) before := treeHash(t, f.root) _, err := Apply(context.Background(), Request{Runtime: &fakeRuntime{}, ProjectDir: f.project}, ApplyOptions{PlanID: p.PlanID, Yes: true}) - if d := errs.Describe(err); d == nil || d.Code != "E451" { - t.Fatalf("want E451, got %v", err) + if d := errs.Describe(err); d == nil || d.Code != "E450" { + t.Fatalf("want E450, got %v", err) } if treeHash(t, f.root) != before { - t.Fatal("E451 apply wrote") + t.Fatal("the refused apply wrote") } - if _, err := Apply(context.Background(), Request{Runtime: &fakeRuntime{}, ProjectDir: f.project, Seed: []byte("s")}, ApplyOptions{Yes: true}); err != nil { - t.Fatal(err) +} + +// TestSameShape: plans equal but for plan_id agree; any other field differs. +func TestSameShape(t *testing.T) { + a := Plan{PlanID: "a", Command: CmdBuild, Artifacts: []Artifact{{Path: ".env.secrets", DiffLines: 3}}} + b := a + b.PlanID = "b" + if same, err := sameShape(a, b); err != nil || !same { + t.Fatalf("same plans with other ids: %v %v", same, err) } - if _, err := os.Stat(f.project + "/.env.secrets"); err != nil { - t.Fatal("apply did not persist the generated secrets") + b.Artifacts = []Artifact{{Path: ".env.secrets", DiffLines: 4}} + if same, _ := sameShape(a, b); same { + t.Fatal("plans with other artifacts compared equal") } } diff --git a/internal/reconcile/binding.go b/internal/reconcile/binding.go new file mode 100644 index 000000000..107d09d27 --- /dev/null +++ b/internal/reconcile/binding.go @@ -0,0 +1,63 @@ +package reconcile + +// binding.go — the plan id of a render that generates secrets (P7-LIVE D9, +// P7-SHIP-02 gap 2). +// +// Purpose: a first build generates secrets from a random seed, so its content +// id changes with every render and no plan id could bind it (E451). Such a +// render gets an id that its inputs reproduce: the content id of a second, +// read-only render of the same inputs with a fixed public seed. The id so binds +// the inputs and the whole plan (artifacts, actions, modes, effects, container +// impact); the secret values are the declared secrets-persist step, drawn fresh +// at write time and never part of any plan, as they never were shown in one. +// Inputs: the Request and the computed real render. Outputs: the render's +// PlanID replaced by the binding id and bindable set, or the render unchanged +// when the two renders disagree on anything but the random bytes. +// Constraints: the binding render runs in plan mode and writes nothing, so the +// fixed seed's values never reach the disk; it only feeds a hash. A shape +// mismatch keeps the content id and leaves bindable false, so --plan-id is +// still refused with E451 (fail closed). + +import ( + "bytes" + "context" + "crypto/sha256" +) + +// bindingSeed is the public seed of the binding render. Its values are hashed, +// never written. +var bindingSeed = sha256.Sum256([]byte("nself change-plan binding seed v1")) + +// bindGenerated sets c.plan.PlanID to the binding id of a render that +// generates secrets, when the binding render has the same plan shape. +func bindGenerated(ctx context.Context, req Request, c *computed) error { + b := req + b.Seed = bindingSeed[:] + b.DiffOut = nil + b.Stderr = nil + b.binding = true + bc, err := compute(ctx, b) + if err != nil { + return err + } + same, err := sameShape(*c.plan, *bc.plan) + if err != nil || !same { + return err + } + c.plan.PlanID = bc.plan.PlanID + c.bindable = true + return nil +} + +// sameShape reports whether two plans are equal in everything but plan_id. +func sameShape(a, b Plan) (bool, error) { + ja, err := CanonicalJSON(a) + if err != nil { + return false, err + } + jb, err := CanonicalJSON(b) + if err != nil { + return false, err + } + return bytes.Equal(ja, jb), nil +} diff --git a/internal/reconcile/planner.go b/internal/reconcile/planner.go index a758ce189..ec6584a9b 100644 --- a/internal/reconcile/planner.go +++ b/internal/reconcile/planner.go @@ -46,6 +46,7 @@ type computed struct { planned *nbuild.PlannedBuild before, after ArtifactSet generates bool // the render generates secrets (random values) + bindable bool // plan.PlanID is reproducible from the inputs (binding.go) record []byte // the exact record bytes this plan's apply writes (P7-LIVE-04) } @@ -156,7 +157,13 @@ func compute(ctx context.Context, req Request) (*computed, error) { for _, e := range p.Effects { gen = gen || e.Kind == EffectSecretsPersist } - return &computed{plan: p, planned: res.Planned, before: before, after: after, generates: gen, record: recBody}, nil + c := &computed{plan: p, planned: res.Planned, before: before, after: after, generates: gen, bindable: !gen, record: recBody} + if gen && !req.binding { + if err := bindGenerated(ctx, req, c); err != nil { + return nil, err + } + } + return c, nil } // modeArtifacts lists files whose bytes do not change but whose permission bits @@ -264,8 +271,8 @@ func effectsOf(in []nbuild.PlannedEffect, sitesChanged bool) []Effect { // permission bits. The id so binds the content of the change, not only its // shape; the bytes themselves never reach the plan JSON. A render that // generates secrets hashes those random values too, so its id is only -// reproducible with the same seed: Apply refuses --plan-id for such a render -// (E451) and binds it through its own single render instead. +// reproducible with the same seed: compute replaces it with the binding id of +// binding.go, and Apply refuses --plan-id (E451) only when that is impossible. func contentPlanID(p Plan, after ArtifactSet) (string, error) { canon, err := CanonicalJSON(p) if err != nil { diff --git a/internal/reconcile/request.go b/internal/reconcile/request.go index acf42475d..6cc8aa03c 100644 --- a/internal/reconcile/request.go +++ b/internal/reconcile/request.go @@ -73,6 +73,8 @@ type Request struct { envChanged, envChangedSet bool // resolvedEnv is the cascade selector shared by every render of an apply. resolvedEnv string + // binding marks the fixed-seed render of binding.go, which is never bound again. + binding bool } // ApplyOptions are the operator's answers to a plan. diff --git a/sdk/ts-cli-api/src/generated/types.ts b/sdk/ts-cli-api/src/generated/types.ts index ee12986af..a8787f4d8 100644 --- a/sdk/ts-cli-api/src/generated/types.ts +++ b/sdk/ts-cli-api/src/generated/types.ts @@ -5122,7 +5122,7 @@ export interface DeployOpsRequest { confirm?: string; } -export type DeployPromoteArgs = [string?]; +export type DeployPromoteArgs = [string?, string?]; export interface DeployPromoteData { backup_id?: string; From a3ce6ba5e602d8f8d1e7672054fb36c4d5694211 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 17:31:29 -0400 Subject: [PATCH 2/4] fix(mcp): keep cmd/commands within its package-size ceiling Main grew cmd/commands to its ratchet ceiling, so the no-project notice moves into mcpgen.Surface (printed through Warn) and the constructor comment is shortened. Behaviour is unchanged. --- cmd/commands/mcp.go | 4 ---- cmd/commands/mcp_generated.go | 7 +++---- internal/mcpgen/surface.go | 3 +++ 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/cmd/commands/mcp.go b/cmd/commands/mcp.go index 88d05b03c..0d0410db8 100644 --- a/cmd/commands/mcp.go +++ b/cmd/commands/mcp.go @@ -23,7 +23,6 @@ package commands // SPORT: CLI-CMD-MCP-001 import ( - "fmt" "os" "time" @@ -91,9 +90,6 @@ func runMCPServe(cmd *cobra.Command, args []string) error { if timeout < time.Second { return errs.Newf("E401", "--timeout %s is too short", timeout).WithFix("Pass a duration of at least 1s, such as 15m.") } - if _, err := resolveProjectDir(); err != nil { // an MCP-only agent must reach init (mcpgen/surface.go) - fmt.Fprintln(os.Stderr, "nself mcp: no nself project here; serving init and the read-only tools until one exists") - } // Bind and token policy first: a refused start must not build anything or // write a token file. stdio skips it. diff --git a/cmd/commands/mcp_generated.go b/cmd/commands/mcp_generated.go index fcdf73bf5..8c510daf2 100644 --- a/cmd/commands/mcp_generated.go +++ b/cmd/commands/mcp_generated.go @@ -29,10 +29,9 @@ import ( "github.com/nself-org/cli/schemas" ) -// newMCPServer builds the MCP server for the compat mode: v1.4 registers the -// 26 hand tools and the four hand resources first and the generated ones on -// top; v1.5 the generated surface only (mcpgen.Surface keeps the list live). -// timeout bounds each call; 0 is invoke.DefaultTimeout. +// newMCPServer builds the MCP server: v1.4 lists the 26 hand tools and four hand +// resources, then the generated ones; v1.5 only the generated surface +// (mcpgen.Surface keeps the list live). timeout 0 is invoke.DefaultTimeout. func newMCPServer(transport string, v15 bool, timeout time.Duration) (*server.MCPServer, error) { s := server.NewMCPServer(mcpgen.ServerName, mcpgen.ServerVersion, mcpgen.ServerOptions()...) o := mcpgen.SurfaceOptions{Server: s, V15: v15, Schema: schemas.Lookup, PluginDir: resolvePluginDir, diff --git a/internal/mcpgen/surface.go b/internal/mcpgen/surface.go index 5b939e407..670ed025e 100644 --- a/internal/mcpgen/surface.go +++ b/internal/mcpgen/surface.go @@ -101,6 +101,9 @@ func (m *Surface) Start() (*cmdregistry.Registry, error) { m.mu.Lock() defer m.mu.Unlock() m.dir = m.o.Project() + if m.dir == "" { // an MCP-only agent must still reach init + m.warn("no nself project here; serving init and the read-only tools until one exists") + } m.seen = m.inputs(m.dir) m.o.Server.SetTools(visible(m.hand, m.dir)...) reg, err := m.o.Registry(false) From f5f7d9eca4d5547672c0d5ae52240c6da23dde4b Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 18:15:18 -0400 Subject: [PATCH 3/4] test(mcp): wait for tools/list_changed on the stream, not with pings The server writes notifications from its own goroutine, so on a loaded Linux runner list_changed arrived after the 20 ping round trips the test allowed. With no request outstanding the next line can only be a notification, so the test reads the stream until it arrives, bounded by a 30 s deadline. --- cmd/commands/mcp_surface_test.go | 36 +++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/cmd/commands/mcp_surface_test.go b/cmd/commands/mcp_surface_test.go index 8845096da..2aec00fef 100644 --- a/cmd/commands/mcp_surface_test.go +++ b/cmd/commands/mcp_surface_test.go @@ -10,6 +10,7 @@ package commands // from internal/invoke/testdata/stubcli; no network, no sleeps. import ( + "encoding/json" "os" "path/filepath" "slices" @@ -21,18 +22,37 @@ import ( "github.com/nself-org/cli/internal/invoke" ) -// waitListChanged reads until the server has sent tools/list_changed, with -// ping round trips (a notification and a response may interleave). +// waitListChanged waits for tools/list_changed. The server queues it on its +// own writer goroutine, so it can arrive after the response of the call that +// caused it: with no request outstanding, the next line on the stream can +// only be a notification, so read lines until it comes, bounded by a deadline. func waitListChanged(t *testing.T, sess *stdioSession) { t.Helper() - for i := 0; i < 20; i++ { - if slices.Contains(sess.notes, "notifications/tools/list_changed") { - sess.notes = nil - return + const method = "notifications/tools/list_changed" + lines := make(chan []byte) + go func() { + for !slices.Contains(sess.notes, method) { + raw, err := sess.out.ReadBytes('\n') + if err != nil { + break + } + var msg struct { + Method string `json:"method"` + } + _ = json.Unmarshal(raw, &msg) + sess.notes = append(sess.notes, msg.Method) } - sess.call("ping", `{}`) + close(lines) + }() + select { + case <-lines: + case <-time.After(30 * time.Second): + t.Fatalf("no %s within 30s; notifications seen: %v", method, sess.notes) } - t.Fatalf("no notifications/tools/list_changed; notifications seen: %v", sess.notes) + if !slices.Contains(sess.notes, method) { + t.Fatalf("the stream ended before %s; notifications seen: %v", method, sess.notes) + } + sess.notes = nil } func TestMCPSurfaceWithoutProjectThenRefresh(t *testing.T) { From c1e1f58d9f83fec0645b7d1c6e5b8bdc74ba3d77 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 19:55:19 -0400 Subject: [PATCH 4/4] fix(build): bind local dev certificate and mkcert CA steps to the plan id (P7-SHIP-02) A confirmed first build over MCP failed with E454: the plan has a certificates step (local-nself-org) and a trust-store step (mkcert CA) next to secrets-persist, and E454 refused every certificate or trust effect under --plan-id. The plan now names both steps exactly. The certificates step lists every domain the certificate covers. The trust-store step names the mkcert CA by the sha256 of its root certificate, or says a new CA is created. The plan id covers those words, so a CA replaced or a domain added after the plan changes the id and the apply is refused (E450). A plan id binds them only as local development steps: a dev-class env, a certificate under the project's ssl/certificates whose domains are all local development names (localhost, loopback addresses, local.nself.org, and names under .local.nself.org, .localhost or .local), and the mkcert CA named as above. Any other certificate or trust change stays E454. The MCP gate now always asks for confirmation of a plan with a certificates or trust-store step, so the agent consents to exactly the named change. The step text and the scope test live in the new internal/build/trustplan package (internal/build is at its package-size ceiling), with a coverage floor. TestSurfaceIdentity stubs mkcert and pins CAROOT, so no surface creates a CA or touches the host trust store and all surfaces plan the same named trust step whatever the host has installed. --- .github/coverage-floors.txt | 1 + .github/facts.json | 4 +- .github/wiki/MCP-Server.md | 2 +- .github/wiki/MCP-Tools.md | 123 +++++++++---------- .github/wiki/Safe-On-Live.md | 2 +- .github/wiki/cmd-build.md | 2 +- .github/wiki/error-codes.md | 4 +- cmd/commands/surface_identity_test.go | 6 +- internal/build/effects.go | 33 ++--- internal/build/trustplan/trustplan.go | 136 +++++++++++++++++++++ internal/build/trustplan/trustplan_test.go | 49 ++++++++ internal/errs/codes_reconcile.go | 2 +- internal/invoke/gate.go | 2 +- internal/invoke/plan_facts.go | 19 +-- internal/invoke/plan_facts_test.go | 2 + internal/reconcile/apply.go | 4 +- internal/reconcile/render.go | 2 +- internal/reconcile/trust_binding.go | 35 ++++++ internal/reconcile/trust_binding_test.go | 89 ++++++++++++++ 19 files changed, 408 insertions(+), 109 deletions(-) create mode 100644 internal/build/trustplan/trustplan.go create mode 100644 internal/build/trustplan/trustplan_test.go create mode 100644 internal/reconcile/trust_binding.go create mode 100644 internal/reconcile/trust_binding_test.go diff --git a/.github/coverage-floors.txt b/.github/coverage-floors.txt index aee5a0935..d13bc16dc 100644 --- a/.github/coverage-floors.txt +++ b/.github/coverage-floors.txt @@ -32,6 +32,7 @@ internal/backup 15 # measured 17.1 2026-10-05 internal/backup/destinations 40 # measured 42.1 2026-10-05 internal/backup/pitr 25 # measured 27.6 2026-10-05 internal/build 71 # measured 73.5 2026-10-05 +internal/build/trustplan 74 # measured 76.2 2026-10-30 internal/builtin/admin 12 # measured 12.4 2026-10-08 (P7-CANON-19, moved from cmd/commands) internal/bundle 64 # measured 67.0 2026-10-05 internal/bundle/appready 85 # measured 92.1 2026-10-10 (P7-PLUG-19) diff --git a/.github/facts.json b/.github/facts.json index ad07f7072..f6b5a5665 100644 --- a/.github/facts.json +++ b/.github/facts.json @@ -9,8 +9,8 @@ }, "schema_version": "1", "sources": { - "openapi_sha256": "1f47f7be7d98810ce4eca08fb61bd930cc662290040b162bfc96fe6e602a6059", - "registry_sha256": "37fe98fe4dfa26f0c8af15f5cc50e9a5b81f499976850ae2b6066c536e8fe6c9" + "openapi_sha256": "11122bde68b446a20556664dbfe3d99d3b2b73b1af41fb1034c5ac3575c987a8", + "registry_sha256": "3846753c456f348e5ef3f1c74af81f7894244006b435268cc716663dc9266314" }, "verbs": [ "init", diff --git a/.github/wiki/MCP-Server.md b/.github/wiki/MCP-Server.md index 48cbc523d..0671a0418 100644 --- a/.github/wiki/MCP-Server.md +++ b/.github/wiki/MCP-Server.md @@ -141,7 +141,7 @@ The gate decides on the effective side effect of the request (flag escalation ap - Consent and plan-id flags belong to the invoker. A request that sets one is refused with `E425` before anything runs. - `read` runs. - `write` without a plan form runs, with no consent flags. -- A command with a plan form and no `confirm`: the invoker runs the plan form once (` --json -- ...`) and reads `data.plan_id` and `data.requires_confirmation` from the child's change plan. When `requires_confirmation` is true, the class is `remote` or `destructive`, or the plan declares the `secrets-persist` effect (a first build that generates secrets), it returns a confirmation of kind `plan` whose `confirm` is the plan id. Otherwise it runs the command once with `--=`. A plan exec that fails returns the child's error envelope unchanged; a plan without a valid `plan_id` or `requires_confirmation` is `E422`. The plan exec and the run are two children, each with the full invocation timeout. +- A command with a plan form and no `confirm`: the invoker runs the plan form once (` --json -- ...`) and reads `data.plan_id` and `data.requires_confirmation` from the child's change plan. When `requires_confirmation` is true, the class is `remote` or `destructive`, or the plan declares a `secrets-persist`, `certificates` or `trust-store` step (a first build that generates secrets, issues a certificate or installs the mkcert CA), it returns a confirmation of kind `plan` whose `confirm` is the plan id. Otherwise it runs the command once with `--=`. A plan exec that fails returns the child's error envelope unchanged; a plan without a valid `plan_id` or `requires_confirmation` is `E422`. The plan exec and the run are two children, each with the full invocation timeout. - `remote` or `destructive` without a plan form and no `confirm`: a confirmation of kind `request`, whose `confirm` is a nonce. - A server without a confirmation store refuses every remote and destructive command with `E421`. diff --git a/.github/wiki/MCP-Tools.md b/.github/wiki/MCP-Tools.md index dfaa9ae8b..c897a971a 100644 --- a/.github/wiki/MCP-Tools.md +++ b/.github/wiki/MCP-Tools.md @@ -239,57 +239,57 @@ The tools `nself mcp` serves in v1.5 mode, one per exposed command of the machin | `nself_init` | Initialize a new nSelf project | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22565) | | `nself_init_template` | Browse and publish full-stack app templates | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22729) | | `nself_init_template_info` | Show detail for a single template | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22792) | -| `nself_init_template_list` | List available app templates | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22904) | -| `nself_init_template_publish` | Submit a template to the nSelf registry | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23078) | -| `nself_init_template_update` | Apply incremental migrations for the installed template | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23200) | -| `nself_license` | Manage license keys for nSelf product bundles | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23263) | -| `nself_license_account` | Manage your nSelf account, sessions, licenses, team, and devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23326) | -| `nself_license_account_devices` | List and revoke registered devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23404) | -| `nself_license_account_licenses` | List and manage account licenses | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23484) | -| `nself_license_account_status` | Show current account summary | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23580) | -| `nself_license_account_team` | Manage team members | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23658) | -| `nself_license_add` | Add one or more license keys | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23728) | -| `nself_license_clear` | Remove all saved license keys | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23802) | -| `nself_license_health` | Validate format, ping server, and report cache integrity | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23913) | -| `nself_license_import` | Import a previously exported license cache file | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23980) | -| `nself_license_list` | Show all configured licenses (alias for status) | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24038) | -| `nself_license_migrate` | Migrate legacy license key to a nSelf account | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24164) | -| `nself_license_refresh` | Force-refresh license validation against ping.nself.org | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24340) | -| `nself_license_remove` | Remove a license key by value or product name | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24446) | -| `nself_license_restore` | Reactivate dormant plugins with a new license key | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24555) | -| `nself_license_revalidate` | Force a fresh validation against ping.nself.org and update the cache | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24667) | -| `nself_license_revoke` | Mark local license as revoked and wipe the stored key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24714) | -| `nself_license_set` | Replace all keys with a single key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24817) | -| `nself_license_show` | Display license tier, bundles, plugins, and cache expiry | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24934) | -| `nself_license_simulate_offline` | Simulate being offline for N days (testing only) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25009) | -| `nself_license_status` | Show all configured licenses and plugin coverage | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25087) | -| `nself_license_upgrade` | Open pricing page in browser | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25213) | -| `nself_license_validate` | Validate key against ping.nself.org | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25261) | -| `nself_logs` | View and filter service logs | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25311) | -| `nself_mcp_setup` | Print or write the MCP client config and AGENTS.md for this project | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25471) | -| `nself_remove` | Remove an installed plugin or bundle | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25758) | -| `nself_reset` | Stop containers, remove all data volumes, and clean generated files | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25922) | -| `nself_restart` | Smart restart with config change detection | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26046) | -| `nself_start` | Boot your nSelf stack | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26238) | -| `nself_status` | Show health status of all services | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26405) | -| `nself_status_health` | Health check management with continuous monitoring | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26534) | -| `nself_status_health_check` | Run all health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26653) | -| `nself_status_health_config` | Show health check settings | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26740) | -| `nself_status_health_endpoint` | Check an HTTP endpoint | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26804) | -| `nself_status_health_history` | Show last 20 health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26866) | -| `nself_status_health_service` | Check a single service | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26968) | -| `nself_status_urls` | Display all service URLs with route conflict detection | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27030) | -| `nself_stop` | Gracefully shut down all services or specific services | `destructive` | with `--volumes` | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27483) | -| `nself_update` | Update the nSelf CLI and admin UI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27634) | -| `nself_update_images` | Refresh Docker image digests from registries | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27917) | -| `nself_update_project_detect` | Detect v1 artifacts in the current project | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28008) | -| `nself_update_project_from_bash` | Migrate a v0.9.9 Bash-era project to the current nSelf CLI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28060) | -| `nself_update_project_from_v099` | Migrate v0.9.9 home-level state (license key, channel, ssh keys) to v1.x layout | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28123) | -| `nself_update_project_rollback` | Restore a v1 backup created by migrate run | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28186) | -| `nself_update_project_run` | Migrate v1 project to v2 | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28308) | -| `nself_update_stack` | Upgrade the stack with a verified backup and resumable steps | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28360) | -| `nself_update_upgrade` | Upgrade the nSelf CLI (detects install method) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28512) | -| `nself_update_verify_sbom` | Verify the SBOM signature for a CLI release | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28598) | +| `nself_init_template_list` | List available app templates | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22913) | +| `nself_init_template_publish` | Submit a template to the nSelf registry | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23096) | +| `nself_init_template_update` | Apply incremental migrations for the installed template | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23218) | +| `nself_license` | Manage license keys for nSelf product bundles | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23281) | +| `nself_license_account` | Manage your nSelf account, sessions, licenses, team, and devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23344) | +| `nself_license_account_devices` | List and revoke registered devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23422) | +| `nself_license_account_licenses` | List and manage account licenses | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23502) | +| `nself_license_account_status` | Show current account summary | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23598) | +| `nself_license_account_team` | Manage team members | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23676) | +| `nself_license_add` | Add one or more license keys | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23746) | +| `nself_license_clear` | Remove all saved license keys | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23820) | +| `nself_license_health` | Validate format, ping server, and report cache integrity | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23931) | +| `nself_license_import` | Import a previously exported license cache file | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23998) | +| `nself_license_list` | Show all configured licenses (alias for status) | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24056) | +| `nself_license_migrate` | Migrate legacy license key to a nSelf account | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24182) | +| `nself_license_refresh` | Force-refresh license validation against ping.nself.org | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24358) | +| `nself_license_remove` | Remove a license key by value or product name | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24464) | +| `nself_license_restore` | Reactivate dormant plugins with a new license key | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24573) | +| `nself_license_revalidate` | Force a fresh validation against ping.nself.org and update the cache | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24685) | +| `nself_license_revoke` | Mark local license as revoked and wipe the stored key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24732) | +| `nself_license_set` | Replace all keys with a single key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24835) | +| `nself_license_show` | Display license tier, bundles, plugins, and cache expiry | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24952) | +| `nself_license_simulate_offline` | Simulate being offline for N days (testing only) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25027) | +| `nself_license_status` | Show all configured licenses and plugin coverage | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25105) | +| `nself_license_upgrade` | Open pricing page in browser | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25231) | +| `nself_license_validate` | Validate key against ping.nself.org | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25279) | +| `nself_logs` | View and filter service logs | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25329) | +| `nself_mcp_setup` | Print or write the MCP client config and AGENTS.md for this project | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25489) | +| `nself_remove` | Remove an installed plugin or bundle | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25776) | +| `nself_reset` | Stop containers, remove all data volumes, and clean generated files | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25940) | +| `nself_restart` | Smart restart with config change detection | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26064) | +| `nself_start` | Boot your nSelf stack | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26256) | +| `nself_status` | Show health status of all services | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26423) | +| `nself_status_health` | Health check management with continuous monitoring | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26552) | +| `nself_status_health_check` | Run all health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26671) | +| `nself_status_health_config` | Show health check settings | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26758) | +| `nself_status_health_endpoint` | Check an HTTP endpoint | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26822) | +| `nself_status_health_history` | Show last 20 health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26884) | +| `nself_status_health_service` | Check a single service | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26986) | +| `nself_status_urls` | Display all service URLs with route conflict detection | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27048) | +| `nself_stop` | Gracefully shut down all services or specific services | `destructive` | with `--volumes` | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27501) | +| `nself_update` | Update the nSelf CLI and admin UI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27652) | +| `nself_update_images` | Refresh Docker image digests from registries | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27935) | +| `nself_update_project_detect` | Detect v1 artifacts in the current project | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28026) | +| `nself_update_project_from_bash` | Migrate a v0.9.9 Bash-era project to the current nSelf CLI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28078) | +| `nself_update_project_from_v099` | Migrate v0.9.9 home-level state (license key, channel, ssh keys) to v1.x layout | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28141) | +| `nself_update_project_rollback` | Restore a v1 backup created by migrate run | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28204) | +| `nself_update_project_run` | Migrate v1 project to v2 | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28326) | +| `nself_update_stack` | Upgrade the stack with a verified backup and resumable steps | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28378) | +| `nself_update_upgrade` | Upgrade the nSelf CLI (detects install method) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28530) | +| `nself_update_verify_sbom` | Verify the SBOM signature for a CLI release | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28616) | ## Legacy aliases @@ -300,16 +300,15 @@ Deprecated names from v1.4, removed in v1.6.0. Each maps its parameters onto the | `nself_env_list` | Deprecated: use nself_config_env_list; removed in v1.6.0. List available environments. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22136) | | `nself_get_permissions` | Deprecated: use nself_db_hasura_permissions; removed in v1.6.0. Read Hasura table permissions. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22187) | | `nself_get_schema` | Deprecated: use nself_db_hasura_schema; removed in v1.6.0. Read the Hasura GraphQL schema. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22239) | -| `nself_plugin_install` | Deprecated: use nself_add; removed in v1.6.0. Install a plugin or bundle. | `remote` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25543) | -| `nself_plugin_list` | Deprecated: use nself_config_plugins_list; removed in v1.6.0. List available and installed plugins. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25666) | -| `nself_run_migration` | Deprecated: removed in v1.5. nself_run_migration ran raw SQL outside the migration ledger and is removed in v1.5; use nself db migrate create then nself db migrate up. | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26133) | -| `nself_service_list` | Deprecated: use nself_config_services_list; removed in v1.6.0. List all optional services with enabled/disabled status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26162) | -| `nself_urls` | Deprecated: use nself_status_urls; removed in v1.6.0. Display all service URLs with route conflict detection. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28661) | -| `sentry_incidents_ack` | Deprecated: use nself_sentry_incidents_ack; removed in v1.6.0. Acknowledge an ɳSentry incident. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29081) | -| `sentry_incidents_list` | Deprecated: use nself_sentry_incidents_list; removed in v1.6.0. List ɳSentry incidents. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29109) | -| `sentry_monitors_add` | Deprecated: use nself_sentry_monitors_add; removed in v1.6.0. Add an ɳSentry uptime monitor. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29134) | -| `sentry_monitors_list` | Deprecated: use nself_sentry_monitors_list; removed in v1.6.0. List ɳSentry uptime monitors. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29174) | -| `sentry_status` | Deprecated: use nself_sentry_status; removed in v1.6.0. Read the public ɳSentry status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29194) | +| `nself_plugin_install` | Deprecated: use nself_add; removed in v1.6.0. Install a plugin or bundle. | `remote` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25561) | +| `nself_plugin_list` | Deprecated: use nself_config_plugins_list; removed in v1.6.0. List available and installed plugins. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25684) | +| `nself_run_migration` | Deprecated: removed in v1.5. nself_run_migration ran raw SQL outside the migration ledger and is removed in v1.5; use nself db migrate create then nself db migrate up. | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26151) | +| `nself_service_list` | Deprecated: use nself_config_services_list; removed in v1.6.0. List all optional services with enabled/disabled status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26180) | +| `nself_urls` | Deprecated: use nself_status_urls; removed in v1.6.0. Display all service URLs with route conflict detection. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28679) | +| `sentry_incidents_ack` | Deprecated: use nself_sentry_incidents_ack; removed in v1.6.0. Acknowledge an ɳSentry incident. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29099) | +| `sentry_incidents_list` | Deprecated: use nself_sentry_incidents_list; removed in v1.6.0. List ɳSentry incidents. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29127) | +| `sentry_monitors_add` | Deprecated: use nself_sentry_monitors_add; removed in v1.6.0. Add an ɳSentry uptime monitor. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29152) | +| `sentry_monitors_list` | Deprecated: use nself_sentry_monitors_list; removed in v1.6.0. List ɳSentry uptime monitors. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29192) | +| `sentry_status` | Deprecated: use nself_sentry_status; removed in v1.6.0. Read the public ɳSentry status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29212) | **Plugin commands.** Installed plugins add tools by the same `ToolName` rule. This page is generated from the builtin registry alone, so it never lists them; each plugin's wiki page lists its own in its machine-surface region, for example the [CI plugin page](https://github.com/nself-org/plugins/wiki/CI). -**Plugin commands.** Installed plugins add tools by the same `ToolName` rule. This page is generated from the builtin registry alone, so it never lists them; each plugin's wiki page lists its own in its machine-surface region, for example the [CI plugin page](https://github.com/nself-org/plugins/wiki/CI). Plugin tools are advertised on the MCP surface like any other command, but a call that arrives while a secret bundle was restored is refused with `E421` because plugins do not yet support the secret-handoff protocol (D-0651); a machine-invoked plugin call that carries no secrets runs normally. diff --git a/.github/wiki/Safe-On-Live.md b/.github/wiki/Safe-On-Live.md index f232b4c2f..631bf502e 100644 --- a/.github/wiki/Safe-On-Live.md +++ b/.github/wiki/Safe-On-Live.md @@ -81,7 +81,7 @@ The plan is the `contract:cli.change-plan` v1 document (schema `schemas/commands `plan_id` is the sha256 of the plan's canonical JSON followed by, for every artifact in path order, the sha256 of the bytes the build would leave there and its permission bits. It binds the content of the change, not only its shape, while the JSON carries no file bytes. `nself build --yes --plan-id ` renders again and applies only if the id is unchanged; otherwise it fails with E450 and writes nothing. Swapping one env value for another of the same length changes the id. -The plan and the id cover rendered artifacts: project files, plugin compose fragments rewritten in place (shown as `@plugins//docker-compose.plugin.yml`), `.env.secrets` with the planned bytes, and permission-only changes (a `0644` `.env` is listed as a change with `diff_lines` 0). Certificate bytes and host trust state are runtime effects outside artifact byte confirmation. A plan with `certificates` or `trust-store` effects refuses `--plan-id` with E454 before any effects or artifact writes. Human plan and apply output disclose this exclusion, including on dev. Confirm at the prompt or use `--yes` without `--plan-id`, or provision certificates and host trust first. +The plan and the id cover rendered artifacts: project files, plugin compose fragments rewritten in place (shown as `@plugins//docker-compose.plugin.yml`), `.env.secrets` with the planned bytes, and permission-only changes (a `0644` `.env` is listed as a change with `diff_lines` 0). Certificate bytes and host trust state are runtime effects outside artifact byte confirmation. On a dev-class env a plan id binds them as named local development steps: a certificate under `ssl/certificates/` whose domains are all local development names (localhost, loopback addresses, `local.nself.org` and names under `.local.nself.org`, `.localhost` or `.local`), and the mkcert CA install named by the sha256 of its root certificate (or as a new CA). A CA replaced or a domain added after the plan changes the id (E450). Every other `certificates` or `trust-store` effect refuses `--plan-id` with E454 before any effects or artifact writes. The machine gate always asks for confirmation of a plan with these steps. Human plan and apply output disclose these runtime effects, including on dev. Otherwise confirm at the prompt or use `--yes` without `--plan-id`, or provision certificates and host trust first. A build that generates secrets (a first build, or a missing `*_INTERNAL_SECRET`) renders random values, which no earlier `--plan` run can reproduce. Its `plan_id` therefore binds the inputs, not the random bytes: it is the id of a second, read-only render of the same inputs with a fixed public seed, and the plan lists the `secrets-persist` effect. `--plan-id` binds such a build (E450 when an input changed); the apply draws fresh secrets and writes exactly its own render. The machine gate always asks for a confirmation of such a plan. Only when the random values would change the plan itself (not just the secret bytes) is `--plan-id` refused with E451 (exit 1, nothing written); then confirm at the prompt, or run `--yes` without `--plan-id`. diff --git a/.github/wiki/cmd-build.md b/.github/wiki/cmd-build.md index 10a7bee18..6c1906f35 100644 --- a/.github/wiki/cmd-build.md +++ b/.github/wiki/cmd-build.md @@ -136,7 +136,7 @@ Non-env diffs also redact secret-named keys, pairs and flags and their repeated A first build generates secrets; its `plan_id` binds the inputs and the plan (the `secrets-persist` effect), not the random values, so `--plan-id` binds it and the apply writes fresh secrets. E451 is left for the rare render whose random values change the plan itself; then confirm at the prompt or use `--yes` without `--plan-id`. A change on a prod-class env needs `--yes` or an interactive yes. In v1.5 mode (`NSELF_V15=1`) a non-interactive build without `--yes` is refused with E403 (exit 4). In the default v1.4 mode it prints the plan summary and a one-line notice on stderr, then proceeds as before. A dev env never asks. `nself build --json` without `--plan` applies and prints the applied plan as the envelope data. -Certificate bytes and host trust state are runtime effects outside artifact byte confirmation. A plan that generates or copies certificates, or installs the mkcert CA, refuses `--plan-id` with E454 before applying anything; its human plan and apply output disclose this exclusion, including on dev. Confirm at the prompt or use `--yes` without `--plan-id`, or provision certificates and host trust first. Plugin installation and removal similarly refuse `--plan-id` (E453): the build shows and, interactively on prod-class envs, confirms the render again after those effects. Downloaded plugin bytes are checked against the registry checksum rather than bound by the plan id; edits during that install window are reflected in the second render. +Certificate bytes and host trust state are runtime effects outside artifact byte confirmation. On a dev-class env the plan names them as local development steps: the certificate lists every domain it covers, and the mkcert CA install names the CA by the sha256 of its root certificate (or says a new CA is created). `--plan-id` binds those steps, so a CA replaced or a domain added after the plan is E450. Any other certificate or trust change (a prod-class env, a non-local domain, an unnamed trust step) refuses `--plan-id` with E454 before applying anything; confirm at the prompt or use `--yes` without `--plan-id`, or provision certificates and host trust first. The human plan and apply output disclose these runtime effects, including on dev. Plugin installation and removal similarly refuse `--plan-id` (E453): the build shows and, interactively on prod-class envs, confirms the render again after those effects. Downloaded plugin bytes are checked against the registry checksum rather than bound by the plan id; edits during that install window are reflected in the second render. ## Flags diff --git a/.github/wiki/error-codes.md b/.github/wiki/error-codes.md index d6e3cf050..1bd0224d2 100644 --- a/.github/wiki/error-codes.md +++ b/.github/wiki/error-codes.md @@ -233,7 +233,7 @@ These errors come from the command line itself: usage, JSON support, destructive | E407 | 1 | Builtin family not mounted | This builtin command family is disabled or was removed from the local installation, so its commands are not mounted. | Run nself add to restore the builtin family. | | E410 | 1 | Command removed in v1.5 | This command was removed in nSelf v1.5 and has no replacement. | Read the message for what to use instead, or run nself --help to see the current commands. | | E420 | 1 | Invalid machine request | The request sent over MCP or HTTP does not match the command's parameters. | Send only the args, flags and confirm members the command's params schema lists, with the right types. | -| E421 | 1 | Command not exposed on machine surfaces | This command is not served over MCP or HTTP, or the secret values passed to a machine-invoked child are unusable (bad bundle, an unused or reused value); the reason is in the cause. | Run the command from a terminal, or call a command that is exposed. | +| E421 | 1 | Command not exposed on machine surfaces | This command is not served over MCP or HTTP, the secret values passed to a machine-invoked child are unusable (bad bundle, an unused or reused value), or a plugin was invoked while a secret bundle was restored; the reason is in the cause. | Run the command from a terminal, or call a command that is exposed. | | E422 | 2 | Command produced no single JSON document | The command ran but its standard output was not exactly one JSON document. | Run the same command in a terminal with --json and read its output; report the command if it prints text in JSON mode. | | E423 | 2 | Command invocation timed out | The command did not finish before the invocation timeout and was stopped. | Raise the server invocation timeout, or use a command form that returns sooner. | | E424 | 4 | Confirmation does not match | The confirm value is not a live server-issued nonce or plan id for this exact request: it was reused, expired, unknown, computed by the client, or issued for a different request. | Send the request again without confirm, read the confirmation it returns, and repeat the identical request with that confirm value. | @@ -264,7 +264,7 @@ Change-plan (E450) and operation-lock (E460) codes. The other codes in this bloc | E451 | 1 | Plan id cannot bind generated secrets | This build generates secrets, and the random values change the plan itself, so they cannot be reproduced from a plan id. | Set the secrets in .env.secrets first, or run nself build and confirm at the prompt (or with --yes, without --plan-id). | | E452 | 1 | Planned files were not written | After the build, a file the confirmed plan listed is missing, has other content, or has another mode. | Re-run nself build --plan to see what differs, then run nself build again; check that the project is writable. | | E453 | 1 | Plan id cannot bind plugin changes | The plan installs or removes plugins, and what those change is only known after they run, so a plan id cannot describe the final render. | Run nself build --yes without --plan-id (the render after the plugin changes is printed and held), or install or remove the plugins first. | -| E454 | 1 | Plan id cannot bind certificate or trust effects | Certificate bytes are generated at runtime and host trust state can change independently; artifact byte confirmation excludes these effects. | Run nself build and confirm at the prompt (or with --yes, without --plan-id), or provision certificates and host trust first. | +| E454 | 1 | Plan id cannot bind certificate or trust effects | The plan changes certificates or host trust outside local development (a prod-class env, a non-local domain or an unnamed trust change); a plan id binds only local development certificates and the mkcert CA named by its fingerprint. | Run nself build and confirm at the prompt (or with --yes, without --plan-id), or provision certificates and host trust first. | | E455 | 1 | Stack does not match nself.lock.yaml | nself start --frozen found a service image, an installed plugin version or checksum, or the CLI minor that differs from the project lock, or the project has no lock. | Run nself build to regenerate the stack from the lock, or accept the change by running nself build --write-lock and committing the new nself.lock.yaml. | | E456 | 1 | Update source unavailable | nself update --plan or --yes could not get an answer from the CLI release lookup or the plugin registry, so it cannot build a plan. | Retry in a few minutes. Nothing was changed. | | E457 | 1 | Update source rate limited | The CLI release lookup or the plugin registry answered nself update --plan or --yes with HTTP 429. | Retry in a few minutes. Nothing was changed. | diff --git a/cmd/commands/surface_identity_test.go b/cmd/commands/surface_identity_test.go index b8046cbde..bd4254fe4 100644 --- a/cmd/commands/surface_identity_test.go +++ b/cmd/commands/surface_identity_test.go @@ -559,13 +559,17 @@ func TestSurfaceIdentity(t *testing.T) { t.Fatal(err) } // docker and brew are stubs: no daemon, and no Homebrew index download from doctor. - for name, body := range map[string]string{"docker": dockerStub, "brew": "#!/bin/sh\nexit 1\n"} { + // mkcert is a failing stub and CAROOT a fixed empty dir: no surface creates a + // CA or touches the host trust store, so every surface plans the same named + // trust step whatever the host has installed (P7-SHIP-02). + for name, body := range map[string]string{"docker": dockerStub, "brew": "#!/bin/sh\nexit 1\n", "mkcert": "#!/bin/sh\nexit 1\n"} { if err := os.WriteFile(filepath.Join(stubs, name), []byte(body), 0o755); err != nil { t.Fatal(err) } } t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) + t.Setenv("CAROOT", filepath.Join(home, "mkcert-ca")) t.Setenv("PATH", stubs+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("NSELF_PLUGIN_DIR", filepath.Join(home, "plugins")) t.Setenv("NSELF_PLUGIN_CACHE", filepath.Join(home, "cache")) diff --git a/internal/build/effects.go b/internal/build/effects.go index 0647435cb..bd4bb269e 100644 --- a/internal/build/effects.go +++ b/internal/build/effects.go @@ -13,12 +13,11 @@ package build // Constraints: secrets-persist details carry key names only, never values. import ( - "os" "os/exec" "path/filepath" - "runtime" "strings" + "github.com/nself-org/cli/internal/build/trustplan" "github.com/nself-org/cli/internal/config" "github.com/nself-org/cli/internal/ssl" ) @@ -79,28 +78,10 @@ func (p *planEffects) Recorded() []PlannedEffect { return append([]PlannedEffect // caTrusted reports whether the mkcert CA is already in the OS trust store. It // is a seam so tests need no real trust store. Plan mode runs no mkcert, so the -// CA root is found the way mkcert finds it (CAROOT, else the platform data -// dir), then only read: a failure counts as "not trusted", so an unreadable -// state is still planned, never hidden. +// CA root is only read (trustplan.CARoot): a failure counts as "not trusted", so an +// unreadable state is still planned, never hidden. var caTrusted = func() bool { - root := os.Getenv("CAROOT") - if root == "" { - switch runtime.GOOS { - case "darwin": - home, _ := os.UserHomeDir() - root = filepath.Join(home, "Library", "Application Support", "mkcert") - case "windows": - root = filepath.Join(os.Getenv("LOCALAPPDATA"), "mkcert") - default: - if x := os.Getenv("XDG_DATA_HOME"); x != "" { - root = filepath.Join(x, "mkcert") - } else { - home, _ := os.UserHomeDir() - root = filepath.Join(home, ".local", "share", "mkcert") - } - } - } - ok, err := ssl.IsCAInstalled(filepath.Join(root, "rootCA.pem")) + ok, err := ssl.IsCAInstalled(filepath.Join(trustplan.CARoot(), "rootCA.pem")) return err == nil && ok } @@ -122,14 +103,14 @@ func planSSL(fx Effects, cfg *config.Config, sslDir string, explicitHosts bool) } certDir := filepath.Join(sslDir, "certificates", dirName) if !ssl.CertPairValid(certDir) { - detail := "generate a certificate for " + strings.Join(domains, ", ") + detail := trustplan.CertGenerate + strings.Join(domains, ", ") if ssl.CertPairValid(sslDir) { - detail = "copy the certificate from `nself trust` for " + strings.Join(domains, ", ") + detail = trustplan.CertCopy + strings.Join(domains, ", ") } _ = fx.Do(EffectCertificates, certDir, detail, nil) } if _, err := exec.LookPath("mkcert"); err == nil && !caTrusted() { - _ = fx.Do(EffectTrustStore, "mkcert CA", "install the mkcert CA when it is not yet trusted", nil) + _ = fx.Do(EffectTrustStore, trustplan.Target, trustplan.TrustDetail(trustplan.CARoot()), nil) } if entries := ssl.PlanHostsEntries(cfg.Env, cfg.BaseDomain, explicitHosts, domains); len(entries) > 0 { _ = fx.Do(EffectHosts, "/etc/hosts", "ensure entries: "+strings.Join(entries, ", "), nil) diff --git a/internal/build/trustplan/trustplan.go b/internal/build/trustplan/trustplan.go new file mode 100644 index 000000000..c4a1f559b --- /dev/null +++ b/internal/build/trustplan/trustplan.go @@ -0,0 +1,136 @@ +// Package trustplan holds the exact text of the certificates and trust-store +// plan steps, and the local-development scope a plan id may bind (P7-SHIP-02). +// +// Layer: L2 (internal/build/**); stdlib only, imported by internal/build and +// internal/reconcile. +package trustplan + +// trustplan.go — the exact text of the certificates and trust-store plan +// steps, and the local-development scope a plan id may bind (P7-SHIP-02). +// +// Purpose: a plan names what a write build will do outside the project tree. +// For certificates it lists every domain the certificate will cover; for the +// mkcert CA it names the CA by the sha256 of its root certificate, or says a +// new CA will be created. Those words are part of the plan, so the plan id +// binds them: a CA replaced or a domain added after the plan changes the id +// (E450). LocalDevStep says whether a step is one a plan id may bind at all. +// Inputs: the plan-mode SSL step (internal/build effects.go); reconcile's apply gate. +// Outputs: details, and the local-development scope test. +// Constraints: plan mode only reads the CA root (never runs mkcert). Every +// detail this file does not write, every domain that is not a local +// development name and every other target is outside the scope, so the apply +// keeps refusing it (E454). + +import ( + "crypto/sha256" + "encoding/hex" + "net" + "os" + "path/filepath" + "runtime" + "strings" +) + +// Target is the target of the trust-store step. +const Target = "mkcert CA" + +// Effect kinds (build.EffectCertificates, build.EffectTrustStore). +const ( + certificates = "certificates" + trustStore = "trust-store" +) + +// Details of the certificates and trust-store steps. +const ( + // CertGenerate and CertCopy start a certificates detail; the domains follow, ", "-joined. + CertGenerate = "generate a certificate for " + CertCopy = "copy the certificate from `nself trust` for " + trustDetailInstall = "install the mkcert CA sha256:" + trustDetailSuffix = " into the system trust store" + trustDetailNew = "create a new mkcert CA and install it into the system trust store" +) + +// CARoot is the mkcert CA root directory, found the way mkcert finds it: +// CAROOT, else the platform data dir. +func CARoot() string { + if root := os.Getenv("CAROOT"); root != "" { + return root + } + switch runtime.GOOS { + case "darwin": + home, _ := os.UserHomeDir() + return filepath.Join(home, "Library", "Application Support", "mkcert") + case "windows": + return filepath.Join(os.Getenv("LOCALAPPDATA"), "mkcert") + } + if x := os.Getenv("XDG_DATA_HOME"); x != "" { + return filepath.Join(x, "mkcert") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "share", "mkcert") +} + +// TrustDetail names the CA the write build will install: the sha256 of the +// existing root certificate, or a new CA when there is none yet. +func TrustDetail(root string) string { + b, err := os.ReadFile(filepath.Join(root, "rootCA.pem")) + if err != nil { + return trustDetailNew + } + sum := sha256.Sum256(b) + return trustDetailInstall + hex.EncodeToString(sum[:]) + trustDetailSuffix +} + +// LocalDevStep reports whether a certificates or trust-store step is a local +// development step a plan id may bind: a certificate under projectDir's +// ssl/certificates for local development names only, or the mkcert CA +// install named by fingerprint (or as a new CA). The env class is the +// caller's to check. +func LocalDevStep(kind, target, detail, projectDir string) bool { + switch kind { + case certificates: + rel, err := filepath.Rel(filepath.Join(projectDir, "ssl", "certificates"), target) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") || strings.ContainsRune(rel, filepath.Separator) { + return false + } + list := "" + for _, p := range []string{CertGenerate, CertCopy} { + if strings.HasPrefix(detail, p) { + list = strings.TrimPrefix(detail, p) + } + } + if list == "" { + return false + } + for _, d := range strings.Split(list, ", ") { + if !localDevName(d) { + return false + } + } + return true + case trustStore: + if target != Target { + return false + } + if detail == trustDetailNew { + return true + } + fp, ok := strings.CutPrefix(detail, trustDetailInstall) + fp, ok2 := strings.CutSuffix(fp, trustDetailSuffix) + _, err := hex.DecodeString(fp) + return ok && ok2 && len(fp) == 64 && err == nil && fp == strings.ToLower(fp) + } + return false +} + +// localDevName reports whether a certificate name is local development only: +// a loopback address, localhost, local.nself.org, or a name under +// .local.nself.org, .localhost or .local (wildcards included). +func localDevName(d string) bool { + d = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(d), "*.")) + if ip := net.ParseIP(d); ip != nil { + return ip.IsLoopback() + } + return d == "localhost" || d == "local.nself.org" || strings.HasSuffix(d, ".local.nself.org") || + strings.HasSuffix(d, ".localhost") || strings.HasSuffix(d, ".local") +} diff --git a/internal/build/trustplan/trustplan_test.go b/internal/build/trustplan/trustplan_test.go new file mode 100644 index 000000000..734a1c732 --- /dev/null +++ b/internal/build/trustplan/trustplan_test.go @@ -0,0 +1,49 @@ +package trustplan + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestLocalDevStep: only local development certificates under the project and +// the mkcert CA named by fingerprint (or as a new CA) are bindable steps. +func TestLocalDevStep(t *testing.T) { + proj := filepath.Join(t.TempDir(), "proj") + certs := filepath.Join(proj, "ssl", "certificates", "local-nself-org") + local := CertGenerate + "localhost, 127.0.0.1, ::1, local.nself.org, *.local.nself.org, api.local.nself.org" + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "rootCA.pem"), []byte("ROOT CA\n"), 0o600); err != nil { + t.Fatal(err) + } + named := TrustDetail(root) + if !strings.HasPrefix(named, trustDetailInstall) || named == trustDetailNew { + t.Fatalf("an existing CA is not named by fingerprint: %q", named) + } + if got := TrustDetail(t.TempDir()); got != trustDetailNew { + t.Fatalf("a missing CA: %q", got) + } + for _, tc := range []struct { + name, kind, target, detail string + want bool + }{ + {"local certificate", certificates, certs, local, true}, + {"copied local certificate", certificates, certs, CertCopy + "localhost, app.localhost", true}, + {"public domain", certificates, certs, local + ", example.com", false}, + {"non-loopback address", certificates, certs, CertGenerate + "localhost, 10.0.0.5", false}, + {"outside the project", certificates, filepath.Join(t.TempDir(), "ssl", "certificates", "x"), local, false}, + {"nested target", certificates, filepath.Join(certs, "deeper"), local, false}, + {"unknown detail", certificates, certs, "issue something for localhost", false}, + {"named CA", trustStore, Target, named, true}, + {"new CA", trustStore, Target, trustDetailNew, true}, + {"unnamed CA", trustStore, Target, "install the mkcert CA when it is not yet trusted", false}, + {"short fingerprint", trustStore, Target, trustDetailInstall + "abcd" + trustDetailSuffix, false}, + {"other trust target", trustStore, "corporate CA", named, false}, + {"hosts", "hosts", "/etc/hosts", "ensure entries: localhost", false}, + } { + if got := LocalDevStep(tc.kind, tc.target, tc.detail, proj); got != tc.want { + t.Errorf("%s: LocalDevStep = %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/internal/errs/codes_reconcile.go b/internal/errs/codes_reconcile.go index 60aea3485..1bc31886b 100644 --- a/internal/errs/codes_reconcile.go +++ b/internal/errs/codes_reconcile.go @@ -45,7 +45,7 @@ func init() { Code: "E454", Category: "reconcile", Summary: "Plan id cannot bind certificate or trust effects", - DefaultWhy: "Certificate bytes are generated at runtime and host trust state can change independently; artifact byte confirmation excludes these effects.", + DefaultWhy: "The plan changes certificates or host trust outside local development (a prod-class env, a non-local domain or an unnamed trust change); a plan id binds only local development certificates and the mkcert CA named by its fingerprint.", DefaultFix: "Run nself build and confirm at the prompt (or with --yes, without --plan-id), or provision certificates and host trust first.", DocsPath: "reference/error-codes#e454", Exit: 1, diff --git a/internal/invoke/gate.go b/internal/invoke/gate.go index 8af82beac..dd0a20667 100644 --- a/internal/invoke/gate.go +++ b/internal/invoke/gate.go @@ -12,7 +12,7 @@ package invoke // runs, with no consent flags. A plan form without confirm is executed once // (--); the child's data.plan_id and data.requires_confirmation // decide between a kind plan confirmation (confirm = plan_id) and one run with -// --=; a plan that declares generated secrets is always a +// --=; a plan that declares secrets, certificates or trust is always a // confirmation (plan_facts.go). remote and destructive without a plan form get a // kind request confirmation backed by a Guard nonce. A nil Guard refuses // remote and destructive commands (E421, fail closed). diff --git a/internal/invoke/plan_facts.go b/internal/invoke/plan_facts.go index 99b21becc..b4230c027 100644 --- a/internal/invoke/plan_facts.go +++ b/internal/invoke/plan_facts.go @@ -6,24 +6,27 @@ package invoke // requires_confirmation, whether the plan generates secrets, the raw data), or // whether the child failed, or a short problem. // Constraints: the plan is data from the child; a missing or malformed member -// is a problem, never a default. A plan that declares the secrets-persist -// effect generates random values its plan id binds only by their inputs -// (reconcile binding.go), so the gate always shows it to the caller (P7-SHIP-02). +// is a problem, never a default. A plan that declares a secrets-persist, +// certificates or trust-store step changes something its plan id binds only +// by name (generated secrets, certificate bytes, the host trust store: +// reconcile binding.go and trust_binding.go), so the gate always shows it to +// the caller for consent (P7-SHIP-02). import ( "bytes" "encoding/json" ) -// effectSecretsPersist is the change-plan effect kind of a render that -// generates and persists secrets (reconcile.EffectSecretsPersist). -const effectSecretsPersist = "secrets-persist" +// consentEffects are the change-plan effect kinds that always need the +// caller's confirmation (reconcile.EffectSecretsPersist, EffectCertificates, +// EffectTrustStore). +var consentEffects = map[string]bool{"secrets-persist": true, "certificates": true, "trust-store": true} // planFacts is what the gate reads from the child's plan document. type planFacts struct { id string requires bool - secrets bool // the plan declares the secrets-persist step + secrets bool // the plan declares a consentEffects step data json.RawMessage } @@ -64,7 +67,7 @@ func parsePlan(stdout []byte) (f planFacts, childErr bool, problem string) { } secrets := false for _, e := range data.Effects { - secrets = secrets || e.Kind == effectSecretsPersist + secrets = secrets || consentEffects[e.Kind] } return planFacts{id: *data.PlanID, requires: *data.Requires, secrets: secrets, data: doc.Data}, false, "" } diff --git a/internal/invoke/plan_facts_test.go b/internal/invoke/plan_facts_test.go index d24023fd2..a4803d791 100644 --- a/internal/invoke/plan_facts_test.go +++ b/internal/invoke/plan_facts_test.go @@ -32,6 +32,8 @@ func TestParsePlanSecrets(t *testing.T) { {`[]`, false}, {`[{"kind":"hosts"}]`, false}, {`[{"kind":"hosts"},{"kind":"secrets-persist"}]`, true}, + {`[{"kind":"trust-store"}]`, true}, + {`[{"kind":"certificates"}]`, true}, } { doc := `{"data":{"plan_id":"` + id + `","requires_confirmation":false,"effects":` + tc.effects + `}}` f, childErr, problem := parsePlan([]byte(doc)) diff --git a/internal/reconcile/apply.go b/internal/reconcile/apply.go index 5e3a023e2..481572c95 100644 --- a/internal/reconcile/apply.go +++ b/internal/reconcile/apply.go @@ -114,8 +114,8 @@ func ApplyBuild(ctx context.Context, req Request, opt ApplyOptions) (applied *Pl WithWhy("what a plugin brings is only known after it runs, so the id cannot describe the render that is written"). WithFix("run nself build --yes without --plan-id (the render after the plugin changes is printed and held), or install or remove the plugins first") } - if certificateEffects(p) { - return nil, nil, errs.New("E454", "this plan changes certificates or trust state, so --plan-id cannot bind it") + if !bindableTrust(p, req.ProjectDir) { // trust_binding.go: local dev steps only + return nil, nil, errs.New("E454", "this plan changes certificates or trust state outside local development, so --plan-id cannot bind it") } if opt.PlanID != p.PlanID { return nil, nil, errs.New("E450", "the plan id does not match what this command would change now"). diff --git a/internal/reconcile/render.go b/internal/reconcile/render.go index 15989203b..6bb9afa36 100644 --- a/internal/reconcile/render.go +++ b/internal/reconcile/render.go @@ -53,7 +53,7 @@ func RenderHuman(w io.Writer, p Plan) error { } } if certificateEffects(&p) { - sb.WriteString("Notice: certificate bytes and trust state are runtime effects outside artifact byte confirmation; --plan-id cannot bind this build.\n") + sb.WriteString("Notice: certificate bytes and trust state are runtime effects outside artifact byte confirmation; --plan-id binds them only as the local development steps named above.\n") } if !p.Empty { renderContainers(&sb, p.Containers) diff --git a/internal/reconcile/trust_binding.go b/internal/reconcile/trust_binding.go new file mode 100644 index 000000000..e90f7749a --- /dev/null +++ b/internal/reconcile/trust_binding.go @@ -0,0 +1,35 @@ +package reconcile + +// trust_binding.go — which certificates and trust-store steps a plan id may +// bind (P7-SHIP-02; E454 keeps refusing the rest). +// +// Purpose: a first local build issues a development certificate and installs +// the mkcert CA. Both are named in the plan (domains, CA fingerprint), so the +// plan id binds what the user consented to: a CA replaced or a domain added +// after the plan changes the id (E450). Everything else stays refused. +// Inputs: the computed plan and the project dir. Outputs: true when every +// certificates and trust-store effect is a local development step. +// Constraints: a prod-class env is never bindable; the step test itself is +// trustplan.LocalDevStep, next to the code that writes the details. + +import "github.com/nself-org/cli/internal/build/trustplan" + +// bindableTrust reports whether a plan id may bind p's certificate and trust +// effects: none, or all local development steps on a dev-class env. +func bindableTrust(p *Plan, projectDir string) bool { + if !certificateEffects(p) { + return true + } + if EnvClass(p.Env) != ClassDev { + return false + } + for _, e := range p.Effects { + if e.Kind != EffectCertificates && e.Kind != EffectTrustStore { + continue + } + if !trustplan.LocalDevStep(string(e.Kind), e.Target, e.Detail, projectDir) { + return false + } + } + return true +} diff --git a/internal/reconcile/trust_binding_test.go b/internal/reconcile/trust_binding_test.go new file mode 100644 index 000000000..80b485db7 --- /dev/null +++ b/internal/reconcile/trust_binding_test.go @@ -0,0 +1,89 @@ +package reconcile + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/nself-org/cli/internal/errs" +) + +// localTrustSteps are the certificates and trust-store steps of a first local +// build, as plan mode records them; fp names the mkcert CA. +func localTrustSteps(project, fp string) []Effect { + return []Effect{ + {Kind: EffectCertificates, Target: filepath.Join(project, "ssl", "certificates", "local-nself-org"), + Detail: "generate a certificate for localhost, 127.0.0.1, ::1, local.nself.org, *.local.nself.org"}, + {Kind: EffectTrustStore, Target: "mkcert CA", + Detail: "install the mkcert CA sha256:" + strings.Repeat(fp, 64) + " into the system trust store"}, + } +} + +// TestPlanIDBindsLocalTrustSteps (P7-SHIP-02): on a dev env a plan id binds +// the local certificate and the named mkcert CA install; the apply passes the +// E454 gate and reaches the write. +func TestPlanIDBindsLocalTrustSteps(t *testing.T) { + f := loadFixture(t, "dev-minimal") + req := Request{Runtime: &fakeRuntime{}, ProjectDir: f.project, Seed: []byte("fixture-seed"), Extra: localTrustSteps(f.project, "a")} + p, err := Compute(context.Background(), req) + if err != nil { + t.Fatal(err) + } + reached := errors.New("write reached") + _, err = Apply(context.Background(), req, ApplyOptions{PlanID: p.PlanID, Yes: true, BeforeWrite: func() error { return reached }}) + if !errors.Is(err, reached) { + t.Fatalf("the bound local trust steps must reach the write, got %v", err) + } +} + +// TestPlanIDRefusesChangedTrust: a trust step that differs from the plan (the +// CA was replaced after the plan) is refused (E450) and nothing runs. +func TestPlanIDRefusesChangedTrust(t *testing.T) { + f := loadFixture(t, "dev-minimal") + req := Request{Runtime: &fakeRuntime{}, ProjectDir: f.project, Seed: []byte("fixture-seed"), Extra: localTrustSteps(f.project, "a")} + p, err := Compute(context.Background(), req) + if err != nil { + t.Fatal(err) + } + req.Extra = localTrustSteps(f.project, "b") + before := treeHash(t, f.root) + started := false + _, err = Apply(context.Background(), req, ApplyOptions{PlanID: p.PlanID, Yes: true, BeforeWrite: func() error { started = true; return nil }}) + if d := errs.Describe(err); d == nil || d.Code != "E450" { + t.Fatalf("want E450 for a changed trust step, got %v", err) + } + if started || treeHash(t, f.root) != before { + t.Fatal("the refused apply ran effects or wrote project files") + } +} + +// TestPlanIDRefusesTrustOutsideLocalDev: the same steps on a prod-class env, +// a certificate for a public domain, or an unnamed CA install stay E454. +func TestPlanIDRefusesTrustOutsideLocalDev(t *testing.T) { + for name, tc := range map[string]struct { + fixture string + mut func(project string, e []Effect) + }{ + "prod env": {"prod-ssl", func(string, []Effect) {}}, + "public domain": {"dev-minimal", func(_ string, e []Effect) { e[0].Detail += ", example.com" }}, + "unnamed CA": {"dev-minimal", func(_ string, e []Effect) { e[1].Detail = "install the mkcert CA when it is not yet trusted" }}, + } { + t.Run(name, func(t *testing.T) { + f := loadFixture(t, tc.fixture) + steps := localTrustSteps(f.project, "a") + tc.mut(f.project, steps) + req := Request{Runtime: &fakeRuntime{}, ProjectDir: f.project, Seed: []byte("fixture-seed"), Extra: steps} + p, err := Compute(context.Background(), req) + if err != nil { + t.Fatal(err) + } + started := false + _, err = Apply(context.Background(), req, ApplyOptions{PlanID: p.PlanID, Yes: true, BeforeWrite: func() error { started = true; return nil }}) + if d := errs.Describe(err); d == nil || d.Code != "E454" || started { + t.Fatalf("want E454 before any write, got %v (write started %v)", err, started) + } + }) + } +}