Ctrl K
ComunidadGuías…

patch_openclaw.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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