Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@ vi.mock("../../messaging-channel-setup", () => ({

vi.mocked(detectMessagingChannelsFromEnv).mockReturnValue([]);

function defaultCreateFingerprint(sandboxName = "my-assistant"): string {
function defaultCreateFingerprint(
builtFingerprint = "my-assistant",
policyFingerprint = "default",
): string {
return [
sandboxName,
"default",
builtFingerprint,
policyFingerprint,
"provider",
"model",
"openai-completions",
Expand Down Expand Up @@ -924,6 +927,34 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => {
expect(calls.error.mock.calls.flat().join("\n")).toContain("--recreate-sandbox");
});

it.each([
["build", defaultCreateFingerprint("v0.0.108")],
["policy", defaultCreateFingerprint("my-assistant", "previous-policy")],
] as const)("recreates after %s drift when explicitly requested (#9297)", async (_drift, fingerprint) => {
const session = sessionWithCheckpoint(
crashedCheckpoint({
effectGroups: {
sandbox_create: { completedAt: "2026-01-01T00:00:00.000Z", fingerprint },
},
}),
);
session.machine.state = "openclaw";
const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready" }, session);

await handleSandboxState({
...baseOptions(deps, session),
resume: true,
sandboxName: "my-assistant",
recreateSandbox: () => true,
});

expect(calls.createSandbox).toHaveBeenCalledOnce();
expect(calls.createSandbox.mock.calls[0]?.at(-1)).toEqual(
expect.objectContaining({ recreate: true }),
);
expect(calls.error).not.toHaveBeenCalled();
});

it("rejects reuse when a resolved policy or package input drifted despite an unchanged build version and policy tier (#7022)", async () => {
const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready" });
const session = sessionWithCheckpoint(crashedCheckpoint());
Expand Down Expand Up @@ -1020,6 +1051,45 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => {
expect(resumedRun.calls.error.mock.calls.flat().join("\n")).toContain("--recreate-sandbox");
});

it("recreates after stable resolved create-intent drift when explicitly requested (#9297)", async () => {
const session = createSession({ sessionId: "sess-1", agent: "openclaw" });
const updateSession = vi.fn((mutator: (value: typeof session) => void) => {
mutator(session);
return session;
});
const firstRun = createDeps({ getSandboxReuseState: () => "missing", updateSession });

await handleSandboxState({
...baseOptions(firstRun.deps, session),
resume: false,
sandboxName: "my-assistant",
});

const resumedRun = createDeps({ getSandboxReuseState: () => "missing", updateSession });
const defaultResolve = resumedRun.calls.resolveCreateIntent.getMockImplementation();
expect(defaultResolve).toBeDefined();
resumedRun.calls.resolveCreateIntent.mockImplementation(async (input) => {
const resolved = await defaultResolve!(input);
return {
...resolved,
policy: { ...resolved.policy, basePolicyPath: "/repo/changed-policy.yaml" },
};
});

await handleSandboxState({
...baseOptions(resumedRun.deps, session),
resume: true,
recreateSandbox: () => true,
sandboxName: "my-assistant",
});

expect(resumedRun.calls.createSandbox).toHaveBeenCalledOnce();
expect(resumedRun.calls.createSandbox.mock.calls[0]?.at(-1)).toEqual(
expect.objectContaining({ recreate: true }),
);
expect(resumedRun.calls.error).not.toHaveBeenCalled();
});

it("rejects reasoning capability drift before replaying a recorded sandbox create (#7570)", async () => {
const session = createSession({ sessionId: "sess-1", agent: "openclaw" });
const updateSession = vi.fn((mutator: (value: typeof session) => void) => {
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/machine/handlers/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,7 @@ class SandboxStateFlow<
sandboxName: string,
createIntent: ResolvedSandboxCreateIntent,
): void {
if (this.options.recreateSandbox(false)) return;
const recordedFingerprint = state.session?.checkpoint?.effectGroups.sandbox_create?.fingerprint;
if (!recordedFingerprint) return;
// Older and reuse-backfilled receipts contain the stable create-input prefix.
Expand Down
Loading