docs+module: fleet boxes without platform-bucket credentials
- fleet_byo.html: no-bucket env contract is now primary (CARLOS_FLEET, CARLOS_FLEET_BOX, CARLOS_REGION, CARLOS_TUNNEL_URL/NAME/TOKEN, CARLOS_HIBERNATE_BUCKET + own-bucket creds); explicit no-deployment- bucket/no-platform-creds line; CARLOS_STATUS_PREFIX moved out to an optional-override note; deployment-bucket path demoted to a "Legacy direct-S3 (grandfathered)" note. accountfleet_test.go pins the new note. - region-pi.md: "flipping to the fleet API" note — drop CARLOS_DEPLOYMENT_BUCKET + the platform-bucket credential half once worktree-fleet-api merges and rolls; CARLOS_STATUS_PREFIX becomes optional. - eleven-fleet.md: preconditions reworded from "not yet built" to "built on branch worktree-fleet-api, pending merge + edge roll" for both the edge door and the region-remote-host module change. - region-remote-host module: deployment_bucket_arn now optional (default ""); its IAM statement moved to a separate aws_iam_user_policy resource gated by count, emitted only when the ARN is set (matches the module's existing host_route53 optional-statement pattern in region-host). README reframed: fleet-API consumers need no deployment-bucket grant at all; grandfathered direct-S3 paragraph kept. tofu validate clean. - spec status line: all six implementation tasks built on this branch, pending merge + edge roll.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+109
−31
docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md+7 −3infra/dev/region-pi.md+12 −0infra/eleven-fleet.md+12 −9infra/modules/region-remote-host/README.md+20 −3infra/modules/region-remote-host/main.tf+33 −13infra/modules/region-remote-host/variables.tf+2 −1internal/console/accountfleet_test.go+7 −0internal/console/templates/fleet_byo.html+16 −2
diff --git a/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md b/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md| index f4d583723..a56c06867 100644 |
| --- a/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md |
| +++ b/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md |
| @@ -1,9 +1,13 @@ |
| # Fleet API Surface: the deployment bucket behind the edge (issue #73) |
| **Date:** 2026-08-11 |
| -**Status:** Draft — designed autonomously in the unattended fleets session; |
| -Paul pre-approved the direction in issue #73 (2026-08-07: "don't paper over |
| -it with a bucket policy — abstract the bucket behind the carlos API"). |
| +**Status:** All six implementation tasks built on branch `worktree-fleet-api` |
| +(edge store door, `internal/fleetapi` client, host env-driven store |
| +selection + F6 retention skip, this doc + BYO page + region-pi.md + |
| +region-remote-host module updates) — pending merge to `main` and the edge |
| +roll; designed autonomously in the unattended fleets session, Paul |
| +pre-approved the direction in issue #73 (2026-08-07: "don't paper over it |
| +with a bucket policy — abstract the bucket behind the carlos API"). |
| **Depends on:** customer fleets v1+v2 (merged, main 00c3344); the tunnel |
| dynamic-auth table (edge/tunnel.go). |
| **Closes:** #73. **Unblocks:** untrusted-customer fleets, the Amadan |
diff --git a/infra/dev/region-pi.md b/infra/dev/region-pi.md| index d0f48c86f..7a03a9f54 100644 |
| --- a/infra/dev/region-pi.md |
| +++ b/infra/dev/region-pi.md |
| @@ -41,6 +41,18 @@ Follow infra/remote-host.md with these dev-platform values: |
| shows UNREACHABLE in the fleet view and `carlos status -live`; instances |
| resume when it re-dials. This is the approved demo posture. |
| +**Flipping to the fleet API (issue #73, branch `worktree-fleet-api`, not yet |
| +merged):** once that branch is on `main` and rolled to the dev edge, drop |
| +`CARLOS_DEPLOYMENT_BUCKET` from the standard agent env on this box, and drop |
| +the platform-bucket half of `region-remote-host`'s IAM grant |
| +(`deployment_bucket_arn` unset on the `pi-fleet` tofu root — the module |
| +defaults it to `""` and simply omits that statement). Keep |
| +`AWS_SHARED_CREDENTIALS_FILE`/`CARLOS_HIBERNATE_BUCKET` exactly as above — |
| +the Pi still owns its data bucket directly. `CARLOS_STATUS_PREFIX` becomes |
| +optional at that point too (an unset value defaults to `fleets/<name>/` on |
| +the door branch); keep the explicit line above until this doc is updated |
| +post-merge, or drop it once verified. |
| + |
| Seeding: scripts/seed-pi-demo.sh (batches of ≤10/day — console limiter; |
| re-running an already-seeded batch is safe but not free, since the limiter |
| counts attempts, not successes, so only re-run a batch that actually |
diff --git a/infra/eleven-fleet.md b/infra/eleven-fleet.md| index 4c10f803f..0495eaba8 100644 |
| --- a/infra/eleven-fleet.md |
| +++ b/infra/eleven-fleet.md |
| @@ -17,17 +17,20 @@ Preconditions). Not a data migration: there is no data to migrate. |
| ## Preconditions (must be true before Step 1) |
| - [ ] **#73 (fleet API surface) merged and deployed to Carloku's flagship |
| - edge.** *Not true at time of writing* — branch `worktree-fleet-api` has |
| - the design (`docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md`) |
| - and the first edge piece (`VerifyDynamic`, `2c9f697`/`b2b0867`), but the |
| + edge.** *Built on branch `worktree-fleet-api`, pending merge + edge |
| + roll* — the design |
| + (`docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md`), the |
| store door (`/store/o/<key>` etc.), the `internal/fleetapi` client, and |
| - the env-driven store selection in `openDeployStore` aren't built yet. |
| - Without it a fleet box still needs RW on Carloku's platform deployment |
| - bucket to run — unacceptable for boxes in Tito's own AWS account. |
| + the env-driven store selection in `openDeployStore` are all built and |
| + tested on that branch, but nothing has merged to `main` or shipped to |
| + Carloku's edge yet. Without a deployed edge a fleet box still needs RW |
| + on Carloku's platform deployment bucket to run — unacceptable for |
| + boxes in Tito's own AWS account. |
| - [ ] **`region-remote-host`'s `deployment_bucket_arn` is optional.** Also |
| - #73 rollout item 4 — today the variable is required, no default |
| - (`infra/modules/region-remote-host/variables.tf`). Eleven's tofu root |
| - must be able to omit the grant. |
| + built on branch `worktree-fleet-api` (`infra/modules/region-remote-host/ |
| + variables.tf` now defaults it to `""`, IAM statement emitted only when |
| + set) but not yet merged. Eleven's tofu root must be able to omit the |
| + grant, which requires that branch's module on `main`. |
| - [ ] **Eleven's account exists on Carloku**, passkey-reachable, apps already |
| created under it (`carlos accounts create -name eleven -owner |
| <address>`, then the normal `apps create` flow) — currently unplaced, |
diff --git a/infra/modules/region-remote-host/README.md b/infra/modules/region-remote-host/README.md| index e68f31059..aceb8ff16 100644 |
| --- a/infra/modules/region-remote-host/README.md |
| +++ b/infra/modules/region-remote-host/README.md |
| @@ -2,14 +2,31 @@ |
| Provisions what an off-cloud CARLOS host needs when there is no EC2 instance |
| profile to lean on: a region data bucket plus an IAM user carrying a scoped |
| -inline policy and access key, limited to exactly the deployment bucket and |
| -this region's data bucket (wrong-region-fails-at-IAM, the same residency |
| -property `region-host` gives cloud hosts). |
| +inline policy and access key, limited to this region's data bucket and |
| +nothing else (wrong-region-fails-at-IAM, the same residency property |
| +`region-host` gives cloud hosts). |
| Consumed by customer fleets (paulca/pi-fleet is the live example) and |
| the platform's tito hq box. The module is reusable as-is for both — they |
| differ only in account and variable values, not shape. |
| +**`deployment_bucket_arn` is optional (default `""`).** The fleet API |
| +surface (issue #73) means a fleet box no longer needs any IAM grant on the |
| +platform's deployment bucket at all: it reads releases and status, and |
| +writes heartbeats, through the console's edge (`/store/o/...`) using the |
| +same tunnel token it already dials with — the door authenticates and |
| +scopes the request, not a bucket-level AWS credential. That is the shape |
| +every new fleet-API consumer's tofu root should apply with: leave |
| +`deployment_bucket_arn` unset, and the module emits no deployment-bucket |
| +IAM statement at all. |
| + |
| +The variable still exists for **grandfathered direct-S3 boxes** — ones |
| +still running the pre-#73 agent, or deliberately kept off the fleet API — |
| +which need RW straight on the deployment bucket exactly as before; set |
| +`deployment_bucket_arn` explicitly for those and the module emits the |
| +extra scoped statement (`aws_iam_user_policy.host_deployment_bucket`) on |
| +top of the always-present own-data-bucket grant. |
| + |
| The S3 action list in the inline policy is not independently chosen: it is |
| copied verbatim from `infra/modules/region-host/main.tf`'s |
| `aws_iam_role_policy.host_buckets` (the authority for what this principal |
diff --git a/infra/modules/region-remote-host/main.tf b/infra/modules/region-remote-host/main.tf| index 90b74d912..64da92b08 100644 |
| --- a/infra/modules/region-remote-host/main.tf |
| +++ b/infra/modules/region-remote-host/main.tf |
| @@ -3,9 +3,11 @@ |
| # IAM principal, since there is no EC2 instance profile to lean on off-cloud. |
| # |
| # Residency is enforced the same way as region-host: the principal reaches |
| -# the deployment bucket plus ITS OWN region's data bucket and nothing else. |
| -# A wrong-region host fails loudly at the IAM layer, same property as every |
| -# cloud host. |
| +# ITS OWN region's data bucket and nothing else. A wrong-region host fails |
| +# loudly at the IAM layer, same property as every cloud host. The deployment |
| +# bucket is grandfathered-optional (var.deployment_bucket_arn, default "") |
| +# — a fleet-API box needs no platform-bucket grant at all; see the |
| +# aws_iam_user_policy.host_deployment_bucket comment below. |
| # |
| # The data bucket follows the deployment-buckets convention for this class |
| # of bucket (infra/modules/README.md, infra/modules/deployment-buckets/ |
| @@ -58,8 +60,8 @@ resource "aws_iam_access_key" "host" { |
| user = aws_iam_user.host.name |
| } |
| -# Scope: the deployment bucket + THIS region's data bucket and nothing |
| -# else — wrong-region-fails-at-IAM, same property as every cloud host. |
| +# Scope: THIS region's data bucket, always — wrong-region-fails-at-IAM, same |
| +# property as every cloud host. |
| # |
| # The action list is not independently chosen: region-host's |
| # aws_iam_role_policy.host_buckets (infra/modules/region-host/main.tf) is |
| @@ -74,28 +76,46 @@ resource "aws_iam_user_policy" "host" { |
| Version = "2012-10-17" |
| Statement = [ |
| { |
| - Sid = "DeploymentBucketObjects" |
| + Sid = "OwnRegionDataObjects" |
| Effect = "Allow" |
| Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] |
| - Resource = ["${var.deployment_bucket_arn}/*"] |
| + Resource = ["${aws_s3_bucket.data.arn}/*"] |
| }, |
| { |
| - Sid = "DeploymentBucketList" |
| + Sid = "OwnRegionDataList" |
| Effect = "Allow" |
| Action = ["s3:ListBucket", "s3:GetBucketLocation"] |
| - Resource = [var.deployment_bucket_arn] |
| + Resource = [aws_s3_bucket.data.arn] |
| }, |
| + ] |
| + }) |
| +} |
| + |
| +# Deployment-bucket grant: GRANDFATHERED, direct-S3 boxes only. A fleet-API |
| +# box (issue #73) never needs this — it reaches the platform's deployment |
| +# bucket through the console's edge, over the same tunnel token it dials |
| +# with, so var.deployment_bucket_arn is left "" and this whole statement |
| +# (and the IAM policy resource carrying it) is omitted. count, not a dynamic |
| +# block, matches this module's own aws_iam_role_policy.host_route53 pattern |
| +# for an optional statement. |
| +resource "aws_iam_user_policy" "host_deployment_bucket" { |
| + count = var.deployment_bucket_arn != "" ? 1 : 0 |
| + name = "carlos-remote-host-${var.region_code}-deployment-bucket" |
| + user = aws_iam_user.host.name |
| + policy = jsonencode({ |
| + Version = "2012-10-17" |
| + Statement = [ |
| { |
| - Sid = "OwnRegionDataObjects" |
| + Sid = "DeploymentBucketObjects" |
| Effect = "Allow" |
| Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] |
| - Resource = ["${aws_s3_bucket.data.arn}/*"] |
| + Resource = ["${var.deployment_bucket_arn}/*"] |
| }, |
| { |
| - Sid = "OwnRegionDataList" |
| + Sid = "DeploymentBucketList" |
| Effect = "Allow" |
| Action = ["s3:ListBucket", "s3:GetBucketLocation"] |
| - Resource = [aws_s3_bucket.data.arn] |
| + Resource = [var.deployment_bucket_arn] |
| }, |
| ] |
| }) |
diff --git a/infra/modules/region-remote-host/variables.tf b/infra/modules/region-remote-host/variables.tf| index 8b1240278..2096ca518 100644 |
| --- a/infra/modules/region-remote-host/variables.tf |
| +++ b/infra/modules/region-remote-host/variables.tf |
| @@ -9,6 +9,7 @@ variable "data_bucket_name" { |
| } |
| variable "deployment_bucket_arn" { |
| - description = "ARN of the existing deployment bucket the host reads releases/status from and writes heartbeats to." |
| + description = "ARN of the existing deployment bucket the host reads releases/status from and writes heartbeats to. Optional (default \"\"): a fleet-API box (fleet API surface, issue #73) reaches the platform's deployment bucket over the console's edge with its tunnel token, not a direct IAM grant — leave this unset and no deployment-bucket statement is emitted. Only a grandfathered direct-S3 box needs it set." |
| type = string |
| + default = "" |
| } |
diff --git a/internal/console/accountfleet_test.go b/internal/console/accountfleet_test.go| index 1cd589a81..800664bb0 100644 |
| --- a/internal/console/accountfleet_test.go |
| +++ b/internal/console/accountfleet_test.go |
| @@ -359,6 +359,13 @@ func TestFleetBYOPageMemberReadable(t *testing.T) { |
| t.Fatalf("BYO page missing %q in:\n%s", want, body) |
| } |
| } |
| + // The primary env contract is bucketless: no platform-bucket var or |
| + // credential belongs there (the box reaches releases/heartbeats through |
| + // the console's edge with its tunnel token). CARLOS_DEPLOYMENT_BUCKET |
| + // still appears, but only inside the demoted legacy-direct-S3 note. |
| + if !strings.Contains(body, "Legacy direct-S3") { |
| + t.Fatalf("BYO page missing the legacy direct-S3 note in:\n%s", body) |
| + } |
| } |
| // TestAccountFleetDegradesOnStoreFailure: a bucket having a moment must cost |
diff --git a/internal/console/templates/fleet_byo.html b/internal/console/templates/fleet_byo.html| index 5d88b38c2..b034e5230 100644 |
| --- a/internal/console/templates/fleet_byo.html |
| +++ b/internal/console/templates/fleet_byo.html |
| @@ -31,13 +31,27 @@ CARLOS_HIBERNATE_BUCKET=<your-bucket> |
| CARLOS_FLEET=<name> |
| CARLOS_FLEET_BOX=<label> |
| CARLOS_REGION=fleet/<name> |
| -CARLOS_STATUS_PREFIX=fleets/<name>/ |
| CARLOS_TUNNEL_URL=<this deployment's tunnel address> |
| CARLOS_TUNNEL_TOKEN=<from carlos fleets add-box <name> <label> — shown once> |
| CARLOS_TUNNEL_NAME=<name>/<label></code></pre> |
| <p class="small"><code>CARLOS_FLEET_BOX</code> is optional and defaults from |
| <code>CARLOS_TUNNEL_NAME</code>'s label half. <code>AWS_SHARED_CREDENTIALS_FILE</code> points at |
| -an access key for the bucket above, written root-owned mode 600 alongside the env file.</p> |
| +an access key for the bucket above — <strong>your own bucket only</strong> — written root-owned |
| +mode 600 alongside the env file.</p> |
| +<p class="small">No <code>CARLOS_DEPLOYMENT_BUCKET</code>, and no AWS credentials for the |
| +platform's own bucket, belong in this file at all: the box reads its app releases and writes its |
| +heartbeats through the console's edge, over HTTPS, using the same tunnel token it already dials |
| +in with — not a bucket-level AWS grant.</p> |
| +<p class="small">Optional override: <code>CARLOS_STATUS_PREFIX=fleets/<name>/</code> — the box |
| +defaults to this on its own; set it explicitly only if you're pointing status/heartbeats |
| +somewhere non-standard.</p> |
| + |
| +<h3>Legacy direct-S3 (grandfathered)</h3> |
| +<p class="small">A box still running a pre-fleet-API agent build needs |
| +<code>CARLOS_DEPLOYMENT_BUCKET</code> set to the platform's own deployment bucket, plus an AWS |
| +credential granting it RW there, in addition to the hibernate-bucket vars above. New boxes should |
| +not be configured this way — it is kept working for boxes that haven't upgraded yet, not as a |
| +choice.</p> |
| <h2>Hardening</h2> |
| <ul> |