paperclipai/paperclip · critical

CreateOS setup failed and cleanup is unconfirmed for sandbox

Error message

CreateOS setup failed and cleanup is unconfirmed for sandbox ${sandbox.id}.

What it means

During lease acquisition, CreateOS plugin provisions a sandbox, transitions it to running, prepares the workspace, and uploads a lease marker. If any of those steps fail, acquire tries to destroy the partially-created sandbox so no orphan is left behind. This error is thrown when that compensating destroySandbox call itself fails, meaning the setup failed AND the provider may still be billing for a live sandbox the host no longer references.

Solutions

  1. Check the CreateOS dashboard/API for a sandbox with the ID in the message and destroy it manually to avoid orphaned billing.
  2. Retry acquire once connectivity is confirmed — the failed sandbox was never returned as a lease, so a fresh acquire creates a new sandbox.
  3. Verify the API key has both sandbox-create and sandbox-delete permissions for the configured apiUrl.
  4. If destroys consistently fail, probe the environment (onEnvironmentProbe) to check API health before acquiring leases.

Example fix

// before: destroy failure hides the original error entirely
catch { throw new Error(`CreateOS setup failed and cleanup is unconfirmed for sandbox ${sandbox.id}.`); }
// after: run a probe first and surface the original cause when destroying
const probe = await fetch(`${config.apiUrl}/sandboxes/${sandbox.id}`, { headers: { authorization: `Bearer ${resolveApiKey(config)}` } });
if (probe.ok) console.warn(`sandbox ${sandbox.id} may still be live; destroy it manually`);
throw error; // rethrow the original setup failure for better diagnosis
Defensive patterns

Strategy: try-catch

Validate before calling

// check API reachability and credentials before acquiring
const res = await fetch(`${config.apiUrl}/health`, { headers: { authorization: `Bearer ${apiKey}` } });
if (!res.ok) throw new Error(`CreateOS API unhealthy (${res.status}); skipping acquire`);

Try / catch

try {
  lease = await acquireLease(params);
} catch (e) {
  if (e.message.includes("cleanup is unconfirmed")) {
    const sandboxId = e.message.match(/sandbox ([^\s.]+)/)?.[1];
    logger.error(`orphaned sandbox possible: ${sandboxId}; reconcile via CreateOS API`);
  }
  throw e;
}

Prevention

When it happens

Trigger: acquire() fails partway (transition to 'running' fails, workspace mkdir exec fails, marker upload fails) and then the compensating client.destroySandbox(sandbox.id) also throws — e.g. the CreateOS API went down between the two calls, the API key lacks delete permission, or a transient network partition interrupts the destroy request.

Common situations: CreateOS API outage or flaky network during environment probe/acquire; sandbox stuck in a transitional state that cannot be deleted; API key created with create but not delete scope; rate limiting that lets create succeed but rejects the immediate destroy call.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/aac92d31524e287c. Report an issue: GitHub.

Appendix: source

Thrown at packages/plugins/sandbox-providers/createos/src/plugin.ts:49

    const data = await client.json(`/sandboxes/${sandbox.id}/exec`, "POST", {
      cmd: "/bin/bash", args: ["-lc", `mkdir -p -- ${shellQuote(CWD)}`],
    }, signal);
    if (object(data.result).exit_code !== 0) throw new Error("CreateOS workspace preparation failed; the image must provide Bash.");
    const marker = randomUUID();
    await client.upload(sandbox.id, MARKER, marker, signal);
    return {
      providerLeaseId: sandbox.id,
      metadata: {
        provider: "createos", apiUrl: config.apiUrl,
        companyId: params.companyId, environmentId: params.environmentId,
        remoteCwd: CWD, shellCommand: "bash", marker,
        shape: config.shape, rootfs: config.rootfs, region: config.region,
        reuseLease: config.reuseLease,
      },
    };
  } catch (error) {
    try { await client.destroySandbox(sandbox.id); }
    catch { throw new Error(`CreateOS setup failed and cleanup is unconfirmed for sandbox ${sandbox.id}.`); }
    throw error;
  }
}

// Each worker owns its own transient lifecycle state. The host owns durable leases.
export function createPlugin() {
  let ctx: PluginContext | null = null;
  let shuttingDown = false;
  type Active = { controller: AbortController; done: Promise<void> };
  const active = new Map<string, Set<Active>>();
  const closing = new Set<string>();
  const unconfirmedCleanup = new Set<string>();

  function key(params: PluginEnvironmentDriverBaseParams, id: string): string {
    const config = parseConfig(params.config);
    const account = createHash("sha256").update(resolveApiKey(config)).digest("hex");
    return JSON.stringify([params.companyId, params.environmentId, config.apiUrl, account, id]);
  }

View on GitHub (pinned to 3f1d897a7c)