sync: auto-sync from HOWARD-HOME at 2026-07-07 20:31:16

Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 20:31:16
This commit is contained in:
2026-07-07 20:31:47 -07:00
parent 9340cdec17
commit a2a277ab67
8 changed files with 200 additions and 13 deletions

View File

@@ -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=<name>]` (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

View File

@@ -57,6 +57,8 @@ if [ "${1:-}" = "--read" ]; then
THREAD="${2:-}"
LIMIT="${3:-25}"
[ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --read <thread_id> [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=<thread> 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=<starter> = 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

View File

@@ -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/<thread>` 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

View File

@@ -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 <id> --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.

View File

@@ -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/<id>/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/<SITE_CODE>/windows'\'')"'
$SC send-command --session <sessionID> --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 <client>` 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):**

View File

@@ -19,6 +19,12 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
<!-- Append entries below this line -->
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']

30
scratch-qbcopy.ps1 Normal file
View File

@@ -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 =="

41
scratch-qbwc.ps1 Normal file
View File

@@ -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 ====="