cli, console: flag-gated declare-lost and readmit paths that refuse until Plan 8b (claim Plan 8 Task 4)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+356
−30
cmd/console/hostclaim_flag.go+7 −0cmd/console/hostclaim_flag_test.go+16 −0cmd/console/main.go+1 −0hostclaim_cli.go+111 −0hostclaim_cli_test.go+32 −0internal/console/apihostclaim.go+37 −0internal/console/apihostclaim_test.go+86 −0internal/console/authcli.go+7 −0internal/console/server.go+2 −0internal/console/svcroutes_test.go+56 −28main.go+1 −2
CI passed — run details
diff --git a/cmd/console/hostclaim_flag.go b/cmd/console/hostclaim_flag.go| new file mode 100644 |
| index 000000000..ca1379713 |
| --- /dev/null |
| +++ b/cmd/console/hostclaim_flag.go |
| @@ -0,0 +1,7 @@ |
| +package main |
| + |
| +import "strings" |
| + |
| +// hostClaimSwitch follows the fleet's feature-flag convention: any nonempty |
| +// trimmed value enables the flag, including "0". |
| +func hostClaimSwitch(raw string) bool { return strings.TrimSpace(raw) != "" } |
diff --git a/cmd/console/hostclaim_flag_test.go b/cmd/console/hostclaim_flag_test.go| new file mode 100644 |
| index 000000000..daae42b40 |
| --- /dev/null |
| +++ b/cmd/console/hostclaim_flag_test.go |
| @@ -0,0 +1,16 @@ |
| +package main |
| + |
| +import "testing" |
| + |
| +func TestHostClaimSwitchFleetConvention(t *testing.T) { |
| + for _, raw := range []string{"1", "0", "on", " true "} { |
| + if !hostClaimSwitch(raw) { |
| + t.Errorf("hostClaimSwitch(%q) = false", raw) |
| + } |
| + } |
| + for _, raw := range []string{"", " ", "\t\n"} { |
| + if hostClaimSwitch(raw) { |
| + t.Errorf("hostClaimSwitch(%q) = true", raw) |
| + } |
| + } |
| +} |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index e2ee59ee9..1c876def0 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -292,6 +292,7 @@ func serve() { |
| Region: placement, |
| ReleasePub: pub, |
| Key: key, |
| + HostClaim: hostClaimSwitch(os.Getenv("CARLOS_HOST_CLAIM")), |
| EdgeIPs: edgeIPs(), |
| SecretsPub: secretsPub(), |
| AppsDomain: appsDomain(), |
diff --git a/hostclaim_cli.go b/hostclaim_cli.go| new file mode 100644 |
| index 000000000..fce0098a8 |
| --- /dev/null |
| +++ b/hostclaim_cli.go |
| @@ -0,0 +1,111 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "net/url" |
| + "os" |
| + "strings" |
| + |
| + boxops "github.com/carlosframework/platform/internal/ops" |
| +) |
| + |
| +const hostClaimPendingRuling = "awaiting Paul's ruling P2/P3 (amadan #236)" |
| + |
| +func hostClaimEnabled(raw string) bool { return strings.TrimSpace(raw) != "" } |
| + |
| +type hostResolveOptions struct { |
| + Host string |
| + Action string |
| + Box string |
| + Yes bool |
| + Console string |
| +} |
| + |
| +// runOps leaves the existing box-side ops surface byte-for-byte alone while |
| +// the claim feature is off. The remote host family only exists behind the |
| +// explicit deployment flag. |
| +func runOps(args []string) { |
| + if hostClaimEnabled(os.Getenv("CARLOS_HOST_CLAIM")) && len(args) > 0 && args[0] == "host" { |
| + runOpsHost(args[1:]) |
| + return |
| + } |
| + boxops.Run(args) |
| +} |
| + |
| +func parseHostResolve(args []string) (hostResolveOptions, error) { |
| + var o hostResolveOptions |
| + if len(args) == 0 || args[0] != "resolve" { |
| + return o, errors.New("usage: carlos ops host resolve <host> --declare-lost <box> [--yes] [--console <url>]") |
| + } |
| + args = args[1:] |
| + for i := 0; i < len(args); i++ { |
| + a := args[i] |
| + next := func() (string, error) { |
| + if i+1 >= len(args) { |
| + return "", fmt.Errorf("%s needs a value", a) |
| + } |
| + i++ |
| + return args[i], nil |
| + } |
| + switch { |
| + case a == "--yes": |
| + o.Yes = true |
| + case a == "--declare-lost" || a == "--readmit": |
| + if o.Action != "" { |
| + return o, errors.New("--declare-lost and --readmit are mutually exclusive") |
| + } |
| + v, err := next() |
| + if err != nil { |
| + return o, err |
| + } |
| + o.Action, o.Box = strings.TrimPrefix(a, "--"), v |
| + case a == "--console": |
| + v, err := next() |
| + if err != nil { |
| + return o, err |
| + } |
| + o.Console = v |
| + case strings.HasPrefix(a, "--console="): |
| + o.Console = strings.TrimPrefix(a, "--console=") |
| + case strings.HasPrefix(a, "-"): |
| + return o, fmt.Errorf("unknown option %q", a) |
| + case o.Host == "": |
| + o.Host = a |
| + default: |
| + return o, fmt.Errorf("unexpected argument %q", a) |
| + } |
| + } |
| + if o.Host == "" || o.Action == "" || o.Box == "" { |
| + return o, errors.New("resolve needs a host and exactly one of --declare-lost <box> or --readmit <box>") |
| + } |
| + return o, nil |
| +} |
| + |
| +func runOpsHost(args []string) { |
| + o, err := parseHostResolve(args) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + sess := requireDeployAPISession(o.Console) |
| + if err := sess.inspectHostResolve(context.Background(), o); err != nil { |
| + log.Fatal(err) |
| + } |
| + // the follow-on integration replaces this impossible branch when ActorDirectory exists. |
| + log.Fatal(hostClaimPendingRuling) |
| +} |
| + |
| +func (s *apiSession) inspectHostResolve(ctx context.Context, o hostResolveOptions) error { |
| + status, body, err := s.postJSON(ctx, "/api/cli/ops/hosts/"+url.PathEscape(o.Host)+"/resolve/inspect", map[string]any{ |
| + "action": o.Action, "box": o.Box, "confirmed": o.Yes, |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + if status != 200 { |
| + return apiErrorFromBody(status, body) |
| + } |
| + return nil |
| +} |
diff --git a/hostclaim_cli_test.go b/hostclaim_cli_test.go| new file mode 100644 |
| index 000000000..0e86834a9 |
| --- /dev/null |
| +++ b/hostclaim_cli_test.go |
| @@ -0,0 +1,32 @@ |
| +package main |
| + |
| +import "testing" |
| + |
| +func TestHostClaimFlagUsesTheFleetConvention(t *testing.T) { |
| + for _, raw := range []string{"1", "0", "on", " true "} { |
| + if !hostClaimEnabled(raw) { |
| + t.Errorf("hostClaimEnabled(%q) = false", raw) |
| + } |
| + } |
| + for _, raw := range []string{"", " ", "\t\n"} { |
| + if hostClaimEnabled(raw) { |
| + t.Errorf("hostClaimEnabled(%q) = true", raw) |
| + } |
| + } |
| +} |
| + |
| +func TestParseHostResolveAcceptsDocumentedInterspersedShape(t *testing.T) { |
| + o, err := parseHostResolve([]string{"resolve", "app.example.com", "--declare-lost", "box-1", "--yes", "--console", "https://console.example"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if o.Host != "app.example.com" || o.Action != "declare-lost" || o.Box != "box-1" || !o.Yes || o.Console != "https://console.example" { |
| + t.Fatalf("options = %+v", o) |
| + } |
| +} |
| + |
| +func TestParseHostResolveRefusesAmbiguousAction(t *testing.T) { |
| + if _, err := parseHostResolve([]string{"resolve", "app.example.com", "--declare-lost", "box-1", "--readmit", "box-1"}); err == nil { |
| + t.Fatal("accepted both actions") |
| + } |
| +} |
diff --git a/internal/console/apihostclaim.go b/internal/console/apihostclaim.go| new file mode 100644 |
| index 000000000..399f10348 |
| --- /dev/null |
| +++ b/internal/console/apihostclaim.go |
| @@ -0,0 +1,37 @@ |
| +package console |
| + |
| +import ( |
| + "log" |
| + "net/http" |
| +) |
| + |
| +const hostClaimPendingRuling = "awaiting Paul's ruling P2/P3 (amadan #236)" |
| + |
| +// hostClaimRefusal applies the D2 authority boundary before reporting that |
| +// the follow-on integration's ActorDirectory is unavailable. It deliberately performs no claim, |
| +// directory or cloud write. |
| +func (s *Server) hostClaimRefusal(w http.ResponseWriter, r *http.Request) { |
| + addr, ok := s.cliUser(w, r) |
| + if !ok { |
| + return |
| + } |
| + isOp, err := s.store.IsOperator(addr) |
| + if err != nil { |
| + log.Printf("console: host claim operator lookup: %v", err) |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !isOp || !s.cfg.HostClaim { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + jsonError(w, http.StatusServiceUnavailable, hostClaimPendingRuling) |
| +} |
| + |
| +func (s *Server) handleAPIHostResolveInspect(w http.ResponseWriter, r *http.Request) { |
| + s.hostClaimRefusal(w, r) |
| +} |
| + |
| +func (s *Server) handleAPIHostResolve(w http.ResponseWriter, r *http.Request) { |
| + s.hostClaimRefusal(w, r) |
| +} |
diff --git a/internal/console/apihostclaim_test.go b/internal/console/apihostclaim_test.go| new file mode 100644 |
| index 000000000..a5efcac67 |
| --- /dev/null |
| +++ b/internal/console/apihostclaim_test.go |
| @@ -0,0 +1,86 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestHostClaimRoutesArePersonOperatorFlagGatedAndInert(t *testing.T) { |
| + st := openTestStore(t) |
| + rel := release.NewDirStore(t.TempDir()) |
| + disabled, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", Releases: rel}, st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + path := "/api/cli/ops/hosts/app.example.com/resolve/inspect" |
| + ghostStatus, ghostBody := serveHostClaim(t, disabled, "/unknown-host-claim-route", "") |
| + if status, body := serveHostClaim(t, disabled, path, ""); status != ghostStatus || !bytes.Equal(body, ghostBody) { |
| + t.Fatalf("flag-off route = %d %q, want unknown route %d %q", status, body, ghostStatus, ghostBody) |
| + } |
| + |
| + token := mintHostClaimToken(t, st, "paul@keymail.dev") |
| + enabled, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", Releases: rel, HostClaim: true}, st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, _ := serveHostClaim(t, enabled, path, ""); status != http.StatusUnauthorized { |
| + t.Fatalf("unsigned enabled status = %d", status) |
| + } |
| + if status, _ := serveHostClaim(t, enabled, path, token); status != http.StatusNotFound { |
| + t.Fatalf("enabled member status = %d", status) |
| + } |
| + if err := st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, body := serveHostClaim(t, enabled, path, token) |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("flag-on status = %d, body %s", status, body) |
| + } |
| + var out map[string]string |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + t.Fatal(err) |
| + } |
| + if out["error"] != hostClaimPendingRuling { |
| + t.Fatalf("refusal = %q", out["error"]) |
| + } |
| + |
| + status, body = serveHostClaim(t, enabled, "/api/cli/ops/hosts/app.example.com/resolve", token) |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("mutation route = %d, body %s", status, body) |
| + } |
| +} |
| + |
| +func mintHostClaimToken(t *testing.T, st *Store, address string) string { |
| + t.Helper() |
| + now := time.Now() |
| + userCode, deviceCode, err := st.NewCLIAuth("test@host", "192.0.2.1", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.ApproveCLIAuth(userCode, address, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, token, _, err := st.PollCLIAuth(deviceCode, now) |
| + if err != nil || status != CLIAuthApproved || token == "" { |
| + t.Fatalf("mint token = %q %v", status, err) |
| + } |
| + return token |
| +} |
| + |
| +func serveHostClaim(t *testing.T, srv *Server, path, bearer string) (int, []byte) { |
| + t.Helper() |
| + req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{}`)) |
| + req.Header.Set("Content-Type", "application/json") |
| + if bearer != "" { |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + rec := httptest.NewRecorder() |
| + srv.ServeHTTP(rec, req) |
| + return rec.Code, rec.Body.Bytes() |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 2f35a07dc..f3a335532 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -481,6 +481,13 @@ func (s *Server) registerCLIRoutes() { |
| // handler, since cliRoute's role set can only narrow SERVICE principals |
| // and a person always passes through it. |
| s.cliRoute("GET /api/cli/status/instances", svcrole.Set{}, s.handleAPIStatusInstances) |
| + if s.cfg.HostClaim { |
| + // Host loss inspection and submission are person-only and repeat the |
| + // fleet-operator check in their handlers. Until the follow-on integration supplies the |
| + // ActorDirectory both routes stop at the same inert refusal. |
| + s.cliRoute("POST /api/cli/ops/hosts/{host}/resolve/inspect", svcrole.Set{}, s.handleAPIHostResolveInspect) |
| + s.cliRoute("POST /api/cli/ops/hosts/{host}/resolve", svcrole.Set{}, s.handleAPIHostResolve) |
| + } |
| // The literal `new` mirrors the browser door (/a/{sqid}/apps/new); no |
| // bare POST .../{app} route exists to collide with, and the mux prefers |
| // the literal if one ever does. |
diff --git a/internal/console/server.go b/internal/console/server.go| index 01c315e6e..3a7ec0d35 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -508,6 +508,8 @@ type Config struct { |
| // nil is a legal deployment: the console runs read-only, the promotion |
| // UI renders disabled, and the mutation handlers refuse with 503. |
| Key ed25519.PrivateKey |
| + // HostClaim is CARLOS_HOST_CLAIM captured once at console boot. |
| + HostClaim bool |
| // AppClient fetches app-internal instance reports (INSTANCES.md); nil |
| // uses a default client with the contract's short timeout. |
| AppClient *http.Client |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index add8583c4..e030a0cf4 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -209,36 +209,64 @@ var closedDoors = []string{ |
| // is unreachable by every service principal. Both are failures worth catching |
| // at build time rather than in production. |
| func TestEveryCLIDoorNamesItsRoles(t *testing.T) { |
| - s := newConsoleFixture(t).srv |
| - |
| - var empty []string |
| - for pattern, set := range s.cliRoles { |
| - if set.Empty() { |
| - empty = append(empty, pattern) |
| - } |
| - } |
| - sort.Strings(empty) |
| - wantClosed := append([]string(nil), closedDoors...) |
| - sort.Strings(wantClosed) |
| - if !reflect.DeepEqual(empty, wantClosed) { |
| - t.Errorf("doors registered with an empty role set:\n got %q\nwant %q\n"+ |
| - "An unexpected empty Set is a door nobody can reach; a missing one is a door that should be shut and is not.", |
| - empty, wantClosed) |
| + hostClaimDoors := []string{ |
| + "POST /api/cli/ops/hosts/{host}/resolve/inspect", |
| + "POST /api/cli/ops/hosts/{host}/resolve", |
| } |
| - |
| - // scanCLISource fails on its own for a door registered outside |
| - // cliRoute; this pairs what it DID find against what the running server |
| - // recorded. The equality is what makes a walker that silently found |
| - // nothing fail too — no magic minimum needed. |
| registered := scanCLISource(t).gated |
| - for _, pattern := range registered { |
| - if _, ok := s.cliRoles[pattern]; !ok { |
| - t.Errorf("%s: the source registers it, the server did not record it", pattern) |
| - } |
| - } |
| - if len(s.cliRoles) != len(registered) { |
| - t.Errorf("cliRoles holds %d doors, the source registers %d — the two must be the same set", |
| - len(s.cliRoles), len(registered)) |
| + for _, tc := range []struct { |
| + name string |
| + enabled bool |
| + }{{"flag-off", false}, {"flag-on", true}} { |
| + t.Run(tc.name, func(t *testing.T) { |
| + st := openTestStore(t) |
| + s, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", HostClaim: tc.enabled}, st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var empty []string |
| + for pattern, set := range s.cliRoles { |
| + if set.Empty() { |
| + empty = append(empty, pattern) |
| + } |
| + } |
| + wantClosed := append([]string(nil), closedDoors...) |
| + if tc.enabled { |
| + wantClosed = append(wantClosed, hostClaimDoors...) |
| + } |
| + sort.Strings(empty) |
| + sort.Strings(wantClosed) |
| + if !reflect.DeepEqual(empty, wantClosed) { |
| + t.Errorf("doors registered with an empty role set: got %q, want %q", empty, wantClosed) |
| + } |
| + |
| + // Only these named routes may be absent with the flag off. All |
| + // source registrations remain subject to the default-deny scan. |
| + wantRegistered := make(map[string]bool) |
| + for _, pattern := range registered { |
| + wantRegistered[pattern] = true |
| + } |
| + for _, pattern := range hostClaimDoors { |
| + if !wantRegistered[pattern] { |
| + t.Errorf("host claim route missing from source: %s", pattern) |
| + } |
| + if !tc.enabled { |
| + delete(wantRegistered, pattern) |
| + } |
| + set, present := s.cliRoles[pattern] |
| + if present != tc.enabled || (present && !set.Empty()) { |
| + t.Errorf("%s: present=%v empty=%v, want present=%v and no service roles", pattern, present, set.Empty(), tc.enabled) |
| + } |
| + } |
| + for pattern := range wantRegistered { |
| + if _, ok := s.cliRoles[pattern]; !ok { |
| + t.Errorf("%s: the source registers it, the server did not record it", pattern) |
| + } |
| + } |
| + if len(s.cliRoles) != len(wantRegistered) { |
| + t.Errorf("cliRoles holds %d doors, want %d for this flag state", len(s.cliRoles), len(wantRegistered)) |
| + } |
| + }) |
| } |
| } |
diff --git a/main.go b/main.go| index 16f2ccd5b..279071dcf 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -12,7 +12,6 @@ import ( |
| "github.com/carlosframework/platform/internal/host" |
| "github.com/carlosframework/platform/internal/instancectl" |
| "github.com/carlosframework/platform/internal/instanceexec" |
| - "github.com/carlosframework/platform/internal/ops" |
| "github.com/carlosframework/platform/internal/version" |
| "github.com/carlosframework/platform/internal/vet" |
| ) |
| @@ -133,7 +132,7 @@ func main() { |
| case "release-keygen": |
| runKeygen(args) |
| case "ops": |
| - ops.Run(args) |
| + runOps(args) |
| case "system": |
| runSystem(args) |
| case "status": |