Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

- Serve: report per-machine lease counts, runtime, and allocated CPU-hours; machine listings and the portal use durable completion rollups starting at deployment without a history scan or backfill, while usage retains full recorded history. [PR 2864](https://github.com/openclaw/crabbox/pull/2864).
- Coordinator: allow an explicit legacy AWS account attestation to reconcile pre-cutover retained records without enabling resource deletion. [PR 2861](https://github.com/openclaw/crabbox/pull/2861).

### Fixed

Expand Down
31 changes: 29 additions & 2 deletions docs/features/lifecycle-cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -878,8 +878,8 @@ reconciler performs no provider calls or lease mutations for them. Counters incl
recorded-scope VM/NIC/public-IP/disk reads; Hetzner needs original project/credential
evidence and its action journal; GCP needs immutable instance/project/zone checks;
Daytona needs its original API/organization/credential context. Historical records
without original scope remain unresolved indefinitely; they are never backfilled
from current settings.
without original scope remain unresolved unless eligible for the explicit legacy
AWS attestation below; current credentials never supply missing historical scope.

| Setting | Default | Meaning |
| --- | --- | --- |
Expand All @@ -889,9 +889,36 @@ from current settings.
| `CRABBOX_RETAINED_RECONCILE_GRACE_SECONDS` | `3600` | Terminal age before deletion, integer 60–31536000. |
| `CRABBOX_RETAINED_RECONCILE_BUDGET` | `200` | Maximum current-index entries hydrated per pass, integer 1–200. |
| `CRABBOX_RETAINED_RECONCILE_LOOKUP_BUDGET` | `64` | AWS lookups per pass, including STS, exact-instance batches/fallbacks and companion reads, integer 5–256. |
| `CRABBOX_RETAINED_RECONCILE_AWS_LEGACY_ACCOUNT` | unset | Operator-attested AWS account for eligible legacy records; exactly 12 digits. Invalid values are treated as unset and logged once per runtime, without the value. |

Invalid numeric settings use their defaults. Use
`crabbox admin retained-reconcile --json` to inspect cumulative outcomes, cursor
progress and backoff. The first rollout processes the roughly 9,000 retained AWS
records over repeated bounded passes; only records meeting all evidence
requirements leave the current index. Deletion remains off until explicitly enabled.

#### Attested legacy AWS scope

An operator who has independently established the original AWS account may set
`CRABBOX_RETAINED_RECONCILE_AWS_LEGACY_ACCOUNT` in coordinator runtime configuration.
This permits reconciliation of AWS records created strictly before
`2026-09-13T00:00:00Z`, the cutover following scope persistence in commit
`1efb91002`. The account is not inferred from current credentials or stored in
repository configuration. Admin status exposes `awsLegacyAccountConfigured` as a
boolean and counts eligible records as `legacy_scope_attested`.

Only records with an absent `providerScope`, a valid recorded AWS Region and an
instance ID are eligible. Present scopes, including empty or malformed values,
are never overridden; records at or after the cutover remain unresolved. All
other ownership, action, dedicated-host, retained-identity and key checks still
apply. STS must match the attested account, and the same fixed credentials,
exact-ID reads, companion-resource checks and two observations at least ten
minutes apart are required. The attested scope is persisted only together with
cleanup completion through the lease-record write funnel, with
`cleanupConfirmedBy: retained-reconcile-legacy-attested`.

Attested records never enter the deletion path, even with
`CRABBOX_RETAINED_RECONCILE_DELETE=1`; existing resources are reported as `present`.
A wrong attestation can only incorrectly mark audit records as cleaned up; it
cannot delete provider resources. Verify the historical account before enabling
the setting.
13 changes: 7 additions & 6 deletions internal/cli/admin_retained.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import (
type CoordinatorRetainedReconcile struct {
Version int `json:"version"`
Settings struct {
Mode string `json:"mode"`
Delete bool `json:"delete"`
Budget int `json:"budget"`
GraceSeconds int `json:"graceSeconds"`
LookupBudget int `json:"lookupBudget"`
Mode string `json:"mode"`
Delete bool `json:"delete"`
Budget int `json:"budget"`
GraceSeconds int `json:"graceSeconds"`
LookupBudget int `json:"lookupBudget"`
AWSLegacyAccountConfigured bool `json:"awsLegacyAccountConfigured"`
} `json:"settings"`
Cursor string `json:"cursor"`
Cycles int64 `json:"cycles"`
Expand Down Expand Up @@ -61,7 +62,7 @@ func (a App) adminRetainedReconcile(ctx context.Context, args []string) error {
if *jsonOut {
return json.NewEncoder(a.Stdout).Encode(result)
}
fmt.Fprintf(a.Stdout, "retained reconciliation mode=%s delete=%t budget=%d lookups=%d grace_seconds=%d scanned=%d cycles=%d last_pass=%s cursor=%s\n", result.Settings.Mode, result.Settings.Delete, result.Settings.Budget, result.Settings.LookupBudget, result.Settings.GraceSeconds, result.Scanned, result.Cycles, blank(result.LastPassAt, "-"), blank(result.Cursor, "-"))
fmt.Fprintf(a.Stdout, "retained reconciliation mode=%s delete=%t aws_legacy_account_configured=%t budget=%d lookups=%d grace_seconds=%d scanned=%d cycles=%d last_pass=%s cursor=%s\n", result.Settings.Mode, result.Settings.Delete, result.Settings.AWSLegacyAccountConfigured, result.Settings.Budget, result.Settings.LookupBudget, result.Settings.GraceSeconds, result.Scanned, result.Cycles, blank(result.LastPassAt, "-"), blank(result.Cursor, "-"))
var rows []string
for provider, outcomes := range result.Counters {
for outcome, count := range outcomes {
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/admin_retained_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func TestAdminRetainedReconcile(t *testing.T) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/admin/retained-reconcile" || r.Header.Get("Authorization") != "Bearer fixture-admin" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
_, _ = w.Write([]byte(`{"settings":{"mode":"on","delete":false,"budget":200,"lookupBudget":64,"graceSeconds":3600},"cycles":2,"scanned":400,"counters":{"aws":{"confirmed":20}},"backoff":{}}`))
_, _ = w.Write([]byte(`{"settings":{"mode":"on","delete":false,"budget":200,"lookupBudget":64,"graceSeconds":3600,"awsLegacyAccountConfigured":true},"cycles":2,"scanned":400,"counters":{"aws":{"confirmed":20,"legacy_scope_attested":25}},"backoff":{}}`))
}))
defer server.Close()
t.Setenv("CRABBOX_COORDINATOR", server.URL)
Expand All @@ -33,10 +33,10 @@ func TestAdminRetainedReconcile(t *testing.T) {
}
if jsonOutput {
var result CoordinatorRetainedReconcile
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil || (result.Counters["aws"]["confirmed"] != 20 || result.Settings.LookupBudget != 64 || result.Settings.GraceSeconds != 3600) {
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil || (result.Counters["aws"]["confirmed"] != 20 || result.Counters["aws"]["legacy_scope_attested"] != 25 || !result.Settings.AWSLegacyAccountConfigured || result.Settings.LookupBudget != 64 || result.Settings.GraceSeconds != 3600) {
t.Fatalf("invalid JSON: %s", stdout.String())
}
} else if !strings.Contains(stdout.String(), "aws confirmed=20") {
} else if !strings.Contains(stdout.String(), "aws confirmed=20") || !strings.Contains(stdout.String(), "aws legacy_scope_attested=25") || !strings.Contains(stdout.String(), "aws_legacy_account_configured=true") {
t.Fatal(stdout.String())
}
})
Expand Down
92 changes: 80 additions & 12 deletions worker/src/retained-lease-reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,45 @@ import type { Env, LeaseRecord, ProviderMachine } from "./types";
export const retainedReconcileStateKey = "retained-reconcile-state";
export const retainedAbsenceIntervalMs = 10 * 60_000;
const claimDurationMs = 5 * 60_000;
// Scope persistence landed in 1efb91002; only older records may use operator attestation.
const awsLegacyScopeCutoverMs = Date.parse("2026-09-13T00:00:00Z");
let warnedInvalidAWSLegacyAccount = false;

function awsLegacyAccount(env: Env): string | undefined {
const account = env.CRABBOX_RETAINED_RECONCILE_AWS_LEGACY_ACCOUNT;
if (account?.length === 12 && /^\d{12}$/.test(account)) return account;
if (account !== undefined && !warnedInvalidAWSLegacyAccount) {
warnedInvalidAWSLegacyAccount = true;
console.warn("CRABBOX_RETAINED_RECONCILE_AWS_LEGACY_ACCOUNT is invalid; ignoring it");
}
return undefined;
}

// sanitizeAWSRegion throws on malformed input; one bad legacy record must not
// fail every reconcile pass at the same cursor.
function sanitizedRegionMatches(region: string): boolean {
try {
return sanitizeAWSRegion(region) === region;
} catch {
return false;
}
}

function attestedAWSLegacyScope(lease: LeaseRecord, account?: string): string | undefined {
if (
lease.provider === "aws" &&
lease.providerScope === undefined &&
Comment on lines +34 to +48

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 legacy AWS attestation behind the provider adapter

This adds AWS-specific account formatting, region and instance-ID eligibility, and rollout-cutover policy directly to coordinator reconciliation. The repository requires provider-specific reconciliation, resource identity, and rollout compatibility to live behind provider adapters, so this logic should be exposed through an AWS adapter capability while core consumes a provider-neutral result.

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

Useful? React with 👍 / 👎.

account !== undefined &&
account.length === 12 &&
/^\d{12}$/.test(account) &&
lease.region &&
sanitizedRegionMatches(lease.region) &&
/^i-[a-f0-9]{8,17}$/.test(lease.cloudID) &&
Date.parse(lease.createdAt) < awsLegacyScopeCutoverMs

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 Reject noncanonical creation timestamps

When a stored legacy record has a malformed createdAt such as "0", "1", or "2026-09-12junk", JavaScript's permissive Date.parse still returns a pre-cutover date, so the record becomes eligible for attestation without valid evidence that it predates the scope-persistence rollout. This can incorrectly publish cleanup and persist the attested account for malformed records; validate the expected timestamp format before comparing it with the cutoff.

Useful? React with 👍 / 👎.

)
return `aws:account:${account}`;
return undefined;
}

type Counters = Record<string, Record<string, number>>;
export interface RetainedReconcileState {
Expand All @@ -37,6 +76,7 @@ export interface RetainedSettings {
budget: number;
lookupBudget: number;
intervalSeconds: number;
awsLegacyAccountConfigured: boolean;
}
function integer(value: string | undefined, fallback: number, min: number, max: number): number {
if (!value || !/^\d+$/.test(value)) return fallback;
Expand All @@ -52,6 +92,7 @@ export function retainedReconcileSettings(env: Env): RetainedSettings {
budget: integer(env.CRABBOX_RETAINED_RECONCILE_BUDGET, 200, 1, 200),
lookupBudget: integer(env.CRABBOX_RETAINED_RECONCILE_LOOKUP_BUDGET, 64, 5, 256),
intervalSeconds: integer(env.CRABBOX_RETAINED_RECONCILE_INTERVAL_SECONDS, 120, 60, 86_400),
awsLegacyAccountConfigured: awsLegacyAccount(env) !== undefined,
};
}
function emptyState(): RetainedReconcileState {
Expand Down Expand Up @@ -129,7 +170,10 @@ export class RetainedLookupBudget {
}

// Skip uncertain allocation/action ownership; a missing instance cannot retire these journals.
export function retainedUnresolvedReason(lease: LeaseRecord): string | undefined {
export function retainedUnresolvedReason(
lease: LeaseRecord,
legacyAccount?: string,
): string | undefined {
if (isRegisteredLease(lease)) return "registered_resource";
if (!lease.cloudID) return "missing_identity";
if (
Expand All @@ -151,8 +195,8 @@ export function retainedUnresolvedReason(lease: LeaseRecord): string | undefined
return "no_scoped_exact_read";
}
if (!/^i-[a-f0-9]{8,17}$/.test(lease.cloudID)) return "missing_identity";
if (!/^aws:account:\d{12}$/.test(lease.providerScope ?? "") || !lease.region)
return "missing_original_scope";
const scope = lease.providerScope ?? attestedAWSLegacyScope(lease, legacyAccount);
if (!/^aws:account:\d{12}$/.test(scope ?? "") || !lease.region) return "missing_original_scope";
try {
if (sanitizeAWSRegion(lease.region) !== lease.region) return "invalid_scope";
} catch {
Expand Down Expand Up @@ -228,6 +272,7 @@ export async function observeAWSRetained(
): Promise<Map<string, RetainedObservation>> {
const result = new Map<string, RetainedObservation>();
if (!leases.length) return result;
const legacyAccount = awsLegacyAccount(env);
try {
budget.take(); // STS identity is part of the provider lookup budget.
await new EC2SpotClient(env, leases[0]!.region!, undefined, budget.signal).withLeaseOperation(
Expand All @@ -236,7 +281,10 @@ export async function observeAWSRetained(
if (
leases.some(
(lease) =>
lease.providerScope !== `aws:account:${identity.account}` ||
(lease.providerScope ??
(!retainedUnresolvedReason(lease, legacyAccount)
? attestedAWSLegacyScope(lease, legacyAccount)
: undefined)) !== `aws:account:${identity.account}` ||
Comment on lines +284 to +287

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 Avoid globally backing off AWS on attestation mismatch

When the configured legacy account differs from the STS identity, this classifies every eligible legacy record as scope_mismatch; maintainRetainedLeases then increments the shared state.backoff.aws for each record, and subsequent passes skip observations for every AWS scope while that backoff is active. A typo in this optional rollout setting can therefore repeatedly suspend reconciliation of correctly scoped AWS records for up to the one-hour cap, so legacy-attestation mismatches should not activate the provider-wide backoff.

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

Useful? React with 👍 / 👎.

lease.region !== session.region,
)
) {
Expand Down Expand Up @@ -348,6 +396,7 @@ export async function maintainRetainedLeases(
): Promise<void> {
const settings = retainedReconcileSettings(env);
if (settings.mode === "off") return;
const legacyAccount = awsLegacyAccount(env);
const now = hooks.now ?? Date.now;
const started = now();
const stored = await storage.get<RetainedReconcileState>(retainedReconcileStateKey, {
Expand Down Expand Up @@ -409,10 +458,11 @@ export async function maintainRetainedLeases(
!lease ||
!leaseCurrentIndex.member(lease) ||
leaseIsLive(lease) ||
retainedUnresolvedReason(lease)
retainedUnresolvedReason(lease, legacyAccount)
)
continue;
const group = `${lease.providerScope}:${lease.region}`;
const scope = lease.providerScope ?? attestedAWSLegacyScope(lease, legacyAccount);
const group = `${scope}:${lease.region}`;
groups.set(group, [...(groups.get(group) ?? []), lease]);
}
if ((state.backoff["aws"]?.until ?? 0) <= started) {
Expand Down Expand Up @@ -444,7 +494,7 @@ export async function maintainRetainedLeases(
if (
lease &&
!leaseIsLive(lease) &&
!retainedUnresolvedReason(lease) &&
!retainedUnresolvedReason(lease, legacyAccount) &&
observations.get(lease.id)?.outcome === "lookup_budget"
)
break;
Expand All @@ -455,11 +505,13 @@ export async function maintainRetainedLeases(
count(lease?.provider ?? "all", "not_terminal");
continue;
}
const reason = retainedUnresolvedReason(lease);
const reason = retainedUnresolvedReason(lease, legacyAccount);
if (reason) {
count(lease.provider, reason);
continue;
}
const attestedScope = attestedAWSLegacyScope(lease, legacyAccount);
if (attestedScope) count(lease.provider, "legacy_scope_attested");
const observation = observations.get(lease.id);
if (!observation) {
count(lease.provider, "backoff");
Expand All @@ -484,7 +536,9 @@ export async function maintainRetainedLeases(
if (settings.mode === "report") continue;
const snapshot = baseline(lease);
// oxlint-disable-next-line eslint/no-await-in-loop -- fingerprint is bound to this exact candidate before a short transactional commit.
const fingerprint = await sha256Hex(snapshot);
const fingerprint = await sha256Hex(
attestedScope ? JSON.stringify([snapshot, attestedScope]) : snapshot,
);
if (outcome === "absent" && observation.credentialFingerprint) {
// oxlint-disable-next-line eslint/no-await-in-loop -- only publication holds the lifecycle fence; all cloud I/O has finished.
await hooks.exclusive(() =>
Expand Down Expand Up @@ -512,7 +566,11 @@ export async function maintainRetainedLeases(
current.retainedReconcileDeleteClaim = "awaiting-confirmation";
delete current.cleanupStartedAt;
delete current.cleanupClaimExpiresAt;
observationFingerprint = await sha256Hex(baseline(current));
observationFingerprint = await sha256Hex(
attestedScope
? JSON.stringify([baseline(current), attestedScope])
: baseline(current),
);
}
const previous = current.retainedReconcileObservation;
if (previous && previous.credentialFingerprint !== observation.credentialFingerprint) {
Expand Down Expand Up @@ -545,7 +603,10 @@ export async function maintainRetainedLeases(
delete current.providerKeyCleanupPending;
delete current.providerKeyCleanupID;
completeLeaseProviderCleanup(current, observedAt);
current.cleanupConfirmedBy = "retained-reconcile";
current.cleanupConfirmedBy = attestedScope
? "retained-reconcile-legacy-attested"
: "retained-reconcile";
if (attestedScope) current.providerScope = attestedScope;
count("aws", "confirmed");
} else count("aws", "first_absence");
await putLeaseRecord(transaction, current);
Expand All @@ -565,7 +626,14 @@ export async function maintainRetainedLeases(
}
}),
);
if (!settings.delete || !observation.server || !observation.credentialFingerprint) continue;
// Attestation permits audit completion only, never provider deletion.
if (
attestedScope ||
!settings.delete ||
!observation.server ||
!observation.credentialFingerprint
)
continue;
const terminalAt = Date.parse(lease.endedAt ?? lease.releasedAt ?? "");
if (!Number.isFinite(terminalAt) || started - terminalAt < settings.graceSeconds * 1000) {
count("aws", "grace");
Expand Down
3 changes: 2 additions & 1 deletion worker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface Env {
CRABBOX_RETAINED_RECONCILE_BUDGET?: string;
CRABBOX_RETAINED_RECONCILE_LOOKUP_BUDGET?: string;
CRABBOX_RETAINED_RECONCILE_INTERVAL_SECONDS?: string;
CRABBOX_RETAINED_RECONCILE_AWS_LEGACY_ACCOUNT?: string;

CRABBOX_LEASE_LIFETIME_MODE?: string;
CRABBOX_BATCH_MAX_AGE_SECONDS?: string;
Expand Down Expand Up @@ -519,7 +520,7 @@ export interface LeaseRecord {
firstAbsentAt: string;
lastAbsentAt: string;
};
cleanupConfirmedBy?: "retained-reconcile";
cleanupConfirmedBy?: "retained-reconcile" | "retained-reconcile-legacy-attested";
serve?: import("./serve").ServeAssignment;
serveIntent?: string;
serveUsageRecordedAt?: string;
Expand Down
28 changes: 27 additions & 1 deletion worker/test/fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61418,12 +61418,38 @@ describe("retained reconciliation admin status", () => {
const allowed = await send("fixture-admin");
expect(allowed.status).toBe(200);
expect(await allowed.json()).toMatchObject({
settings: { mode: "on", delete: false },
settings: { mode: "on", delete: false, awsLegacyAccountConfigured: false },
counters: {},
scanned: 0,
});
});
});
describe("retained reconciliation legacy admin status", () => {
it("exposes the legacy attestation counter and only a configured boolean", async () => {
const env = { CRABBOX_RETAINED_RECONCILE_AWS_LEGACY_ACCOUNT: "123456789012" } as Env;
const storage = new MemoryStorage();
storage.seed("retained-reconcile-state", {
version: 1,
cycles: 1,
scanned: 1,
counters: { aws: { legacy_scope_attested: 1 } },
backoff: {},
});
const fleet = testFleet(storage, {}, env);
const response = await fleet.fetch(
new Request("https://coordinator.test/v1/admin/retained-reconcile", {
headers: { "x-crabbox-admin": "true" },
}),
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toMatchObject({
settings: { awsLegacyAccountConfigured: true },
counters: { aws: { legacy_scope_attested: 1 } },
});
expect(JSON.stringify(body)).not.toContain(env.CRABBOX_RETAINED_RECONCILE_AWS_LEGACY_ACCOUNT);
});
});
describe("coordinator lease lifetime policy", () => {
const headers = { "x-crabbox-owner": "alice@example.com", "x-crabbox-org": "example-org" };
function fixture(overrides: Partial<LeaseRecord> = {}, env: Partial<Env> = {}) {
Expand Down
Loading
Loading