patch_openclaw.sh
Rama main · réplica de lectura del repositorio original.
#!/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