diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 870fa56bec5..241845934ec 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2207,6 +2207,15 @@ If you want to upgrade the sandbox while preserving state, use `$$nemoclaw If the Hermes sandbox has managed MCP entries, shields must be down before destroy can scrub their adapter configuration. diff --git a/src/lib/actions/sandbox/destroy-flow.test.ts b/src/lib/actions/sandbox/destroy-flow.test.ts index 97986044577..4370c4c2844 100644 --- a/src/lib/actions/sandbox/destroy-flow.test.ts +++ b/src/lib/actions/sandbox/destroy-flow.test.ts @@ -49,6 +49,78 @@ describe("destroySandbox flow", () => { expectStrictSandboxPresenceClassification(); }); + const disputedIdentityDockerRun = (ownedId: string, foreignId: string) => (args: unknown) => { + const argv = Array.isArray(args) ? args.map(String) : []; + const inspectLines = [ + JSON.stringify([ownedId, "/openshell-alpha", "openshell", "default"]), + JSON.stringify([foreignId, "/alpha-foreign", "", "foreign"]), + ].join("\n"); + return { + status: 0, + stdout: argv[0] === "ps" ? `${ownedId}\n${foreignId}\n` : `${inspectLines}\n`, + stderr: "", + }; + }; + + it("refuses destroy before any mutation when a foreign container disputes the sandbox-name label (#8999)", async () => { + const harness = createDestroyHarness(); + harness.dockerRunSpy.mockImplementation( + disputedIdentityDockerRun("a".repeat(64), "b".repeat(64)), + ); + + await expect(harness.destroySandbox("alpha", { yes: true })).rejects.toThrow( + "process.exit(1)", + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(harness.events).toEqual([]); + expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); + expect(harness.selectGatewaySpy).not.toHaveBeenCalled(); + expect(harness.stopNimByNameSpy).not.toHaveBeenCalled(); + expect(harness.removeSandboxSpy).not.toHaveBeenCalled(); + const errorOutput = harness.errorSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(errorOutput).toContain("Refusing to destroy sandbox 'alpha'"); + expect(errorOutput).toContain("openshell.ai/sandbox-name=alpha"); + expect(errorOutput).toContain( + "NemoClaw did not change any container, image, or local sandbox state.", + ); + expect(errorOutput).toContain("'destroy --force' skips this check"); + }); + + it("destroys under a disputed sandbox-name label only with --force and a warning (#8999)", async () => { + const harness = createDestroyHarness(); + harness.dockerRunSpy.mockImplementation( + disputedIdentityDockerRun("a".repeat(64), "b".repeat(64)), + ); + + await expect( + harness.destroySandbox("alpha", { force: true, cleanupGateway: false }), + ).resolves.toBeUndefined(); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(harness.events).toContain("delete"); + const warnOutput = harness.warnSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(warnOutput).toContain("do not share one OpenShell-managed identity"); + expect(warnOutput).toContain("--force"); + }); + + it("warns and proceeds when Docker cannot answer the identity check (#8999)", async () => { + const harness = createDestroyHarness(); + harness.dockerRunSpy.mockImplementation(() => ({ + status: 1, + stdout: "", + stderr: "daemon down", + })); + + await expect( + harness.destroySandbox("alpha", { yes: true, cleanupGateway: false }), + ).resolves.toBeUndefined(); + + expect(harness.events).toContain("delete"); + const warnOutput = harness.warnSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(warnOutput).toContain("skipped the container identity check"); + }); + it("selects the sandbox gateway, deletes live resources, cleans host state, and removes registry state", async () => { const harness = createDestroyHarness(); diff --git a/src/lib/actions/sandbox/destroy-preflight.test.ts b/src/lib/actions/sandbox/destroy-preflight.test.ts new file mode 100644 index 00000000000..06678ff55b2 --- /dev/null +++ b/src/lib/actions/sandbox/destroy-preflight.test.ts @@ -0,0 +1,207 @@ +// 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 { queryDockerSandboxNameClaims } from "../../onboard/openshell-docker-sandbox-containers"; +import { + renderDestroySandboxContainerIdentityRefusal, + resolveDestroySandboxContainerIdentity, +} from "./destroy-preflight"; + +const OWNED_ID = "a".repeat(64); +const FOREIGN_ID = "b".repeat(64); +const EXPECTED_INSPECT_FORMAT = + '[{{json .Id}},{{json .Name}},{{json (index .Config.Labels "openshell.ai/managed-by")}},' + + '{{json (index .Config.Labels "openshell.ai/sandbox-workspace")}}]'; + +function inspectLine(id: string, name: string, managedBy: string, workspace: string): string { + return JSON.stringify([id, `/${name}`, managedBy, workspace]); +} + +function dockerRunForContainers(lines: string[]) { + const ids = lines.map((line) => (JSON.parse(line) as string[])[0]).join("\n"); + return vi.fn((args: readonly string[]) => ({ + status: 0, + stdout: args[0] === "ps" ? `${ids}\n` : `${lines.join("\n")}\n`, + stderr: "", + })); +} + +describe("docker sandbox-name claim query (#8999)", () => { + it("enumerates label claims without the managed-by filter and pins both argvs", () => { + const dockerRun = dockerRunForContainers([ + inspectLine(OWNED_ID, "openshell-alpha", "openshell", "default"), + inspectLine(FOREIGN_ID, "alpha-foreign", "", "foreign"), + ]); + const claims = queryDockerSandboxNameClaims("alpha", { dockerRun }); + expect(claims).toEqual({ + ok: true, + rows: [ + { id: OWNED_ID, name: "openshell-alpha", managedBy: "openshell", workspace: "default" }, + { id: FOREIGN_ID, name: "alpha-foreign", managedBy: "", workspace: "foreign" }, + ], + }); + expect(dockerRun).toHaveBeenNthCalledWith( + 1, + [ + "ps", + "-a", + "--no-trunc", + "--filter", + "label=openshell.ai/sandbox-name=alpha", + "--format", + "{{.ID}}", + ], + { ignoreError: true, suppressOutput: true, timeout: 30_000 }, + ); + expect(dockerRun).toHaveBeenNthCalledWith( + 2, + ["inspect", "--type", "container", "--format", EXPECTED_INSPECT_FORMAT, OWNED_ID, FOREIGN_ID], + { ignoreError: true, suppressOutput: true, timeout: 30_000 }, + ); + }); + + it("reports zero claims as ok with no inspect call", () => { + const dockerRun = vi.fn(() => ({ status: 0, stdout: "", stderr: "" })); + expect(queryDockerSandboxNameClaims("alpha", { dockerRun })).toEqual({ ok: true, rows: [] }); + expect(dockerRun).toHaveBeenCalledTimes(1); + }); + + it("distinguishes Docker failures and malformed answers from zero claims", () => { + const psFails = vi.fn(() => ({ status: 1, stdout: "", stderr: "daemon down" })); + expect(queryDockerSandboxNameClaims("alpha", { dockerRun: psFails })).toMatchObject({ + ok: false, + error: expect.stringContaining("daemon down"), + }); + const malformedId = vi.fn(() => ({ status: 0, stdout: "not-an-id\n", stderr: "" })); + expect(queryDockerSandboxNameClaims("alpha", { dockerRun: malformedId })).toMatchObject({ + ok: false, + error: expect.stringContaining("malformed container identity"), + }); + const inspectFails = vi.fn((args: readonly string[]) => ({ + status: args[0] === "ps" ? 0 : 1, + stdout: args[0] === "ps" ? `${OWNED_ID}\n` : "", + stderr: args[0] === "ps" ? "" : "no such object", + })); + expect(queryDockerSandboxNameClaims("alpha", { dockerRun: inspectFails })).toMatchObject({ + ok: false, + error: expect.stringContaining("no such object"), + }); + const partialInspect = vi.fn((args: readonly string[]) => ({ + status: 0, + stdout: + args[0] === "ps" + ? `${OWNED_ID}\n${FOREIGN_ID}\n` + : `${inspectLine(OWNED_ID, "openshell-alpha", "openshell", "default")}\n`, + stderr: "", + })); + expect(queryDockerSandboxNameClaims("alpha", { dockerRun: partialInspect })).toMatchObject({ + ok: false, + error: expect.stringContaining("every container"), + }); + const nulByte = vi.fn(() => ({ status: 0, stdout: "\0", stderr: "" })); + expect(queryDockerSandboxNameClaims("alpha", { dockerRun: nulByte })).toMatchObject({ + ok: false, + error: expect.stringContaining("oversized or malformed"), + }); + }); +}); + +describe("destroy container identity resolution (#8999)", () => { + it("flags the issue repro: a foreign container that copies only the name label", () => { + const queryClaims = vi.fn(() => ({ + ok: true as const, + rows: [ + { id: OWNED_ID, name: "openshell-alpha", managedBy: "openshell", workspace: "default" }, + { id: FOREIGN_ID, name: "alpha-foreign", managedBy: "", workspace: "foreign" }, + ], + })); + expect(resolveDestroySandboxContainerIdentity("alpha", "docker", { queryClaims })).toEqual({ + outcome: "ambiguous", + rows: [ + { id: OWNED_ID, name: "openshell-alpha", managedBy: "openshell", workspace: "default" }, + { id: FOREIGN_ID, name: "alpha-foreign", managedBy: "", workspace: "foreign" }, + ], + }); + }); + + it("accepts a single managed container and skips non-Docker drivers", () => { + const singleClaim = vi.fn(() => ({ + ok: true as const, + rows: [ + { id: OWNED_ID, name: "openshell-alpha", managedBy: "openshell", workspace: "default" }, + ], + })); + expect( + resolveDestroySandboxContainerIdentity("alpha", "docker", { queryClaims: singleClaim }), + ).toEqual({ outcome: "unambiguous" }); + const neverQueried = vi.fn(() => ({ ok: true as const, rows: [] })); + expect( + resolveDestroySandboxContainerIdentity("alpha", "kubernetes", { queryClaims: neverQueried }), + ).toEqual({ outcome: "skipped" }); + expect(neverQueried).not.toHaveBeenCalled(); + expect( + resolveDestroySandboxContainerIdentity("alpha", undefined, { queryClaims: neverQueried }), + ).toEqual({ outcome: "unambiguous" }); + }); + + it("reports unavailable with the Docker error instead of guessing", () => { + const queryClaims = vi.fn(() => ({ ok: false as const, rows: [] as [], error: "boom" })); + expect(resolveDestroySandboxContainerIdentity("alpha", "docker", { queryClaims })).toEqual({ + outcome: "unavailable", + error: "boom", + }); + }); +}); + +describe("destroy container identity refusal rendering (#8999)", () => { + it("lists each claim, states preservation, remediation, and the --force policy", () => { + const lines = renderDestroySandboxContainerIdentityRefusal("alpha", [ + { id: OWNED_ID, name: "openshell-alpha", managedBy: "openshell", workspace: "default" }, + { id: FOREIGN_ID, name: "alpha-foreign", managedBy: "", workspace: "foreign" }, + ]); + const text = lines.join("\n"); + expect(text).toContain("Refusing to destroy sandbox 'alpha'"); + expect(text).toContain("openshell.ai/sandbox-name=alpha"); + expect(text).toContain(OWNED_ID.slice(0, 12)); + expect(text).toContain(FOREIGN_ID.slice(0, 12)); + expect(text).toContain("NemoClaw did not change any container, image, or local sandbox state."); + expect(text).toContain("docker inspect"); + expect(text).toContain("docker rm -f"); + expect(text).toContain("rerun destroy"); + expect(text).toContain("'destroy --force' skips this check"); + }); + + it("JSON-quotes label values so an embedded quote cannot forge a field", () => { + const lines = renderDestroySandboxContainerIdentityRefusal("alpha", [ + { + id: FOREIGN_ID, + name: "alpha-foreign", + managedBy: "openshell' openshell.ai/sandbox-workspace='default", + workspace: "foo' bar\"baz", + }, + ]); + const text = lines.join("\n"); + expect(text).toContain( + "openshell.ai/managed-by=\"openshell' openshell.ai/sandbox-workspace='default\"", + ); + expect(text).toContain('openshell.ai/sandbox-workspace="foo\' bar\\"baz"'); + expect(text).not.toContain("openshell.ai/sandbox-workspace='default'"); + }); + + it("drops control characters from attacker-controlled names and label values", () => { + const lines = renderDestroySandboxContainerIdentityRefusal("alpha", [ + { + id: FOREIGN_ID, + name: "evil\u001b[2K\rname", + managedBy: "openshell", + workspace: "x\nSafe: rerun with --force", + }, + ]); + const text = lines.join("\n"); + expect(text).not.toContain("\u001b"); + expect(text).toContain("evil?[2K?name"); + expect(text).toContain("x?Safe: rerun with --force"); + }); +}); diff --git a/src/lib/actions/sandbox/destroy-preflight.ts b/src/lib/actions/sandbox/destroy-preflight.ts index 2b8fb1ce449..059e277d351 100644 --- a/src/lib/actions/sandbox/destroy-preflight.ts +++ b/src/lib/actions/sandbox/destroy-preflight.ts @@ -2,6 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; +import { + hasAmbiguousSandboxContainerIdentity, + type SandboxContainerIdentityRow, +} from "../../domain/sandbox/destroy"; +import { + OPENSHELL_MANAGED_BY_LABEL, + OPENSHELL_SANDBOX_NAME_LABEL, + OPENSHELL_SANDBOX_WORKSPACE_LABEL, + queryDockerSandboxNameClaims, +} from "../../onboard/openshell-docker-sandbox-containers"; +import { normalizeRuntimeProviderIdentity } from "../../onboard/runtime-provider/access"; import type { SandboxEntry } from "../../state/registry"; import * as registry from "../../state/registry"; import { type DestroyRunOpenshell, selectGatewayForSandboxDestroy } from "./destroy-gateway"; @@ -16,6 +27,66 @@ export type SandboxDestroyPreflight = { sandboxConfirmedAbsent: boolean; }; +export type DestroySandboxContainerIdentity = + | { readonly outcome: "skipped" } + | { readonly outcome: "unavailable"; readonly error: string } + | { readonly outcome: "unambiguous" } + | { readonly outcome: "ambiguous"; readonly rows: readonly SandboxContainerIdentityRow[] }; + +type ResolveContainerIdentityDeps = { + queryClaims?: typeof queryDockerSandboxNameClaims; +}; + +/** + * Resolve whether the host containers that claim this sandbox's name label + * share one OpenShell-managed identity (#8999). Non-Docker runtime drivers + * return `skipped`. A Docker failure or unparsable answer returns + * `unavailable`; only positive evidence of a disputed name is `ambiguous`. + */ +export function resolveDestroySandboxContainerIdentity( + sandboxName: string, + openshellDriver: string | null | undefined, + deps: ResolveContainerIdentityDeps = {}, +): DestroySandboxContainerIdentity { + if (normalizeRuntimeProviderIdentity(openshellDriver) !== "docker") { + return { outcome: "skipped" }; + } + const queryClaims = deps.queryClaims ?? queryDockerSandboxNameClaims; + const claims = queryClaims(sandboxName); + if (!claims.ok) return { outcome: "unavailable", error: claims.error }; + return hasAmbiguousSandboxContainerIdentity(claims.rows) + ? { outcome: "ambiguous", rows: claims.rows } + : { outcome: "unambiguous" }; +} + +// Container IDs are validated as 64-hex upstream; names and label values are +// attacker-controlled. Drop every non-printable byte, then JSON-quote so an +// embedded quote cannot forge an apparent field in the refusal output. +function terminalSafeLabelValue(value: string): string { + return JSON.stringify(value.replace(/[^\x20-\x7e]/g, "?").slice(0, 64)); +} + +export function renderDestroySandboxContainerIdentityRefusal( + sandboxName: string, + rows: readonly SandboxContainerIdentityRow[], +): string[] { + return [ + ` Refusing to destroy sandbox '${sandboxName}': ${rows.length} container(s) carry the ` + + `label '${OPENSHELL_SANDBOX_NAME_LABEL}=${sandboxName}' but do not share one ` + + `OpenShell-managed identity:`, + ...rows.map( + (row) => + ` - ${row.id.slice(0, 12)} name=${terminalSafeLabelValue(row.name)} ` + + `${OPENSHELL_MANAGED_BY_LABEL}=${terminalSafeLabelValue(row.managedBy)} ` + + `${OPENSHELL_SANDBOX_WORKSPACE_LABEL}=${terminalSafeLabelValue(row.workspace)}`, + ), + ` NemoClaw did not change any container, image, or local sandbox state.`, + ` Inspect each container with 'docker inspect '. Remove a container that is not an ` + + `OpenShell sandbox with 'docker rm -f ', then rerun destroy.`, + ` After you verify the destroy target yourself, 'destroy --force' skips this check.`, + ]; +} + function stopSandboxInferenceResources(sandboxName: string, sandbox: SandboxEntry | null): void { const nim = require("../../inference/nim") as { stopNimContainer: (name: string, opts?: { silent?: boolean }) => void; @@ -39,8 +110,43 @@ function stopSandboxInferenceResources(sandboxName: string, sandbox: SandboxEntr } } -export function prepareSandboxDestroy(sandboxName: string): SandboxDestroyPreflight { +export function prepareSandboxDestroy( + sandboxName: string, + options: { force?: boolean } = {}, +): SandboxDestroyPreflight { const sandbox = registry.getSandbox(sandboxName); + + // Fail closed before any destructive work when the sandbox-name label is + // disputed on the host: a foreign container that copies the label means the + // destroy target's identity cannot be trusted (#8999). `--force` skips the + // gate for an operator who verified the target, so a label squatter cannot + // permanently block destroy. + const containerIdentity = resolveDestroySandboxContainerIdentity( + sandboxName, + sandbox?.openshellDriver, + ); + if (containerIdentity.outcome === "ambiguous" && options.force !== true) { + for (const line of renderDestroySandboxContainerIdentityRefusal( + sandboxName, + containerIdentity.rows, + )) { + console.error(line); + } + process.exit(1); + } + if (containerIdentity.outcome === "ambiguous") { + console.warn( + ` ⚠ Containers that carry the label '${OPENSHELL_SANDBOX_NAME_LABEL}=${sandboxName}' do ` + + `not share one OpenShell-managed identity. Destroy proceeds because of --force.`, + ); + } + if (containerIdentity.outcome === "unavailable") { + console.warn( + ` ⚠ Destroy skipped the container identity check for '${sandboxName}' because Docker did ` + + `not answer it: ${terminalSafeLabelValue(containerIdentity.error)}`, + ); + } + console.log(` Deleting sandbox '${sandboxName}'...`); const { runOpenshell } = require("../../adapters/openshell/runtime") as { runOpenshell: DestroyRunOpenshell; diff --git a/src/lib/actions/sandbox/destroy.ts b/src/lib/actions/sandbox/destroy.ts index 0f4c55dea67..8c4105179cc 100644 --- a/src/lib/actions/sandbox/destroy.ts +++ b/src/lib/actions/sandbox/destroy.ts @@ -466,7 +466,7 @@ async function destroySandboxUnlocked( if (!(await confirmSandboxDestroy(sandboxName, normalized))) return; const { cleanupGatewayName, runOpenshell, sandbox, sandboxConfirmedAbsent } = - prepareSandboxDestroy(sandboxName); + prepareSandboxDestroy(sandboxName, { force: normalized.force === true }); const priorHttpsPinRouteId = parseHttpsPinRouteId(sandbox?.endpointUrl); const destructiveResult = await executeSandboxDestroy({ cleanupShieldsArtifacts: cleanupShieldsDestroyArtifacts, diff --git a/src/lib/domain/sandbox/destroy.test.ts b/src/lib/domain/sandbox/destroy.test.ts index db448f99551..68bb9069a29 100644 --- a/src/lib/domain/sandbox/destroy.test.ts +++ b/src/lib/domain/sandbox/destroy.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { getSandboxDeleteOutcome, + hasAmbiguousSandboxContainerIdentity, hasNoLiveSandboxes, hasRunningDockerSandboxContainer, isGatewayUnreachableDeleteOutput, @@ -15,6 +16,45 @@ import { } from "./destroy"; describe("sandbox destroy helpers", () => { + it("flags a disputed sandbox-name label as ambiguous container identity (#8999)", () => { + const owned = { + id: "a".repeat(64), + name: "openshell-alpha", + managedBy: "openshell", + workspace: "default", + }; + const foreign = { + id: "b".repeat(64), + name: "alpha-foreign", + managedBy: "", + workspace: "foreign", + }; + expect(hasAmbiguousSandboxContainerIdentity([])).toBe(false); + expect(hasAmbiguousSandboxContainerIdentity([owned])).toBe(false); + expect(hasAmbiguousSandboxContainerIdentity([owned, { ...owned, id: "c".repeat(64) }])).toBe( + false, + ); + expect(hasAmbiguousSandboxContainerIdentity([foreign])).toBe(true); + expect(hasAmbiguousSandboxContainerIdentity([owned, foreign])).toBe(true); + expect( + hasAmbiguousSandboxContainerIdentity([ + owned, + { ...owned, id: "d".repeat(64), workspace: "other" }, + ]), + ).toBe(true); + expect( + hasAmbiguousSandboxContainerIdentity([ + owned, + { ...owned, id: "e".repeat(64), managedBy: "third-party" }, + ]), + ).toBe(true); + // Pre-v0.0.99 OpenShell containers carry no workspace label; a retained + // one next to the current container must not block destroy. + expect( + hasAmbiguousSandboxContainerIdentity([owned, { ...owned, id: "f".repeat(64), workspace: "" }]), + ).toBe(false); + }); + it("detects missing sandbox delete output", () => { expect(isMissingSandboxDeleteOutput("Error: sandbox alpha not found")).toBe(true); expect(isMissingSandboxDeleteOutput("\u001b[31mNotFound\u001b[0m: missing")).toBe(true); diff --git a/src/lib/domain/sandbox/destroy.ts b/src/lib/domain/sandbox/destroy.ts index 83d2744212e..23a7cfdad35 100644 --- a/src/lib/domain/sandbox/destroy.ts +++ b/src/lib/domain/sandbox/destroy.ts @@ -6,6 +6,7 @@ import { resolveSandboxContainerOwner } from "./container-owner"; const ANSI_RE = /\x1b\[[0-9;]*m/g; const TERMINAL_OPEN_SHELL_SANDBOX_PHASES = new Set(["Error", "Failed"]); +const OPENSHELL_MANAGED_BY_VALUE = "openshell"; function stripAnsi(value = ""): string { return String(value).replace(ANSI_RE, ""); @@ -45,6 +46,31 @@ export type LiveSandboxProbeSnapshot = { dockerContainersBySandboxName: ReadonlyMap; }; +export type SandboxContainerIdentityRow = { + readonly id: string; + readonly name: string; + readonly managedBy: string; + readonly workspace: string; +}; + +/** + * True when the host containers that carry one sandbox-name label do not + * share one OpenShell-managed identity: a container without + * `openshell.ai/managed-by=openshell`, or two containers that record + * different non-empty `openshell.ai/sandbox-workspace` values, disputes + * ownership of the name. An absent workspace label is not a dispute — + * OpenShell releases before v0.0.99 wrote no workspace label, and their + * retained containers must not block destroy (#8999). + */ +export function hasAmbiguousSandboxContainerIdentity( + rows: readonly SandboxContainerIdentityRow[], +): boolean { + if (rows.length === 0) return false; + const allManaged = rows.every((row) => row.managedBy === OPENSHELL_MANAGED_BY_VALUE); + const workspaces = new Set(rows.map((row) => row.workspace).filter(Boolean)); + return !(allManaged && workspaces.size <= 1); +} + export function isMissingSandboxDeleteOutput(output = ""): boolean { return /\bNotFound\b|\bNot Found\b|sandbox not found|sandbox .* not found|sandbox .* not present|sandbox does not exist|no such sandbox/i.test( stripAnsi(output), diff --git a/src/lib/onboard/openshell-docker-sandbox-containers.ts b/src/lib/onboard/openshell-docker-sandbox-containers.ts index e09b376b58d..44353f08dad 100644 --- a/src/lib/onboard/openshell-docker-sandbox-containers.ts +++ b/src/lib/onboard/openshell-docker-sandbox-containers.ts @@ -8,6 +8,7 @@ export const OPENSHELL_MANAGED_BY_LABEL = "openshell.ai/managed-by"; export const OPENSHELL_MANAGED_BY_VALUE = "openshell"; export const OPENSHELL_SANDBOX_NAME_LABEL = "openshell.ai/sandbox-name"; export const OPENSHELL_SANDBOX_ID_LABEL = "openshell.ai/sandbox-id"; +export const OPENSHELL_SANDBOX_WORKSPACE_LABEL = "openshell.ai/sandbox-workspace"; const DOCKER_SANDBOX_QUERY_TIMEOUT_MS = 30_000; const STALE_DOCKER_ORPHAN_TIMEOUT_MS = 30_000; @@ -82,6 +83,126 @@ export function queryOpenShellDockerSandboxContainers( return { ok: true, ids }; } +const FULL_CONTAINER_ID_PATTERN = /^[0-9a-f]{64}$/; +const MAX_LABEL_CLAIM_OUTPUT_BYTES = 1_048_576; +// One JSON array per container, following the docker-state-mutation.ts +// labeled-inspect precedent. `index` on a present-but-unlabeled key of a +// non-nil label map renders "" and `json` escapes hostile label content. +const SANDBOX_NAME_CLAIM_INSPECT_FORMAT = + `[{{json .Id}},{{json .Name}},` + + `{{json (index .Config.Labels "${OPENSHELL_MANAGED_BY_LABEL}")}},` + + `{{json (index .Config.Labels "${OPENSHELL_SANDBOX_WORKSPACE_LABEL}")}}]`; + +export type SandboxNameClaimRow = { + readonly id: string; + readonly name: string; + readonly managedBy: string; + readonly workspace: string; +}; + +export type SandboxNameClaimQuery = + | { ok: true; rows: SandboxNameClaimRow[] } + | { ok: false; rows: []; error: string }; + +function boundedClaimText(value: unknown): string | null { + const text = String(value ?? ""); + if (Buffer.byteLength(text, "utf8") > MAX_LABEL_CLAIM_OUTPUT_BYTES || text.includes("\0")) { + return null; + } + return text; +} + +function parseSandboxNameClaimRow(line: string): SandboxNameClaimRow | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (!Array.isArray(parsed) || parsed.length !== 4) return null; + const [id, name, managedBy, workspace] = parsed; + if (typeof id !== "string" || !FULL_CONTAINER_ID_PATTERN.test(id)) return null; + if (typeof name !== "string" || typeof managedBy !== "string" || typeof workspace !== "string") { + return null; + } + return { id, name: name.replace(/^\//, ""), managedBy, workspace }; +} + +/** + * Enumerate every container that claims one sandbox's + * `openshell.ai/sandbox-name` label — deliberately WITHOUT the + * `openshell.ai/managed-by` filter the other lookups in this module apply, so + * a foreign container that copies only the name label stays visible (#8999). + * Status-bearing like `queryOpenShellDockerSandboxContainers`: `ok: false` + * distinguishes a Docker failure or unparsable answer from zero claims. + */ +export function queryDockerSandboxNameClaims( + sandboxName: string, + deps: DockerSandboxContainerQueryDeps = {}, +): SandboxNameClaimQuery { + const run = deps.dockerRun ?? dockerRun; + const psResult = run( + [ + "ps", + "-a", + "--no-trunc", + "--filter", + `label=${OPENSHELL_SANDBOX_NAME_LABEL}=${sandboxName}`, + "--format", + "{{.ID}}", + ], + { ignoreError: true, suppressOutput: true, timeout: DOCKER_SANDBOX_QUERY_TIMEOUT_MS }, + ); + if (Number(psResult.status ?? 1) !== 0) { + return { + ok: false, + rows: [], + error: commandResultText(psResult) || "docker ps did not complete successfully", + }; + } + const psText = boundedClaimText(psResult.stdout); + if (psText === null) { + return { ok: false, rows: [], error: "docker ps returned an oversized or malformed answer" }; + } + const ids = psText + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (ids.length === 0) return { ok: true, rows: [] }; + if (!ids.every((id) => FULL_CONTAINER_ID_PATTERN.test(id))) { + return { ok: false, rows: [], error: "docker ps returned a malformed container identity" }; + } + const inspectResult = run( + ["inspect", "--type", "container", "--format", SANDBOX_NAME_CLAIM_INSPECT_FORMAT, ...ids], + { ignoreError: true, suppressOutput: true, timeout: DOCKER_SANDBOX_QUERY_TIMEOUT_MS }, + ); + if (Number(inspectResult.status ?? 1) !== 0) { + return { + ok: false, + rows: [], + error: commandResultText(inspectResult) || "docker inspect did not complete successfully", + }; + } + const inspectText = boundedClaimText(inspectResult.stdout); + if (inspectText === null) { + return { + ok: false, + rows: [], + error: "docker inspect returned an oversized or malformed answer", + }; + } + const rows = inspectText + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map(parseSandboxNameClaimRow); + if (rows.length !== ids.length || rows.some((row) => row === null)) { + return { ok: false, rows: [], error: "docker inspect did not answer for every container" }; + } + const sorted = (rows as SandboxNameClaimRow[]).slice().sort((a, b) => a.id.localeCompare(b.id)); + return { ok: true, rows: sorted }; +} + type StaleDockerOrphanCleanupDeps = { queryContainers?: typeof queryOpenShellDockerSandboxContainers; forceRemove?: (containerId: string) => { status?: number | null };