| index 478be9866..787043a2d 100644 |
| --- a/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md |
| +++ b/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md |
| @@ -1,25 +1,31 @@ |
| -# Instance moves P7: the edge-local door — Implementation Plan (rev 3) |
| +# Instance moves P7: the edge-local door — Implementation Plan (rev 4) |
| |
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| |
| **Goal:** An app's own processes on a box (Tito's router, relay and import-sync loop) reach any of the app's instances through one box-local unix socket, whatever box the instance lives on and whatever its socket path, authenticated by an app-scoped door key that a box verifies offline. |
| |
| -**Architecture:** A signed-in person mints a door key at the console. The console's own database is the authority for keys. The bucket holds a signed projection of them, under `control/door-keys/<account>/<app>.json`, signed with the deployment's root release key, carrying a revision and a 72-hour expiry, re-signed every 12 hours, and written by one serialized, compare-and-swap publisher that always rebuilds from the database. Every edge mirrors the projections on their own 30-second loop and verifies them against `CARLOS_RELEASE_PUBKEY`. A second listener on `/run/carlos/edge-local.sock` (0660, group `carlos-sockets` through the directory's setgid) authenticates `X-Carlos-Door-Key`, strips it, replaces X-Forwarded-For with one validated address, marks the request as a door caller and hands it to `Edge.ServeHTTP`. For a host this box serves, `ServeHTTP` refuses reserved handlers to door callers and checks ownership against the final upstream it is about to serve. For a host served elsewhere, the door checks the owner the home box published, which a box publishes only for a host whose every route has one owner, and then backhauls it as an ordinary hop. |
| +**Architecture:** A signed-in person mints a door key at the console. The console's own database is the authority for keys. The bucket holds a signed projection of them, under `control/door-keys/<account>/<app>.json`, signed with the deployment's root release key, carrying a revision and a 72-hour expiry, re-signed every 12 hours, and written by one serialized, compare-and-swap publisher that always rebuilds from the database. Every edge mirrors the projections on their own 30-second loop and verifies them against `CARLOS_RELEASE_PUBKEY`. A second listener on `/run/carlos/edge-local.sock` (0660, group `carlos-sockets` through the directory's setgid) authenticates `X-Carlos-Door-Key`, strips it, removes every forwarded-address header, marks the request as a door caller and hands it to `Edge.ServeHTTP`. For a host this box serves, `ServeHTTP` refuses reserved handlers to door callers and checks ownership against the final upstream it is about to serve. For a host served elsewhere, the door checks the owner the home box published, which a box publishes only for a host whose every route has one owner, and then backhauls it as an ordinary hop. |
| |
| -## Security model (rev 3) |
| +## Security model (rev 4; Paul's ruling, 2026-09-28) |
| |
| -**The door grants exactly one privilege over the public door: the caller's claim of the visitor's address is believed.** Everything else a door request can do (reach a host, wake an instance, meet the password gate, be backhauled), a request from the internet to the same URL can already do. This is checked against the code, not assumed: hop trust (`hopVerified`) is read in exactly three places, `setForwarded` (`homemap.go:914`), `clientIP` (`analytics.go:77`) and the one-hop refusal (`edge.go:2617`), and a client address feeds only the gate's rate-limit key (`gate/gate.go:231`) and the visitor hash (`analytics.go:86`). Nothing in `internal/edge`, `internal/activator` or `internal/gate` trusts a loopback or other special address. |
| +**The door grants no privilege over the public door.** Paul ruled (2026-09-28, after Astra's round 3 showed that a believed address changes the password gate's guess limits and any app that authorizes by IP) that the door believes no caller-supplied visitor address. So: |
| |
| -So the door's authorization exists to confine that one privilege to the calling app's own routes, and every residual gap below costs at most a misattributed visitor address on some app's traffic, never access, data or a write: |
| +- The door deletes `Forwarded`, `X-Forwarded-For`, `X-Forwarded-Host` and `X-Forwarded-Proto` from every request (`stripForwardedHeaders`, `homemap.go:806`). |
| +- It sets `RemoteAddr` to `door/<account>/<app>`. That value has no port, so `net.SplitHostPort` fails on it. The gate's rate-limit key (`gate/gate.go:230-235`) and the analytics visitor address (`analytics.go:86-90`) then fall back to the raw string. Gate guesses through the door share **one limit per app per box**, and door traffic counts as **one visitor** per app per box. No gate or analytics code changes. |
| +- `setForwarded` (`homemap.go:912`) gives a door request `X-Forwarded-For: unknown` and `X-Forwarded-Proto: https`, before its hop check. `unknown` is the value RFC 7239 reserves for an undisclosed client. A backhauled door request therefore arrives at the far edge carrying `unknown`. The far edge believes that chain as it believes any verified hop's, so the app sees `unknown` wherever the instance lives. The far edge's TLS makes the scheme `https` there too, so the app sees one visitor address and one scheme whichever box serves it (round 3, finding 3). |
| +- No loopback, private or other real address is ever used. Some apps treat loopback as a local administrator, and the door must not be the way to become one. |
| +- An app that needs the buyer's address has its own router send it in the app's own signed header, as Tito already does for identity (`X-Tito-Auth-*`). Headers the app sets pass through untouched. The platform reads none of them. |
| |
| -| residual | bound | why accepted | |
| -|---|---|---| |
| -| a bucket writer (#171) forges or replays a door-key record | a replayed record is admitted until its own `expires_at`, at most 72 h after it was signed | the same writer can already rewrite instance records and edges files; #171 is the fix | |
| -| a bucket writer forges an edges-file owner | misattribution on the forged host until the next honest publish | same class; edges files are unsigned today | |
| -| ownership changes (a host moves between apps) | up to one home-map poll (30 s) of the old owner's attribution | the edge's existing staleness contract | |
| -| the console's database is restored from backup | keys minted after the backup vanish; keys revoked after it return | the same holds for every console credential (`cli_tokens`); a restore is an incident with its own runbook | |
| +**What the door key is for, then.** It says which app's processes are calling, and confines them to that app's hosts (ruling Q3). With no privilege behind the door, a gap in that confinement lets a caller reach a host through the door that it could already reach from the internet, with less than an internet request carries. That is why the residuals below are accepted. They are hygiene, not security boundaries: |
| + |
| +| residual | bound | |
| +|---|---| |
| +| a bucket writer (#171) forges or replays a door-key record | a replayed record is admitted until its own `expires_at`, at most 72 h after signing | |
| +| a bucket writer forges an edges-file owner, or ownership is stale | until an honest publish replaces it; unbounded if the owner's box never rewrites the file | |
| +| ownership changes (a host moves between apps) | until every edge's home map has refreshed | |
| +| the console's database is restored from backup | keys minted after the backup vanish; keys revoked after it come back until revoked again | |
| |
| -Round 2's findings 1–3 (door-hop verification failure, the unauthenticated capability flag, reserved handlers on the receiving box) all concerned carrying a door scope across a hop. Rev 3 carries none: a backhauled door request is an ordinary verified hop, and the receiving edge treats it exactly as it treats any hop today. Findings 6 and 7 concerned box-side revision persistence, which rev 3 drops. |
| +The credential machinery (signed, revisioned, expiring records; one serialized publisher) stays as reviewed in rounds 1–3. It costs little, keeps keys unforgeable by a bucket writer, and would be the base if the door ever needed a privilege. Any such privilege needs its own design and review. |
| |
| **Tech Stack:** Go standard library (`net/http`, `crypto/ed25519`, `crypto/hmac`, `crypto/sha256`, `crypto/subtle`), the repo's `release.Store`, the console's SQLite store, `cliRoute`/`svcrole`, the CLI's `apiSession`. |
| |
| @@ -40,6 +46,15 @@ Round 2's findings 1–3 (door-hop verification failure, the unauthenticated cap |
| | 9 | wrong file and fixture references | **Accepted:** `runInstances` is in `cli.go:284`; fixture is `fx.ts`/`fx.pub`, helpers in `apiinstances_test.go`; catchall publisher `edgesfile.go:363` and the alias-versus-route fold covered; docs go to `docs/site/cli.md` (the only reference page). | |
| | 10 | root signing acceptable; add a domain prefix | **Accepted:** signatures cover `"carlos door-keys v1\n" + payload`; cross-kind rejection tests (Task 1). | |
| |
| +## Revision 4: Astra round 3 dispositions |
| + |
| +| # | finding | disposition | |
| +|---|---|---| |
| +| R3-1 | address trust confers gate and app authority | **Accepted, resolved by Paul's ruling:** the door believes no caller address (Security model). The remote-confinement question goes away with the privilege. | |
| +| R3-2 | ownership residuals have no maximum lifetime | **Accepted:** the bounds are restated honestly (Security model), and the residuals are accepted as hygiene because the door carries no privilege. | |
| +| R3-3 | forwarded scheme differs by placement | **Accepted:** `setForwarded` gives door requests `X-Forwarded-Proto: https` locally; the far edge's TLS gives the same (Task 5). | |
| +| R3-4 | renewal bumps the revision; stale `WithEdgeDoor` example | **Accepted:** the advance fires only when the database is *below* the bucket (Task 2). The agent example takes one argument (Task 7). | |
| + |
| ## Revision 3: Astra round 2 dispositions |
| |
| | # | finding | disposition | |
| @@ -82,7 +97,7 @@ Round 2's findings 1–3 (door-hop verification failure, the unauthenticated cap |
| 2. **A path-prefix hostname whose prefixes belong to different apps**: locally, app A's key never reaches app B's prefix; remotely, the host publishes no owner and the door refuses it (Tasks 4 and 5 tests). |
| 3. **A gate login POST whose `next` names another app's prefix** is refused before the gate judges the password (Task 5 test). |
| 4. **A revoke racing a renewal** never leaves the older record in the bucket (Task 2 test). |
| -5. **An X-Forwarded-For that is absent, empty, multi-line or not an IP** leaves exactly one valid address in the header the app and analytics see (Task 6 test). |
| +5. **Any `Forwarded` or `X-Forwarded-*` header a caller sends** never reaches the app, the gate or analytics. The app sees `X-Forwarded-For: unknown` and `X-Forwarded-Proto: https`, served on this box or across a hop (Tasks 5 and 6 tests). |
| |
| --- |
| |
| @@ -97,11 +112,10 @@ Round 2's findings 1–3 (door-hop verification failure, the unauthenticated cap |
| | `doorkeys_cli.go` (new, package main) | `carlos instances door-key create|list|revoke`. | |
| | `cli.go` (modify, `runInstances` at ~line 284) | Dispatch `door-key`. | |
| | `internal/host/edgesfile.go` (modify: `edgeHost`, the route fill, the catchall publisher ~line 363) | Publish per-host `account`/`app` for single-owner hostnames. | |
| -| `internal/edge/homemap.go` (modify) | Fold the owner with the winning entry; `Owner`; `setForwarded` trust. | |
| +| `internal/edge/homemap.go` (modify) | Fold the owner with the winning entry; `Owner`; `setForwarded`'s door branch. | |
| | `internal/edge/doorkeys.go` (new) | The key mirror: its own loop and freshness contract. | |
| | `internal/edge/door.go` (new) | Door mark, `serveDoor`, `doorOwnsRoute`, reserved-name refusal, the listener. | |
| | `internal/edge/edge.go` (modify: `ServeHTTP`, `backhaul`/`backhaulProxy`, `RunWithActivatorReady`, shutdown) | Enforcement points and lifecycle. | |
| -| `internal/edge/analytics.go` (modify `clientIP`) | Trust the door's normalized address. | |
| | `internal/host/agent.go` (modify: options passed to `RunWithActivatorReady`) | `edge.WithEdgeDoor` from `CARLOS_EDGE_DOOR`. | |
| | `docs/site/cli.md` (modify) | The door and the key verbs. | |
| |
| @@ -448,7 +462,7 @@ func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Record, error) { |
| - `func (st *Store) DoorKeyRevoke(accountID int64, app, id string) (revision uint64, err error)` — `os.ErrNotExist` for an unknown or already revoked id; bumps the revision |
| - `func (st *Store) DoorKeysLive(accountID int64, app string) (keys []doorkey.Entry, revision uint64, err error)` |
| - `func (st *Store) DoorKeyApps() ([]struct{ AccountID int64; Sqid, App string }, error)` — every app with a revision row |
| - - `func (st *Store) DoorKeyAdvanceRevision(accountID int64, app string, past uint64) (uint64, error)` — sets the revision to `past+1` if it is at or below `past`; returns the resulting revision |
| + - `func (st *Store) DoorKeyAdvanceRevision(accountID int64, app string, past uint64) (uint64, error)` — sets the revision to `past+1` if it is below `past`; returns the resulting revision |
| - `func (s *Server) publishDoorKeys(ctx context.Context, acct *Account, app string) error` — the one publisher, protocol below |
| - `s.doorPublish` — `map[string]*sync.Mutex` keyed `"<sqid>/<app>"`, guarded by its own mutex, one lock per app |
| |
| @@ -456,7 +470,7 @@ func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Record, error) { |
| |
| 1. Take the app's publication mutex; hold it to the end. |
| 2. `wire, etag, err := s.cfg.Releases.Get(ctx, key)`. Not found: `etag = ""`, `bucketRev = 0`. Found: `rec, verr := doorkey.Verify(pub, wire, time.Time{})`. Pass the zero time so an expired record still yields its revision. If `Verify` refuses the zero time, add `doorkey.VerifyRevision(pub, wire) (uint64, error)`, which checks the signature and shape only. `bucketRev` is the verified revision, or `0` for anything unverifiable (a tampered object is overwritten, never trusted). |
| -3. `keys, rev := DoorKeysLive(...)`, one read transaction. If `rev <= bucketRev`, then `rev = DoorKeyAdvanceRevision(..., bucketRev)` and re-read `keys`. That covers a restored database and a second writer. |
| +3. `keys, rev := DoorKeysLive(...)`, one read transaction. If `rev < bucketRev`, then `rev = DoorKeyAdvanceRevision(..., bucketRev)` and re-read `keys`. An equal revision is the ordinary renewal case and is re-published as it is. That covers a restored database and a second writer. |
| 4. Build `doorkey.Record{Revision: rev, Keys: keys, SignedAt: now, ExpiresAt: now + doorkey.Lifetime}`, sign it, and `Put(ctx, key, body, etag)`. |
| 5. On `release.ErrCASConflict`, go back to step 2, at most three times. Every retry rebuilds from the database and never reuses signed bytes. If all three fail, return the error. |
| |
| @@ -619,18 +633,38 @@ Mirror rules, each with its own test: |
| |
| ### Task 5: Enforcement inside `ServeHTTP` |
| |
| -**Files:** Create `internal/edge/door.go`. Modify `internal/edge/edge.go` (`ServeHTTP`, `backhaul`), `internal/edge/homemap.go` (`setForwarded`), `internal/edge/analytics.go` (`clientIP`). Tests in `internal/edge/door_test.go`. |
| +**Files:** Create `internal/edge/door.go`. Modify `internal/edge/edge.go` (`ServeHTTP`, `backhaul`) and `internal/edge/homemap.go` (`setForwarded`). Tests in `internal/edge/door_test.go`. |
| |
| **Interfaces:** |
| - `type doorCallerKey struct{}`; `type doorCaller struct{ Account, App string }`; `func doorCallerFrom(ctx context.Context) (doorCaller, bool)` |
| - `func (e *Edge) backhaulTo(w http.ResponseWriter, r *http.Request, host, eip, box string) bool`: the dial half of `backhaul`, split out so the door dials exactly the target `Owner` returned. `backhaul` becomes `Home` plus `backhaulTo`, and its behaviour is unchanged. |
| - `func doorOwnsRoute(c doorCaller, rt registry.Route) bool` → `release.RouteAccount(rt) == c.Account && rt.App == c.App` |
| - `func (e *Edge) doorReservedHost(host string) bool` — true for the tunnel hostname and the steering probe hostname, compared after lowercasing and stripping one trailing dot (the normalization `steeringProbe` and `canonicalizeHopHost` use) |
| -- `func forwardedTrusted(ctx context.Context) bool` → `hopVerified(ctx) || door caller present`; used by `setForwarded` and `clientIP` only. `backhaul`'s one-hop refusal keeps reading `hopVerified`. |
| +- `setForwarded` gains a first branch: |
| + |
| +```go |
| +func setForwarded(pr *httputil.ProxyRequest) { |
| + pr.SetXForwarded() |
| + // A door request discloses no visitor (Security model): one fixed |
| + // address and the scheme the far side of a hop would give it, so the |
| + // app sees the same thing whichever box serves it. |
| + if _, ok := doorCallerFrom(pr.In.Context()); ok { |
| + pr.Out.Header.Set("X-Forwarded-For", "unknown") |
| + pr.Out.Header.Set("X-Forwarded-Proto", "https") |
| + return |
| + } |
| + if !hopVerified(pr.In.Context()) { |
| + return |
| + } |
| + if chain := pr.In.Header.Values("X-Forwarded-For"); len(chain) > 0 { |
| + pr.Out.Header.Set("X-Forwarded-For", strings.Join(chain, ", ")) |
| + } |
| +} |
| +``` |
| |
| Enforcement, in `ServeHTTP` order: |
| 1. **First statement after the port strip:** `door, isDoor := doorCallerFrom(r.Context())`. If `isDoor && e.doorReservedHost(host)`: `e.unknownHost(w, r); return`. If `isDoor`, skip the steering probe and tunnel branches entirely (wrap them in `if !isDoor { ... }`). The mark is only ever set by `serveDoor` on this box. Nothing carries it across a hop, and `acceptHop` is unchanged. |
| -2. **Miss branch:** if `isDoor`, call `acct, app, eip, box, ok := e.homes.Load().Owner(host)`. Unless `ok && acct == door.Account && app == door.App`, answer `unknownHost`. Otherwise `backhaulTo(w, r, host, eip, box)`. The receiving edge sees an ordinary verified hop, as it does for public backhaul today. It believes the forwarded chain, which the door has already reduced to one validated address. This is the whole of what crosses, and a mismatch on the far side costs at most attribution (Security model). |
| +2. **Miss branch:** if `isDoor`, call `acct, app, eip, box, ok := e.homes.Load().Owner(host)`. Unless `ok && acct == door.Account && app == door.App`, answer `unknownHost`. Otherwise `backhaulTo(w, r, host, eip, box)`. The receiving edge sees an ordinary verified hop, as it does for public backhaul today. It believes the forwarded chain, which is `unknown`, so the app there sees exactly what a local serve shows it (Security model). |
| 3. **After the final `up` is chosen**, that is, after the login POST re-resolution block (~`edge.go:1416-1446`) and before the gate (`:1516`), the cache and dispatch: `if isDoor && !doorOwnsRoute(door, up.route) { e.unknownHost(w, r); return }`. The same `up` value is then used for everything after it, so authorization and dispatch cannot diverge. |
| |
| - [ ] **Step 1: Failing tests** (an in-process `Edge` built the way existing `edge_test.go` tests build one with local routes and a stubbed home map; `grep -n "func newTestEdge\|func testEdge" internal/edge/*_test.go`): |
| @@ -671,29 +705,14 @@ func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request) { |
| doorRefused(w) |
| return |
| } |
| - ip := doorClientIP(r) |
| - r.Header.Del("Forwarded") |
| - r.Header.Set("X-Forwarded-For", ip) |
| - r.RemoteAddr = net.JoinHostPort(ip, "0") |
| + // No visitor address is believed (Security model): every forwarded |
| + // header goes, and RemoteAddr names the calling app without a port, so |
| + // the gate's limiter and the visitor hash key on it as one client. |
| + stripForwardedHeaders(r) |
| + r.RemoteAddr = "door/" + tok.Account + "/" + tok.App |
| e.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), doorCallerKey{}, doorCaller{Account: tok.Account, App: tok.App}))) |
| } |
| |
| -// doorClientIP is the caller's claim of who the visitor is: the last |
| -// X-Forwarded-For element across every header line, if it parses as an IP. |
| -// Anything else is the loopback address. The door then REPLACES the header |
| -// with that one address, so nothing unparsed travels on to the app, the |
| -// gate, analytics or a backhaul. |
| -func doorClientIP(r *http.Request) string { |
| - chain := strings.Join(r.Header.Values("X-Forwarded-For"), ",") |
| - if i := strings.LastIndexByte(chain, ','); i >= 0 { |
| - chain = chain[i+1:] |
| - } |
| - if ip := net.ParseIP(strings.TrimSpace(chain)); ip != nil { |
| - return ip.String() |
| - } |
| - return "127.0.0.1" |
| -} |
| - |
| func doorRefused(w http.ResponseWriter) { |
| w.Header().Set("Cache-Control", "no-store") |
| http.Error(w, "door key refused", http.StatusUnauthorized) |
| @@ -706,8 +725,9 @@ func doorRefused(w http.ResponseWriter) { |
| 1. no key, malformed key, revoked key → 401 with `Cache-Control: no-store`, upstream never called; |
| 2. valid key → served; the upstream sees no `X-Carlos-Door-Key` and sees `X-Tito-Auth-User` untouched; |
| 3. inbound `X-Carlos-Hop`, `X-Carlos-Hop-Auth` → neither reaches the upstream, and a miss goes to the door's owner check, never `staleHopRefusal`; |
| - 4. X-Forwarded-For cases: `203.0.113.9` → upstream sees exactly `203.0.113.9`; `garbage`, empty, absent, a trailing empty element (`203.0.113.9, `), and two header lines whose last element is `198.51.100.7` → upstream sees exactly `127.0.0.1`, `127.0.0.1`, `127.0.0.1`, `127.0.0.1`, `198.51.100.7`; `clientIP` returns the same value; |
| + 4. forwarded headers: a request with `X-Forwarded-For: 203.0.113.9`, `Forwarded: for=198.51.100.7` and `X-Forwarded-Proto: http` reaches the upstream with exactly `X-Forwarded-For: unknown` and `X-Forwarded-Proto: https` and no `Forwarded`. The same holds with none of them sent. `clientIP` returns `door/bab/a`. The gate stub's `clientKey` sees `door/bab/a` for two requests that claimed different addresses; |
| 5. a gated route still answers with the gate's page; |
| + 5b. a backhauled door request: using two in-process edges joined as the existing backhaul tests join them (`grep -n "func.*TwoEdges\|backhaul.*httptest" internal/edge/*_test.go`), the far upstream sees `X-Forwarded-For: unknown` and `X-Forwarded-Proto: https`, the same as case 4; |
| 6. `openDoor` on a temp path makes a 0660 socket; a real unix-socket client round-trips case 2; `closeDoor` removes the socket; `openDoor` with a nil mirror returns an error and creates nothing. |
| - [ ] **Step 2–4:** run, implement, run. |
| - [ ] **Step 5: Commit** `edge: the edge-local door (#175 P7)`. |
| @@ -722,7 +742,7 @@ func doorRefused(w http.ResponseWriter) { |
| - `func WithEdgeDoor(path string) RunOption` — records the path on the `Edge`. |
| - In the run path, immediately after `e.homes.Store(hm)` and its `go hm.run(...)`: when a door path is set and `e.staticStore` and `e.staticPub` are non-nil, build `e.doorKeys = newDoorKeyMirror(e.staticStore, e.staticPub)`, run one synchronous `Refresh` with a 10 s budget (log a failure, carry on: every key is refused until a refresh lands), start `go e.doorKeys.run(...)`, then `openDoor(path)` and keep `closeDoor` on the `Edge`. With no store or no public key: log that the door is off and why. In dev mode: log that the door is off in dev mode. |
| - Shutdown calls `closeDoor` before the HTTP servers' `Shutdown`. |
| -- agent: `if p := strings.TrimSpace(os.Getenv("CARLOS_EDGE_DOOR")); p != "" { opts = append(opts, edge.WithEdgeDoor(p, filepath.Join(dataDir, "door-keys.state"))) }`. |
| +- agent: `if p := strings.TrimSpace(os.Getenv("CARLOS_EDGE_DOOR")); p != "" { opts = append(opts, edge.WithEdgeDoor(p)) }`. |
| |
| - [ ] **Step 1: Failing tests:** extract the arming block into `func (e *Edge) armDoor(ctx context.Context) error` so it is testable: with a `DirStore` holding one signed record and a temp socket path, `armDoor` then a real unix-socket request with the key → served; before any record exists → 401 (not a panic); after `closeDoor` the socket is gone. Agent: with `CARLOS_EDGE_DOOR` unset no `WithEdgeDoor` option is passed (extract `edgeDoorOptions(getenv func(string) string, dataDir string) []edge.RunOption`). |
| - [ ] **Step 2–4:** run, implement, run; then `GOOS=darwin go build ./... && GOOS=windows go build ./...`. |