Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0636191
fix(rebuild): support hosts without Docker Buildx
apurvvkumaria Jul 20, 2026
0e9a222
refactor(onboard): preserve entrypoint growth budget
apurvvkumaria Jul 20, 2026
5d07406
test(rebuild): keep failure assertion linear
apurvvkumaria Jul 20, 2026
1233a9e
merge: resolve conflicts with main
github-actions[bot] Jul 26, 2026
de5a8ea
merge: resolve conflicts with main
github-actions[bot] Jul 26, 2026
dc60bb8
Merge branch 'main' into codex/7111-root-cause-investigation
prekshivyas Jul 26, 2026
c2817d0
merge: resolve conflicts with main
github-actions[bot] Jul 27, 2026
7f2f107
Merge branch 'main' into codex/7111-root-cause-investigation
cv Jul 27, 2026
28614b2
merge: sync main into Buildx fallback
apurvvkumaria Jul 28, 2026
33bcd9d
fix(rebuild): require Docker Buildx
jyaunches Jul 29, 2026
b81e779
Merge remote-tracking branch 'origin/main' into codex/pr-7253-buildx-…
jyaunches Jul 29, 2026
4c04584
chore(ci): ratchet shell quote fan-in
jyaunches Jul 29, 2026
0a077a3
test(rebuild): cover Buildx diagnostic streams
jyaunches Jul 29, 2026
ed9f04e
test(rebuild): guard Buildx mutation boundary
jyaunches Jul 29, 2026
89d5de6
fix(rebuild): support OpenClaw without Buildx
jyaunches Jul 29, 2026
ce5cf9d
refactor(onboard): keep rebuild orchestration net-neutral
jyaunches Jul 29, 2026
f5e80b7
test(rebuild): keep guardrail scenarios linear
jyaunches Jul 29, 2026
901e4a5
docs(rebuild): clarify custom Dockerfile exclusion
jyaunches Jul 29, 2026
5ca7deb
test(rebuild): bind Docker engine fixtures
jyaunches Jul 29, 2026
95124f8
test(rebuild): stop interactive fixture after backup
jyaunches Jul 29, 2026
0c559f7
test(rebuild): preserve sandbox after delete failure
jyaunches Jul 29, 2026
36a3fae
merge: resolve conflicts with main
github-actions[bot] Jul 30, 2026
5496192
merge: resolve conflicts with main
github-actions[bot] Jul 31, 2026
aff5256
merge: resolve conflicts with main
github-actions[bot] Aug 3, 2026
f52d3fd
Merge branch 'main' into codex/7111-root-cause-investigation
cv Aug 3, 2026
016ad2e
merge: resolve conflicts with main
github-actions[bot] Aug 3, 2026
8828c52
fix(rebuild): refresh retained OpenClaw rebuild
cv Aug 3, 2026
30938a5
fix(rebuild): keep onboard entrypoint net neutral
cv Aug 3, 2026
825984c
merge: resolve conflicts with main
github-actions[bot] Aug 3, 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
5 changes: 5 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2477,6 +2477,11 @@ A rebuild preserves the recorded Deep Agents Code auto-approval capability unles
A sandbox onboarded with an explicit GPU opt-out (stored as `sandboxGpuMode: "0"`, plus legacy registry entries that only record `gpuEnabled: false`) is recreated with the same opt-out, so the inner `onboard --resume` skips the Docker CDI GPU preflight on hosts without an NVIDIA GPU.
Auto-mode sandboxes remain auto.

Before backup or deletion, `rebuild` stages and validates the replacement image.
When recreation can consume a local Docker image and Docker reports that Buildx is missing or broken while validating the managed OpenClaw context generated by NemoClaw, NemoClaw prints a warning, retries that exact fingerprinted context once with Docker's legacy builder, and carries the verified builder and Docker endpoint configuration into recreation.
The local preflight does not retry a user-supplied custom Dockerfile context with a different builder.
If validation still fails or the staged context changes, rebuild stops and leaves the existing sandbox intact.

```bash
$$nemoclaw my-assistant rebuild [--yes|-y|--force] [--verbose|-v] [--tool-disclosure <progressive|direct>] [--dcode-auto-approval <disabled|thread-opt-in>] [--observability|--no-observability]
```
Expand Down
321 changes: 321 additions & 0 deletions src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,24 @@ function input(fromDockerfile: string | null) {
sandboxGpuDevice: null,
errors: [],
},
localPrebuildEnabled: true,
gatewayPort: 8080,
chatUiUrl: "http://127.0.0.1:18789",
};
}

describe("preflightRebuildImage", () => {
it("keeps the generated OpenClaw Dockerfile compatible with the legacy fallback (#7111)", () => {
const dockerfile = fs.readFileSync(path.join(ROOT, "Dockerfile"), "utf8");
for (const buildKitOnlySyntax of [
/^\s*RUN\s+--mount=/mu,
/^\s*(?:COPY|ADD)\s+--link(?:=|\s)/mu,
/^\s*RUN\s+<<-?\w+/mu,
]) {
expect(dockerfile).not.toMatch(buildKitOnlySyntax);
}
});

it("prebuilds the managed OpenClaw image instead of deferring its first build until delete", async () => {
const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-preflight-"));
const stagedDockerfile = path.join(buildCtx, "Dockerfile");
Expand Down Expand Up @@ -82,8 +94,312 @@ describe("preflightRebuildImage", () => {
);
expect(buildImage).toHaveBeenCalledOnce();
expect(cleanupBuildCtx).not.toHaveBeenCalled();
expect(result.prepared.prebuildBuilder).toBeUndefined();
expect(result.prepared.prebuildDockerEnv).toBeUndefined();
expect(verifyPreparedBuildContext(result.prepared)).toBe(true);
expect(disposePreparedBuildContext(result.prepared)).toBe(true);
expect(cleanupBuildCtx).toHaveBeenCalledOnce();
} finally {
fs.rmSync(buildCtx, { recursive: true, force: true });
}
});

it("retries an exact generated image once with the legacy builder when Buildx is unavailable (#7111)", async () => {
const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-buildx-fallback-"));
const stagedDockerfile = path.join(buildCtx, "Dockerfile");
fs.writeFileSync(stagedDockerfile, "FROM scratch\n");
const cleanupBuildCtx = vi.fn(() => {
fs.rmSync(buildCtx, { recursive: true, force: true });
return true;
});
const buildImage = vi
.fn()
.mockReturnValueOnce({
status: 1,
stderr: Buffer.from(
"ERROR: BuildKit is enabled but the buildx component is missing or broken.",
),
} as never)
.mockReturnValueOnce({ status: 0 } as never);
const dockerEnv = Object.freeze({
DOCKER_CONFIG: "/home/test/.docker",
DOCKER_CONTEXT: "verified-builder",
});
const buildxAvailable = vi.fn(() => false);
const removeImage = vi.fn(() => ({ status: 0 }) as never);
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);

try {
const result = successful(
await preflightRebuildImage(input(null), {
stageBuildContext: vi.fn(() => ({
buildCtx,
stagedDockerfile,
cleanupBuildCtx,
origin: "generated" as const,
})),
prepareDockerfilePatch: vi.fn(async () => ({
buildId: "buildx-fallback",
dashboardRemoteBindPrepared: false,
resolvedBaseImage: null,
})),
buildImage,
buildxAvailable,
buildDockerEnv: () => dockerEnv,
removeImage,
}),
);

expect(buildImage).toHaveBeenCalledTimes(2);
expect(buildImage.mock.calls[0]?.[3]).toEqual(
expect.objectContaining({ cwd: ROOT, env: dockerEnv }),
);
expect(buildImage.mock.calls[1]?.[3]).toEqual(
expect.objectContaining({
cwd: ROOT,
env: { ...dockerEnv, DOCKER_BUILDKIT: "0" },
}),
);
expect(buildxAvailable).toHaveBeenCalledWith({ cwd: ROOT, env: dockerEnv });
expect(removeImage).toHaveBeenCalledWith(
expect.stringMatching(/^nemoclaw-rebuild-preflight:/),
expect.objectContaining({ cwd: ROOT, env: dockerEnv }),
);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("legacy builder"));
expect(result.prepared.prebuildBuilder).toBe("legacy");
expect(result.prepared.prebuildDockerEnv).toEqual(dockerEnv);
expect(result.prepared.prebuildDockerEnv).not.toBe(dockerEnv);
expect(verifyPreparedBuildContext(result.prepared)).toBe(true);
const clonedPrepared = { ...result.prepared };
expect(clonedPrepared.verifyBuildCtx()).toBe(false);
expect(verifyPreparedBuildContext(clonedPrepared)).toBe(false);
(result.prepared as { prebuildBuilder?: string }).prebuildBuilder = "buildkit";
expect(result.prepared.verifyBuildCtx()).toBe(false);
(result.prepared as { prebuildBuilder?: string }).prebuildBuilder = "legacy";
expect(disposePreparedBuildContext(result.prepared)).toBe(true);
} finally {
warn.mockRestore();
fs.rmSync(buildCtx, { recursive: true, force: true });
}
});

it.each([
["the independent Buildx probe succeeds", null, true, true],
["the generated target is not managed OpenClaw", { name: "hermes" }, false, true],
["local image prebuild is unavailable", null, false, false],
])("does not downgrade the builder when %s", async (_label, agent, available, localPrebuildEnabled) => {
const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-buildx-no-downgrade-"));
const stagedDockerfile = path.join(buildCtx, "Dockerfile");
fs.writeFileSync(stagedDockerfile, "FROM scratch\n");
const cleanupBuildCtx = vi.fn(() => {
fs.rmSync(buildCtx, { recursive: true, force: true });
return true;
});
const buildImage = vi.fn(
() =>
({
status: 1,
stderr: "ERROR: BuildKit is enabled but the buildx component is missing or broken.",
}) as never,
);

try {
const result = await preflightRebuildImage(
{ ...input(null), agent: agent as never, localPrebuildEnabled },
{
stageBuildContext: vi.fn(() => ({
buildCtx,
stagedDockerfile,
cleanupBuildCtx,
origin: "generated" as const,
})),
prepareDockerfilePatch: vi.fn(async () => ({
buildId: "no-builder-downgrade",
dashboardRemoteBindPrepared: false,
resolvedBaseImage: null,
})),
buildImage,
buildxAvailable: () => available,
removeImage: vi.fn(() => ({ status: 0 }) as never),
},
);

expect(result).toEqual({
ok: false,
detail: "ERROR: BuildKit is enabled but the buildx component is missing or broken.",
});
expect(buildImage).toHaveBeenCalledOnce();
expect(cleanupBuildCtx).toHaveBeenCalledOnce();
} finally {
fs.rmSync(buildCtx, { recursive: true, force: true });
}
});

it("does not retry a custom Dockerfile when Buildx is unavailable", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-buildx-"));
const dockerfile = path.join(dir, "Dockerfile.custom");
fs.writeFileSync(dockerfile, "FROM scratch\n");
const buildImage = vi.fn(
() =>
({
status: 1,
stderr: "ERROR: BuildKit is enabled but the buildx component is missing or broken.",
}) as never,
);

try {
const result = await preflightRebuildImage(input(dockerfile), {
prepareDockerfilePatch: vi.fn(async () => ({
buildId: "custom-buildx",
dashboardRemoteBindPrepared: false,
resolvedBaseImage: null,
})),
buildImage,
removeImage: vi.fn(() => ({ status: 0 }) as never),
});

expect(result).toEqual({
ok: false,
detail: "ERROR: BuildKit is enabled but the buildx component is missing or broken.",
});
expect(buildImage).toHaveBeenCalledOnce();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("rejects generated build-context drift before a legacy-builder retry", async () => {
const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-buildx-drift-"));
const stagedDockerfile = path.join(buildCtx, "Dockerfile");
fs.writeFileSync(stagedDockerfile, "FROM scratch\n");
const cleanupBuildCtx = vi.fn(() => {
fs.rmSync(buildCtx, { recursive: true, force: true });
return true;
});
const buildImage = vi.fn(() => {
fs.writeFileSync(path.join(buildCtx, "mutated"), "changed\n");
return {
status: 1,
stderr: "ERROR: BuildKit is enabled but the buildx component is missing or broken.",
} as never;
});

try {
await expect(
preflightRebuildImage(input(null), {
stageBuildContext: vi.fn(() => ({
buildCtx,
stagedDockerfile,
cleanupBuildCtx,
origin: "generated" as const,
})),
prepareDockerfilePatch: vi.fn(async () => ({
buildId: "buildx-drift",
dashboardRemoteBindPrepared: false,
resolvedBaseImage: null,
})),
buildImage,
buildxAvailable: () => false,
removeImage: vi.fn(() => ({ status: 0 }) as never),
}),
).resolves.toEqual({
ok: false,
detail: "replacement build context changed during preflight",
});
expect(buildImage).toHaveBeenCalledOnce();
expect(cleanupBuildCtx).toHaveBeenCalledOnce();
} finally {
fs.rmSync(buildCtx, { recursive: true, force: true });
}
});

it("prioritizes the redacted legacy failure when both generated-image attempts fail", async () => {
const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-buildx-double-fail-"));
const stagedDockerfile = path.join(buildCtx, "Dockerfile");
const credential = ["legacy", "retry", "credential"].join("-");
fs.writeFileSync(stagedDockerfile, "FROM scratch\n");
const cleanupBuildCtx = vi.fn(() => {
fs.rmSync(buildCtx, { recursive: true, force: true });
return true;
});
const buildImage = vi
.fn()
.mockReturnValueOnce({
status: 1,
stderr:
"ERROR: BuildKit is enabled but the buildx component is missing or broken.\n" +
"x".repeat(9_000),
} as never)
.mockReturnValueOnce({
status: 1,
stderr:
`legacy build could not read ${os.homedir()}/private-context\n` +
`Authorization: Bearer ${credential}`,
} as never);
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);

try {
const result = await preflightRebuildImage(input(null), {
stageBuildContext: vi.fn(() => ({
buildCtx,
stagedDockerfile,
cleanupBuildCtx,
origin: "generated" as const,
})),
prepareDockerfilePatch: vi.fn(async () => ({
buildId: "buildx-double-fail",
dashboardRemoteBindPrepared: false,
resolvedBaseImage: null,
})),
buildImage,
buildxAvailable: () => false,
removeImage: vi.fn(() => ({ status: 0 }) as never),
});

expect(result.ok).toBe(false);
const failure = result as Extract<RebuildImagePreflightResult, { ok: false }>;
expect(failure.detail).toContain("Legacy-builder retry failed");
expect(failure.detail).toContain("legacy build could not read ~/private-context");
expect(failure.detail).toContain("Authorization: Bearer <REDACTED>");
expect(failure.detail).not.toContain(credential);
expect(failure.detail.length).toBeLessThan(8_100);
expect(buildImage).toHaveBeenCalledTimes(2);
expect(cleanupBuildCtx).toHaveBeenCalledOnce();
} finally {
warn.mockRestore();
fs.rmSync(buildCtx, { recursive: true, force: true });
}
});

it("does not retry an unrelated generated-image build failure", async () => {
const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-no-retry-"));
const stagedDockerfile = path.join(buildCtx, "Dockerfile");
fs.writeFileSync(stagedDockerfile, "FROM scratch\n");
const cleanupBuildCtx = vi.fn(() => {
fs.rmSync(buildCtx, { recursive: true, force: true });
return true;
});
const buildImage = vi.fn(() => ({ status: 1, stderr: "registry timeout" }) as never);

try {
await expect(
preflightRebuildImage(input(null), {
stageBuildContext: vi.fn(() => ({
buildCtx,
stagedDockerfile,
cleanupBuildCtx,
origin: "generated" as const,
})),
prepareDockerfilePatch: vi.fn(async () => ({
buildId: "unrelated-build-failure",
dashboardRemoteBindPrepared: false,
resolvedBaseImage: null,
})),
buildImage,
removeImage: vi.fn(() => ({ status: 0 }) as never),
}),
).resolves.toEqual({ ok: false, detail: "registry timeout" });
expect(buildImage).toHaveBeenCalledOnce();
expect(cleanupBuildCtx).toHaveBeenCalledOnce();
} finally {
fs.rmSync(buildCtx, { recursive: true, force: true });
Expand Down Expand Up @@ -277,6 +593,7 @@ describe("preflightRebuildImage", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-preflight-cleanup-"));
const dockerfile = path.join(dir, "Dockerfile.custom");
fs.writeFileSync(dockerfile, "FROM scratch\n");
const dockerEnv = Object.freeze({ DOCKER_CONTEXT: "cleanup-builder" });
const removeImage = vi
.fn()
.mockReturnValueOnce({ status: 1 } as never)
Expand All @@ -296,6 +613,7 @@ describe("preflightRebuildImage", () => {
resolvedBaseImage: null,
})),
buildImage: vi.fn(() => ({ status: 0 }) as never),
buildDockerEnv: () => dockerEnv,
removeImage,
}),
);
Expand All @@ -305,6 +623,9 @@ describe("preflightRebuildImage", () => {
);
expect(processOnce).toHaveBeenCalledWith("exit", expect.any(Function));
expect(removeImage).toHaveBeenCalledTimes(2);
for (const call of removeImage.mock.calls) {
expect(call[1]).toEqual(expect.objectContaining({ cwd: ROOT, env: dockerEnv }));
}
expect(result.prepared.dashboardRemoteBindPrepared).toBe(true);
expect(disposePreparedBuildContext(result.prepared)).toBe(true);
} finally {
Expand Down
Loading
Loading