Initial commit: OpenClaw ops workspace

This commit is contained in:
2026-03-28 00:15:47 +00:00
commit f1aeaeefb5
42 changed files with 4297 additions and 0 deletions

125
scripts/backup-openclaw-state.sh Executable file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env bash
set -euo pipefail
# Backs up key OpenClaw state to a versioned ZIP archive.
# Includes:
# - ~/.openclaw/openclaw.json
# - ~/.openclaw/cron/
# - ~/.openclaw/credentials/
# - ~/.openclaw/delivery-queue/ (if present)
#
# Usage:
# scripts/backup-openclaw-state.sh [output_dir]
# Example:
# scripts/backup-openclaw-state.sh /home/node/.openclaw/backups
OPENCLAW_HOME="${OPENCLAW_HOME:-$HOME/.openclaw}"
OUTPUT_DIR="${1:-$OPENCLAW_HOME/backups}"
TS="$(date -u +"%Y%m%d-%H%M%SZ")"
mkdir -p "$OUTPUT_DIR"
get_version() {
local raw ver
raw="$(openclaw --version 2>/dev/null || true)"
if [[ -n "$raw" ]]; then
ver="$(printf '%s' "$raw" | head -n1 | sed -E 's/[^0-9A-Za-z._-]+/-/g; s/^-+|-+$//g')"
if [[ -n "$ver" ]]; then
printf '%s' "$ver"
return 0
fi
fi
raw="$(openclaw status 2>/dev/null | grep -E 'Channel|Update' | head -n1 || true)"
ver="$(printf '%s' "$raw" | sed -E 's/[^0-9A-Za-z._-]+/-/g; s/^-+|-+$//g')"
if [[ -n "$ver" ]]; then
printf '%s' "$ver"
else
printf 'unknown'
fi
}
VERSION="$(get_version)"
ARCHIVE_NAME="openclaw-backup-${TS}-v${VERSION}.zip"
ARCHIVE_PATH="$OUTPUT_DIR/$ARCHIVE_NAME"
# Build include list (required + optional)
INCLUDE_PATHS=()
if [[ -f "$OPENCLAW_HOME/openclaw.json" ]]; then
INCLUDE_PATHS+=("$OPENCLAW_HOME/openclaw.json")
else
echo "WARN: Missing $OPENCLAW_HOME/openclaw.json" >&2
fi
if [[ -d "$OPENCLAW_HOME/cron" ]]; then
INCLUDE_PATHS+=("$OPENCLAW_HOME/cron")
else
echo "WARN: Missing $OPENCLAW_HOME/cron" >&2
fi
if [[ -d "$OPENCLAW_HOME/credentials" ]]; then
INCLUDE_PATHS+=("$OPENCLAW_HOME/credentials")
else
echo "WARN: Missing $OPENCLAW_HOME/credentials" >&2
fi
if [[ -d "$OPENCLAW_HOME/delivery-queue" ]]; then
INCLUDE_PATHS+=("$OPENCLAW_HOME/delivery-queue")
fi
if [[ ${#INCLUDE_PATHS[@]} -eq 0 ]]; then
echo "ERROR: Nothing to back up." >&2
exit 1
fi
# Create a temp metadata file and include it in the archive.
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
META_FILE="$TMP_DIR/backup-metadata.txt"
{
echo "timestamp_utc=$TS"
echo "openclaw_version=$VERSION"
echo "openclaw_home=$OPENCLAW_HOME"
echo "archive_name=$ARCHIVE_NAME"
echo "included_paths="
for p in "${INCLUDE_PATHS[@]}"; do
echo " - $p"
done
} > "$META_FILE"
# Create zip via Python for consistent behavior.
python3 - "$ARCHIVE_PATH" "$META_FILE" "${INCLUDE_PATHS[@]}" <<'PY'
import os
import sys
import zipfile
from pathlib import Path
archive = Path(sys.argv[1])
meta_file = Path(sys.argv[2])
items = [Path(p) for p in sys.argv[3:]]
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as zf:
# Put metadata at archive root
zf.write(meta_file, arcname="backup-metadata.txt")
for item in items:
if not item.exists():
continue
if item.is_file():
zf.write(item, arcname=str(item).lstrip("/"))
else:
for root, dirs, files in os.walk(item):
root_path = Path(root)
# preserve empty dirs
if not files and not dirs:
zi = zipfile.ZipInfo(str(root_path).lstrip("/") + "/")
zf.writestr(zi, "")
for f in files:
fp = root_path / f
zf.write(fp, arcname=str(fp).lstrip("/"))
PY
echo "Backup created: $ARCHIVE_PATH"

165
scripts/email-review-run.sh Executable file
View File

@@ -0,0 +1,165 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="/home/node/.openclaw/workspace"
STATE_DIR="$ROOT/mail/state"
RUN_DIR="$ROOT/mail/runs"
STATE_FILE="$STATE_DIR/tasks.json"
ACTION_FILE="$STATE_DIR/action-items.json"
MAX_ITEMS="${EMAIL_REVIEW_MAX_ITEMS:-10}"
WEBHOOK_URL="${N8N_EMAIL_WEBHOOK_URL:-}"
NOTIFY="${EMAIL_REVIEW_NOTIFY:-1}"
mkdir -p "$STATE_DIR" "$RUN_DIR"
if [[ -z "$WEBHOOK_URL" ]]; then
echo "ERROR: N8N_EMAIL_WEBHOOK_URL is required" >&2
exit 2
fi
if [[ ! -f "$STATE_FILE" ]]; then
cat > "$STATE_FILE" <<'JSON'
{
"schemaVersion": 1,
"updatedAt": null,
"items": []
}
JSON
fi
if [[ ! -f "$ACTION_FILE" ]]; then
cat > "$ACTION_FILE" <<'JSON'
{
"schemaVersion": 1,
"updatedAt": null,
"items": []
}
JSON
fi
RUN_TS="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_FILE="$RUN_DIR/run-$RUN_TS.json"
TMP_RESP="$(mktemp)"
TMP_ITEMS="$(mktemp)"
trap 'rm -f "$TMP_RESP" "$TMP_ITEMS"' EXIT
curl -sS -X POST "$WEBHOOK_URL" \
-H 'content-type: application/json' \
-d '{}' > "$TMP_RESP"
jq '{fetchedAt: now|todate, response: .}' "$TMP_RESP" > "$RUN_FILE"
if ! jq -e '.emails? // [] | type == "array"' "$TMP_RESP" >/dev/null 2>&1; then
echo "ERROR: n8n response missing emails[] array" >&2
exit 3
fi
jq -c '.emails // [] | .[]' "$TMP_RESP" | head -n "$MAX_ITEMS" > "$TMP_ITEMS" || true
NEW_COUNT=0
UPDATED_COUNT=0
TOTAL=0
while IFS= read -r email_json; do
[[ -z "$email_json" ]] && continue
TOTAL=$((TOTAL+1))
MESSAGE_ID="$(jq -r '.messageId // empty' <<<"$email_json")"
SUBJECT="$(jq -r '.subject // ""' <<<"$email_json")"
FROM_ADDR="$(jq -r '.from.value[0].address // .from.text // ""' <<<"$email_json")"
RECEIVED_AT="$(jq -r '.date // empty' <<<"$email_json")"
BODY="$(jq -r '(.textPlain // .snippet // .textHtml // "") | tostring' <<<"$email_json" | sed 's/\s\+/ /g' | cut -c1-8000)"
[[ -z "$MESSAGE_ID" ]] && continue
TRIAGE_INPUT="$(jq -cn \
--arg messageId "$MESSAGE_ID" \
--arg subject "$SUBJECT" \
--arg from "$FROM_ADDR" \
--arg receivedAt "$RECEIVED_AT" \
--arg body "$BODY" \
'{type:"email_review", messageId:$messageId, subject:$subject, from:$from, receivedAt:$receivedAt, body:$body}')"
TRIAGE_RAW="$(openclaw agent --agent mail-triage --message "$TRIAGE_INPUT" --json 2>/dev/null || true)"
TRIAGE_TEXT="$(jq -r '.result.payloads[0].text // empty' <<<"$TRIAGE_RAW" 2>/dev/null || true)"
if [[ -z "$TRIAGE_TEXT" ]]; then
continue
fi
TRIAGE_JSON="$(jq -c . <<<"$TRIAGE_TEXT" 2>/dev/null || true)"
if [[ -z "$TRIAGE_JSON" ]]; then
continue
fi
ITEM_KEY="$MESSAGE_ID"
EXISTING="$(jq -c --arg k "$ITEM_KEY" '.items[]? | select(.messageId==$k)' "$STATE_FILE")"
RECORD="$(jq -cn \
--arg messageId "$MESSAGE_ID" \
--arg updatedAt "$(date -u +%FT%TZ)" \
--argjson source "$email_json" \
--argjson triage "$TRIAGE_JSON" \
'{messageId:$messageId, updatedAt:$updatedAt, source:$source, triage:$triage}')"
if [[ -z "$EXISTING" ]]; then
jq --argjson rec "$RECORD" --arg ts "$(date -u +%FT%TZ)" '.items += [$rec] | .updatedAt=$ts' "$STATE_FILE" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
NEW_COUNT=$((NEW_COUNT+1))
SUMMARY="$(jq -r '.summary // "(no summary)"' <<<"$TRIAGE_JSON")"
echo "NEW ACTIONABLE EMAIL: $SUBJECT"
echo "- from: $FROM_ADDR"
echo "- summary: $SUMMARY"
else
OLD_HASH="$(jq -Sc '.triage' <<<"$EXISTING" | sha256sum | awk '{print $1}')"
NEW_HASH="$(jq -Sc . <<<"$TRIAGE_JSON" | sha256sum | awk '{print $1}')"
if [[ "$OLD_HASH" != "$NEW_HASH" ]]; then
jq --arg k "$ITEM_KEY" --argjson rec "$RECORD" --arg ts "$(date -u +%FT%TZ)" '
.items = (.items | map(if .messageId==$k then $rec else . end))
| .updatedAt=$ts
' "$STATE_FILE" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
UPDATED_COUNT=$((UPDATED_COUNT+1))
echo "UPDATED EMAIL TRIAGE: $SUBJECT"
fi
fi
done < "$TMP_ITEMS"
# Build normalized action list for downstream reminder/overview use.
jq --arg ts "$(date -u +%FT%TZ)" '
. as $root
| {
schemaVersion: 1,
updatedAt: $ts,
items: [
$root.items[]?
| {
messageId,
emailSubject: (.source.subject // ""),
from: (.source.from.value[0].address // .source.from.text // ""),
receivedAt: (.source.date // null),
priority: (.triage.priority // "unknown"),
urgency: (.triage.urgency // "unknown"),
no_action_needed: (.triage.no_action_needed // false),
summary: (.triage.summary // ""),
actions: (
[(.triage.actions // [])[]? | {
action: (.action // ""),
owner: (.owner // "Ben"),
deadline: (.deadline // null),
estimated: (.estimated // false),
evidence: (.evidence // "")
}]
)
}
]
}
' "$STATE_FILE" > "$ACTION_FILE"
SUMMARY_LINE="email-review-run complete: total=$TOTAL new=$NEW_COUNT updated=$UPDATED_COUNT"
echo "$SUMMARY_LINE"
if [[ "$NOTIFY" == "1" && $((NEW_COUNT + UPDATED_COUNT)) -gt 0 ]]; then
openclaw system event --text "Email review: $NEW_COUNT new, $UPDATED_COUNT updated actionable item(s)." --mode now >/dev/null 2>&1 || true
fi

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env bash
set -euo pipefail
# Generates a sanitized JSON snapshot of an OpenClaw container deployment.
# Safe-by-default: excludes environment values, secrets, mounts with secret-looking
# paths, and full labels/annotations. Intended for sharing with the agent.
#
# Usage:
# ./export-openclaw-host-facts.sh <container_name_or_id> [output.json]
# Example:
# ./export-openclaw-host-facts.sh openclaw ./openclaw-host-facts.json
CONTAINER="${1:-}"
OUT="${2:-./openclaw-host-facts.json}"
if [[ -z "$CONTAINER" ]]; then
echo "Usage: $0 <container_name_or_id> [output.json]" >&2
exit 1
fi
if ! command -v docker >/dev/null 2>&1; then
echo "docker not found" >&2
exit 1
fi
TMP=$(mktemp)
trap 'rm -f "$TMP"' EXIT
docker inspect "$CONTAINER" > "$TMP"
node - <<'NODE' "$TMP" "$OUT"
const fs = require('fs');
const inFile = process.argv[2];
const outFile = process.argv[3];
const data = JSON.parse(fs.readFileSync(inFile, 'utf8'));
if (!Array.isArray(data) || data.length === 0) {
throw new Error('No inspect data found');
}
const c = data[0];
const secretish = /(secret|token|key|passwd|password|cookie|session|credential|auth|oauth|api[-_]?key|webhook)/i;
const sensitivePath = /(secret|secrets|private|\.ssh|gnupg|aws|gcloud|kube|\.env|credentials?)/i;
function uniq(arr) {
return [...new Set(arr.filter(Boolean))];
}
function safeMounts(mounts) {
return (mounts || [])
.filter(m => !(sensitivePath.test(m.Source || '') || sensitivePath.test(m.Destination || '')))
.map(m => ({
type: m.Type,
source: m.Source,
destination: m.Destination,
mode: m.Mode || '',
rw: !!m.RW,
}));
}
function safeEnv(env) {
const out = {};
for (const entry of env || []) {
const idx = entry.indexOf('=');
const key = idx === -1 ? entry : entry.slice(0, idx);
const value = idx === -1 ? '' : entry.slice(idx + 1);
if (secretish.test(key)) continue;
// Keep only clearly non-sensitive runtime facts.
if (/^(NODE_ENV|TZ|HOSTNAME|OPENCLAW_VARIANT|OPENCLAW_INSTALL_BROWSER|OPENCLAW_INSTALL_DOCKER_CLI)$/i.test(key)) {
out[key] = value;
} else {
out[key] = '<set>';
}
}
return out;
}
const networkSettings = c.NetworkSettings || {};
const hostConfig = c.HostConfig || {};
const config = c.Config || {};
const ports = [];
for (const [containerPort, bindings] of Object.entries(networkSettings.Ports || {})) {
if (!bindings) {
ports.push({ container: containerPort, published: null });
continue;
}
for (const b of bindings) {
ports.push({
container: containerPort,
hostIp: b.HostIp,
hostPort: b.HostPort,
});
}
}
const summary = {
generatedAt: new Date().toISOString(),
source: 'docker inspect (sanitized)',
deploymentHint: 'komodo-or-docker',
container: {
name: (c.Name || '').replace(/^\//, ''),
idShort: (c.Id || '').slice(0, 12),
image: config.Image || null,
entrypoint: config.Entrypoint || null,
cmd: config.Cmd || null,
user: config.User || null,
workingDir: config.WorkingDir || null,
},
runtime: {
running: c.State?.Running || false,
status: c.State?.Status || null,
startedAt: c.State?.StartedAt || null,
restartPolicy: hostConfig.RestartPolicy?.Name || null,
privileged: !!hostConfig.Privileged,
networkMode: hostConfig.NetworkMode || null,
pidMode: hostConfig.PidMode || null,
ipcMode: hostConfig.IpcMode || null,
readOnlyRootfs: !!hostConfig.ReadonlyRootfs,
},
ports,
mounts: safeMounts(c.Mounts),
networks: Object.keys(networkSettings.Networks || {}),
aliases: uniq(Object.values(networkSettings.Networks || {}).flatMap(n => n.Aliases || [])),
env: safeEnv(config.Env),
labels: Object.fromEntries(
Object.entries(config.Labels || {}).filter(([k]) => {
const lk = String(k).toLowerCase();
return lk.startsWith('com.komodo.') || lk.startsWith('komodo.') || lk.startsWith('com.docker.compose.');
})
),
};
fs.writeFileSync(outFile, JSON.stringify(summary, null, 2));
console.log(`Wrote sanitized facts to ${outFile}`);
NODE

View File

@@ -0,0 +1,244 @@
#!/usr/bin/env bash
set -euo pipefail
# v2: Export sanitized OpenClaw runtime facts from Docker + optional Komodo API.
# Safe by default:
# - no secret env var values
# - no obvious secret-ish mount paths
# - no raw Komodo payload dump
# - only selected/sanitized metadata is emitted
#
# Usage:
# ./export-openclaw-runtime-facts.sh <container_name_or_id> [output.json]
#
# Optional env:
# KOMODO_URL=https://komodo.example/api
# KOMODO_TOKEN=read_only_token
# KOMODO_STACK_NAME=openclaw
# KOMODO_RESOURCE_ID=optional-id
# KOMODO_VERIFY_TLS=true|false (default: true)
#
# Example:
# KOMODO_URL=https://komodo.example/api \
# KOMODO_TOKEN=... \
# KOMODO_STACK_NAME=openclaw \
# ./export-openclaw-runtime-facts.sh openclaw ./openclaw-runtime-facts.json
CONTAINER="${1:-}"
OUT="${2:-./openclaw-runtime-facts.json}"
if [[ -z "$CONTAINER" ]]; then
echo "Usage: $0 <container_name_or_id> [output.json]" >&2
exit 1
fi
if ! command -v docker >/dev/null 2>&1; then
echo "docker not found" >&2
exit 1
fi
if ! command -v node >/dev/null 2>&1; then
echo "node not found" >&2
exit 1
fi
DOCKER_JSON=$(mktemp)
KOMODO_JSON=$(mktemp)
trap 'rm -f "$DOCKER_JSON" "$KOMODO_JSON"' EXIT
docker inspect "$CONTAINER" > "$DOCKER_JSON"
KOMODO_STATUS="not_configured"
if [[ -n "${KOMODO_URL:-}" && -n "${KOMODO_TOKEN:-}" ]]; then
CURL_OPTS=(-sS -H "Authorization: Bearer ${KOMODO_TOKEN}" -H "Accept: application/json")
if [[ "${KOMODO_VERIFY_TLS:-true}" == "false" ]]; then
CURL_OPTS+=(-k)
fi
# Try a few likely read-only endpoints/patterns without assuming one exact API shape.
# The Node merger below is tolerant of missing/unexpected payloads.
{
echo '{"attempts":['
first=1
for path in \
"/api/stacks" \
"/api/stack" \
"/api/deployments" \
"/api/deployment" \
"/api/resources"; do
if resp=$(curl "${CURL_OPTS[@]}" "${KOMODO_URL%/}${path}" 2>/dev/null); then
[[ $first -eq 0 ]] && echo ','
first=0
printf '{"path":%s,"body":%s}' "$(node -p 'JSON.stringify(process.argv[1])' "$path")" "$(printf '%s' "$resp" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.stringify(s)))')"
fi
done
echo ']}'
} > "$KOMODO_JSON" || true
if [[ -s "$KOMODO_JSON" ]]; then
KOMODO_STATUS="queried"
else
KOMODO_STATUS="query_failed"
fi
else
echo '{"attempts":[]}' > "$KOMODO_JSON"
fi
node - <<'NODE' "$DOCKER_JSON" "$KOMODO_JSON" "$OUT" "$KOMODO_STATUS" "${KOMODO_STACK_NAME:-}" "${KOMODO_RESOURCE_ID:-}"
const fs = require('fs');
const dockerFile = process.argv[2];
const komodoFile = process.argv[3];
const outFile = process.argv[4];
const komodoStatus = process.argv[5];
const hintedStackName = process.argv[6] || null;
const hintedResourceId = process.argv[7] || null;
const dockerData = JSON.parse(fs.readFileSync(dockerFile, 'utf8'));
if (!Array.isArray(dockerData) || dockerData.length === 0) throw new Error('No docker inspect data');
const c = dockerData[0];
const komodoRaw = JSON.parse(fs.readFileSync(komodoFile, 'utf8'));
const secretish = /(secret|token|key|passwd|password|cookie|session|credential|auth|oauth|api[-_]?key|webhook)/i;
const sensitivePath = /(secret|secrets|private|\.ssh|gnupg|aws|gcloud|kube|\.env|credentials?)/i;
function uniq(arr) { return [...new Set((arr || []).filter(Boolean))]; }
function short(v, n=12) { return typeof v === 'string' ? v.slice(0, n) : v; }
function isObj(v) { return v && typeof v === 'object' && !Array.isArray(v); }
function safeMounts(mounts) {
return (mounts || [])
.filter(m => !(sensitivePath.test(m.Source || '') || sensitivePath.test(m.Destination || '')))
.map(m => ({
type: m.Type,
source: m.Source,
destination: m.Destination,
mode: m.Mode || '',
rw: !!m.RW,
}));
}
function safeEnv(env) {
const out = {};
for (const entry of env || []) {
const idx = entry.indexOf('=');
const key = idx === -1 ? entry : entry.slice(0, idx);
const value = idx === -1 ? '' : entry.slice(idx + 1);
if (secretish.test(key)) continue;
if (/^(NODE_ENV|TZ|HOSTNAME|OPENCLAW_VARIANT|OPENCLAW_INSTALL_BROWSER|OPENCLAW_INSTALL_DOCKER_CLI)$/i.test(key)) {
out[key] = value;
} else {
out[key] = '<set>';
}
}
return out;
}
function safeLabels(labels) {
return Object.fromEntries(
Object.entries(labels || {}).filter(([k, v]) => {
const lk = String(k).toLowerCase();
if (secretish.test(lk)) return false;
return lk.startsWith('com.komodo.') || lk.startsWith('komodo.') || lk.startsWith('com.docker.compose.');
}).map(([k, v]) => [k, String(v)])
);
}
function parseIfJson(s) {
try { return JSON.parse(s); } catch { return s; }
}
function walkForCandidates(node, acc = []) {
if (Array.isArray(node)) {
for (const item of node) walkForCandidates(item, acc);
return acc;
}
if (!isObj(node)) return acc;
const lowerKeys = Object.keys(node).map(k => k.toLowerCase());
const joined = lowerKeys.join(' ');
if (/(stack|deployment|service|container|compose|docker)/.test(joined)) acc.push(node);
for (const v of Object.values(node)) walkForCandidates(v, acc);
return acc;
}
function summarizeKomodo(raw) {
const attempts = raw.attempts || [];
const parsedBodies = attempts.map(a => ({ path: a.path, body: parseIfJson(a.body) }));
const candidates = parsedBodies.flatMap(a => walkForCandidates(a.body, []));
const textBlob = JSON.stringify(parsedBodies).toLowerCase();
const guessed = {
stackName: hintedStackName,
resourceId: hintedResourceId,
sourcePaths: attempts.map(a => a.path),
apiReachable: attempts.length > 0,
hints: {
mentionsOpenclaw: /openclaw/.test(textBlob),
mentionsKomodo: /komodo/.test(textBlob),
mentionsCompose: /compose/.test(textBlob),
},
matchedObjects: candidates.slice(0, 10).map(obj => {
const entries = Object.entries(obj)
.filter(([k, v]) => !secretish.test(k) && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'))
.slice(0, 20);
return Object.fromEntries(entries);
}),
};
return guessed;
}
const networkSettings = c.NetworkSettings || {};
const hostConfig = c.HostConfig || {};
const config = c.Config || {};
const ports = [];
for (const [containerPort, bindings] of Object.entries(networkSettings.Ports || {})) {
if (!bindings) {
ports.push({ container: containerPort, published: null });
continue;
}
for (const b of bindings) {
ports.push({ container: containerPort, hostIp: b.HostIp, hostPort: b.HostPort });
}
}
const summary = {
generatedAt: new Date().toISOString(),
source: 'docker inspect + optional Komodo API (sanitized)',
container: {
name: (c.Name || '').replace(/^\//, ''),
idShort: short(c.Id, 12),
image: config.Image || null,
entrypoint: config.Entrypoint || null,
cmd: config.Cmd || null,
user: config.User || null,
workingDir: config.WorkingDir || null,
},
runtime: {
running: c.State?.Running || false,
status: c.State?.Status || null,
startedAt: c.State?.StartedAt || null,
restartPolicy: hostConfig.RestartPolicy?.Name || null,
privileged: !!hostConfig.Privileged,
networkMode: hostConfig.NetworkMode || null,
pidMode: hostConfig.PidMode || null,
ipcMode: hostConfig.IpcMode || null,
readOnlyRootfs: !!hostConfig.ReadonlyRootfs,
},
ports,
mounts: safeMounts(c.Mounts),
networks: Object.keys(networkSettings.Networks || {}),
aliases: uniq(Object.values(networkSettings.Networks || {}).flatMap(n => n.Aliases || [])),
env: safeEnv(config.Env),
labels: safeLabels(config.Labels),
komodo: {
status: komodoStatus,
...summarizeKomodo(komodoRaw),
},
};
fs.writeFileSync(outFile, JSON.stringify(summary, null, 2));
console.log(`Wrote sanitized runtime facts to ${outFile}`);
NODE

100
scripts/gmail-unread-poll.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
STATE_DIR="${STATE_DIR:-/home/node/.openclaw/workspace/state}"
STATE_FILE="${STATE_FILE:-$STATE_DIR/gmail-unread-state.json}"
ACCOUNT="${ACCOUNT:-claw@ben.io}"
MAX_RESULTS="${MAX_RESULTS:-10}"
OPENCLAW_BIN="${OPENCLAW_BIN:-openclaw}"
CHANNEL="${CHANNEL:-discord}"
DEST="${DEST:-channel:1467247377743347953}"
ACCOUNT_ID="${ACCOUNT_ID:-default}"
mkdir -p "$STATE_DIR"
TMP_LIST=$(mktemp)
trap 'rm -f "$TMP_LIST"' EXIT
if ! gws gmail users messages list --params "{\"userId\":\"me\",\"maxResults\":$MAX_RESULTS,\"q\":\"is:unread\"}" --format json > "$TMP_LIST" 2>/dev/null; then
echo "gmail-unread-poll: failed to query Gmail unread messages" >&2
exit 1
fi
CURRENT_IDS=$(node -e '
const fs = require("fs");
const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const ids = (data.messages || []).map(m => m.id).sort();
process.stdout.write(JSON.stringify(ids));
' "$TMP_LIST")
if [[ -f "$STATE_FILE" ]]; then
PREV_IDS=$(node -e '
const fs = require("fs");
const p = process.argv[1];
const data = JSON.parse(fs.readFileSync(p, "utf8"));
process.stdout.write(JSON.stringify((data.unreadIds || []).sort()));
' "$STATE_FILE")
else
PREV_IDS='[]'
fi
if [[ "$CURRENT_IDS" == "$PREV_IDS" ]]; then
exit 0
fi
NEW_IDS=$(node -e '
const prev = new Set(JSON.parse(process.argv[1]));
const curr = JSON.parse(process.argv[2]);
process.stdout.write(JSON.stringify(curr.filter(id => !prev.has(id))));
' "$PREV_IDS" "$CURRENT_IDS")
NEW_COUNT=$(node -e 'const a = JSON.parse(process.argv[1]); process.stdout.write(String(a.length));' "$NEW_IDS")
TOTAL_UNREAD=$(node -e 'const a = JSON.parse(process.argv[1]); process.stdout.write(String(a.length));' "$CURRENT_IDS")
if [[ "$NEW_COUNT" -gt 0 ]]; then
SUMMARY=$(node - <<'NODE' "$NEW_IDS"
const ids = JSON.parse(process.argv[2]);
console.log(`claw@ben.io: ${ids.length} new unread mail(s)`);
NODE
)
DETAILS=()
while IFS= read -r id; do
[[ -z "$id" ]] && continue
JSON=$(gws gmail +read --message-id "$id" --format json 2>/dev/null || true)
if [[ -n "$JSON" ]]; then
LINE=$(node -e '
const fs = require("fs");
const data = JSON.parse(fs.readFileSync(0, "utf8"));
const from = data.from?.email || data.from?.name || "unknown";
const subject = data.subject || "(no subject)";
process.stdout.write(`- ${from} — ${subject}`);
' <<<"$JSON")
DETAILS+=("$LINE")
fi
done < <(node -e 'for (const id of JSON.parse(process.argv[1])) console.log(id)' "$NEW_IDS")
MSG="$SUMMARY
Total unread: $TOTAL_UNREAD"
if [[ ${#DETAILS[@]} -gt 0 ]]; then
MSG+="
$(printf '%s
' "${DETAILS[@]}")"
fi
"$OPENCLAW_BIN" message send \
--account "$ACCOUNT_ID" \
--channel "$CHANNEL" \
--target "$DEST" \
--message "$MSG" >/dev/null 2>&1 || {
echo "gmail-unread-poll: failed to send notification" >&2
exit 1
}
fi
node - <<'NODE' "$STATE_FILE" "$CURRENT_IDS"
const fs = require('fs');
const path = process.argv[2];
const unreadIds = JSON.parse(process.argv[3]);
fs.writeFileSync(path, JSON.stringify({ unreadIds, updatedAt: new Date().toISOString() }, null, 2));
NODE

48
scripts/google-drift-audit.py Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
import json
from datetime import datetime, timezone
from pathlib import Path
STATE_PATH = Path('/home/node/.openclaw/workspace/state/projects.json')
def parse_ts(ts: str | None):
if not ts:
return None
if ts.endswith('Z'):
ts = ts[:-1] + '+00:00'
return datetime.fromisoformat(ts)
def main():
state = json.loads(STATE_PATH.read_text())
now = datetime.now(timezone.utc)
stale = []
missing_next_action = []
missing_tasks = []
for project in state.get('projects', []):
if project.get('status') != 'open':
continue
if not project.get('next_action'):
missing_next_action.append(project['title'])
if not project.get('task_ids'):
missing_tasks.append(project['title'])
last = parse_ts(project.get('last_activity_at'))
if last and (now - last).days >= 14:
stale.append({
'title': project['title'],
'days_since_update': (now - last).days,
'last_activity_at': project.get('last_activity_at')
})
print(json.dumps({
'generated_at': now.isoformat(),
'stale_projects': stale,
'missing_next_action': missing_next_action,
'missing_tasks': missing_tasks
}, indent=2))
if __name__ == '__main__':
main()

166
scripts/google-sync.py Executable file
View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
import json
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any
WORKSPACE = Path('/home/node/.openclaw/workspace')
STATE_PATH = WORKSPACE / 'state' / 'projects.json'
REMINDER_LIST = Path('/home/node/.openclaw/skills/reminder/scripts/list.sh')
@dataclass
class Reminder:
when_local: str
message: str
reminder_id: str
def run(cmd: list[str]) -> str:
res = subprocess.run(cmd, text=True, capture_output=True)
if res.returncode != 0:
raise RuntimeError((res.stderr or res.stdout).strip())
return res.stdout
def load_state() -> dict[str, Any]:
return json.loads(STATE_PATH.read_text())
def save_state(state: dict[str, Any]) -> None:
STATE_PATH.write_text(json.dumps(state, indent=2) + "\n")
def slug(text: str) -> str:
return re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-')
def parse_reminders() -> list[Reminder]:
out = run(['bash', str(REMINDER_LIST)])
reminders: list[Reminder] = []
current_when = None
current_msg = None
current_id = None
for line in out.splitlines():
if line.startswith(''):
current_when = line.replace('', '', 1).strip()
current_msg = None
current_id = None
elif line.strip().startswith('ID:'):
current_id = line.split('ID:', 1)[1].strip()
if current_when and current_msg and current_id:
reminders.append(Reminder(when_local=current_when, message=current_msg, reminder_id=current_id))
current_when = None
current_msg = None
current_id = None
elif current_when and line.startswith(' ') and current_msg is None and line.strip() and not line.strip().startswith('ID:'):
current_msg = line.strip()
return reminders
def ensure_followup_task(state: dict[str, Any], title: str, notes: str = '') -> str:
tasklist = state['google']['tasklists']['claw_follow_ups']['id']
out = run([
'gws', 'tasks', 'tasks', 'insert',
'--params', json.dumps({'tasklist': tasklist}),
'--json', json.dumps({'title': title, 'notes': notes})
])
obj = json.loads(out)
return obj['id']
def ensure_calendar_event(state: dict[str, Any], summary: str, start_utc: str, end_utc: str, description: str = '') -> str:
cal_id = state['google']['calendar']['claw_ops']['id']
existing_raw = run([
'gws', 'calendar', 'events', 'list',
'--params', json.dumps({'calendarId': cal_id})
])
existing = json.loads(existing_raw)
for item in existing.get('items', []):
if item.get('summary') == summary and item.get('start', {}).get('dateTime') == start_utc:
return item['id']
out = run([
'gws', 'calendar', 'events', 'insert',
'--params', json.dumps({'calendarId': cal_id}),
'--json', json.dumps({
'summary': summary,
'description': description,
'start': {'dateTime': start_utc, 'timeZone': 'UTC'},
'end': {'dateTime': end_utc, 'timeZone': 'UTC'}
})
])
obj = json.loads(out)
return obj['id']
def sync_projects(state: dict[str, Any]) -> dict[str, Any]:
project_list_id = state['google']['tasklists']['claw_projects']['id']
created = []
for project in state['projects']:
ids = set(project.get('task_ids', []))
if not ids:
title = f"[Project] {project['title']}"
notes = f"Project ID: {project['id']}\nStatus: {project['status']}\nNext action: {project.get('next_action', '')}\nNotes ref: {project.get('notes_ref', '')}"
out = run([
'gws', 'tasks', 'tasks', 'insert',
'--params', json.dumps({'tasklist': project_list_id}),
'--json', json.dumps({'title': title, 'notes': notes})
])
obj = json.loads(out)
project.setdefault('task_ids', []).append(obj['id'])
created.append({'type': 'project-task', 'project_id': project['id'], 'task_id': obj['id']})
if project.get('next_action') and len(project.get('task_ids', [])) < 2:
title = f"[{project['title']}] {project['next_action']}"
notes = f"Project ID: {project['id']}\nSource: {project.get('notes_ref', '')}"
out = run([
'gws', 'tasks', 'tasks', 'insert',
'--params', json.dumps({'tasklist': project_list_id}),
'--json', json.dumps({'title': title, 'notes': notes})
])
obj = json.loads(out)
project.setdefault('task_ids', []).append(obj['id'])
created.append({'type': 'next-action-task', 'project_id': project['id'], 'task_id': obj['id']})
return {'created': created}
def sync_reminders(state: dict[str, Any]) -> dict[str, Any]:
reminder_state = state.setdefault('reminders', {'synced': {}})
synced = reminder_state.setdefault('synced', {})
created = []
skipped = []
for rem in parse_reminders():
if rem.reminder_id in synced:
continue
msg = rem.message.replace('\\n', '\n')
lower = msg.lower()
if 'vehicle registration renewal' in lower:
# known time conversion from existing reminder local labels
if '09:00 cdt' in rem.when_local.lower():
start, end = '2026-04-15T14:00:00Z', '2026-04-15T14:30:00Z'
elif '16:00 cdt' in rem.when_local.lower():
start, end = '2026-04-15T21:00:00Z', '2026-04-15T21:30:00Z'
else:
skipped.append({'reminder_id': rem.reminder_id, 'reason': 'unsupported-known-reminder-time'})
continue
event_id = ensure_calendar_event(state, 'Vehicle registration renewal - Tesla Model Y (VJF3166)', start, end, msg)
synced[rem.reminder_id] = {'kind': 'calendar', 'event_id': event_id}
created.append({'reminder_id': rem.reminder_id, 'calendar_event_id': event_id})
else:
skipped.append({'reminder_id': rem.reminder_id, 'reason': 'past-or-unscheduled-manual-review'})
return {'created': created, 'skipped': skipped}
def main() -> None:
state = load_state()
result = {
'projects': sync_projects(state),
'reminders': sync_reminders(state),
}
save_state(state)
print(json.dumps(result, indent=2))
if __name__ == '__main__':
main()

208
scripts/resolve-channel-names.sh Executable file
View File

@@ -0,0 +1,208 @@
#!/usr/bin/env bash
set -euo pipefail
REG_JSON="/home/node/.openclaw/memory/channel-registry.json"
REG_MD="/home/node/.openclaw/memory/channel-registry.md"
OVERRIDES_JSON="/home/node/.openclaw/memory/channel-name-overrides.json"
python3 - <<'PY'
import json, re
from pathlib import Path
from datetime import datetime, timezone
reg_path = Path('/home/node/.openclaw/memory/channel-registry.json')
md_path = Path('/home/node/.openclaw/memory/channel-registry.md')
overrides_path = Path('/home/node/.openclaw/memory/channel-name-overrides.json')
reg = json.loads(reg_path.read_text())
entries = reg.get('entries', [])
idx = {(e['platform'], e['kind'], e['id']): e for e in entries}
obs = {}
def note(cid, key, value):
if not cid or not value:
return
obs.setdefault(cid, {})[key] = value
# 1) Explicit overrides win (manual curated)
if overrides_path.exists():
try:
ov = json.loads(overrides_path.read_text())
for cid, data in (ov.get('discord', {}) or {}).items():
if isinstance(data, dict):
for k in ('guild_name','channel_name','thread_name','guild_id'):
if data.get(k):
note(cid, k, data[k])
except Exception:
pass
# Ensure override IDs are represented even if not referenced yet
for cid, data in (ov.get('discord', {}) or {}).items() if 'ov' in locals() else []:
if not isinstance(data, dict):
continue
kind = 'guild' if data.get('guild_name') and not data.get('channel_name') and not data.get('thread_name') else 'channel'
key = ('discord', kind, cid)
if key not in idx:
entries.append({
'platform': 'discord',
'kind': kind,
'id': cid,
'guild_id': data.get('guild_id') or (cid if kind == 'guild' else None),
'guild_name': data.get('guild_name'),
'channel_name': data.get('channel_name'),
'thread_name': data.get('thread_name'),
'agent_owner': None,
'used_by': ['override:manual'],
'purpose': 'manual override registry seed',
'status': 'active' if (data.get('guild_name') or data.get('channel_name') or data.get('thread_name')) else 'unresolved',
'last_verified_utc': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
})
idx[key] = entries[-1]
# 2) Scan transcripts for embedded metadata (bounded to prevent huge-context blowups)
roots = [
Path('/home/node/.openclaw/workspace'),
Path('/home/node/.openclaw/workspace-home'),
Path('/home/node/.openclaw/workspace-security'),
Path('/home/node/.openclaw/workspace-research'),
]
MAX_JSONL_FILES = 400
MAX_FILE_SCAN_BYTES = 1_000_000
MAX_TOTAL_SCAN_BYTES = 25_000_000
pat_discord = re.compile(r'discord:(\d+)#([A-Za-z0-9_-]+)')
pat_conv = re.compile(r'channel id:(\d+)')
pat_group_channel = re.compile(r'"group_channel"\s*:\s*"(#?[^"]+)"')
pat_thread = re.compile(r'"thread_label"\s*:\s*"Discord thread\s+#([^"]+)\s+\s+([^"]+)"')
pat_subject = re.compile(r'"group_subject"\s*:\s*"(#?[^"]+)"')
def bounded_read_jsonl(path: Path, limit_bytes: int) -> str:
size = path.stat().st_size
with path.open('rb') as fh:
if size <= limit_bytes:
data = fh.read(limit_bytes)
else:
head = fh.read(limit_bytes // 2)
fh.seek(max(0, size - (limit_bytes // 2)))
tail = fh.read(limit_bytes // 2)
data = head + b'\n...TRUNCATED...\n' + tail
return data.decode('utf-8', errors='ignore')
jsonl_paths = []
for root in roots:
if root.exists():
jsonl_paths.extend(root.rglob('*.jsonl'))
bytes_scanned = 0
for p in sorted(jsonl_paths)[:MAX_JSONL_FILES]:
if bytes_scanned >= MAX_TOTAL_SCAN_BYTES:
break
budget_left = MAX_TOTAL_SCAN_BYTES - bytes_scanned
per_file_cap = min(MAX_FILE_SCAN_BYTES, budget_left)
if per_file_cap <= 0:
break
try:
txt = bounded_read_jsonl(p, per_file_cap)
except Exception:
continue
bytes_scanned += len(txt.encode('utf-8', errors='ignore'))
# pattern: discord:<id>#name
for m in pat_discord.finditer(txt):
cid, cname = m.group(1), m.group(2)
if not cname.startswith('#'):
cname = '#' + cname
note(cid, 'channel_name', cname)
# conversation metadata blocks
for m in pat_conv.finditer(txt):
cid = m.group(1)
window = txt[max(0, m.start()-1200): m.end()+1200]
gm = pat_group_channel.search(window)
if gm:
cname = gm.group(1)
if cname and not cname.startswith('#'):
cname = '#' + cname
note(cid, 'channel_name', cname)
sm = pat_subject.search(window)
if sm and not obs.get(cid, {}).get('channel_name'):
sname = sm.group(1)
if sname and not sname.startswith('#'):
sname = '#' + sname
note(cid, 'channel_name', sname)
tm = pat_thread.search(window)
if tm:
# forum-ish parent and thread label
forum = tm.group(1).strip()
tname = tm.group(2).strip()
if forum:
if not forum.startswith('#'):
forum = '#' + forum
note(cid, 'channel_name', forum)
note(cid, 'thread_name', tname)
# 3) Apply observations to registry
now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
changed = 0
for e in entries:
if e.get('platform') != 'discord':
continue
cid = e.get('id')
data = obs.get(cid, {})
before = json.dumps(e, sort_keys=True)
for k in ('guild_id','guild_name','channel_name','thread_name'):
if data.get(k) and not e.get(k):
e[k] = data[k]
# status rule
if e.get('kind') == 'guild':
e['status'] = 'active' if e.get('guild_name') else 'unresolved'
else:
e['status'] = 'active' if (e.get('channel_name') or e.get('thread_name')) else 'unresolved'
e['last_verified_utc'] = now
after = json.dumps(e, sort_keys=True)
if before != after:
changed += 1
reg['updated_utc'] = now
reg_path.write_text(json.dumps(reg, indent=2) + '\n')
# 4) Render markdown table from JSON
lines = []
lines.append('# Channel Registry')
lines.append('')
lines.append('Global ID→name registry for cron delivery targets and routing bindings.')
lines.append('')
lines.append('## Resolution Policy')
lines.append('- IDs are canonical; names are metadata and may drift.')
lines.append('- Auto-resolution uses transcript/session metadata + optional overrides file.')
lines.append('- Any referenced entry with `status: unresolved` must be manually resolved.')
lines.append('')
lines.append('## Entries')
lines.append('')
lines.append('| Platform | Kind | ID | Guild ID | Guild Name | Channel Name | Thread Name | Agent Owner | Status | Used By |')
lines.append('|---|---|---|---|---|---|---|---|---|---|')
for e in sorted(entries, key=lambda x: (x['platform'], x['kind'], x['id'])):
lines.append(
f"| {e.get('platform','')} | {e.get('kind','')} | `{e.get('id','')}` | `{e.get('guild_id') or ''}` | {e.get('guild_name') or 'UNRESOLVED'} | {e.get('channel_name') or 'UNRESOLVED'} | {e.get('thread_name') or ''} | {e.get('agent_owner') or ''} | {e.get('status') or ''} | {'; '.join(e.get('used_by',[]))} |"
)
lines.append('')
lines.append('## Unresolved IDs')
for e in entries:
if e.get('status') == 'unresolved':
lines.append(f"- `{e.get('kind')}:{e.get('id')}` (agent `{e.get('agent_owner')}`)")
lines.append('')
lines.append('## Manual Resolution')
lines.append('1. Add/patch explicit values in `/home/node/.openclaw/memory/channel-name-overrides.json`.')
lines.append('2. Re-run `scripts/resolve-channel-names.sh` to merge overrides + observations.')
lines.append('3. Run `scripts/validate-channel-registry.sh` and ensure it returns `OK`.')
md_path.write_text('\n'.join(lines) + '\n')
print(f'Updated registry. Changed entries: {changed}')
PY

28
scripts/safe-jsonl-peek.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <jsonl-file> [max-bytes-per-file]" >&2
exit 1
fi
FILE="$1"
MAX_BYTES="${2:-1048576}"
if [[ ! -f "$FILE" ]]; then
echo "error: file not found: $FILE" >&2
exit 1
fi
SIZE=$(wc -c < "$FILE")
if [[ "$SIZE" -le "$MAX_BYTES" ]]; then
cat "$FILE"
exit 0
fi
HALF=$(( MAX_BYTES / 2 ))
head -c "$HALF" "$FILE"
printf '\n...TRUNCATED...\n'
tail -c "$HALF" "$FILE"

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
REG_JSON="/home/node/.openclaw/memory/channel-registry.json"
CONF_JSON="/home/node/.openclaw/openclaw.json"
CRON_JSON="/home/node/.openclaw/cron/jobs.json"
python3 - <<'PY'
import json, sys
from pathlib import Path
reg = json.loads(Path('/home/node/.openclaw/memory/channel-registry.json').read_text())
conf = json.loads(Path('/home/node/.openclaw/openclaw.json').read_text())
jobs = json.loads(Path('/home/node/.openclaw/cron/jobs.json').read_text())['jobs']
index={(e['platform'],e['kind'],e['id']):e for e in reg.get('entries',[])}
errors=[]
# check bindings
for b in conf.get('bindings',[]):
m=b.get('match',{})
if m.get('channel')!='discord':
continue
gid=m.get('guildId')
if gid:
k=('discord','guild',gid)
if k not in index:
errors.append(f"Missing registry entry for binding guild:{gid}")
peer=m.get('peer') or {}
pid=peer.get('id'); kind=peer.get('kind')
if pid and kind in ('channel','group'):
k=('discord',kind,pid)
if k not in index:
errors.append(f"Missing registry entry for binding {kind}:{pid}")
# check cron delivery targets
for j in jobs:
d=j.get('delivery',{})
to=d.get('to')
if not isinstance(to,str):
continue
cid=None
if to.startswith('channel:'): cid=to.split(':',1)[1]; kind='channel'
elif to.isdigit(): cid=to; kind='channel'
else: continue
k=('discord',kind,cid)
if k not in index:
errors.append(f"Missing registry entry for cron {j['id']} target {kind}:{cid}")
# unresolved check for referenced entries
referenced=set()
for b in conf.get('bindings',[]):
m=b.get('match',{})
if m.get('channel')!='discord': continue
gid=m.get('guildId')
if gid: referenced.add(('discord','guild',gid))
peer=m.get('peer') or {}
pid=peer.get('id'); kind=peer.get('kind')
if pid and kind in ('channel','group'): referenced.add(('discord',kind,pid))
for j in jobs:
d=j.get('delivery',{})
to=d.get('to')
if not isinstance(to,str): continue
if to.startswith('channel:'): referenced.add(('discord','channel',to.split(':',1)[1]))
elif to.isdigit(): referenced.add(('discord','channel',to))
for k in sorted(referenced):
e=index.get(k)
if e and e.get('status')=='unresolved':
errors.append(f"Unresolved referenced ID: {k[1]}:{k[2]}")
if errors:
print('CHANNEL REGISTRY VALIDATION: FAIL')
for err in errors:
print('-', err)
sys.exit(1)
print('CHANNEL REGISTRY VALIDATION: OK')
PY

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
CONF="/home/node/.openclaw/openclaw.json"
CONTRACT="/home/node/.openclaw/workspace/memory/discord-routing-contract.json"
python3 - <<'PY'
import json,sys
from pathlib import Path
conf=json.loads(Path('/home/node/.openclaw/openclaw.json').read_text())
contract=json.loads(Path('/home/node/.openclaw/workspace/memory/discord-routing-contract.json').read_text())
errors=[]
warn=[]
bindings=conf.get('bindings',[])
discord_cfg=((conf.get('channels') or {}).get('discord') or {})
guilds=(discord_cfg.get('guilds') or {})
gid=contract.get('guildId')
guild_cfg=(guilds.get(gid) or {})
guild_channels=(guild_cfg.get('channels') or {})
# index bindings by (agent, kind, id)
def has_binding(agent,kind,cid):
for b in bindings:
if b.get('agentId')!=agent: continue
m=b.get('match') or {}
if m.get('channel')!='discord': continue
p=m.get('peer') or {}
if p.get('kind')==kind and p.get('id')==cid:
return True
return False
# validate channel contracts
for cid,meta in (contract.get('channels') or {}).items():
agent=meta.get('expectedAgent')
kinds=meta.get('peerKinds') or []
for k in kinds:
if not has_binding(agent,k,cid):
errors.append(f"Missing binding: agent={agent} peer={k}:{cid}")
# allowlist presence
if cid not in guild_channels:
errors.append(f"Channel {cid} missing from channels.discord.guilds.{gid}.channels allowlist")
else:
req=(guild_channels.get(cid) or {}).get('requireMention')
exp=meta.get('requireMention')
if exp is not None and req!=exp:
errors.append(f"requireMention mismatch for {cid}: expected {exp}, got {req}")
# validate guild fallback
fb=((contract.get('guildFallback') or {}).get('expectedAgent'))
if fb:
ok=False
for b in bindings:
if b.get('agentId')!=fb: continue
m=b.get('match') or {}
if m.get('channel')=='discord' and m.get('guildId')==gid:
ok=True
break
if not ok:
errors.append(f"Missing guild fallback binding for guild {gid} -> {fb}")
# Note: binding index order does not override match-tier precedence (peer beats guild).
# So we intentionally do not fail on peer-after-guild placement.
if errors:
print('DISCORD ROUTING VERIFICATION: FAIL')
for e in errors:
print('-',e)
sys.exit(1)
print('DISCORD ROUTING VERIFICATION: OK')
PY