Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## 0.48.1 (Unreleased)

### Added

- Added an opt-in Islo idle-pause policy behind `--islo-idle-pause` / `islo.idlePause` / `CRABBOX_ISLO_IDLE_PAUSE`, off by default: when enabled, `--idle-timeout` is sent on create as the sandbox `pause_after_idle` lifecycle field with `auto_resume` pinned to `never`, and a `--reclaim` whose idle timeout differs from the sandbox's immutable policy fails instead of adopting it. It stays opt-in because Islo does not document what counts as activity, so an opted-in run longer than `--idle-timeout` can be paused mid-exec; raise `--idle-timeout` past the longest run you expect. With the knob off, the create request carries no `lifecycle` object, exactly as before. `--ttl` is never handed to Islo as a deletion deadline; Crabbox remains the only thing that deletes an Islo lease.

### Fixed

- Scoped automatic SSH failure bundles to the current uploaded script, excluding retained uploads and neighboring files while preserving explicit artifact/download selection.
Expand All @@ -15,6 +19,7 @@
- Added exact-source abandonment for unresolved ordinary Machine0 checkpoints: dispose of the positively identified source through its existing claim owner while retaining the unknown image obligation and rejecting later fork, prune, or local deletion.
- Released ordinary Machine0 checkpoint reservations when the provider proves image submission was never attempted, keeping failed captures from blocking source cleanup while retaining interrupted or uncertain submissions.
- Made explicit coordinator Stop share one five-minute cancellation budget across inspection, claim waits, release, and observation, and made local daemon lock waits honor cancellation without losing confirmed cleanup results.
- Fixed reused Islo leases being driven while paused: `run --id` now checks the sandbox status and resumes it before sync and exec, as `ssh` already did.
- Fixed Parallels clone destinations to pass the configured parent directory to `prlctl --dst`, letting Parallels name and create the VM bundle beneath it.
- Preserved exclusive ready-pool lease ownership across typed and legacy pools, including existing duplicate records, and prevented expired or quarantined borrows from becoming ready through return or re-registration.
- Reduced SSH startup round trips by checking readiness before transport diagnosis and skipping unused run telemetry when no coordinator run handle exists.
Expand Down
33 changes: 33 additions & 0 deletions docs/features/islo.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ islo:
vcpus: 2
memoryMB: 4096
diskGB: 20
idlePause: false
```

Defaults: `baseUrl` `https://api.islo.dev`, `workdir` `crabbox`, `vcpus` `2`,
Expand Down Expand Up @@ -72,6 +73,7 @@ variable:
| `vcpus` | `--islo-vcpus` | `CRABBOX_ISLO_VCPUS` |
| `memoryMB` | `--islo-memory-mb` | `CRABBOX_ISLO_MEMORY_MB` |
| `diskGB` | `--islo-disk-gb` | `CRABBOX_ISLO_DISK_GB` |
| `idlePause` | `--islo-idle-pause` | `CRABBOX_ISLO_IDLE_PAUSE` |

`gatewayProfile` accepts an Islo gateway profile name or id and is passed
opaquely in the sandbox create request. Gateway profiles are created and
Expand All @@ -88,6 +90,37 @@ crabbox resume --provider islo blue-lobster
crabbox stop --provider islo blue-lobster
```

## Idle pause policy (opt-in)

Off by default. With `--islo-idle-pause` (or `islo.idlePause: true`), Crabbox
sends `--idle-timeout` on create as the Islo `lifecycle` object's
`pause_after_idle`, asking Islo to pause a sandbox that has been idle that long
rather than leave it billing for CPU and memory. Without the knob, no
`lifecycle` object is sent at all and `--idle-timeout` stays local bookkeeping,
exactly as in earlier releases.

It is opt-in because what Islo counts as activity is undocumented: an opted-in
run longer than `--idle-timeout` may be paused mid-exec, and a warm lease
serving a share may be paused without a Crabbox call. That tradeoff is worth
choosing deliberately — see
[the provider reference](../providers/islo.md#idle-pause-policy-opt-in).

`auto_resume` is pinned to `never` whenever the policy is sent: Crabbox checks
the sandbox status and resumes it itself before reusing a lease over `run --id`
or `ssh`, so an explicit `crabbox pause` is not undone by a background policy.
That resume-before-reuse check is unconditional, since an Islo tenant default or
an explicit `crabbox pause` can leave a sandbox paused with the knob off too.

`--ttl` is deliberately not mapped to `delete_after`, opted in or not: Crabbox
stays the only thing that deletes a Crabbox lease, so for Islo `--ttl` has no
provider-side effect and a kept sandbox lives until an explicit `stop`.

Islo fixes the policy at create time, so with the knob on, a `--reclaim` of a
sandbox whose reported `pause_after_idle` differs from the current
`--idle-timeout` fails with exit 2 instead of pretending the new value applies.
With the knob off, reclaim ignores the sandbox's lifecycle entirely, because
Crabbox is not claiming anything about it.

## Behavior

- **warmup** creates a `crabbox-...` Islo sandbox and records a local lease ID of
Expand Down
65 changes: 64 additions & 1 deletion docs/providers/islo.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ islo:
vcpus: 2
memoryMB: 4096
diskGB: 20
idlePause: false
```

Provider flags (each overrides the matching `islo.*` config key):
Expand All @@ -92,13 +93,14 @@ Provider flags (each overrides the matching `islo.*` config key):
--islo-vcpus
--islo-memory-mb
--islo-disk-gb
--islo-idle-pause
```

Every key also reads a `CRABBOX_ISLO_*` environment variable, which takes
precedence over the config file: `CRABBOX_ISLO_BASE_URL`, `CRABBOX_ISLO_IMAGE`,
`CRABBOX_ISLO_WORKDIR`, `CRABBOX_ISLO_GATEWAY_PROFILE`,
`CRABBOX_ISLO_SNAPSHOT_NAME`, `CRABBOX_ISLO_VCPUS`, `CRABBOX_ISLO_MEMORY_MB`,
and `CRABBOX_ISLO_DISK_GB`.
`CRABBOX_ISLO_DISK_GB`, and `CRABBOX_ISLO_IDLE_PAUSE`.

The resolved defaults are kept in Crabbox config for display and override
compatibility, but the Islo create request omits implicit default `image`,
Expand Down Expand Up @@ -137,6 +139,67 @@ rejected before workspace preparation and sync.
5. Require an `exit` event before treating a stream as successful.
6. Delete the sandbox on release unless the lease is kept.

### Idle pause policy (opt-in)

`--islo-idle-pause` / `islo.idlePause` / `CRABBOX_ISLO_IDLE_PAUSE` opts a
sandbox into a provider-enforced idle pause. It is **off by default**. When it
is off, the create request carries no `lifecycle` object at all — the same wire
request earlier releases sent — and `--idle-timeout` remains local Crabbox
bookkeeping with no provider-side effect.

When it is on, Crabbox sends one lease setting to Islo in the create request's
`lifecycle` object: `--idle-timeout` becomes `pause_after_idle` (seconds),
asking Islo to pause a sandbox that has been idle that long instead of leaving
it billing for CPU and memory.

It is opt-in rather than a default with a caveat because the safety of the
policy under an active workload is not established. On 2026-08-31, one manual
check against the live API observed the field being enforced rather than merely
recorded: a sandbox created with `pause_after_idle=60` still reported `running`
at 75s and `paused` at 90s while `GET /sandboxes` polled it every 15s
throughout, so control-plane reads of that kind — what `crabbox status` does —
did not hold the idle clock off. That is provider behavior observed once at a
point in time, not a contract Islo documents, and nothing in this repository
reproduces it: the tests here cover only the request Crabbox sends. What else
counts as activity is undocumented: if an exec that is still running, or
traffic to a published share or a tailnet peer, does not hold the idle clock
off, then an opted-in `crabbox run` longer than `--idle-timeout` can be paused
mid-exec, and a warm lease serving a share can be paused after the idle timeout
elapses without a Crabbox call. Crabbox cannot detect that case — nothing in
the Islo API reports why a sandbox paused — so raising `--idle-timeout` past the
longest run you expect is the only mitigation, and `crabbox resume` recovers a
sandbox that was paused under you. Turning the knob on is therefore a deliberate
cost-versus-interruption tradeoff rather than something to inherit on upgrade.

`auto_resume` is pinned to `never` whenever the policy is sent, and Crabbox
resumes a paused sandbox itself: `crabbox run --id` and `crabbox ssh` check the
sandbox status and resume it before driving it, and `crabbox resume` resumes on
demand, so an explicit `crabbox pause` is not undone by a background policy. A
resume is billable, so it is worth triggering deliberately rather than as the
side effect of some other request. The resume-before-reuse check runs whether or
not the knob is set, because an Islo tenant default or an explicit `crabbox
pause` can leave a reused lease paused either way.

`--ttl` is *not* sent as `delete_after`, opted in or not. A provider-side
deletion deadline would let Islo destroy a sandbox that Crabbox still holds a
lease claim on, possibly mid-run, so Crabbox stays the only thing that deletes a
Crabbox lease. For Islo `--ttl` therefore has no provider-side effect: a kept
sandbox lives until an explicit `stop` (or a `--stop-after` policy).
`pause_after` is left to the Islo tenant default because Crabbox has no generic
absolute pause deadline.

The policy is immutable once the sandbox exists — Islo exposes no lifecycle
update — so with the knob on, an explicit `--reclaim` of a sandbox whose
reported `pause_after_idle` disagrees with the current `--idle-timeout` fails
with exit 2 rather than adopting the lease under a policy that is not in force.
Reuse it with a matching `--idle-timeout` or create a new lease. With the knob
off, reclaim does not inspect the sandbox's lifecycle at all: Crabbox is
claiming nothing about it, so a sandbox carrying a pause policy from an Islo
tenant default, another tool, or an opted-in run stays adoptable. Commands that
talk to a sandbox without resolving it first (for example `crabbox bridge`
publishing a share) can still act on a paused sandbox; if Islo rejects the
request, run `crabbox resume` and retry.

`crabbox status --wait` polls the sandbox every 2 seconds until it reports
`running`, bounded by `--wait-timeout` (default 5 minutes). If the sandbox
enters a terminal state (`failed`, `stopped`, `stopping`, or `deleted`) before
Expand Down
11 changes: 11 additions & 0 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,10 @@ type IsloConfig struct {
VCPUs int
MemoryMB int
DiskGB int
// IdlePause opts a sandbox into a provider-enforced idle pause derived from
// IdleTimeout. Off by default: the provider adapter sends no lifecycle
// policy unless it is set.
IdlePause bool
}

type FreestyleConfig struct {
Expand Down Expand Up @@ -4225,6 +4229,7 @@ type fileIsloConfig struct {
VCPUs int `yaml:"vcpus,omitempty"`
MemoryMB int `yaml:"memoryMB,omitempty"`
DiskGB int `yaml:"diskGB,omitempty"`
IdlePause *bool `yaml:"idlePause,omitempty"`
}

type fileTenkiConfig struct {
Expand Down Expand Up @@ -6919,6 +6924,9 @@ func applyFileConfigWithTrustAndProviderSource(cfg *Config, file fileConfig, tru
cfg.Islo.DiskGB = file.Islo.DiskGB
cfg.isloDiskGBExplicit = true
}
if file.Islo.IdlePause != nil {
cfg.Islo.IdlePause = *file.Islo.IdlePause
}
}
if file.Freestyle != nil {
if file.Freestyle.APIURL != "" {
Expand Down Expand Up @@ -9205,6 +9213,9 @@ func applyEnv(cfg *Config) error {
cfg.isloDiskGBExplicit = true
}
}
if value, ok := getenvBool("CRABBOX_ISLO_IDLE_PAUSE"); ok {
cfg.Islo.IdlePause = value
}
cfg.Freestyle.APIKey = getenv("CRABBOX_FREESTYLE_API_KEY", getenv("FREESTYLE_API_KEY", cfg.Freestyle.APIKey))
cfg.Freestyle.APIURL = getenv("CRABBOX_FREESTYLE_API_URL", getenv("FREESTYLE_API_URL", cfg.Freestyle.APIURL))
cfg.Freestyle.Workdir = getenv("CRABBOX_FREESTYLE_WORKDIR", cfg.Freestyle.Workdir)
Expand Down
58 changes: 58 additions & 0 deletions internal/cli/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ func clearConfigEnv(t *testing.T) {
"CRABBOX_ISLO_VCPUS",
"CRABBOX_ISLO_MEMORY_MB",
"CRABBOX_ISLO_DISK_GB",
"CRABBOX_ISLO_IDLE_PAUSE",
"CRABBOX_FREESTYLE_API_KEY",
"FREESTYLE_API_KEY",
"CRABBOX_FREESTYLE_API_URL",
Expand Down Expand Up @@ -635,6 +636,63 @@ func TestIsloCreateDefaultsTrackExplicitConfigAndEnvironment(t *testing.T) {
}
}

// TestIsloIdlePauseDefaultsOffAndOptsInExplicitly pins the opt-in: the shipped
// config leaves the Islo idle pause off despite a positive default idle timeout,
// and only an explicit config-file or environment opt-in turns it on.
func TestIsloIdlePauseDefaultsOffAndOptsInExplicitly(t *testing.T) {
base := baseConfig()
if base.IdleTimeout <= 0 {
t.Fatalf("base idle timeout=%s want the positive shipped default", base.IdleTimeout)
}
if base.Islo.IdlePause {
t.Fatal("islo idle pause must ship off")
}

untouched := base
if err := applyFileConfig(&untouched, fileConfig{Islo: &fileIsloConfig{Workdir: "crabbox"}}); err != nil {
t.Fatal(err)
}
if untouched.Islo.IdlePause {
t.Fatal("an islo config block without idlePause must not opt in")
}

optIn := true
fromFile := base
if err := applyFileConfig(&fromFile, fileConfig{Islo: &fileIsloConfig{IdlePause: &optIn}}); err != nil {
t.Fatal(err)
}
if !fromFile.Islo.IdlePause {
t.Fatal("islo.idlePause: true did not opt in")
}

clearConfigEnv(t)
sealed := base
if err := applyEnv(&sealed); err != nil {
t.Fatal(err)
}
if sealed.Islo.IdlePause {
t.Fatal("clearConfigEnv must unset CRABBOX_ISLO_IDLE_PAUSE; a value exported in the developer's shell must not opt in")
}

t.Setenv("CRABBOX_ISLO_IDLE_PAUSE", "1")
fromEnv := base
if err := applyEnv(&fromEnv); err != nil {
t.Fatal(err)
}
if !fromEnv.Islo.IdlePause {
t.Fatal("CRABBOX_ISLO_IDLE_PAUSE=1 did not opt in")
}

t.Setenv("CRABBOX_ISLO_IDLE_PAUSE", "0")
envOff := fromFile
if err := applyEnv(&envOff); err != nil {
t.Fatal(err)
}
if envOff.Islo.IdlePause {
t.Fatal("CRABBOX_ISLO_IDLE_PAUSE=0 did not override a config-file opt-in")
}
}

func TestProviderExplicitMarkerHelpers(t *testing.T) {
cfg := Config{
SSHUser: "alice",
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/providers_describe_binary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,9 @@ func TestProvidersDescribeBuiltBinaryContract(t *testing.T) {
t.Fatalf("run --help exit=%d stdout=%q stderr=%q", helpCode, helpStdout, helpStderr)
}
digest := sha256.Sum256(helpStderr)
const baselineSHA256 = "98a17ef99fb5742abca8132c95a6ea1fee035b65ac770a22ebfd84f8643da6ae"
if got := hex.EncodeToString(digest[:]); got != baselineSHA256 || len(helpStderr) != 61825 {
t.Fatalf("run --help changed: sha256=%s bytes=%d, want sha256=%s bytes=61825", got, len(helpStderr), baselineSHA256)
const baselineSHA256 = "3b1c2db73ecf89d726b7cd0e2f42074d3dd66c375a8a758935c5f7495268b79c"
if got := hex.EncodeToString(digest[:]); got != baselineSHA256 || len(helpStderr) != 61931 {
t.Fatalf("run --help changed: sha256=%s bytes=%d, want sha256=%s bytes=61931", got, len(helpStderr), baselineSHA256)
}
}

Expand Down
24 changes: 24 additions & 0 deletions internal/providers/islo/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type isloFlagValues struct {
VCPUs *int
MemoryMB *int
DiskGB *int
IdlePause *bool
}

func RegisterIsloProviderFlags(fs *flag.FlagSet, defaults Config) any {
Expand All @@ -73,6 +74,7 @@ func RegisterIsloProviderFlags(fs *flag.FlagSet, defaults Config) any {
VCPUs: fs.Int("islo-vcpus", defaults.Islo.VCPUs, "Islo sandbox vCPUs"),
MemoryMB: fs.Int("islo-memory-mb", defaults.Islo.MemoryMB, "Islo sandbox memory in MB"),
DiskGB: fs.Int("islo-disk-gb", defaults.Islo.DiskGB, "Islo sandbox disk in GB"),
IdlePause: fs.Bool("islo-idle-pause", defaults.Islo.IdlePause, "ask Islo to pause the sandbox after --idle-timeout of inactivity (off by default)"),
}
}

Expand Down Expand Up @@ -109,6 +111,9 @@ func ApplyIsloProviderFlags(cfg *Config, fs *flag.FlagSet, values any) error {
cfg.Islo.DiskGB = *v.DiskGB
core.MarkIsloDiskGBExplicit(cfg)
}
if flagWasSet(fs, "islo-idle-pause") {
cfg.Islo.IdlePause = *v.IdlePause
}
return nil
}

Expand Down Expand Up @@ -217,6 +222,17 @@ func (b *isloBackend) Run(ctx context.Context, req RunRequest) (RunResult, error
}
tailnetReady = err == nil && meta.Enabled
}
// A reused lease can be paused: `crabbox pause` pauses it outright, an
// Islo tenant default can, and so can the opt-in idle pause policy
// (isloLifecycleForConfig). Crabbox does not drive a paused sandbox --
// whether Islo would accept a sync or exec request against one, or wake
// it, is not documented -- so bring it back first, exactly as the SSH
// resolve path does. This is unconditional: it does not depend on the
// idle-pause knob, because Crabbox is not the only thing that can pause
// a sandbox.
if _, err := b.resolveRunningSandbox(ctx, client, name, core.ResolveRequest{}); err != nil {
return RunResult{}, err
}
}
shouldStop := acquired && !req.Keep
if shouldStop {
Expand Down Expand Up @@ -633,6 +649,7 @@ func (b *isloBackend) createSandbox(ctx context.Context, client isloAPI, repo Re
if b.cfg.Islo.DiskGB > 0 && (b.cfg.Islo.DiskGB != base.Islo.DiskGB || core.IsloDiskGBExplicit(b.cfg)) {
create.DiskGb = intValue(b.cfg.Islo.DiskGB)
}
create.Lifecycle = isloLifecycleForConfig(b.cfg)
sandbox, err := client.CreateSandbox(ctx, create)
if err != nil {
return "", "", "", isloError("create sandbox", err)
Expand Down Expand Up @@ -773,6 +790,13 @@ func (b *isloBackend) resolveLeaseIDForRepo(ctx context.Context, client isloAPI,
if sandbox == nil || sandbox.GetName() != name {
return "", "", "", exit(4, "islo sandbox %q was not found; refusing to create a local claim", name)
}
// Islo fixes the lifecycle policy at create time, so when idle pausing is
// opted in, adopting a sandbox whose policy disagrees with this config must
// fail instead of leaving the caller with an idle timeout that was never sent
// for this sandbox. With the knob off this is a no-op.
if err := isloLifecycleConflict(name, sandbox, b.cfg); err != nil {
return "", "", "", err
}
if err := claimLeaseForRepoProviderWithPond(leaseID, slug, isloProvider, b.cfg.Pond, repoRoot, b.cfg.IdleTimeout, true); err != nil {
return "", "", "", err
}
Expand Down
Loading
Loading