carlos / platform Public

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>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 47043c9bb71674a09af0d0b49fb51e4060f77835 parent aa8b702
11 files changed, +356 −30
  • cmd/console/hostclaim_flag.go +7 −0
  • cmd/console/hostclaim_flag_test.go +16 −0
  • cmd/console/main.go +1 −0
  • hostclaim_cli.go +111 −0
  • hostclaim_cli_test.go +32 −0
  • internal/console/apihostclaim.go +37 −0
  • internal/console/apihostclaim_test.go +86 −0
  • internal/console/authcli.go +7 −0
  • internal/console/server.go +2 −0
  • internal/console/svcroutes_test.go +56 −28
  • main.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":