diff --git a/.claude/commands/syncro.md b/.claude/commands/syncro.md index 8edac1f2..8ada3b4d 100644 --- a/.claude/commands/syncro.md +++ b/.claude/commands/syncro.md @@ -613,6 +613,27 @@ COMMENT_ID=$(echo "$COMMENT_RESP" | jq -r '.comment.id') - Do NOT wrap the payload in `{"comment": {...}}` — returns 422. - **If `COMMENT_ID` is null:** GET `/tickets/{id}` and check `.ticket.comments[]` by subject before doing anything else. Comments cannot be deleted via API — duplicates require manual GUI removal. +#### Customer Assets (retire / archive — API GAP) + +**Retiring a device in Syncro = "Archive" (UI ONLY — no REST API).** Verified against +Syncro's own docs 2026-07-08 (`docs.syncrosecure.com/assets-rmm/archive-assets`): archiving +is done in the web UI only — asset Details page → **Actions → Archive**, or bulk via the +Assets table checkboxes → **Bulk Actions → Archive**. There is **no** archive endpoint and +**no** `archived` field on `PUT /customer_assets/{id}`. Do NOT try to archive via API, and +do NOT substitute `DELETE /customer_assets/{id}` — delete is destructive/irreversible and +loses history + breaks ticket linkages (Archive keeps the asset visible on its tickets/alerts). + +- **To retire assets: hand the user the asset list + the Bulk-Actions-→-Archive steps.** We + cannot do it for them via API. +- Asset READS are fine: `GET /customer_assets?customer_id=N[&query=]` (list, 100/page, + `.assets[]`) and `GET /customer_assets/{id}` (detail; `.asset` — RMM data lives under + `.asset.properties.kabuto_information`). `updated_at` is NOT a reliable "last online" (bulk + account touches move it) — use the ScreenConnect `GuestInfoUpdateTime` or kabuto data for + real last-seen. +- **[RESEARCH]** Re-check if Syncro ships an archive API (watch release notes). If/when it + does, wire it in here and add `--confirm` gating. Until then this is a "cannot be performed + via skill" function. (First hit: IMC retirement pass 2026-07-08.) + #### Customers ```bash diff --git a/.claude/scripts/ask-forum.sh b/.claude/scripts/ask-forum.sh index b4e2c9af..4957905a 100644 --- a/.claude/scripts/ask-forum.sh +++ b/.claude/scripts/ask-forum.sh @@ -57,6 +57,8 @@ if [ "${1:-}" = "--read" ]; then THREAD="${2:-}" LIMIT="${3:-25}" [ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --read [limit]" >&2; exit 1; } + printf '%s' "$LIMIT" | grep -qE '^[0-9]+$' || LIMIT=25 # Discord caps at 100 + [ "$LIMIT" -lt 1 ] && LIMIT=1; [ "$LIMIT" -gt 100 ] && LIMIT=100 TOKEN="$(resolve_token)" [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { echo "[ERROR] no Discord bot token" >&2; exit 2; } auth=(-H "Authorization: Bot ${TOKEN}" -H "User-Agent: ${UA}") @@ -86,15 +88,20 @@ if [ "${1:-}" = "--wait" ]; then TOKEN="$(resolve_token)" [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { echo "[ERROR] no Discord bot token" >&2; exit 2; } auth=(-H "Authorization: Bot ${TOKEN}" -H "User-Agent: ${UA}") - # baseline = id of newest message present right now - AFTER="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?limit=1" | jq -r '.[0].id // "0"' 2>/dev/null)" - [ -z "$AFTER" ] && AFTER="0" + # baseline: for a forum post the starter message id == the thread id, so + # after= returns every human reply after the starter -- this catches a + # reply that already landed BEFORE this --wait began (the old "newest message + # now" baseline missed it and hung to timeout). + AFTER="${THREAD}" echo "[INFO] waiting on thread ${THREAD} for a human reply (up to ${TIMEOUT}s)..." >&2 DEADLINE=$(( $(date +%s) + TIMEOUT )) while [ "$(date +%s)" -lt "$DEADLINE" ]; do - sleep "$POLL" MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?after=${AFTER}")" - printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1 || continue + if ! printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1; then + # thread gone -> bail fast; otherwise (transient/429) keep waiting + printf '%s' "$MSGS" | jq -e '.code==10003' >/dev/null 2>&1 && { echo "[ERROR] thread ${THREAD} no longer exists" >&2; exit 3; } + sleep "$POLL"; continue + fi HUMAN="$(printf '%s' "$MSGS" | jq -c 'reverse[] | select(.author.bot|not) | {u:.author.username, c:.content}')" if [ -n "$HUMAN" ]; then echo "[OK] answer received in thread ${THREAD}:" @@ -102,6 +109,7 @@ if [ "${1:-}" = "--wait" ]; then echo "THREAD_ID=${THREAD}" exit 0 fi + sleep "$POLL" done echo "[TIMEOUT] no human reply on thread ${THREAD} within ${TIMEOUT}s" >&2 echo "THREAD_ID=${THREAD}" >&2 @@ -149,7 +157,18 @@ fi auth=(-H "Authorization: Bot ${TOKEN}" -H "Content-Type: application/json" -H "User-Agent: ${UA}") # --- create the forum post (thread) with the question --- -BODY="$(jq -nc --arg name "$TITLE" --arg c "$CONTENT" '{name:$name, message:{content:$c}}')" +# Discord caps message content at 2000 chars. Keep the starter <=1900 and send any +# overflow as follow-up messages in the created thread (rare, but don't hard-fail). +LIMIT_CHARS=1900 +STARTER_CONTENT="$CONTENT" +OVERFLOW="" +if [ "${#CONTENT}" -gt "$LIMIT_CHARS" ]; then + STARTER_CONTENT="${CONTENT:0:$LIMIT_CHARS}" + nl="${STARTER_CONTENT%$'\n'*}" + [ "${#nl}" -lt "${#STARTER_CONTENT}" ] && [ "${#nl}" -gt 0 ] && STARTER_CONTENT="$nl" + OVERFLOW="${CONTENT:${#STARTER_CONTENT}}"; OVERFLOW="${OVERFLOW#$'\n'}" +fi +BODY="$(jq -nc --arg name "$TITLE" --arg c "$STARTER_CONTENT" '{name:$name, message:{content:$c}}')" RESP="$(printf '%s' "$BODY" | curl -s -m 20 -w $'\n%{http_code}' "${auth[@]}" \ -X POST "$API/channels/${FORUM_ID}/threads" --data-binary @-)" HTTP="$(printf '%s' "$RESP" | tail -n1)" @@ -165,17 +184,29 @@ if [ -z "$THREAD" ]; then echo "[ERROR] posted but no thread id in response: $(printf '%s' "$JSON" | head -c 200)" >&2 exit 3 fi +# --- post any overflow chunks as follow-up messages in the new thread --- +rest="$OVERFLOW" +while [ -n "$rest" ]; do + piece="${rest:0:$LIMIT_CHARS}" + nl="${piece%$'\n'*}"; [ "${#nl}" -lt "${#piece}" ] && [ "${#nl}" -gt 0 ] && piece="$nl" + printf '%s' "$(jq -nc --arg c "$piece" '{content:$c}')" | \ + curl -s -m 15 "${auth[@]}" -X POST "$API/channels/${THREAD}/messages" --data-binary @- >/dev/null 2>&1 + rest="${rest:${#piece}}"; rest="${rest#$'\n'}" +done + echo "[INFO] asked in #ct-forum (thread=${THREAD}) — waiting up to ${TIMEOUT}s for a human reply..." >&2 # --- block-poll the thread for the first NON-BOT reply --- -AFTER="${STARTER:-0}" +# starter message id == thread id for a forum post, so after= = all replies. +AFTER="${STARTER:-$THREAD}" DEADLINE=$(( $(date +%s) + TIMEOUT )) while [ "$(date +%s)" -lt "$DEADLINE" ]; do sleep "$POLL" - Q="after=${AFTER}" - [ "$AFTER" = "0" ] && Q="limit=50" - MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?${Q}")" - printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1 || continue + MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?after=${AFTER}")" + if ! printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1; then + printf '%s' "$MSGS" | jq -e '.code==10003' >/dev/null 2>&1 && { echo "[ERROR] thread ${THREAD} disappeared" >&2; exit 3; } + continue + fi # oldest-first; keep only human (non-bot) messages HUMAN="$(printf '%s' "$MSGS" | jq -c 'reverse[] | select(.author.bot|not) | {u:.author.username, c:.content, id:.id}')" if [ -n "$HUMAN" ]; then diff --git a/.claude/skills/ask-forum/SKILL.md b/.claude/skills/ask-forum/SKILL.md index b03b4a4f..d184f93d 100644 --- a/.claude/skills/ask-forum/SKILL.md +++ b/.claude/skills/ask-forum/SKILL.md @@ -77,8 +77,25 @@ replies — then you surface the answer to the user **unprompted**. See the bot explicitly allowed; Mike sees it as owner. To let someone else answer, they need **View Channel** on #ct-forum (grant a role like Techs, or the person). Winter/Rob are not in by default. -- **Deleting threads** needs the bot to have **Manage Threads** on #ct-forum (owner grants - it). Without it, `DELETE /channels/` returns 403. + +### Bot capability map — the limit of control (tested 2026-07-08) +What the bot can do in #ct-forum with its current permissions, and where the wall is: + +| Bot CAN (no grant needed) | Bot CANNOT (needs a permission grant) | +|---|---| +| Read messages / thread / members / guild channels | **Pin** a message → 403 (needs *Manage Messages*) | +| Create a forum post (thread), post follow-up messages | **Delete** a thread → 403 (needs *Manage Threads*) | +| Edit its OWN messages; add/remove reactions; typing | **Archive** a thread → 403 (needs *Manage Threads*) | +| Rename / lock a thread it owns (see trap below) | **Unlock** a thread once locked → 403 (needs *Manage Threads*) | + +- **One-way trap:** as thread owner the bot can *lock* a thread (200) but then **cannot + unlock or rename it** (403) — locking without *Manage Threads* is irreversible. So the + skill does NOT lock/rename threads; treat thread lifecycle as off-limits until granted. +- **To enable cleanup:** owner grants the ClaudeTools bot **Manage Threads** on #ct-forum + (Edit Channel → Permissions → bot → Manage Threads → Allow). Then delete/archive/unlock + work. **Manage Messages** additionally enables pin. +- Reactions + own-message edits DO work with no grant — usable for lightweight + acknowledgement (react to a reply, or edit the question to "[answered]") if ever wanted. ## Exit codes `0` answered · `1` usage · `2` no token · `3` Discord API error · `4` timeout (thread stays diff --git a/.claude/skills/datto-edr/SKILL.md b/.claude/skills/datto-edr/SKILL.md index e1d58870..f13ea50c 100644 --- a/.claude/skills/datto-edr/SKILL.md +++ b/.claude/skills/datto-edr/SKILL.md @@ -130,6 +130,16 @@ erroring `a value is required for '--url'`. Push via **GuruRMM** (`/rmm`) or any remote-exec channel. `RegKey` auto-approves + adds to the key's target group; without it the agent registers but awaits manual approval. +**Offline endpoint whose GuruRMM agent is down but ScreenConnect is up:** the one-liner +is a single self-contained command, so queue it via the `screenconnect` skill's +`send-command --session --command "powershell -NoProfile -ExecutionPolicy Bypass +-Command \"...Install-EDR...\"" --confirm` — SC holds it in the session's one command slot +and runs it on reconnect (verify by `agents --org` / `sweep` afterward; SC returns no +output). Used 2026-07-08 to stage REPAIRADMIN (IMC) while it was offline overnight. +**AV-swap ordering:** when replacing another AV (e.g. Bitdefender), install + verify EDR +live FIRST, then remove the old AV — never leave an unprotected window. Note Bitdefender's +API has NO uninstall (console-only); pull it via its local uninstall tool over `/rmm`. + ## Safety gating - Reads never mutate and run without confirmation. diff --git a/.claude/skills/screenconnect/SKILL.md b/.claude/skills/screenconnect/SKILL.md index f131c1fa..477eba7c 100644 --- a/.claude/skills/screenconnect/SKILL.md +++ b/.claude/skills/screenconnect/SKILL.md @@ -58,6 +58,37 @@ This is the headline use case - set a device for SC and have it land correctly: VERIFIED end-to-end on RMM-TEST-MACHINE 2026-06-22 (installed, self-tagged Company/Site/Tag, ran a command, re-tagged via set-properties). +### Deploying the GuruRMM agent via SC (verified 2026-07-08) + +Different from the SC-installer push above: to enroll a box that is in SC but NOT in GuruRMM, +`send-command` the server's own site-preconfigured one-liner. The site code is baked into the +downloaded signed binary (GRMM_CFG trailer) so it self-enrolls straight into that site — no +Staging, no reassign: + +```bash +# site_code from GET /api/sites//install-info (e.g. Main = INNER-BRIDGE-8354) +CMD='powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; iex (irm '\''https://rmm.azcomputerguru.com/install//windows'\'')"' +$SC send-command --session --command "$CMD" --confirm +``` + +The install script auto-relaunches into native 64-bit PowerShell (handles SC's 32-bit command +runner via Sysnative). Verified: DESKTOP-NFU17AJ enrolled into IMC → Main this way 2026-07-08. + +- **`send-command` returns `{}`** — SC gives no command output via the API (documented below). + It is queued, not confirmed. **Verify by enrollment**, not by the send response: poll + `rmm-search.sh -c ` for the new agent (pipe `--json` with `2>/dev/null` — the `[OK] + Authenticated` banner is on stderr; `2>&1 | jq` breaks jq). +- **Offline targets:** the one-liner is a single self-contained command, so it queues in the + session's one event slot and runs on reconnect. It will NOT run until the box is online. +- **Gotcha:** the install script calls `Get-CimInstance Win32_Processor`; a box with corrupt + WMI dies there (bit CP-QB). A box that black-holes outbound TLS installs but never connects + (bit IMC-PRINTSERVER) — SC still works because its relay is not TLS. +- **CP values are under `.CustomProperties.CustomPropertyN` / `.CustomPropertyValues[]`** on the + raw session object (CP1=Company, CP2=Site, ...). `--company "X"` matches CP1 **exactly**, so + pass the full tagged value (IMC's CP1 is `IMC - Instrumental Music Center`, not `Instrumental + Music Center`) or enumerate with `--like` / a raw `CustomProperty1 = '...'` filter first. + SC truncates session Name to 15 chars — `IMC-M-EDSERVICE` (SC) is `IMC-M-EdServices1` (RMM). + ## Method surface (probed live 2026-06-22) **Available (CLI-exposed):** diff --git a/errorlog.md b/errorlog.md index 4352620f..6f300c85 100644 --- a/errorlog.md +++ b/errorlog.md @@ -19,6 +19,12 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure · +2026-07-08 | Howard-Home | bash/background-task | [friction] added shell '& ... disown' on top of run_in_background:true -> shell forks the long-running wait and exits 0 immediately, orphaning it; the blocking read never notifies. Twice this session. Fix: with run_in_background:true, run the command in the FOREGROUND of that shell (no trailing &, no disown) - the harness does the backgrounding. [ctx: ref=discord ask-forum.sh --wait] + +2026-07-08 | Howard-Home | rmm/deploy-beast | [friction] cannot git-fetch Gitea submodule as SYSTEM via RMM: credential.helper=manager (GCM) fails with 'Must specify at least one OAuthAuthenticationModes' when no guru desktop session is logged on; new commit not fetchable, so remote SYSTEM deploy of the discord-bot is blocked. Restart-Service works as SYSTEM, but pull must run in guru context (interactive /sync on BEAST) first. [ctx: host=GURU-BEAST-ROG repo=acg-discord-bot] + +2026-07-08 | Howard-Home | syncro/assets | [correction] assumed 'retire in Syncro' meant DELETE /customer_assets; correct is Syncro's Archive feature (UI-only, no API) and never delete — delete loses history + breaks ticket links [ctx: client=IMC ref=syncro.md#customer-assets] + 2026-07-07 | Howard-Home | ps-encoded | encode produced empty output [ctx: src=scratch-recon.ps1] (x2) 2026-07-07 | Howard-Home | windows/scheduled-task | [friction] EDR Isolation Watcher task launched bash.exe directly (Hidden=False, every 10min) -> console window flashed on desktop; recurring class also hit GPS-RMM-Progress today. FIX: wscript VBS wrapper (style 0) for bash, pythonw+-Hidden for python; fixed both installers [ctx: ref=feedback_scheduled_task_no_console_flash host=Howard-Home task='ClaudeTools - EDR Isolation Watcher'] diff --git a/scratch-qbcopy.ps1 b/scratch-qbcopy.ps1 new file mode 100644 index 00000000..6aa9e721 --- /dev/null +++ b/scratch-qbcopy.ps1 @@ -0,0 +1,30 @@ +$ErrorActionPreference='Continue' +$src='C:\Share\Quickbooks' +$dst='\\TPS-SVR\Share\Quickbooks' +$qbw='schoonerQB2025.QBW' +$tlg='schoonerQB2025.QBW.TLG' + +"== stop QB hosting services (prevents re-lock during copy) ==" +foreach($s in 'QuickBooksDB34','QBCFMonitorService'){ Stop-Service $s -Force -ErrorAction SilentlyContinue; Start-Sleep 1; "$s -> $((Get-Service $s -ErrorAction SilentlyContinue).Status)" } +Start-Sleep 3 + +try { + "== confirm company file is unlocked ==" + try { $fs=[System.IO.File]::Open((Join-Path $src $qbw),'Open','Read','None'); $fs.Close(); "UNLOCKED - copying" } + catch { "STILL LOCKED: $($_.Exception.Message)" } + + "== copy clean company file + transaction log ==" + $out = robocopy $src $dst $qbw $tlg /COPY:DAT /R:2 /W:3 /NP /NJH + ($out | Select-String 'schoonerQB2025|Bytes :|ERROR|New File') | Out-String + "robocopy exit code: $LASTEXITCODE (0-7 = success)" +} +finally { + "== restart QB services (users resume on OLD server) ==" + foreach($s in 'QBCFMonitorService','QuickBooksDB34'){ Start-Service $s -ErrorAction SilentlyContinue; Start-Sleep 1; "$s -> $((Get-Service $s -ErrorAction SilentlyContinue).Status)" } +} + +"== verify copied file matches source ==" +$s=(Get-Item (Join-Path $src $qbw) -ErrorAction SilentlyContinue).Length +$d=(Get-Item (Join-Path $dst $qbw) -ErrorAction SilentlyContinue).Length +"SRC: $([math]::Round($s/1MB,1)) MB DST: $([math]::Round($d/1MB,1)) MB MATCH: $($s -eq $d -and $s -gt 0)" +"== DONE ==" diff --git a/scratch-qbwc.ps1 b/scratch-qbwc.ps1 new file mode 100644 index 00000000..e285121b --- /dev/null +++ b/scratch-qbwc.ps1 @@ -0,0 +1,41 @@ +$ErrorActionPreference='SilentlyContinue' + +"===== .QWC application files (define the connected app) =====" +$roots = @('C:\Share\Quickbooks','C:\ProgramData\Intuit','C:\Program Files\Common Files\Intuit','C:\Program Files (x86)\Common Files\Intuit','C:\Program Files (x86)\Intuit','C:\Program Files\Intuit') +$qwc = @() +# top-level of the QB data folder (where apps usually drop the .qwc) +$qwc += Get-ChildItem 'C:\Share\Quickbooks' -Filter *.qwc -ErrorAction SilentlyContinue +# recursive in the (small) profile + program locations +foreach($r in @('C:\Users','C:\ProgramData\Intuit','C:\Program Files\Common Files\Intuit','C:\Program Files (x86)\Common Files\Intuit')){ + if(Test-Path $r){ $qwc += Get-ChildItem $r -Recurse -Filter *.qwc -ErrorAction SilentlyContinue } +} +$qwc = $qwc | Sort-Object FullName -Unique +if(-not $qwc){ "(no .qwc files in searched locations)" } +foreach($f in $qwc){ + "### $($f.FullName) (modified $($f.LastWriteTime))" + try { [xml]$x = Get-Content $f.FullName -Raw + " AppName : $($x.QBWCXML.AppName)" + " AppURL : $($x.QBWCXML.AppURL)" + " AppSupport : $($x.QBWCXML.AppSupport)" + " AppDescription: $($x.QBWCXML.AppDescription)" + " UserName : $($x.QBWCXML.UserName)" + } catch { " raw:"; (Get-Content $f.FullName -Raw) } +} + +"`n===== Web Connector data dirs (per-user) =====" +Get-ChildItem 'C:\Users\*\AppData\Local\Intuit\QBWebConnector' -Directory -ErrorAction SilentlyContinue | ForEach-Object { + "-- $($_.FullName) --" + Get-ChildItem $_.FullName -Recurse -ErrorAction SilentlyContinue | Select-Object @{n='KB';e={[math]::Round($_.Length/1KB,1)}},FullName,LastWriteTime | Format-Table -Auto -Wrap | Out-String +} + +"`n===== Web Connector LOG - app names / URLs it talks to =====" +$logs = Get-ChildItem 'C:\Users\*\AppData\Local\Intuit\QBWebConnector','C:\ProgramData\Intuit\QBWebConnector' -Recurse -Include *.txt,*.log -ErrorAction SilentlyContinue +foreach($l in $logs){ + "### $($l.FullName)" + Select-String -Path $l.FullName -Pattern 'AppName|App Name|AppURL|Connecting|http' -ErrorAction SilentlyContinue | ForEach-Object { $_.Line.Trim() } | Sort-Object -Unique | Select-Object -Last 25 | Out-String +} + +"`n===== registry-registered Web Connector apps =====" +reg query "HKLM\SOFTWARE\Intuit\QBWebConnector" /s 2>$null +reg query "HKLM\SOFTWARE\WOW6432Node\Intuit\QBWebConnector" /s 2>$null +"===== DONE ====="