From 41ded303b4f6dae5afa854531ff837c3276ad60b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 04:31:11 +0100 Subject: [PATCH] fix(sandbox): preserve array order in config hashing --- CHANGELOG.md | 1 + src/agents/sandbox/config-hash.test.ts | 102 +++++++++++++++++++++++++ src/agents/sandbox/config-hash.ts | 30 +------- 3 files changed, 104 insertions(+), 29 deletions(-) create mode 100644 src/agents/sandbox/config-hash.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 36e692e996d..aba3f29b0eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh. +- Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh. - Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent. - Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code. - Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd. diff --git a/src/agents/sandbox/config-hash.test.ts b/src/agents/sandbox/config-hash.test.ts new file mode 100644 index 00000000000..851b621c1fe --- /dev/null +++ b/src/agents/sandbox/config-hash.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import type { SandboxDockerConfig } from "./types.js"; +import { computeSandboxBrowserConfigHash, computeSandboxConfigHash } from "./config-hash.js"; + +function createDockerConfig(overrides?: Partial): SandboxDockerConfig { + return { + image: "openclaw-sandbox:test", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp", "/var/tmp", "/run"], + network: "none", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + dns: ["1.1.1.1", "8.8.8.8"], + extraHosts: ["host.docker.internal:host-gateway"], + binds: ["/tmp/workspace:/workspace:rw", "/tmp/cache:/cache:ro"], + ...overrides, + }; +} + +describe("computeSandboxConfigHash", () => { + it("ignores object key order", () => { + const shared = { + workspaceAccess: "rw" as const, + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + }; + const left = computeSandboxConfigHash({ + ...shared, + docker: createDockerConfig({ + env: { + LANG: "C.UTF-8", + B: "2", + A: "1", + }, + }), + }); + const right = computeSandboxConfigHash({ + ...shared, + docker: createDockerConfig({ + env: { + A: "1", + B: "2", + LANG: "C.UTF-8", + }, + }), + }); + expect(left).toBe(right); + }); + + it("treats primitive array order as significant", () => { + const shared = { + workspaceAccess: "rw" as const, + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + }; + const left = computeSandboxConfigHash({ + ...shared, + docker: createDockerConfig({ + dns: ["1.1.1.1", "8.8.8.8"], + }), + }); + const right = computeSandboxConfigHash({ + ...shared, + docker: createDockerConfig({ + dns: ["8.8.8.8", "1.1.1.1"], + }), + }); + expect(left).not.toBe(right); + }); +}); + +describe("computeSandboxBrowserConfigHash", () => { + it("treats docker bind order as significant", () => { + const shared = { + browser: { + cdpPort: 9222, + vncPort: 5900, + noVncPort: 6080, + headless: false, + enableNoVnc: true, + }, + workspaceAccess: "rw" as const, + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + }; + const left = computeSandboxBrowserConfigHash({ + ...shared, + docker: createDockerConfig({ + binds: ["/tmp/workspace:/workspace:rw", "/tmp/cache:/cache:ro"], + }), + }); + const right = computeSandboxBrowserConfigHash({ + ...shared, + docker: createDockerConfig({ + binds: ["/tmp/cache:/cache:ro", "/tmp/workspace:/workspace:rw"], + }), + }); + expect(left).not.toBe(right); + }); +}); diff --git a/src/agents/sandbox/config-hash.ts b/src/agents/sandbox/config-hash.ts index 80b6d734ffe..a21852026a4 100644 --- a/src/agents/sandbox/config-hash.ts +++ b/src/agents/sandbox/config-hash.ts @@ -19,24 +19,12 @@ type SandboxBrowserHashInput = { agentWorkspaceDir: string; }; -function isPrimitive(value: unknown): value is string | number | boolean | bigint | symbol | null { - return value === null || (typeof value !== "object" && typeof value !== "function"); -} function normalizeForHash(value: unknown): unknown { if (value === undefined) { return undefined; } if (Array.isArray(value)) { - const normalized = value - .map(normalizeForHash) - .filter((item): item is unknown => item !== undefined); - const primitives = normalized.filter(isPrimitive); - if (primitives.length === normalized.length) { - return [...primitives].toSorted((a, b) => - primitiveToString(a).localeCompare(primitiveToString(b)), - ); - } - return normalized; + return value.map(normalizeForHash).filter((item): item is unknown => item !== undefined); } if (value && typeof value === "object") { const entries = Object.entries(value).toSorted(([a], [b]) => a.localeCompare(b)); @@ -52,22 +40,6 @@ function normalizeForHash(value: unknown): unknown { return value; } -function primitiveToString(value: unknown): string { - if (value === null) { - return "null"; - } - if (typeof value === "string") { - return value; - } - if (typeof value === "number") { - return String(value); - } - if (typeof value === "boolean") { - return value ? "true" : "false"; - } - return JSON.stringify(value); -} - export function computeSandboxConfigHash(input: SandboxHashInput): string { return computeHash(input); }