diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts new file mode 100644 index 00000000000..1d878cf04d9 --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as agentDefs from "../../agent/defs"; +import * as agentRuntime from "../../agent/runtime"; +import * as shields from "../../shields"; +import * as registry from "../../state/registry"; +import * as messagingHostForward from "./messaging-host-forward-lifecycle"; +import * as processRecovery from "./process-recovery"; +import * as rebuildConfigHash from "./rebuild-config-hash"; +import * as rebuildMcp from "./rebuild-mcp-phase"; +import * as rebuildMessaging from "./rebuild-messaging-phase"; +import { runRebuildPostRestorePhase } from "./rebuild-post-restore-phase"; +import * as sessionModels from "./reconcile-session-models"; + +describe("rebuild post-restore session model reconciliation (#7102)", () => { + let agentName: "openclaw" | "hermes"; + let order: string[]; + + beforeEach(() => { + agentName = "openclaw"; + order = []; + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(agentRuntime, "getSessionAgent").mockImplementation( + () => ({ name: agentName }) as never, + ); + vi.spyOn(agentRuntime, "getAgentDisplayName").mockReturnValue("test agent"); + vi.spyOn(agentDefs, "loadAgent").mockImplementation( + () => ({ name: agentName, expectedVersion: null }) as never, + ); + vi.spyOn(processRecovery, "executeSandboxCommand").mockImplementation(() => { + order.push("doctor"); + return { status: 0, stdout: "", stderr: "" }; + }); + vi.spyOn(sessionModels, "reconcileStalePinnedSessionModelsAfterRebuild").mockImplementation( + () => { + order.push("reconcile"); + }, + ); + vi.spyOn(rebuildMessaging, "reapplyMessagingManifestAfterOpenClawDoctor").mockImplementation( + async () => { + order.push("messaging"); + }, + ); + vi.spyOn( + rebuildConfigHash, + "refreshMutableOpenClawConfigHashAfterPostRestoreWrites", + ).mockImplementation(() => { + order.push("config-hash"); + return true; + }); + vi.spyOn(shields, "repairMutableConfigPerms").mockReturnValue({ + applied: false, + reason: "not needed", + skipReason: "not-needed", + } as never); + vi.spyOn(rebuildMcp, "restoreMcpAfterRebuild").mockResolvedValue(true); + vi.spyOn(registry, "updateSandbox").mockReturnValue(true); + vi.spyOn(messagingHostForward, "ensureMessagingHostForwardAfterRebuild").mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function input() { + return { + sandboxName: "alpha", + sandboxEntry: {} as never, + messagingPlan: null, + backupManifest: null, + mcpEntries: [], + restoreSucceeded: true, + backupWasForceSkipped: false, + failedPresets: [], + finalBuiltinPresets: [], + failedPresetRemovals: [], + policyPresetReconciliationVerified: true, + staleRecovery: false, + recoveryRecreate: false, + preparedBackupRecovery: false, + staleSandboxWasLocked: false, + versionCheck: { expectedVersion: null } as never, + relockShieldsIfNeeded: vi.fn(() => true), + log: vi.fn(), + bail: vi.fn() as never, + }; + } + + it("reconciles OpenClaw sessions after doctor and before later config writes", async () => { + await runRebuildPostRestorePhase(input()); + + expect(order).toEqual(["doctor", "reconcile", "messaging", "config-hash"]); + }); + + it("does not run OpenClaw session reconciliation for another agent", async () => { + agentName = "hermes"; + + await runRebuildPostRestorePhase(input()); + + expect(sessionModels.reconcileStalePinnedSessionModelsAfterRebuild).not.toHaveBeenCalled(); + expect(processRecovery.executeSandboxCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts index bd4551354a3..296c46576c5 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts @@ -23,6 +23,7 @@ import { restoreMcpAfterRebuild, } from "./rebuild-mcp-phase"; import { reapplyMessagingManifestAfterOpenClawDoctor } from "./rebuild-messaging-phase"; +import { reconcileStalePinnedSessionModelsAfterRebuild } from "./reconcile-session-models"; export interface RebuildPostRestorePhaseInput { sandboxName: string; @@ -145,6 +146,10 @@ export async function runRebuildPostRestorePhase( ); } + // #7102: clear stale per-session pinned models left over from an + // `inference set` before this rebuild, while the gateway is still down. + reconcileStalePinnedSessionModelsAfterRebuild(sandboxName, log); + await reapplyMessagingManifestAfterOpenClawDoctor(sandboxName, messagingPlan, log); log("Refreshing mutable OpenClaw config hash after post-restore config writes"); if (!refreshMutableOpenClawConfigHashAfterPostRestoreWrites(sandboxName, log)) { diff --git a/src/lib/actions/sandbox/reconcile-session-models.test.ts b/src/lib/actions/sandbox/reconcile-session-models.test.ts new file mode 100644 index 00000000000..f1bcb98a097 --- /dev/null +++ b/src/lib/actions/sandbox/reconcile-session-models.test.ts @@ -0,0 +1,362 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { + closeSync, + constants, + fstatSync, + mkdirSync, + mkdtempSync, + openSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { executeSandboxCommand } from "./process-recovery"; +import { + buildSessionStoreReplaceCommand, + reconcilePinnedSessionModels, + reconcileStalePinnedSessionModelsAfterRebuild, +} from "./reconcile-session-models"; + +vi.mock("./process-recovery", () => ({ executeSandboxCommand: vi.fn() })); + +const executeSandboxCommandMock = vi.mocked(executeSandboxCommand); + +beforeEach(() => { + executeSandboxCommandMock.mockReset(); +}); + +function store(entries: Record): string { + return JSON.stringify(entries); +} + +function readRegularFileNoFollow(filePath: string): string { + const descriptor = openSync( + filePath, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + try { + const metadata = fstatSync(descriptor); + expect(metadata.isFile(), `${filePath} must be a regular file`).toBe(true); + expect(metadata.nlink, `${filePath} must have exactly one link`).toBe(1); + return readFileSync(descriptor, "utf8"); + } finally { + closeSync(descriptor); + } +} + +describe("reconcilePinnedSessionModels (#7102)", () => { + const primary = "inference/nvidia/llama-3.3-nemotron-super-49b-v1.5"; + + it("clears a managed pin that no longer matches the current default", () => { + const raw = store({ + "agent:main:main": { + sessionId: "s1", + updatedAt: 1, + modelProvider: "inference", + model: "meta/llama-3.1-8b-instruct", + }, + }); + const result = reconcilePinnedSessionModels(raw, primary); + expect(result.changed).toBe(true); + expect(result.clearedSessionKeys).toEqual(["agent:main:main"]); + const parsed = JSON.parse(result.content); + expect(parsed["agent:main:main"].model).toBeUndefined(); + expect(parsed["agent:main:main"].modelProvider).toBeUndefined(); + // Non-model fields are preserved. + expect(parsed["agent:main:main"].sessionId).toBe("s1"); + }); + + it("leaves a session already on the current default untouched", () => { + const raw = store({ + "agent:main:main": { + modelProvider: "inference", + model: "nvidia/llama-3.3-nemotron-super-49b-v1.5", + }, + }); + const result = reconcilePinnedSessionModels(raw, primary); + expect(result.changed).toBe(false); + expect(result.content).toBe(raw); + }); + + it("leaves an intentional non-managed provider pin untouched", () => { + const raw = store({ + "agent:main:main": { modelProvider: "openai", model: "gpt-5.6-terra" }, + }); + const result = reconcilePinnedSessionModels(raw, primary); + expect(result.changed).toBe(false); + }); + + it("only clears the stale managed sessions in a mixed store", () => { + const raw = store({ + stale: { modelProvider: "inference", model: "meta/llama-3.1-8b-instruct" }, + current: { modelProvider: "inference", model: "nvidia/llama-3.3-nemotron-super-49b-v1.5" }, + intentional: { modelProvider: "openai", model: "gpt-5.6-terra" }, + unpinned: { sessionId: "x" }, + }); + const result = reconcilePinnedSessionModels(raw, primary); + expect(result.clearedSessionKeys).toEqual(["stale"]); + const parsed = JSON.parse(result.content); + expect(parsed.stale.model).toBeUndefined(); + expect(parsed.current.model).toBe("nvidia/llama-3.3-nemotron-super-49b-v1.5"); + expect(parsed.intentional.model).toBe("gpt-5.6-terra"); + expect(parsed.unpinned.sessionId).toBe("x"); + }); + + it("is a no-op when the primary ref is missing", () => { + const raw = store({ + "agent:main:main": { modelProvider: "inference", model: "meta/llama-3.1-8b-instruct" }, + }); + expect(reconcilePinnedSessionModels(raw, null).changed).toBe(false); + }); + + it("is a no-op on malformed session json", () => { + expect(reconcilePinnedSessionModels("not json", primary).changed).toBe(false); + expect(reconcilePinnedSessionModels("[]", primary).changed).toBe(false); + }); + + it("ignores an entry with a non-string model", () => { + const raw = store({ + "agent:main:main": { modelProvider: "inference", model: 42 }, + }); + expect(reconcilePinnedSessionModels(raw, primary).changed).toBe(false); + }); +}); + +describe("buildSessionStoreReplaceCommand", () => { + it("uses no-follow exclusive staging with atomic replacement and cleanup (#7102)", () => { + const command = buildSessionStoreReplaceCommand( + "/sandbox/.openclaw/agents/main/sessions/sessions.json", + '{"new":true}\n', + '{"old":true}', + ); + + expect(command).toContain("os.O_EXCL"); + expect(command).toContain("python3 -I -c"); + expect(command).toContain("os.O_NOFOLLOW"); + expect(command).toContain("os.fchown(staged_fd, source_stat.st_uid, source_stat.st_gid)"); + expect(command).toContain("os.fchmod(staged_fd, stat.S_IMODE(source_stat.st_mode))"); + expect(command).toContain("os.replace(staged_name, target_name"); + expect(command).toContain("if not installed and staged_name"); + expect(command).toContain("os.unlink(staged_name, dir_fd=parent_fd)"); + expect(command).not.toContain(".nemoclaw-tmp"); + }); + + it("atomically replaces a regular store while preserving its metadata (#7102)", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "nemoclaw-session-reconcile-"))); + try { + const sessionsPath = join(root, "sessions.json"); + const original = '{"old":true}\n'; + const replacement = '{"new":true}\n'; + writeFileSync(sessionsPath, original, { mode: 0o640 }); + const before = statSync(sessionsPath); + + const result = spawnSync( + "sh", + ["-c", buildSessionStoreReplaceCommand(sessionsPath, replacement, original.trim())], + { encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(readRegularFileNoFollow(sessionsPath)).toBe(replacement); + const after = statSync(sessionsPath); + expect(after.mode & 0o777).toBe(before.mode & 0o777); + expect(after.uid).toBe(before.uid); + expect(after.gid).toBe(before.gid); + expect(readdirSync(root)).toEqual(["sessions.json"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("refuses a symlinked store without changing its target (#7102)", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "nemoclaw-session-reconcile-link-"))); + try { + const targetPath = join(root, "target.json"); + const sessionsPath = join(root, "sessions.json"); + const original = '{"keep":true}\n'; + writeFileSync(targetPath, original); + symlinkSync(targetPath, sessionsPath); + + const result = spawnSync( + "sh", + [ + "-c", + buildSessionStoreReplaceCommand(sessionsPath, '{"replace":true}\n', original.trim()), + ], + { encoding: "utf8" }, + ); + + expect(result.status).not.toBe(0); + expect(readRegularFileNoFollow(targetPath)).toBe(original); + expect(readdirSync(root).sort()).toEqual(["sessions.json", "target.json"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("refuses a symlinked parent path without changing its target (#7102)", () => { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "nemoclaw-session-reconcile-parent-link-")), + ); + try { + const realParent = join(root, "real-sessions"); + const linkedParent = join(root, "linked-sessions"); + const original = '{"keep":true}\n'; + mkdirSync(realParent); + writeFileSync(join(realParent, "sessions.json"), original); + symlinkSync(realParent, linkedParent, "dir"); + + const result = spawnSync( + "sh", + [ + "-c", + buildSessionStoreReplaceCommand( + join(linkedParent, "sessions.json"), + '{"replace":true}\n', + original.trim(), + ), + ], + { encoding: "utf8" }, + ); + + expect(result.status).not.toBe(0); + expect(readRegularFileNoFollow(join(realParent, "sessions.json"))).toBe(original); + expect(readdirSync(realParent)).toEqual(["sessions.json"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("refuses stale source content without changing the store (#7102)", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "nemoclaw-session-reconcile-race-"))); + try { + const sessionsPath = join(root, "sessions.json"); + const current = '{"current":true}\n'; + writeFileSync(sessionsPath, current); + + const result = spawnSync( + "sh", + [ + "-c", + buildSessionStoreReplaceCommand(sessionsPath, '{"replacement":true}\n', '{"stale":true}'), + ], + { encoding: "utf8" }, + ); + + expect(result.status).not.toBe(0); + expect(readRegularFileNoFollow(sessionsPath)).toBe(current); + expect(readdirSync(root)).toEqual(["sessions.json"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("reconcileStalePinnedSessionModelsAfterRebuild", () => { + const primary = "inference/nvidia/llama-3.3-nemotron-super-49b-v1.5"; + const config = JSON.stringify({ agents: { defaults: { model: { primary } } } }); + const staleStore = store({ + "agent:main:main": { + modelProvider: "inference", + model: "meta/llama-3.1-8b-instruct", + }, + }); + + it("reads restored state and dispatches a guarded write for stale pins (#7102)", () => { + executeSandboxCommandMock + .mockReturnValueOnce({ status: 0, stdout: config, stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: staleStore, stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); + const log = vi.fn(); + + reconcileStalePinnedSessionModelsAfterRebuild("alpha", log); + + expect(executeSandboxCommandMock).toHaveBeenCalledTimes(3); + expect(executeSandboxCommandMock.mock.calls[0]).toEqual([ + "alpha", + "cat /sandbox/.openclaw/openclaw.json 2>/dev/null", + ]); + expect(executeSandboxCommandMock.mock.calls[1]).toEqual([ + "alpha", + "cat /sandbox/.openclaw/agents/main/sessions/sessions.json 2>/dev/null", + ]); + expect(log).toHaveBeenLastCalledWith( + `Session model reconcile: cleared stale pinned model on 1 session(s) so they follow ${primary}`, + ); + }); + + it("stops when the restored config has no primary model (#7102)", () => { + executeSandboxCommandMock.mockReturnValueOnce({ + status: 0, + stdout: '{"agents":{"defaults":{}}}', + stderr: "", + }); + const log = vi.fn(); + + reconcileStalePinnedSessionModelsAfterRebuild("alpha", log); + + expect(executeSandboxCommandMock).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenLastCalledWith( + "Session model reconcile skipped: could not read agents.defaults.model.primary", + ); + }); + + it("rejects a restored primary model with terminal control characters (#7102)", () => { + executeSandboxCommandMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ + agents: { defaults: { model: { primary: "inference/model\u001b[2J" } } }, + }), + stderr: "", + }); + const log = vi.fn(); + + reconcileStalePinnedSessionModelsAfterRebuild("alpha", log); + + expect(executeSandboxCommandMock).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenLastCalledWith( + "Session model reconcile skipped: could not read agents.defaults.model.primary", + ); + expect(log.mock.calls.flat().join(" ")).not.toContain("\u001b"); + }); + + it("stops when the restored session store cannot be read (#7102)", () => { + executeSandboxCommandMock + .mockReturnValueOnce({ status: 0, stdout: config, stderr: "" }) + .mockReturnValueOnce({ status: 1, stdout: "", stderr: "missing" }); + const log = vi.fn(); + + reconcileStalePinnedSessionModelsAfterRebuild("alpha", log); + + expect(executeSandboxCommandMock).toHaveBeenCalledTimes(2); + expect(log).toHaveBeenLastCalledWith( + "Session model reconcile skipped: no session store at /sandbox/.openclaw/agents/main/sessions/sessions.json", + ); + }); + + it("reports an atomic write failure without retrying or claiming success (#7102)", () => { + executeSandboxCommandMock + .mockReturnValueOnce({ status: 0, stdout: config, stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: staleStore, stderr: "" }) + .mockReturnValueOnce({ status: 9, stdout: "", stderr: "refused" }); + const log = vi.fn(); + + reconcileStalePinnedSessionModelsAfterRebuild("alpha", log); + + expect(executeSandboxCommandMock).toHaveBeenCalledTimes(3); + expect(log).toHaveBeenLastCalledWith( + "Session model reconcile: failed to write /sandbox/.openclaw/agents/main/sessions/sessions.json (status=9)", + ); + expect(log.mock.calls.flat()).not.toContainEqual(expect.stringContaining("cleared stale")); + }); +}); diff --git a/src/lib/actions/sandbox/reconcile-session-models.ts b/src/lib/actions/sandbox/reconcile-session-models.ts new file mode 100644 index 00000000000..3a20f18fd3c --- /dev/null +++ b/src/lib/actions/sandbox/reconcile-session-models.ts @@ -0,0 +1,285 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import { shellQuote } from "../../core/shell-quote"; +import { MANAGED_PROVIDER_ID } from "../../inference/config"; +import { isSafeModelId } from "../../validation"; +import { executeSandboxCommand } from "./process-recovery"; +import type { RebuildLog } from "./rebuild-credential-preflight"; +import { DEFAULT_AGENT_ID } from "./sessions/paths"; + +const OPENCLAW_CONFIG_PATH = "/sandbox/.openclaw/openclaw.json"; +const MAX_PRIMARY_MODEL_REF_LENGTH = 512; + +const SESSION_STORE_REPLACE_PYTHON = String.raw` +import base64 +import hashlib +import os +import secrets +import stat +import sys + +target_path = sys.argv[1] +payload = base64.b64decode(sys.argv[2], validate=True) +expected_sha256 = sys.argv[3] +parent_path, target_name = os.path.split(target_path) +if not os.path.isabs(target_path) or not parent_path or not target_name: + raise ValueError("session store path must be absolute with a parent and basename") +for flag_name in ("O_DIRECTORY", "O_NOFOLLOW"): + if not hasattr(os, flag_name): + raise OSError(f"{flag_name} is required for safe session store replacement") + +parent_fd = -1 +source_fd = -1 +staged_fd = -1 +staged_name = "" +staged_identity = None +installed = False +try: + directory_flags = ( + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0) + ) + parent_fd = os.open(os.sep, directory_flags) + for component in (part for part in parent_path.split(os.sep) if part): + next_fd = os.open(component, directory_flags, dir_fd=parent_fd) + os.close(parent_fd) + parent_fd = next_fd + source_fd = os.open( + target_name, + os.O_RDONLY + | os.O_NOFOLLOW + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NONBLOCK", 0), + dir_fd=parent_fd, + ) + source_stat = os.fstat(source_fd) + if not stat.S_ISREG(source_stat.st_mode) or source_stat.st_nlink != 1: + raise ValueError("session store must be a single regular file") + source_chunks = [] + while True: + chunk = os.read(source_fd, 1024 * 1024) + if not chunk: + break + source_chunks.append(chunk) + if hashlib.sha256(b"".join(source_chunks).strip()).hexdigest() != expected_sha256: + raise ValueError("session store changed before atomic replacement") + + create_flags = ( + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | os.O_NOFOLLOW + | getattr(os, "O_CLOEXEC", 0) + ) + for _attempt in range(100): + staged_name = f".sessions.json.nemoclaw.{secrets.token_hex(16)}" + try: + staged_fd = os.open(staged_name, create_flags, 0o600, dir_fd=parent_fd) + break + except FileExistsError: + continue + if staged_fd < 0: + raise OSError("could not create a private session store staging file") + staged_identity = os.fstat(staged_fd) + if not stat.S_ISREG(staged_identity.st_mode) or staged_identity.st_nlink != 1: + raise ValueError("session store staging path is not a single regular file") + + written = 0 + while written < len(payload): + count = os.write(staged_fd, payload[written:]) + if count <= 0: + raise OSError("session store staging write made no progress") + written += count + os.fchown(staged_fd, source_stat.st_uid, source_stat.st_gid) + os.fchmod(staged_fd, stat.S_IMODE(source_stat.st_mode)) + os.fsync(staged_fd) + + current_stat = os.stat(target_name, dir_fd=parent_fd, follow_symlinks=False) + current_identity = ( + current_stat.st_dev, + current_stat.st_ino, + current_stat.st_size, + current_stat.st_mtime_ns, + current_stat.st_ctime_ns, + ) + source_identity = ( + source_stat.st_dev, + source_stat.st_ino, + source_stat.st_size, + source_stat.st_mtime_ns, + source_stat.st_ctime_ns, + ) + if current_identity != source_identity: + raise ValueError("session store changed before atomic replacement") + latest_staged = os.stat(staged_name, dir_fd=parent_fd, follow_symlinks=False) + if (latest_staged.st_dev, latest_staged.st_ino) != ( + staged_identity.st_dev, + staged_identity.st_ino, + ): + raise ValueError("session store staging file changed before atomic replacement") + + os.replace(staged_name, target_name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd) + installed = True + os.fsync(parent_fd) +finally: + if not installed and staged_name and staged_identity is not None and parent_fd >= 0: + try: + latest_staged = os.stat(staged_name, dir_fd=parent_fd, follow_symlinks=False) + if (latest_staged.st_dev, latest_staged.st_ino) == ( + staged_identity.st_dev, + staged_identity.st_ino, + ): + os.unlink(staged_name, dir_fd=parent_fd) + except OSError: + pass + for descriptor in (staged_fd, source_fd, parent_fd): + if descriptor >= 0: + os.close(descriptor) +`.trim(); + +function defaultAgentSessionsPath(agentId: string): string { + return `/sandbox/.openclaw/agents/${agentId}/sessions/sessions.json`; +} + +export function buildSessionStoreReplaceCommand( + sessionsPath: string, + content: string, + expectedSource: string, +): string { + const encoded = Buffer.from(content, "utf8").toString("base64"); + const expectedSha256 = createHash("sha256").update(expectedSource).digest("hex"); + return [ + "python3", + "-I", + "-c", + shellQuote(SESSION_STORE_REPLACE_PYTHON), + shellQuote(sessionsPath), + shellQuote(encoded), + shellQuote(expectedSha256), + ].join(" "); +} + +export interface SessionModelReconcileResult { + changed: boolean; + content: string; + clearedSessionKeys: string[]; +} + +/** + * #7102: OpenClaw pins a `{ modelProvider, model }` on each stored session in + * `agents//sessions/sessions.json`. `nemoclaw inference set` + `rebuild` + * update the config default (`agents.defaults.model.primary`) but leave those + * per-session pins on the pre-switch model, so the TUI status bar shows the old + * model when it resumes the last session on the first connect after a switch. + * + * This reconciles the persisted pins: for a session whose pin is the *managed* + * provider and no longer matches the current default, clear the pin so the + * session falls back to the config default — matching OpenClaw's own clean-entry + * semantics after `sessions.reset`. Sessions already on the default, and + * sessions pinned to a different provider (an intentional per-session choice), + * are left untouched. Pure so the contract is unit-tested without a sandbox. + */ +export function reconcilePinnedSessionModels( + sessionsRaw: string, + primaryModelRef: string | null, +): SessionModelReconcileResult { + const noChange: SessionModelReconcileResult = { + changed: false, + content: sessionsRaw, + clearedSessionKeys: [], + }; + if (!primaryModelRef) return noChange; + let parsed: unknown; + try { + parsed = JSON.parse(sessionsRaw); + } catch { + return noChange; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return noChange; + const store = parsed as Record; + const clearedSessionKeys: string[] = []; + for (const [key, entry] of Object.entries(store)) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; + const record = entry as Record; + const provider = record.modelProvider; + const model = record.model; + // Only touch pins on the managed provider; a different provider is an + // intentional per-session choice. + if (provider !== MANAGED_PROVIDER_ID || typeof model !== "string") continue; + // Already following the current default → nothing to reconcile. + if (`${provider}/${model}` === primaryModelRef) continue; + delete record.model; + delete record.modelProvider; + clearedSessionKeys.push(key); + } + if (clearedSessionKeys.length === 0) return noChange; + return { + changed: true, + content: `${JSON.stringify(store, null, 2)}\n`, + clearedSessionKeys, + }; +} + +function readPrimaryModelRef(sandboxName: string): string | null { + const res = executeSandboxCommand(sandboxName, `cat ${OPENCLAW_CONFIG_PATH} 2>/dev/null`); + if (!res || res.status !== 0 || !res.stdout.trim()) return null; + try { + const config = JSON.parse(res.stdout) as { + agents?: { defaults?: { model?: { primary?: unknown } } }; + }; + const primary = config.agents?.defaults?.model?.primary; + if (typeof primary !== "string") return null; + const normalized = primary.trim(); + return normalized.length > 0 && + normalized.length <= MAX_PRIMARY_MODEL_REF_LENGTH && + isSafeModelId(normalized) + ? normalized + : null; + } catch { + return null; + } +} + +/** + * Best-effort reconcile of stale pinned session models after a rebuild restore. + * MUST run in the post-restore window (gateway down): OpenClaw owns + * `sessions.json` while it is live, so editing it during `inference set` would + * race its writes, while omitting the session store from rebuild restore would + * discard conversation state. This recovery can be removed when OpenClaw + * exposes an offline, race-free session-model reset operation. + */ +export function reconcileStalePinnedSessionModelsAfterRebuild( + sandboxName: string, + log: RebuildLog, +): void { + const primary = readPrimaryModelRef(sandboxName); + if (!primary) { + log("Session model reconcile skipped: could not read agents.defaults.model.primary"); + return; + } + const sessionsPath = defaultAgentSessionsPath(DEFAULT_AGENT_ID); + const readResult = executeSandboxCommand(sandboxName, `cat ${sessionsPath} 2>/dev/null`); + if (!readResult || readResult.status !== 0 || !readResult.stdout.trim()) { + log(`Session model reconcile skipped: no session store at ${sessionsPath}`); + return; + } + const reconciled = reconcilePinnedSessionModels(readResult.stdout, primary); + if (!reconciled.changed) { + log("Session model reconcile: no stale pinned session models"); + return; + } + const writeResult = executeSandboxCommand( + sandboxName, + buildSessionStoreReplaceCommand(sessionsPath, reconciled.content, readResult.stdout), + ); + if (!writeResult || writeResult.status !== 0) { + log( + `Session model reconcile: failed to write ${sessionsPath} (status=${writeResult?.status ?? "null"})`, + ); + return; + } + log( + `Session model reconcile: cleared stale pinned model on ${reconciled.clearedSessionKeys.length} session(s) so they follow ${primary}`, + ); +}