#!/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 [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 [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] = ''; } } 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