Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ef77620
fix(sandbox): fail closed on ambiguous sandbox-name during destroy (#…
aarav1109s Aug 13, 2026
8d18300
merge(main): refresh PR branch
apurvvkumaria Aug 13, 2026
4bbf7a1
fix(sandbox): fail closed on unprovable destroy identity (#8999)
aarav1109s Aug 13, 2026
069c347
refactor(sandbox): move destroy identity docker probe into an adapter…
aarav1109s Aug 13, 2026
4541797
test(sandbox): add flow-level destroy-identity refusal test + doc the…
aarav1109s Aug 13, 2026
e763576
fix(sandbox): preserve destroy container identity
apurvvkumaria Aug 13, 2026
76c84a5
fix(sandbox): close destroy revalidation gaps
apurvvkumaria Aug 13, 2026
c3ce585
merge(main): refresh PR branch
apurvvkumaria Aug 13, 2026
001d266
test(sandbox): retain contributor destroy contracts
apurvvkumaria Aug 13, 2026
772594e
merge(contributor): retain reviewed PR history
apurvvkumaria Aug 13, 2026
00dbe69
test(sandbox): keep destroy flow cases linear
apurvvkumaria Aug 13, 2026
7e85d37
merge(main): refresh PR branch
apurvvkumaria Aug 13, 2026
3bff2fa
test(sandbox): tighten destroy review contracts
apurvvkumaria Aug 13, 2026
7a4f169
merge(main): refresh destroy safety review
apurvvkumaria Aug 14, 2026
68abe49
fix(sandbox): quote destroy identity labels
Dongni-Yang Aug 14, 2026
8adc05d
merge(main): refresh destroy ambiguity fix
apurvvkumaria Aug 14, 2026
3ba0ea7
merge(main): refresh destroy ambiguity fix
apurvvkumaria Aug 14, 2026
76c2d87
merge(main): refresh destroy identity fix
cv Aug 14, 2026
8561f29
fix(sandbox): preserve provider-neutral destroy checks
cv Aug 14, 2026
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
36 changes: 36 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2243,6 +2243,42 @@ If you want to upgrade the sandbox while preserving state, use `$$nemoclaw <name
If another terminal has an active SSH session to the sandbox, `destroy` prints an active-session warning and requires a second confirmation before it proceeds.
Pass `--yes`, `-y`, or `--force`, or set `NEMOCLAW_NON_INTERACTIVE=1`, to authorize deletion without prompting in scripted workflows.

Before changing a Docker-backed sandbox, NemoClaw inspects every container with the requested `openshell.ai/sandbox-name` label.
The command continues when Docker returns no matching containers.
For one matching container, the command continues only when all these labels have the required values:

- `openshell.ai/managed-by=openshell`
- A nonempty `openshell.ai/sandbox-workspace`
- A nonempty `openshell.ai/sandbox-id`

If the initial inspection cannot complete, more than one container matches, a matching container has conflicting or incomplete labels, or Docker returns malformed identity data, `destroy` exits before changing sandbox resources.
The identity checks still apply with `--force`, `--yes`, or `NEMOCLAW_NON_INTERACTIVE=1`; those controls authorize confirmation but do not authorize an unproven container identity.
NemoClaw rechecks the exact identity after read-only preflight, before provider cleanup, and synchronously at the sandbox-deletion boundary.
If a later recheck detects drift or fails, `destroy` refuses sandbox deletion, restores managed MCP preparation when possible, preserves local ownership state, and reports any earlier cleanup already performed.
If Docker cannot complete the inspection, correct the reported Docker error before you rerun `destroy`.
For common recovery steps, refer to [Docker is not running](troubleshooting#docker-is-not-running) and [Docker permission denied on Linux](troubleshooting#docker-permission-denied-on-linux).

If `destroy` reports conflicting, incomplete, or malformed identity data, inspect the matching containers:

~~~bash
docker ps -a --no-trunc \
--filter "label=openshell.ai/sandbox-name=my-assistant" \
--format 'table {{.ID}}\t{{.Label "openshell.ai/managed-by"}}\t{{.Label "openshell.ai/sandbox-workspace"}}\t{{.Label "openshell.ai/sandbox-id"}}'
~~~

The labels show what each container claims.
They do not prove container ownership.

<Warning>
Do not remove or recreate a container until you verify its purpose, ownership, and data-retention requirements.
Removing or recreating a container can discard state that is not stored in a volume.
</Warning>

Resolve a conflict through the workflow that created the conflicting container.
Docker cannot change labels on an existing container.
Rerun the query after you resolve the conflict.
Rerun `destroy` only when the query returns one complete expected label set that you verified belongs to the target sandbox, or no containers after you independently confirm that the sandbox is absent.

<AgentOnly variant="hermes">

If the Hermes sandbox has managed MCP entries, shields must be down before destroy can scrub their adapter configuration.
Expand Down
198 changes: 198 additions & 0 deletions src/lib/actions/sandbox/destroy-container-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from "vitest";

import {
classifyDestroyContainerIdentity,
type DestroyContainerIdentityVerdict,
formatAmbiguousDestroyIdentity,
} from "./destroy-presence";

type Row = {
id: string;
managedBy?: string;
workspace?: string;
sandboxId?: string;
};

function observeRows(rows: Row[], malformedRows = 0) {
return {
status: "observed" as const,
rows: rows.map((row) => ({
id: row.id,
managedBy: row.managedBy ?? "",
workspace: row.workspace ?? "",
sandboxId: row.sandboxId ?? "",
})),
malformedRows,
};
}

const MANAGED = {
id: "aaaa000000000000",
managedBy: "openshell",
workspace: "default",
sandboxId: "sb-real",
} as const;

const FOREIGN = {
id: "ffff000000000000",
managedBy: "",
workspace: "foreign",
sandboxId: "",
} as const;

function expectAmbiguous(
verdict: DestroyContainerIdentityVerdict,
): Extract<DestroyContainerIdentityVerdict, { status: "ambiguous" }> {
expect(verdict.status).toBe("ambiguous");
return verdict as Extract<DestroyContainerIdentityVerdict, { status: "ambiguous" }>;
}

describe("classifyDestroyContainerIdentity", () => {
it("is clear when no container carries the sandbox-name label", () => {
expect(classifyDestroyContainerIdentity("destroytest", observeRows([]))).toEqual({
status: "clear",
identity: null,
});
});

it("is clear for exactly one managed container", () => {
expect(classifyDestroyContainerIdentity("destroytest", observeRows([MANAGED]))).toEqual({
status: "clear",
identity: MANAGED,
});
});

it("refuses when a foreign container shares the sandbox-name label (#8999)", () => {
// The exact repro: a real managed sandbox plus a busybox that borrows the
// sandbox-name label with a different workspace and no managed marker.
const verdict = expectAmbiguous(
classifyDestroyContainerIdentity("destroytest", observeRows([MANAGED, FOREIGN])),
);
expect(verdict.foreign).toHaveLength(1);
expect(verdict.foreign[0].id).toBe(FOREIGN.id);
expect(verdict.managed).toHaveLength(1);
expect(verdict.reason).toContain("managed-by");
});

it("refuses a foreign-only match with no managed container behind it", () => {
const verdict = expectAmbiguous(
classifyDestroyContainerIdentity("destroytest", observeRows([FOREIGN])),
);
expect(verdict.managed).toHaveLength(0);
expect(verdict.foreign).toHaveLength(1);
});

it("refuses when managed containers span more than one workspace", () => {
const observe = vi.fn(() =>
observeRows([
MANAGED,
{
id: "bbbb000000000000",
managedBy: "openshell",
workspace: "other",
sandboxId: "sb-real",
},
]),
);
const verdict = expectAmbiguous(classifyDestroyContainerIdentity("destroytest", observe()));
expect(verdict.reason).toContain("2 managed containers");
});

it("refuses when managed containers span more than one sandbox-id", () => {
const observe = vi.fn(() =>
observeRows([
MANAGED,
{
id: "cccc000000000000",
managedBy: "openshell",
workspace: "default",
sandboxId: "sb-two",
},
]),
);
expect(classifyDestroyContainerIdentity("destroytest", observe()).status).toBe("ambiguous");
});

it("refuses multiple managed containers even when their mutable labels match", () => {
const verdict = expectAmbiguous(
classifyDestroyContainerIdentity(
"destroytest",
observeRows([MANAGED, { ...MANAGED, id: "dddd000000000000" }]),
),
);
expect(verdict.reason).toContain("2 managed containers");
});

it.each([
["workspace", { ...MANAGED, workspace: "" }],
["sandbox ID", { ...MANAGED, sandboxId: "" }],
])("refuses a managed container with no %s", (_label, row) => {
const verdict = expectAmbiguous(
classifyDestroyContainerIdentity("destroytest", observeRows([row])),
);
expect(verdict.reason).toContain("missing");
});

it("refuses malformed Docker identity output", () => {
const verdict = expectAmbiguous(
classifyDestroyContainerIdentity("destroytest", observeRows([], 1)),
);
expect(verdict.reason).toContain("malformed container identity");
});

it("refuses terminal-control label output without rendering it", () => {
const verdict = expectAmbiguous(
classifyDestroyContainerIdentity("destroytest", observeRows([], 1)),
);
expect(formatAmbiguousDestroyIdentity(verdict, "nemoclaw").join("\n")).not.toContain("\u001b");
});

it("reports a failed Docker probe when identity cannot be proven", () => {
const verdict = classifyDestroyContainerIdentity("destroytest", {
status: "probe-failed",
detail: "Cannot connect to daemon",
});
expect(verdict).toEqual({
status: "probe-failed",
detail: expect.stringContaining("daemon"),
});
});
});

describe("formatAmbiguousDestroyIdentity", () => {
it("names the refusal, both container roles, and the recovery step", () => {
const verdict = expectAmbiguous(
classifyDestroyContainerIdentity("destroytest", observeRows([MANAGED, FOREIGN])),
);
const lines = formatAmbiguousDestroyIdentity(verdict, "nemoclaw").join("\n");
expect(lines).toContain("Refusing to destroy sandbox 'destroytest'");
expect(lines).toContain("Conflicting container:");
expect(lines).toContain("Managed sandbox container:");
expect(lines).toContain('openshell.ai/sandbox-id="sb-real"');
expect(lines).toContain("Resolve the conflict through the workflow that owns the container");
expect(lines).toContain("nemoclaw destroytest destroy");
expect(lines).not.toContain("--yes");
});

it("quotes label values so printable delimiters cannot forge adjacent fields", () => {
const foreign = {
...FOREIGN,
managedBy: 'foreign", openshell.ai/sandbox-workspace="default',
workspace: 'foo, bar"baz',
sandboxId: 'sb-foreign", openshell.ai/managed-by="openshell',
};
const verdict = expectAmbiguous(
classifyDestroyContainerIdentity("destroytest", observeRows([MANAGED, foreign])),
);
const lines = formatAmbiguousDestroyIdentity(verdict, "nemoclaw").join("\n");
expect(lines).toContain(`openshell.ai/managed-by=${JSON.stringify(foreign.managedBy)}`);
expect(lines).toContain(`openshell.ai/sandbox-workspace=${JSON.stringify(foreign.workspace)}`);
expect(lines).toContain(`openshell.ai/sandbox-id=${JSON.stringify(foreign.sandboxId)}`);
expect(lines).not.toContain(
'openshell.ai/managed-by="foreign", openshell.ai/sandbox-workspace="default"',
);
});
});
Loading
Loading