Elnor Repo Reader

DOC5A_WILL_ACCOUNT_BUILD.md

Current Specs/DOC5/DOC5A_WILL_ACCOUNT_BUILD.md

Generated 2026-06-09T01:23:58.539Z from commit dbaa25962edc11ab30e8d4ca1715f9ae5bf77331. Worktree: clean.

Open text page · Open raw txt · Open path URL

# DOC5A — File Sync Infrastructure (will account)

**Date:** 2026-02-25
**Status:** Implementation guide — for Codex / Claude Code on the `will` user account.
**Source:** DOC5 File Sync & Security Architecture v1.11.7 (§1–§6, §8–§10)
**Companion:** DOC5B — Elnor Skill Build (openclaw1 account)

---

## What This Document Covers

Everything that runs under or is owned by the `will` macOS user account:

- Folder structure creation (both accounts — `will` has admin rights)
- Permissions setup (cross-account ownership and chmod)
- Sync scripts (rsync mirror pull + outbox push)
- launchd plists (scheduled sync jobs)
- Cleanup/purge scripts and their plists
- The web dashboard (Express server, auth, API, frontend)
- config.json and include files

**What this does NOT cover:** The OpenClaw file-sync skill that Elnor uses. That's in DOC5B, built from the `openclaw1` account.

---

## Prerequisites

- Logged into the `will` macOS user account
- `will` has admin privileges (can `sudo`, can create folders under `/Users/openclaw1/`)
- Node.js installed (for the dashboard)
- OneDrive configured and syncing on the `will` account:
  - Firm: `/Users/will/Library/CloudStorage/OneDrive-SharedLibraries-schallfirm.com/`
  - Personal: `/Users/will/Library/CloudStorage/OneDrive-Personal/`
- The `openclaw1` user account exists

---

## Phase 1 — Folder Structure & Permissions

### Step 1: Create All Folders

Run as `will` (with sudo for openclaw1-owned paths):

```bash
#!/bin/bash
# setup-folders.sh — Run once from will account
# Creates the complete folder structure for the file sync system

set -e

OC_HOME="/Users/openclaw1"

echo "Creating folder structure..."

# Mirror (will-owned, openclaw1 reads)
sudo mkdir -p "$OC_HOME/onedrive-mirror/firm"
sudo mkdir -p "$OC_HOME/onedrive-mirror/personal"

# Outbox (openclaw1-owned, both read/write)
sudo mkdir -p "$OC_HOME/onedrive-outbox/firm"
sudo mkdir -p "$OC_HOME/onedrive-outbox/personal"

# Working (openclaw1-owned, private scratch)
sudo mkdir -p "$OC_HOME/working"

# Snapshots (openclaw1-owned)
sudo mkdir -p "$OC_HOME/snapshots/firm"
sudo mkdir -p "$OC_HOME/snapshots/personal"

# Mirror versions (will-owned)
sudo mkdir -p "$OC_HOME/onedrive-mirror-versions"

# Sync config (will-owned except requests.txt)
sudo mkdir -p "$OC_HOME/sync-config/scripts"
sudo mkdir -p "$OC_HOME/sync-config/logs"

echo "Folders created."
```

### Step 2: Set Ownership & Permissions

```bash
#!/bin/bash
# setup-permissions.sh — Run once from will account
# Sets ownership and permissions per §2.2 of DOC5

set -e

OC_HOME="/Users/openclaw1"
WILL_USER="will"
OC_USER="openclaw1"

echo "Setting ownership..."

# will-owned (Elnor reads only)
sudo chown -R "$WILL_USER" "$OC_HOME/onedrive-mirror"
sudo chown -R "$WILL_USER" "$OC_HOME/onedrive-mirror-versions"
sudo chown -R "$WILL_USER" "$OC_HOME/sync-config"

# openclaw1-owned (Elnor reads and writes)
sudo chown -R "$OC_USER" "$OC_HOME/onedrive-outbox"
sudo chown -R "$OC_USER" "$OC_HOME/working"
sudo chown -R "$OC_USER" "$OC_HOME/snapshots"

echo "Setting permissions..."

# Mirror: openclaw1 can read, not write
sudo chmod -R 755 "$OC_HOME/onedrive-mirror"
sudo chmod -R 755 "$OC_HOME/onedrive-mirror-versions"

# Outbox: both can read/write
sudo chmod -R 777 "$OC_HOME/onedrive-outbox"

# Working: openclaw1 only
sudo chmod -R 700 "$OC_HOME/working"
sudo chown -R "$OC_USER" "$OC_HOME/working"

# Snapshots: openclaw1 writes, will can read via dashboard
sudo chmod -R 755 "$OC_HOME/snapshots"

# Sync config: will owns, openclaw1 reads
sudo chmod -R 755 "$OC_HOME/sync-config"
sudo chmod -R 700 "$OC_HOME/sync-config/scripts"  # scripts: will only

# Logs: will writes, openclaw1 reads
sudo chmod -R 755 "$OC_HOME/sync-config/logs"

# requests.txt: openclaw1 writes, will reads
sudo touch "$OC_HOME/sync-config/requests.txt"
sudo chown "$OC_USER" "$OC_HOME/sync-config/requests.txt"
sudo chmod 644 "$OC_HOME/sync-config/requests.txt"

echo "Permissions set."
```

### Step 3: Verify Permissions

After running both scripts, verify:

```bash
# From will account:
ls -la /Users/openclaw1/onedrive-mirror/          # owner: will
ls -la /Users/openclaw1/onedrive-outbox/           # owner: openclaw1
ls -la /Users/openclaw1/sync-config/               # owner: will
ls -la /Users/openclaw1/sync-config/scripts/       # owner: will, 700
ls -la /Users/openclaw1/sync-config/requests.txt   # owner: openclaw1

# Test from openclaw1 (switch user or ssh):
# Should succeed:
#   cat /Users/openclaw1/onedrive-mirror/firm/somefile.docx
#   touch /Users/openclaw1/onedrive-outbox/firm/test.txt
#   echo "test" >> /Users/openclaw1/sync-config/requests.txt
# Should fail:
#   touch /Users/openclaw1/onedrive-mirror/firm/test.txt          → Permission denied
#   touch /Users/openclaw1/sync-config/firm-folders.txt            → Permission denied
#   cat /Users/openclaw1/sync-config/scripts/firm-mirror.sh        → Permission denied
```

---

## Phase 2 — Sync Scripts

### Step 4: config.json

Create at `/Users/openclaw1/sync-config/config.json`:

```json
{
  "accounts": {
    "firm": {
      "label": "Firm OneDrive",
      "onedrive_path": "/Users/will/Library/CloudStorage/OneDrive-SharedLibraries-schallfirm.com/",
      "mirror_path": "/Users/openclaw1/onedrive-mirror/firm/",
      "outbox_path": "/Users/openclaw1/onedrive-outbox/firm/",
      "mirror_interval_seconds": 180,
      "outbox_interval_seconds": 180,
      "mirror_active": true,
      "outbox_active": true,
      "auto_push": true
    },
    "personal": {
      "label": "Personal OneDrive",
      "onedrive_path": "/Users/will/Library/CloudStorage/OneDrive-Personal/",
      "mirror_path": "/Users/openclaw1/onedrive-mirror/personal/",
      "outbox_path": "/Users/openclaw1/onedrive-outbox/personal/",
      "mirror_interval_seconds": 900,
      "outbox_interval_seconds": 900,
      "mirror_active": true,
      "outbox_active": true,
      "auto_push": true
    }
  },
  "paths": {
    "working": "/Users/openclaw1/working/",
    "snapshots": "/Users/openclaw1/snapshots/",
    "mirror_versions": "/Users/openclaw1/onedrive-mirror-versions/"
  },
  "naming_rule_default": "never_overwrite",
  "naming_overrides": {},
  "retention": {
    "outbox_days": 7,
    "mirror_versions_days": 30,
    "snapshots_days": 60
  },
  "dashboard": {
    "port": 8417
  }
}
```

### Step 5: Include Files

Create `/Users/openclaw1/sync-config/firm-folders.txt`:

```
# Folders synced from firm OneDrive to Elnor's mirror
# Managed by the dashboard — do not edit manually
# First match wins. + includes, - excludes.

- *
```

Create `/Users/openclaw1/sync-config/personal-folders.txt`:

```
# Folders synced from personal OneDrive to Elnor's mirror
# Managed by the dashboard — do not edit manually

- *
```

Both start empty (exclude all). Folders are added via the dashboard.

### Step 6: Mirror Pull Scripts

Create `/Users/openclaw1/sync-config/scripts/firm-mirror.sh`:

```bash
#!/bin/bash
# Firm OneDrive → Mirror pull
# Runs as 'will' user via launchd
# SAFETY: rsync --delete only affects DESTINATION (the mirror), never SOURCE (OneDrive)

CONFIG_DIR="/Users/openclaw1/sync-config"
LOG="$CONFIG_DIR/logs/firm-mirror.log"
INCLUDE_FILE="$CONFIG_DIR/firm-folders.txt"
SOURCE="/Users/will/Library/CloudStorage/OneDrive-SharedLibraries-schallfirm.com/"
DEST="/Users/openclaw1/onedrive-mirror/firm/"
VERSIONS="/Users/openclaw1/onedrive-mirror-versions/$(date +%Y%m%d)"

# Check if paused
ACTIVE=$(python3 -c "
import json
config = json.load(open('$CONFIG_DIR/config.json'))
print(config['accounts']['firm']['mirror_active'])
")
if [ "$ACTIVE" = "False" ]; then
  echo "$(date '+%Y-%m-%d %H:%M:%S'): Firm mirror PAUSED — skipping" >> "$LOG"
  exit 0
fi

mkdir -p "$DEST" "$VERSIONS"

echo "$(date '+%Y-%m-%d %H:%M:%S'): Starting firm mirror sync" >> "$LOG"

rsync -av --delete \
  --backup --backup-dir="$VERSIONS" \
  --include-from="$INCLUDE_FILE" \
  "$SOURCE" \
  "$DEST" \
  >> "$LOG" 2>&1

EXIT_CODE=$?
echo "$(date '+%Y-%m-%d %H:%M:%S'): Firm mirror sync complete (exit: $EXIT_CODE)" >> "$LOG"
```

Create `/Users/openclaw1/sync-config/scripts/personal-mirror.sh` — identical structure, swap `firm` → `personal` and use the Personal OneDrive path.

### Step 7: Outbox Push Scripts

Create `/Users/openclaw1/sync-config/scripts/firm-outbox.sh`:

```bash
#!/bin/bash
# Outbox → Firm OneDrive push
# Runs as 'will' user via launchd
# SAFETY: No --delete flag. Can only add/update files, never delete from OneDrive.
# SAFETY: Naming rules enforced before copy.
# SAFETY: Path traversal blocked.

CONFIG_DIR="/Users/openclaw1/sync-config"
CONFIG_FILE="$CONFIG_DIR/config.json"
LOG="$CONFIG_DIR/logs/firm-outbox.log"
SOURCE="/Users/openclaw1/onedrive-outbox/firm/"
DEST="/Users/will/Library/CloudStorage/OneDrive-SharedLibraries-schallfirm.com/"

# Check if paused
ACTIVE=$(python3 -c "
import json
config = json.load(open('$CONFIG_FILE'))
print(config['accounts']['firm']['outbox_active'])
")
if [ "$ACTIVE" = "False" ]; then
  echo "$(date '+%Y-%m-%d %H:%M:%S'): Firm outbox push PAUSED — skipping" >> "$LOG"
  exit 0
fi

echo "$(date '+%Y-%m-%d %H:%M:%S'): Starting firm outbox push" >> "$LOG"

# Read global naming rule
NAMING_RULE=$(python3 -c "
import json
config = json.load(open('$CONFIG_FILE'))
print(config.get('naming_rule_default', 'never_overwrite'))
")

# Process each file in the outbox (skip .pushed markers)
find "$SOURCE" -type f -not -name "*.pushed" | while read -r OUTBOX_FILE; do
  REL_PATH="${OUTBOX_FILE#$SOURCE}"

  # Path traversal check
  if [[ "$REL_PATH" == *".."* ]]; then
    echo "$(date '+%Y-%m-%d %H:%M:%S'): BLOCKED $REL_PATH — path traversal detected" >> "$LOG"
    continue
  fi

  # Skip already-pushed files
  if [ -f "${OUTBOX_FILE}.pushed" ]; then
    continue
  fi

  DEST_FILE="$DEST$REL_PATH"
  DEST_DIR=$(dirname "$DEST_FILE")

  # Check for folder-specific naming override
  FOLDER_RULE=$(python3 -c "
import json, sys
config = json.load(open('$CONFIG_FILE'))
overrides = config.get('naming_overrides', {})
rel = '$REL_PATH'
for folder, rule in overrides.items():
    if rel.startswith(folder):
        print(rule)
        sys.exit(0)
print('$NAMING_RULE')
  ")

  # Apply naming rule
  case "$FOLDER_RULE" in
    "elnor_decides")
      ;;
    "never_overwrite")
      if [ -f "$DEST_FILE" ]; then
        echo "$(date '+%Y-%m-%d %H:%M:%S'): BLOCKED $REL_PATH — file exists (never_overwrite)" >> "$LOG"
        continue
      fi
      ;;
    "auto_version")
      if [ -f "$DEST_FILE" ]; then
        BASENAME="${DEST_FILE%.*}"
        EXT="${DEST_FILE##*.}"
        VERSION=2
        while [ -f "${BASENAME}_v${VERSION}.${EXT}" ]; do
          VERSION=$((VERSION + 1))
        done
        DEST_FILE="${BASENAME}_v${VERSION}.${EXT}"
        echo "$(date '+%Y-%m-%d %H:%M:%S'): Auto-versioned $REL_PATH → $(basename "$DEST_FILE")" >> "$LOG"
      fi
      ;;
    "timestamp")
      BASENAME="${DEST_FILE%.*}"
      EXT="${DEST_FILE##*.}"
      TS=$(date +%Y%m%d-%H%M%S)
      DEST_FILE="${BASENAME}_${TS}.${EXT}"
      ;;
  esac

  mkdir -p "$DEST_DIR"
  cp -p "$OUTBOX_FILE" "$DEST_FILE"

  if [ $? -eq 0 ]; then
    echo "$(date '+%Y-%m-%d %H:%M:%S'): PUSHED $REL_PATH → $(basename "$DEST_FILE")" >> "$LOG"
    touch "${OUTBOX_FILE}.pushed"
  else
    echo "$(date '+%Y-%m-%d %H:%M:%S'): FAILED $REL_PATH" >> "$LOG"
  fi
done

echo "$(date '+%Y-%m-%d %H:%M:%S'): Firm outbox push complete" >> "$LOG"
```

Create `personal-outbox.sh` — same structure, swap `firm` → `personal`, use Personal OneDrive path.

### Step 8: Make Scripts Executable (will-only)

```bash
sudo chmod 700 /Users/openclaw1/sync-config/scripts/*.sh
sudo chown will /Users/openclaw1/sync-config/scripts/*.sh
```

---

## Phase 3 — launchd Jobs

### Step 9: Sync Plists

All plists go in `~/Library/LaunchAgents/` on the `will` account (i.e., `/Users/will/Library/LaunchAgents/`).

**com.elnor.sync-mirror-firm.plist:**

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.elnor.sync-mirror-firm</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>/Users/openclaw1/sync-config/scripts/firm-mirror.sh</string>
  </array>
  <key>StartInterval</key>
  <integer>180</integer>
  <key>RunAtLoad</key>
  <true/>
  <key>StandardErrorPath</key>
  <string>/Users/openclaw1/sync-config/logs/firm-mirror-stderr.log</string>
</dict>
</plist>
```

**Create four plists total:**

| Plist filename | Script | Default interval |
|---|---|---|
| `com.elnor.sync-mirror-firm.plist` | `firm-mirror.sh` | 180s (3 min) |
| `com.elnor.sync-mirror-personal.plist` | `personal-mirror.sh` | 900s (15 min) |
| `com.elnor.sync-outbox-firm.plist` | `firm-outbox.sh` | 180s (3 min) |
| `com.elnor.sync-outbox-personal.plist` | `personal-outbox.sh` | 900s (15 min) |

### Step 10: Cleanup Plists

**Purge scripts** (in `sync-config/scripts/`):

`purge-versions.sh`:
```bash
#!/bin/bash
VERSIONS_DIR="/Users/openclaw1/onedrive-mirror-versions"
RETENTION_DAYS=$(python3 -c "
import json
config = json.load(open('/Users/openclaw1/sync-config/config.json'))
print(config['retention']['mirror_versions_days'])
")
find "$VERSIONS_DIR" -maxdepth 1 -type d -mtime +${RETENTION_DAYS} -exec rm -rf {} \;
```

`purge-snapshots.sh`:
```bash
#!/bin/bash
SNAPSHOTS_DIR="/Users/openclaw1/snapshots"
RETENTION_DAYS=$(python3 -c "
import json
config = json.load(open('/Users/openclaw1/sync-config/config.json'))
print(config['retention']['snapshots_days'])
")
find "$SNAPSHOTS_DIR" -type f -mtime +${RETENTION_DAYS} -delete
```

`purge-outbox.sh`:
```bash
#!/bin/bash
OUTBOX_DIR="/Users/openclaw1/onedrive-outbox"
RETENTION_DAYS=$(python3 -c "
import json
config = json.load(open('/Users/openclaw1/sync-config/config.json'))
print(config['retention']['outbox_days'])
")
find "$OUTBOX_DIR" -name "*.pushed" -mtime +${RETENTION_DAYS} | while read -r MARKER; do
  ORIGINAL="${MARKER%.pushed}"
  rm -f "$MARKER" "$ORIGINAL"
done
```

**One plist for all cleanup** (`com.elnor.sync-purge.plist`):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.elnor.sync-purge</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>-c</string>
    <string>
      /Users/openclaw1/sync-config/scripts/purge-versions.sh;
      /Users/openclaw1/sync-config/scripts/purge-snapshots.sh;
      /Users/openclaw1/sync-config/scripts/purge-outbox.sh
    </string>
  </array>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key>
    <integer>3</integer>
    <key>Minute</key>
    <integer>0</integer>
  </dict>
  <key>RunAtLoad</key>
  <false/>
</dict>
</plist>
```

### Step 11: Load All Jobs

```bash
launchctl load ~/Library/LaunchAgents/com.elnor.sync-mirror-firm.plist
launchctl load ~/Library/LaunchAgents/com.elnor.sync-mirror-personal.plist
launchctl load ~/Library/LaunchAgents/com.elnor.sync-outbox-firm.plist
launchctl load ~/Library/LaunchAgents/com.elnor.sync-outbox-personal.plist
launchctl load ~/Library/LaunchAgents/com.elnor.sync-purge.plist

# Verify:
launchctl list | grep elnor
```

---

## Phase 4 — Web Dashboard

### Step 12: Scaffold

Create the Express app at `~/.elnor-sync-dashboard/` on the `will` account.

```
~/.elnor-sync-dashboard/
├── package.json
├── server.js              ← main Express server
├── auth.json              ← bcrypt password hash (created on first run)
├── routes/
│   ├── auth.js
│   ├── status.js
│   ├── folders.js
│   ├── sync.js
│   ├── outbox.js
│   ├── requests.js
│   ├── snapshots.js
│   └── settings.js
└── public/
    ├── index.html          ← single-page frontend
    ├── style.css
    └── app.js
```

**Technology:**
- Node.js + Express
- bcryptjs for password hashing
- express-session for session cookies
- No database — reads/writes JSON config files and runs shell commands

### Step 13: Authentication

- Password set on first run (prompt or setup endpoint)
- Stored as bcrypt hash in `auth.json`
- Session-based: login once, cookie lasts 24h
- 5 failed attempts → 15 min lockout
- Bind to `127.0.0.1` only (never `0.0.0.0`)
- Every endpoint requires authentication — no exceptions

### Step 14: API Endpoints

Full endpoint list from DOC5 §6.3:

| Method | Endpoint | Purpose |
|---|---|---|
| POST | `/api/auth/login` | Login |
| POST | `/api/auth/logout` | Logout |
| GET | `/api/status` | Overall sync status |
| GET | `/api/accounts` | OneDrive account info |
| POST | `/api/accounts/detect` | Re-detect OneDrive folders |
| GET | `/api/folders/{account}` | Synced folders |
| POST | `/api/folders/{account}/add` | Add folder to sync |
| DELETE | `/api/folders/{account}/{folder}` | Remove folder from sync |
| GET | `/api/folders/{account}/available` | Browse OneDrive tree |
| POST | `/api/sync/{account}/mirror/pause` | Pause mirror |
| POST | `/api/sync/{account}/mirror/resume` | Resume mirror |
| POST | `/api/sync/{account}/mirror/now` | Trigger immediate mirror |
| POST | `/api/sync/{account}/outbox/pause` | Pause outbox push |
| POST | `/api/sync/{account}/outbox/resume` | Resume outbox push |
| POST | `/api/sync/{account}/outbox/now` | Trigger immediate push |
| POST | `/api/sync/emergency-stop` | Stop all jobs |
| GET | `/api/sync/{account}/timing` | Get interval |
| PUT | `/api/sync/{account}/timing` | Set interval (updates plist, reloads job) |
| GET | `/api/outbox/{account}` | List outbox files |
| POST | `/api/outbox/{account}/push/{file}` | Push specific file |
| POST | `/api/outbox/{account}/rename/{file}` | Rename outbox file |
| DELETE | `/api/outbox/{account}/{file}` | Delete from outbox |
| POST | `/api/outbox/{account}/overwrite/{file}` | Force-push blocked file |
| GET | `/api/requests` | Elnor's folder requests |
| POST | `/api/requests/{id}/approve` | Approve request (adds folder to include list) |
| POST | `/api/requests/{id}/deny` | Deny request |
| GET | `/api/snapshots/{account}` | List snapshots |
| POST | `/api/snapshots/{account}/restore/{file}` | Copy snapshot to outbox |
| DELETE | `/api/snapshots/{account}/{file}` | Delete snapshot |
| GET | `/api/versions/{account}` | Mirror version history |
| GET | `/api/logs/{job}` | Recent log lines |
| GET | `/api/settings` | All settings |
| PUT | `/api/settings` | Update settings |

**Key implementation details for sync control endpoints:**

Pause/resume: Update `mirror_active` or `outbox_active` in config.json. The sync scripts already check this flag at the start and skip if paused.

Timing changes: Update `mirror_interval_seconds` / `outbox_interval_seconds` in config.json, then:
```bash
launchctl unload ~/Library/LaunchAgents/com.elnor.sync-mirror-firm.plist
# Update <integer> in the plist file
launchctl load ~/Library/LaunchAgents/com.elnor.sync-mirror-firm.plist
```

Emergency stop: Unload all four sync plists.

Trigger now: Run the script directly via `execSync`.

### Step 15: Frontend

Single-page HTML/CSS/JS matching the wireframes in DOC5 §6.2.

Four views:
1. **Main page** — Sync status for both accounts, synced folders, outbox summary, Elnor's requests
2. **Outbox view** — Pending, blocked, recently pushed files with actions
3. **Snapshots view** — Browse and restore pre-edit backups
4. **Settings page** — Paths, naming rules, timing, retention, password

See DOC5 §6.2 for complete wireframe layouts.

### Step 16: Dashboard launchd Plist

`com.elnor.sync-dashboard.plist`:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.elnor.sync-dashboard</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/bin/node</string>
    <string>/Users/will/.elnor-sync-dashboard/server.js</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
  <key>StandardErrorPath</key>
  <string>/Users/will/.elnor-sync-dashboard/dashboard-error.log</string>
  <key>StandardOutPath</key>
  <string>/Users/will/.elnor-sync-dashboard/dashboard.log</string>
</dict>
</plist>
```

Load: `launchctl load ~/Library/LaunchAgents/com.elnor.sync-dashboard.plist`

---

## Phase 5 — Testing

### Infrastructure Tests (after Phase 1–3)

| # | Test | How | Expected |
|---|---|---|---|
| 1 | Folders exist | `ls -la /Users/openclaw1/onedrive-mirror/` etc. | All folders present |
| 2 | Permissions correct | See Step 3 verification commands | Pass all checks |
| 3 | Mirror pull works | Add a folder to firm-folders.txt, run `bash firm-mirror.sh` | Files appear in mirror |
| 4 | Mirror versioning | Edit a file in OneDrive, re-run mirror | Old version saved to versions dir |
| 5 | Outbox push works | Put a test file in outbox, run `bash firm-outbox.sh` | File appears in OneDrive |
| 6 | never_overwrite blocks | Put a file with existing name in outbox, push | Blocked, logged |
| 7 | auto_version works | Set naming rule, push conflicting file | File saved as `_v2` |
| 8 | Path traversal blocked | Put file with `..` in path, push | Blocked, logged |
| 9 | launchd jobs running | `launchctl list \| grep elnor` | All 5 jobs listed |
| 10 | Purge works | Create old test files, run purge script | Old files deleted |

### Dashboard Tests (after Phase 4)

| # | Test | How | Expected |
|---|---|---|---|
| 11 | Server starts | `node server.js`, visit localhost:8417 | Login page |
| 12 | Auth works | Login with password | Session created |
| 13 | Unauth rejected | Hit API without cookie | 401 |
| 14 | Status API | GET /api/status | Correct sync state |
| 15 | Add folder | Use folder picker, add a folder | Appears in include list, mirrors on next sync |
| 16 | Remove folder | Remove folder via dashboard | Removed from include list |
| 17 | Pause/resume | Pause mirror, verify skip, resume | Script skips when paused |
| 18 | Outbox management | View, push, rename, delete via dashboard | All operations work |
| 19 | Approve request | Write a request to requests.txt, approve in dashboard | Folder added |
| 20 | Settings persist | Change settings, restart dashboard | Settings retained |

---

## OneDrive Files-On-Demand Note

If OneDrive is set to "Files On-Demand," cloud-only stub files won't sync correctly. Best solution: right-click each synced folder in OneDrive → "Always Keep on This Device." Do this once per folder.

Fallback (add to mirror scripts before rsync):

```bash
# Force-download cloud-only stubs
find "$SOURCE" -name "*.docx" -o -name "*.pdf" -o -name "*.xlsx" | while read -r F; do
  if xattr -p com.microsoft.OneDrive.DownloadState "$F" 2>/dev/null | grep -q "stub"; then
    cat "$F" > /dev/null 2>&1
  fi
done
sleep 5
```

---

## Files Created by This Build

| File | Location | Owner |
|---|---|---|
| `setup-folders.sh` | One-time run script | will |
| `setup-permissions.sh` | One-time run script | will |
| `config.json` | `/Users/openclaw1/sync-config/` | will |
| `firm-folders.txt` | `/Users/openclaw1/sync-config/` | will |
| `personal-folders.txt` | `/Users/openclaw1/sync-config/` | will |
| `requests.txt` | `/Users/openclaw1/sync-config/` | openclaw1 |
| `firm-mirror.sh` | `/Users/openclaw1/sync-config/scripts/` | will |
| `personal-mirror.sh` | `/Users/openclaw1/sync-config/scripts/` | will |
| `firm-outbox.sh` | `/Users/openclaw1/sync-config/scripts/` | will |
| `personal-outbox.sh` | `/Users/openclaw1/sync-config/scripts/` | will |
| `purge-versions.sh` | `/Users/openclaw1/sync-config/scripts/` | will |
| `purge-snapshots.sh` | `/Users/openclaw1/sync-config/scripts/` | will |
| `purge-outbox.sh` | `/Users/openclaw1/sync-config/scripts/` | will |
| 4× sync plists | `/Users/will/Library/LaunchAgents/` | will |
| 1× purge plist | `/Users/will/Library/LaunchAgents/` | will |
| 1× dashboard plist | `/Users/will/Library/LaunchAgents/` | will |
| Dashboard app | `/Users/will/.elnor-sync-dashboard/` | will |

---

## What Happens Next

After this build is complete and tested, switch to the `openclaw1` account and run DOC5B to build the file-sync skill that lets Elnor interact with this infrastructure.

---

**End of DOC5A.**