#!/bin/bash # RMP Post-Install Patch Script — run after any OpenClaw update. # Keeps OpenClaw aligned with RMP architecture: # - hook persistence (typed hooks always fire when runner exists) # - suppress native announce/Slack for RMP-owned sessions # - minimal bootstrap for RMP internal sessions (task/verify/intake) # - allowUnsafe passthrough for RMP JSON intake # - LLM idle 5s (fail fast, rotate NVIDIA keys); OpenAI first byte 20s; # max-effort OpenAI calls 120s first byte and 30s between chunks # - HTTP 410 skip (model_not_found, not idle-timeout retry) # - skip local placements in the cloud-worker orphan cleanup (startup freeze) # Model fallbacks (gpt-6-luna → gpt-oss-20b on NVIDIA) are INTENTIONAL — do not disable them. set -euo pipefail # OPENCLAW_DIST_DIR: another dist to patch (ops/openclaw_preflight.py rehearses a target there). DIST_DIR="${OPENCLAW_DIST_DIR:-/usr/lib/node_modules/openclaw/dist}" PATCHED=0 RMP_GUARD='rmp_(task|verify|intake)_' echo "=== RMP OpenClaw Patch Script ===" echo "Target: $DIST_DIR" echo "" if [ ! -d "$DIST_DIR" ]; then echo "ERROR: OpenClaw dist directory not found at $DIST_DIR" exit 1 fi # Narrow file set via ripgrep when available (full-tree sed is very slow). 2026.9.7 ships most of # its dist as .mjs, and repeats many targets in package-update-activation-recovery.mjs. mapfile -t CANDIDATES < <( if command -v rg >/dev/null 2>&1; then rg -l --glob '*.js' --glob '*.mjs' \ 'hasHooks\("before_message_write"\)|filterBootstrapFilesForSession|runSubagentAnnounceFlow|async function deliverReplies|fallbackConfigured = false && hasConfiguredModelFallbacks|function normalizeAgentPayload|allowUnsafeExternalContent: value\.allowUnsafeExternalContent|DEFAULT_LLM_IDLE_TIMEOUT_MS|scanCanonicalSqliteSessionEntries|validateCanonicalSessionRow|existing\.entry_valid !== 1|function parseSqliteSessionEntryRecord|status === 410|cleanedWorkspaceRoots|function buildOpenAIThinkingProfile|function createOpenAIResponsesContextManagementWrapper' \ "$DIST_DIR" 2>/dev/null || true else find "$DIST_DIR" \( -name '*.js' -o -name '*.mjs' \) fi ) if [ "${#CANDIDATES[@]}" -eq 0 ]; then echo "WARN: no candidate files matched; falling back to full dist scan" mapfile -t CANDIDATES < <(find "$DIST_DIR" \( -name '*.js' -o -name '*.mjs' \)) fi echo "Scanning ${#CANDIDATES[@]} candidate file(s)" restore_model_fallbacks() { local f="$1" if grep -q 'const fallbackConfigured = false && hasConfiguredModelFallbacks({' "$f" 2>/dev/null; then sed -i 's/const fallbackConfigured = false && hasConfiguredModelFallbacks({/const fallbackConfigured = hasConfiguredModelFallbacks({/' "$f" echo " Restored model fallbacks in $(basename "$f")" PATCHED=$((PATCHED + 1)) fi } patch_file() { local f="$1" local applied="" # Patch 1: Hook persistence — typed hasHooks() can miss plugin api.on() hooks. if grep -q 'hookRunner?.hasHooks("before_message_write")' "$f" 2>/dev/null; then sed -i 's/hookRunner?.hasHooks("before_message_write")/hookRunner \/* RMP_HOOK_PERSISTENCE *\//g' "$f" applied="${applied} hook-persistence" fi # Patch 2: Announce suppression for RMP-owned sessions (2026.9 returns "delivered"). if grep -q 'async function runSubagentAnnounceFlow(params) {' "$f" 2>/dev/null \ && ! grep -q 'RMP_ANNOUNCE_SUPPRESS' "$f" 2>/dev/null; then python3 - "$f" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() needle = "async function runSubagentAnnounceFlow(params) {" insert = ( needle + ' if (params.childSessionKey && /rmp_(task|verify|intake)_/.test(params.childSessionKey)) return "delivered"; /* RMP_ANNOUNCE_SUPPRESS */' ) if needle not in text: sys.exit(0) path.write_text(text.replace(needle, insert, 1)) print("patched-announce") PY if grep -q 'RMP_ANNOUNCE_SUPPRESS' "$f" 2>/dev/null; then applied="${applied} announce-suppress" fi fi # Patch 3: Minimal bootstrap for RMP internal sessions. # 2026.9 second arg is a session object (or a string key on older dist). if grep -q 'function filterBootstrapFilesForSession(files,' "$f" 2>/dev/null \ && ! grep -q 'RMP_MINIMAL_BOOTSTRAP' "$f" 2>/dev/null; then python3 - "$f" <<'PY' import pathlib, re, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() m = re.search(r"function filterBootstrapFilesForSession\(files, \w+\) \{", text) if not m: sys.exit(0) insert = ( m.group(0) + ' const __rmpSk = typeof session === "string" ? session : session && session.sessionKey;' + ' if (__rmpSk && /rmp_(task|verify|intake)_/.test(__rmpSk)) return files.filter((file) => file.name === "TOOLS.md" || file.name === "tools.md"); /* RMP_MINIMAL_BOOTSTRAP */' ) # If the param is not named `session`, bind from the actual parameter. param = re.search(r"function filterBootstrapFilesForSession\(files, (\w+)\)", m.group(0)) arg = param.group(1) if param else "session" if arg != "session": insert = ( m.group(0) + f' const __rmpSk = typeof {arg} === "string" ? {arg} : {arg} && {arg}.sessionKey;' + ' if (__rmpSk && /rmp_(task|verify|intake)_/.test(__rmpSk)) return files.filter((file) => file.name === "TOOLS.md" || file.name === "tools.md"); /* RMP_MINIMAL_BOOTSTRAP */' ) path.write_text(text[:m.start()] + insert + text[m.end():]) print("patched-bootstrap") PY if grep -q 'RMP_MINIMAL_BOOTSTRAP' "$f" 2>/dev/null; then applied="${applied} rmp-minimal-bootstrap" fi fi # Patch 4: Native Slack deliverReplies → RMP suppressor if grep -qE 'async function deliverReplies(\$[0-9]+)?\(params\) \{' "$f" 2>/dev/null \ && ! grep -q '__RMP_SUPPRESS_NATIVE_SLACK' "$f" 2>/dev/null; then sed -i -E 's/async function deliverReplies(\$[0-9]+)?\(params\) \{/async function deliverReplies\1(params) { try { if (typeof globalThis.__RMP_SUPPRESS_NATIVE_SLACK === "function" \&\& globalThis.__RMP_SUPPRESS_NATIVE_SLACK(params)) return; } catch (_) {} /' "$f" applied="${applied} slack-rmp-suppress" fi # Patch 5a: OpenClaw 2026.7+ dropped allowUnsafeExternalContent from HTTP # /hooks/agent normalizeAgentPayload — without it, EXTERNAL wrap forces NO_REPLY # on structured JSON intake. Pass through flag + auto-enable for RMP sessions. if grep -q 'function normalizeAgentPayload(payload)' "$f" 2>/dev/null \ && ! grep -q 'RMP_ALLOW_UNSAFE_EXTERNAL' "$f" 2>/dev/null; then python3 - "$f" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() needles = [ # 2026.7 — timeoutSeconds inline in the returned object ( "\t\t\ttimeoutSeconds: typeof timeoutRaw === \"number\" && Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? Math.floor(timeoutRaw) : void 0\n\t\t}", ( "\t\t\ttimeoutSeconds: typeof timeoutRaw === \"number\" && Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? Math.floor(timeoutRaw) : void 0,\n" "\t\t\t/* RMP_ALLOW_UNSAFE_EXTERNAL */\n" "\t\t\tallowUnsafeExternalContent: payload.allowUnsafeExternalContent === true || " "(typeof sessionKey === \"string\" && /rmp_(task|verify|intake)_/.test(sessionKey)) ? true : void 0\n" "\t\t}" ), ), # 2026.9 — timeoutSeconds already a local, returned as a shorthand property ( "\t\t\tmodel,\n\t\t\tthinking,\n\t\t\ttimeoutSeconds\n\t\t}", ( "\t\t\tmodel,\n" "\t\t\tthinking,\n" "\t\t\ttimeoutSeconds,\n" "\t\t\t/* RMP_ALLOW_UNSAFE_EXTERNAL */\n" "\t\t\tallowUnsafeExternalContent: payload.allowUnsafeExternalContent === true || " "(typeof sessionKey === \"string\" && /rmp_(task|verify|intake)_/.test(sessionKey)) ? true : void 0\n" "\t\t}" ), ), ] for needle, insert in needles: if needle in text: path.write_text(text.replace(needle, insert, 1)) print("patched-normalize") break PY if grep -q 'RMP_ALLOW_UNSAFE_EXTERNAL' "$f" 2>/dev/null; then applied="${applied} allow-unsafe-passthrough" fi fi # Patch 5b: Force allowUnsafe for RMP session keys at dispatch (belt-and-suspenders). if grep -q 'allowUnsafeExternalContent: value.allowUnsafeExternalContent,' "$f" 2>/dev/null \ && ! grep -q 'RMP_FORCE_ALLOW_UNSAFE' "$f" 2>/dev/null; then sed -i 's/allowUnsafeExternalContent: value.allowUnsafeExternalContent,/allowUnsafeExternalContent: value.allowUnsafeExternalContent === true || (typeof value.sessionKey === "string" \&\& \/rmp_(task|verify|intake)_\/.test(value.sessionKey)) \/* RMP_FORCE_ALLOW_UNSAFE *\/,/g' "$f" applied="${applied} allow-unsafe-rmp-force" fi # Patch 6: Fast LLM idle silence (5s) then rotate NVIDIA keys — do not sit 120s. # A `const` in most chunks; an assignment to a hoisted `var` in 2026.9.7's recovery bundle. if grep -q 'DEFAULT_LLM_IDLE_TIMEOUT_MS = 12e4;' "$f" 2>/dev/null; then sed -i -E 's/((const )?DEFAULT_LLM_IDLE_TIMEOUT_MS = )12e4;/\15e3; \/* RMP_LLM_IDLE_5S *\//' "$f" applied="${applied} llm-idle-5s" fi # Patch 6b: HTTP 410 is a dead/retired model — skip to next fallback, do not # classify as timeout (which becomes a 5s idle retry on the same model). if grep -q 'if (status === 410)' "$f" 2>/dev/null \ && ! grep -q 'RMP_410_SKIP' "$f" 2>/dev/null; then python3 - "$f" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() if "RMP_410_SKIP" in text: raise SystemExit(0) old = ( '\tif (status === 410) {\n' '\t\tif (messageReason === "session_expired" || messageReason === "billing" || messageReason === "auth_permanent" || messageReason === "auth") return messageClassification;\n' '\t\treturn toReasonClassification("timeout");\n' '\t}' ) new = ( '\tif (status === 410) {\n' '\t\tif (messageReason === "session_expired" || messageReason === "billing" || messageReason === "auth_permanent" || messageReason === "auth") return messageClassification;\n' '\t\treturn toReasonClassification("model_not_found"); /* RMP_410_SKIP */\n' '\t}' ) if old in text: path.write_text(text.replace(old, new, 1)) print("patched-410") PY if grep -q 'RMP_410_SKIP' "$f" 2>/dev/null; then applied="${applied} 410-skip" fi fi # Patch 6c: OpenAI gets 20s for the first byte (stream creation, first chunk, and # the provider's first-event guard). gpt-5-nano's first byte takes about 4s at the # median and up to 5s, so a 5s cut re-billed the prompt on roughly half the calls. # Gaps between chunks, and every other provider, keep the 5s rule. The 20s is written inline: # 2026.9.7's recovery bundle assigns these module constants to hoisted vars inside an init # function, where a new const would be out of scope for streamWithIdleTimeout. if grep -q 'function streamWithIdleTimeout(baseFn, timeoutMs, onIdleTimeout, opts) {' "$f" 2>/dev/null \ && ! grep -q 'RMP_OPENAI_FIRST_BYTE_20S' "$f" 2>/dev/null; then python3 - "$f" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() if "RMP_OPENAI_FIRST_BYTE_20S" in text: raise SystemExit(0) edits = [ ( "isSelfHostedRuntimeModel ? LOCAL_LLM_FIRST_EVENT_TIMEOUT_MS : CLOUD_LLM_FIRST_EVENT_TIMEOUT_MS, ...timeoutBounds));", "isSelfHostedRuntimeModel ? LOCAL_LLM_FIRST_EVENT_TIMEOUT_MS : params?.model?.provider === \"openai\" ? 2e4 /* RMP_OPENAI_FIRST_BYTE_20S */ : CLOUD_LLM_FIRST_EVENT_TIMEOUT_MS, ...timeoutBounds));", ), ( # Inside the returned (model, context, options) closure; 2026.9.7 opens it with a # trackCleanup line, so the anchor is this line alone. "\t\tconst createIdleTimeoutError = () => /* @__PURE__ */ new Error(`LLM idle timeout (${Math.floor(timeoutMs / 1e3)}s): no response from model`);", "\t\tconst firstByteMs = model?.provider === \"openai\" ? Math.max(timeoutMs, 2e4) : timeoutMs; /* RMP_OPENAI_FIRST_BYTE_20S */\n" "\t\tconst createIdleTimeoutError = (windowMs = timeoutMs) => /* @__PURE__ */ new Error(`LLM idle timeout (${Math.floor(windowMs / 1e3)}s): no response from model`);", ), ( "\t\t\t\tconst timer = setTimeout(() => {\n" "\t\t\t\t\tconst error = createIdleTimeoutError();\n" "\t\t\t\t\tabortStream(error);\n" "\t\t\t\t\tonIdleTimeout?.(error);\n" "\t\t\t\t\treject(error);\n" "\t\t\t\t}, timeoutMs);", "\t\t\t\tconst timer = setTimeout(() => {\n" "\t\t\t\t\tconst error = createIdleTimeoutError(firstByteMs);\n" "\t\t\t\t\tabortStream(error);\n" "\t\t\t\t\tonIdleTimeout?.(error);\n" "\t\t\t\t\treject(error);\n" "\t\t\t\t}, firstByteMs);", ), ( "\t\t\t\t\tconst recentActivity = activeToolMs > 0 && Date.now() - activeToolMs < timeoutMs;\n" "\t\t\t\t\tconst isFirstStreamArm = firstArmPending && !streamFirstArmDone;\n" "\t\t\t\t\tconst effectiveTimeout = isFirstStreamArm && recentActivity ? Math.max(1, timeoutMs - Math.max(0, Date.now() - activeToolMs)) : timeoutMs;\n" "\t\t\t\t\tfirstArmPending = false;\n" "\t\t\t\t\tif (isFirstStreamArm) streamFirstArmDone = true;\n" "\t\t\t\t\tidleTimer = setTimeout(() => {\n" "\t\t\t\t\t\tidleTimer = null;\n" "\t\t\t\t\t\tconst error = createIdleTimeoutError();", "\t\t\t\t\tconst isFirstStreamArm = firstArmPending && !streamFirstArmDone;\n" "\t\t\t\t\tconst armMs = isFirstStreamArm ? firstByteMs : timeoutMs;\n" "\t\t\t\t\tconst recentActivity = activeToolMs > 0 && Date.now() - activeToolMs < armMs;\n" "\t\t\t\t\tconst effectiveTimeout = isFirstStreamArm && recentActivity ? Math.max(1, armMs - Math.max(0, Date.now() - activeToolMs)) : armMs;\n" "\t\t\t\t\tfirstArmPending = false;\n" "\t\t\t\t\tif (isFirstStreamArm) streamFirstArmDone = true;\n" "\t\t\t\t\tidleTimer = setTimeout(() => {\n" "\t\t\t\t\t\tidleTimer = null;\n" "\t\t\t\t\t\tconst error = createIdleTimeoutError(armMs);", ), ] missing = [old[:60] for old, _ in edits if text.count(old) != 1] if missing: print("skip openai-first-byte (dist shape changed): " + "; ".join(missing)) raise SystemExit(0) for old, new in edits: text = text.replace(old, new, 1) path.write_text(text) print("patched-openai-first-byte") PY if grep -q 'RMP_OPENAI_FIRST_BYTE_20S' "$f" 2>/dev/null; then applied="${applied} openai-first-byte-20s" fi fi # Patch 6d: OpenAI calls at thinking=max get 120s for the first byte and 30s # between chunks. gpt-6-luna at max sent nothing for 69s on one probe and paused # 5s mid-stream on another. Every other call keeps Patch 6c's rule. if grep -q 'RMP_OPENAI_FIRST_BYTE_20S' "$f" 2>/dev/null \ && ! grep -q 'RMP_OPENAI_MAX_EFFORT_120S' "$f" 2>/dev/null; then python3 - "$f" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() if "RMP_OPENAI_MAX_EFFORT_120S" in text: raise SystemExit(0) edits = [ ( "\t\tconst firstByteMs = model?.provider === \"openai\" ? Math.max(timeoutMs, 2e4) : timeoutMs; /* RMP_OPENAI_FIRST_BYTE_20S */\n", "\t\tconst rmpMaxEffort = model?.provider === \"openai\" && options?.reasoning === \"max\"; /* RMP_OPENAI_MAX_EFFORT_120S */\n" "\t\tconst firstByteMs = rmpMaxEffort ? Math.max(timeoutMs, 12e4) : model?.provider === \"openai\" ? Math.max(timeoutMs, 2e4) : timeoutMs; /* RMP_OPENAI_FIRST_BYTE_20S */\n" "\t\tconst gapMs = rmpMaxEffort ? Math.max(timeoutMs, 3e4) : timeoutMs;\n", ), ( "\t\t\t\t\tconst armMs = isFirstStreamArm ? firstByteMs : timeoutMs;\n", "\t\t\t\t\tconst armMs = isFirstStreamArm ? firstByteMs : gapMs;\n", ), ( "\t\t\t\tfirstEventTimeoutMs: optionsWithFirstEvent?.firstEventTimeoutMs ?? firstEventTimeoutMs,\n", "\t\t\t\tfirstEventTimeoutMs: optionsWithFirstEvent?.firstEventTimeoutMs ?? (model?.provider === \"openai\" && options?.reasoning === \"max\" ? Math.max(firstEventTimeoutMs, 12e4) : firstEventTimeoutMs),\n", ), ] missing = [old[:60] for old, _ in edits if text.count(old) != 1] if missing: print("skip openai-max-effort (dist shape changed): " + "; ".join(missing)) raise SystemExit(0) for old, new in edits: text = text.replace(old, new, 1) path.write_text(text) print("patched-openai-max-effort") PY if grep -q 'RMP_OPENAI_MAX_EFFORT_120S' "$f" 2>/dev/null; then applied="${applied} openai-max-effort-120s" fi fi # Patch 6e: OpenClaw 2026.9.1 offers "max" only to gpt-5.6* and ignores declared # efforts outside the codex runtime, so a gpt-6-luna session clamped max to high. # Backport 2026.9.6's GPT-6 branch: levels come from the declared efforts. Releases # that already know GPT-6 (OPENAI_GPT_6_MODEL_IDS) are left alone. if grep -q 'function buildOpenAIThinkingProfile(params) {' "$f" 2>/dev/null \ && ! grep -qE 'RMP_GPT6_THINKING_BACKPORT|OPENAI_GPT_6_MODEL_IDS' "$f" 2>/dev/null; then python3 - "$f" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() if "RMP_GPT6_THINKING_BACKPORT" in text or "OPENAI_GPT_6_MODEL_IDS" in text: raise SystemExit(0) old = "\tconst codexEfforts = params.compat?.supportedReasoningEfforts?.map(normalizeLowercaseStringOrEmpty);\n" new = old + ( "\tif (/^gpt-6(?:-|$)/u.test(modelId)) { /* RMP_GPT6_THINKING_BACKPORT */\n" "\t\tconst rmpGpt6Efforts = codexEfforts?.length ? codexEfforts : [\"none\", \"low\", \"medium\", \"high\", \"xhigh\", \"max\"];\n" "\t\treturn { levels: buildCodexLevels(rmpGpt6Efforts), ...rmpGpt6Efforts.includes(\"medium\") ? { defaultLevel: \"medium\" } : {} };\n" "\t}\n" ) if text.count(old) != 1: print("skip gpt6-thinking-backport (dist shape changed)") raise SystemExit(0) path.write_text(text.replace(old, new, 1)) print("patched-gpt6-thinking-backport") PY if grep -q 'RMP_GPT6_THINKING_BACKPORT' "$f" 2>/dev/null; then applied="${applied} gpt6-thinking-backport" fi fi # Patch 6f: the OpenAI provider sent store:true on every Responses call, so OpenAI kept # Aura's prompts and replies (Kirill, 2026-09-29: storage off). Reasoning still carries # across turns: the transport requests reasoning.encrypted_content and replays it when # store is false. Server-side compaction needs stored responses, so it goes too; # OpenClaw's own compaction handles long sessions, as on Chat Completions before. if grep -q 'function createOpenAIResponsesContextManagementWrapper' "$f" 2>/dev/null \ && ! grep -q 'RMP_OPENAI_NO_STORE' "$f" 2>/dev/null; then python3 - "$f" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() if "RMP_OPENAI_NO_STORE" in text: raise SystemExit(0) old = ( "\t\tconst policy = resolveOpenAIResponsesPayloadPolicy(model, {\n" "\t\t\textraParams,\n" "\t\t\tenablePromptCacheStripping: true,\n" "\t\t\tenableServerCompaction: true,\n" "\t\t\tstoreMode: \"provider-policy\"\n" "\t\t});\n" ) new = ( "\t\tconst rmpNoStore = String(model?.provider || \"\").toLowerCase() === \"openai\"; /* RMP_OPENAI_NO_STORE */\n" "\t\tconst policy = resolveOpenAIResponsesPayloadPolicy(model, {\n" "\t\t\textraParams,\n" "\t\t\tenablePromptCacheStripping: true,\n" "\t\t\tenableServerCompaction: !rmpNoStore,\n" "\t\t\tstoreMode: rmpNoStore ? \"disable\" : \"provider-policy\"\n" "\t\t});\n" ) if text.count(old) != 1: print("skip openai-no-store (dist shape changed)") raise SystemExit(0) path.write_text(text.replace(old, new, 1)) print("patched-openai-no-store") PY if grep -q 'RMP_OPENAI_NO_STORE' "$f" 2>/dev/null; then applied="${applied} openai-no-store" fi fi # Patch 7: a session_nodes row whose entry_valid is not 1 fails the canonical checks # closed, and with them every /hooks/agent: in-flight {} placeholders, and parseable # rows that the entry_valid triggers reset to 0 whenever an entry is updated (RMP's # session writes included). Keep placeholders skippable and let parseable pending rows # through. 2026.9.1 applies the rule in the scan and the write-path check; 2026.9.7 # moved the scan's rule into validateCanonicalSessionRow and ...RowEntry, which still # refuse entry_valid 0 when admitting a row. if grep -qE 'scanCanonicalSqliteSessionEntries|validateCanonicalSessionRow|existing\.entry_valid !== 1' "$f" 2>/dev/null; then out7="$(python3 - "$f" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() edits = [ # 2026.9.1: the scan ( '\t\tif (row.entry_json === "{}" && row.entry_valid === -1 && row.retained_window_id === row.current_session_id) continue;\n' '\t\tif (row.entry_valid !== 1) throw canonicalSessionKeyMigrationRequiredError(`invalid persisted session row requires repair for ${row.session_key}`);\n', '\t\tif (row.entry_json === "{}" && row.entry_valid !== 1 && row.retained_window_id === row.current_session_id) continue; /* RMP_SESSION_PLACEHOLDER_SKIP */\n', ), # 2026.9.1 and 2026.9.7: the write-path check ( '\t\tif (existing && existing.entry_valid !== 1) {\n' '\t\t\tif (!(existing.entry_json === "{}" ? executeSqliteQueryTakeFirstSync(database.db, db.selectFrom("session_windows").select("session_id").where("session_id", "=", existing.current_session_id).where("session_key", "=", scope.sessionKey)) : void 0)) throw canonicalSessionKeyMigrationRequiredError(`invalid persisted session row requires repair for ${scope.sessionKey}`);\n' '\t\t}\n', '\t\tif (existing && existing.entry_valid !== 1) {\n' '\t\t\tif (existing.entry_json !== "{}" && parseSessionEntryJson(existing)) { /* RMP_SESSION_PENDING_OK */ }\n' '\t\t\telse if (!(existing.entry_json === "{}" ? executeSqliteQueryTakeFirstSync(database.db, db.selectFrom("session_windows").select("session_id").where("session_id", "=", existing.current_session_id).where("session_key", "=", scope.sessionKey)) : void 0)) throw canonicalSessionKeyMigrationRequiredError(`invalid persisted session row requires repair for ${scope.sessionKey}`);\n' '\t\t}\n', ), # 2026.9.7: row validation ( '\tconst record = row.entry_valid === 1 || mode === "read" && row.entry_valid === 0 ? parseSqliteSessionEntryRecord({\n', '\tconst record = row.entry_valid === 1 || row.entry_valid === 0 ? parseSqliteSessionEntryRecord({ /* RMP_SESSION_PENDING_OK */\n', ), ( '\tif (row.entry_json === "{}" && row.entry_valid === -1 && row.retained_window_id === row.current_session_id) return;\n', '\tif (row.entry_json === "{}" && row.entry_valid !== 1 && row.retained_window_id === row.current_session_id) return; /* RMP_SESSION_PLACEHOLDER_SKIP */\n', ), ( '\tif (!entry || row.entry_valid !== 1 && (mode !== "read" || row.entry_valid !== 0)) throw canonicalSessionKeyMigrationRequiredError(', '\tif (!entry || row.entry_valid !== 1 && row.entry_valid !== 0) throw canonicalSessionKeyMigrationRequiredError(', ), ] applied = 0 for old, new in edits: if text.count(old) == 1: text = text.replace(old, new, 1) applied += 1 if applied: path.write_text(text) print("patched-session-canonical") PY )" if [[ "${out7}" == *patched-session-canonical* ]]; then applied="${applied} session-canonical-lenient" fi fi # Patch 8: column updated_at can lag JSON updatedAt by tens of ms. The # stock parser then returns null and every hook throws SESSION_CANONICAL. if grep -q 'function parseSqliteSessionEntryRecord' "$f" 2>/dev/null \ && ! grep -q 'RMP_SESSION_TS_DRIFT' "$f" 2>/dev/null; then python3 - "$f" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() old = ( "\t\tif (row.current_session_id !== void 0 && row.current_session_id !== record.sessionId " "|| row.updated_at !== void 0 && row.updated_at !== record.updatedAt) return null;\n" ) new = ( "\t\tif (row.current_session_id !== void 0 && row.current_session_id !== record.sessionId) " "return null; /* RMP_SESSION_TS_DRIFT */\n" ) if old in text: path.write_text(text.replace(old, new, 1)) print("patched-session-ts-drift") PY if grep -q 'RMP_SESSION_TS_DRIFT' "$f" 2>/dev/null; then applied="${applied} session-ts-drift" fi fi # Patch 9: the first reconcile after start runs the cloud-worker orphan cleanup over # every placement. Each resolveWorkspacePath re-reads and parses the whole session # store (uncached), then fails for placements without a worktree, so ~500 local # placements froze the gateway for ~100 s per start. A placement that never left # this host (local, no environment) has no worker results to clean. if grep -q 'cleanedWorkspaceRoots' "$f" 2>/dev/null \ && ! grep -q 'RMP_SKIP_LOCAL_PLACEMENT_CLEANUP' "$f" 2>/dev/null; then python3 - "$f" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() old = ( "\t\tfor (const placement of placements.list()) try {\n" "\t\t\tconst root = await deps.resolveWorkspacePath(placement);\n" "\t\t\tif (!cleanedWorkspaceRoots.has(root)) {" ) new = ( "\t\tfor (const placement of placements.list()) try {\n" "\t\t\tif (placement.state === \"local\" && !placement.environmentId) continue; /* RMP_SKIP_LOCAL_PLACEMENT_CLEANUP */\n" "\t\t\tconst root = await deps.resolveWorkspacePath(placement);\n" "\t\t\tif (!cleanedWorkspaceRoots.has(root)) {" ) if text.count(old) != 1: print("skip local-placement-cleanup (dist shape changed)") raise SystemExit(0) path.write_text(text.replace(old, new, 1)) print("patched-local-placement-cleanup") PY if grep -q 'RMP_SKIP_LOCAL_PLACEMENT_CLEANUP' "$f" 2>/dev/null; then applied="${applied} skip-local-placement-cleanup" fi fi if [ -n "$applied" ]; then echo " Patched $(basename "$f"):$applied" PATCHED=$((PATCHED + 1)) fi } for f in "${CANDIDATES[@]}"; do [ -f "$f" ] || continue restore_model_fallbacks "$f" patch_file "$f" done echo "" echo "Done. Patched/restored $PATCHED files." echo "Note: model fallbacks left ENABLED (gpt-6-luna → gpt-oss-20b on NVIDIA)." # Every patch in place, and none of the code they rewrite left in any copy of it. if ! python3 "$(dirname "${BASH_SOURCE[0]}")/ops/openclaw_patch_audit.py" "$DIST_DIR"; then exit 1 fi if [ "$PATCHED" -eq 0 ]; then echo "Already patched (idempotent re-run)." fi