diff --git a/copaw/src/copaw_worker/matrix_channel.py b/copaw/src/copaw_worker/matrix_channel.py index bf1b04f3..9413fafe 100644 --- a/copaw/src/copaw_worker/matrix_channel.py +++ b/copaw/src/copaw_worker/matrix_channel.py @@ -1001,11 +1001,14 @@ async def _upload_file(self, file_ref: str) -> Optional[str]: "MatrixChannel: uploaded %s → %s", path.name, resp.content_uri ) return resp.content_uri - logger.warning("MatrixChannel: upload failed: %s", resp) + logger.warning( + "MatrixChannel: upload returned %s (expected UploadResponse) for %s", + type(resp).__name__, file_ref, + ) return None except Exception as exc: - logger.warning( - "MatrixChannel: upload error for %s: %s", file_ref, exc + logger.exception( + "MatrixChannel: upload exception for %s: %s", file_ref, exc ) return None @@ -1458,6 +1461,18 @@ async def send_media( logger.warning( "MatrixChannel: send_media upload failed for %s", file_ref ) + # Send fallback notice so the user knows the file didn't arrive + try: + path_str = file_ref.removeprefix("file://") + _fname = os.path.basename(path_str) or "file" + await self._client.room_send( + room_id, "m.room.message", + {"msgtype": "m.notice", + "body": f"\u26a0\ufe0f File upload failed: {_fname}"}, + ignore_unverified_devices=True, + ) + except Exception: + pass return # Build and send the Matrix room event @@ -1495,3 +1510,12 @@ async def send_media( logger.exception( "MatrixChannel: send_media failed for %s: %s", room_id, exc ) + try: + await self._client.room_send( + room_id, "m.room.message", + {"msgtype": "m.notice", + "body": f"\u26a0\ufe0f File delivery failed: {filename}"}, + ignore_unverified_devices=True, + ) + except Exception: + pass diff --git a/manager/agent/TOOLS.md b/manager/agent/TOOLS.md index 0893ddcf..1b4a2409 100644 --- a/manager/agent/TOOLS.md +++ b/manager/agent/TOOLS.md @@ -2,7 +2,9 @@ Each skill has a full `SKILL.md` in `skills//`. The `description` field in each SKILL.md tells the system when to load it. -Available skills: `task-management`, `task-coordination`, `git-delegation-management`, `worker-management`, `project-management`, `channel-management`, `matrix-server-management`, `mcp-server-management`, `model-switch`, `worker-model-switch`, `file-sync-management` +Available skills: `task-management`, `task-coordination`, `git-delegation-management`, `worker-management`, `project-management`, `channel-management`, `matrix-server-management`, `mcp-server-management`, `model-switch`, `worker-model-switch`, `file-sync-management`, `send-file` + +> **Shared skills** (in `shared-skills//`): `send-file` — available to both Manager and Workers. ## Cross-Skill Combos diff --git a/manager/agent/shared-skills/send-file/SKILL.md b/manager/agent/shared-skills/send-file/SKILL.md new file mode 100644 index 00000000..9229770f --- /dev/null +++ b/manager/agent/shared-skills/send-file/SKILL.md @@ -0,0 +1,57 @@ +--- +name: send-file +description: Send a local file as a Matrix attachment to a chat room. Use when someone asks you to send, share, or transfer a file. +assign_when: Agent needs to send files to users via Matrix +--- + +# Send File + +Send a local file as a Matrix attachment so the recipient can download it directly from the chat. + +## Usage + +**Manager:** +```bash +bash /opt/hiclaw/agent/shared-skills/send-file/scripts/send-file.sh +``` + +**Worker:** +```bash +bash ~/skills/send-file/scripts/send-file.sh +``` + +| Parameter | Description | +|-----------|-------------| +| `file_path` | Absolute path to the local file to send | +| `room_id` | Matrix room ID (e.g., `!abc123:domain`) — the target room | + +On success, prints the `mxc://` URI. On failure, prints an error to stderr and exits non-zero. + +## Examples + +### Send a file you just created + +```bash +echo "# Report" > /tmp/report.md +bash ~/skills/send-file/scripts/send-file.sh /tmp/report.md '!roomid:domain' +``` + +### Download from OSS/MinIO then send + +```bash +mc cp ${HICLAW_STORAGE_PREFIX}/shared/tasks/task-123/output.csv /tmp/output.csv +bash ~/skills/send-file/scripts/send-file.sh /tmp/output.csv '!roomid:domain' +``` + +### Send a task result + +```bash +bash ~/skills/send-file/scripts/send-file.sh ~/shared/tasks/task-123/result.md '!roomid:domain' +``` + +## Important + +- The file must exist locally before sending — download it first if it's on OSS/MinIO +- MIME type is auto-detected from the file content +- Credentials are auto-resolved from environment variables or `openclaw.json` +- Do NOT paste file contents as text when asked to "send" a file — use this script to send as a proper downloadable attachment diff --git a/manager/agent/shared-skills/send-file/scripts/send-file.sh b/manager/agent/shared-skills/send-file/scripts/send-file.sh new file mode 100755 index 00000000..e0764592 --- /dev/null +++ b/manager/agent/shared-skills/send-file/scripts/send-file.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# send-file.sh — Upload a local file to Matrix and send as m.file attachment +# +# Usage: send-file.sh +# +# Credentials (checked in order): +# 1. MATRIX_ACCESS_TOKEN env var +# 2. accessToken from openclaw.json (auto-detected path) +# +# Matrix server: HICLAW_MATRIX_SERVER env var (required) +# +# Output: mxc:// URI on success +# Exit: 0 on success, 1 on failure + +set -euo pipefail + +# ============================================================ +# Arguments +# ============================================================ +if [ $# -lt 2 ]; then + echo "Usage: send-file.sh " >&2 + exit 1 +fi + +FILE_PATH="$1" +ROOM_ID="$2" + +# ============================================================ +# Validate file +# ============================================================ +if [ ! -f "${FILE_PATH}" ]; then + echo "ERROR: File not found: ${FILE_PATH}" >&2 + exit 1 +fi + +if [ ! -r "${FILE_PATH}" ]; then + echo "ERROR: File not readable: ${FILE_PATH}" >&2 + exit 1 +fi + +FILENAME=$(basename "${FILE_PATH}") +FILE_SIZE=$(stat -c%s "${FILE_PATH}" 2>/dev/null || stat -f%z "${FILE_PATH}" 2>/dev/null || echo 0) + +# ============================================================ +# Resolve Matrix server URL +# ============================================================ +MATRIX_URL="${HICLAW_MATRIX_SERVER:-}" +if [ -z "${MATRIX_URL}" ]; then + echo "ERROR: HICLAW_MATRIX_SERVER environment variable not set" >&2 + exit 1 +fi + +# ============================================================ +# Resolve access token +# ============================================================ +TOKEN="${MATRIX_ACCESS_TOKEN:-}" + +if [ -z "${TOKEN}" ]; then + # Try to find openclaw.json (Worker environments) + OPENCLAW_JSON="" + + # CoPaw Worker: ~/.copaw-worker//openclaw.json + if [ -z "${OPENCLAW_JSON}" ]; then + for f in ~/.copaw-worker/*/openclaw.json; do + [ -f "$f" ] && OPENCLAW_JSON="$f" && break + done + fi + + # OpenClaw Worker: ~/openclaw.json or ~/.openclaw/openclaw.json + [ -z "${OPENCLAW_JSON}" ] && [ -f ~/openclaw.json ] && OPENCLAW_JSON=~/openclaw.json + [ -z "${OPENCLAW_JSON}" ] && [ -f ~/.openclaw/openclaw.json ] && OPENCLAW_JSON=~/.openclaw/openclaw.json + + if [ -n "${OPENCLAW_JSON}" ]; then + TOKEN=$(jq -r '.channels.matrix.accessToken // empty' "${OPENCLAW_JSON}" 2>/dev/null) + fi +fi + +# Manager environment: try MANAGER_MATRIX_TOKEN +if [ -z "${TOKEN}" ]; then + TOKEN="${MANAGER_MATRIX_TOKEN:-}" +fi + +if [ -z "${TOKEN}" ]; then + echo "ERROR: No Matrix access token found. Set MATRIX_ACCESS_TOKEN or ensure openclaw.json is available." >&2 + exit 1 +fi + +# ============================================================ +# Detect MIME type +# ============================================================ +MIME_TYPE=$(file --brief --mime-type "${FILE_PATH}" 2>/dev/null || echo "application/octet-stream") + +# ============================================================ +# Step 1: Upload file to Matrix media repo +# ============================================================ +UPLOAD_RESP=$(curl -sf -X POST \ + "${MATRIX_URL}/_matrix/media/v3/upload?filename=$(printf '%s' "${FILENAME}" | jq -sRr @uri)" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: ${MIME_TYPE}" \ + --data-binary "@${FILE_PATH}" 2>&1) || { + echo "ERROR: File upload failed" >&2 + echo "${UPLOAD_RESP}" >&2 + exit 1 +} + +MXC_URI=$(echo "${UPLOAD_RESP}" | jq -r '.content_uri // empty' 2>/dev/null) +if [ -z "${MXC_URI}" ]; then + echo "ERROR: Upload succeeded but no content_uri in response" >&2 + echo "${UPLOAD_RESP}" >&2 + exit 1 +fi + +# ============================================================ +# Step 2: Send m.file event to room +# ============================================================ +TXN_ID="sf-$(date +%s%N)" +ROOM_ENC="${ROOM_ID//!/%21}" + +SEND_RESP=$(curl -sf -X PUT \ + "${MATRIX_URL}/_matrix/client/v3/rooms/${ROOM_ENC}/send/m.room.message/${TXN_ID}" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "$(jq -n \ + --arg body "${FILENAME}" \ + --arg url "${MXC_URI}" \ + --arg mime "${MIME_TYPE}" \ + --argjson size "${FILE_SIZE}" \ + '{ + msgtype: "m.file", + body: $body, + url: $url, + info: { mimetype: $mime, size: $size } + }')" 2>&1) || { + echo "ERROR: Failed to send file event to room" >&2 + echo "${SEND_RESP}" >&2 + exit 1 +} + +EVENT_ID=$(echo "${SEND_RESP}" | jq -r '.event_id // empty' 2>/dev/null) +if [ -z "${EVENT_ID}" ]; then + echo "ERROR: Send succeeded but no event_id in response" >&2 + echo "${SEND_RESP}" >&2 + exit 1 +fi + +# ============================================================ +# Success +# ============================================================ +echo "${MXC_URI}" diff --git a/manager/agent/skills/worker-management/scripts/create-worker.sh b/manager/agent/skills/worker-management/scripts/create-worker.sh index 904581b4..cbdabc9b 100644 --- a/manager/agent/skills/worker-management/scripts/create-worker.sh +++ b/manager/agent/skills/worker-management/scripts/create-worker.sh @@ -440,6 +440,19 @@ if [ -d "${WORKER_AGENT_SRC}" ]; then || log " WARNING: Failed to push ${_skill_name} skill" done fi + + # Push shared skills (used by both Manager and Workers) + if [ -d "/opt/hiclaw/agent/shared-skills" ]; then + for _skill_dir in /opt/hiclaw/agent/shared-skills/*/; do + [ ! -d "${_skill_dir}" ] && continue + _skill_name=$(basename "${_skill_dir}") + log " Pushing shared skill ${_skill_name} to worker MinIO..." + mc mirror "${_skill_dir}" \ + "${HICLAW_STORAGE_PREFIX}/agents/${WORKER_NAME}/skills/${_skill_name}/" --overwrite \ + || log " WARNING: Failed to push shared skill ${_skill_name}" + done + fi + log " Worker agent files pushed" else log " WARNING: worker-agent directory not found at ${WORKER_AGENT_SRC}" diff --git a/manager/scripts/init/start-manager-agent.sh b/manager/scripts/init/start-manager-agent.sh index 0534cda1..cd7c8c8f 100755 --- a/manager/scripts/init/start-manager-agent.sh +++ b/manager/scripts/init/start-manager-agent.sh @@ -785,6 +785,7 @@ bash "$RENDER" /root/manager-workspace/copaw-worker-agent bash "$RENDER" /opt/hiclaw/agent/worker-skills bash "$RENDER" /opt/hiclaw/agent/worker-agent bash "$RENDER" /opt/hiclaw/agent/copaw-worker-agent +bash "$RENDER" /opt/hiclaw/agent/shared-skills log "Agent doc templates rendered" # Cloud mode: start background file sync (workspace ↔ OSS) and initial push diff --git a/manager/scripts/init/upgrade-builtins.sh b/manager/scripts/init/upgrade-builtins.sh index 27f32c96..86d33d63 100755 --- a/manager/scripts/init/upgrade-builtins.sh +++ b/manager/scripts/init/upgrade-builtins.sh @@ -179,6 +179,18 @@ if [ -d "${WORKER_AGENT_SRC}" ] && mc alias ls hiclaw > /dev/null 2>&1; then done fi + # Push shared skills (used by both Manager and Workers) + if [ -d "${AGENT_SRC}/shared-skills" ]; then + for _skill_dir in "${AGENT_SRC}/shared-skills"/*/; do + [ ! -d "${_skill_dir}" ] && continue + _skill_name=$(basename "${_skill_dir}") + mc mirror "${_skill_dir}" \ + "${HICLAW_STORAGE_PREFIX}/agents/${_worker_name}/skills/${_skill_name}/" --overwrite 2>/dev/null \ + && log " Updated shared skill: ${_skill_name}" \ + || log " WARNING: Failed to sync shared skill ${_skill_name}" + done + fi + # Push assigned worker-skills (on-demand skills from registry) for _skill_name in $(jq -r --arg w "${_worker_name}" \ '.workers[$w].skills // [] | .[]' "${REGISTRY}" 2>/dev/null); do diff --git a/tests/lib/matrix-client.sh b/tests/lib/matrix-client.sh index ca996ea4..d727cc33 100755 --- a/tests/lib/matrix-client.sh +++ b/tests/lib/matrix-client.sh @@ -289,3 +289,97 @@ matrix_find_dm_room() { return 1 } + +# ============================================================ +# File Transfer +# ============================================================ + +# Upload a file to Matrix media repo and send as m.file to a room +# Usage: matrix_send_file [content_type] +# Returns: JSON with event_id +matrix_send_file() { + local token="$1" + local room_id="$2" + local filename="$3" + local content="$4" + local content_type="${5:-text/plain}" + local room_enc + room_enc="$(_encode_room_id "${room_id}")" + + # Step 1: Upload to media repo — write content to a temp file to avoid quoting issues + local upload_resp mxc_uri + local tmp_file="/tmp/matrix-send-file-$$" + exec_in_manager sh -c "cat > ${tmp_file}" <<< "${content}" + upload_resp=$(exec_in_manager curl -sf -X POST \ + "${TEST_MATRIX_DIRECT_URL}/_matrix/media/v3/upload?filename=${filename}" \ + -H "Authorization: Bearer ${token}" \ + -H "Content-Type: ${content_type}" \ + --data-binary "@${tmp_file}" 2>/dev/null) + exec_in_manager rm -f "${tmp_file}" 2>/dev/null || true + + mxc_uri=$(echo "${upload_resp}" | jq -r '.content_uri // empty') + if [ -z "${mxc_uri}" ]; then + echo "" + return 1 + fi + + # Step 2: Send m.file event to room + local txn_id content_length + txn_id="$(date +%s%N)" + content_length=${#content} + + exec_in_manager curl -sf -X PUT \ + "${TEST_MATRIX_DIRECT_URL}/_matrix/client/v3/rooms/${room_enc}/send/m.room.message/${txn_id}" \ + -H "Authorization: Bearer ${token}" \ + -H 'Content-Type: application/json' \ + -d '{ + "msgtype": "m.file", + "body": "'"${filename}"'", + "url": "'"${mxc_uri}"'", + "info": { + "mimetype": "'"${content_type}"'", + "size": '"${content_length}"' + } + }' +} + +# Wait for a message with a specific msgtype from a user +# Usage: matrix_wait_for_media_message [timeout] +# Returns: JSON of the matching event's content, or empty on timeout +matrix_wait_for_media_message() { + local token="$1" + local room_id="$2" + local from_user="$3" + local msgtype="$4" + local timeout="${5:-180}" + local elapsed=0 + + # Snapshot baseline + local baseline_event + baseline_event=$(matrix_read_messages "${token}" "${room_id}" 5 2>/dev/null | \ + jq -r --arg user "${from_user}" \ + '[.chunk[] | select(.sender | startswith($user)) | .event_id] | first // ""' 2>/dev/null) + + while [ "${elapsed}" -lt "${timeout}" ]; do + sleep 10 + elapsed=$((elapsed + 10)) + + local messages match + messages=$(matrix_read_messages "${token}" "${room_id}" 20 2>/dev/null) || continue + + # Find a new event from the target user with the desired msgtype + match=$(echo "${messages}" | jq -r --arg user "${from_user}" --arg mt "${msgtype}" --arg base "${baseline_event}" \ + '[.chunk[] | select( + (.sender | startswith($user)) and + (.content.msgtype == $mt) and + (.event_id != $base) + )] | first // empty' 2>/dev/null) + + if [ -n "${match}" ] && [ "${match}" != "null" ]; then + echo "${match}" + return 0 + fi + done + + return 1 +} diff --git a/tests/test-15-file-transfer.sh b/tests/test-15-file-transfer.sh new file mode 100755 index 00000000..dfece819 --- /dev/null +++ b/tests/test-15-file-transfer.sh @@ -0,0 +1,131 @@ +#!/bin/bash +# test-15-file-transfer.sh - Case 15: File transfer via Matrix +# Verifies: File upload/download through Matrix media repo, +# send-file.sh script works end-to-end, +# error handling on missing files + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/lib/test-helpers.sh" +source "${SCRIPT_DIR}/lib/matrix-client.sh" +source "${SCRIPT_DIR}/lib/agent-metrics.sh" + +test_setup "15-file-transfer" + +if ! require_llm_key; then + test_teardown "15-file-transfer" + test_summary + exit 0 +fi + +ADMIN_LOGIN=$(matrix_login "${TEST_ADMIN_USER}" "${TEST_ADMIN_PASSWORD}") +ADMIN_TOKEN=$(echo "${ADMIN_LOGIN}" | jq -r '.access_token') + +MANAGER_USER="@manager:${TEST_MATRIX_DOMAIN}" + +# ============================================================ +# Setup: Find DM room with Manager +# ============================================================ +log_section "Setup" + +DM_ROOM=$(matrix_find_dm_room "${ADMIN_TOKEN}" "${MANAGER_USER}" 2>/dev/null || true) +assert_not_empty "${DM_ROOM}" "DM room with Manager found" + +wait_for_manager_agent_ready 300 "${DM_ROOM}" "${ADMIN_TOKEN}" || { + log_fail "Manager Agent not ready in time" + test_teardown "15-file-transfer" + test_summary + exit 1 +} + +# ============================================================ +# Test 1: Matrix file upload + send (basic capability) +# ============================================================ +log_section "Test 1: Matrix File Upload" + +SEND_RESULT=$(matrix_send_file "${ADMIN_TOKEN}" "${DM_ROOM}" \ + "test-transfer.txt" "Hello from file transfer integration test" "text/plain") +FILE_EVENT_ID=$(echo "${SEND_RESULT}" | jq -r '.event_id // empty') +assert_not_empty "${FILE_EVENT_ID}" "File event sent successfully" + +# Verify the file event is visible in room messages +sleep 5 +MESSAGES=$(matrix_read_messages "${ADMIN_TOKEN}" "${DM_ROOM}" 10) +FILE_MSG=$(echo "${MESSAGES}" | jq -r \ + '[.chunk[] | select(.content.msgtype == "m.file")] | first // empty') +assert_not_empty "${FILE_MSG}" "m.file event visible in room" + +FILE_BODY=$(echo "${FILE_MSG}" | jq -r '.content.body // empty') +assert_eq "test-transfer.txt" "${FILE_BODY}" "File event has correct filename" + +FILE_MXC=$(echo "${FILE_MSG}" | jq -r '.content.url // empty') +assert_contains "${FILE_MXC}" "mxc://" "File event has mxc:// URL" + +FILE_MIME=$(echo "${FILE_MSG}" | jq -r '.content.info.mimetype // empty') +assert_eq "text/plain" "${FILE_MIME}" "File event has correct MIME type" + +# ============================================================ +# Test 2: send-file.sh script in Manager container +# ============================================================ +log_section "Test 2: send-file.sh Script" + +# Create a test file inside the Manager container +exec_in_manager sh -c "echo 'Test file from send-file.sh' > /tmp/test-send-file.txt" + +# Run send-file.sh inside the Manager container +SEND_FILE_OUTPUT=$(exec_in_manager bash /opt/hiclaw/agent/shared-skills/send-file/scripts/send-file.sh \ + /tmp/test-send-file.txt "${DM_ROOM}" 2>&1) || true + +# Check if the output contains an mxc:// URI (success) +if echo "${SEND_FILE_OUTPUT}" | grep -q "mxc://"; then + log_pass "send-file.sh returned mxc:// URI" + + # Verify the file appeared in the room + sleep 5 + MESSAGES2=$(matrix_read_messages "${ADMIN_TOKEN}" "${DM_ROOM}" 10) + SCRIPT_FILE_MSG=$(echo "${MESSAGES2}" | jq -r \ + '[.chunk[] | select(.content.body == "test-send-file.txt" and .content.msgtype == "m.file")] | first // empty') + assert_not_empty "${SCRIPT_FILE_MSG}" "send-file.sh file visible in room" +else + # Script may fail if HICLAW_MATRIX_SERVER or token not available in container + log_info "send-file.sh output: ${SEND_FILE_OUTPUT}" + if echo "${SEND_FILE_OUTPUT}" | grep -q "ERROR"; then + log_info "SKIP: send-file.sh credentials not configured in Manager container — skipping script test" + else + log_fail "send-file.sh unexpected output" + fi +fi + +# ============================================================ +# Test 3: send-file.sh error handling (missing file) +# ============================================================ +log_section "Test 3: Error Handling" + +MISSING_OUTPUT=$(exec_in_manager bash /opt/hiclaw/agent/shared-skills/send-file/scripts/send-file.sh \ + /tmp/nonexistent-file.txt "${DM_ROOM}" 2>&1) && { + log_fail "send-file.sh should exit non-zero for missing file" +} || { + log_pass "send-file.sh exits non-zero for missing file" +} + +assert_contains "${MISSING_OUTPUT}" "File not found" "Error message mentions file not found" + +# ============================================================ +# Collect Metrics +# ============================================================ +log_section "Collect Metrics" + +# Only collect worker metrics if a worker is running +if wait_for_worker_container "alice" 10 2>/dev/null; then + METRICS_BASELINE=$(snapshot_baseline "alice" 2>/dev/null || echo "{}") + wait_for_worker_session_stable "alice" 5 60 2>/dev/null || true + wait_for_session_stable 5 60 2>/dev/null || true + PREV_METRICS=$(cat "${TEST_OUTPUT_DIR}/metrics-15-file-transfer.json" 2>/dev/null || true) + METRICS=$(collect_delta_metrics "15-file-transfer" "$METRICS_BASELINE" "alice" 2>/dev/null || echo "{}") + print_metrics_report "$METRICS" "$PREV_METRICS" 2>/dev/null || true + save_metrics_file "$METRICS" "15-file-transfer" 2>/dev/null || true +else + log_info "No worker container running, skipping metrics collection" +fi + +test_teardown "15-file-transfer" +test_summary