// The CLI's sidecar provisioning API: the verbs that turn "run a member's // bot" into a data write in the deployment bucket, so an app process holding // no AWS credentials and no box access can provision one per enrolled member // in seconds. The record it writes is the whole authorization; the agent's // sidecar reconcile pass (internal/provision/sidecar_reconcile.go) is what // turns it into a route, on its own box only. // // Everything structural here is the instance family's, deliberately: the // GATE is instanceGate verbatim (apiinstances.go), so the SAME two bearer // shapes reach it — a member's user token, or an APP token bound to one // account and one app at mint time — with the same ordering (principal, // budget, readiness) and the same byte-identical 404 on every refusal before // the principal is proven. That reuse is the point rather than a shortcut: // Eleven's process calls this door with the CARLOS_INSTANCE_TOKEN it already // holds, and a second hand-rolled gate would be a second place for the // ordering to drift. `apidomains.go` shares it for the identical reason. // // Three things are NOT the instance family's, and each is a consequence of // what a sidecar is (design: sidecar members): // // - There is no host in the request and no domain in the manifest. A // sidecar's host is DERIVED — provision.SidecarHost(name, app, sqid), // under the reserved, never-resolvable .sidecar.internal suffix — so // there is no namespace for a caller to reach into and no sibling-apex // hazard to refuse: the app and account are IN the host. The only thing // the request names is one DNS label. // - There is no PATCH. A sidecar has no upstream set to repoint, which is // the one thing an instance record's update verb exists for. The family // is create-only plus delete, which is also what makes CreateSidecarRecord's // lost race a 409 rather than a silent overwrite. // - CHANNEL is stamped on every record, always. Task 9's reconcile pass // PROTECTS a channel-less sidecar record — it is deliberately never // minted, because a route with no channel cannot resolve a release and // so has no build to exec. A record written without one would be // silently inert: minted by nothing, with no error anywhere. That makes // this door load-bearing for it (see handleAPISidecarCreate). // // Spec: docs/superpowers/specs/2026-08-17-sidecar-members-design.md §11 (the // verbs), §4 (the shapes), §7.4 (the wake), §8 (placement). package console import ( "context" "errors" "log" "net/http" "os" "slices" "strings" "time" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // sidecarListMax caps one list answer, the instance family's number for the // same reason: a sidecar fleet is one row per enrolled member and can be // large, and the response says `truncated` rather than silently serving a // prefix. const sidecarListMax = 500 // The manifest's argv bounds (design §4.1). Args becomes the argv the // activator execs the app's OWN binary with, so the bound here is manifest // hygiene rather than privilege — the caller already controls that binary // via ship — but a manifest is read forever, and an unbounded argv written // once would be permanent. Same numbers as instanceSeedMaxArgs for the same // reason, kept as their own constants so a change to one family's bound is // never silently a change to the other's. const ( sidecarArgsMax = 16 sidecarArgMaxLen = 256 ) // sidecarMaxSeconds bounds the two duration knobs. Thirty days is far past // any honest idle or max-run and short of the values that read as "never" — // a manifest is read forever, so a nonsense value written once outlives // whoever typed it. const sidecarMaxSeconds = 30 * 24 * 60 * 60 // sidecarManifest reads the app's sidecar manifest and answers the caller // itself when it cannot be had. ok=false means a response has already been // written. // // CREATE is its only caller, exactly as instanceManifest's is: the manifest // is policy about what may be BROUGHT INTO existence, and reads, wakes and // deletes are about what already exists, which no version of it can retract. // // The two failures are kept apart on purpose. ABSENCE is an answer — the // feature does not exist for this app — while a read failure is the bucket // being unreachable. Reporting a hiccup as "sidecars are not enabled" would // tell an app its feature had been switched off. The 409 names SIDECARS' own // enable verb: an app with instances enabled and sidecars not is the ordinary // state, so pointing at the other family's verb would be actively wrong. func (s *Server) sidecarManifest(ctx context.Context, w http.ResponseWriter, acct *Account, app string) (*provision.SidecarManifest, bool) { m, err := provision.GetSidecarManifest(ctx, s.cfg.Releases, acct.Sqid, app) switch { case err == nil: return m, true case errors.Is(err, os.ErrNotExist): // Membership (or binding) is already proven, so this may name its // cause and the act that fixes it. jsonError(w, http.StatusConflict, "sidecars are not enabled for "+app+" — enable them first (carlos sidecars enable -app "+app+")") return nil, false default: log.Printf("console: api sidecars manifest %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read sidecar settings") return nil, false } } // sidecarListRecord wraps provision.SidecarRecord with the console's own // running-version join, kept OFF the record for the same reason // instanceListRecord keeps staged_version off provision.Record: the record is // the wire shape CreateSidecarRecord persists to the bucket, this field is // never written there, and giving the record the field would risk a future // write path serializing a stale value into stored bytes the join was never // meant to touch. type sidecarListRecord struct { provision.SidecarRecord // RunningVersion is the build the agent last started for the sidecar // (registry ServingVersion, zero-downtime plan 5), read from whichever // box carries its route via status/boxes/