Skip to content

feat: guard direct lease expiry and gate remote allocations - #2806

Merged
steipete merged 5 commits into
mainfrom
codex/direct-lease-guard
Oct 13, 2026
Merged

steipete merged 5 commits into
mainfrom
codex/direct-lease-guard

Conversation

@steipete

Copy link
Copy Markdown
Contributor

Problem

Direct leases could outlive the CLI indefinitely. Only GCP had a guest expiry guard, and the manual sweep skipped kept machines forever while allowing running/provisioning machines another 12 hours. There was also no machine-level policy to prevent new direct remote allocations.

Change

Install a shared root-owned Linux expiry script and systemd service/timer on the shared cloud-init path. It runs every two minutes, requires crabbox=true, treats metadata as data, and fails closed on missing ownership. No credentials are written into guard files. Brokered leases and Windows/macOS targets do not receive the guard.

Direct Linux provider Deadline source Reclaim action
GCP Live labels through the existing metadata-token/Compute API mechanism Self-delete through Compute API
AWS Instance tags through IMDSv2; launch enables metadata tags Guest poweroff with terminate-on-shutdown
Azure IMDS compute/tagsList Guest poweroff
Hetzner, DigitalOcean, Vultr, Linode Root-owned /etc/crabbox/lease-expiry Guest poweroff
OVHcloud, Scaleway, Nebius, Lambda, Tencent Cloud Root-owned /etc/crabbox/lease-expiry Guest poweroff
Incus, Multipass, Firecracker Root-owned /etc/crabbox/lease-expiry Guest poweroff

The file-backed bootstrap records crabbox, state, keep, and expires_at. RefreshGuestLeaseExpiry is the hook for file-backed lifetime updates; ordinary CLI touches use it too. Updates validate the complete SSH payload before atomically renaming a mode-0600 file. Readers evaluate one complete snapshot. This change does not add an extend command.

The CLI sweep and shell guard share tested decision cases: kept leases expire, running/provisioning grace is one hour, and unowned/unlabeled machines remain untouched. failed, released, and expired remain immediately reclaimable. keep only suppresses automatic release after run.

Add directLeases: allow|deny (default allow), CRABBOX_DIRECT_LEASES, directLeaseAllow, and comma-separated CRABBOX_DIRECT_LEASE_ALLOW. Denial is checked before direct acquisition for new runs, warmup/prewarm, fixed IDs, checkpoint forks, and shards. Existing lease operations remain available. A repository can set deny, but cannot loosen an inherited deny or grant allowlist exceptions; those belong to trusted user/system config or environment. Admission policy stays out of the existing controller identity contract and fixed-resource fingerprints.

The local/static exemptions are ssh, local-container, docker-sandbox, apple-container, apple-machine, apple-vm, anthropic-sandbox-runtime, mxc, multipass, firecracker, lume, tart, hyperv, and windows-sandbox. Providers that can target remote hosts, including Incus and Parallels, are not automatically exempt.

Limits

Poweroff stops the workload; it does not necessarily stop billing. Hetzner still needs crabbox cleanup to delete the server, and Azure guest shutdown does not deallocate the VM. Retained disks or other resources may remain billable. GCP keeps its existing self-delete permission requirement.

Custom/template bootstraps such as Proxmox, XCP-ng, Parallels, and Apple VM, plus hosted/delegated/static providers without the shared bootstrap, remain uncovered. The lifecycle/provider docs enumerate the complete covered and uncovered sets. No worker code changed, and no live provider calls were used for proof.

Complete excluded provider list (no shared Linux bootstrap)
  • agent-sandbox, anthropic-sandbox-runtime, apple-container, apple-machine, apple-vm, ascii-box, aws-lambda-microvm, azure-dynamic-sessions.
  • azure-sandbox, blacksmith-testbox, blaxel, boxd, cloud-run-sandbox, cloudflare, cloudflare-dynamic-workers, cloudflare-sandbox.
  • coder, codesandbox, crownest, cua, cubesandbox, daytona, docker-sandbox, e2b.
  • exe-dev, external, fastapi-cloud, freestyle, github-codespaces, hostinger, hyperv, islo.
  • kubevirt, local-container, lume, machine0, modal, morph, mxc, namespace-devbox.
  • namespace-instance, nomad, nvidia-brev, opencomputer, opensandbox, orgo, parallels, phala.
  • proxmox, railway, runpod, sealos-devbox, semaphore, serve, smolvm, sprites.
  • ssh, superserve, tart, tenki, tensorlake, unikraft-cloud, upstash-box, vast.
  • vercel-sandbox, wandb, windows-sandbox, xcp-ng.

Verification

All Go commands use GOTOOLCHAIN=go1.26.5.

  • gofmt -l internal cmd: empty.
  • go vet ./...: passed.
  • go build -trimpath -o bin/crabbox ./cmd/crabbox: passed.
  • Focused CLI tests: passed in 11.284s with an explicit anchored list of the new/changed guard generation, Go/shell decision parity, atomic refresh, direct admission/trust, AWS launch, and affected config-contract tests; -count=1 -timeout=10m.
  • Provider tests: one go test -count=1 -timeout=15m invocation covering AWS, Azure, GCP, Hetzner, DigitalOcean, Vultr, Linode, OVH, Scaleway, Nebius, Lambda, Tencent Cloud, Incus, Multipass, and Firecracker. The new refresh exposed missing SSH fixtures in DigitalOcean and Multipass heartbeat tests. Those fixtures now verify the guest payload; both full packages passed on the affected-package rerun (77.465s and 10.633s). All other 13 packages passed initially.
  • node scripts/check-command-docs.mjs: 64 command docs passed.
  • node scripts/check-docs-links.mjs: 261 Markdown files passed.
  • ShellCheck of generated guard scripts, exercised by TestGuestExpiryGuardProviders when ShellCheck is installed.
  • Real isolated Codex autoreview against the origin/main merge base (80498fbc8112f85934002f1e43b93b465324c768): scoped-clean, no accepted/actionable P0–P2 findings. Review covered ownership, shell/metadata parsing, expiry semantics, config trust, broker exclusion, AWS launch options, and atomic refresh; provider integration is also covered by the tests above.

The earlier over-broad CLI selection ran for 1175.272 seconds under heavy host load and failed unchanged TestSSHTransportCapturedConfigPreventsAliasReassignment on its shared 15-second context (captured command after reassignment: context deadline exceeded). The maintainer ruled this environmental; it was not changed or rerun as a broad selection. PR CI is the broad proof. Earlier scratch-filesystem experiments preserved all assertions; no scratch volumes remain. No live provider calls were made.

Exact focused command and proof tails
GOTOOLCHAIN=go1.26.5 go test -count=1 -timeout=10m ./internal/cli -run '^(TestGuestExpiryDecisionParity|TestGuestExpiryGuardProviders|TestAWSDirectGuestExpiryLaunchOptions|TestRefreshGuestLeaseExpiryAtomic|TestGuestExpiryMetadataFailsClosed|TestAWSDirectGuestExpiryLaunchRequest|TestGuestExpiryTouchRefreshesFile|TestGuestExpiryFileSnapshot|TestDirectLeasePolicy|TestDirectLeaseConfigTrust|TestDirectDenyCommandsBeforeProviderConfigure|TestDirectLeaseConfigLayering|TestCloudInitGCPInstallsExpiryGuard|TestWarmupGCPKeptIdleExpiryGuard|TestShouldCleanupServerSkipsRunningAndProvisioningStates|TestShouldCleanupServerDeletesExpiredIdleStates|TestShouldCleanupServerDeletesStaleRunningStates|TestShouldCleanupServerDeletesExpiredInactive|TestShouldCleanupServerKeepsUnexpiredAndMissingExpiry|TestShouldCleanupServerReapsKeptExpiredLeases|TestDirectLeaseExpiresAtUsesTTLAsCap|TestWarmupKeepIdleConfigPrecedence|TestFixedAWSCreateIntentClassifiesEveryExportedConfigField|TestConfigShowExportsRawControllerProviderIdentityContract|TestProviderConfigShowLegacyLabelInventoryMatchesSource)$'
GOTOOLCHAIN=go1.26.5 go test -count=1 -timeout=15m ./internal/providers/{aws,azure,gcp,hetzner,digitalocean,vultr,linode,ovh,scaleway,nebius,lambda,tencentcloud,incus,multipass,firecracker}
# After fixture corrections, rerun only the affected packages:
GOTOOLCHAIN=go1.26.5 go test -count=1 -timeout=15m ./internal/providers/digitalocean ./internal/providers/multipass
ok  	github.com/openclaw/crabbox/internal/cli	11.284s
ok  	github.com/openclaw/crabbox/internal/providers/aws	235.655s
ok  	github.com/openclaw/crabbox/internal/providers/azure	113.232s
ok  	github.com/openclaw/crabbox/internal/providers/gcp	127.932s
ok  	github.com/openclaw/crabbox/internal/providers/hetzner	23.847s
ok  	github.com/openclaw/crabbox/internal/providers/vultr	28.887s
ok  	github.com/openclaw/crabbox/internal/providers/linode	83.889s
ok  	github.com/openclaw/crabbox/internal/providers/ovh	25.502s
ok  	github.com/openclaw/crabbox/internal/providers/scaleway	137.770s
ok  	github.com/openclaw/crabbox/internal/providers/nebius	7.722s
ok  	github.com/openclaw/crabbox/internal/providers/lambda	25.455s
ok  	github.com/openclaw/crabbox/internal/providers/tencentcloud	7.845s
ok  	github.com/openclaw/crabbox/internal/providers/incus	90.769s
ok  	github.com/openclaw/crabbox/internal/providers/firecracker	10.647s
ok  	github.com/openclaw/crabbox/internal/providers/digitalocean	77.465s
ok  	github.com/openclaw/crabbox/internal/providers/multipass	10.633s
checked 64 command docs: command surface ok
checked 261 markdown files: internal links ok
autoreview scoped-clean: no accepted/actionable P0–P2 findings

@clawsweeper

clawsweeper Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-13T08:37:10.801026Z 0e3ab66 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5387877dba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return false
}

func validateDirectLeaseCreation(cfg Config) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply the allocation gate to watch

When directLeases: deny is configured and crabbox watch is invoked without --id, watchWithBackend still calls backend.Acquire without ever invoking this validator. Subsequent iterations use the acquired ID and therefore also bypass the new-run check in run, allowing the command to create exactly the remote direct lease this machine policy is intended to prohibit; apply the gate to the lease-creation branch of watch while preserving existing-lease reuse.

Useful? React with 👍 / 👎.

"ssh_keys": []any{key.ID},
"tags": tags,
"user_data": core.CloudInitUserData(cfg, publicKey),
"user_data": core.CloudInitUserData(core.WithGuestLeaseExpiry(cfg, labelsFromTags(tags)), publicKey),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Publish the ready state to the guest after warmup

This user-data snapshot is built from DigitalOcean's provisioning tags, while acquisition later changes only the provider tags and returned server to ready. A successful standalone warmup does not call touchLeaseWithGuestExpiry, so if no later command reaches the lease, its guest file remains in provisioning and the guard grants the one-hour provisioning grace after expires_at instead of reclaiming at the ready lease's deadline. Refresh the file-backed snapshot after successful acquisition/ready publication; the same issue affects the other file-backed integrations initialized from provisioning labels.

Useful? React with 👍 / 👎.

@steipete
steipete force-pushed the codex/direct-lease-guard branch from 5387877 to 7b9637a Compare October 13, 2026 07:10

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7b9637afc3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +41 to +45
switch cfg.Provider {
case "gcp", "aws", "azure":
return cfg.Provider
case "hetzner", "digitalocean", "vultr", "linode", "ovh", "scaleway", "nebius", "lambda", "tencentcloud", "incus", "multipass", "firecracker":
return "file"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move guest-expiry dispatch behind provider adapters

This switch makes provider coverage depend on a hard-coded list in core, while guestExpiryScript also selects provider-specific metadata and reclaim behavior there. Adding or changing a provider now requires editing internal/cli, and an otherwise compatible shared-cloud-init provider silently receives no guard. Expose this through a provider capability so each adapter owns its metadata source and reclaim semantics.

AGENTS.md reference: AGENTS.md:L24-L26

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 949e71cfed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +255 to +256
if err := RefreshGuestLeaseExpiry(ctx, target, GuestLeaseExpiryFromLabels(server.Labels)); err != nil {
return server, fmt.Errorf("refresh guest lease expiry: %w", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent stale guest deadlines after successful touches

When a file-backed provider touch succeeds but the subsequent SSH refresh fails—for example during a transient SSH or sudo failure—the provider deadline has already advanced while /etc/crabbox/lease-expiry retains its old deadline. Callers such as run and interactive connect downgrade this returned error to a warning and continue, so the guest guard can power off an active lease at the pre-touch deadline even though provider metadata records the renewal. Retry or otherwise reconcile the guest snapshot before allowing the touch to remain partially successful.

Useful? React with 👍 / 👎.

Comment thread internal/cli/pool.go
Comment on lines +570 to 573
if labels == nil || labels["crabbox"] != "true" {
return false, "missing labels"
}
if strings.EqualFold(labels["keep"], "true") {
return false, "keep=true"
}
state := strings.ToLower(labels["state"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove remaining keep exemptions from cleanup

Removing the shared keep=true exemption does not apply the advertised expiry behavior to provider-specific cleanup paths: multipass.shouldCleanup and firecracker.shouldCleanupRecord still return immediately for every kept lease before evaluating expiry. Consequently, expired kept Multipass instances and Firecracker VMs remain indefinitely during crabbox cleanup, despite the new policy and documentation saying that keep only suppresses post-run release.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b94a8835f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/cli/hcloud.go
Comment on lines +296 to +297
labels := DirectLeaseLabels(cfg, leaseID, slug, "hetzner", "", keep, now)
cfg = WithGuestLeaseExpiry(cfg, labels)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mark the initial Hetzner snapshot as provisioning

When a Hetzner bootstrap takes longer than a short configured idle timeout, this snapshot records state=leased, so the newly installed timer applies the deadline immediately instead of the one-hour provisioning grace and can power off the VM while Acquire is still waiting for SSH. Build the guest snapshot with state=provisioning, as the other provisioning-aware integrations do, and publish the ready state after acquisition. This is distinct from the existing ready-publication comment because Hetzner can be reclaimed before acquisition completes.

Useful? React with 👍 / 👎.

Comment on lines +64 to +66
func directLeaseExempt(provider string) bool {
switch provider {
case "ssh", "local-container", "docker-sandbox", "multipass", "firecracker", "lume", "tart", "hyperv", "windows-sandbox", "apple-container", "apple-machine", "apple-vm", "anthropic-sandbox-runtime", "mxc":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move direct-lease exemptions behind provider adapters

This provider-name switch makes core own provider-specific allocation semantics: every new local-only backend is rejected under directLeases: deny until this unrelated core list is updated, while adapters cannot declare the exemption themselves. Expose an allocation/locality capability on the provider contract and let each adapter own it, keeping the policy evaluator provider-neutral.

AGENTS.md reference: AGENTS.md:L24-L28

Useful? React with 👍 / 👎.

Keep AWS raw-gzip and Hetzner MIME transport intact through a shared encoder, bind the Hetzner round-trip fixture to allocation labels, and bound Tencent Cloud compressed user data. Compact the shared guard and use the exported expiry refresh hook directly.

Normalize Sealos native ownership and recheck live expiry before deletion. Update ownership fixtures and test renewed kept leases, expired kept leases, and non-mutating cleanup dry runs.

Verified targeted failing tests and affected guard/transport/size regressions with GOTOOLCHAIN=go1.26.5 across seven packages. Documentation links pass. Codex autoreview against the origin/main merge base is scoped-clean through P2.
Wait for the client channel-close acknowledgement before closing TCP, so native Windows OpenSSH receives the fixture exit status instead of intermittently returning 255 after a successful refresh.

The targeted DigitalOcean heartbeat/status test passes with GOTOOLCHAIN=go1.26.5. Codex autoreview is scoped-clean through P2.
@steipete
steipete force-pushed the codex/direct-lease-guard branch from 77976d9 to 0e3ab66 Compare October 13, 2026 08:33

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0e3ab66715

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Quantity: 1,
SSHKeyNames: []string{key.Name},
UserData: core.CloudInitUserData(cfg, publicKey),
UserData: core.CloudInitUserData(core.WithGuestLeaseExpiry(cfg, core.DirectLeaseLabels(cfg, leaseID, slug, providerName, "", keep, now)), publicKey),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bootstrap Lambda guards in provisioning state

When a Lambda lease has an idle timeout shorter than its bootstrap time (especially two minutes or less), DirectLeaseLabels writes state=leased into the new guest snapshot. The timer starts two minutes after boot, and leased receives no provisioning grace, so it can power off the instance while Acquire is still waiting for SSH. Build the initial snapshot with state=provisioning, as the other provisioning-aware provider paths do.

Useful? React with 👍 / 👎.

@steipete
steipete merged commit c8010cb into main Oct 13, 2026
38 checks passed
@steipete
steipete deleted the codex/direct-lease-guard branch October 13, 2026 09:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant