diff --git a/unsupported/workspace/skills/self-config/SKILL.md b/unsupported/workspace/skills/self-config/SKILL.md new file mode 100644 index 000000000..ae8b31e0d --- /dev/null +++ b/unsupported/workspace/skills/self-config/SKILL.md @@ -0,0 +1,89 @@ +--- +name: self-config +description: Allows the agent to safely update its own configuration files +--- + +# Self-Config Skill Capabilities + +1. Presentation of the `config.json` with secrets redacted. +2. Staging of a `config.new.json` with utilities for patching, sorting, diffing, and validation. +3. Safe service restarts with auto-rollback timers. + +## Tools used +- `scripts/config.sh`: Base utility for presenting redacted JSON files. +- `scripts/config-patch.sh`: Tools for iterative patching of a staged `config.new.json` file. +- `scripts/service.sh`: Manages service restarts with auto-rollback. +- `jq`: Used internally for JSON manipulation. + +## View Current Configuration + +### Redacted (Full) +```bash +scripts/config.sh redacted +``` + +### Summary (Filtered) +Shows only configured models, agents, enabled tools, devices, and heartbeat settings. +```bash +scripts/config.sh summary +``` + +--- + +## Workflow: Safe Configuration Update + +### 1. Start a Session +Initialize the staging environment. This redacts sensitive keys (tokens, passwords) into a separate hidden file so they aren't exposed in plain text during editing. +```bash +scripts/config-patch.sh start +``` + +### 2. Apply Patches +Apply `jq` filters to the **staged** file. You can run this multiple times. +```bash +scripts/config-patch.sh '.path.to.key = "new_value"' +``` + +### 3. Review Changes +Review the diff between the original and the staged (redacted) version. +```bash +scripts/config-patch.sh diff +``` + +### 4. View Staged Config +```bash +scripts/config-patch.sh config +# OR +scripts/config-patch.sh summary +``` + +### 5. Sort Keys (Optional) +Sort the keys in the staged file alphabetically. +```bash +scripts/config-patch.sh sort +``` + +### 6. Reset (Abort) +If you make a mistake *before* switching, clear the staging files. +```bash +scripts/config-patch.sh reset +``` + +### 7. Switch & Test (The "Hot" Update) +Commit the patch, restart the service, and create a timer that will rollback +unless 'confirm' action is performed. Default is 120 seconds. + +```bash +TIMEOUT=300 scripts/service.sh restart-auto-rollback +``` + +### 8. Confirm +If the agent is still working and the changes are correct, confirm the update to remove the rollback marker. +```bash +scripts/service.sh confirm +``` + +## Safety Rules +1. **Always** use `start` before applying patches. +2. **Never** manually edit the `.secrets.json` file. +3. **Always** confirm your changes within the time limit after a `switch`. diff --git a/unsupported/workspace/skills/self-config/scripts/config-patch.sh b/unsupported/workspace/skills/self-config/scripts/config-patch.sh new file mode 100755 index 000000000..c6d4cbaa8 --- /dev/null +++ b/unsupported/workspace/skills/self-config/scripts/config-patch.sh @@ -0,0 +1,162 @@ +#!/bin/bash + +# config-patch: A tool for iterative JSON patching with high-security secret handling. + +# Determine script directory for relocatable operation +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_ROOT="$(dirname "$SCRIPT_DIR")" + +# Source shared config and functions +source "$SCRIPT_DIR/config.sh" + +COMMAND="${1:-help}" + +case "$COMMAND" in + + start|sort) + + [ -f "$SECRETS_FILE" ] && { echo "Error: Session active. Commit or reset first."; exit 1; } + [ ! -f "$PICOCLAW_CONFIG" ] && { echo "Error: $PICOCLAW_CONFIG not found."; exit 1; } + + redact_secrets || exit 1 + + # On success - Move results final locations (trap will clean if script crashes before this) + # Clear _TMP so trap doesn't try to delete final files + + mv "$CONFIG_TMP" "$STAGING_FILE" + CONFIG_TMP="" + mv "$SECRETS_TMP" "$SECRETS_FILE" + SECRETS_TMP="" + + echo "Start: Redacted staging file created. Secrets mapped to $SECRETS_FILE" + + ;;& + + start) + exit 0 + ;; + + sort) + + { rm "$STAGING_FILE" ; jq -S . > "$STAGING_FILE"; } < "$STAGING_FILE" + + echo "Sorted: keys in $STAGING_FILE" + exit 0 + ;; + + commit) + [ ! -f "$STAGING_FILE" ] && { echo "Error: No staged changes."; exit 1; } + [ ! -f "$SECRETS_FILE" ] && { echo "Error: No secrets file found."; exit 1; } + + # 3. Restoration & Validation + FINAL_FILE=$(mktemp) + + # Restore secrets: replace path placeholders with stored values + # Keys in secrets file are the paths (e.g., "SECRET:agents.defaults.model") + jq --slurpfile secrets "$SECRETS_FILE" 'walk(if type == "string" and $secrets[0][.] != null then $secrets[0][.] else . end)' "$STAGING_FILE" > "$FINAL_FILE" + + # Ensure no placeholders survived (means they weren't in the map) + # Check if any string key in secrets map still exists in output + while IFS= read -r key; do + if grep -q "\"$key\"" "$FINAL_FILE"; then + echo "Error: Placeholder '$key' was not restored. Check for typos in secrets map." + exit 1 + fi + done < <(jq -r 'keys[]' "$SECRETS_FILE") + + # 4. JSON Integrity Check + jq . "$FINAL_FILE" > /dev/null 2>&1 || { echo "Error: Invalid JSON output. Aborting."; exit 1; } + + # 5. Backup & Swap (unredacted config) + mkdir -p "$BACKUP_DIR" + cp "$PICOCLAW_CONFIG" "$BACKUP_DIR/${BASE}_${TIMESTAMP}.json" + + mv "$FINAL_FILE" "$PICOCLAW_CONFIG" + rm -f "$STAGING_FILE" "$SECRETS_FILE" + echo "Committed successfully. Backup: $BACKUP_DIR/${BASE}_${TIMESTAMP}.json" + exit 0 + ;; + + status) + echo "Target: $PICOCLAW_CONFIG" + [ -f "$STAGING_FILE" ] && echo "Staging: Active ($STAGING_FILE)" || echo "Staging: None" + [ -f "$SECRETS_FILE" ] && echo "Secrets: $(jq 'keys | length' "$SECRETS_FILE") tracked" || echo "Secrets: None" + ;; + + summary) + # Output a summary of the staged config with only configured models, agents, enabled tools, devices, and heartbeat + + summarize "$STAGING_FILE" + + exit 0 + ;; + + show|redacted|config) + # Output the full staged config + + cat "$STAGING_FILE" + ;; + + diff) + [ ! -f "$STAGING_FILE" ] && { echo "Error: No staged changes to diff."; exit 1; } + + redact_secrets + diff -u "$CONFIG_TMP" "$STAGING_FILE" + exit 0 + ;; + + rollback) + # Restore the most recent backup + LATEST=$(ls -t "$BACKUP_DIR"/${BASE}_*.json 2>/dev/null | head -n 1) + if [ -z "$LATEST" ]; then + echo "Error: No backups found for $PICOCLAW_CONFIG" + exit 1 + fi + + LATEST_BASE=$(basename "$LATEST" .json) + + cp "$LATEST" "$PICOCLAW_CONFIG" + + echo "Rollback: Restored $PICOCLAW_CONFIG from $LATEST" + exit 0 + ;; + + reset) + rm -f "$STAGING_FILE" "$SECRETS_FILE" + echo "Session cleared." + exit 0 + ;; + + help) + echo "Usage: $0 [config_file]" + echo "Commands:" + echo " start - Create staging file with redacted secrets" + echo " sort - Sort JSON keys alphabetically" + echo " diff - Show staged changes" + echo " commit - Apply staged changes to config" + echo " reset - Clear staging (discard changes)" + echo " rollback - Restore from last backup" + echo " status - Show current state" + echo " summary - Show summary of the staged config with unused models/chatconfigs filtered" + echo " config - Show the staged config" + echo " - Apply inline jq patch (e.g. '.agents.model=\"gpt-4\"')" + exit 0 + ;; + + *) + # Generic Patching + redact_secrets + if [ -f "$STAGING_FILE" ]; then + SOURCE="$STAGING_FILE" + else + SOURCE="$PICOCLAW_CONFIG" + fi + TMP=$(mktemp) + if jq "$COMMAND" "$SOURCE" > "$TMP"; then + mv "$TMP" "$STAGING_FILE" + echo "Applied patch to $STAGING_FILE" + else + rm -f "$TMP"; exit 1 + fi + ;; +esac diff --git a/unsupported/workspace/skills/self-config/scripts/config.sh b/unsupported/workspace/skills/self-config/scripts/config.sh new file mode 100755 index 000000000..056d4c86c --- /dev/null +++ b/unsupported/workspace/skills/self-config/scripts/config.sh @@ -0,0 +1,157 @@ +#!/bin/bash + +# config: A tool for presenting config in insecure channels. + +# Input validation functions +assert_is_identifier() { + if ! echo "$1" | grep -qE '^[a-zA-Z_][a-zA-Z0-9_-]*$'; then + echo "Error: $2" >&2 + return 1 + fi +} + +assert_is_file_path() { + if [ -z "$1" ]; then + echo "Error: $2" >&2 + return 1 + fi + local dir + dir=$(dirname "$1") + if [ ! -d "$dir" ] && [ "$dir" != "." ]; then + echo "Error: Directory does not exist: $dir" >&2 + return 1 + fi +} + +assert_is_safe() { + # Only check the command argument, not the entire command line + local cmd="$1" + if echo "$cmd" | grep -qE '[\$\`\\]'; then + echo "Error: $2 - Command contains dangerous characters" >&2 + return 1 + fi + return 0 +} + +COMMAND="${1:-help}" + +PICOCLAW_CONFIG="${2:-}" +PICOCLAW_CONFIG="${PICOCLAW_CONFIG:-$HOME/.picoclaw/config.json}" + +assert_is_safe "$COMMAND" "Command must be a valid identifier" || exit 1 +assert_is_file_path "$PICOCLAW_CONFIG" "Config file path must be valid" || exit 1 + +# Setup paths +BASE=$(basename "$PICOCLAW_CONFIG" .json) +DIR=$(dirname "$PICOCLAW_CONFIG") +STAGING_FILE="$DIR/$BASE.new.json" +SECRETS_FILE="$DIR/.$BASE.secrets.json" +BACKUP_DIR="$DIR/.config_backups" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) + +# Temp file for staging (cleaned up on script exit) +CONFIG_TMP="" +SECRETS_TMP="" +FINAL_FILE="" +cleanup() { rm -f "$CONFIG_TMP" "$FINAL_FILE" "$SECRETS_TMP"; } +trap cleanup EXIT + +SECRET_PATTERN="key|pass|secret|token|auth|credential|sid|sk-" +EXCLUDE_PATTERN="max_tokens|auth_method|enable_auth|auth_type" + +# Shared function: extract secrets if not already done +redact_secrets() { + + # Check if original has any secrets + paths=$(jq -c "paths(scalars) | select(.[-1] | tostring | ascii_downcase | (test(\"$SECRET_PATTERN\"; \"i\") and (test(\"$EXCLUDE_PATTERN\"; \"i\") | not)))" "$PICOCLAW_CONFIG" 2>/dev/null) || return 0 + + # Create staging file in temp (global var for trap cleanup) + CONFIG_TMP=$(mktemp) + cp "$PICOCLAW_CONFIG" "$CONFIG_TMP" + + # Extract secrets directly to final location + SECRETS_TMP=$(mktemp) + echo "{}" > "$SECRETS_TMP" + chmod 600 "$SECRETS_TMP" + + while read -r p; do + [ -z "$p" ] && continue + ID="SECRET:$(echo "$p" | jq -r '. | map(tostring) | map(if . | test("^[0-9]+$") then "[" + . + "]" else "." + . end) | join("") | ltrimstr(".")')" + VAL=$(jq -r "getpath($p)" "$PICOCLAW_CONFIG") + + # Add to secrets file + { rm -f "$SECRETS_TMP" ; jq --arg id "$ID" --arg val "$VAL" '. + {($id): $val}' > "$SECRETS_TMP"; } < "$SECRETS_TMP" + + # Replace with placeholder in staging + { rm -f "$CONFIG_TMP" ; jq --argjson p "$p" --arg id "$ID" 'setpath($p; $id)' > "${CONFIG_TMP}"; } < "$CONFIG_TMP" + + done <<< "$paths" +} + + +# Output a summary with only configured models, agents, enabled tools, devices, and heartbeat + +summarize() +{ + jq ' + def referenced_models: + [.. | objects | if has("model") then .model elif has("model_name") then .model_name else null end // empty] | unique; + + def used_model_ids: + referenced_models; + + def is_configured: + . != null and . != {} and . != []; + + def filter_tools: + (walk( + if type == "object" then + if .enabled == false then + null + else + . as $obj | reduce keys_unsorted[] as $k ({}; . + {($k): ($obj[$k] | filter_tools)}) + end + else + . + end + ) | if type == "object" then with_entries(select(.value != null)) else . end); + + . * { + model_list: (.model_list // []) | map(select(.model_name as $id | used_model_ids | contains([$id]))), + agents: (.agents // {}) | map_values(select(is_configured)) + } | .channels = ((.channels // {}) | to_entries | map(select(.value.enabled == true)) | from_entries) + | .tools = ((.tools // {}) | filter_tools) + | .providers = ((.providers // {} | to_entries | map(select(.value.api_key | gsub("^\\s+"; "") | gsub("\\s+$"; "") != "")) | from_entries)) + | .devices = (if (.devices.enabled // false) then .devices else null end) + | .heartbeat = (if (.heartbeat.enabled // false) then .heartbeat else null end) + ' "${1}" | jq 'with_entries(select(.value != null))' +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + case "$COMMAND" in + + redacted) + + redact_secrets + cat "$CONFIG_TMP" + ;; + + summary) + # Output a summary with only configured models, agents, enabled tools, devices, and heartbeat + + redact_secrets + summarize "$CONFIG_TMP" + + exit 0 + ;; + + help) + echo "Usage: $0 [config_file]" + echo "Commands:" + echo " redacted - Show config (default: PICOCLAW_CONFIG)" + echo " summary - Show config with unused models/chatconfigs filtered" + exit 0 + ;; + + esac +fi diff --git a/unsupported/workspace/skills/self-config/scripts/service.sh b/unsupported/workspace/skills/self-config/scripts/service.sh new file mode 100644 index 000000000..8b91dc192 --- /dev/null +++ b/unsupported/workspace/skills/self-config/scripts/service.sh @@ -0,0 +1,141 @@ +#!/bin/bash + +# service.sh - Service management with auto-rollback support +# A "dead man's switch" for configuration changes. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Validate identifier (alphanumeric, dash, underscore) +assert_is_identifier() { + if ! echo "$1" | grep -qE '^[a-zA-Z_][a-zA-Z0-9_-]*$'; then + echo "Error: $2" >&2 + return 1 + fi +} + +assert_is_number () +{ + # Use grep for robust, portable POSIX regex matching. + if ! echo "$1" | grep -qE '^[0-9]+$'; + then + echo "Error: $2" >&2 + return 1 + fi +} + +SERVICE_NAME="${PICOCLAW_SERVICE_NAME:-picoclaw}" + +COMMAND="${1:-help}" +assert_is_identifier "$COMMAND" "Action must be a valid identifier" || exit 1 + +SERVICE="${2:-$SERVICE_NAME}" +assert_is_identifier "$SERVICE" "Service name must be a valid identifier" || exit 1 + +CONFIG="${3:-${PICOCLAW_CONFIG:-$HOME/.picoclaw/config.json}}" + +[[ -n "$CONFIG" ]] && ! [[ -f "$CONFIG" ]] && echo "Expected file" && exit 1 + +case "$COMMAND" in + + restart) + + echo "Restarting service: $SERVICE" + systemctl --user restart "$SERVICE" + echo "Done." + ;; + + _rollback) + + # Check if marker file exists (auto-rollback was set) + MARKER="${CONFIG}-PENDING-ROLLBACK" + + if [ ! -f "$MARKER" ]; then + # No marker = nothing to rollback = success + exit 0 + fi + + # Marker exists - perform rollback + rm -f "$MARKER" + + "$SCRIPT_DIR/config.sh" rollback "$CONFIG" + + echo "[ROLLBACK] Restarting service: $SERVICE" + systemctl --user restart "$SERVICE" || true + echo "Rollback complete." + + ;; + + restart-auto-rollback) + + TIMEOUT="${TIMEOUT:-}" + TIMEOUT="${4:-120}" + assert_is_number "$TIMEOUT" "Timer must be number of seconds" || exit 1 + + # Create marker file + MARKER="${CONFIG}-PENDING-ROLLBACK" + + # Restart service first + echo "Restarting service: $SERVICE" + systemctl --user restart "$SERVICE" + + echo "Auto-rollback armed for $SERVICE (timeout: ${TIMEOUT}s)" + echo "Marker: $MARKER" + + # Start background timer + ( + sleep "$TIMEOUT" + if [ -f "$MARKER" ]; then + echo "[TIMEOUT] Auto-rollback triggered for $SERVICE" + bash "$0" _rollback "$SERVICE" "$CONFIG" + fi + ) & + TIMER_PID=$! + + { + echo "Timer PID: $TIMER_PID" + echo "----------------------------------------------------" + echo "Service will ROLLBACK in $TIMEOUT seconds if not confirmed." + echo "To confirm: service.sh confirm $SERVICE" + echo "----------------------------------------------------" + } | tee "$MARKER" + + ;; + + confirm) + + MARKER="${CONFIG}-PENDING-ROLLBACK" + + if [ -n "$MARKER" ] && [ -f "$MARKER" ]; then + rm -f "$MARKER" + echo "Rollback cancelled. Changes confirmed." + else + echo "No pending rollback to confirm." + fi + + ;; + + help) + echo "Usage: $0 [args...]" + echo "" + echo "Commands:" + echo " restart [service] Restart service (default: \$PICOCLAW_SERVICE_NAME or picoclaw)" + echo " auto-rollback [service] [config] [timeout]" + echo " Arm rollback timer, restart service" + echo " confirm [service] [config] Cancel pending rollback" + echo " _rollback [service] [config]" + echo " Internal: check marker, rollback if present" + echo "" + echo "Environment:" + echo " PICOCLAW_SERVICE_NAME Default service name (default: picoclaw)" + echo " PICOCLAW_CONFIG Default config file (default: picoclaw)" + exit 0 + ;; + + *) + echo "Unknown command: $COMMAND" + echo "Run '$0 help' for usage." + exit 1 + ;; +esac