Compare commits
288 Commits
27b9966dfa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ffa384e69 | |||
| 8f89667398 | |||
| aa12284fbf | |||
| 742bc5f81b | |||
| 9edb424869 | |||
| 772b6119d3 | |||
| 48871a0706 | |||
| f443eeafea | |||
| 10d14719c3 | |||
| 5cf263fc42 | |||
| 5076f8f4b1 | |||
| 8d2ca14cac | |||
| 3a7b6a1cc1 | |||
| 22c54f7307 | |||
| 56a8b9f2ab | |||
| b2fb8db956 | |||
| 089741b1db | |||
| 51b09dacbb | |||
|
|
7db991b037 | ||
|
|
e4639b419f | ||
|
|
eba471a61a | ||
| 7e1525711a | |||
| 05f115307c | |||
| 4e3cf71851 | |||
| 3bab0de9c3 | |||
| fd72c36f1e | |||
| 0e244a610f | |||
| 5aeabbcfb6 | |||
| 3846c35b81 | |||
| dff86c7d05 | |||
|
|
fbec6a0480 | ||
| 9bd3dd796b | |||
| 71aa0da646 | |||
| aae897abfe | |||
| 9dd241626f | |||
| 941de499c2 | |||
| ec2fe6060b | |||
| a6d8d8173f | |||
|
|
a9a17f426f | ||
| 088ffb7645 | |||
| 548ebb8a20 | |||
|
|
2f1af438d8 | ||
| 22503149ce | |||
| 4ab8137daa | |||
| 8b55e9645a | |||
| 0daecf471a | |||
| f617c507c4 | |||
| 4a246ff097 | |||
| cda32b53bb | |||
| f2f0fe0f70 | |||
| 7291c5e691 | |||
| 1ead30b6f1 | |||
| 794c46bff1 | |||
| 3770d52777 | |||
| 384f5967e9 | |||
| 285687d48d | |||
| c72048d4d7 | |||
| d8941c56df | |||
| fe91b4ecfc | |||
| 273eb150c8 | |||
| d1da1e5857 | |||
| 8afd7ec9b7 | |||
| 269449d27c | |||
| ae2ee346b5 | |||
| 1fa1a42092 | |||
| 555e1ffd5a | |||
| 12bb9f1544 | |||
| 996041406c | |||
| 1f56098652 | |||
| 46d2ae7a5b | |||
| 830a86ea95 | |||
| a0690793bc | |||
| 0fdeabde3d | |||
|
|
df46f56e59 | ||
|
|
299c053795 | ||
|
|
8350a4aeaf | ||
| 1041db8587 | |||
| ebeb0ec1f6 | |||
| ecc0c8cd82 | |||
|
|
07404759f3 | ||
|
|
3286a47090 | ||
| f95809cd9d | |||
| e78152a706 | |||
| 017dcdf04c | |||
| 2f35163866 | |||
| c34cdb3a39 | |||
| ec1cb40b62 | |||
| f9b44cfb68 | |||
| 1ca456b03f | |||
| 250eb8ecfe | |||
| 5641532429 | |||
| 1b0eeb5595 | |||
| b4972497a7 | |||
| a70ec28bdc | |||
| 7f8fb5d9c5 | |||
| d477970ab8 | |||
| 522594dedb | |||
| 06ed9ba562 | |||
| 25af453f61 | |||
| 736d43f482 | |||
| 033019df18 | |||
| bf95a10685 | |||
| 2fba62b7df | |||
| db10206aea | |||
| eb04287396 | |||
| ab987bf9e1 | |||
| d9f877661e | |||
| 5a463ae133 | |||
| 6942827391 | |||
|
|
07268cd741 | ||
| 57c23fc8d7 | |||
| 2e92a2d38d | |||
| 9926e5f2cc | |||
|
|
252d22048b | ||
| b3ea44a8ff | |||
| e765c9115d | |||
| cac5eadaee | |||
| 9c998a95d0 | |||
| 447b18d62b | |||
| a7b2fae468 | |||
| cc336243e3 | |||
| fa97bcc49b | |||
| d037d82f5e | |||
| c0d304485a | |||
| 25ca9306e1 | |||
| fcf43044d4 | |||
| 2a1ea6c9f7 | |||
| fc12c596f1 | |||
| eee469ce86 | |||
| 4e148b6dc5 | |||
| 19edfc45c0 | |||
| e548f63605 | |||
| 840b0ce9d6 | |||
| 09ab3de106 | |||
| 361478dd66 | |||
| 0a438929d0 | |||
|
|
f379a828e9 | ||
| a2a277ab67 | |||
| 9340cdec17 | |||
| 8def189a17 | |||
| 29725a3851 | |||
| 81398663ac | |||
| 2fd646218d | |||
|
|
30c20df2b9 | ||
| 5edab36df6 | |||
| 1952c36675 | |||
| 8b016edb9d | |||
| 5dfaaaa33b | |||
| 2259f4c172 | |||
| a507678254 | |||
|
|
1e1bae772d | ||
| 3d28f9ea5d | |||
|
|
a0a86742bc | ||
| 27595d6475 | |||
| 766c7fa54d | |||
| ff6546bee8 | |||
| 33b6140236 | |||
| efca405669 | |||
| f30413cffc | |||
| 5d53452c6d | |||
| 03d02128d7 | |||
| 2e262c619e | |||
| 8efc351e25 | |||
| ead30520e5 | |||
| 0daf06263f | |||
| 8854a584f3 | |||
| 5968ee9231 | |||
| da2aa60990 | |||
| b9635cafd9 | |||
| 45e1072dcb | |||
| 7264840da6 | |||
| 43101f4597 | |||
| e790d7c7e8 | |||
| 12c3d83f75 | |||
|
|
419de64af5 | ||
| ba2403b798 | |||
| 7dc519827c | |||
| 8f4984c5a6 | |||
| 0201e7422c | |||
| 0b11a400ed | |||
| 4b535a1a81 | |||
| c9ea7e8f01 | |||
| 27dc83f927 | |||
| b10f527cda | |||
| bd247191e6 | |||
| b20985d241 | |||
| 3cc9b22594 | |||
| 49d5a535a4 | |||
| 37a6598ef7 | |||
| a2a9356a02 | |||
| c3ad79d9fe | |||
| dfc237e6d4 | |||
| 14ac542a00 | |||
| c1706393b9 | |||
| e08f6c534e | |||
| f88c7d1739 | |||
| d5f7eadf82 | |||
| c289ba3370 | |||
| 60b307316c | |||
| 355c5f1e3f | |||
| d70cdd88e4 | |||
| faf796386a | |||
| 8b08ec64c2 | |||
| da6567ffe0 | |||
| 3d8aa2be59 | |||
| a8e76ba215 | |||
| 976bfe7957 | |||
| b0ebd44c9d | |||
| f274eed8fa | |||
| 5e682c9293 | |||
| bee140c020 | |||
| a737c4ab24 | |||
| fa4867580f | |||
| a5b8f6e7b1 | |||
| 6ad608c3bf | |||
| ea84b7bcd2 | |||
| b835f350d6 | |||
| 5e1ebf4789 | |||
| 656101a7b4 | |||
| 7c0f467cdf | |||
| 26b1cf400f | |||
| e359702837 | |||
| 67822d5ca8 | |||
| 346832b469 | |||
| a7174a894e | |||
| 3600df76cf | |||
| 9efa6eaf4f | |||
| 9542de2b0a | |||
| a0998fa255 | |||
| c8036d7e67 | |||
| 567fd8dd8c | |||
| 66211a5652 | |||
| 46705d48ff | |||
| e6dae3dba2 | |||
| 46d4900229 | |||
| 65b6043cde | |||
| 7a6fbcfc29 | |||
| 371fce1104 | |||
| 42d43a5bcc | |||
| a270f405a6 | |||
| a6a6a477d5 | |||
| b8bba3cd8f | |||
| 011ee7350d | |||
| 21b198f1ee | |||
| 6d36f7151c | |||
| edd4bd39e7 | |||
| ddd6bb06c2 | |||
| b97223f69e | |||
| f64418d5e1 | |||
| 2aed9d93c9 | |||
| cb99daa6cb | |||
| d7bc2b34ac | |||
| 734a7b201d | |||
| d4a1bbbc5b | |||
| a733db1029 | |||
| d5020a6415 | |||
| 0da31cbb65 | |||
| ff7025d912 | |||
| ad6a529ba4 | |||
| 60a0a5728f | |||
| 10b1ea7528 | |||
| 85c8149495 | |||
| a8ed995979 | |||
| ac05230cd2 | |||
| a1f0a3e5e8 | |||
| 4e739abe5f | |||
| 1ab0e88e67 | |||
| 0ca8e384d8 | |||
| fad05480a5 | |||
| e71a486db2 | |||
| 2e167c97fa | |||
| e2d902a337 | |||
| c18e74ed1c | |||
| fceb7c6075 | |||
| 442f3cb1c7 | |||
| f61088dac4 | |||
| 63e1eb743b | |||
| c82c1c76bb | |||
| e0e3dd0d82 | |||
| 78f794a924 | |||
| 41c12a934f | |||
| 727a0757f6 | |||
| 6cb90c5b09 | |||
| 8ecb406571 | |||
| 5e4b2cf385 | |||
| d745c7d40f | |||
| 7300f57a47 | |||
| 101b95e610 |
1
.agents.json
Normal file
1
.agents.json
Normal file
File diff suppressed because one or more lines are too long
36
.analyze-glaztech.py
Normal file
36
.analyze-glaztech.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
with open('/Users/azcomputerguru/ClaudeTools/.glaztech-sessions.json') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
print(f'Total machines: {len(data)}')
|
||||||
|
|
||||||
|
# Extract IPs and current sites
|
||||||
|
subnet_info = defaultdict(lambda: {'count': 0, 'machines': [], 'current_sites': set()})
|
||||||
|
|
||||||
|
for session in data:
|
||||||
|
ip = session.get('GuestInfo', {}).get('PrivateNetworkAddress', '')
|
||||||
|
name = session.get('Name', 'Unknown')
|
||||||
|
current_site = session.get('CustomProperties', {}).get('CustomProperty2', '')
|
||||||
|
|
||||||
|
if ip and ip != '':
|
||||||
|
# Extract /24 subnet
|
||||||
|
subnet = '.'.join(ip.split('.')[:3]) + '.0/24'
|
||||||
|
subnet_info[subnet]['count'] += 1
|
||||||
|
subnet_info[subnet]['machines'].append({'name': name, 'ip': ip})
|
||||||
|
if current_site:
|
||||||
|
subnet_info[subnet]['current_sites'].add(current_site)
|
||||||
|
|
||||||
|
# Sort by count descending
|
||||||
|
sorted_subnets = sorted(subnet_info.items(), key=lambda x: x[1]['count'], reverse=True)
|
||||||
|
|
||||||
|
print('\n=== Subnet Distribution ===')
|
||||||
|
for subnet, info in sorted_subnets:
|
||||||
|
sites_str = ', '.join(sorted(info['current_sites'])) if info['current_sites'] else 'No site tag'
|
||||||
|
print(f'{subnet:20s} {info["count"]:3d} machines Current tags: {sites_str}')
|
||||||
|
# Show first 3 machines as examples
|
||||||
|
for m in info['machines'][:3]:
|
||||||
|
print(f' - {m["name"]:30s} {m["ip"]}')
|
||||||
|
if len(info['machines']) > 3:
|
||||||
|
print(f' ... and {len(info["machines"]) - 3} more')
|
||||||
346
.audit/safe_to_delete.csv
Normal file
346
.audit/safe_to_delete.csv
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
class,company,hostname,endpoint_id,reason,evidence
|
||||||
|
DECOMMISSIONED,AT Trebesch,AT,5dd6d0e660e456225596d973,managed stale lastSeen 282d | Windows 7 Home Premium,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Arizona Computer Guru - Michael Swanson,ACG-HC,66ad7a8194d8d96dbbd317ff,managed stale lastSeen 706d | Windows Server 2012 R2 Standard,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Arizona Computer Guru - Michael Swanson,ACGVDS,675f08a6261f375674d73033,managed stale lastSeen 549d | Windows Server 2012 R2 Standard,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Arizona Computer Guru - Michael Swanson,DESKTOP-0O8A1RL,69ce91b1f6eea97ee1016e70,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Arizona Computer Guru - Michael Swanson,DESKTOP-BCRENMO,5d2e7459f3509009dd6b0bc4,unmanaged mtype=1 | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Arizona Computer Guru - Michael Swanson,GURU-BEAST-I9,66ad772128980e6e026398a5,managed stale lastSeen 612d | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Arizona Computer Guru - Michael Swanson,GURU-LEGION,60dca8aa04c8e42270fa4604,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Arizona Computer Guru - Michael Swanson,GURU-TEST,5e476ba49d5dfb49a5d227cd,managed stale lastSeen 2200d | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Arizona Computer Guru - Michael Swanson,GURUBOX2,5c42aa4287606605f6978872,managed stale lastSeen 2668d | Windows 10 Enterprise,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Arizona Computer Guru - Michael Swanson,LENOVHA,5eb9d18c1b250679c5717336,managed stale lastSeen 1961d | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Arizona Computer Guru - Michael Swanson,VIP,5cb95f67b5e538748bb3c708,unmanaged mtype=1 | Windows Server 2016 Datacenter,RMM absent; Datto no; no-live-BD
|
||||||
|
DUPLICATE,Arizona Computer Guru - Michael Swanson,ACGVDS,66c4d5b00b0f0ee09c418016,dup; keep 675f08a6261f375674d73033 (mgd) | this mgd Windows Server 2012 R2 Standard,RMM absent; Datto no; no-live-BD
|
||||||
|
DUPLICATE,Arizona Computer Guru - Michael Swanson,ACGVDS,66b15faff22e8a0befe670df,dup; keep 675f08a6261f375674d73033 (mgd) | this mgd Windows Server 2012 R2 Standard,RMM absent; Datto no; no-live-BD
|
||||||
|
MIGRATED,Arizona Medical Transit - Bertie Lozano,SERVER,5cb9075cf2c564744116f7c7,unmanaged mtype=2 | Windows Server 2008 R2 Enterprise,RMM 0d; Datto yes; BD-live-agent
|
||||||
|
DECOMMISSIONED,Associated Realty of the Americas - Carl Bosse,LAPTOP-PSIC3D3T,66cb8a029489d746d72c99d0,managed stale lastSeen 645d | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Baboquivari District - Idella Stanley,BAQ-DESKTOP0103,66670c297b424e8512864f8e,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Curtis Plumbing - Ken Curtis,CURTIS-003,5f7c92e504b0062a4b09d536,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Curtis Plumbing - Ken Curtis,CURTIS-005-W7,5da75781302bc04bc2d3c082,unmanaged mtype=1 | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DUPLICATE,Curtis Plumbing - Ken Curtis,CURTIS-001,66fab92596da30df7ef0d9b1,dup; keep 5dbc5f7bc6da513b2df125e3 (unmgd) | this unmgd Windows 10 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Curtis Plumbing - Ken Curtis,CURTIS-001,5dbc5f7bc6da513b2df125e3,unmanaged mtype=1 | Windows 7 Professional,RMM 0d; Datto yes; no-live-BD
|
||||||
|
DECOMMISSIONED,David Anaise,DROBO-OLD,5dc5ac37f4c40339b97ccbfe,unmanaged mtype=1 | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ALBCSR1,66731c113a4c2e615a0628b6,managed stale lastSeen 163d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ALBCUTTINGTABLE,659b2925ebbb1c432fac2426,managed stale lastSeen 910d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ALBMAINTENANCE,5fd129b686f0e37c182bc9b6,managed stale lastSeen 93d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ALBO14,614dcea1290cb4b9145b0432,managed stale lastSeen 1544d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ALBO7,5dcde10e8cf293433d6dd5cf,managed stale lastSeen 2430d | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ALBPERFECTCUT23,6944188314e9c7f6cb765f0d,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ALBSVR1,69657cbcf5b8e864c49c0213,unmanaged mtype=1 | Windows Server 2008 R2 Standard,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ALHOME,68ddbd34cfc93f575816fcd5,managed stale lastSeen 162d | Windows 11 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,BOICSR1,6665cf997740d8e5de318fdc,managed stale lastSeen 381d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,BOIFAB,66731c00c7a4f567685c5f1a,managed stale lastSeen 140d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,BOIFURNANCE,601c5d1c1084082cf07b0fda,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,BOILEIA,6665cf9a15b4e6468bae5541,managed stale lastSeen 646d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,BOINTAYLOR,6944187f7e28b2b991431268,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,BOISESVR,6941b72c6254f011aa26a5b3,unmanaged mtype=1 | Windows Server 2022 Standard,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,BOISHIPPING,601c5d2ce421af6446a70f87,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,BOISHIPPING1,694418818c399d0cb3038728,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,BOITRACER,694418824eb00e080ed15b75,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,BRLCSRMORGAN,6944188380301a004e83ee25,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,BRLMAINT,6944187e12b7fd777f9d1eb2,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,BRLOMROXY,694418836bbd28939f3ede85,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,BRLS3,601c5e15a01f6763f2ecf316,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,BRLSHIPPING,6944187f0a7faeceb913bef7,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,CHRISHOME,69fbb7087cc0382fe4551e11,managed stale lastSeen 65d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,CSMITHAR,69441881377e86115ebd7825,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,CUTTINGSANTABLE,69441882d69f3e9d75792826,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,DENS3,5cb783929be88a7320f60ae4,managed stale lastSeen 1355d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,DENWATERJET32,69441881a977c1e2255ed329,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,DESKTOP-CPMCF00,5ceeb1ab4159e84a4021dd3e,managed stale lastSeen 305d | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,DESKTOP-GOTG6ET,5cbb49aed1fef636029f48b1,managed stale lastSeen 1411d | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ELPCSR1,66731c16e89e6c61808777a1,managed stale lastSeen 254d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ELPO10,5cb4fa5a4442de68067d5fde,managed stale lastSeen 1912d | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ELPO7,5cb4fa5a4442de68067d5fe1,managed stale lastSeen 1935d | Windows 7 Home Premium,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ELPO8,6665cfacd84664e67332f51b,managed stale lastSeen 591d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ELPS6,5cb4fa5a4442de68067d5fe3,managed stale lastSeen 268d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ELPS9,6665cf8e6293cbce8b9ee8f5,managed stale lastSeen 267d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ELPSVR,6665cf2c1490b1ebecbd7e1a,unmanaged mtype=1 | Windows Server 2008 R2 Standard,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ELPSVR1,601c62e35c31a26c7e0ef518,managed stale lastSeen 1982d | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GLAZ-SEASTMAN,5cb740e71f1b8c2d5fe037d8,managed stale lastSeen 1067d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GLAZLAPTOP,69de9d0886ce7e6e2682d093,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GLAZOFFICE10,60c14365d46a268f48dea372,managed stale lastSeen 1724d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GLAZOFFICE11,5cb90e16ea51de212ed1fc84,managed stale lastSeen 1270d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTI-INV-SQL12,62c3f0b041b01242b7191d3c,managed stale lastSeen 289d | Windows Server 2008 R2 Standard,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIALBALBERT,66731c0a843694782b1223ae,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIALBANTONIO,6665cf8cbe52690e9ebc8f38,managed stale lastSeen 186d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIALBCSR1,6944188301e388a90b2c2af1,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIALBDEIDRA,6256db34133dc52457e3da9d,managed stale lastSeen 177d | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIALBFAB,5cd47bed16f09c67b7faf1b3,managed stale lastSeen 1766d | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIALBIAN,6665cf4dcdcec4b6a85ae3ec,managed stale lastSeen 175d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIALBO3,5cb3e76b4442de680673fb58,managed stale lastSeen 1736d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIALBS1,601194f6219d9a5bc2249f42,managed stale lastSeen 462d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIALBS7,60119506e74d6237ca1af38f,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIALBSIG,66731c11e8dc689013583b22,managed stale lastSeen 149d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBLRS1,601c5e4393b85d4e46af5823,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBLT2,5cb88c0223b35836c23199c0,managed stale lastSeen 1958d | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBO10,5cb52cf79be88a7320e4c3b2,managed stale lastSeen 1752d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBO13,6665cf4c696ccfb94f656063,managed stale lastSeen 458d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBO4,5cb52cf79be88a7320e4c3b4,managed stale lastSeen 1780d | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBOICSR2,69949c0b1d4e9a8356d29fa6,unmanaged mtype=1 | Windows,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBOIJAMIE,6944188310aa6562af960bad,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBOIJEFF,694418838d981de163ebd577,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBOIMAINTENAN,6944188363e2ce57977e4cb3,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBOIPM,694418836a7d56f6c53c743c,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBRLCSR,6665cfa0b560096618f4de69,managed stale lastSeen 345d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBRLCSR1,5cbde2de8eb04d361fb2c675,managed stale lastSeen 1375d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBRLCSR2,6667730cbf64d65ef9b15559,managed stale lastSeen 315d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBRLFAB,5cbf68c7ea51de212efbd281,managed stale lastSeen 268d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBRLFAB3,69441883a11bce397c3a8f94,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBRLKUMASI,68783432a5e3e4122c8f902e,managed stale lastSeen 141d | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBRLLTO2,6666ca7af079fa08a59beb46,managed stale lastSeen 277d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBRLO7,6665cf9ca888096dc3fa03c6,managed stale lastSeen 571d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBRLO8,66731c13436606b5dd4e94b8,managed stale lastSeen 473d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBRLS10,61cdbfa2c946c45788577e91,managed stale lastSeen 1047d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBRLS7,614c90c6931713b3125c0fff,managed stale lastSeen 1751d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIBS5,66ab98a8b474c9b783683e69,managed stale lastSeen 82d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDEN3,5cb783929be88a7320f60ae5,managed stale lastSeen 1424d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDENCSR1,6665cf911920e79808854261,managed stale lastSeen 235d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDENCSR2,66731c11bf53b9ec4841c88d,managed stale lastSeen 239d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDENCSR5,69441882dd21243a43131eb2,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDENGM,66731c0d4d99eaeee06e2f72,managed stale lastSeen 220d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDENO14,6476636fded92a5875e23511,managed stale lastSeen 192d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDENO4,5cb783929be88a7320f60ae6,managed stale lastSeen 1905d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDENO7,5cb783929be88a7320f60ae8,managed stale lastSeen 400d | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDENO8,5cb888394c838874dba3e25a,managed stale lastSeen 1822d | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDENS11,601c5f0a658fc163f8e48c86,managed stale lastSeen 1439d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDENS5,619e8c1e2df7ff18c88b97f5,managed stale lastSeen 1052d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDENS6,6944188166ed6a6b5b1b3edf,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDENS7,67902ba30e4c39831f2525fa,managed stale lastSeen 144d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDENS9,665e1ac1967c66b2a677d880,managed stale lastSeen 193d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIDENSD1,69441883b86a8415a554d74b,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIOFFICE35,5cdcb723724f211df04d366a,managed stale lastSeen 1864d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIPHXCSR1,66731c168e5a816a47d45f42,managed stale lastSeen 486d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIPHXCSR2,60be73181b8455e6cae91984,managed stale lastSeen 1513d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIPHXCSR3,66731c100b40cb69483106bd,managed stale lastSeen 232d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIPHXCSR4,66731c11373a33f2c06dd51c,managed stale lastSeen 213d | Windows 10 Pro Education,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIPHXFAB,6944188347940e4b1e01ef16,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIPHXO10,6665cf4d9168569359cfa97b,managed stale lastSeen 722d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIPHXO13,5cb5d66aba316028d97460e1,managed stale lastSeen 1428d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIPHXS12,69441881993486f154d87546,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIPHXS4,5cb5d66aba316028d97460e5,managed stale lastSeen 1404d | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIPHXS9,6632bed0f6b4d54268e6b28f,managed stale lastSeen 534d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIPHXSALES,66db5bec159494576db1facf,managed stale lastSeen 530d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTIPHXSHIPPING,601c625589e6cc68d4dac52d,managed stale lastSeen 715d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISANS10,66590f0b47afb2d3d86a2023,managed stale lastSeen 728d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISHOP2324,636acbdea0a011e1aaa9f286,managed stale lastSeen 905d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISHVSPACER,6944188414e9c7f6cb765f0f,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISLBLT02,5cb5fbea3debea680183bba4,managed stale lastSeen 1408d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISLC03,6665cf9a3477817a0309ef7e,managed stale lastSeen 189d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISLCCSR1,6665cf8cb30e91d541a0428e,managed stale lastSeen 641d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISLCCSR2,6944187ffe062c8c9daae191,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISLCCSR3,6665cf80cc2503a6dc657a73,managed stale lastSeen 556d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISLCINVENTORY,601c628a785e2823e1163523,managed stale lastSeen 199d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISLCS3,66731c03b514cb20134b0bfd,managed stale lastSeen 189d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISLCS4,66731c0910a0d1ae9eef5ff5,managed stale lastSeen 199d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISLO4,5ffcae213871092538a93704,unmanaged mtype=1 | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISLO5,6665cf7bf27c80d4b0f7e754,managed stale lastSeen 640d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISTCESAR,6665cf7c9c6bae29043837d4,managed stale lastSeen 254d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISTCSRO1,5cb4fa5a4442de68067d5fe7,managed stale lastSeen 1638d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISTCSRO2,66731c113f2529d8a7898362,managed stale lastSeen 252d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISTCSRO3,6665cf934c48966d46a2d3c8,managed stale lastSeen 567d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISTCSRO4,6665cf9268d2b063e5091c0c,managed stale lastSeen 473d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTISTIG,66731c43a36fdd0e2cac471f,managed stale lastSeen 246d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTITUC50-CSR,5cb8d026c1f9903613b4e959,managed stale lastSeen 1171d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTITUCO1,5cb8ae50ea51de212ecff733,managed stale lastSeen 1864d | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTITUCS10,5ce6d7b4b67a6365228af334,managed stale lastSeen 1775d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,GTITUCSPACER,5cb5d66aba316028d97460e3,managed stale lastSeen 184d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,HRALBLPT1,5e46fe2fe2363443dd73209f,managed stale lastSeen 1408d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,HRCLERK,6765bae08b5be0ec074434dc,managed stale lastSeen 520d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,HRSLCLPT,659b138f5ffd78adf5f25b9e,managed stale lastSeen 861d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ITSUPPORT,63222d8cf1005b58dd9de1eb,managed stale lastSeen 93d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ITTZELHR,66732ee96844ef8b32cb92e8,managed stale lastSeen 639d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,LOGISTICS,6679f1b3047a9744d355d413,managed stale lastSeen 556d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,MAS90SVR,63877eb52676f7765bb43de5,managed stale lastSeen 1318d | Windows Server 2012 R2 Datacenter,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,NICKS-LAPTOP,5f99ba775abc1d06245692d7,unmanaged mtype=1 | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,OFFICETUCSTEVE,6665cf75a5d80f290bd0e42f,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,PERFPHXBILLCO,6944187f009fcff23feb2651,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,PETES_LAPTOP,5d5dc8fdca42a426bac5a433,managed stale lastSeen 2398d | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,PFAZLOLLAHHOME,60d904f5f4c25dc9f7206db2,managed stale lastSeen 1319d | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,PHXDELL2,5cb5d59e2205cd67732df5c7,managed stale lastSeen 1838d | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,PHXLT2,5f3daf67311be85e8892ae99,managed stale lastSeen 1278d | Windows 8.1,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,PHXMAINT,66731c136fac40716e2bb832,managed stale lastSeen 232d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,PHXO10,5cb5d66aba316028d97460e7,managed stale lastSeen 1816d | Windows 7 Home Premium,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,PHXO9,5cb5ee45f16b7467c7fef774,managed stale lastSeen 1709d | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,PHXSALES,69442580ddfb7991d995fcda,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,ROBERTHOME,694711fe47019a9d7f83609d,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,SANCUTTINGTABLE,5ea1b115f2594a6edad0dfcc,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,SBS,6665cf9f1f9b3337f75866e3,unmanaged mtype=1 | Windows Server 2008 R2 Standard,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,SHOP11,6665cf83343ce1b15aacf4de,managed stale lastSeen 462d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,SHOPGTITUC,649db49395c1a87f34183c23,managed stale lastSeen 534d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,SHVFURNACE,69441883f8992ba2c41a79e1,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,SHVPERFECTCUT,69441883469466a26d03bf21,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,SHVPM,694418838002ffd7fb9cf1c7,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,SHVSHIPPING,69441883c88c68c22c653105,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,SLCLT1,5e78f8aee87b84574d4f90b8,managed stale lastSeen 1204d | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,STCSR1,66c657db363dc2e6c1688c72,managed stale lastSeen 149d | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,STEVEGHADMIN,69441880004ece09488c59d4,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,STSHIPPING,668c67d663266a84b59651a4,managed stale lastSeen 268d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,TGDC1,69441880eec0b7b4b0cade0c,unmanaged mtype=1 | Windows Server 2016 Standard,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,TOMAKK02-PC,694418a5d9c42ec04a0e7ec9,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,TOMAKK05-PC,6944188272d1cac82a52ead2,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,TOMLAPTOP,6027f54644c65b740f26f306,managed stale lastSeen 101d | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,TOMSKK06-PC,694418833619705af64388c2,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,TOM_OFFICE,66d4dae359eade85d92a5310,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,TUC-PM,6665cf932394521a53fedf6c,managed stale lastSeen 184d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,TUC-SALES,69441880f7df3360924365a7,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,TUCKRISTY,66731c07a19b8d3ec973c825,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,TUCS2,601c63ad4a174a4ab2cbf6d8,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,TUCS25,601c63c2f4cc952c713dc834,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,TUCSHIPPING,69ab421351c9104a5ed050f5,managed stale lastSeen 71d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,TUCTEMPERING,601c63e279ae996c77715e6c,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,TUCWATERJETGA,694429d91792c630324ca393,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,WIN-MLNNOHI46VA,6666f7956fb28de144bf01ba,managed stale lastSeen 241d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Glaz-Tech Industries - Steve Eastman,WIN11PRO16GI767,6973b42e16f15a4c834ef731,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DUPLICATE,Glaz-Tech Industries - Steve Eastman,BOIFAB,69a0e9f5d2e9a5f0b0499b75,dup; keep 66731c00c7a4f567685c5f1a (mgd) | this unmgd Windows,RMM absent; Datto no; no-live-BD
|
||||||
|
MIGRATED,Glaz-Tech Industries - Steve Eastman,WWW,66731c10fe374dc282a4f0b0,unmanaged mtype=1 | Windows Server 2019 Standard,RMM 0d; Datto yes; no-live-BD
|
||||||
|
DECOMMISSIONED,Grabb & Durando Law Office - Jeff Williams,GND-JEANNETTE,5d8b6481151c720930e32e83,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Grabb & Durando Law Office - Jeff Williams,GND-L-2,5e8217fe4ec7f03c794d6a13,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Grabb & Durando Law Office - Jeff Williams,GND-L-4,5f5acefbe8b45260ffd55098,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Grabb & Durando Law Office - Jeff Williams,GND-MARTHA,5d8b67355a8a566479b76998,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Grabb & Durando Law Office - Jeff Williams,GND-MELODY,5d8b673cb567a51b2104c370,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
MIGRATED,Grabb & Durando Law Office - Jeff Williams,GND-BOB-PC,66f6e0a0cb4cf07be5f86c0e,managed stale lastSeen no-posture | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Grabb & Durando Law Office - Jeff Williams,GND-JEFF-2,6a517f7bd7d0c0b92f858041,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
DECOMMISSIONED,Horseshoe Management - Bill Young,HSM-DONNA,5d676a255a34a14bfa927358,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Horseshoe Management - Bill Young,HSM-FRANK,5d676a5bc00bc54bf3108b8d,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Horseshoe Management - Bill Young,HSM-RANDI,666388621490b1ebecbd7d28,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Horseshoe Management - Bill Young,HSM-SURFACE,66fcdceecb79455ee9cd7296,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
MIGRATED,Horseshoe Management - Bill Young,HSM-BILL,66f6e02a85d9f362d35420ea,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Horseshoe Management - Bill Young,HSM-FRANK02,66635af3e7e9e7d2105acecc,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Horseshoe Management - Bill Young,HSM-NEWSERVER,627af44cab5f6082fea31005,unmanaged mtype=1 | Windows Server 2019 Essentials,RMM 0d; Datto yes; no-live-BD
|
||||||
|
DECOMMISSIONED,Instrumental Music Center - Leslie Faltin,IMC-M-EDSERVICE,6a4be5c9487e29403f73c193,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
MIGRATED,Instrumental Music Center - Leslie Faltin,C2B,6a487f5677b615646b11307d,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Instrumental Music Center - Leslie Faltin,DESKTOP-44L80C0,6a487f682dd91ce4845bb6c2,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Instrumental Music Center - Leslie Faltin,DESKTOP-GHG12G3,6a487f55220ff2f93ab7ccc1,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Instrumental Music Center - Leslie Faltin,DESKTOP-MR3ALTK,6a487f541933628dc5488870,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Instrumental Music Center - Leslie Faltin,IMC-L1-STATION9,6a487f55c88178e3f4aaf1cb,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Instrumental Music Center - Leslie Faltin,IMC-LESSONS,6a487f55ddc985cabdceae6a,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Instrumental Music Center - Leslie Faltin,IMC-LUIS,6a4bc78a4f8e1d54db50f73f,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Instrumental Music Center - Leslie Faltin,IMC-MINI,6a487f5640ffe040cccce0e6,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Instrumental Music Center - Leslie Faltin,IMC-STATION1,6a487f56b1c1d8ff52c05d29,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Instrumental Music Center - Leslie Faltin,IMC-STATION2,6a487f561abbaadb83f548a9,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Instrumental Music Center - Leslie Faltin,IMC-SVCSTR,6a487f55ab4696625a449034,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Instrumental Music Center - Leslie Faltin,IMC1,681290f6ad2aebf7b838bbde,unmanaged mtype=1 | Windows Server 2016 Standard,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Instrumental Music Center - Leslie Faltin,LAPTOP-DCHQ3F92,6a487f5634e7c7cb1256fd05,unmanaged mtype=1 | Windows 10 Home,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Jimmy Company - Jimmy Hughes,BLASTER2,666600421ea6cb6a5dac25ad,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
DECOMMISSIONED,Khalsa Montessori School - Michael Herrera,AD-JANICE-D,669813f875fcea56c7c3d3c5,managed stale lastSeen 529d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Khalsa Montessori School - Michael Herrera,CAM-STAFFROOM-D,667321e4098c23fb2a620f19,managed stale lastSeen 414d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Khalsa Montessori School - Michael Herrera,DESKTOP-9LT4ICC,666c55d14912423a2c8a56d1,managed stale lastSeen 342d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Khalsa Montessori School - Michael Herrera,DESKTOP-RKKR9KN,67c8b53c940a0a5ca7d052f6,managed stale lastSeen 468d | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Khalsa Montessori School - Michael Herrera,LAPTOP-NIBQP9LG,667eb0f1667aab6a5401d2dc,managed stale lastSeen 631d | Windows 11 Pro for Workstations,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Khalsa Montessori School - Michael Herrera,PRIME-RTG-PC,6665cf3b8348a5f6336e7c85,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Khalsa Montessori School - Michael Herrera,SALMON,667ec0638cad39fd5dd85cd5,unmanaged mtype=1 | Windows Server 2016 Standard,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Khalsa Montessori School - Michael Herrera,TROUT,667edc6da782682d14f64cbb,unmanaged mtype=1 | Windows Server 2016 Standard,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Len Swanson,LEN-PC,5c8d5c698baa2522897ee01d,managed stale lastSeen 2557d | Windows 7 Home Premium,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Len's Auto Brokerage - Becky Kahn,LAB-005246,65848253d3aea6ddcb23792a,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Len's Auto Brokerage - Becky Kahn,LAB-005248,5e45a44e7b367c215ddc3eea,managed stale lastSeen 1838d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Len's Auto Brokerage - Becky Kahn,LAB-005251,5e45a4d63d248c5d381da8b6,managed stale lastSeen 1500d | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Len's Auto Brokerage - Becky Kahn,LAB-005252,6463d32980fc8b5788251e32,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
MIGRATED,Len's Auto Brokerage - Becky Kahn,LAB-BECKY,6463d329bbc719578210eb8d,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
DECOMMISSIONED,MVAN Enterprises Inc - June Bradford,June’s MacBook Pro,654d6731d926169444537a02,unmanaged mtype=1 | macOS Sequoia 15.3.0,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,MVAN Enterprises Inc - June Bradford,MITCH-LAPTOP,6665e1b4d855cb5af2fa96d3,managed stale lastSeen 761d | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",BCW-20BQK97,666b3fc3d714274b452e9500,unmanaged mtype=1 | Windows 11 Pro for Workstations,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",BWA-IK12323,666b40617a4642e103a8c8dd,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",DISC-GBT6BP1,666b41ca9a6a4778698da00b,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",DMS-5X8GVD3,666b3eacad64464053d8e6f9,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",HWM-56V84Z2,666b40938a5a8bf55bf3c521,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",JMH-FLSDXQ1,666b3f757cf38ef20042a7a7,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",JPL-B1W39Y1,666b3f89c3928c83d26534d6,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",KCP-FG3G842,666b3f17d77745c974f4bec0,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",KSB-JFLTB82,666b3f1602063d43bb4e6366,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",LIM-614MVD3,666b3f94475a8113dc7e47f4,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",LNW-7GLTB82,666b3f60d77745c974f4bec3,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",MACH-GM2Y8P1,666b424da9ba473659422e54,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",NJN-2W73T12,666b405c436606b5dd4e8b4d,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",PRO-FMM7GZ1,666b3f8eeea213874724bc52,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",PRW-62C7X04,666b3f8f51d3ad8d8d3b53b5,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",RW-DH83T12,666b3f734400652fc3935158,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",SHIP-9PJPWP3,666b3f369f201a1660c99145,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",SLW-183CQW1,666b527c436606b5dd4e8b68,managed stale lastSeen 452d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",TDM-DKK5KS1,666b40a7d26e9e2fc97338b6,managed stale lastSeen 567d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",TDM-FG3G842,66959b178d9a59668d563074,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"Midway Industries, Inc. - Bryan Wilson",TWB-8H73T12,666b408148250c6c4dc18bda,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
MIGRATED,Mineralogical Record - Tom Gressman,DESKTOP-C84J8OS,691619a537206be5436830a1,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
DECOMMISSIONED,"PUTT LAND SURVEYING, INC. - David Putt",PLS-FOUR,6690122ea2701cd0495cb42d,managed stale lastSeen 416d | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"PUTT LAND SURVEYING, INC. - David Putt",PLS-FRONT,67003607182f0f03a7eee78c,managed stale lastSeen 199d | Windows 11 Pro for Workstations,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"PUTT LAND SURVEYING, INC. - David Putt",PLS-LAPTOP,669013c42f46c7e0dbdfad80,managed stale lastSeen 528d | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,"PUTT LAND SURVEYING, INC. - David Putt",PLS-ONE,6690264d056706a244f11dd8,managed stale lastSeen 567d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
MIGRATED,Quantum Wealth Management - Sheila Peress,QUANTUMSERVER,6665cf20dd4cd55b318c511a,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
DECOMMISSIONED,Reliant Well Drilling and Pump Corporate - Channa V,DESKTOP-09H7T66,67228367cbe6b4c2bc288d69,managed stale lastSeen 199d | Windows 11 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Reliant Well Drilling and Pump Corporate - Channa V,DESKTOP-V2247T9,672262a52162eaefe712e706,managed stale lastSeen 380d | Windows 11 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Reliant Well Drilling and Pump Corporate - Channa V,FWSERVER,5c6f14666850d34c5aba5913,managed stale lastSeen 1704d | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Reliant Well Drilling and Pump Corporate - Channa V,FWW-ERIKALAPTOP,62dab5eeea4692a14a255e7d,unmanaged mtype=1 | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Reliant Well Drilling and Pump Corporate - Channa V,LAPTOP-EJL9PFOU,67f825d6a2cf678ddb7fe2b6,managed stale lastSeen 372d | Windows 11 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Reliant Well Drilling and Pump Corporate - Channa V,LAPTOP-FR4QN9KF,67f825b9adbda4a403c2d692,managed stale lastSeen 372d | Windows 11 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Reliant Well Drilling and Pump Corporate - Channa V,LAPTOP-JQMU8N9K,67f825c1954153156bfb1c68,managed stale lastSeen 372d | Windows 11 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Reliant Well Drilling and Pump Corporate - Channa V,RELIANT-L01,68923903dfab0192d9ca8cb7,managed stale lastSeen 299d | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Reliant Well Drilling and Pump Corporate - Channa V,RELIANT-L02,689239143ef4423d63b163d8,managed stale lastSeen 339d | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Reliant Well Drilling and Pump Corporate - Channa V,RELIANT-L03,689238d65268b36f707e1443,managed stale lastSeen 339d | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Reliant Well Drilling and Pump Corporate - Channa V,RWD-ALANLAPTOP,67226e3116029df6801d1a85,managed stale lastSeen 133d | Windows 11 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Reliant Well Drilling and Pump Corporate - Channa V,WILCOXADMIN,67101a81c3d6dd4666e09551,managed stale lastSeen 596d | Windows 8.1,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Russo Law Firm - Shannon Trionfo,RRSAD,6294eca9bc14cc060b47df4c,unmanaged mtype=1 | Windows Server 2019 Standard,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Russo Law Firm - Shannon Trionfo,RUSSOADMIN,60f639b001f0c2896cbc023b,unmanaged mtype=1 | Windows Server 2008 R2 Standard,RMM absent; Datto no; no-live-BD
|
||||||
|
DUPLICATE,Russo Law Firm - Shannon Trionfo,STRIONFO,660c5e1bad9b30fdbc120012,dup; keep 67290a8e932f963c6b82d29a (mgd) | this unmgd Windows 10 Pro,RMM 0d; Datto yes; BD-live-agent
|
||||||
|
DECOMMISSIONED,Safesite LLC - Tim Story,1122-SUZANNE-DE,6932e9ac8765ee735b0d8b8d,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Safesite LLC - Tim Story,1123DELL3540-2,69323aaccf244c5062a29338,unmanaged mtype=1 | Windows 11 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
MIGRATED,Safesite LLC - Tim Story,0122-DENIM-DELL,6932e975b71a6a52a89e0043,unmanaged mtype=1 | Windows 11 Home,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Safesite LLC - Tim Story,0524-DELLG16,6932ee6332646df3c0243aaf,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Safesite LLC - Tim Story,0525-ASUSFX707Z,693253fd9b6c8af9f094e8aa,unmanaged mtype=1 | Windows 11 Home,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Safesite LLC - Tim Story,0621-DELL3502,6932d7adf3bfca192e2f2b0d,unmanaged mtype=1 | Windows 11 Pro for Workstations,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Safesite LLC - Tim Story,0624DELL3550,6932dfe12b64a9bfd3c83a6a,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Safesite LLC - Tim Story,0923-DELL616,6932f26ce03c1570408e6da7,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Safesite LLC - Tim Story,SAFESITE-LT04,693342ee84f072623b49ebec,unmanaged mtype=1 | Windows 11 Home,RMM 2d; Datto yes; no-live-BD
|
||||||
|
DECOMMISSIONED,Saguaro Conveyor Equipment - Rick Ostrop,DESKTOP-DGM4C1T,66fabad76751f1db6125803b,managed stale lastSeen 303d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Saguaro Conveyor Equipment - Rick Ostrop,DESKTOP-L0U6QUN,67290b3b9516187d08294c2d,managed stale lastSeen 303d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Saguaro Conveyor Equipment - Rick Ostrop,RICKOSTROP-PC,6704d83114f1c3dd2c7983b7,managed stale lastSeen 303d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sells District - Tohono O'odham Nation - Delmarie Pan,DESKTOP-9204DI5,69dd41be340d40b4c8073064,unmanaged mtype=1 | Windows,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sells District - Tohono O'odham Nation - Delmarie Pan,SD-DT-60JJ,6144e71bf2cfcb1a4a2cef14,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sells District - Tohono O'odham Nation - Delmarie Pan,SD-DT-868J,6145022429c59ab2a1a5a586,unmanaged mtype=1 | Windows 10 Pro for Workstations,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sells District - Tohono O'odham Nation - Delmarie Pan,SD-DT-BGZH,6144e72a815369c338c13dd1,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sells District - Tohono O'odham Nation - Delmarie Pan,SD-DT-BGZS,6144e7336518d2e3c8fdc2c8,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sells District - Tohono O'odham Nation - Delmarie Pan,SD-DT-BWY2,69dd41be340d40b4c8073061,unmanaged mtype=1 | Windows,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sells District - Tohono O'odham Nation - Delmarie Pan,SD-DT-GCX1,69dd41be340d40b4c8073065,unmanaged mtype=1 | Windows,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sells District - Tohono O'odham Nation - Delmarie Pan,SD-DT-LHKQ,69dd41be340d40b4c8073063,unmanaged mtype=1 | Windows,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sells District - Tohono O'odham Nation - Delmarie Pan,SD-DT-XN22,69dd41be340d40b4c8073062,unmanaged mtype=1 | Windows,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sells District - Tohono O'odham Nation - Delmarie Pan,SD-LT-4WMN,6144e7585bcc1d3b54a9282d,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sells District - Tohono O'odham Nation - Delmarie Pan,SD-LT-9JPN,6146664f598f9470676baf5d,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sells District - Tohono O'odham Nation - Delmarie Pan,SD-LT-CWPN,6146378f11163bd471d87b35,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sells District - Tohono O'odham Nation - Delmarie Pan,SDO-CHAIRPERSON,5d55723a0b2c750908be3f9b,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sells District - Tohono O'odham Nation - Delmarie Pan,SELLS-SHELLSTOR,6144e77053b3b5c433dc0c87,unmanaged mtype=1 | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sif-oidak District - Tohono O'odham Nation - Alex Cr,SIF-FJUAN,61b21623275b90362889bbd3,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sif-oidak District - Tohono O'odham Nation - Alex Cr,SIF-L-01,62b0f15d0c41724c800f5cf4,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sif-oidak District - Tohono O'odham Nation - Alex Cr,SIF-L-02,61b1625cfcaf19929d5617b5,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sif-oidak District - Tohono O'odham Nation - Alex Cr,SIF-L-03,65f0db94af36077f61bee8bf,managed stale lastSeen 716d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sif-oidak District - Tohono O'odham Nation - Alex Cr,SIF-L-13,6584c7c3f5717670a9970390,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sif-oidak District - Tohono O'odham Nation - Alex Cr,SIF-L-14,62b634e734e19a5493f9e307,managed stale lastSeen 722d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sif-oidak District - Tohono O'odham Nation - Alex Cr,SIF-L-15,615e51e47d5a6346e1504427,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sif-oidak District - Tohono O'odham Nation - Alex Cr,SIF-L-18,65f0ce2b50d2181f62a600c5,managed stale lastSeen 722d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sif-oidak District - Tohono O'odham Nation - Alex Cr,SIF-L-19,632a40031c5fbf2393bde427,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sif-oidak District - Tohono O'odham Nation - Alex Cr,SIF-L-9,62b3bf68a1574dc358791717,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sif-oidak District - Tohono O'odham Nation - Alex Cr,SIF-RWOOD,615f0bf046e7be8b63e5febd,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Sif-oidak District - Tohono O'odham Nation - Alex Cr,SIF-TREASURER-D,615e4da8023769c17b584783,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Stamback Septic - Joe Schmuker,SSS-SPARE,6665cf3cc910b9822d07395e,managed stale lastSeen 393d | Windows 11 Pro for Workstations,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,SunDanzer Refrigeration - David Bergeron,ENGR-COMPUTER,67bf5439db2464cade8b4e97,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,The Prairie Schooner - Tina Coalter,DESKTOP-SRUOH4R,66fab14a3164148712d02dcf,managed stale lastSeen 316d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,The Prairie Schooner - Tina Coalter,JAYMINEWDELL,5d9639845c1950227c339213,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
MIGRATED,The Prairie Schooner - Tina Coalter,TPS-SERVER,665900e22a5978810642f297,unmanaged mtype=1 | Windows Server 2016 Essentials,RMM 0d; Datto yes; no-live-BD
|
||||||
|
DUPLICATE,Tom Sorensen,TOM-WIN7,5ebd66174c004c2ddc701ebd,dup; keep 66fabc590627e2af49646a95 (mgd) | this unmgd Windows 10 Pro,RMM 0d; Datto yes; BD-live-agent
|
||||||
|
DECOMMISSIONED,Tucson Mountain Motors - Pete Jacoby,TMM-FRONT,5c92c1d797a6682e089c71e0,unmanaged mtype=1 | Windows 7 Home Premium,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Tucson Safety and Medical - Gerald Long,DESKTOP-AJT3O8I,5e19c4bef6686c3db8a55478,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Tucson Safety and Medical - Gerald Long,POS1-PC,5e190c90cd272c67f77f93fd,managed stale lastSeen 2212d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Tucson Safety and Medical - Gerald Long,TSAM-DRIVER2,670da21f48088defd39eafdb,managed stale lastSeen 284d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Tucson Safety and Medical - Gerald Long,TSAM-JERRY,68acabd09b5e43255082a6d5,unmanaged mtype=1 | Windows 10 Pro for Workstations,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Tucson Safety and Medical - Gerald Long,TSAM-KAYTIE,68acabcfc3b0e1613a13fa4e,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Tucson Safety and Medical - Gerald Long,TSAM-SERVER,6665cf2762e4cb63e90285cf,unmanaged mtype=1 | Windows Server 2016 Essentials,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Tucson Safety and Medical - Gerald Long,TSM-FRONTDESK,670c204cd12823c4ad1aa76b,managed stale lastSeen 408d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Tucson Safety and Medical - Gerald Long,TUCSONSAFETY-LT,5e1243aa1f985c21c3d81aa4,managed stale lastSeen 2097d | Windows 7 Professional,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Universal Cryogenics - Richard Schickling,AARDMAN,67361977fa2612fcc78649df,managed stale lastSeen 513d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Universal Cryogenics - Richard Schickling,GARFIELD,666a0e29eadf86115042969e,managed stale lastSeen 231d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Universal Cryogenics - Richard Schickling,SCOOBY,66673da0a711ab8d2ebd39e5,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Universal Cryogenics - Richard Schickling,SNOOP,63594bf032d88ee20e0f5ca4,managed stale lastSeen 1352d | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Yvonne Tedards,BILL,5d20fe4c32551b7ecbd20b21,unmanaged mtype=1 | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Yvonne Tedards,DESKTOP-SUFJR0J,5d20ffe95967865bb2c7f194,unmanaged mtype=1 | Windows 10 Home,RMM absent; Datto no; no-live-BD
|
||||||
|
DECOMMISSIONED,Yvonne Tedards,TEDARDSLAPTOP,5e7a5ac10d4d5e5dc8e47742,unmanaged mtype=1 | Windows 10 Pro,RMM absent; Datto no; no-live-BD
|
||||||
|
DUPLICATE,Zeus Nestora - Cindy White,CINDYW,6665cf3e963d87e89ca1f311,dup; keep 5e63f85708f6f833d2be52e0 (unmgd) | this unmgd Windows 10 Home,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Zeus Nestora - Cindy White,CINDYW,5e63f85708f6f833d2be52e0,unmanaged mtype=1 | Windows 7 Home Premium,RMM 0d; Datto yes; no-live-BD
|
||||||
|
MIGRATED,Zeus Nestora - Cindy White,SERVER,66425bba4bda5fb351ea0091,unmanaged mtype=1 | Windows Server 2016 Essentials,RMM 0d; Datto yes; BD-live-agent
|
||||||
|
4
.audit/uncertain.csv
Normal file
4
.audit/uncertain.csv
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
company,hostname,endpoint_id,reason,evidence
|
||||||
|
Safesite LLC - Tim Story,0124-DELL3540,6936d62a85cfdaad1acd6b5a,managed stale lastSeen 80d | Windows 11 Pro,RMM absent; Datto yes; no-live-BD
|
||||||
|
Safesite LLC - Tim Story,0323-LENOVO-NIC,6932f160a6b469cdd086c074,unmanaged mtype=1 | Windows 11 Pro,RMM 17d; Datto yes; no-live-BD
|
||||||
|
Tucson Safety and Medical - Gerald Long,OFFICE1,5e137b0ad161a869f1785dab,unmanaged mtype=1 | Windows 7 Professional,RMM absent; Datto yes; no-live-BD
|
||||||
|
47
.audit/unprotected_gap.csv
Normal file
47
.audit/unprotected_gap.csv
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
company,hostname,endpoint_id,reason,evidence
|
||||||
|
Curtis Plumbing - Ken Curtis,DESKTOP-1ORLAQ1,66fac36cbe6e24d604885121,unmanaged mtype=1 | Windows 11 Pro for Workstations,RMM 0d; Datto no; no-live-BD
|
||||||
|
Curtis Plumbing - Ken Curtis,SRV02,5dcdd5bb2d95236828a0c659,unmanaged mtype=1 | Windows Server 2008 R2 Standard,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,ALBFURNANCE,69441880c8c72a20a1641704,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,ALBSHIPPING,69441880bd56e52a331456fb,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,BOIPATRICK,66731c114a0c063dec1791f4,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,BRLKUMASI,69441890fe062c8c9daae194,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,DENPERFECTCUT,69441888dd21243a43131eb4,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,GTI-FINANCESVR,646811189bf31557bf3bf807,unmanaged mtype=1 | Windows Server 2022 Standard Evaluation,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,GTI-TUC-VM,66731c111bf6653667c2f968,unmanaged mtype=1 | Windows Server 2012 R2 Standard,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,GTIALBLINDAOM,6944187f009463b1c08dcc86,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,GTIBRLIG,69441883ee5579c8fb6ad1ac,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,GTIDENSDOOR,66731c06cc38d43d4ef92760,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,GTIGLAZSTEVE,6944188018a4e7959792e084,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,GTIPHXSM,69441883f8b8209b6c253774,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,GTISLCALUM,694418842801b3e87a5f1ba4,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,LEIAGMBOI,6944187dc7c91272245cacdf,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,LOGISTICS1,6944419b87fec01d63fd162d,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,OFFICEGLAZPRO,69441883b9633c9729c41b1d,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,PEBBLES,694418819c69d194cdcbfefe,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,PERFECTCUTBRL,69441882b6b2d1b702f66049,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,PHXCSR1,69441880a977c1e2255ed327,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,PHXDIANA,69441884ee5579c8fb6ad1ae,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,PHXTRACER,6944187e42a684e559b80653,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,SANCSR1,69441880ea7287f8e67b7736,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,SANFURNACE2,69441884ae9615383a448af2,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,SANSHOP1,6944188290517a16a4b97d34,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,SANSHOWERD,6944187f6c4b6de750744514,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,SHVERIN,69441883bb339ec4331fd732,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,SHVWASHER,69441883abb02940255fd416,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,STEVEHOMEPC,6944187f61d497d416fce5e3,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,STEVEMLAPTOP,69443b5adc4c18389626274c,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,SUEHOMEW11,6944187e64f9a23fd1cdffec,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,TOMHOME1,694422debfa97ea38078b3ea,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,TUCCLERK1,69441882cf55df39f96452b2,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,TUCPERFECTTABLE,6944188280bc46b1234c0685,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,TUCTRACER,694426d1c2567b39fe785c15,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Glaz-Tech Industries - Steve Eastman,W11I78716G21225,699f676581bc6dd53e315a78,unmanaged mtype=1 | Windows 11 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Grabb & Durando Law Office - Jeff Williams,GND-L-1,6a517f7990ebd4fe7c130c69,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Len's Auto Brokerage - Becky Kahn,LAB-MADONNA,6463d325c392e3c8ea4ebc60,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Sif-oidak District - Tohono O'odham Nation - Alex Cr,SIF-SERVER2,615e4de15567dc4ca397a8a2,unmanaged mtype=1 | Windows Server 2016 Standard,RMM 0d; Datto no; no-live-BD
|
||||||
|
Universal Cryogenics - Richard Schickling,DESKTOP-PMML1JC,666619bb98bc7f4740c92164,unmanaged mtype=1 | Windows 10 Pro,RMM 7d; Datto no; no-live-BD
|
||||||
|
Universal Cryogenics - Richard Schickling,GROMIT,6665cf3df2bcdb4df67cc655,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Universal Cryogenics - Richard Schickling,HOBBES,6665cf3c83ff4404e082bf5e,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Universal Cryogenics - Richard Schickling,PLUTO,63596b68616a033cea3bddea,managed stale lastSeen 1289d | Windows 10 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
Universal Cryogenics - Richard Schickling,UC2-SERVER,5df93532bff7d24b1911a3d1,unmanaged mtype=2 | Windows Server 2012 R2 Essentials,RMM 4d; Datto no; no-live-BD
|
||||||
|
Zeus Nestora - Cindy White,ZEUS-ACCOUNTING,6667178102b261593a4569ab,unmanaged mtype=1 | Windows 10 Pro,RMM 0d; Datto no; no-live-BD
|
||||||
|
1
.bb-tok
Normal file
1
.bb-tok
Normal file
@@ -0,0 +1 @@
|
|||||||
|
eyJ0eXAiOiJKV1QiLCJub25jZSI6IlNzRUhBcVJSVzVzOFYwTzBaVVBUdnZGZjRnVXl2YmRvNjFiVklhYjJNbDgiLCJhbGciOiJSUzI1NiIsIng1dCI6ImFGa21LVkZjLTRXVjZzWENCdk5aa1hJNTA1WSIsImtpZCI6ImFGa21LVkZjLTRXVjZzWENCdk5aa1hJNTA1WSJ9.eyJhdWQiOiJodHRwczovL2dyYXBoLm1pY3Jvc29mdC5jb20iLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC8xOWE1NjhlOC05ZTg4LTQxM2ItOTM0MS1jYmMyMjRiMzkxNDUvIiwiaWF0IjoxNzgzMDQ4NDQwLCJuYmYiOjE3ODMwNDg0NDAsImV4cCI6MTc4MzA1MjM0MCwiYWNycyI6WyJwZmRyIl0sImFpbyI6ImsyRmdZQWhtcnMxdW5yZlo2TmVTNUxmbDNzNkZNNXA0YnNoN1RuNGhmc1hJZkhWYjkzMEEiLCJhcHBfZGlzcGxheW5hbWUiOiJDb21wdXRlckd1cnUgLSBUZW5hbnQgQWRtaW4iLCJhcHBpZCI6IjcwOWU2ZWVkLTA3MTEtNDg3NS05YzQ0LTJkMzUxOGM0NzA2MyIsImFwcGlkYWNyIjoiMiIsImlkcCI6Imh0dHBzOi8vc3RzLndpbmRvd3MubmV0LzE5YTU2OGU4LTllODgtNDEzYi05MzQxLWNiYzIyNGIzOTE0NS8iLCJpZHR5cCI6ImFwcCIsIm9pZCI6IjdhMTk5YjExLTk3ZmItNGU2NS05MTdkLWY4ZDI5YTUzYmE0OSIsInJoIjoiMS5BVVlBNkdpbEdZaWVPMEdUUWN2Q0pMT1JSUU1BQUFBQUFBQUF3QUFBQUFBQUFBQUFBQUJHQUEuIiwicm9sZXMiOlsiUG9saWN5LlJlYWRXcml0ZS5Db25kaXRpb25hbEFjY2VzcyIsIlVzZXIuUmVhZFdyaXRlLkFsbCIsIlNlY3VyaXR5RXZlbnRzLlJlYWQuQWxsIiwiVXNlckF1dGhlbnRpY2F0aW9uTWV0aG9kLlJlYWRXcml0ZS5BbGwiLCJBcHBsaWNhdGlvbi5SZWFkV3JpdGUuQWxsIiwiRGlyZWN0b3J5LlJlYWRXcml0ZS5BbGwiLCJTaXRlcy5SZWFkV3JpdGUuQWxsIiwiQXBwUm9sZUFzc2lnbm1lbnQuUmVhZFdyaXRlLkFsbCIsIlJvbGVNYW5hZ2VtZW50LlJlYWRXcml0ZS5EaXJlY3RvcnkiLCJQb2xpY3kuUmVhZC5BbGwiLCJTaXRlcy5GdWxsQ29udHJvbC5BbGwiXSwic3ViIjoiN2ExOTliMTEtOTdmYi00ZTY1LTkxN2QtZjhkMjlhNTNiYTQ5IiwidGVuYW50X3JlZ2lvbl9zY29wZSI6Ik5BIiwidGlkIjoiMTlhNTY4ZTgtOWU4OC00MTNiLTkzNDEtY2JjMjI0YjM5MTQ1IiwidXRpIjoiRFE2UE4wWlRqMEdSMDhWeEhhUWJBQSIsInZlciI6IjEuMCIsIndpZHMiOlsiYjFiZTFjM2UtYjY1ZC00ZjE5LTg0MjctZjZmYTBkOTdmZWI5IiwiMDk5N2ExZDAtMGQxZC00YWNiLWI0MDgtZDVjYTczMTIxZTkwIl0sInhtc19hY2QiOjE3NzY3MDg0MDEsInhtc19hY3RfZmN0IjoiOSAzIiwieG1zX2Z0ZCI6IjZuYTJJRXF0MWliaVd5emFrbzdzM0VHbEdnbm1JRlBUSjdFWUZTcEZPcjBCZFhOemIzVjBhQzFrYzIxeiIsInhtc19pZHJlbCI6IjEwIDciLCJ4bXNfcGZ0ZXhwIjoxNzgzMTM4NzQwLCJ4bXNfcmQiOiIwLjQyTGxZQkppZEJNUzRXQVZFcEJ6U3p5M29MSEZaN2RYdGFuWExOTmNJUkVPVGlHQnRJOWVVdy1lZXVrNVFldzVjLU4ybHM5Q0lod2NRZ0xNREJCd0FFb0xpWEJ3Q3dsc2VTRDdOZHpmN3VRaVNha1duNWF0QWdBIiwieG1zX3N1Yl9mY3QiOiIzIDkiLCJ4bXNfdGNkdCI6MTU4MTAyMjkyNywieG1zX3RudF9mY3QiOiIzIDE0In0.Rfz02nCkcm5iJ19EQVEFhZEcBf_xdd6kXTck-paz5UVgRhSWE8TDPfF9eliB38OZ59bRJdkY6TdtUNrlAAHhJxHs8Hxq6vyYJwKAhzfD-XYntD00Mas1yEe1pobc4vjLPrNYl5ie2KIZvSpmvUc97Lvf5juRueRbvUh9k9QgVwKfGBCHVXirBv9Bekhx4XUraskE-UvROmDvf5mi1DtkskMIC4-Xutz6gFTbJDj-uwTtI2vZa9dQMsVJfMpmq6pWD5lNrIm1tk6LMMIBVA00h3ue5hIt7v5IggvFFqk2JlacMnElQPNkgpwaMJTdcwrVEuonVZuKpv3iYZJU8MtCcg
|
||||||
@@ -30,6 +30,12 @@ failures, production risk). Bump one tier for: security, auth, credential, migra
|
|||||||
production, data-loss. Detail: EXTENDED + `.claude/OLLAMA.md`.
|
production, data-loss. Detail: EXTENDED + `.claude/OLLAMA.md`.
|
||||||
|
|
||||||
## Key rules (always)
|
## Key rules (always)
|
||||||
|
- **Simplest solution FIRST.** Start with the least complex thing that could answer the
|
||||||
|
problem, confirm it works, and only THEN graduate to more advanced/complicated approaches
|
||||||
|
as the simple one proves insufficient. Don't reach for SSH/plink/multi-hop tooling when a
|
||||||
|
one-shot query (a single DNS lookup, one API call, one command) would answer it. E.g. "what
|
||||||
|
IP does the UDM think X is?" = `nslookup X <udm-ip>`, not shelling into the device. Escalate
|
||||||
|
deliberately, not by default.
|
||||||
- **NO EMOJIS.** Use ASCII markers: `[OK]` `[ERROR]` `[WARNING]` `[INFO]` `[CRITICAL]`.
|
- **NO EMOJIS.** Use ASCII markers: `[OK]` `[ERROR]` `[WARNING]` `[INFO]` `[CRITICAL]`.
|
||||||
- **Skill-first — if a skill/command covers the task, USE IT; never hand-roll the API.**
|
- **Skill-first — if a skill/command covers the task, USE IT; never hand-roll the API.**
|
||||||
When a request maps to an installed skill or slash-command, INVOKE THAT SKILL instead of
|
When a request maps to an installed skill or slash-command, INVOKE THAT SKILL instead of
|
||||||
@@ -38,7 +44,9 @@ production, data-loss. Detail: EXTENDED + `.claude/OLLAMA.md`.
|
|||||||
records (e.g. Syncro tickets/invoices) reach a human for cleanup. **Syncro billing/invoicing
|
records (e.g. Syncro tickets/invoices) reach a human for cleanup. **Syncro billing/invoicing
|
||||||
ALWAYS runs through `/syncro` (or `/syncro-emergency-billing`) — no exceptions.** Same for
|
ALWAYS runs through `/syncro` (or `/syncro-emergency-billing`) — no exceptions.** Same for
|
||||||
other covered domains: credentials → `vault`, RMM actions → `/rmm` (+ `rmm-search` to find a
|
other covered domains: credentials → `vault`, RMM actions → `/rmm` (+ `rmm-search` to find a
|
||||||
host), M365 → `remediation-tool`, etc. Knowing the API is NOT a reason to bypass the skill —
|
host), M365 → `remediation-tool`, asking a human a question/decision in Discord (get their
|
||||||
|
reply back in-session) → `ask-forum` (never hand-roll the Discord API — that footgun cost us
|
||||||
|
a broken background wait), etc. Knowing the API is NOT a reason to bypass the skill —
|
||||||
the memory rules (e.g. [[feedback_syncro_billing]]) describe what the SKILL does, not a license
|
the memory rules (e.g. [[feedback_syncro_billing]]) describe what the SKILL does, not a license
|
||||||
to free-hand it. Reach for raw API ONLY when no skill fits or the skill genuinely cannot do it
|
to free-hand it. Reach for raw API ONLY when no skill fits or the skill genuinely cannot do it
|
||||||
— and say so explicitly when you do. Mistakes here go to `errorlog.md` (`--correction`).
|
— and say so explicitly when you do. Mistakes here go to `errorlog.md` (`--correction`).
|
||||||
|
|||||||
1
.claude/MEMORY.md
Normal file
1
.claude/MEMORY.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- [PS5.1 -Headers $H empty-auth quirk](.claude/memory/ps51_invoke_restmethod_headers_variable.md) - build Graph auth headers inline per call under RMM powershell -File; capture Graph error bodies
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
| Run a command / investigate / script on an RMM agent | `/rmm` (find the host first with `rmm-search`) |
|
| Run a command / investigate / script on an RMM agent | `/rmm` (find the host first with `rmm-search`) |
|
||||||
| M365 investigation/remediation, breach check, mailbox/inbox-rule/forwarding audit, tenant sweep | `remediation-tool` |
|
| M365 investigation/remediation, breach check, mailbox/inbox-rule/forwarding audit, tenant sweep | `remediation-tool` |
|
||||||
| Onboard a new M365 tenant to the remediation app suite | `onboard365` |
|
| Onboard a new M365 tenant to the remediation app suite | `onboard365` |
|
||||||
| Backblaze / B2 storage, buckets, backup storage cost | `b2` |
|
| Anything about a customer's **BACKUP** — is X backing up, who backs up where, backup status/failures, or set up / provision a backup | **Backups → wiki-first** (see the "Backups" block right below this table) |
|
||||||
| Bitdefender / GravityZone AV — endpoints, sweeps, policies, quarantine, EDR | `bitdefender` |
|
| Bitdefender / GravityZone AV — endpoints, sweeps, policies, quarantine, EDR | `bitdefender` |
|
||||||
| Datto EDR / Datto AV — detections, isolate, scan, agent deploy | `datto-edr` |
|
| Datto EDR / Datto AV — detections, isolate, scan, agent deploy | `datto-edr` |
|
||||||
| VoIP — PacketDial / OITVOIP / NetSapiens domains, users, DIDs, queues, CDRs | `packetdial` |
|
| VoIP — PacketDial / OITVOIP / NetSapiens domains, users, DIDs, queues, CDRs | `packetdial` |
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
| UniFi WiFi tuning / RF / channel analysis | `unifi-wifi` |
|
| UniFi WiFi tuning / RF / channel analysis | `unifi-wifi` |
|
||||||
| Map/repoint a Windows network drive on a remote endpoint | `drive-map` |
|
| Map/repoint a Windows network drive on a remote endpoint | `drive-map` |
|
||||||
| Send someone a Discord DM / copy-paste-friendly link or command | `discord-dm` |
|
| Send someone a Discord DM / copy-paste-friendly link or command | `discord-dm` |
|
||||||
|
| Ask a teammate a question/decision/sign-off and get their human answer back in-session | `ask-forum` (run the `--wait` as a proper background task — NO shell `&`) |
|
||||||
| Inter-session messaging, fleet todos, resource locks, coord status | `coord` |
|
| Inter-session messaging, fleet todos, resource locks, coord status | `coord` |
|
||||||
| Git / Gitea / submodules / sync health | `gitea` (or `/sync`, `/scc`, `/save` for session ops) |
|
| Git / Gitea / submodules / sync health | `gitea` (or `/sync`, `/scc`, `/save` for session ops) |
|
||||||
| Build/verify GuruRMM agent/server/dashboard, pre-merge check | `gururmm-build` |
|
| Build/verify GuruRMM agent/server/dashboard, pre-merge check | `gururmm-build` |
|
||||||
@@ -46,6 +47,31 @@
|
|||||||
|
|
||||||
> Not listed? If no skill fits, do it directly — and say so. New recurring need → `skill-creator`.
|
> Not listed? If no skill fits, do it directly — and say so. New recurring need → `skill-creator`.
|
||||||
|
|
||||||
|
### Backups (wiki-first routing)
|
||||||
|
|
||||||
|
A customer can back up through **any** of several backends, so a backup request is a
|
||||||
|
two-step route: **first find out which backend THIS customer uses, then use that skill.**
|
||||||
|
Never assume B2/MSP360 — several clients are on Seafile, ownCloud, or Datto Workplace.
|
||||||
|
|
||||||
|
1. **Identify the backend for this customer (don't guess).** Read the client wiki
|
||||||
|
(`wiki/clients/<slug>.md`) and the backup map (`projects/gps-rmm-audit/backup-map.md`) —
|
||||||
|
both record each customer's real destination. If it isn't recorded, confirm with
|
||||||
|
`msp360 monitoring --company <name>` plus the per-backend inventory skills, then write the
|
||||||
|
answer back to the client wiki.
|
||||||
|
2. **Route to that backend's skill:**
|
||||||
|
|
||||||
|
| Need | Skill |
|
||||||
|
|---|---|
|
||||||
|
| Is X backing up? status / last run / failures / stale plans (MSP360 + B2 Cloud plans) | `msp360` — **authoritative**, check here not bucket contents |
|
||||||
|
| Who backs up to Seafile / ownCloud / Datto Workplace (inventory + endpoint detection) | `seafile` / `owncloud` / `datto-workplace` |
|
||||||
|
| Storage / bucket / cost on the destination side | `b2` (Backblaze) |
|
||||||
|
| **Set up / provision a backup** — create account/user, quota, library, license, destination | the **target backend's** skill: `msp360` (MBS user/company/license), `seafile`, `owncloud` (all writes gated `--confirm`); `b2` for a new bucket/key |
|
||||||
|
|
||||||
|
- **Which machine gets backed up is a per-client decision** — report mismatches (billed vs
|
||||||
|
running), don't provision jobs off a billing count. See [[feedback_backup_targets_never_guessed]].
|
||||||
|
- Full per-customer destination + health map: `projects/gps-rmm-audit/backup-map.md`;
|
||||||
|
how backup-status works: memory `[[reference_msp360_backup_monitoring]]`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Check-skills (work-type → the gate run BEFORE "done")
|
## Check-skills (work-type → the gate run BEFORE "done")
|
||||||
|
|||||||
46
.claude/commands/ask-forum.md
Normal file
46
.claude/commands/ask-forum.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: ask-forum
|
||||||
|
description: Ask a teammate (e.g. Mike) a question in the private #ct-forum Discord forum and BLOCK until a human answers, then act on the reply — a live three-way between the user, this same Claude session, and the teammate. No second bot, no DMs, one tool call (the wait runs server-side in bash).
|
||||||
|
---
|
||||||
|
|
||||||
|
# /ask-forum — human-in-the-loop question via #ct-forum
|
||||||
|
|
||||||
|
Entry point to the `ask-forum` skill — full contract in `.claude/skills/ask-forum/SKILL.md`.
|
||||||
|
Engine: `.claude/scripts/ask-forum.sh`. Lets THIS session put a question
|
||||||
|
to a human in the private #ct-forum forum and get their answer back in-session, so it
|
||||||
|
can keep going. The forum post's thread id is the correlation key — the session reads
|
||||||
|
only the thread it created, so an answer is never confused with another question's.
|
||||||
|
|
||||||
|
Forum-only by design (Mike's call, 2026-07-08): DM replies are intentionally not used,
|
||||||
|
so answers stay visible to the team. The BEAST bot ignores #ct-forum, so the asking
|
||||||
|
session is the only agent that consumes replies there.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ask and block until a human replies (default timeout 600s, poll 8s):
|
||||||
|
bash .claude/scripts/ask-forum.sh "your question" --tag @264814939619721216
|
||||||
|
|
||||||
|
# Re-read a thread you already asked in (one-shot, no waiting):
|
||||||
|
bash .claude/scripts/ask-forum.sh --read <thread_id>
|
||||||
|
|
||||||
|
# Resume blocking on a thread already posted (e.g. after a timeout):
|
||||||
|
bash .claude/scripts/ask-forum.sh --wait <thread_id> --timeout 1800 --poll 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags: `--title "short title"` (forum post name; defaults to first 60 chars of the
|
||||||
|
question), `--timeout SEC`, `--poll SEC`, `--tag @<discord_id>` (repeatable — pings
|
||||||
|
that person; ids in `.claude/users.json`: mike 264814939619721216,
|
||||||
|
howard 624667664501178379, winter 624666486362996755, rob 261978810713505792).
|
||||||
|
|
||||||
|
## How to run it well
|
||||||
|
|
||||||
|
- **Long waits → background it** with `run_in_background: true` (NO shell `&` — that
|
||||||
|
forks the wait off and exits early). You get the answer when the human replies.
|
||||||
|
- Only **non-bot** replies count as answers, so bot chatter can never be mistaken for
|
||||||
|
the human's answer.
|
||||||
|
- Exit codes: 0 answered · 2 no token · 3 Discord API error · 4 timeout (thread stays
|
||||||
|
open — resume with `--wait <thread_id>`).
|
||||||
|
|
||||||
|
Channel: #ct-forum (`1522960388432465950`), private — @everyone denied; Howard, Mike,
|
||||||
|
and the bot allowed. Add someone via a channel permission overwrite if they need access.
|
||||||
@@ -23,7 +23,7 @@ Follow these steps exactly when /inject-standards is invoked:
|
|||||||
|
|
||||||
### Step 2 — Auto-select relevant standards
|
### Step 2 — Auto-select relevant standards
|
||||||
|
|
||||||
1. Read `D:/claudetools/.claude/standards/index.yml`.
|
1. Read `.claude/standards/index.yml`.
|
||||||
2. Review the descriptions for all entries.
|
2. Review the descriptions for all entries.
|
||||||
3. Select the 2–5 standards most relevant to either:
|
3. Select the 2–5 standards most relevant to either:
|
||||||
- The task description in $ARGUMENTS, or
|
- The task description in $ARGUMENTS, or
|
||||||
@@ -35,7 +35,7 @@ Follow these steps exactly when /inject-standards is invoked:
|
|||||||
|
|
||||||
For each selected standard (in order of relevance):
|
For each selected standard (in order of relevance):
|
||||||
|
|
||||||
1. Read the file at `D:/claudetools/.claude/standards/<slug>.md`.
|
1. Read the file at `.claude/standards/<slug>.md`.
|
||||||
2. Display a header:
|
2. Display a header:
|
||||||
```
|
```
|
||||||
=== STANDARD: <slug> ===
|
=== STANDARD: <slug> ===
|
||||||
@@ -87,8 +87,8 @@ Reads the recent conversation, infers the task type, selects 2–5 most relevant
|
|||||||
|
|
||||||
## Standards index location
|
## Standards index location
|
||||||
|
|
||||||
`D:/claudetools/.claude/standards/index.yml`
|
`.claude/standards/index.yml`
|
||||||
|
|
||||||
## Standards files location
|
## Standards files location
|
||||||
|
|
||||||
`D:/claudetools/.claude/standards/<folder>/<name>.md`
|
`.claude/standards/<folder>/<name>.md`
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ Create, update, close, comment on, and bill tickets in Syncro PSA.
|
|||||||
/syncro estimate <customer> <subject> Create ticket + linked estimate with line items and private purchase notes
|
/syncro estimate <customer> <subject> Create ticket + linked estimate with line items and private purchase notes
|
||||||
/syncro schedules <customer> List recurring invoice schedules for a customer
|
/syncro schedules <customer> List recurring invoice schedules for a customer
|
||||||
/syncro schedule <id> View a schedule's template and line items
|
/syncro schedule <id> View a schedule's template and line items
|
||||||
|
/syncro assets <customer> List a customer's RMM assets (read)
|
||||||
|
/syncro asset <id> Asset detail + patches + installed applications
|
||||||
|
/syncro asset-update <id> Rename / fix serial / edit an asset (preview-gated)
|
||||||
|
/syncro move-asset <id> <folder> Move asset to a policy folder (needs token scope - see below)
|
||||||
|
/syncro rmm-alerts [<customer>] List live RMM alerts; mute/clear by id
|
||||||
|
/syncro deploy-agent <customer> <host> Push the Syncro RMM agent to a machine via GuruRMM
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Configuration
|
## API Configuration
|
||||||
@@ -26,6 +32,10 @@ Create, update, close, comment on, and bill tickets in Syncro PSA.
|
|||||||
**API Key:** per-user tokens in SOPS vault — see "Get API key" below
|
**API Key:** per-user tokens in SOPS vault — see "Get API key" below
|
||||||
**Rate limit:** 180 requests/minute per IP
|
**Rate limit:** 180 requests/minute per IP
|
||||||
**Docs:** https://api-docs.syncromsp.com/
|
**Docs:** https://api-docs.syncromsp.com/
|
||||||
|
**Full endpoint catalog:** `.claude/standards/syncro/api-reference.md` — every Syncro call
|
||||||
|
(~180 endpoints, 38 resource types), verified against the live spec + tenant 2026-07-10.
|
||||||
|
This file documents the core PSA + asset/RMM/agent-deploy workflows; the reference covers
|
||||||
|
the rest (contacts, contracts, leads, vendors, POs, products, payments, wiki, policy folders).
|
||||||
|
|
||||||
## Hard Rules (violations have occurred — no exceptions)
|
## Hard Rules (violations have occurred — no exceptions)
|
||||||
|
|
||||||
@@ -82,6 +92,21 @@ $PYTHON -c "import datetime; d = datetime.date(YYYY, M, D); print(d.strftime('%A
|
|||||||
|
|
||||||
If any check fails, complete the missing step before reporting done. This rule fires on initial estimate creation AND on every subsequent "add X to the estimate" request. Incident: 2026-05-22, UPS added to estimate #7189 without a ticket note — caught by Winter.
|
If any check fails, complete the missing step before reporting done. This rule fires on initial estimate creation AND on every subsequent "add X to the estimate" request. Incident: 2026-05-22, UPS added to estimate #7189 without a ticket note — caught by Winter.
|
||||||
|
|
||||||
|
**Emailing an invoice/estimate returns 200 "Email sent" even when it reached NOBODY — never
|
||||||
|
report delivery from the 200 alone.** `POST /invoices/{id}/email` and `/estimates/{id}/email`
|
||||||
|
take no recipient param; Syncro picks it from the record. If `customer.email` is null (and no
|
||||||
|
recipient contact is flagged), the send is a silent no-op that still returns HTTP 200. Before
|
||||||
|
emailing, confirm the customer has a valid `email`. Note `customer.email` is **UNIQUE
|
||||||
|
tenant-wide** — reusing one returns `{"success":false,"message":["Email has already been
|
||||||
|
taken"]}`; a `PUT /customers` that fails returns that error shape (not `{"customer":{...}}`),
|
||||||
|
so check `.success` on customer writes instead of assuming a silent no-op.
|
||||||
|
|
||||||
|
**Living test-findings log:** `.claude/standards/syncro/test-findings.md` records verified
|
||||||
|
behaviors/gotchas discovered while exercising the skill on the Howard Test sandbox
|
||||||
|
(customer 36118743). Consult + append to it as new API quirks surface — it is how the skill
|
||||||
|
avoids re-learning the same trap. Critical entries are folded into these Hard Rules and the
|
||||||
|
Verified Response Shapes table.
|
||||||
|
|
||||||
## Implementation
|
## Implementation
|
||||||
|
|
||||||
When invoked, use the Syncro REST API via `curl`. All requests include `?api_key=<key>` as query parameter (NOT in header — Syncro uses query param auth).
|
When invoked, use the Syncro REST API via `curl`. All requests include `?api_key=<key>` as query parameter (NOT in header — Syncro uses query param auth).
|
||||||
@@ -96,53 +121,39 @@ Every Syncro API call is attributed to the **owner of the API key**. Comments, l
|
|||||||
- **Correcting mis-billed labor** (a debug action) must keep the ORIGINAL tech's `user_id` (their commission). `update_line_item` preserves the existing `user_id`; a remove+add defaults the new line to the API-key owner — so set `user_id` to the original tech on `add_line_item`, or PUT it afterward. Determine the original tech from `.ticket.user_id` and the line's `.user_id`. Don't take a tech's commission just because the math was fixed by someone else.
|
- **Correcting mis-billed labor** (a debug action) must keep the ORIGINAL tech's `user_id` (their commission). `update_line_item` preserves the existing `user_id`; a remove+add defaults the new line to the API-key owner — so set `user_id` to the original tech on `add_line_item`, or PUT it afterward. Determine the original tech from `.ticket.user_id` and the line's `.user_id`. Don't take a tech's commission just because the math was fixed by someone else.
|
||||||
- **Ticket ownership:** adding notes/labor or changing status does NOT change the ticket owner. Multiple techs routinely work one ticket. Only PUT a ticket's `user_id` (reassign owner) when explicitly asked; status PUTs send only `status`.
|
- **Ticket ownership:** adding notes/labor or changing status does NOT change the ticket owner. Multiple techs routinely work one ticket. Only PUT a ticket's `user_id` (reassign owner) when explicitly asked; status PUTs send only `status`.
|
||||||
|
|
||||||
| identity.json user | Syncro user | user_id |
|
| identity.json user | Syncro user | user_id | vault path |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `mike` | Michael Swanson | 1735 |
|
| `mike` | Michael Swanson | 1735 | `msp-tools/syncro` |
|
||||||
| `howard` | Howard Enos | 1750 |
|
| `howard` | Howard Enos | 1750 | `msp-tools/syncro-howard` |
|
||||||
|
| `winter` | Winter Williams | 1737 | `msp-tools/syncro-winter` |
|
||||||
|
|
||||||
Keys are baked into the skill below. To add a new user: generate a token in Syncro → Admin → API Tokens, add a case to the key-select block, and store a backup copy in the vault at `msp-tools/syncro-<user>.sops.yaml`.
|
Keys live in the SOPS vault and are resolved by `.claude/scripts/syncro-env.sh` (source it — never hardcode a key). When `CLAUDETOOLS_REQUESTER_USER` is set (Discord bot threads), syncro-env.sh prefers the REQUESTER's key if one is vaulted, so bot-driven Syncro actions attribute to the person who asked; it falls back to the identity.json user otherwise. To add a new user: generate a token in Syncro → Admin → API Tokens, vault it at `msp-tools/syncro-<user>.sops.yaml`, and add a case to `_syncro_key_path()` in syncro-env.sh.
|
||||||
|
|
||||||
### Get API key
|
### Get API key
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BASE="https://computerguru.syncromsp.com/api/v1"
|
# Resolves CLAUDETOOLS_ROOT + VAULT_ROOT from identity.json and reads the caller's
|
||||||
|
# per-user Syncro key from the SOPS vault. Never hardcode a repo path or an API key.
|
||||||
|
#
|
||||||
|
# The key is per-user on purpose: every action in Syncro is attributed to the key
|
||||||
|
# owner, so using someone else's key misattributes tickets, time, and invoices.
|
||||||
|
#
|
||||||
|
# NOTE: identity.json lives in the REPO (.claude/identity.json), not ~/.claude/.
|
||||||
|
# Reading ~/.claude/identity.json first is a known bug — it silently picks up a
|
||||||
|
# stale or absent file. syncro-env.sh resolves the repo copy from its own location.
|
||||||
|
source "$(git rev-parse --show-toplevel)/.claude/scripts/syncro-env.sh" || {
|
||||||
|
echo "[ERROR] Cannot resolve Syncro credentials — run onboarding / check the vault" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# Get repo root from identity.json (set during machine onboarding)
|
BASE="$SYNCRO_BASE"
|
||||||
# Fallback to dynamic detection for legacy machines that haven't updated identity.json yet
|
API_KEY="$SYNCRO_API_KEY"
|
||||||
IDENTITY_PATH="${HOME}/.claude/identity.json"
|
REPO_ROOT="$CLAUDETOOLS_ROOT"
|
||||||
if [ ! -f "$IDENTITY_PATH" ]; then
|
|
||||||
# Try in-repo identity.json (gitignored, machine-specific)
|
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
||||||
if [ -n "$REPO_ROOT" ]; then
|
|
||||||
IDENTITY_PATH="$REPO_ROOT/.claude/identity.json"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$IDENTITY_PATH" ]; then
|
if [ -z "$API_KEY" ]; then
|
||||||
echo "[ERROR] Cannot locate identity.json - run onboarding first" >&2
|
echo "[ERROR] No Syncro API key for user '${SYNCRO_USER:-unknown}'" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
REPO_ROOT=$(jq -r '.claudetools_root // empty' "$IDENTITY_PATH")
|
|
||||||
if [ -z "$REPO_ROOT" ]; then
|
|
||||||
# Legacy fallback for machines without claudetools_root in identity.json
|
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
||||||
if [ -z "$REPO_ROOT" ]; then
|
|
||||||
echo "[ERROR] claudetools_root not set in identity.json and not in a git directory" >&2
|
|
||||||
echo "[ERROR] Add 'claudetools_root' field to $IDENTITY_PATH" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[WARNING] Using git-detected repo root. Add 'claudetools_root' to identity.json to avoid this." >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Per-user keys — actions in Syncro are attributed to the key owner
|
|
||||||
USER_ID=$(jq -r '.user // empty' "$IDENTITY_PATH")
|
|
||||||
case "$USER_ID" in
|
|
||||||
mike) API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;;
|
|
||||||
howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;;
|
|
||||||
*) echo "[ERROR] Unknown user '$USER_ID' in identity.json — cannot select Syncro API key" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ollama drafting
|
### Ollama drafting
|
||||||
@@ -572,6 +583,21 @@ Every endpoint's response shape, verified against the live API. Parse exactly as
|
|||||||
| Add schedule line | POST `/schedules/{id}/line_items` | `{"schedule_line_item": {...}}` | `.schedule_line_item.id` |
|
| Add schedule line | POST `/schedules/{id}/line_items` | `{"schedule_line_item": {...}}` | `.schedule_line_item.id` |
|
||||||
| Update schedule line | PUT `/schedules/{id}/line_items/{li_id}` | `{"schedule_line_item": {...}}` | `.schedule_line_item.quantity`, `.schedule_line_item.price_retail` |
|
| Update schedule line | PUT `/schedules/{id}/line_items/{li_id}` | `{"schedule_line_item": {...}}` | `.schedule_line_item.quantity`, `.schedule_line_item.price_retail` |
|
||||||
| Delete schedule line | DELETE `/schedules/{id}/line_items/{li_id}` | `{"success": true}` | — |
|
| Delete schedule line | DELETE `/schedules/{id}/line_items/{li_id}` | `{"success": true}` | — |
|
||||||
|
| Create customer | POST `/customers` | `{"customer": {...}}` | `.customer.id` |
|
||||||
|
| Update customer | PUT `/customers/{id}` | **success** `{"customer": {...}}` / **error** `{"success": false, "message": [...]}` | check `.success` — email is unique tenant-wide |
|
||||||
|
| Create contact | POST `/contacts` | **FLAT** `{"id": N, "email": ...}` (NOT wrapped) | `.id` — GET-verify; null-parse ≠ failure |
|
||||||
|
| Update contact | PUT `/contacts/{id}` | **FLAT** `{"id": N, ...}` | `.id` — `primary`/`receives_invoices` flags are ignored via API |
|
||||||
|
| Create contract | POST `/contracts` | write OK but body does NOT echo the object | GET `/contracts` + match; never retry on null |
|
||||||
|
| Email invoice | POST `/invoices/{id}/email` | `{"message": "Email sent."}` | **200 even with NO recipient** — verify `customer.email` first |
|
||||||
|
| Email estimate | POST `/estimates/{id}/email` | `{"message": "Email sent"}` | **200 even with NO recipient** — verify `customer.email` first |
|
||||||
|
| Create lead | POST `/leads` | `{"lead": {...}}` | `.lead.id` |
|
||||||
|
| Create vendor | POST `/vendors` | `{"vendor": {...}}` | `.vendor.id` — no DELETE (persists) |
|
||||||
|
| Create/Update product | POST/PUT `/products/{id}` | `{"product": {...}}` | `.product.id` — `name`+`description` required; no DELETE |
|
||||||
|
| Create PO | POST `/purchase_orders` | `{"purchase_order": {...}}` | `.purchase_order.id` — no DELETE |
|
||||||
|
| Add PO line | POST `/purchase_orders/{id}/create_po_line_item` | `{"po_line_item": {...}}` | needs product `maintain_stock:true` |
|
||||||
|
| Create worksheet | POST `/tickets/{id}/worksheet_results` | `{"worksheet_result": {...}}` | `.worksheet_result.id` — template ids in `GET /tickets/settings` |
|
||||||
|
| Create policy folder | POST `/policy_folders` | `{"policy_folder": {...}}` | `.policy_folder.id` — `parent_id` REQUIRED (null → 422); full-scope token |
|
||||||
|
| Delete policy folder | DELETE `/policy_folders/{id}` | empty body / 200 | full-scope token only |
|
||||||
|
|
||||||
**Invoice GET line_items field names differ from ticket line_items:** `item` = product name, `name` = description, `price` = unit rate. Do not use `price_retail` when reading invoice line items.
|
**Invoice GET line_items field names differ from ticket line_items:** `item` = product name, `name` = description, `price` = unit rate. Do not use `price_retail` when reading invoice line items.
|
||||||
|
|
||||||
@@ -613,6 +639,162 @@ COMMENT_ID=$(echo "$COMMENT_RESP" | jq -r '.comment.id')
|
|||||||
- Do NOT wrap the payload in `{"comment": {...}}` — returns 422.
|
- 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.
|
- **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 (RMM) — read intelligence + write management
|
||||||
|
|
||||||
|
Assets are Syncro's RMM device records. Reads are free; writes follow the standard
|
||||||
|
preview+confirm gate. Response shape is `{"asset": {...}}` (single) / `{"assets": [...]}`
|
||||||
|
(list). RMM telemetry lives under `.asset.properties.kabuto_information`.
|
||||||
|
|
||||||
|
**Reads (verified with per-user token 2026-07-10):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List / search a customer's assets (100/page, .assets[])
|
||||||
|
curl -s "${BASE}/customer_assets?customer_id=${CUST_ID}&per_page=100&api_key=${API_KEY}" | \
|
||||||
|
tr -d '\000-\037' | jq -r '.assets[] | "\(.id)\t\(.asset_type_name)\t\(.name)"'
|
||||||
|
|
||||||
|
# Asset detail
|
||||||
|
curl -s "${BASE}/customer_assets/${ASSET_ID}?api_key=${API_KEY}" | tr -d '\000-\037' | \
|
||||||
|
jq '{id:.asset.id, name:.asset.name, customer_id:.asset.customer_id, policy_folder_id:.asset.policy_folder_id, serial:.asset.asset_serial}'
|
||||||
|
|
||||||
|
# Windows patch posture -> {available_patches, available_patches_meta, installed_patches}
|
||||||
|
curl -s "${BASE}/customer_assets/${ASSET_ID}/patches?api_key=${API_KEY}" | tr -d '\000-\037' | \
|
||||||
|
jq '{available: (.available_patches|length), installed: (.installed_patches|length)}'
|
||||||
|
|
||||||
|
# Installed software inventory (paginated) -> {installed_applications, meta}
|
||||||
|
# Use this to VERIFY an install (e.g. after /syncro deploy-agent) — see Agent Deployment.
|
||||||
|
curl -s "${BASE}/customer_assets/${ASSET_ID}/installed_applications?per_page=100&api_key=${API_KEY}" | \
|
||||||
|
tr -d '\000-\037' | jq -r '.installed_applications[]?.name' | sort -u
|
||||||
|
```
|
||||||
|
|
||||||
|
`updated_at` is NOT a reliable "last online" (bulk account touches move it) — use the
|
||||||
|
ScreenConnect `GuestInfoUpdateTime` or kabuto data for real last-seen.
|
||||||
|
|
||||||
|
**Writes — `POST /customer_assets` (create), `PUT /customer_assets/{id}` (update).**
|
||||||
|
`name` is **required** on both (422 without it). Accepted body fields: `name`,
|
||||||
|
`asset_serial`, `asset_type_id`, `asset_type_name`, `customer_id`, `properties`. Preview the
|
||||||
|
full payload and confirm before writing; post a bot alert after. Response `{"asset": {...}}`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update (rename / fix serial). Always resend name (required) even when only changing serial.
|
||||||
|
curl -s -X PUT "${BASE}/customer_assets/${ASSET_ID}?api_key=${API_KEY}" \
|
||||||
|
-H "Content-Type: application/json" --data-binary @- <<JSON
|
||||||
|
{"name": "${NEW_NAME}", "asset_serial": "${NEW_SERIAL}"}
|
||||||
|
JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
**Move to a policy folder — `PUT /customer_assets/{id}` with `policy_folder_id` — WORKS, but
|
||||||
|
is TOKEN-SCOPE-GATED and MUST be capability-checked + verified.** Live-proven 2026-07-10 via
|
||||||
|
flip-and-restore on ACG-internal asset `12335235` (`692253 → 692278 → 692253`) with Howard's
|
||||||
|
full-scope token. The spec: *"Updating only `policy_folder_id` requires **Assets - Policy
|
||||||
|
Change**; with other fields requires **Assets - Edit** AND **Assets - Policy Change**. Nil,
|
||||||
|
nonexistent, or cross-customer folder IDs return 422."* `name` is required on the PUT, so a
|
||||||
|
move always sends other fields → needs BOTH scopes.
|
||||||
|
|
||||||
|
**The failure mode is SILENT, so never trust the 200.** A token WITHOUT Policy-Change returns
|
||||||
|
`HTTP 200` and simply does not change the folder (this is the symptom once misread as "RMM is
|
||||||
|
GUI-only"). `/me` reports coarse `asset:{write:true}` even without Policy-Change, so it cannot
|
||||||
|
detect this — you must probe and verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
move_asset() { # $1=asset_id $2=target_folder_id (needs $BASE $API_KEY)
|
||||||
|
local AID="$1" TARGET="$2" NAME CUST CUR
|
||||||
|
local A; A=$(curl -s "${BASE}/customer_assets/${AID}?api_key=${API_KEY}" | tr -d '\000-\037')
|
||||||
|
NAME=$(echo "$A" | jq -r '.asset.name'); CUST=$(echo "$A" | jq -r '.asset.customer_id')
|
||||||
|
CUR=$(echo "$A" | jq -r '.asset.policy_folder_id')
|
||||||
|
# 1) CAPABILITY PREFLIGHT — 200 = token can move; 401 = lacks Assets - Policy Change
|
||||||
|
local PC; PC=$(curl -s -o /dev/null -w '%{http_code}' "${BASE}/policy_folders?customer_id=${CUST}&api_key=${API_KEY}")
|
||||||
|
if [ "$PC" = "401" ]; then
|
||||||
|
echo "[ERROR] This token lacks 'Assets - Policy Change' — cannot move policy folders."
|
||||||
|
echo " Options: move it in the Syncro web console, OR have a user whose token has"
|
||||||
|
echo " the scope run it (do NOT silently swap keys — writes attribute to the owner)."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# 2) PUT (name required), then 3) VERIFY the value actually changed — a 200 no-op = failure
|
||||||
|
curl -s -X PUT "${BASE}/customer_assets/${AID}?api_key=${API_KEY}" -H "Content-Type: application/json" \
|
||||||
|
--data-binary "$(jq -nc --arg n "$NAME" --argjson pf "$TARGET" '{name:$n, policy_folder_id:$pf}')" >/dev/null
|
||||||
|
local NEW; NEW=$(curl -s "${BASE}/customer_assets/${AID}?api_key=${API_KEY}" | tr -d '\000-\037' | jq -r '.asset.policy_folder_id')
|
||||||
|
if [ "$NEW" = "$TARGET" ]; then echo "[OK] asset ${AID} moved ${CUR} -> ${NEW}"; else
|
||||||
|
echo "[ERROR] move NOT applied (still ${NEW}) — token likely under-scoped; do it in the console."; return 1; fi
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Policy-folder route quirks (verified 2026-07-10):** `GET /policy_folders` (bare) → **404**;
|
||||||
|
`GET /policy_folders?customer_id=N` → **200** (list a customer's folders — how you enumerate
|
||||||
|
folder IDs); `GET /policy_folders/{id}` → **401** even with full scope. Find valid same-customer
|
||||||
|
folder IDs from the `?customer_id=N` list or from other assets' `policy_folder_id` values.
|
||||||
|
|
||||||
|
**Fleet posture:** Howard's per-user token now has full scope (verified). Other per-user tokens
|
||||||
|
may not — the preflight above handles that per-run. Recommend granting every per-user Syncro
|
||||||
|
token the Policy-Change scope so `move-asset` behaves the same regardless of who runs it (Mike's
|
||||||
|
call — least-privilege alternative is to leave the preflight to degrade gracefully). Corrects
|
||||||
|
memory `reference-syncro-rmm-api-gui-only`; full detail in `.claude/standards/syncro/api-reference.md`.
|
||||||
|
|
||||||
|
**Archive (retire) is still GUI-only — no REST API.** Verified 2026-07-08: archiving is web
|
||||||
|
UI only (asset Details → **Actions → Archive**, or Assets table → **Bulk Actions →
|
||||||
|
Archive**). There is no archive endpoint and no `archived` field on the PUT. Do NOT substitute
|
||||||
|
`DELETE /customer_assets/{id}` — delete is destructive/irreversible, loses history, and breaks
|
||||||
|
ticket linkages (Archive keeps the asset on its tickets/alerts). To retire: hand the user the
|
||||||
|
asset list + the Bulk-Actions-→-Archive steps.
|
||||||
|
|
||||||
|
#### RMM Alerts
|
||||||
|
|
||||||
|
Live RMM alert feed (`GET /rmm_alerts` -> `{rmm_alerts, meta}`, verified 2026-07-10). Reads
|
||||||
|
free; `POST /rmm_alerts/{id}/mute` and `DELETE /rmm_alerts/{id}` (clear) are gated writes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "${BASE}/rmm_alerts?per_page=50&api_key=${API_KEY}" | tr -d '\000-\037' | \
|
||||||
|
jq -r '.rmm_alerts[] | "\(.id)\t\(.created_at)\t\(.description // .kind)"'
|
||||||
|
# Mute: POST /rmm_alerts/${ALERT_ID}/mute | Clear: DELETE /rmm_alerts/${ALERT_ID}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Agent Deployment (`/syncro deploy-agent`) — hybrid via GuruRMM
|
||||||
|
|
||||||
|
Push the Syncro RMM agent to a machine. Syncro's API has **no** installer-download endpoint
|
||||||
|
(verified: 0 of 116 paths), so this is a **hybrid**: the per-policy-folder installer URL is
|
||||||
|
harvested once from the Syncro GUI and **vaulted**; **GuruRMM (`/rmm`)** does the push (runs as
|
||||||
|
SYSTEM). `/rmm diagnose` already *detects* a Syncro agent — this is the deploy half.
|
||||||
|
|
||||||
|
**The installer URL is a per-client SECRET and is NOT constructible.** Decoded (2026-07-10):
|
||||||
|
`https://rmm.syncromsp.com/dl/msi/<b64>` where `<b64>` = base64 of
|
||||||
|
`v1-{customer_id}-{A}-48753-{policy_folder_id}`. `48753` is OUR account id (constant across all
|
||||||
|
clients); `customer_id` and `policy_folder_id` are API-readable — but **`A` is an unguessable
|
||||||
|
~1.8-billion per-client security token that appears nowhere in the API** (checked customer +
|
||||||
|
policy-folder objects). So you cannot build the URL; it must be harvested per client. Note the
|
||||||
|
link is an **MSI** (`/dl/msi/`) → silent install is `msiexec /qn`, not the `.exe --console` path.
|
||||||
|
|
||||||
|
**Prerequisites (one-time per client):**
|
||||||
|
1. In Syncro GUI: the client's Policy Folder → **Downloads** → copy the `.../dl/msi/<token>` URL.
|
||||||
|
2. Vault it (it embeds the secret `A`): `bash .claude/skills/vault/scripts/vault-helper.sh new
|
||||||
|
clients/<slug>/syncro-agent-installer --kind generic --name "Syncro RMM Installer - <Client>"
|
||||||
|
--set installer_url=<URL> --set customer_id=<N> --set policy_folder_id=<N>`. Already vaulted:
|
||||||
|
`cascades-tucson`, `grabb-durando`, `reliant`, `howard-test`.
|
||||||
|
|
||||||
|
**Deploy workflow:**
|
||||||
|
1. Read the URL: `bash .claude/scripts/vault.sh get-field clients/<slug>/syncro-agent-installer
|
||||||
|
credentials.installer_url`. If the client has no entry, STOP and have the URL harvested first.
|
||||||
|
2. Resolve the target host in GuruRMM (`/rmm` — hostname → UUID; confirm `os_type` windows +
|
||||||
|
`is_connected`). Confirm the client↔host mapping with the user.
|
||||||
|
3. Preview the exact command + target; get explicit confirmation. **deploy-agent is
|
||||||
|
outward-facing (installs software, may reboot) — always confirm first.**
|
||||||
|
4. Dispatch via GuruRMM (`command_type: powershell`, SYSTEM). Download the MSI and install silent:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$url = '<vaulted installer_url>'
|
||||||
|
$msi = "$env:TEMP\SyncroSetup.msi"
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile $msi -UseBasicParsing
|
||||||
|
Start-Process msiexec.exe -ArgumentList '/i',"`"$msi`"",'/qn','/norestart' -Wait
|
||||||
|
```
|
||||||
|
(`.exe` variant, if a client's link is `/dl/exe/`: `Start-Process $exe -ArgumentList
|
||||||
|
'--console','--allow-force-reboot' -Wait`.)
|
||||||
|
5. **Verify enrollment:** after ~2-3 min, confirm the new asset appears —
|
||||||
|
`GET /customer_assets?customer_id=N&query=<host>` — and/or "Syncro" shows in the endpoint's
|
||||||
|
`installed_applications`.
|
||||||
|
6. Bot alert (RMM write → `[RMM]`/`[DEPLOY]` routes to #dev-alerts).
|
||||||
|
|
||||||
|
**[VERIFY before first production use]** Confirm the `msiexec /qn /norestart` silent behavior on
|
||||||
|
RMM-TEST-MACHINE (Howard Test) before rolling to a customer. Full detail:
|
||||||
|
`.claude/standards/syncro/test-findings.md`.
|
||||||
|
|
||||||
#### Customers
|
#### Customers
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
55
.claude/commands/tailscale.md
Normal file
55
.claude/commands/tailscale.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: tailscale
|
||||||
|
description: Manage an ACG-administered Tailscale tailnet via the REST API v2 - list/inspect devices, delete a node, authorize a device, create/list/revoke tagged pre-auth keys. Reads free; writes gated --confirm.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /tailscale — Tailscale tailnet management (REST API v2)
|
||||||
|
|
||||||
|
Thin entry point to the `tailscale` skill. Engine:
|
||||||
|
`.claude/skills/tailscale/scripts/tailscale-api.sh`. Full reference:
|
||||||
|
`.claude/skills/tailscale/SKILL.md`. Doctrine (per-client tailnets, tagged pre-auth
|
||||||
|
keys, offboarding): `wiki/patterns/tailscale-client-management.md`.
|
||||||
|
|
||||||
|
Read-only by default; every device delete/authorize and auth-key create/delete is
|
||||||
|
**gated behind `--confirm`** and previews first. ADMIN rights: add + delete.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TS="bash .claude/skills/tailscale/scripts/tailscale-api.sh"
|
||||||
|
|
||||||
|
# Reads (no confirm)
|
||||||
|
$TS status # auth check + tailnet + device count
|
||||||
|
$TS devices [--json] # list devices
|
||||||
|
$TS device <name|id|100.x> [--json] # device detail
|
||||||
|
$TS keys [--json] # list auth keys
|
||||||
|
|
||||||
|
# Writes (GATED — preview without --confirm, act with it)
|
||||||
|
$TS create-key --tag tag:<client> --reusable --preauth [--ephemeral] \
|
||||||
|
[--expiry-days N] [--desc "..."] --confirm
|
||||||
|
$TS delete-key <keyId> --confirm # revoke an auth key
|
||||||
|
$TS authorize <name|id|100.x> --confirm # approve a device
|
||||||
|
$TS delete-device <name|id|100.x> --confirm # remove a node
|
||||||
|
|
||||||
|
# Point at a specific per-client tailnet's vault entry
|
||||||
|
$TS devices --vault tailscale/roberts.sops.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **Vault-first auth.** Credentials come from `tailscale/api-access.sops.yaml` (OAuth
|
||||||
|
client preferred, `credentials.api_key` token fallback); tailnet defaults to `-`. Never
|
||||||
|
hardcode a token. Provisioning commands are in the skill doc.
|
||||||
|
2. **Read before write.** Device ops resolve a name/100.x to the stable id first; an
|
||||||
|
**ambiguous match STOPS** (lists candidates, does nothing) — pass a specific id.
|
||||||
|
3. **Gated writes.** No `--confirm` = preview + exit, no change. Deleting a node drops it
|
||||||
|
off the tailnet; revoking a key blocks future enrollments with it.
|
||||||
|
4. **Per-client isolation.** One tailnet per client, one vault entry per tailnet. Confirm
|
||||||
|
the `--vault`/tailnet before any write.
|
||||||
|
5. **Key secrets → vault, never chat/commit.** `create-key` prints the secret once; store
|
||||||
|
it via `/vault` immediately.
|
||||||
|
6. **Bot alert after every write.** Successful writes auto-post a `[TAILSCALE] ...` line to
|
||||||
|
Discord (soft-fail); reads do not.
|
||||||
|
|
||||||
|
Use this to *drive* the tailnet; use `/rmm` to push the tagged pre-auth key onto client
|
||||||
|
machines (`tailscale-client-enroll.ps1`) once a key is minted.
|
||||||
@@ -36,17 +36,18 @@ want a periodic clean rebuild. For "I just did some work, capture it," use plain
|
|||||||
## Phase 0 — Setup
|
## Phase 0 — Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
CLAUDETOOLS_ROOT="D:/claudetools"
|
# Resolves CLAUDETOOLS_ROOT + VAULT_ROOT from identity.json and reads the caller's
|
||||||
VAULT="$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh"
|
# per-user Syncro key from the SOPS vault. Never hardcode a repo path or an API key.
|
||||||
|
# Exports: CLAUDETOOLS_ROOT, VAULT_ROOT, SYNCRO_USER, SYNCRO_BASE, SYNCRO_API_KEY.
|
||||||
|
source "$(git rev-parse --show-toplevel)/.claude/scripts/syncro-env.sh" || true
|
||||||
|
|
||||||
# Syncro auth (read-only operations only — GET requests ONLY in this skill)
|
VAULT="$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh"
|
||||||
BASE="https://computerguru.syncromsp.com/api/v1"
|
BASE="$SYNCRO_BASE" # read-only: GET requests ONLY in this skill
|
||||||
USER_ID=$(jq -r '.user // empty' "$CLAUDETOOLS_ROOT/.claude/identity.json")
|
API_KEY="$SYNCRO_API_KEY" # empty if no key vaulted for this user -> skip enrichment
|
||||||
case "$USER_ID" in
|
|
||||||
mike) API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;;
|
if [ -z "$API_KEY" ]; then
|
||||||
howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;;
|
echo "[WARNING] No Syncro key for user '${SYNCRO_USER:-unknown}' — Syncro enrichment skipped"
|
||||||
*) echo "[WARNING] Unknown user — Syncro enrichment skipped" ; API_KEY="" ;;
|
fi
|
||||||
esac
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Synthesis engine:** seed/full article drafting is done by a **Sonnet subagent** (Agent tool, `model: "sonnet"`), not Ollama. The main agent gathers sources + Syncro data, delegates the draft, then reviews it before writing. No Ollama dependency.
|
**Synthesis engine:** seed/full article drafting is done by a **Sonnet subagent** (Agent tool, `model: "sonnet"`), not Ollama. The main agent gathers sources + Syncro data, delegates the draft, then reviews it before writing. No Ollama dependency.
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ Scan for clients and projects that have session logs but no wiki article.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List all client slugs that have session-logs but no wiki article
|
# List all client slugs that have session-logs but no wiki article
|
||||||
cd D:/claudetools
|
# Repo root is machine-specific (C:/ on some, D:/ on others) — never hardcode it.
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
for dir in clients/*/session-logs; do
|
for dir in clients/*/session-logs; do
|
||||||
slug=$(echo "$dir" | sed 's|clients/||;s|/session-logs||')
|
slug=$(echo "$dir" | sed 's|clients/||;s|/session-logs||')
|
||||||
wiki="wiki/clients/$slug.md"
|
wiki="wiki/clients/$slug.md"
|
||||||
@@ -96,13 +97,17 @@ For every client wiki article that contains a `Syncro customer ID` line, pull li
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BASE="https://computerguru.syncromsp.com/api/v1"
|
# Resolves CLAUDETOOLS_ROOT + VAULT_ROOT from identity.json and reads the caller's
|
||||||
USER_ID=$(jq -r '.user // empty' "$CLAUDETOOLS_ROOT/.claude/identity.json")
|
# per-user Syncro key from the SOPS vault. Never hardcode a repo path or an API key.
|
||||||
case "$USER_ID" in
|
source "$(git rev-parse --show-toplevel)/.claude/scripts/syncro-env.sh" || true
|
||||||
mike) API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;;
|
|
||||||
howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;;
|
BASE="$SYNCRO_BASE"
|
||||||
*) echo "[SYNCRO] No API key for user '$USER_ID' — skipping Step 6" ; exit 0 ;;
|
API_KEY="$SYNCRO_API_KEY"
|
||||||
esac
|
|
||||||
|
if [ -z "$API_KEY" ]; then
|
||||||
|
echo "[SYNCRO] No API key for user '${SYNCRO_USER:-unknown}' — skipping Step 6"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
### For Each Client Article
|
### For Each Client Article
|
||||||
|
|||||||
@@ -1,208 +1,236 @@
|
|||||||
# Memory Index
|
# Memory Index
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
- [ACG resource map](reference_resource_map.md) — **READ THIS FIRST** when a task references a server/service/tenant/API. What we have access to, how to connect from this machine, per-machine exceptions, gotchas. Points at the detail files below.
|
- [ACG resource map](reference_resource_map.md) — **READ THIS FIRST** when a task references a server/service/tenant/API. What we have access to, how to connect from this machine, per-machine exceptions, gotchas. Points at the detail files below.
|
||||||
- [ALIS (Medtelligent)](reference_alis_medtelligent.md) — Cascades assisted-living EHR. API host api.alisonline.com, community 622; username must be tenant-qualified (howard.enos@cascadestucson). Staff are READ-ONLY via API — create/change staff via web-UI Staff Import .xls. Use the `alis` skill.
|
- [MSP360 backup monitoring](reference_msp360_backup_monitoring.md) — Authoritative "is client X actually backing up / last run" source for the whole fleet (NOT B2 bucket contents). MSP360 MBS API `/api/Monitoring`, vault `msp-tools/msp360-api.sops.yaml`. CompanyName also IDs B2 buckets (ACG-PST=Peaceful Spirit, ACG-TCA=Tucson Coin).
|
||||||
- [GuruRMM User Manager](reference_gururmm_user_manager.md) — GuruRMM has a built-in per-agent User Manager tab (reset_password/enable/disable/groups for local+domain+AAD endpoint users; domain users only on a DC via `is_dc`). Use it, NOT raw Set-ADAccountPassword via /rmm. Endpoints: /api/agents/{id}/users + /users/action.
|
- [Backup checks only for billed clients](feedback_backup_check_only_billed.md) — Don't scan backup status for clients with no billed Data Backup line (Syncro schedule qty=0); verify billed clients only. Non-billed-but-backing-up = revenue-leak question for Mike/Winter, not a scan target.
|
||||||
- [RMM map network drive (err67 double-hop)](reference_rmm_map_network_drive.md) — Pushing a persistent mapped drive to a remote share via /rmm user_session fails with err67/1702 (impersonated token = no network cred/double-hop). Plant HKCU:\Network\<drv> keys + cmdkey; mounts at next interactive logon. Immediate visibility needs the live session (ScreenConnect).
|
- [ALIS (Medtelligent)](reference_alis_medtelligent.md) — Cascades assisted-living EHR. API host api.alisonline.com, community 622; username must be tenant-qualified (howard.enos@cascadestucson). Staff are READ-ONLY via API — create/change staff via web-UI Staff Import .xls. Use the `alis` skill.
|
||||||
- [exchange-op = all-access Exchange tier](feedback_exchange_op_all_access.md) — STOP claiming "no tier can write mail." Exchange Operator app = Exchange Admin role + full_access_as_app + Exchange.ManageAsApp = full all-access (move mail, rules, config, EWS). Default to `exchange-op` for any Exchange write.
|
- [GuruRMM User Manager](reference_gururmm_user_manager.md) — GuruRMM has a built-in per-agent User Manager tab (reset_password/enable/disable/groups for local+domain+AAD endpoint users; domain users only on a DC via `is_dc`). Use it, NOT raw Set-ADAccountPassword via /rmm. Endpoints: /api/agents/{id}/users + /users/action.
|
||||||
- [Tedards tenant facts](reference_tedards_tenant_facts.md) — Bill Tedards law office; tenant `4fcbb1f4…`; bt@/y226@ mailboxes; matter-number filing; UAL ingestion OFF; 9 synced devices; botched-import DUPLICATE folder.
|
- [RMM map network drive (err67 double-hop)](reference_rmm_map_network_drive.md) — Pushing a persistent mapped drive to a remote share via /rmm user_session fails with err67/1702 (impersonated token = no network cred/double-hop). Plant HKCU:\Network\<drv> keys + cmdkey; mounts at next interactive logon. Immediate visibility needs the live session (ScreenConnect).
|
||||||
- [Investigator EXO ManageAsApp gap](reference_investigator_exo_manageasapp_gap.md) — Security Investigator app lacks `Exchange.ManageAsApp` (only `full_access_as_app`) so `investigator-exo` 401s on EXO adminapi; use `exchange-op` tier for InvokeCommand.
|
- [exchange-op = all-access Exchange tier](feedback_exchange_op_all_access.md) — STOP claiming "no tier can write mail." Exchange Operator app = Exchange Admin role + full_access_as_app + Exchange.ManageAsApp = full all-access (move mail, rules, config, EWS). Default to `exchange-op` for any Exchange write.
|
||||||
- [Tailscale subnet-route key expiry](reference_tailscale_subnet_key_expiry.md) — "internet OK but all of 172.16.3.x (Gitea .20, RMM/coord .30) dead" = Tailscale infra-node KEY EXPIRY (pfSense subnet router advertises 172.16.0.0/22), NOT a LAN outage; expiry now disabled on infra nodes (2026-06-25). Fallback: gururmm-server direct at tailnet 100.86.12.15:3001.
|
- [Tedards tenant facts](reference_tedards_tenant_facts.md) — Bill Tedards law office; tenant `4fcbb1f4…`; bt@/y226@ mailboxes; matter-number filing; UAL ingestion OFF; 9 synced devices; botched-import DUPLICATE folder.
|
||||||
- [GravityZone support center](reference_gravityzone_support.md) — Authoritative Bitdefender GravityZone product + Public API docs; use to confirm UNVERIFIED `bitdefender` skill methods/param shapes (push setPushEventSettings, assignPolicy, report/account writes, maintenancewindows/integrations names).
|
- [Investigator EXO ManageAsApp gap](reference_investigator_exo_manageasapp_gap.md) — Security Investigator app lacks `Exchange.ManageAsApp` (only `full_access_as_app`) so `investigator-exo` 401s on EXO adminapi; use `exchange-op` tier for InvokeCommand.
|
||||||
- [GURU-5070 Rust toolchain](reference_guru5070_rust_toolchain.md) — GURU-5070 now has cargo + MSVC + protoc; build/clippy/test guru-connect LOCALLY (set PROTOC to the winget path) instead of the build host. CI only clippy-checks the Linux server, not the Windows agent.
|
- [Windows edition upgrade via RMM](reference_windows_edition_upgrade_rmm.md) — Verified Home->Pro/Pro-for-Workstations fleet procedure: `changepk.exe /ProductKey <generic Pro>` (NOT DISM Set-Edition — Error 50) -> reboot with `Restart-Computer -Force` (`shutdown /r /t N` is silently dropped in SYSTEM session) -> `slmgr /ipk <MAK>` + /ato. The vault MAK is a Pro-for-WORKSTATIONS key (auto-upgrades edition past plain Pro).
|
||||||
- [ACG Office Network Infrastructure](infra_office_network.md) — IPs/hosts/roles for pfSense/Jupiter/VMs/Docker. Check before assuming; .21 (Uranus) is storage.
|
- [Tailscale subnet-route key expiry](reference_tailscale_subnet_key_expiry.md) — "internet OK but all of 172.16.3.x (Gitea .20, RMM/coord .30) dead" = Tailscale infra-node KEY EXPIRY (pfSense subnet router advertises 172.16.0.0/22), NOT a LAN outage; expiry now disabled on infra nodes (2026-06-25). Fallback: gururmm-server direct at tailnet 100.86.12.15:3001.
|
||||||
- [Power Failure Runbook](../POWER_FAILURE_RUNBOOK.md) — Recovery order after a power event: Tailscale routes, libvirt/VMs, Seafile, NPM/DNS.
|
- [GravityZone support center](reference_gravityzone_support.md) — Authoritative Bitdefender GravityZone product + Public API docs; use to confirm UNVERIFIED `bitdefender` skill methods/param shapes (push setPushEventSettings, assignPolicy, report/account writes, maintenancewindows/integrations names).
|
||||||
- [Syncro API — Invoice Verification Pattern](syncro_invoice_verification_pattern.md) — /invoices?customer_id=X returns no ticket linkage; query /invoices/{number} for ticket_id. Compare by ticket ID, not number.
|
- [GURU-5070 Rust toolchain](reference_guru5070_rust_toolchain.md) — toolchain installed BUT a WDAC/Smart App Control policy (since 2026-07-04) BLOCKS running rustc/cargo/sops/openssl locally (os error 4551) — build the Windows agent on Beast, not here. Toolchain paths + CI gates still valid for reference.
|
||||||
- [Syncro RMM policies = API-impossible](reference_syncro_rmm_api_gui_only.md) — policy create/assign/folder-move is GUI-ONLY; `policy_folder_id` is read-only on PUT (live-proven), policy endpoints 404, /policy_folders 401 scope-gated. Don't build /syncro move-asset; use `bitdefender` for API policy work.
|
- [ACG Office Network Infrastructure](infra_office_network.md) — IPs/hosts/roles for pfSense/Jupiter/VMs/Docker. Check before assuming; .21 (Uranus) is storage.
|
||||||
- [Datto EDR detection behavior](reference_datto_edr_detection_behavior.md) — alert `sourceType`: `av`=Datto AV signature, `rule`=EDR reputation detection (both via `edr.py detections`). EDR is reputation-based not structural (wire known-bad file as autostart exe to trip it; loose files aren't surveyed). AV is tamper-protected (console-only disable); disabling Datto AV uninstalls it + Defender auto-reactivates (AMSI blocks scripts with literal EICAR → build from char codes). Verified live on RMM-TEST-MACHINE.
|
- [Power Failure Runbook](../POWER_FAILURE_RUNBOOK.md) — Recovery order after a power event: Tailscale routes, libvirt/VMs, Seafile, NPM/DNS.
|
||||||
- [Approval Workflow: Tools vs Projects](approval-workflow-tools-vs-projects.md) — Tools (remediation, scripts): Howard/Claude with approval. Projects (GuruRMM): Mike approval for architecture/features; Howard can handle merges/deploys himself (2026-06-21); bugs→bug list.
|
- [Syncro API — Invoice Verification Pattern](syncro_invoice_verification_pattern.md) — /invoices?customer_id=X returns no ticket linkage; query /invoices/{number} for ticket_id. Compare by ticket ID, not number.
|
||||||
- [CDP Chrome driver](reference_cdp_chrome_driver.md) — Drive Chrome via DevTools Protocol (.claude/scripts/cdp.py): visible window + screenshots-to-disk so Gemini/Grok can SEE the live site. Use localhost not 127.0.0.1; dedicated profile. Antigravity-style.
|
- [Syncro asset/RMM IS API-drivable](reference_syncro_rmm_api_gui_only.md) — CORRECTED 2026-07-10: asset read/create/update, patches, installed_apps, rmm_alerts all work via API; ONLY policy folders + `policy_folder_id` move need the `Assets - Policy Change` token scope (401 today). Archive stays GUI-only. Full catalog: `.claude/standards/syncro/api-reference.md`.
|
||||||
- [Firefox driver (ff.py)](reference_ff_firefox_driver.md) — PREFERRED browser driver. Drive Firefox via Playwright (.claude/scripts/ff.py): daemon on :9333, persistent profile, nav/shot/click/type/eval/console/network. Mike dislikes Chrome; claude-in-chrome connector disabled 2026-06-06.
|
- [Datto EDR detection behavior](reference_datto_edr_detection_behavior.md) — alert `sourceType`: `av`=Datto AV signature, `rule`=EDR reputation detection (both via `edr.py detections`). EDR is reputation-based not structural (wire known-bad file as autostart exe to trip it; loose files aren't surveyed). AV is tamper-protected (console-only disable); disabling Datto AV uninstalls it + Defender auto-reactivates (AMSI blocks scripts with literal EICAR → build from char codes). Verified live on RMM-TEST-MACHINE.
|
||||||
- [Community Forum (Flarum)](reference_community_forum.md) — Flarum forum at community.azcomputerguru.com, API access, database, posting workflow.
|
- [Bug & Project Tracker (Gitea Issues)](project_bug_tracker_gitea.md) — Official tracker for bugs/features/skills across GuruRMM, GuruConnect, ClaudeTools. Dashboard + `bug-tracker` skill. Set up 2026-07-17.
|
||||||
- [Radio Show Website](reference_radio_website.md) — Astro static site at radio.azcomputerguru.com on IX server.
|
- [Approval Workflow: Tools vs Projects](approval-workflow-tools-vs-projects.md) — Tools (remediation, scripts): Howard/Claude with approval. Projects (GuruRMM): Mike approval for architecture/features; Howard can handle merges/deploys himself (2026-06-21); bugs→bug list.
|
||||||
- [IX Server Access](reference_ix_server_access.md) — `ix.azcomputerguru.com` / 172.16.3.10. Reachable when Tailscale is on (no VPN). SSH currently uses sshpass with root password; key auth from GURU-5070 not configured yet (was CachyOS, now Win11 — verify).
|
- [CDP Chrome driver](reference_cdp_chrome_driver.md) — Drive Chrome via DevTools Protocol (.claude/scripts/cdp.py): visible window + screenshots-to-disk so Gemini/Grok can SEE the live site. Use localhost not 127.0.0.1; dedicated profile. Antigravity-style.
|
||||||
- [Cloudflare access](reference_cloudflare_access.md) — Cloudflare API creds in SOPS `services/cloudflare.sops.yaml` (full DNS + account tokens; azcomputerguru zone_id 1beb9917...). azcomputerguru.com DNS is on Cloudflare (not IX) — edit via Cloudflare API, not whmapi1.
|
- [Firefox driver (ff.py)](reference_ff_firefox_driver.md) — PREFERRED browser driver. Drive Firefox via Playwright (.claude/scripts/ff.py): daemon on :9333, persistent profile, nav/shot/click/type/eval/console/network. Mike dislikes Chrome; claude-in-chrome connector disabled 2026-06-06.
|
||||||
- [Matomo Analytics](reference_matomo_analytics.md) — Self-hosted analytics at analytics.azcomputerguru.com, site IDs, tracking for all 3 sites.
|
- [Community Forum (Flarum)](reference_community_forum.md) — Flarum forum at community.azcomputerguru.com, API access, database, posting workflow.
|
||||||
- [TickTick Integration](reference_ticktick_integration.md) — OAuth API integration, MCP server, SOPS vault creds, project/task CRUD.
|
- [Radio Show Website](reference_radio_website.md) — Astro static site at radio.azcomputerguru.com on IX server.
|
||||||
- [Client Docs Structure](reference_client_docs_structure.md) — clients/<name>/docs/ layout (overview, network, servers, cloud, security, rmm). Template: clients/_client_template/.
|
- [IX Server Access](reference_ix_server_access.md) — `ix.azcomputerguru.com` / 172.16.3.10. Reachable when Tailscale is on (no VPN). SSH currently uses sshpass with root password; key auth from GURU-5070 not configured yet (was CachyOS, now Win11 — verify).
|
||||||
- [MSP Audit Scripts](reference_msp_audit_scripts.md) — server_audit.ps1 / workstation_audit.ps1 at projects/msp-tools/msp-audit-scripts/.
|
- [Cloudflare access](reference_cloudflare_access.md) — Cloudflare API creds in SOPS `services/cloudflare.sops.yaml` (full DNS + account tokens; azcomputerguru zone_id 1beb9917...). azcomputerguru.com DNS is on Cloudflare (not IX) — edit via Cloudflare API, not whmapi1.
|
||||||
- [Pluto Build Server](reference_pluto_build_server.md) — Windows build VM: hostname PLUTO = Unraid VM "Claude-Builder" = 172.16.3.36 (all the same box). MSVC + WiX + Azure Trusted Signing. Drive via /rmm (agent enrolls as PLUTO) when SSH key isn't authorized.
|
- [Matomo Analytics](reference_matomo_analytics.md) — Self-hosted analytics at analytics.azcomputerguru.com, site IDs, tracking for all 3 sites.
|
||||||
- [Coord /messages API shape](reference_coord_messages_api_shape.md) — GET /api/coord/messages returns {total,skip,limit,messages[]} NOT a bare array; parse .messages[], strip control chars, read flag may be null.
|
- [TickTick Integration](reference_ticktick_integration.md) — OAuth API integration, MCP server, SOPS vault creds, project/task CRUD.
|
||||||
- [Gitea API credential](reference_gitea_api_credential.md) — Gitea API (PRs/merges) as howard uses services/gitea-howard.sops.yaml password on internal http://172.16.3.20:3000; NOT the gururmm-server SSH password.
|
- [Client Docs Structure](reference_client_docs_structure.md) — clients/<name>/docs/ layout (overview, network, servers, cloud, security, rmm). Template: clients/_client_template/.
|
||||||
- [Gitea Internal API Access](reference_gitea_internal.md) — git.azcomputerguru.com is NOT behind Cloudflare — it's the office Cox IP NAT'd to NPM (openresty) on Jupiter. Prefer internal 172.16.3.20:3000 for reliability (bypasses NPM SSL-renewal reload blips).
|
- [Client Wi-Fi Inventory](reference_client_wifi_inventory.md) — Building a fleet Wi-Fi list to connect onsite without asking. Passwords → vault `clients/<slug>/wifi.sops.yaml` (`credentials.<key>_ssid/_password`); readable index → `wiki/reference/client-wifi.md`. `/wifi` importer deferred (structure-only 2026-07-06).
|
||||||
- [Gitea git-op latency](reference_gitea_git_op_latency.md) — SSH (.20:2222) is SLOWEST (~1.5s); internal HTTP+token ~0.55s; SOPS lookup only ~0.33s. Don't switch to SSH for speed. Gitea SSH is .20:2222 (API ssh_url .21 is wrong).
|
- [Git Bash TZ ignored](feedback_gitbash_tz_ignored.md) — `TZ=America/Phoenix date` in Git Bash silently prints UTC (no tzdata) — confidently-wrong "current time". Use PowerShell `Get-Date` (machine already AZ) or plain `date` with no TZ override for now-time on Windows.
|
||||||
- [GuruRMM technical reference](reference_gururmm.md) — Server (172.16.3.30) layout + downloads dir `/var/www/gururmm/downloads` + `.channel` sidecar rollout control (stable/beta) + privileged server access via the server's OWN root RMM agent (hostname `gururmm`, no SSH needed; plink fallback) + API + `context=user_session` (WTS impersonation) + build-pipeline vendoring at `deploy/build-pipeline/` + Linux agent systemd sandbox trap.
|
- [Discord read replies](feedback_discord_read_replies.md) — Discord DM replies don't come through coord/repo sync. Use `discord-dm.sh read <user>` to see if mike/winter/rob actually answered before assuming silence.
|
||||||
- [GuruRMM command timeout_seconds](reference_gururmm_command_timeout_seconds.md) — agent command dispatch honors `timeout_seconds`, NOT `timeout`; long jobs die ~300s / go zombie (`running`, empty stdout) otherwise. Cost Birth Biologic a full day.
|
- [MSP Audit Scripts](reference_msp_audit_scripts.md) — server_audit.ps1 / workstation_audit.ps1 at projects/msp-tools/msp-audit-scripts/.
|
||||||
- [SharePoint Graph large-file upload](reference_sharepoint_graph_large_file_upload.md) — <4MB simple PUT, >=4MB MUST use chunked upload session (Content-Range); `\\?\` long paths; idempotent size-check; verify counts via /root/delta; single stream ~40Mbps (SPO throttle).
|
- [Pluto Build Server](reference_pluto_build_server.md) — Windows build VM: hostname PLUTO = Unraid VM "Claude-Builder" = 172.16.3.36 (all the same box). MSVC + WiX + Azure Trusted Signing. Drive via /rmm (agent enrolls as PLUTO) when SSH key isn't authorized.
|
||||||
- [RMM-spawn headless Claude](reference_rmm_spawn_headless_claude.md) — run `claude -p` on any RMM-managed Windows box with Claude Code (reaches coord-isolated sites like AD2); use `context:user_session`, UNSET the stale machine `ANTHROPIC_API_KEY` (shadows OAuth → "Invalid API key"), detach + poll a DONE marker. Validated on AD2 2026-07-01.
|
- [Coord /messages API shape](reference_coord_messages_api_shape.md) — GET /api/coord/messages returns {total,skip,limit,messages[]} NOT a bare array; parse .messages[], strip control chars, read flag may be null.
|
||||||
- [RMM agent update model](rmm-agent-update-model.md) — Agent updates are server-PUSH on heartbeat (no self-poll); available versions = filesystem scan needing a `.sha256`; promote flips `.channel` sidecars beta→stable globally. Two stranders: beta-first freezes stable until an explicit promote; agents older than ~0.6.50 re-enroll with a NEW device_id/agent row when updated.
|
- [Gitea API credential](reference_gitea_api_credential.md) — Gitea API (PRs/merges) as howard uses services/gitea-howard.sops.yaml password on internal http://172.16.3.20:3000; NOT the gururmm-server SSH password.
|
||||||
- [GuruRMM physical server storage](gururmm-physical-server-storage.md) — New box 172.16.1.231 (temp IP→will be .30), Ubuntu 26.04, ssh key `gururmm-physical`/alias `gururmm-new`. SSD (915G root) = HOT (PG default tablespace + WAL + builds); HDD ext4 at `/data` = COLD (`gururmm_cold` PG tablespace for aged `agent_logs` partitions + downloads + backups + archive). The #3 retention answer.
|
- [Gitea Internal API Access](reference_gitea_internal.md) — git.azcomputerguru.com is NOT behind Cloudflare — it's the office Cox IP NAT'd to NPM (openresty) on Jupiter. Prefer internal 172.16.3.20:3000 for reliability (bypasses NPM SSL-renewal reload blips).
|
||||||
- [Trebesch DESKTOP-QNP3ON5 shell replacement](reference_trebesch_qnp3on5.md) — AT Trebesch box runs an Explorer shell replacement; explorer.exe owner check returns blank — use Win32_ComputerSystem.UserName. GuruRMM SWIFT-LION-2892.
|
- [Gitea git-op latency](reference_gitea_git_op_latency.md) — SSH (.20:2222) is SLOWEST (~1.5s); internal HTTP+token ~0.55s; SOPS lookup only ~0.33s. Don't switch to SSH for speed. Gitea SSH is .20:2222 (API ssh_url .21 is wrong).
|
||||||
- [reference_backblaze_storage_rate](reference_backblaze_storage_rate.md) -- ACG's Backblaze B2 storage cost rate ($0.00695/GB) for the GuruRMM mspbackups storage-cost calculation
|
- [GuruRMM technical reference](reference_gururmm.md) — Server (172.16.3.30) layout + downloads dir `/var/www/gururmm/downloads` + `.channel` sidecar rollout control (stable/beta) + privileged server access via the server's OWN root RMM agent (hostname `gururmm`, no SSH needed; plink fallback) + API + `context=user_session` (WTS impersonation) + build-pipeline vendoring at `deploy/build-pipeline/` + Linux agent systemd sandbox trap.
|
||||||
- [Unraid VM no-IP causes](unraid-windows-vm-virtio-no-ip.md) — PRIMARY (general "new VMs stopped getting IPs lately"): Docker sets bridge-nf-call-iptables=1, so br0 VM DHCP OFFERs hit DOCKER-FORWARD (no br0 ACCEPT) and get dropped; new VMs can't complete DORA (existing renew via ESTABLISHED). Fix `=0` runtime (needs persistent post-Docker hook; not yet persisted on Jupiter). SECONDARY (Windows VM): virtio-net has no in-box driver -> use e1000 or virtio-win. Diagnose: tcpdump DHCP on pfSense; /sys vnetN rx_packets.
|
- [GuruRMM command timeout_seconds](reference_gururmm_command_timeout_seconds.md) — agent command dispatch honors `timeout_seconds`, NOT `timeout`; long jobs die ~300s / go zombie (`running`, empty stdout) otherwise. Cost Birth Biologic a full day.
|
||||||
- [Starr Pass mail routing](reference_starrpass_mail_routing.md) — starrpass.com is DIRECT to MS (EOP/Defender, tenant 222450dd…); only devconllc.com is on Mailprotector (MP acct 16170). Check @starrpass.com quarantine/rejects via remediation-tool, not Mailprotector.
|
- [SharePoint Graph large-file upload](reference_sharepoint_graph_large_file_upload.md) — <4MB simple PUT, >=4MB MUST use chunked upload session (Content-Range); `\\?\` long paths; idempotent size-check; verify counts via /root/delta; single stream ~40Mbps (SPO throttle).
|
||||||
- [INKY outbound breaks DMARC](reference_inky_outbound_breaks_dmarc.md) — Reverse-resolve DMARC rua failing IPs before blaming a sender: ipw-outbound.inkyphishfence.com / us.cloud-sec-av.com = INKY re-injection breaking DKIM+SPF. INKY is in-M365 (connectors+transport rules) per enrolled tenant, but hosting-level (IX/cPanel website) outbound also routes through it independent of M365 enrollment. Fix is INKY-side (outbound DKIM/SPF/ARC), not cPanel DNS.
|
- [Exchange REST uses exchange-op tier](reference_exchange_online_exchangeop_tier.md) — ALL Exchange adminapi (Get-Mailbox/InboxRule/MailboxPermission, Set-*) goes through `exchange-op`, NEVER `investigator-exo` (401s: Security Investigator app lacks `Exchange.ManageAsApp`). 401=wrong app→exchange-op; 403=missing Exchange Admin role. Recurred 6+ times.
|
||||||
- [Syncro prepay: full-GET only](feedback_syncro_prepay_full_get_only.md) — read prepay_hours ONLY from GET /customers/{id}; the customer search/list endpoint returns null/stale prepay. Never assert "no block" in a billing preview from search data.
|
- [RMM-spawn headless Claude](reference_rmm_spawn_headless_claude.md) — run `claude -p` on any RMM-managed Windows box with Claude Code (reaches coord-isolated sites like AD2); use `context:user_session`, UNSET the stale machine `ANTHROPIC_API_KEY` (shadows OAuth → "Invalid API key"), detach + poll a DONE marker. Validated on AD2 2026-07-01.
|
||||||
- [Syncro priority/type format](feedback_syncro_priority_type_format.md) — every ticket create needs a number-prefixed priority ("2 Normal", not bare "Normal" which renders blank) AND a valid problem_type. Winter flagged #32193/#32194. Use the syncro skill's create flow.
|
- [RMM agent update model](rmm-agent-update-model.md) — Agent updates are server-PUSH on heartbeat (no self-poll); available versions = filesystem scan needing a `.sha256`; promote flips `.channel` sidecars beta→stable globally. Two stranders: beta-first freezes stable until an explicit promote; agents older than ~0.6.50 re-enroll with a NEW device_id/agent row when updated.
|
||||||
- [Syncro line-item category](feedback_syncro_line_item_category.md) — product_category is a controlled vocab: never invent one, never invoice a line whose product has product_category:null (blank CATEGORY breaks QB mapping). Pre-flight GET /products/<id>. Winter fixed Cascades 67887/67890 "Windows Pro Upgrade".
|
- [GuruRMM physical server storage](gururmm-physical-server-storage.md) — New box 172.16.1.231 (temp IP→will be .30), Ubuntu 26.04, ssh key `gururmm-physical`/alias `gururmm-new`. SSD (915G root) = HOT (PG default tablespace + WAL + builds); HDD ext4 at `/data` = COLD (`gururmm_cold` PG tablespace for aged `agent_logs` partitions + downloads + backups + archive). The #3 retention answer.
|
||||||
- [RMM drive-map Explorer refresh](reference_rmm_drive_map_explorer_refresh.md) — drive mapped via RMM user_session works but the user's running Explorer won't show it until SHChangeNotify(DRIVEADD); also UNC \\ gets eaten in heredoc+jq, build it from [char]92.
|
- [Trebesch DESKTOP-QNP3ON5 shell replacement](reference_trebesch_qnp3on5.md) — AT Trebesch box runs an Explorer shell replacement; explorer.exe owner check returns blank — use Win32_ComputerSystem.UserName. GuruRMM SWIFT-LION-2892.
|
||||||
- [Verify live before acting](feedback_verify_live_before_acting.md) — pull LIVE data (OMSA/iDRAC/live API) before acting on a hardware/infra flag; wiki/logs go stale. Cascades CS-SERVER "degraded RAID" was 9-day-stale (mirror self-recovered, SSDs bought needlessly). Windows can't see RAID member health.
|
- [reference_backblaze_storage_rate](reference_backblaze_storage_rate.md) -- ACG's Backblaze B2 storage cost rate ($0.00695/GB) for the GuruRMM mspbackups storage-cost calculation
|
||||||
- [Windows Pro upgrade billing](feedback_windows_pro_upgrade_billing.md) — ACG MAK key (vault infrastructure/windows-pro-mak, Mike's ACG key) for Home->Pro upgrades; RULE: invoice $99 PER machine activated, name the machine on the line item, bill after success. Each use consumes a MAK count.
|
- [Unraid VM no-IP causes](unraid-windows-vm-virtio-no-ip.md) — PRIMARY (general "new VMs stopped getting IPs lately"): Docker sets bridge-nf-call-iptables=1, so br0 VM DHCP OFFERs hit DOCKER-FORWARD (no br0 ACCEPT) and get dropped; new VMs can't complete DORA (existing renew via ESTABLISHED). Fix `=0` runtime (needs persistent post-Docker hook; not yet persisted on Jupiter). SECONDARY (Windows VM): virtio-net has no in-box driver -> use e1000 or virtio-win. Diagnose: tcpdump DHCP on pfSense; /sys vnetN rx_packets.
|
||||||
- [AAD Connect msDS-KeyCredentialLink writeback](reference_aadconnect_keycredlink_writeback.md) — "completed-export-errors" + 8344 INSUFF_ACCESS_RIGHTS on a protected admin account = WHfB key writeback blocked by AdminSDHolder. Diagnose with csexport /f:x; fix with dsacls WP;msDS-KeyCredentialLink on AdminSDHolder + SDProp.
|
- [Starr Pass mail routing](reference_starrpass_mail_routing.md) — starrpass.com is DIRECT to MS (EOP/Defender, tenant 222450dd…); only devconllc.com is on Mailprotector (MP acct 16170). Check @starrpass.com quarantine/rejects via remediation-tool, not Mailprotector.
|
||||||
- [UniFi Site Manager cloud API](reference_unifi_site_manager_api.md) — `api.ui.com` + `X-API-KEY` (vault `services/unifi-site-manager`) = remote access to the WHOLE ACG UniFi fleet (~36 consoles) outside UOS. Tier1 `/v1/hosts|sites|devices|isp-metrics` = inventory+health+WAN. Tier2 CONNECTOR `/v1/connector/consoles/{id}/proxy/network/api/s/default/stat/{device,sta}` = **full UOS parity** (per-radio cu_total airtime + per-client RSSI) for ANY console, remote. Backend `unifi-wifi/scripts/gw-sitemanager.sh` (`fleet|devices|sites|isp|net`). Standalone UDM WAN SSH usually firewalled; per-console SSH pw at `clients/<slug>/udm-ssh`.
|
- [INKY outbound breaks DMARC](reference_inky_outbound_breaks_dmarc.md) — Reverse-resolve DMARC rua failing IPs before blaming a sender: ipw-outbound.inkyphishfence.com / us.cloud-sec-av.com = INKY re-injection breaking DKIM+SPF. INKY is in-M365 (connectors+transport rules) per enrolled tenant, but hosting-level (IX/cPanel website) outbound also routes through it independent of M365 enrollment. Fix is INKY-side (outbound DKIM/SPF/ARC), not cPanel DNS.
|
||||||
- [reference_sqlx_migrations_immutable](reference_sqlx_migrations_immutable.md) -- NEVER edit an already-applied sqlx migration file — even a comment. sqlx::migrate! checksums each file at compile time and validates against _sqlx_migrations at startup; a changed checksum crash-loops the server with "migration N was previously applied but has been modified". Code review MUST flag any edit to an applied migration.
|
- [Syncro prepay: full-GET only](feedback_syncro_prepay_full_get_only.md) — read prepay_hours ONLY from GET /customers/{id}; the customer search/list endpoint returns null/stale prepay. Never assert "no block" in a billing preview from search data.
|
||||||
- [AD2 SSH MTU blackhole](ad2-ssh-mtu-blackhole.md) — AD2 SSH "lockouts"/mid-session read-errors over the Dataforth OpenVPN were a PMTU blackhole (tunnel PMTU ~1424 vs adapter MTU 1500), NOT a ban/account-lockout/flaky tunnel. Fix: pin the OpenVPN adapter MTU to 1400 (done on GURU-5070 via its SYSTEM RMM agent); permanent = `mssfix 1360` on the OpenVPN server. Diagnose over RMM, not SSH.
|
- [Syncro priority/type format](feedback_syncro_priority_type_format.md) — every ticket create needs a number-prefixed priority ("2 Normal", not bare "Normal" which renders blank) AND a valid problem_type. Winter flagged #32193/#32194. Use the syncro skill's create flow.
|
||||||
- [DSCA33/45 resolved via Hoffman](project_dsca33_45_resolved_via_hoffman.md) — The "lost" DSCA33/45 spec files are recoverable from the Hoffman API (original certs survived the wipe); do NOT ask John. 56/58 models mined into projects/dataforth-dos/dsca33-45-templates.json; only DSCA33-1948 + DSCA45-1746 (24 units) lack an original. AD2 handoff: DSCA33-45-HOFFMAN-RECOVERY-2026-06-18.md.
|
- [Syncro line-item category](feedback_syncro_line_item_category.md) — product_category is a controlled vocab: never invent one, never invoice a line whose product has product_category:null (blank CATEGORY breaks QB mapping). Pre-flight GET /products/<id>. Winter fixed Cascades 67887/67890 "Windows Pro Upgrade".
|
||||||
- [AD2 comms via sync only](ad2-comms-via-sync-only.md) — The AD2 Dataforth-box Claude session is coord-API-isolated (Gitea only); coord msg/lock/todo never reach it. Coordinate with AD2 ONLY via git /sync (committed docs + ## Note blocks).
|
- [RMM drive-map Explorer refresh](reference_rmm_drive_map_explorer_refresh.md) — drive mapped via RMM user_session works but the user's running Explorer won't show it until SHChangeNotify(DRIVEADD); also UNC \\ gets eaten in heredoc+jq, build it from [char]92.
|
||||||
- [reference_syncro_agent_handle_leak](reference_syncro_agent_handle_leak.md) -- RDS "no available computers in the pool" (0x3/0x408) can really be a SyncroLive.Agent.Runner handle leak starving the box. How to spot + fix.
|
- [Verify live before acting](feedback_verify_live_before_acting.md) — pull LIVE data (OMSA/iDRAC/live API) before acting on a hardware/infra flag; wiki/logs go stale. Cascades CS-SERVER "degraded RAID" was 9-day-stale (mirror self-recovered, SSDs bought needlessly). Windows can't see RAID member health.
|
||||||
|
- [Windows Pro upgrade billing](feedback_windows_pro_upgrade_billing.md) — ACG MAK key (vault infrastructure/windows-pro-mak, Mike's ACG key) for Home->Pro upgrades; RULE: invoice $99 PER machine activated, name the machine on the line item, bill after success. Each use consumes a MAK count.
|
||||||
## Users
|
- [AAD Connect msDS-KeyCredentialLink writeback](reference_aadconnect_keycredlink_writeback.md) — "completed-export-errors" + 8344 INSUFF_ACCESS_RIGHTS on a protected admin account = WHfB key writeback blocked by AdminSDHolder. Diagnose with csexport /f:x; fix with dsacls WP;msDS-KeyCredentialLink on AdminSDHolder + SDProp.
|
||||||
- [Howard Enos](user_howard.md) — Mike's brother, technician, full access. Machines: ACG-TECH03L, Howard-Home (authoritative in users.json).
|
- [UniFi Site Manager cloud API](reference_unifi_site_manager_api.md) — `api.ui.com` + `X-API-KEY` (vault `services/unifi-site-manager`) = remote access to the WHOLE ACG UniFi fleet (~36 consoles) outside UOS. Tier1 `/v1/hosts|sites|devices|isp-metrics` = inventory+health+WAN. Tier2 CONNECTOR `/v1/connector/consoles/{id}/proxy/network/api/s/default/stat/{device,sta}` = **full UOS parity** (per-radio cu_total airtime + per-client RSSI) for ANY console, remote. Backend `unifi-wifi/scripts/gw-sitemanager.sh` (`fleet|devices|sites|isp|net`). Standalone UDM WAN SSH usually firewalled; per-console SSH pw at `clients/<slug>/udm-ssh`.
|
||||||
- [Mike — font preference](user_font_preference.md) — Mike prefers Lucida Console for monospace UI.
|
- [reference_sqlx_migrations_immutable](reference_sqlx_migrations_immutable.md) -- NEVER edit an already-applied sqlx migration file — even a comment. sqlx::migrate! checksums each file at compile time and validates against _sqlx_migrations at startup; a changed checksum crash-loops the server with "migration N was previously applied but has been modified". Code review MUST flag any edit to an applied migration.
|
||||||
|
- [AD2 SSH MTU blackhole](ad2-ssh-mtu-blackhole.md) — AD2 SSH "lockouts"/mid-session read-errors over the Dataforth OpenVPN were a PMTU blackhole (tunnel PMTU ~1424 vs adapter MTU 1500), NOT a ban/account-lockout/flaky tunnel. Fix: pin the OpenVPN adapter MTU to 1400 (done on GURU-5070 via its SYSTEM RMM agent); permanent = `mssfix 1360` on the OpenVPN server. Diagnose over RMM, not SSH.
|
||||||
## Feedback
|
- [DSCA33/45 resolved via Hoffman](project_dsca33_45_resolved_via_hoffman.md) — The "lost" DSCA33/45 spec files are recoverable from the Hoffman API (original certs survived the wipe); do NOT ask John. 56/58 models mined into projects/dataforth-dos/dsca33-45-templates.json; only DSCA33-1948 + DSCA45-1746 (24 units) lack an original. AD2 handoff: DSCA33-45-HOFFMAN-RECOVERY-2026-06-18.md.
|
||||||
- [Report times in Arizona time](feedback_timezone_arizona_reporting.md) — All user-facing times in America/Phoenix (MST, no DST), labeled AZ; convert from UTC. Set by Winter 2026-07-02.
|
- [AD2 comms via sync only](ad2-comms-via-sync-only.md) — The AD2 Dataforth-box Claude session is coord-API-isolated (Gitea only); coord msg/lock/todo never reach it. Coordinate with AD2 ONLY via git /sync (committed docs + ## Note blocks).
|
||||||
- [RMM dashboard: beta before main](rmm-dashboard-beta-before-main.md) — GuruRMM website/dashboard changes land on beta (rmm-beta.azcomputerguru.com) and get tested BEFORE pushing/merging to main, unless told otherwise. Pipeline auto-builds beta FROM main, so don't merge to get on beta — build the feature branch's dashboard and rsync to /var/www/gururmm/dashboard-beta via a git worktree. Promote beta→prod with promote-dashboard.sh --confirm.
|
- [reference_syncro_agent_handle_leak](reference_syncro_agent_handle_leak.md) -- RDS "no available computers in the pool" (0x3/0x408) can really be a SyncroLive.Agent.Runner handle leak starving the box. How to spot + fix.
|
||||||
- [Prefer SSH over RMM](feedback_prefer_ssh_over_rmm.md) — When a target has SSH (key auth) and the task is easier over it, default to `scp script + ssh run` (system OpenSSH); RMM runs as SYSTEM + hits the server-side timeout reaper. Reserve RMM as fallback when SSH/VPN is down.
|
|
||||||
- [Re-clone submodule creds](reclone-submodule-creds.md) — Re-cloning the restructured claudetools (projects now submodules): set `credential.helper=store` GLOBALLY before `git submodule update --init --recursive` or every Gitea submodule fails "could not read Username". Steps in RECLONE.md.
|
## Users
|
||||||
- [Bot alerts need a ticket link](feedback_bot_alert_ticket_link.md) — Syncro ticket bot-alerts MUST include a clickable link: https://computerguru.syncromsp.com/tickets/<internal_id> (internal id, not ticket number). post-bot-alert.sh posts raw text; put the URL in the message.
|
- [Howard Enos](user_howard.md) — Mike's brother, technician, full access. Machines: ACG-TECH03L, Howard-Home (authoritative in users.json).
|
||||||
- [RMM Set-Acl timeout loses stdout](feedback_rmm_setacl_timeout_password_loss.md) — NTFS ACL propagation (Set-Acl/icacls) on a large folder tree exceeds the RMM command timeout and stdout is dropped, so a password printed in that script is lost. Generate secrets LOCALLY (placeholder-inject) so they survive; isolate the ACL grant into its own long-timeout command.
|
- [Mike — font preference](user_font_preference.md) — Mike prefers Lucida Console for monospace UI.
|
||||||
- [Mac RMM authentication fixed](feedback_mac_rmm_auth_fixed.md) — Use `.claude/scripts/rmm-auth.sh` helper instead of heredoc pattern. Heredoc with `--data-binary @-` fails on macOS. Helper uses `jq -n --arg` to build JSON safely. Usage: `eval "$(bash .claude/scripts/rmm-auth.sh)"` sets $TOKEN, $RMM, $REPO_ROOT. Updated in /rmm Phase 0.
|
|
||||||
- [Verify committed state before push](feedback_verify_committed_state_before_push.md) — webhook builds from origin/main: verify the COMMITTED build (git stash + build), not the working tree; bad git-add pathspec silently aborts staging. Stage by directory.
|
## Feedback
|
||||||
- [Scheduling = coord todo, not schedulers](feedback_scheduling_via_coord_todo.md) — Defer future work as a coord todo (POST /api/coord/todos; needs text + created_by_user + created_by_machine) for a later session to pick up. NOT /schedule remote CCR agents (no vault/creds there) or local scheduled tasks.
|
- [Simplest solution first](feedback_simplest_solution_first.md) — Start with the least-complex thing that could answer the problem (one DNS lookup / one API call / one command), confirm it works, THEN graduate to advanced/complicated as needed. Don't reach for SSH/plink/multi-hop when a one-shot query answers it. Now a CLAUDE.md core rule.
|
||||||
- [DMARC rua INKY only when onboarded](feedback_dmarc_rua_inky_onboarded_only.md) — Don't point a client's DMARC rua at reports-sg.inkydmarc.com unless that client is onboarded to INKY (most aren't). Use plain `p=none` with no rua otherwise.
|
- [Report times in Arizona time](feedback_timezone_arizona_reporting.md) — All user-facing times in America/Phoenix (MST, no DST), labeled AZ; convert from UTC. Set by Winter 2026-07-02.
|
||||||
- [Use rmm-search to find machines](feedback_rmm_search_skill.md) — Find GuruRMM agents via the `rmm-search` skill (`rmm-search.sh <words> [-c client]`), never hand-grep /api/agents (it bleeds across clients). Then hand hostname/id to `/rmm`.
|
- [RMM dashboard: beta before main](rmm-dashboard-beta-before-main.md) — GuruRMM website/dashboard changes land on beta (rmm-beta.azcomputerguru.com) and get tested BEFORE pushing/merging to main, unless told otherwise. Pipeline auto-builds beta FROM main, so don't merge to get on beta — build the feature branch's dashboard and rsync to /var/www/gururmm/dashboard-beta via a git worktree. Promote beta→prod with promote-dashboard.sh --confirm.
|
||||||
- [Attribution is read, never inferred](feedback_attribution_from_identity.md) — Who-did-what (user+machine) comes ONLY from identity.json + users.json + git authorship. Never infer from hostname patterns, the userEmail hint, or memory. The "5070" box is Mike's. sync.sh reconciles git config to identity.json; /save renders the User block via whoami-block.sh.
|
- [Prefer SSH over RMM](feedback_prefer_ssh_over_rmm.md) — When a target has SSH (key auth) and the task is easier over it, default to `scp script + ssh run` (system OpenSSH); RMM runs as SYSTEM + hits the server-side timeout reaper. Reserve RMM as fallback when SSH/VPN is down.
|
||||||
- [D2TESTNAS SSH Access](feedback_d2testnas_ssh.md) — Use root@192.168.0.9 with Paper123!@#, not sysadmin.
|
- [Re-clone submodule creds](reclone-submodule-creds.md) — Re-cloning the restructured claudetools (projects now submodules): set `credential.helper=store` GLOBALLY before `git submodule update --init --recursive` or every Gitea submodule fails "could not read Username". Steps in RECLONE.md.
|
||||||
- [Bypass Permissions Setting](feedback_bypass_permissions_setting.md) — Set permissions.defaultMode to bypassPermissions in settings.json on all machines.
|
- [Bot alerts need a ticket link](feedback_bot_alert_ticket_link.md) — Syncro ticket bot-alerts MUST include a clickable link: https://computerguru.syncromsp.com/tickets/<internal_id> (internal id, not ticket number). post-bot-alert.sh posts raw text; put the URL in the message.
|
||||||
- [365 Remediation Tool](feedback_365_remediation_tool.md) — "remediation tool" = tiered ComputerGuru app suite via /remediation-tool; NOT CIPP, NOT the deprecated fabb3421.
|
- [RMM Set-Acl timeout loses stdout](feedback_rmm_setacl_timeout_password_loss.md) — NTFS ACL propagation (Set-Acl/icacls) on a large folder tree exceeds the RMM command timeout and stdout is dropped, so a password printed in that script is lost. Generate secrets LOCALLY (placeholder-inject) so they survive; isolate the ACL grant into its own long-timeout command.
|
||||||
- [CA managed programmatically (with discipline)](feedback_ca_programmatic_management.md) — Conditional Access CAN be written via Tenant Admin app; ALWAYS report-only first + exclude break-glass + confirm before enforcing. Overrides old "CA manual" rule.
|
- [Mac RMM authentication fixed](feedback_mac_rmm_auth_fixed.md) — Use `.claude/scripts/rmm-auth.sh` helper instead of heredoc pattern. Heredoc with `--data-binary @-` fails on macOS. Helper uses `jq -n --arg` to build JSON safely. Usage: `eval "$(bash .claude/scripts/rmm-auth.sh)"` sets $TOKEN, $RMM, $REPO_ROOT. Updated in /rmm Phase 0.
|
||||||
- [Ollama Tier-0 Routing](feedback_ollama_tier0_routing.md) — Route drafts/summaries/classifications through Ollama (qwen3:14b). Mike designed ClaudeTools this way — not optional.
|
- [Verify committed state before push](feedback_verify_committed_state_before_push.md) — webhook builds from origin/main: verify the COMMITTED build (git stash + build), not the working tree; bad git-add pathspec silently aborts staging. Stage by directory.
|
||||||
- [/save writes narrative directly](feedback_save_no_ollama.md) — No Ollama for /save; write all sections inline — too slow.
|
- [Scheduling = coord todo, not schedulers](feedback_scheduling_via_coord_todo.md) — Defer future work as a coord todo (POST /api/coord/todos; needs text + created_by_user + created_by_machine) for a later session to pick up. NOT /schedule remote CCR agents (no vault/creds there) or local scheduled tasks.
|
||||||
- [Identity precedence](feedback_identity_precedence.md) — Trust `.claude/identity.json` over the system-reminder `userEmail` hint when they disagree (shared-login machines).
|
- [DMARC rua INKY only when onboarded](feedback_dmarc_rua_inky_onboarded_only.md) — Don't point a client's DMARC rua at reports-sg.inkydmarc.com unless that client is onboarded to INKY (most aren't). Use plain `p=none` with no rua otherwise.
|
||||||
- [1Password — always use service token](feedback_1password_service_token.md) — Source OP_SERVICE_ACCOUNT_TOKEN from SOPS for every `op` call. Desktop-app integration prompts are unacceptable in agent flows.
|
- [Use rmm-search to find machines](feedback_rmm_search_skill.md) — Find GuruRMM agents via the `rmm-search` skill (`rmm-search.sh <words> [-c client]`), never hand-grep /api/agents (it bleeds across clients). Then hand hostname/id to `/rmm`.
|
||||||
- [Point vault-access teammates at SOPS path](feedback_vault_pointer_for_teammates.md) — When relaying infra/credential info to Howard or other vault-access teammates, hand over the SOPS path + key anchors; don't transcribe the entry's fields into the message.
|
- [Attribution is read, never inferred](feedback_attribution_from_identity.md) — Who-did-what (user+machine) comes ONLY from identity.json + users.json + git authorship. Never infer from hostname patterns, the userEmail hint, or memory. The "5070" box is Mike's. sync.sh reconciles git config to identity.json; /save renders the User block via whoami-block.sh.
|
||||||
- [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl.
|
- [D2TESTNAS SSH Access](feedback_d2testnas_ssh.md) — Use root@192.168.0.9 with Paper123!@#, not sysadmin.
|
||||||
- [Windows strips embedded double-quotes](feedback_windows_quote_stripping.md) — Embedded `"` in an arg gets eaten twice over: PowerShell->curl.exe (CommandLineToArgvW) AND RMM->cmd.exe. MECHANICAL FIX: deliver PS scripts via `.claude/scripts/ps-encoded.sh` (-EncodedCommand, byte-exact; author the file with the Write tool). Inline one-offs: single-quoted heredoc `<<'JSON'` + `--data-binary @-`; build `"` from `[char]34`.
|
- [Bypass Permissions Setting](feedback_bypass_permissions_setting.md) — Set permissions.defaultMode to bypassPermissions in settings.json on all machines.
|
||||||
- [Interview the AI / read its docs before probing](feedback_interview_ai_read_docs.md) — To learn an external AI/CLI's syntax or capabilities, READ its bundled docs (Grok: `~/.grok/docs/user-guide/`, `README.md`, `grok inspect`/`models`/`--help`) or interview the model; don't guess flags or run slow trial-and-error. One run to confirm a doc-derived hypothesis, not a dozen to discover.
|
- [365 Remediation Tool](feedback_365_remediation_tool.md) — "remediation tool" = tiered ComputerGuru app suite via /remediation-tool; NOT CIPP, NOT the deprecated fabb3421.
|
||||||
- [Web search over blind probing](feedback_web_search_over_probing.md) — For external API/capability discovery, LEAD with web search (grok/gemini) + vendor docs; live endpoint-probing only CONFIRMS a hypothesis, never the primary discovery method (it mostly 404s, "highly suspect"). Reading a system's OWN config is fine; guessing unknown PATHS is not. Web-search bots being flaky is a must-fix (CT_THOUGHTS Thought 2).
|
- [CA managed programmatically (with discipline)](feedback_ca_programmatic_management.md) — Conditional Access CAN be written via Tenant Admin app; ALWAYS report-only first + exclude break-glass + confirm before enforcing. Overrides old "CA manual" rule.
|
||||||
- [Windows bash command mapping](feedback_windows_bash_mapping.md) — `bash` often resolves to WSL stub instead of Git/MSYS bash required by the harness. Fix by prepending `C:\Program Files\Git\bin` (and usr\bin) to PATH, or source `.claude/scripts/ensure-git-bash.ps1`. Profile has the logic; use plain `bash .claude/scripts/...` after remap. See the helper and this memory file for details.
|
- [Ollama Tier-0 Routing](feedback_ollama_tier0_routing.md) — Route drafts/summaries/classifications through Ollama (qwen3:14b). Mike designed ClaudeTools this way — not optional.
|
||||||
- [Git must authenticate non-interactively](feedback_git_noninteractive_auth.md) — Mike's gripe with Git for Windows is the constant password prompts (GCM) that hang automation, NOT the tool itself. D:\ClaudeTools is set to `credential.helper=store` primed with the azcomputerguru Gitea API token (host 172.16.3.20:3000); always set `GIT_TERMINAL_PROMPT=0`. Any never-prompts solution is acceptable.
|
- [/save writes narrative directly](feedback_save_no_ollama.md) — No Ollama for /save; write all sections inline — too slow.
|
||||||
- [Vault git auth — GCM shadows store token](feedback_vault_gcm_shadow_auth.md) — vault sync "Failed to authenticate user" on git.azcomputerguru.com: GCM is first in the helper chain and shadows the valid store token. Fix (machine-local): store-only credential.helper reset + pin `azcomputerguru@` in the vault remote URL so store returns the durable PAT (not the volatile OAUTH_USER JWT). Applied GURU-5070 2026-06-07.
|
- [Identity precedence](feedback_identity_precedence.md) — Trust `.claude/identity.json` over the system-reminder `userEmail` hint when they disagree (shared-login machines).
|
||||||
- [Antigravity agy.exe is not a headless CLI](reference_antigravity_agy_not_headless.md) — the `agy` skill's real backend is `@google/gemini-cli`, not the Antigravity `agy.exe` (IDE agent, no stdout, hangs). Don't reinstall agy.exe expecting headless output. Mike has a paid Gemini account, so stay on gemini-cli past the June 18 free-tier sunset (prefer `GEMINI_API_KEY`).
|
- [1Password — always use service token](feedback_1password_service_token.md) — Source OP_SERVICE_ACCOUNT_TOKEN from SOPS for every `op` call. Desktop-app integration prompts are unacceptable in agent flows.
|
||||||
- [SQL instance role — verify by connections, not name](feedback_sql_instance_role_by_connection.md) — Standard installed under default `SQLEXPRESS` instance name is real. Prove role with `sys.dm_exec_sessions` + `Get-NetTCPConnection -OwningProcess` before recommending stop/uninstall.
|
- [Point vault-access teammates at SOPS path](feedback_vault_pointer_for_teammates.md) — When relaying infra/credential info to Howard or other vault-access teammates, hand over the SOPS path + key anchors; don't transcribe the entry's fields into the message.
|
||||||
- [RMM password setting limitation](feedback_rmm_password_limitation.md) — `net user <user> <password>` via GuruRMM fails silently (exit 0 but password doesn't set). Tested PowerShell AND CMD - both fail. ScreenConnect CMD works (also as SYSTEM). GuruRMM agent bug in process spawning. Use ScreenConnect for password ops. HIGH priority to fix.
|
- [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl.
|
||||||
- [Clear-RecycleBin fails silently as SYSTEM](feedback_clear_recyclebin_system_context.md) — RMM-dispatched cleanup scripts cannot use `Clear-RecycleBin -Force`; the cmdlet uses Shell COM and silently no-ops without an interactive desktop. Enumerate `C:\$Recycle.Bin\<SID>\*` directly.
|
- [Windows strips embedded double-quotes](feedback_windows_quote_stripping.md) — Embedded `"` in an arg gets eaten twice over: PowerShell->curl.exe (CommandLineToArgvW) AND RMM->cmd.exe. MECHANICAL FIX: deliver PS scripts via `.claude/scripts/ps-encoded.sh` (-EncodedCommand, byte-exact; author the file with the Write tool). Inline one-offs: single-quoted heredoc `<<'JSON'` + `--data-binary @-`; build `"` from `[char]34`.
|
||||||
- [Graph CA policy reads are eventually consistent](feedback_graph_ca_policy_eventual_consistency.md) — After PATCHing a CA policy (204), wait ~5s before GET-verifying; immediate reads can be stale.
|
- [Interview the AI / read its docs before probing](feedback_interview_ai_read_docs.md) — To learn an external AI/CLI's syntax or capabilities, READ its bundled docs (Grok: `~/.grok/docs/user-guide/`, `README.md`, `grok inspect`/`models`/`--help`) or interview the model; don't guess flags or run slow trial-and-error. One run to confirm a doc-derived hypothesis, not a dozen to discover.
|
||||||
- [Graph password reset needs a privileged role](feedback_graph_password_reset_requires_role.md) — PATCH passwordProfile on an existing user 403s without a directory role; User.ReadWrite.All alone only sets a password at CREATE.
|
- [Web search over blind probing](feedback_web_search_over_probing.md) — For external API/capability discovery, LEAD with web search (grok/gemini) + vendor docs; live endpoint-probing only CONFIRMS a hypothesis, never the primary discovery method (it mostly 404s, "highly suspect"). Reading a system's OWN config is fine; guessing unknown PATHS is not. Web-search bots being flaky is a must-fix (CT_THOUGHTS Thought 2).
|
||||||
- [Vault writes — do the full sequence yourself](feedback_complete_vault_operations_end_to_end.md) — A vault entry = write plaintext → sops -e -i → git add/commit/push, all of it; don't stop at "encrypted on disk."
|
- [Windows bash command mapping](feedback_windows_bash_mapping.md) — `bash` often resolves to WSL stub instead of Git/MSYS bash required by the harness. Fix by prepending `C:\Program Files\Git\bin` (and usr\bin) to PATH, or source `.claude/scripts/ensure-git-bash.ps1`. Profile has the logic; use plain `bash .claude/scripts/...` after remap. See the helper and this memory file for details.
|
||||||
- [Exchange role recurring gap — backfill, don't promise](feedback_exchange_role_recurring_gap.md) — EXO email-cleanup 401/403 = Exchange Operator SP missing the Exchange Admin directory role (consent never grants it). Fix: `assign-exchange-role.sh <domain|--all>` (idempotent); audit with `--all --verify`. Fleet backfilled 2026-06-08. Verify membership via roleManagement/directory/roleAssignments (not the laggy directoryRoles/members list); EXO propagation 15-60min.
|
- [Git must authenticate non-interactively](feedback_git_noninteractive_auth.md) — Mike's gripe with Git for Windows is the constant password prompts (GCM) that hang automation, NOT the tool itself. D:\ClaudeTools is set to `credential.helper=store` primed with the azcomputerguru Gitea API token (host 172.16.3.20:3000); always set `GIT_TERMINAL_PROMPT=0`. Any never-prompts solution is acceptable.
|
||||||
- [Syncro is the default PSA; Autotask is opt-in](feedback_psa_default_syncro.md) — Ticketing/billing/customers default to Syncro (/syncro). Only use /autotask on an explicit "in Autotask" request. /autotask kept local/undistributed.
|
- [Vault git auth — GCM shadows store token](feedback_vault_gcm_shadow_auth.md) — vault sync "Failed to authenticate user" on git.azcomputerguru.com: GCM is first in the helper chain and shadows the valid store token. Fix (machine-local): store-only credential.helper reset + pin `azcomputerguru@` in the vault remote URL so store returns the durable PAT (not the volatile OAUTH_USER JWT). Applied GURU-5070 2026-06-07.
|
||||||
- [Paste-safe command formatting (Howard)](feedback_command_formatting.md) — Two clauses, one root cause: (a) multi-line scripts not semicolon one-liners (wrap breaks paste), (b) all code at column 0 inside fences (indentation breaks PowerShell paste).
|
- [agy.exe NOW works headless (2026-07-03)](reference_antigravity_agy_not_headless.md) — Antigravity `agy.exe` v1.0.6+ has a working `-p/--print` headless mode (clean stdout, `--add-dir` to read files); use it as the Gemini/Antigravity second-model path, esp. when gemini-cli OAuth is ineligible. Call by full path until PATH refresh. (Supersedes the old "agy is DOA headless" note.)
|
||||||
- [Autonomous infra/build setup](feedback_autonomous_infra_setup.md) — During infra/build/CI/dev setup, just install prerequisites and push through routine steps; reserve check-ins for genuine decisions (forks, destructive/outward, client/prod).
|
- [SQL instance role — verify by connections, not name](feedback_sql_instance_role_by_connection.md) — Standard installed under default `SQLEXPRESS` instance name is real. Prove role with `sys.dm_exec_sessions` + `Get-NetTCPConnection -OwningProcess` before recommending stop/uninstall.
|
||||||
- [Check patterns before asking](feedback_check_patterns_before_asking.md) — Before asking how to do something repeat-style (sync, save, sweep, billing), study existing artifacts and workflow docs first; reach for similar past artifacts as the template.
|
- [RMM password setting limitation](feedback_rmm_password_limitation.md) — `net user <user> <password>` via GuruRMM fails silently (exit 0 but password doesn't set). Tested PowerShell AND CMD - both fail. ScreenConnect CMD works (also as SYSTEM). GuruRMM agent bug in process spawning. Use ScreenConnect for password ops. HIGH priority to fix.
|
||||||
- [Cascades scan-to-folder uses svc-scan](feedback_cascades_scan_account.md) — Every scanner->network-folder setup at Cascades reuses the one `svc-scan` AD service account (NTLMv2, vaulted); never make a per-printer scan account.
|
- [Clear-RecycleBin fails silently as SYSTEM](feedback_clear_recyclebin_system_context.md) — RMM-dispatched cleanup scripts cannot use `Clear-RecycleBin -Force`; the cmdlet uses Shell COM and silently no-ops without an interactive desktop. Enumerate `C:\$Recycle.Bin\<SID>\*` directly.
|
||||||
- [Drive-letter mapping convention](feedback_drive_letter_mapping.md) — Pick the MAIN/primary drive letter first (consistent across users for the principal share), then assign smaller/secondary maps. Don't retroactively renumber existing maps unless asked.
|
- [Graph CA policy reads are eventually consistent](feedback_graph_ca_policy_eventual_consistency.md) — After PATCHing a CA policy (204), wait ~5s before GET-verifying; immediate reads can be stale.
|
||||||
- [Calibrate effort to stakes](feedback_calibrate_effort_to_stakes.md) — Don't over-verify or over-engineer low-consequence details; confirm the happy path, note the limitation, and take the simplest path (e.g. put the instruction in the prompt) instead of building robust mechanisms.
|
- [Graph password reset needs a privileged role](feedback_graph_password_reset_requires_role.md) — PATCH passwordProfile on an existing user 403s without a directory role; User.ReadWrite.All alone only sets a password at CREATE.
|
||||||
- [Pricing verification — no guessing](policy_pricing_verification.md) — ANY cost presented to the team or a client MUST be verified via live web lookup (WebFetch/WebSearch, fallback to headless Chrome). Never estimate from training data. Cite source + date inline. If unreachable, say so — do NOT substitute a guess.
|
- [Vault writes — do the full sequence yourself](feedback_complete_vault_operations_end_to_end.md) — A vault entry = write plaintext → sops -e -i → git add/commit/push, all of it; don't stop at "encrypted on disk."
|
||||||
- [Client communication tone](feedback_client_tone.md) — How to write client-facing Syncro comments — expert partner, not intake questionnaire.
|
- [Exchange role recurring gap — backfill, don't promise](feedback_exchange_role_recurring_gap.md) — EXO email-cleanup 401/403 = Exchange Operator SP missing the Exchange Admin directory role (consent never grants it). Fix: `assign-exchange-role.sh <domain|--all>` (idempotent); audit with `--all --verify`. Fleet backfilled 2026-06-08. Verify membership via roleManagement/directory/roleAssignments (not the laggy directoryRoles/members list); EXO propagation 15-60min.
|
||||||
- [Impeccable on outbound](feedback_impeccable_on_outbound.md) — Run the `impeccable` skill on anything sent to a client or vendor before delivery; internal drafts exempt.
|
- [Syncro is the default PSA; Autotask is opt-in](feedback_psa_default_syncro.md) — Ticketing/billing/customers default to Syncro (/syncro). Only use /autotask on an explicit "in Autotask" request. /autotask kept local/undistributed.
|
||||||
- [Default to inline links](feedback_inline_links.md) — Use `[text](url)` inline markdown links (clickable, wrap-safe) not bare URLs in code fences; exception = raw URL the user must copy/paste.
|
- [Paste-safe command formatting (Howard)](feedback_command_formatting.md) — Two clauses, one root cause: (a) multi-line scripts not semicolon one-liners (wrap breaks paste), (b) all code at column 0 inside fences (indentation breaks PowerShell paste).
|
||||||
- [Add Mike as owner on all Entra apps](feedback_entra_app_owner.md) — Apps created via management SP have no user owner — must add Mike manually or publisher verification fails.
|
- [Autonomous infra/build setup](feedback_autonomous_infra_setup.md) — During infra/build/CI/dev setup, just install prerequisites and push through routine steps; reserve check-ins for genuine decisions (forks, destructive/outward, client/prod).
|
||||||
- [No TOML/config file approach for endpoints](feedback_no_toml_config_endpoints.md) — User explicitly prohibits TOML or config-file-based endpoint configuration — this will never be approved.
|
- [Check patterns before asking](feedback_check_patterns_before_asking.md) — Before asking how to do something repeat-style (sync, save, sweep, billing), study existing artifacts and workflow docs first; reach for similar past artifacts as the template.
|
||||||
- [Python on Windows — use py launcher](feedback_python_windows.md) — Windows Store python/python3 aliases disabled; always use py or jq on DESKTOP-0O8A1RL.
|
- [Cascades scan-to-folder uses svc-scan](feedback_cascades_scan_account.md) — Every scanner->network-folder setup at Cascades reuses the one `svc-scan` AD service account (NTLMv2, vaulted); never make a per-printer scan account.
|
||||||
- [Memory tooling may delete now — additive-only constraint dropped](feedback_memory_sync_destructive_ok.md) — As of 2026-06-02, memory-dream and sync-memory.sh are sanctioned to perform destructive ops (apply proposed merges/dedups, propagate repo deletions back to harness profile stores). Onboarding-phase safety net now fights deliberate consolidation (e.g. 2026-06-01's 39 deletions resurrected on the next sync). Script updates pending.
|
- [Drive-letter mapping convention](feedback_drive_letter_mapping.md) — Pick the MAIN/primary drive letter first (consistent across users for the principal share), then assign smaller/secondary maps. Don't retroactively renumber existing maps unless asked.
|
||||||
- [Unsaved sessions are recoverable from transcripts](feedback_session_recovery.md) — Crashed/closed-before-save sessions live in `~/.claude/projects/<slug>/*.jsonl`; the detector auto-recovers orphans, `/recover <uuid>` does it manually. Ollama prose + Python verbatim. See `.claude/RECOVERY.md`.
|
- [Calibrate effort to stakes](feedback_calibrate_effort_to_stakes.md) — Don't over-verify or over-engineer low-consequence details; confirm the happy path, note the limitation, and take the simplest path (e.g. put the instruction in the prompt) instead of building robust mechanisms.
|
||||||
- [agy review is not read-only](feedback_agy_review_not_readonly.md) — agy review/review-files CAN write files + run npm despite docs claiming plan-mode; always git diff after and treat Gemini's output as a proposal to validate, not trusted/finished work.
|
- [Pricing verification — no guessing](policy_pricing_verification.md) — ANY cost presented to the team or a client MUST be verified via live web lookup (WebFetch/WebSearch, fallback to headless Chrome). Never estimate from training data. Cite source + date inline. If unreachable, say so — do NOT substitute a guess.
|
||||||
- [Don't present inferred topology as fact](feedback_no_inferred_topology_as_fact.md) — Private-IP overlap (172.16.x on both sides) is NOT proof of a site-to-site link; I fabricated a VWP<->office VPN. State observations vs inferences; a failed reachability test disproves a link, don't explain it away; test "can reach RMM" against the EXTERNAL endpoint, not internal 172.16.3.30.
|
- [Client communication tone](feedback_client_tone.md) — How to write client-facing Syncro comments — expert partner, not intake questionnaire.
|
||||||
|
- [Impeccable on outbound](feedback_impeccable_on_outbound.md) — Run the `impeccable` skill on anything sent to a client or vendor before delivery; internal drafts exempt.
|
||||||
### Syncro
|
- [Default to inline links](feedback_inline_links.md) — Use `[text](url)` inline markdown links (clickable, wrap-safe) not bare URLs in code fences; exception = raw URL the user must copy/paste.
|
||||||
- [Skill-first routing + Definition of Done](feedback_skill_first_routing.md) — If a skill covers the task, INVOKE IT (Syncro billing ALWAYS via `/syncro`, never ad-hoc curl), AND gate the result with the matching check-skill before "done" (code→/code-review+/simplify+/security-review; outbound→impeccable/stop-slop; wiki/memory→wiki-lint/memory-dream) — inferred from the request, not user-named. CORE rule; full map in `.claude/SKILL_ROUTING.md`; calibrate to stakes, Ollama Tier-0 for cheap passes.
|
- [Add Mike as owner on all Entra apps](feedback_entra_app_owner.md) — Apps created via management SP have no user owner — must add Mike manually or publisher verification fails.
|
||||||
- [Syncro API plumbing](feedback_syncro_api.md) — Content-Type required on all POST/PUT; NO idempotency anywhere — always GET before retrying; response wrappers (`.ticket.id`, `.comment.id`); add_line_item shape (internal ID, flat response, required fields); HTML uses `<br>` not `<ul>/<li>`; timer_entry response is FLAT but SUPERSEDED (use add_line_item).
|
- [No TOML/config file approach for endpoints](feedback_no_toml_config_endpoints.md) — User explicitly prohibits TOML or config-file-based endpoint configuration — this will never be approved.
|
||||||
- [Syncro billing rules](feedback_syncro_billing.md) — Bill with `add_line_item` directly (not timers); fetch rates LIVE; never invent labor names (real product names only); match labor type to delivery channel (never "Prepaid project labor"); labor `taxable:false` (AZ); warranty `1049360` (never patch price); emergency `26184` ×1.5 once, branch by `prepay_hours`; corrections preserve original tech's user_id; estimate hardware `32252`.
|
- [Python on Windows — use py launcher](feedback_python_windows.md) — Windows Store python/python3 aliases disabled; always use py or jq on DESKTOP-0O8A1RL.
|
||||||
- [Syncro workflow rules](feedback_syncro_workflow.md) — ALWAYS preview comments before posting (no exceptions); verify appointment day-of-week ("Saturday 2026-05-23") before creating; ASK who the appointment owner is; leave `contact_id` BLANK by default for ALL customers (ignore Syncro's contact-picker auto-default).
|
- [Memory tooling may delete now — additive-only constraint dropped](feedback_memory_sync_destructive_ok.md) — As of 2026-06-02, memory-dream and sync-memory.sh are sanctioned to perform destructive ops (apply proposed merges/dedups, propagate repo deletions back to harness profile stores). Onboarding-phase safety net now fights deliberate consolidation (e.g. 2026-06-01's 39 deletions resurrected on the next sync). Script updates pending.
|
||||||
- [Syncro lessons / incident archive](feedback_syncro_history.md) — Detail behind the three rule files: tickets (#32332, #32312, #32225, #32253, #32203, #32185, #32142, #32304, #32333), verbatim Mike/Howard/Winter quotes, dates, tech user_id table (Mike 1735 / Howard 1750 / Winter 1737 / Rob 1760), labor product table, and superseded-rule history.
|
- [Unsaved sessions are recoverable from transcripts](feedback_session_recovery.md) — Crashed/closed-before-save sessions live in `~/.claude/projects/<slug>/*.jsonl`; the detector auto-recovers orphans, `/recover <uuid>` does it manually. Ollama prose + Python verbatim. See `.claude/RECOVERY.md`.
|
||||||
|
- [Scheduled tasks must not flash a console](feedback_scheduled_task_no_console_flash.md) — A task action running bash.exe/py.exe/python.exe with Hidden=False draws a console window every fire (the recurring "command prompt opening/closing"). Wrap bash via a wscript VBS (style 0), use pythonw.exe for python, always add `-Hidden`. Fixed installers: register-edr-watcher.ps1, register-orphan-detector.ps1.
|
||||||
### GuruRMM
|
- [agy review is not read-only](feedback_agy_review_not_readonly.md) — agy review/review-files CAN write files + run npm despite docs claiming plan-mode; always git diff after and treat Gemini's output as a proposal to validate, not trusted/finished work.
|
||||||
- [GuruRMM build verification (read before touching the pipeline)](feedback_gururmm_build_verification.md) — Merge-to-main IS the build+deploy; verify locally FIRST. Canonical refs: guru-rmm `docs/BUILD.md` + the `gururmm-build` skill (`verify.sh server|agent|dashboard|migrations`) + `deploy/build-pipeline/README.md`. Compile-gate trap: Windows cargo can't verify Linux-gated agent code (openssl-sys); Linux build on .30 is the real gate. Server needs SQLX_OFFLINE + fresh server/.sqlx; check migration-number collisions.
|
- [Don't present inferred topology as fact](feedback_no_inferred_topology_as_fact.md) — Private-IP overlap (172.16.x on both sides) is NOT proof of a site-to-site link; I fabricated a VWP<->office VPN. State observations vs inferences; a failed reachability test disproves a link, don't explain it away; test "can reach RMM" against the EXTERNAL endpoint, not internal 172.16.3.30.
|
||||||
- [Submodule auto-sync discipline](feedback_submodule_autosync_discipline.md) — In auto-synced submodules (guru-rmm/guru-connect) local branch refs/HEAD don't survive across calls (background sync resets to the lagging gitlink; sessions share the tree). Use a git worktree or commit+push-by-explicit-SHA + `ls-remote` verify; assert HEAD==origin/main (or read `origin/main:<file>`) before audits; never `checkout --` shared files. Recurring fleet friction.
|
|
||||||
- [GuruRMM operational rules](feedback_gururmm.md) — Six rules: (1) RMM dev = Mike, never Howard (368/0 commits); GuruScan is Howard's. (2) Agent parity Win+Linux+macOS in same change. (3) Builds via Gitea webhook pipeline only, never SSH. (4) #bot-alerts only for client/ticket impact, skip internal infra/dev. (5) Identify agents by IP, not by reconning candidates. (6) UNC paths in user_session need [char]92 — literals get halved.
|
### Syncro
|
||||||
- [Build channel default = beta](feedback_gururmm_build_channel_default.md) — New agent builds must be tagged BETA by default (stable = explicit promote re-tag); distinct from agents defaulting to the stable CHANNEL (correct). Fixed build-windows/linux.sh 2026-06-01; macOS already correct. Enables beta-first canary.
|
- [Skill-first routing + Definition of Done](feedback_skill_first_routing.md) — If a skill covers the task, INVOKE IT (Syncro billing ALWAYS via `/syncro`, never ad-hoc curl), AND gate the result with the matching check-skill before "done" (code→/code-review+/simplify+/security-review; outbound→impeccable/stop-slop; wiki/memory→wiki-lint/memory-dream) — inferred from the request, not user-named. CORE rule; full map in `.claude/SKILL_ROUTING.md`; calibrate to stakes, Ollama Tier-0 for cheap passes.
|
||||||
- [Dashboard beta-first deploy](feedback_dashboard_beta_first.md) — Dashboard auto-builds to rmm-beta.azcomputerguru.com on push; prod (rmm.azcomputerguru.com) is explicit promote-only via promote-dashboard.sh --confirm. Never hand-rsync prod. One artifact, nginx sub_filter BETA banner. Stood up 2026-06-02.
|
- [Syncro API plumbing](feedback_syncro_api.md) — Content-Type required on all POST/PUT; NO idempotency anywhere — always GET before retrying; response wrappers (`.ticket.id`, `.comment.id`); add_line_item shape (internal ID, flat response, required fields); HTML uses `<br>` not `<ul>/<li>`; timer_entry response is FLAT but SUPERSEDED (use add_line_item).
|
||||||
|
- [Syncro billing rules](feedback_syncro_billing.md) — Bill with `add_line_item` directly (not timers); fetch rates LIVE; never invent labor names (real product names only); match labor type to delivery channel (never "Prepaid project labor"); labor `taxable:false` (AZ); warranty `1049360` (never patch price); emergency `26184` ×1.5 once, branch by `prepay_hours`; corrections preserve original tech's user_id; estimate hardware `32252`.
|
||||||
### Cascades
|
- [Syncro workflow rules](feedback_syncro_workflow.md) — ALWAYS preview comments before posting (no exceptions); verify appointment day-of-week ("Saturday 2026-05-23") before creating; ASK who the appointment owner is; leave `contact_id` BLANK by default for ALL customers (ignore Syncro's contact-picker auto-default).
|
||||||
- [Cascades operational rules](feedback_cascades.md) — Active rules: (1) folder redirection (fdeploy) needs subfolders PRE-CREATED before first logon or it caches a failure forever; recovery via fix-shell-redirect.ps1. (2) ALWAYS ask which security group(s) a new user goes into — never auto-derive from OU. (3) Do NOT lock down the legacy Main\Company Web Docs\Accounting (Everyone:Full) folder — still in active use. (4) NEVER change Cascades production infra (pfSense/UniFi/switches/DHCP) without discussing it + explicit per-change go — read-only/dry-run until then.
|
- [Syncro lessons / incident archive](feedback_syncro_history.md) — Detail behind the three rule files: tickets (#32332, #32312, #32225, #32253, #32203, #32185, #32142, #32304, #32333), verbatim Mike/Howard/Winter quotes, dates, tech user_id table (Mike 1735 / Howard 1750 / Winter 1737 / Rob 1760), labor product table, and superseded-rule history.
|
||||||
- [Cascades FR GPO fix](reference_cascades_fr_gpo_fix.md) — Native Folder Redirection was DOA on every machine: redirect targets were in a misnamed `fdeploy1.ini` (Windows reads `fdeploy.ini`) → empty target path → silent no-op → per-user registry workaround every time. Fixed 2026-06-08 (correct fdeploy.ini + version bump). Also: CS-SERVER live RMM agent is `c39f1de7...` (old `6766e973` stale).
|
|
||||||
- [pfSense 25.07 ops quirks](reference_pfsense_25_07_ops.md) — Cascades pfSense Plus 25.07: logs are PLAIN TEXT (use tail/grep, NOT clog → clog returns empty); clean dhcpd restart = `services_dhcpd_configure()` via slow pfSsh.php (needs 50s+ timeout); dirty boot can leave 2 dhcpd → DISCOVER/OFFER but no ACK; reboot the Cox modem after a config restore; ZFS survives power loss. From the 2026-06-17 power-outage incident.
|
### GuruRMM
|
||||||
- [feedback_ascii_only_api_payloads](feedback_ascii_only_api_payloads.md) -- On Windows/Git-bash, non-ASCII chars (em-dash, arrow, smart quotes) in JSON payload TEXT passed to curl get mangled and rejected — Discord bot-alert returns 400, the coord API returns "error parsing the body". Use ASCII-only in API payload text, or a single-quoted heredoc.
|
- [GuruRMM build verification (read before touching the pipeline)](feedback_gururmm_build_verification.md) — Merge-to-main IS the build+deploy; verify locally FIRST. Canonical refs: guru-rmm `docs/BUILD.md` + the `gururmm-build` skill (`verify.sh server|agent|dashboard|migrations`) + `deploy/build-pipeline/README.md`. Compile-gate trap: Windows cargo can't verify Linux-gated agent code (openssl-sys); Linux build on .30 is the real gate. Server needs SQLX_OFFLINE + fresh server/.sqlx; check migration-number collisions.
|
||||||
- [feedback_bitdefender_unattended_install](feedback_bitdefender_unattended_install.md) -- Bitdefender unattended RMM install must use the FULL KIT as SYSTEM (silent, no UAC) — the downloader stub fails headless and triggers UAC
|
- [Submodule auto-sync discipline](feedback_submodule_autosync_discipline.md) — In auto-synced submodules (guru-rmm/guru-connect) local branch refs/HEAD don't survive across calls (background sync resets to the lagging gitlink; sessions share the tree). Use a git worktree or commit+push-by-explicit-SHA + `ls-remote` verify; assert HEAD==origin/main (or read `origin/main:<file>`) before audits; never `checkout --` shared files. Recurring fleet friction.
|
||||||
- [feedback_rmm_longops_fire_and_forget](feedback_rmm_longops_fire_and_forget.md) -- Long-running RMM endpoint ops (software installs, big downloads) must be fire-and-forget, not live-monitored
|
- [GuruRMM operational rules](feedback_gururmm.md) — Six rules: (1) RMM dev = Mike, never Howard (368/0 commits); GuruScan is Howard's. (2) Agent parity Win+Linux+macOS in same change. (3) Builds via Gitea webhook pipeline only, never SSH. (4) #bot-alerts only for client/ticket impact, skip internal infra/dev. (5) Identify agents by IP, not by reconning candidates. (6) UNC paths in user_session need [char]92 — literals get halved.
|
||||||
|
- [Build channel default = beta](feedback_gururmm_build_channel_default.md) — New agent builds must be tagged BETA by default (stable = explicit promote re-tag); distinct from agents defaulting to the stable CHANNEL (correct). Fixed build-windows/linux.sh 2026-06-01; macOS already correct. Enables beta-first canary.
|
||||||
## Machine
|
- [Dashboard beta-first deploy](feedback_dashboard_beta_first.md) — Dashboard auto-builds to rmm-beta.azcomputerguru.com on push; prod (rmm.azcomputerguru.com) is explicit promote-only via promote-dashboard.sh --confirm. Never hand-rsync prod. One artifact, nginx sub_filter BETA banner. Stood up 2026-06-02.
|
||||||
- [GURU-5070 Workstation Setup](reference_workstation_setup.md) — Mike's primary (owner confirmed 2026-05-26). Windows 11 Pro. Renamed from OC-5070 → ACG-5070/acg-guru-5070 → GURU-5070; all the same box, all Mike's.
|
|
||||||
- [GURU-BEAST-ROG Setup Status](machine_windows_guru_setup_status.md) — Windows workstation fully configured except SSH key deployment to servers.
|
### Cascades
|
||||||
|
- [Cascades operational rules](feedback_cascades.md) — Active rules: (1) folder redirection (fdeploy) needs subfolders PRE-CREATED before first logon or it caches a failure forever; recovery via fix-shell-redirect.ps1. (2) ALWAYS ask which security group(s) a new user goes into — never auto-derive from OU. (3) Do NOT lock down the legacy Main\Company Web Docs\Accounting (Everyone:Full) folder — still in active use. (4) NEVER change Cascades production infra (pfSense/UniFi/switches/DHCP) without discussing it + explicit per-change go — read-only/dry-run until then.
|
||||||
## Project
|
- [Cascades FR GPO fix](reference_cascades_fr_gpo_fix.md) — Native Folder Redirection was DOA on every machine: redirect targets were in a misnamed `fdeploy1.ini` (Windows reads `fdeploy.ini`) → empty target path → silent no-op → per-user registry workaround every time. Fixed 2026-06-08 (correct fdeploy.ini + version bump). Also: CS-SERVER live RMM agent is `c39f1de7...` (old `6766e973` stale).
|
||||||
- [Cascades network + CS-SERVER SMB instability](project_cascades_network_segments.md) — NAS->CS-SERVER migration. NOT a CSC-ENT-vs-CSCNet issue (corrected): fresh SMB to `\\cs-server\*` fails err 67 from BOTH Wi-Fis; only persistent admin-mapped drives work, intermittently → CS-SERVER-side SMB instability (multichannel advertises .248/.254/IPv6 ULAs; Get-SmbServerConfiguration errors). Blockers: CSCNet=WPA3 (old adapters can't join); workstations store domain-admin cred. Repoint tool [[drive-map]].
|
- [pfSense 25.07 ops quirks](reference_pfsense_25_07_ops.md) — Cascades pfSense Plus 25.07: logs are PLAIN TEXT (use tail/grep, NOT clog → clog returns empty); clean dhcpd restart = `services_dhcpd_configure()` via slow pfSsh.php (needs 50s+ timeout); dirty boot can leave 2 dhcpd → DISCOVER/OFFER but no ACK; reboot the Cox modem after a config restore; ZFS survives power loss. From the 2026-06-17 power-outage incident.
|
||||||
- [CyndyOffice physical HP lockups](cyndyoffice-physical-hp-lockups.md) — RMM "Howard-VM" site agent CyndyOffice is a PHYSICAL HP Pavilion TP01 (not a VM); ~20 hard freezes/6wk = Kernel-Power 41 bugcheck-0, no dump/WHEA = hardware (RAM/PSU/BIOS), SSD healthy. UUID re-enrolls.
|
- [feedback_ascii_only_api_payloads](feedback_ascii_only_api_payloads.md) -- On Windows/Git-bash, non-ASCII chars (em-dash, arrow, smart quotes) in JSON payload TEXT passed to curl get mangled and rejected — Discord bot-alert returns 400, the coord API returns "error parsing the body". Use ASCII-only in API payload text, or a single-quoted heredoc.
|
||||||
- [Automate memory consolidation/lint (phased)](project_memory_consolidation_automation.md) — Eventually auto-run /memory-dream; lint+additive fixes can automate early, merges/deletes stay human-approved. Engine: .claude/skills/memory-dream/ + .claude/scripts/sync-memory.sh.
|
- [feedback_bitdefender_unattended_install](feedback_bitdefender_unattended_install.md) -- Bitdefender unattended RMM install must use the FULL KIT as SYSTEM (silent, no UAC) — the downloader stub fails headless and triggers UAC
|
||||||
- [Trebesch PST consolidation (staged)](project_trebesch_pst_consolidation.md) — Address-book CSV from 24 PSTs on DESKTOP-QNP3ON5; scripts staged at .claude/tmp/treb-*.ps1, WAITING for Howard's 6pm-MST 2026-06-01 go signal (attended run). See [[reference_trebesch_qnp3on5]].
|
- [feedback_rmm_longops_fire_and_forget](feedback_rmm_longops_fire_and_forget.md) -- Long-running RMM endpoint ops (software installs, big downloads) must be fire-and-forget, not live-monitored
|
||||||
- [GuruRMM security scope — integrate AV, don't replace it](project_gururmm_security_scope.md) — No native virus/malware removal in the RMM; AV products do that. RMM monitors AV reports + sends commands to AV products, and its built-in value is helping techs FIND issues. Program removal is a separate feature.
|
|
||||||
- [GuruRMM project state](project_gururmm.md) — Dev principles (every feature full-stack: backend+API+UI+docs+scalability; product works without AI; FEATURE_ROADMAP update is part of definition-of-done; mirrors guru-rmm/docs/DESIGN.md). Webhook docs-only build guard (SPEC-020 Phase 0; webhook-handler.py repo copy is STALE — don't redeploy). Mac install-hooks.sh setup STILL PENDING on Mikes-MacBook-Air.
|
## Machine
|
||||||
- [GuruConnect](project_guruconnect.md) — v2 direction (native-first full key fidelity Win+R/Ctrl+Alt+Del + bidirectional file cut/paste/drag; WebRTC fallback only; standalone-first + RMM contract; tenancy-ready schema; Mike willing to scrap v1). Manual deploy procedure to 172.16.3.30 (build-on-server in login shell; sqlx runtime queries; NPM `CONNECT_TRUSTED_PROXIES=172.16.3.20` gotcha). v2 live since 2026-05-30.
|
- [GURU-5070 Workstation Setup](reference_workstation_setup.md) — Mike's primary (owner confirmed 2026-05-26). Windows 11 Pro. Renamed from OC-5070 → ACG-5070/acg-guru-5070 → GURU-5070; all the same box, all Mike's.
|
||||||
- [Apple MDM + Developer certs (GuruRMM mobile)](project_apple_mdm_certs.md) — ACG holds Apple Developer+signing and Apple MDM Push certs (acquired 2026-05-29) for SPEC-017. MDM push cert RENEWS ANNUALLY on the same Apple ID or all enrolled iOS devices break.
|
- [GURU-BEAST-ROG Setup Status](machine_windows_guru_setup_status.md) — Windows workstation fully configured except SSH key deployment to servers.
|
||||||
- [Only RMM & GC are versionable products](project_versionable_products.md) — GuruRMM + GuruConnect are the only products with own repos/submodules; everything else stays in the claudetools monorepo. Split only for independent pipeline OR versioned external consumer.
|
|
||||||
- [Quantum GoDaddy M365 tenant](project_quantum_godaddy_m365_tenant.md) — quantumwms.com parked in a GoDaddy-provisioned M365 tenant (id ddf3d2c9-b76c-40d9-a216-9f11a1a26f97, netorg18235235.onmicrosoft.com); blocks Pax8 migration until GoDaddy removed.
|
## Project
|
||||||
- [Howard-Home LAN shadow (RESOLVED)](howard-home-lan-shadow.md) — Howard-Home renumbered 2026-06-16 to **10.137.42.0/24** (gw 10.137.42.1, UniFi — NOT pfSense), off the old 192.168.0.0/24 that shadowed Cascades pfSense .0.x over the VPN. Cascades .0.x should now route via the tunnel; this machine is 10.137.42.x now (not 192.168.0.x).
|
- [VWP-ROSE QB updater crash-loop](project_vwp_rose_qb_updater.md) — RESOLVED 2026-07-13 via remote QB repair install; billed 0.5h to VWP prepay block, ticket #32545 Invoiced. Open side findings: NETLOGON 5719 at boot + Syncro agent service crashes on VWP-ROSE.
|
||||||
- [Cascades CARF tech plan](project_cascades_carf_tech_plan.md) — Ashley's "technology plan" is a CARF accreditation deliverable (Aging Services Technology and System Plan standard); must use CARF action-plan structure (owner/cost/target+completion dates per area), persons-served assistive-tech lens, annual-review sign-off; it's Cascades' leadership-adopted plan, ACG supplies content.
|
- [GuruRMM software-removal PR #58](project_gururmm_software_removal_pr58.md) — SPEC-030 hardening (driver exclusion in removal UI, lingering-uninstaller sweep, job list + stale reaper) on PR #58, UNMERGED; merge = beta deploy; live testing planned 2026-07-06 on DESKTOP-MS42HNC.
|
||||||
- [Cascades](project_cascades.md) — Active state: Syncro ticket #110680053 + plan file (machine-specific path on Howard's box), admin accounts (sysadmin@=Howard, admin@=Mike — daily-driver, NOT break-glass), Phase-B caregiver CA pilot (SG-Caregivers-Pilot, group-scoped never tenant-wide), prepaid block ~37.5h (rate TBD), pilot cleanup checklist.
|
- [Cascades network + CS-SERVER SMB instability](project_cascades_network_segments.md) — NAS->CS-SERVER migration. NOT a CSC-ENT-vs-CSCNet issue (corrected): fresh SMB to `\\cs-server\*` fails err 67 from BOTH Wi-Fis; only persistent admin-mapped drives work, intermittently → CS-SERVER-side SMB instability (multichannel advertises .248/.254/IPv6 ULAs; Get-SmbServerConfiguration errors). Blockers: CSCNet=WPA3 (old adapters can't join); workstations store domain-admin cred. Repoint tool [[drive-map]].
|
||||||
- [Cascades history](project_cascades_history.md) — fdeploy 502/ACL root cause (Flags=1211→187 fix), 2026-04-29 CA-rescoping decision (Howard pulled the brakes on tenant-wide), 2026-05-14 per-user-security-group decision rationale.
|
- [CyndyOffice physical HP lockups](cyndyoffice-physical-hp-lockups.md) — RMM "Howard-VM" site agent CyndyOffice is a PHYSICAL HP Pavilion TP01 (not a VM); ~20 hard freezes/6wk = Kernel-Power 41 bugcheck-0, no dump/WHEA = hardware (RAM/PSU/BIOS), SSD healthy. UUID re-enrolls.
|
||||||
- [Cascades isolated-VLAN pattern](project_cascades_isolated_vlan_pattern.md) — pfSense: the GUEST VLAN (VLAN50/igc1.50) is the isolation template (4 any-proto quick rules: block 192.168.0.0/22 + 10.0.0.0/8 + 172.16.0.0/12, then pass any; public DNS via DHCP). VLAN20 is NOT isolated. Verify with `pfctl -sr`, not config.xml. Protocol MUST be Any (TCP-only leaks UDP). VOICE VLAN30 built to this 2026-06-17.
|
- [Automate memory consolidation/lint (phased)](project_memory_consolidation_automation.md) — Eventually auto-run /memory-dream; lint+additive fixes can automate early, merges/deletes stay human-approved. Engine: .claude/skills/memory-dream/ + .claude/scripts/sync-memory.sh.
|
||||||
- [Cascades VLAN20 migration + routing](project_cascades_vlan20_migration_routing.md) — Staff machines/printers moving to VLAN20 (10.0.20.0/24). CS-SERVER couldn't reach VLAN20 printers because the LAN "allow LAN to any" rule policy-routes via WAN_Group → add a top LAN pass rule (src CS-SERVER 192.168.2.248, dst 10.0.20.0/24, gw=default) to bypass. pfSense SSH from VPN is blocked (do firewall in GUI). Printer client-map via GPO or SYSTEM `printui /ga` to dodge the 0x800702e4 PrintNightmare prompt; build UNC with [char]92.
|
- [Trebesch PST consolidation (staged)](project_trebesch_pst_consolidation.md) — Address-book CSV from 24 PSTs on DESKTOP-QNP3ON5; scripts staged at .claude/tmp/treb-*.ps1, WAITING for Howard's 6pm-MST 2026-06-01 go signal (attended run). See [[reference_trebesch_qnp3on5]].
|
||||||
- [Cascades KPI dashboard (parked)](project_cascades_kpi_dashboard.md) — Ashley Jensen wants one dashboard across their reporting SaaS (ALIS/QuickBooks/Bill.com/Relias/You've Got Leads/TELS/Focus HR/Helpany/POS). Power BI Gateway is the WRONG frame (on-prem only). Recommended Tier1→Tier2: scheduled exports → SharePoint → Power BI Pro, automate API-capable systems (Bill.com/QBO) via Power Automate later. Full notes: `clients/cascades-tucson/docs/proposals/kpi-dashboard.md`. Next: draft client one-pager.
|
- [GuruRMM security scope — integrate AV, don't replace it](project_gururmm_security_scope.md) — No native virus/malware removal in the RMM; AV products do that. RMM monitors AV reports + sends commands to AV products, and its built-in value is helping techs FIND issues. Program removal is a separate feature.
|
||||||
- [Sync script bug — untracked files (RESOLVED)](project_sync_script_bug.md) — FIXED 2026-05-21: sync.sh now uses `git status --porcelain` for change detection (repo + vault).
|
- [GuruRMM project state](project_gururmm.md) — Dev principles (every feature full-stack: backend+API+UI+docs+scalability; product works without AI; FEATURE_ROADMAP update is part of definition-of-done; mirrors guru-rmm/docs/DESIGN.md). Webhook docs-only build guard (SPEC-020 Phase 0; webhook-handler.py repo copy is STALE — don't redeploy). Mac install-hooks.sh setup STILL PENDING on Mikes-MacBook-Air.
|
||||||
- [MasterBooter Side Project](project_masterbooter.md) — Howard's Rust+Slint Windows deployment toolkit at C:\MasterBooter, separate from client work. Do not log to clients/.
|
- [GuruConnect](project_guruconnect.md) — v2 direction (native-first full key fidelity Win+R/Ctrl+Alt+Del + bidirectional file cut/paste/drag; WebRTC fallback only; standalone-first + RMM contract; tenancy-ready schema; Mike willing to scrap v1). Manual deploy procedure to 172.16.3.30 (build-on-server in login shell; sqlx runtime queries; NPM `CONNECT_TRUSTED_PROXIES=172.16.3.20` gotcha). v2 live since 2026-05-30.
|
||||||
- [AMPIPIT toolkit](project_ampipit.md) — Howard's ACTIVE Rust+Slint Windows deploy/recovery toolkit (not just a reference program), migrated into the fleet as a private Gitea submodule at projects/msp-tools/ampipit (azcomputerguru/ampipit). Active feature: recovery partition with GuruRMM agent + built-in repair scripts for offline-Windows repair (spike GO-WITH-WORK). Preserve all work; PRIVATE Gitea only, no public GitHub.
|
- [Apple MDM + Developer certs (GuruRMM mobile)](project_apple_mdm_certs.md) — ACG holds Apple Developer+signing and Apple MDM Push certs (acquired 2026-05-29) for SPEC-017. MDM push cert RENEWS ANNUALLY on the same Apple ID or all enrolled iOS devices break.
|
||||||
- [Audio Processor Architecture](project_audio_processor_architecture.md) — Segment-first pipeline: detect breaks before transcription for complete content capture.
|
- [Only RMM & GC are versionable products](project_versionable_products.md) — GuruRMM + GuruConnect are the only products with own repos/submodules; everything else stays in the claudetools monorepo. Split only for independent pipeline OR versioned external consumer.
|
||||||
- [Neptune SBR Email Routing Setup](project_neptune_sbr_email_routing.md) — Full SBR routing chain, config file locations, MailProtector integration, access methods. Treat routing breakage as systemic (devcon, Sorensen/rieussetcorp), not per-client.
|
- [GuruRMM legacy 1.77 build disabled](project_gururmm_legacy_disabled.md) — legacy (2008R2/Win7) Rust 1.77 variant DISABLED 2026-07-04 (`LEGACY_ENABLED=false`) after an edition2024 dep (chacha20/rand_core 0.10.1) broke the unpinned 1.77 re-resolve and fail-closed the whole Windows build; existing legacy artifacts preserved at 0.6.76. Do NOT re-enable blindly — 2008R2 support returns via a C++/.NET or clean-rewrite legacy agent (Mike, required).
|
||||||
- [Dataforth Test Datasheet Pipeline](project_datasheet_pipeline.md) — Full pipeline rebuilt 2026-03-27. Server-side generation replaces DFWDS/Uploader. Website upload still broken.
|
- [Quantum GoDaddy M365 tenant](project_quantum_godaddy_m365_tenant.md) — quantumwms.com parked in a GoDaddy-provisioned M365 tenant (id ddf3d2c9-b76c-40d9-a216-9f11a1a26f97, netorg18235235.onmicrosoft.com); blocks Pax8 migration until GoDaddy removed.
|
||||||
- [Dataforth](project_dataforth.md) — M365 email (Graph API; tenant in vault at clients/dataforth/m365.sops.yaml); neptune.acghosting.com is ACG's, NOT Dataforth's. MFA enforced 2026-04-04 (3 CA policies). AJ needs dataforthgit@ forwarding.
|
- [Howard-Home LAN shadow (RESOLVED)](howard-home-lan-shadow.md) — Howard-Home renumbered 2026-06-16 to **10.137.42.0/24** (gw 10.137.42.1, UniFi — NOT pfSense), off the old 192.168.0.0/24 that shadowed Cascades pfSense .0.x over the VPN. Cascades .0.x should now route via the tunnel; this machine is 10.137.42.x now (not 192.168.0.x).
|
||||||
- [Dataforth history (2026-03-27 incident)](project_dataforth_history.md) — DF-JOEL2 compromise via ScreenConnect social-engineering, attacker C2 IPs + IC3 case + remediation log + MFA rollout origin story + Joel Lohr retirement. RESOLVED 2026-04-04.
|
- [Cascades CARF tech plan](project_cascades_carf_tech_plan.md) — Ashley's "technology plan" is a CARF accreditation deliverable (Aging Services Technology and System Plan standard); must use CARF action-plan structure (owner/cost/target+completion dates per area), persons-served assistive-tech lens, annual-review sign-off; it's Cascades' leadership-adopted plan, ACG supplies content.
|
||||||
- [Radio show co-host — Tara, not Tom](radio_show_no_cohost_named_tom.md) — Co-host in 2014-s6e19 and 2016-s8e43 is Tara. "Tom" was hallucinated; rename complete.
|
- [Cascades](project_cascades.md) — Active state: Syncro ticket #110680053 + plan file (machine-specific path on Howard's box), admin accounts (sysadmin@=Howard, admin@=Mike — daily-driver, NOT break-glass), Phase-B caregiver CA pilot (SG-Caregivers-Pilot, group-scoped never tenant-wide), prepaid block ~37.5h (rate TBD), pilot cleanup checklist.
|
||||||
- [Proposal: centralize config in identity.json](proposal_identity_centralization.md) — Rationale for the identity.json machine-config centralization (claudetools_root, ollama/python); now implemented.
|
- [Cascades history](project_cascades_history.md) — fdeploy 502/ACL root cause (Flags=1211→187 fix), 2026-04-29 CA-rescoping decision (Howard pulled the brakes on tenant-wide), 2026-05-14 per-user-security-group decision rationale.
|
||||||
- [Defender: exempt all Claude activity](feedback_defender_claude_exclusions.md) — Mike wants NOTHING Claude/ClaudeTools issues flagged by Defender; ClickFix threat IDs 2147939088/2147945138 (RMM curl dispatch AMSI FPs) set to Allow + broad process/path exclusions. ThreatID-Allow is the lever; exclusions alone don't stop AMSI CmdLine detections.
|
- [Cascades isolated-VLAN pattern](project_cascades_isolated_vlan_pattern.md) — pfSense: the GUEST VLAN (VLAN50/igc1.50) is the isolation template (4 any-proto quick rules: block 192.168.0.0/22 + 10.0.0.0/8 + 172.16.0.0/12, then pass any; public DNS via DHCP). VLAN20 is NOT isolated. Verify with `pfctl -sr`, not config.xml. Protocol MUST be Any (TCP-only leaks UDP). VOICE VLAN30 built to this 2026-06-17.
|
||||||
- [ACG MSP tool stack](reference_acg_msp_stack.md) — ScreenConnect/CW Control, Splashtop, Syncro, Datto RMM, Datto EDR/AV, GuruRMM are ACG's OWN tools; do not flag as foreign/threat on managed machines (Defender-off is expected when Datto AV is active).
|
- [Cascades VLAN20 migration + routing](project_cascades_vlan20_migration_routing.md) — Staff machines/printers moving to VLAN20 (10.0.20.0/24). CS-SERVER couldn't reach VLAN20 printers because the LAN "allow LAN to any" rule policy-routes via WAN_Group → add a top LAN pass rule (src CS-SERVER 192.168.2.248, dst 10.0.20.0/24, gw=default) to bypass. pfSense SSH from VPN is blocked (do firewall in GUI). Printer client-map via GPO or SYSTEM `printui /ga` to dodge the 0x800702e4 PrintNightmare prompt; build UNC with [char]92.
|
||||||
- [VoIP vendor stack: PacketDial / OIT / NetSapiens / YMCS](reference_packetdial_oit_netsapiens.md) — PacketDial = ACG's VoIP-dept brand (pbx.packetdial.com, the `packetdial` skill); NetSapiens = the PBX platform (API v2); OIT/OITVOIP = white-label wholesaler running NetSapiens (api.ucaasnetwork.com); reseller `91912.service`. YMCS (Yealink) = phone device-mgmt, pairs with the PBX.
|
- [Cascades KPI dashboard (parked)](project_cascades_kpi_dashboard.md) — Ashley Jensen wants one dashboard across their reporting SaaS (ALIS/QuickBooks/Bill.com/Relias/You've Got Leads/TELS/Focus HR/Helpany/POS). Power BI Gateway is the WRONG frame (on-prem only). Recommended Tier1→Tier2: scheduled exports → SharePoint → Power BI Pro, automate API-capable systems (Bill.com/QBO) via Power Automate later. Full notes: `clients/cascades-tucson/docs/proposals/kpi-dashboard.md`. Next: draft client one-pager.
|
||||||
- [ACG Website Hosting](project_azcomputerguru_hosting.md) — azcomputerguru.com is hosted on IX Web Hosting via cPanel.
|
- [Sync script bug — untracked files (RESOLVED)](project_sync_script_bug.md) — FIXED 2026-05-21: sync.sh now uses `git status --porcelain` for change detection (repo + vault).
|
||||||
- [jq on Windows emits CRLF](feedback_jq_crlf_windows.md) — winget jq outputs CRLF; trailing \r silently breaks `for x in $(jq ...)` loops + read-from-@tsv. Override `jq(){ command jq "$@"|tr -d '\r'; }`. Windows-build-specific (passes on Mac/Linux).
|
- [MasterBooter Side Project](project_masterbooter.md) — Howard's Rust+Slint Windows deployment toolkit at C:\MasterBooter, separate from client work. Do not log to clients/.
|
||||||
- [ScreenConnect RESTful API auth](reference_screenconnect_api.md) — CTRLAuthHeader = raw api_secret (no Basic/b64) + Origin header; now wrapped by the /screenconnect skill. Verified surface: GetSessionsByName/GetSessionDetails + writes SendCommand/SendMessage/UpdateCustomProperties + parameterized self-tagging installer. Still NO full-fleet inventory method (GetSessions missing).
|
- [AMPIPIT toolkit](project_ampipit.md) — Howard's ACTIVE Rust+Slint Windows deploy/recovery toolkit (not just a reference program), migrated into the fleet as a private Gitea submodule at projects/msp-tools/ampipit (azcomputerguru/ampipit). Active feature: recovery partition with GuruRMM agent + built-in repair scripts for offline-Windows repair (spike GO-WITH-WORK). Preserve all work; PRIVATE Gitea only, no public GitHub.
|
||||||
- [No manufactured guardrails on our products](feedback_no_manufactured_guardrails.md) — At Mikes request on GuruRMM/GuruConnect/ClaudeTools, just execute; stop only for genuinely irreversible/destructive ops (with a heads-up). Read the actual code/state before claiming something is disallowed or a security hole.
|
- [Audio Processor Architecture](project_audio_processor_architecture.md) — Segment-first pipeline: detect breaks before transcription for complete content capture.
|
||||||
- [Stream-of-thought design convos](feedback_stream_of_thought_design.md) — Mike brainstorms features free-form, adding requirements iteratively; Claude validates/sharpens as a design partner but does NOT build until an explicit go, then captures parked threads durably (PARKED_*.md + todos) for a later /shape-spec.
|
- [Neptune SBR Email Routing Setup](project_neptune_sbr_email_routing.md) — Full SBR routing chain, config file locations, MailProtector integration, access methods. Treat routing breakage as systemic (devcon, Sorensen/rieussetcorp), not per-client.
|
||||||
- [RMM Thoughts backlog](feedback_rmm_thoughts_backlog.md) — GuruRMM ideas from Mike & Howard go in projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md (Status: Raw); pipeline thought -> discuss -> spec (/shape-spec) -> roadmap. Don't build until an explicit go.
|
- [Dataforth Test Datasheet Pipeline](project_datasheet_pipeline.md) — Full pipeline rebuilt 2026-03-27. Server-side generation replaces DFWDS/Uploader. Website upload still broken.
|
||||||
- [Syncro preview mandatory](feedback_syncro_preview_mandatory.md) — preview+confirm every Syncro write, including internal notes
|
- [Dataforth](project_dataforth.md) — M365 email (Graph API; tenant in vault at clients/dataforth/m365.sops.yaml); neptune.acghosting.com is ACG's, NOT Dataforth's. MFA enforced 2026-04-04 (3 CA policies). AJ needs dataforthgit@ forwarding.
|
||||||
- [Refresh session history first](feedback_refresh_session_history_first.md) — read prior incident logs before acting; do not re-remediate already-handled accounts
|
- [Dataforth history (2026-03-27 incident)](project_dataforth_history.md) — DF-JOEL2 compromise via ScreenConnect social-engineering, attacker C2 IPs + IC3 case + remediation log + MFA rollout origin story + Joel Lohr retirement. RESOLVED 2026-04-04.
|
||||||
- [Autonomy scope](feedback_autonomy_scope.md) — confirm only for client-affecting actions; internal docs/wiki/ClaudeTools = act autonomously
|
- [Radio show co-host — Tara, not Tom](radio_show_no_cohost_named_tom.md) — Co-host in 2014-s6e19 and 2016-s8e43 is Tara. "Tom" was hallucinated; rename complete.
|
||||||
- [Check for client-slug fragmentation](feedback_client_slug_fragmentation.md) — Before concluding a client has no records, grep broadly (company/owner/initials/hostname/"Last, First") across clients/, wiki/, session-logs/, vault — one client gets split across slug variants (Wolkin was 4: wolkin/wolkin-law/rswolkin/robert-wolkin). Consolidate to one canonical slug; action prior logs' Pending items.
|
- [Proposal: centralize config in identity.json](proposal_identity_centralization.md) — Rationale for the identity.json machine-config centralization (claudetools_root, ollama/python); now implemented.
|
||||||
- [RMM user_session = false SMB failures](feedback_rmm_user_session_smb_false_negative.md) — GuruRMM net use/net view/Add-Printer to a remote \HOST fail with error 67 / RPC 1702 (even with valid creds) because user_session is a WTS-impersonated non-interactive token that can't do authenticated SMB. The share/printer may work fine interactively. Treat RMM SMB results as "can't tell"; verify via ScreenConnect.
|
- [Defender: exempt all Claude activity](feedback_defender_claude_exclusions.md) — Mike wants NOTHING Claude/ClaudeTools issues flagged by Defender; ClickFix threat IDs 2147939088/2147945138 (RMM curl dispatch AMSI FPs) set to Allow + broad process/path exclusions. ThreatID-Allow is the lever; exclusions alone don't stop AMSI CmdLine detections.
|
||||||
- [Prefer SSH over RMM](feedback_prefer_ssh_over_rmm.md) — when a target has SSH (key auth) and it's easier, drive it via system OpenSSH (scp+ssh) instead of the GuruRMM agent; RMM runs as SYSTEM + is bound by the server-side timeout reaper + forces base64/quoting gymnastics. Reserve RMM as the fallback when SSH/VPN is down.
|
- [ACG MSP tool stack](reference_acg_msp_stack.md) — ScreenConnect/CW Control, Splashtop, Syncro, Datto RMM, Datto EDR/AV, GuruRMM are ACG's OWN tools; do not flag as foreign/threat on managed machines (Defender-off is expected when Datto AV is active).
|
||||||
- [Broken [[backlinks]] = write-me-later markers](feedback_broken_backlinks_are_writeme_markers.md) — A [[name]] with no matching file is an intentional "worth writing" marker, not breakage. Flesh the missing memory out from session history/logs and index it; never strip the link to silence the warning. memory-dream reports these as INFO candidates, not errors.
|
- [VoIP vendor stack: PacketDial / OIT / NetSapiens / YMCS](reference_packetdial_oit_netsapiens.md) — PacketDial = ACG's VoIP-dept brand (pbx.packetdial.com, the `packetdial` skill); NetSapiens = the PBX platform (API v2); OIT/OITVOIP = white-label wholesaler running NetSapiens (api.ucaasnetwork.com); reseller `91912.service`. YMCS (Yealink) = phone device-mgmt, pairs with the PBX.
|
||||||
- [gururmm session-logs are in a submodule](gururmm-session-logs-submodule-save.md) — commit in the submodule + `git push origin HEAD:main` (GURU-5070 CAN push over HTTP now); then advance the parent gitlink
|
- [ACG Website Hosting](project_azcomputerguru_hosting.md) — azcomputerguru.com is hosted on IX Web Hosting via cPanel.
|
||||||
- [Use `python` not `python3` on GURU-5070](python3-shim-use-python.md) — `python3` in Git bash hits the flaky MS Store shim; real interpreters are `python` (3.12) / `py` (3.14). coord.py + wiki-compile work via `python`; the coord lock IS claimable here
|
- [jq on Windows emits CRLF](feedback_jq_crlf_windows.md) — winget jq outputs CRLF; trailing \r silently breaks `for x in $(jq ...)` loops + read-from-@tsv. Override `jq(){ command jq "$@"|tr -d '\r'; }`. Windows-build-specific (passes on Mac/Linux).
|
||||||
- [Beast = primary GuruRMM Windows build host](gururmm-beast-windows-build-host.md) — GURU-BEAST-ROG (i9), reached from .30 via Tailscale-on-.30 at 100.101.122.4 as guru; Pluto is the fallback (`attempt_build beast || attempt_build pluto`). WiX must be 4.x (v6+ = OSMF); Beast NuGet needed nuget.org added
|
- [ScreenConnect RESTful API auth](reference_screenconnect_api.md) — CTRLAuthHeader = raw api_secret (no Basic/b64) + Origin header; now wrapped by the /screenconnect skill. Verified surface: GetSessionsByName/GetSessionDetails + writes SendCommand/SendMessage/UpdateCustomProperties + parameterized self-tagging installer. Still NO full-fleet inventory method (GetSessions missing).
|
||||||
- [GuruRMM command_type gotcha](reference_gururmm_command_type.md) — only shell/powershell/python/script/claude_task (+cmd alias); unknown type silently dropped, looks like a black-hole
|
- [No manufactured guardrails on our products](feedback_no_manufactured_guardrails.md) — At Mikes request on GuruRMM/GuruConnect/ClaudeTools, just execute; stop only for genuinely irreversible/destructive ops (with a heads-up). Read the actual code/state before claiming something is disallowed or a security hole.
|
||||||
- [GuruRMM log analysis -> Claude Haiku](gururmm-log-analysis-claude-cutover.md) — cut over from Ollama-on-Beast (timed out on fleet-sized prompts; "unreachable" was a mislabeled 120s timeout) to Anthropic API Haiku 4.5 w/ structured outputs; key at vault `projects/gururmm/anthropic-api`; ZDR pending; deploy needs root on .30 (.env + restart)
|
- [Stream-of-thought design convos](feedback_stream_of_thought_design.md) — Mike brainstorms features free-form, adding requirements iteratively; Claude validates/sharpens as a design partner but does NOT build until an explicit go, then captures parked threads durably (PARKED_*.md + todos) for a later /shape-spec.
|
||||||
- [IX WHM API access = 'ClaudeTools' token, not password](ix-whm-dns-api-access.md) — IX cPanel/WHM (ix.azcomputerguru.com:2087) DNS + all API work uses the FULL-ACCESS-root WHM API token at vault `infrastructure/ix-server` `credentials.whm-api-token` via header `Authorization: whm root:<token>` (force curl -4). Password basic-auth on legacy json-api now 403s. Public NS ns1/ns2.acghosting.com = 52.52.94.202.
|
- [RMM Thoughts backlog](feedback_rmm_thoughts_backlog.md) — GuruRMM ideas from Mike & Howard go in projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md (Status: Raw); pipeline thought -> discuss -> spec (/shape-spec) -> roadmap. Don't build until an explicit go.
|
||||||
- [Vault EVERY credential surfaced in-session](feedback-vault-every-credential.md) — any cred (pasted/created/discovered) -> store via the vault skill + document purpose & exact usage immediately; it's a standing job rule (reinforced in CORE CLAUDE.md). Lost IX creds wasted ~1h on 2026-06-12.
|
- [Syncro preview mandatory](feedback_syncro_preview_mandatory.md) — preview+confirm every Syncro write, including internal notes
|
||||||
- [GuruRMM install-report v1: reuse endpoint + failed-install agent](gururmm-install-report-failed-agent-v1.md) — legacy NSIS installer reuses /api/install-report (machine info + logs, success+fail); server upserts a visible "failed-install" device on failure reports (Mike: in v1); verify-connect-before-success; trend/near-fail analytics. Server side is a separate sequential SPEC after the legacy-agent branch lands.
|
- [Refresh session history first](feedback_refresh_session_history_first.md) — read prior incident logs before acting; do not re-remediate already-handled accounts
|
||||||
- [DM wrapping commands to Mike in Discord](feedback_dm_wrapping_commands_to_mike.md) — long/wrapping output (commands, consent links, URLs) goes via Discord DM (copies clean), not just chat; use the `discord-dm` skill (`discord-dm.sh mike "<x>"`). Gotchas: bot token vault projects/discord-bot/bot-token, Mike uid 264814939619721216, MUST set User-Agent or Cloudflare 403 errcode 1010, build JSON via jq + printf|--data-binary (direct -d → 50109).
|
- [Autonomy scope](feedback_autonomy_scope.md) — confirm only for client-affecting actions; internal docs/wiki/ClaudeTools = act autonomously
|
||||||
- [Physical access codes -> vault + wiki pointer](feedback_physical_access_codes.md) — alarm/lockbox/door codes go in vault clients/<slug>/physical-access-<location>.sops.yaml (kind: physical-access) + a `## Physical Access` pointer section in the client wiki; never plaintext. First entry: Peaceful Spirit NW.
|
- [Check for client-slug fragmentation](feedback_client_slug_fragmentation.md) — Before concluding a client has no records, grep broadly (company/owner/initials/hostname/"Last, First") across clients/, wiki/, session-logs/, vault — one client gets split across slug variants (Wolkin was 4: wolkin/wolkin-law/rswolkin/robert-wolkin). Consolidate to one canonical slug; action prior logs' Pending items.
|
||||||
- [CT Thoughts backlog](feedback_ct_thoughts_backlog.md) — ClaudeTools harness ideas go in docs/CT_THOUGHTS.md (trigger "ct thought:"); CT analogue of RMM_THOUGHTS. Don't build until explicit go. First entry = ClaudeTools 3.0 web co-work vision.
|
- [RMM user_session = false SMB failures](feedback_rmm_user_session_smb_false_negative.md) — GuruRMM net use/net view/Add-Printer to a remote \HOST fail with error 67 / RPC 1702 (even with valid creds) because user_session is a WTS-impersonated non-interactive token that can't do authenticated SMB. The share/printer may work fine interactively. Treat RMM SMB results as "can't tell"; verify via ScreenConnect.
|
||||||
- [AI-auth product boundary](project_ai_auth_product_boundary.md) — ClaudeTools/ClaudeTools 3.0 = internal-only, per-person subscription OAuth ok; GuruRMM = sellable, customer brings own API key (never ACG's subscription); backend dev = internal. Anthropic ToS bans subscription auth in third-party products.
|
- [Prefer SSH over RMM](feedback_prefer_ssh_over_rmm.md) — when a target has SSH (key auth) and it's easier, drive it via system OpenSSH (scp+ssh) instead of the GuruRMM agent; RMM runs as SYSTEM + is bound by the server-side timeout reaper + forces base64/quoting gymnastics. Reserve RMM as the fallback when SSH/VPN is down.
|
||||||
- [RMM SYSTEM context can't see user mapped drives](feedback_rmm_system_context_mapped_drives.md) — RMM runs as SYSTEM; `Test-Path F:\` etc. is False even when the user's mapped/redirected drive exists. Diagnose mapped-drive/redirect issues in `context:user_session`. Elevated apps (e.g. QB DB Server Manager "unable to retrieve root folder") need `EnableLinkedConnections=1` + reboot.
|
- [Broken [[backlinks]] = write-me-later markers](feedback_broken_backlinks_are_writeme_markers.md) — A [[name]] with no matching file is an intentional "worth writing" marker, not breakage. Flesh the missing memory out from session history/logs and index it; never strip the link to silence the warning. memory-dream reports these as INFO candidates, not errors.
|
||||||
- [AD2 = Dataforth-ops fork](project_ad2_dataforth_fork.md) — branch ad2 = main + thin Dataforth layer; keep fork edits ADDITIVE (Dataforth context in clients/dataforth/CLAUDE.dataforth.md, NOT .claude/CLAUDE.md); rebase onto main directly when sync.sh self-lock hits; no vault/jq/sops/age on this box.
|
- [gururmm session-logs are in a submodule](gururmm-session-logs-submodule-save.md) — commit in the submodule + `git push origin HEAD:main` (GURU-5070 CAN push over HTTP now); then advance the parent gitlink
|
||||||
- [GuruScan verification IN TEST / paused](project_guruscan_in_test_paused.md) — multi-engine scanner verify on DESKTOP-MS42HNC paused 2026-06-22 (VM rebooted mid-Emsisoft run); HitmanPro done (36 removed), Emsisoft full-scan unverified; resume `guruscan-agent-test.sh DESKTOP-MS42HNC scan-one Emsisoft`; Defender RTP/Tamper still off on VM
|
- [Use `python` not `python3` on GURU-5070](python3-shim-use-python.md) — `python3` in Git bash hits the flaky MS Store shim; real interpreters are `python` (3.12) / `py` (3.14). coord.py + wiki-compile work via `python`; the coord lock IS claimable here
|
||||||
- [GuruRMM fleet dispatch-hang fix](project_gururmm_dispatch_hang_fix.md) — blocking send_to on a full bounded channel to one black-holed agent wedged ALL command dispatch; fixed with try_send (9dae20c, deployed); proper black-hole eviction still missing (was reverted in 80df458) — finish it if it recurs
|
- [Beast = primary GuruRMM Windows build host](gururmm-beast-windows-build-host.md) — GURU-BEAST-ROG (i9), reached from .30 via Tailscale-on-.30 at 100.101.122.4 as guru; Pluto is the fallback (`attempt_build beast || attempt_build pluto`). WiX must be 4.x (v6+ = OSMF); Beast NuGet needed nuget.org added
|
||||||
- [Windows won't-boot / offline DISM repair playbook](windows-offline-dism-repair-gotchas.md) — Automatic Repair loop = boot-critical fault (disk/registry/wedged update), NOT shell/appx store corruption (that's a symptom); `FaultyPackageInProgress` + 100s of Install/Uninstall-Pending packages = wedged CU -> RevertPendingActions or clean install. Offline DISM rejects `wim:` source (0x800f082e) -> MOUNT the wim, source `\Windows`. Ventoy breaks WIM mount (0xc1420134) -> use Rufus. 25H2(26200)=24H2(26100)+enablement, so match 26100 media. First hit: Four Paws AvImark #32447.
|
- [GuruRMM command_type gotcha](reference_gururmm_command_type.md) — only shell/powershell/python/script/claude_task (+cmd alias); unknown type silently dropped, looks like a black-hole
|
||||||
- [365 app suite — authoritative map + consent-drift fix](reference_365_app_suite.md) — full map in `.claude/skills/remediation-tool/references/app-suite.md`; per-tenant consent is NOT uniform (VWP had the app but no SharePoint role). Run `consent-audit.sh <tenant|--all>` to detect gaps; fix via adminconsent URL or direct appRoleAssignment grant.
|
- [GuruRMM log analysis -> Claude Haiku](gururmm-log-analysis-claude-cutover.md) — cut over from Ollama-on-Beast (timed out on fleet-sized prompts; "unreachable" was a mislabeled 120s timeout) to Anthropic API Haiku 4.5 w/ structured outputs; key at vault `projects/gururmm/anthropic-api`; ZDR pending; deploy needs root on .30 (.env + restart)
|
||||||
- [Remediation-tool has full M365 access (incl. SharePoint)](reference_remediation_tool_365_access.md) — the app suite covers Graph/EXO/Defender/SharePoint; don't declare "no access" on an accessDenied. SharePoint app-only needs a CERT (secret = "Unsupported app only token"); use get-token.sh `sharepoint`/`sharepoint-admin` tiers + CSOM admin API (Graph /admin/sharepoint/settings scope not held). Full map: skill references/app-permissions-and-sharepoint.md.
|
- [IX WHM API access = 'ClaudeTools' token, not password](ix-whm-dns-api-access.md) — IX cPanel/WHM (ix.azcomputerguru.com:2087) DNS + all API work uses the FULL-ACCESS-root WHM API token at vault `infrastructure/ix-server` `credentials.whm-api-token` via header `Authorization: whm root:<token>` (force curl -4). Password basic-auth on legacy json-api now 403s. Public NS ns1/ns2.acghosting.com = 52.52.94.202.
|
||||||
|
- [Vault EVERY credential surfaced in-session](feedback-vault-every-credential.md) — any cred (pasted/created/discovered) -> store via the vault skill + document purpose & exact usage immediately; it's a standing job rule (reinforced in CORE CLAUDE.md). Lost IX creds wasted ~1h on 2026-06-12.
|
||||||
|
- [GuruRMM install-report v1: reuse endpoint + failed-install agent](gururmm-install-report-failed-agent-v1.md) — legacy NSIS installer reuses /api/install-report (machine info + logs, success+fail); server upserts a visible "failed-install" device on failure reports (Mike: in v1); verify-connect-before-success; trend/near-fail analytics. Server side is a separate sequential SPEC after the legacy-agent branch lands.
|
||||||
|
- [DM wrapping commands to Mike in Discord](feedback_dm_wrapping_commands_to_mike.md) — long/wrapping output (commands, consent links, URLs) goes via Discord DM (copies clean), not just chat; use the `discord-dm` skill (`discord-dm.sh mike "<x>"`). Gotchas: bot token vault projects/discord-bot/bot-token, Mike uid 264814939619721216, MUST set User-Agent or Cloudflare 403 errcode 1010, build JSON via jq + printf|--data-binary (direct -d → 50109).
|
||||||
|
- [Physical access codes -> vault + wiki pointer](feedback_physical_access_codes.md) — alarm/lockbox/door codes go in vault clients/<slug>/physical-access-<location>.sops.yaml (kind: physical-access) + a `## Physical Access` pointer section in the client wiki; never plaintext. First entry: Peaceful Spirit NW.
|
||||||
|
- [CT Thoughts backlog](feedback_ct_thoughts_backlog.md) — ClaudeTools harness ideas go in docs/CT_THOUGHTS.md (trigger "ct thought:"); CT analogue of RMM_THOUGHTS. Don't build until explicit go. First entry = ClaudeTools 3.0 web co-work vision.
|
||||||
|
- [AI-auth product boundary](project_ai_auth_product_boundary.md) — ClaudeTools/ClaudeTools 3.0 = internal-only, per-person subscription OAuth ok; GuruRMM = sellable, customer brings own API key (never ACG's subscription); backend dev = internal. Anthropic ToS bans subscription auth in third-party products.
|
||||||
|
- [RMM SYSTEM context can't see user mapped drives](feedback_rmm_system_context_mapped_drives.md) — RMM runs as SYSTEM; `Test-Path F:\` etc. is False even when the user's mapped/redirected drive exists. Diagnose mapped-drive/redirect issues in `context:user_session`. Elevated apps (e.g. QB DB Server Manager "unable to retrieve root folder") need `EnableLinkedConnections=1` + reboot.
|
||||||
|
- [AD2 = Dataforth-ops fork](project_ad2_dataforth_fork.md) — branch ad2 = main + thin Dataforth layer; keep fork edits ADDITIVE (Dataforth context in clients/dataforth/CLAUDE.dataforth.md, NOT .claude/CLAUDE.md); rebase onto main directly when sync.sh self-lock hits; no vault/jq/sops/age on this box.
|
||||||
|
- [GuruScan verification IN TEST / paused](project_guruscan_in_test_paused.md) — multi-engine scanner verify on DESKTOP-MS42HNC paused 2026-06-22 (VM rebooted mid-Emsisoft run); HitmanPro done (36 removed), Emsisoft full-scan unverified; resume `guruscan-agent-test.sh DESKTOP-MS42HNC scan-one Emsisoft`; Defender RTP/Tamper still off on VM
|
||||||
|
- [GuruRMM fleet dispatch-hang fix](project_gururmm_dispatch_hang_fix.md) — blocking send_to on a full bounded channel to one black-holed agent wedged ALL command dispatch; fixed with try_send (9dae20c, deployed); proper black-hole eviction still missing (was reverted in 80df458) — finish it if it recurs
|
||||||
|
- [Windows won't-boot / offline DISM repair playbook](windows-offline-dism-repair-gotchas.md) — Automatic Repair loop = boot-critical fault (disk/registry/wedged update), NOT shell/appx store corruption (that's a symptom); `FaultyPackageInProgress` + 100s of Install/Uninstall-Pending packages = wedged CU -> RevertPendingActions or clean install. Offline DISM rejects `wim:` source (0x800f082e) -> MOUNT the wim, source `\Windows`. Ventoy breaks WIM mount (0xc1420134) -> use Rufus. 25H2(26200)=24H2(26100)+enablement, so match 26100 media. First hit: Four Paws AvImark #32447.
|
||||||
|
- [365 app suite — authoritative map + consent-drift fix](reference_365_app_suite.md) — full map in `.claude/skills/remediation-tool/references/app-suite.md`; per-tenant consent is NOT uniform (VWP had the app but no SharePoint role). Run `consent-audit.sh <tenant|--all>` to detect gaps; fix via adminconsent URL or direct appRoleAssignment grant.
|
||||||
|
- [Remediation-tool has full M365 access (incl. SharePoint)](reference_remediation_tool_365_access.md) — the app suite covers Graph/EXO/Defender/SharePoint; don't declare "no access" on an accessDenied. SharePoint app-only needs a CERT (secret = "Unsupported app only token"); use get-token.sh `sharepoint`/`sharepoint-admin` tiers + CSOM admin API (Graph /admin/sharepoint/settings scope not held). Full map: skill references/app-permissions-and-sharepoint.md.
|
||||||
|
- [AV migration: Bitdefender -> Datto EDR](project_av_migration_bitdefender_to_edr.md) — retire Bitdefender fleet-wide, ONLY exception Glaztech (Dataforth migrates); end-state per machine = GuruRMM + Datto EDR
|
||||||
|
- [RMM deploy via ScreenConnect](reference_rmm_deploy_via_screenconnect.md) — push GuruRMM agent to client workstations via SC send-command (SYSTEM), not DC remote-exec (DCOM/schtasks blocked on Win11 clients)
|
||||||
|
- [ScreenConnect custom-property slots](reference_screenconnect_custom_property_slots.md) — CP1=Company CP2=Site CP3=Department CP4=Device Type CP8=Tag (API hides labels; UpdateSessionCustomProperties replaces the whole array)
|
||||||
|
- [ScreenConnect cleanup uses wiki as source](feedback_screenconnect_cleanup_wiki_source.md) — per-client SC/RMM metadata cleanup pulls machine->dept/location from the client wiki; enrich the wiki when missing
|
||||||
|
- [TECH03L systemprofile shortcut corruption](project_tech03l_systemprofile_shortcut_corruption.md) — Auto-Claude "opens then closes" = .lnk pointing at nonexistent systemprofile path; repoint, do not debug the app
|
||||||
|
- [Claude tui fullscreen crash](feedback_claude_tui_fullscreen_crash.md) — "flicker-free" prompt writes tui=fullscreen; instant-exit crash loop on some Windows terminals; set tui=default (reinstall does NOT fix)
|
||||||
|
- [Backup targets never guessed](feedback_backup_targets_never_guessed.md) — billed-vs-running mismatches are billing questions for Mike/Winter; never infer/deploy backup targets
|
||||||
|
- [Fire #dev-alerts for client/RMM work](feedback_fire_dev_alerts_for_client_work.md) — Howard+Mike watch #dev-alerts Discord for live visibility into what techs/sessions touch. Fire post-bot-alert.sh ([DEV]/[RMM]/[DEPLOY]/[GURURMM] prefix -> #dev-alerts) at the START of any RMM/Dev or active-client-machine action. No CLAUDE.md rule yet — this memory is the rule.
|
||||||
|
- [M365 app: SharePoint needs CERT](reference_m365_app_sharepoint_rest_vs_graph.md) — SP app-only works via the CERT (get-token.sh <tenant> sharepoint); the secret gives "Unsupported app only token". Graph app-only uses the secret.
|
||||||
|
- [EDR auto-isolates GuruRMM PowerShell](project_edr_rmm_autoisolation_fp.md) — Datto "Exfiltration Over HTTP" rule auto-isolates benign GuruRMM scripts fleet-wide (vwp-qbs 4h outage); watcher + narrow suppression deployed, policy fix pending Mike
|
||||||
|
- [Background tasks — no shell `&`](feedback_background_task_no_ampersand.md) — with run_in_background:true, run the command in the foreground of the shell; adding `&`/`disown` forks + exits 0 instantly, orphaning a blocking wait so it never delivers (hit twice building ask-forum)
|
||||||
|
- [ask-forum — human-in-the-loop via #ct-forum](../skills/ask-forum/SKILL.md) — ask a teammate a question in the private ct-forum Discord forum, get their human answer back in-session; forum-only, any human can answer, run the --wait as a proper background task
|
||||||
|
- [Claude Code Bun crash -> pin 2.1.110](reference_claude_code_bun_crash_pin.md) - v2.1.114+ segfaults on Win11 at new-session start; npm pin 2.1.110 + DISABLE_AUTOUPDATER=1; unpin on #55219 fix
|
||||||
|
- [GuruRMM build-server SSH key](reference_gururmm_build_server_ssh.md) — guru@172.16.3.30 key is ~/.ssh/gururmm-physical (not id_*); password fallback vault infrastructure/gururmm-server
|
||||||
|
- [LAB-SVR retired](feedback_labsvr_retired.md) — Len's Auto LAB-SVR replaced by LAB-SERVER; never chase LAB-SVR
|
||||||
|
|||||||
26
.claude/memory/feedback_background_task_no_ampersand.md
Normal file
26
.claude/memory/feedback_background_task_no_ampersand.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: Background tasks — run_in_background only, never add a shell `&`
|
||||||
|
description: When launching a long-running command as a background task (run_in_background:true), do NOT also append a shell `&`/`disown`/`nohup`. The shell forks the command and exits 0 immediately, orphaning it — a blocking wait then never delivers its result. Run it in the FOREGROUND of that shell; the harness does the backgrounding.
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
When you want a long-running command (e.g. `ask-forum.sh --wait`, a poll loop, a blocking
|
||||||
|
watcher) to run in the background, use the Bash tool's **`run_in_background: true`** and run
|
||||||
|
the command in the **foreground of that shell** — a bare `bash script.sh ...` with **no
|
||||||
|
trailing `&`, no `disown`, no `nohup`**.
|
||||||
|
|
||||||
|
**Why:** appending `&` forks the command off inside the shell; the shell then reaches the
|
||||||
|
end and exits **0 immediately**. The harness sees exit 0 and reports the task "completed"
|
||||||
|
right away, while the forked process is orphaned (and often killed). A blocking `--wait`
|
||||||
|
that was supposed to sit for minutes and deliver an answer instead returns nothing — its
|
||||||
|
result is never captured or notified.
|
||||||
|
|
||||||
|
**How to apply:**
|
||||||
|
- Background wait, CORRECT: `Bash(command="bash .claude/scripts/ask-forum.sh --wait <id> --timeout 1800", run_in_background=true)` — the wait truly blocks server-side, costs zero tokens while pending, and the harness pings you the instant it completes.
|
||||||
|
- WRONG: `Bash(command="bash ... --wait ... &", run_in_background=true)` and `... & disown` — orphans the wait, "completes" instantly, no answer.
|
||||||
|
- The two mechanisms are redundant: `run_in_background` IS the backgrounding. Never stack a shell `&` on top of it.
|
||||||
|
|
||||||
|
**Incident:** hit TWICE in one session (2026-07-08) building the [[ask-forum]] flow — each
|
||||||
|
time the "background wait" for a Discord reply exited 0 instantly and never delivered the
|
||||||
|
answer, which looked like the reply was lost and forced the user to manually prompt "did
|
||||||
|
they answer?". Logged as `--friction` in `errorlog.md`. Related: [[ask-forum]].
|
||||||
20
.claude/memory/feedback_backup_check_only_billed.md
Normal file
20
.claude/memory/feedback_backup_check_only_billed.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: feedback_backup_check_only_billed
|
||||||
|
description: Only verify backup status for clients that actually bill a backup line - don't scan non-billed clients
|
||||||
|
metadata:
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
Backup verification is scoped to clients **marked for backups** — i.e. those with a billed
|
||||||
|
Data Backup line (Syncro recurring-schedule line qty > 0). If a client is NOT billed for
|
||||||
|
backup, do NOT scan their machines / MSP360 / backends for backup status. (Howard,
|
||||||
|
2026-07-07, GPS audit — Marty Ryan has Svc = A E only, no backup line, so stop checking.)
|
||||||
|
|
||||||
|
**Why:** "is X backing up" only matters where the client pays for it; scanning non-billed
|
||||||
|
clients burns time/endpoint calls and produces findings nobody asked for.
|
||||||
|
|
||||||
|
**How to apply:** determine billed-backup status first (Syncro backup line qty — see the
|
||||||
|
`/syncro schedules`/`schedule` pull, authoritative), then only verify the ones with a seat.
|
||||||
|
Non-billed clients that happen to be backing up are a separate "revenue leak" question for
|
||||||
|
Mike/Winter, not a per-machine scan target. Related: [[feedback_backup_targets_never_guessed]],
|
||||||
|
[[reference_msp360_backup_monitoring]].
|
||||||
21
.claude/memory/feedback_backup_targets_never_guessed.md
Normal file
21
.claude/memory/feedback_backup_targets_never_guessed.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: feedback_backup_targets_never_guessed
|
||||||
|
description: Backup targets are a deliberate per-client decision - never infer which machine needs backup or deploy backup jobs from billing counts
|
||||||
|
metadata:
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
When reconciling billed backup lines (e.g. Syncro "Service - Data Backup" qty) against
|
||||||
|
machines actually backing up (MSP360/B2), a mismatch is a BILLING question for Mike/Winter,
|
||||||
|
never a deployment instruction. (Howard, 2026-07-05, GPS->RMM audit backup verification.)
|
||||||
|
|
||||||
|
**Why:** which machine gets backed up is a deliberate choice made per client (usually the
|
||||||
|
server or a specific data machine) — billed qty does not mean "N machines each need a
|
||||||
|
backup job." Guessing targets and adding backup jobs would burn storage and misrepresent
|
||||||
|
what the client agreed to.
|
||||||
|
|
||||||
|
**How to apply:** report mismatches (billed > running, running > billed, dead buckets) as
|
||||||
|
findings with evidence; route the decision to Mike/Winter/client. Only configure a backup
|
||||||
|
job when the target machine is explicitly named by the user/client. Related:
|
||||||
|
[[project_av_migration_bitdefender_to_edr]] (the AV migration IS deploy-everywhere, unlike
|
||||||
|
backup — don't conflate the two postures).
|
||||||
22
.claude/memory/feedback_claude_tui_fullscreen_crash.md
Normal file
22
.claude/memory/feedback_claude_tui_fullscreen_crash.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: claude-tui-fullscreen-crash
|
||||||
|
description: Claude Code "flicker-free rendering" prompt writes tui=fullscreen to settings.json and can crash-loop the CLI on Windows; fix = delete the tui key
|
||||||
|
metadata:
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
On ACG-TECH03L (2026-07-04, Claude Code 2.1.201), accepting the startup prompt asking to
|
||||||
|
enable **flicker-free rendering** wrote `"tui": "fullscreen"` into
|
||||||
|
`~/.claude/settings.json`. On that machine's terminal the fullscreen/alternate-screen
|
||||||
|
mode crashed the CLI instantly on every subsequent launch ("opens and then closes") —
|
||||||
|
from any launch path, since all read the same settings file.
|
||||||
|
|
||||||
|
**Why:** the setting persists, so one bad answer to the prompt turns into a permanent
|
||||||
|
crash loop; reinstalling Claude Code does NOT fix it because settings survive reinstall.
|
||||||
|
|
||||||
|
**How to apply:** if a fleet machine's Claude Code exits immediately at launch, check
|
||||||
|
`~/.claude/settings.json` for `"tui": "fullscreen"` and set it to `"tui": "default"`
|
||||||
|
(surgically — keep hooks/plugins; explicit "default" also suppresses re-prompting,
|
||||||
|
which deleting the key does not). Emergency override without touching config:
|
||||||
|
`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`. Decline the flicker-free prompt on machines
|
||||||
|
with older terminals/conhost. Related: [[tech03l-systemprofile-shortcut-corruption]].
|
||||||
26
.claude/memory/feedback_discord_read_replies.md
Normal file
26
.claude/memory/feedback_discord_read_replies.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: feedback_discord_read_replies
|
||||||
|
description: Discord DM replies are invisible to coord/repo sync — use `discord-dm.sh read <user>` to see if mike/winter/rob actually answered.
|
||||||
|
metadata:
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
When we DM someone via the `discord-dm` skill and wait on an answer, their reply does NOT
|
||||||
|
come through coord messages or repo sync — those channels never carry Discord replies. Before
|
||||||
|
telling the user "no reply yet / they didn't answer," READ the DM channel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .claude/scripts/discord-dm.sh read mike 10 # last 10 in the mike DM
|
||||||
|
bash .claude/scripts/discord-dm.sh read #dev-alerts # a channel
|
||||||
|
```
|
||||||
|
|
||||||
|
Output is oldest-first; lines marked `>>>` are from THEM (replies), indented lines are us.
|
||||||
|
|
||||||
|
**Why:** the bot participates in every DM channel it opened, so `GET /channels/<id>/messages`
|
||||||
|
returns full content — the Message Content privileged intent only gates gateway events, not this
|
||||||
|
REST fetch. Before adding `read` (2026-07-07, Howard flagged it) the wrapper was send-only, so
|
||||||
|
replies from mike/winter were silently missed.
|
||||||
|
|
||||||
|
**How to apply:** after DMing a question to mike/winter/rob, `read` that user to check for their
|
||||||
|
answer instead of assuming silence. Related: [[reference_community_forum]] is a different channel;
|
||||||
|
this is Discord DMs/channels via [[discord-dm]] (`.claude/scripts/discord-dm.sh`).
|
||||||
29
.claude/memory/feedback_fire_dev_alerts_for_client_work.md
Normal file
29
.claude/memory/feedback_fire_dev_alerts_for_client_work.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: feedback_fire_dev_alerts_for_client_work
|
||||||
|
description: Fire a #dev-alerts Discord post (post-bot-alert.sh with an [RMM]/[DEV]/[DEPLOY]/[BUILD]/[GURURMM] prefix) at the START of any RMM/Dev or active-client-machine action — Howard+Mike watch that channel for live visibility into what techs/sessions are touching
|
||||||
|
metadata:
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
Howard and Mike watch the **#dev-alerts** Discord channel to see, in real time, what the other techs
|
||||||
|
(and Claude sessions) are working on. Any substantive action on **RMM/Dev** or an **ACTIVE CLIENT
|
||||||
|
machine** MUST post a #dev-alerts heads-up — ideally at the START of the work, not just after.
|
||||||
|
|
||||||
|
**What counts:** SSH into a client server, restarting/reconfiguring services, editing boot/config on a
|
||||||
|
live box, RMM remote commands, agent/server/dashboard deploys, channel promotions, anything with real
|
||||||
|
blast radius on production/client infra.
|
||||||
|
|
||||||
|
**Why:** without it a session can be hand-editing a live client box (e.g. Lonestar Electrical's Unraid
|
||||||
|
`/boot/config/go`, cycling libvirt) with ZERO visibility to the team — exactly what happened
|
||||||
|
2026-07-06, which Mike flagged: "all of the actions you've taken today should have fired the discord
|
||||||
|
alerts."
|
||||||
|
|
||||||
|
**How to apply:**
|
||||||
|
`bash .claude/scripts/post-bot-alert.sh "[DEV] <client/box> — <what + why>. — Claude @ <machine> (<user>)"`
|
||||||
|
Prefixes `[RMM] [DEPLOY] [DEV] [BUILD] [GURURMM] [SMARTBADGE-WATCH]` auto-route to the PRIVATE
|
||||||
|
#dev-alerts (Howard+Mike only, channel 1509998508198068484); everything else → #bot-alerts (whole
|
||||||
|
team). It soft-fails (best-effort), so it never blocks the work. Channel IDs + routing live in
|
||||||
|
`.claude/scripts/post-bot-alert.sh`.
|
||||||
|
|
||||||
|
As of 2026-07-06 there is NO CLAUDE.md rule enforcing this — treat this memory AS the rule until a core
|
||||||
|
rule/hook lands (worth proposing one). Related: [[discord-dm]] skill for direct DMs.
|
||||||
22
.claude/memory/feedback_gitbash_tz_ignored.md
Normal file
22
.claude/memory/feedback_gitbash_tz_ignored.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Git Bash `TZ=` is silently ignored on Windows — never use it for current-time reporting
|
||||||
|
|
||||||
|
**Date:** 2026-07-09
|
||||||
|
**Source:** Winter correction, Len's Auto browser-hijack thread (Discord bot session)
|
||||||
|
|
||||||
|
## What happened
|
||||||
|
Reported "current time" to Winter as `4:40 PM AZ` using
|
||||||
|
`TZ="America/Phoenix" date '+... AZ'` in Git Bash on BEAST. MSYS/Git-Bash has no
|
||||||
|
tzdata for that zone name, so `TZ` was silently ignored and the command printed
|
||||||
|
**UTC** (16:40) — which the format string then labeled "AZ". Real local time was
|
||||||
|
9:40 AM AZ. Winter caught it; caused a confusing side-quest about whether BEAST's
|
||||||
|
clock was a month off (it wasn't — clock + NTP + timezone all correct).
|
||||||
|
|
||||||
|
## Rule
|
||||||
|
- For current date/time on a Windows machine: use **PowerShell `Get-Date`**
|
||||||
|
(system local time — BEAST is already set to Arizona), or `date` in bash with
|
||||||
|
NO TZ override (MSYS date honors the Windows local zone), or an external HTTP
|
||||||
|
`Date:` header for authoritative UTC.
|
||||||
|
- Never trust `TZ=<IANA zone>` in Git Bash — unknown zones fall back to UTC with
|
||||||
|
no error, and a hardcoded zone label in the format string turns that into a
|
||||||
|
confidently-wrong answer.
|
||||||
|
- Calendar math (`date -d 2026-06-05 +%A`) is unaffected — that stays fine.
|
||||||
@@ -9,3 +9,5 @@ When the system-reminder context claims `userEmail = mike@azcomputerguru.com` bu
|
|||||||
**Why:** On 2026-04-23 I addressed Howard as "Mike" because the claudeMd/userEmail context said so. Howard corrected me. The CLAUDE.md onboarding flow explicitly defines identity.json as the authoritative local identity.
|
**Why:** On 2026-04-23 I addressed Howard as "Mike" because the claudeMd/userEmail context said so. Howard corrected me. The CLAUDE.md onboarding flow explicitly defines identity.json as the authoritative local identity.
|
||||||
|
|
||||||
**How to apply:** At every session start, read `.claude/identity.json` FIRST (as CLAUDE.md step 1 requires) and greet from that file's `full_name`. Ignore the `# userEmail` context block for greeting purposes. If identity.json is missing, follow the first-machine bootstrap flow in CLAUDE.md — don't fall back to userEmail.
|
**How to apply:** At every session start, read `.claude/identity.json` FIRST (as CLAUDE.md step 1 requires) and greet from that file's `full_name`. Ignore the `# userEmail` context block for greeting purposes. If identity.json is missing, follow the first-machine bootstrap flow in CLAUDE.md — don't fall back to userEmail.
|
||||||
|
|
||||||
|
This also covers **pronoun resolution**: "me"/"I"/"my" in any request (e.g. "send the list to me and Winter", "DM me the link") resolves to the identity.json user, NOT the userEmail account. On 2026-07-14 Howard asked to send a backup report "to me and Winter" and I DM'd Mike instead — Howard never got it. Any send/notify/assign target of "me" = the person in identity.json.
|
||||||
|
|||||||
16
.claude/memory/feedback_labsvr_retired.md
Normal file
16
.claude/memory/feedback_labsvr_retired.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
name: LAB-SVR is retired — replaced by LAB-SERVER
|
||||||
|
description: Len's Auto machine LAB-SVR was decommissioned and replaced by LAB-SERVER; never chase LAB-SVR again
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
Len's Auto Brokerage's **LAB-SVR was replaced by LAB-SERVER**. Do NOT investigate, probe,
|
||||||
|
queue commands on, or report LAB-SVR as a stale/offline machine — it is retired.
|
||||||
|
|
||||||
|
**Why:** Howard has corrected this repeatedly ("for the last time... STOP LOOKING FOR THAT
|
||||||
|
MACHINE", 2026-07-15). LAB-SVR still appears in stale data (GuruRMM offline agent record,
|
||||||
|
old MSP360 plan rows), which keeps baiting audits into flagging it.
|
||||||
|
|
||||||
|
**How to apply:** In any backup/RMM/fleet audit, treat LAB-SVR rows as retired-machine
|
||||||
|
residue. Check LAB-SERVER for Len's Auto server health instead. Cleanup of the stale
|
||||||
|
LAB-SVR records (MSP360 plans/user side, RMM agent record) is the actual actionable item.
|
||||||
29
.claude/memory/feedback_scheduled_task_no_console_flash.md
Normal file
29
.claude/memory/feedback_scheduled_task_no_console_flash.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: feedback_scheduled_task_no_console_flash
|
||||||
|
description: Windows scheduled tasks must launch console apps windowless (wscript VBS for bash, pythonw + -Hidden for python) or they flash a console window on the desktop every run
|
||||||
|
metadata:
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
A Windows scheduled task whose action runs a **console-subsystem** program — `bash.exe`,
|
||||||
|
`py.exe`, `python.exe`, `cmd.exe` — with `LogonType=Interactive` and `Settings.Hidden=False`
|
||||||
|
draws a visible console window on the desktop **every time it fires**. On short repetition
|
||||||
|
intervals (the EDR watcher fires every 10 min) this reads to the user as a command prompt
|
||||||
|
"opening and closing" constantly. It is the single most reported harness annoyance and it
|
||||||
|
keeps recurring because the *installer scripts* recreate the flashing task.
|
||||||
|
|
||||||
|
**Why:** wscript.exe is a GUI-subsystem host and pythonw.exe is the windowless Python host;
|
||||||
|
neither allocates a console. bash.exe / py.exe / python.exe do.
|
||||||
|
|
||||||
|
**How to apply:** whenever you register (or find) a ClaudeTools scheduled task:
|
||||||
|
- **bash target** -> point the action at `C:\Windows\System32\wscript.exe` with a one-line VBS
|
||||||
|
wrapper that does `CreateObject("WScript.Shell").Run "...bash.exe -lc ...", 0, False`
|
||||||
|
(window style `0` = hidden). Pattern files: `gps-rmm-progress-hidden.vbs`,
|
||||||
|
`edr-isolation-watch-hidden.vbs`.
|
||||||
|
- **python target** -> use `pythonw.exe` (next to the active interpreter), never `py.exe`/`python.exe`.
|
||||||
|
- **Always** add `-Hidden` to `New-ScheduledTaskSettingsSet` as belt-and-suspenders.
|
||||||
|
- Verify: `Start-ScheduledTask`, then confirm no visible bash/wscript/conhost MainWindow.
|
||||||
|
|
||||||
|
Fixed installers: `register-edr-watcher.ps1`, `register-orphan-detector.ps1`. The scheduled
|
||||||
|
tasks themselves are per-machine (not in the repo) — re-run the fixed installer, or repoint
|
||||||
|
the existing task's action, on every box that runs it. Related: [[feedback_session_recovery]].
|
||||||
23
.claude/memory/feedback_screenconnect_cleanup_wiki_source.md
Normal file
23
.claude/memory/feedback_screenconnect_cleanup_wiki_source.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: feedback_screenconnect_cleanup_wiki_source
|
||||||
|
description: ScreenConnect/RMM machine-metadata cleanup uses the client wiki as source of truth; enrich the wiki when info is missing
|
||||||
|
metadata:
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
For the ScreenConnect session-hygiene + RMM-site cleanup (per client: normalize Company,
|
||||||
|
set Site/Department/Device Type/Tag, fix RMM sites, dedup) — **the client wiki is the primary
|
||||||
|
source of truth for machine -> department / person / location.** (Howard, 2026-07-03.)
|
||||||
|
|
||||||
|
**Why:** the wiki already carries person->machine->department maps (e.g. Cascades: Ashley
|
||||||
|
Jensen->Accounting on DESKTOP-U2DHAP0, Shelby Trozzi->MemCare Director on MDIRECTOR-PC), so
|
||||||
|
department/site can be derived from it rather than guessed.
|
||||||
|
|
||||||
|
**How to apply, per client:**
|
||||||
|
1. Pull the machine metadata from the client wiki FIRST (`wiki/clients/<slug>.md` + its source docs), then hostname role tokens, then UniFi switch/AP building/area names.
|
||||||
|
2. Where a machine's department/location is NOT in the wiki, **learn it (ask the user / investigate) and UPDATE the wiki** with the finding — so the wiki becomes the durable record and the next pass is easier.
|
||||||
|
3. Slot mapping = [[reference_screenconnect_custom_property_slots]] (CP1 Company, CP2 Site, CP3 Department, CP4 Device Type, CP8 Tag). Match each client's EXISTING vocabulary (e.g. Cascades uses Device Type "Desktop"/"Laptop"/"Server", not "Workstation"; Dataforth uses "Workstation").
|
||||||
|
4. UniFi placement: Dataforth = cloud UDM via Site Manager connector; Cascades = UOS controller. AP/switch names are building/area-coded. Related: [[project_av_migration_bitdefender_to_edr]], GPS->RMM audit `projects/gps-rmm-audit/tracker.md`.
|
||||||
|
|
||||||
|
Done so far: Dataforth (D1/D2 sites split + tags), Cascades (single-site + departments). Remaining
|
||||||
|
per-client unknowns get filled from the wiki as we walk each machine; wiki gets updated when it doesn't have it.
|
||||||
20
.claude/memory/feedback_simplest_solution_first.md
Normal file
20
.claude/memory/feedback_simplest_solution_first.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: feedback_simplest_solution_first
|
||||||
|
description: Always try the simplest solution to a problem first; escalate to complex only as needed
|
||||||
|
metadata:
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
Always START with the simplest solution that could solve the problem. Confirm it works, then
|
||||||
|
graduate to more advanced/complicated approaches only as the simple one proves insufficient.
|
||||||
|
|
||||||
|
**Why:** Mike watched a "ask the UDM what IP it thinks VWP-QBS is" turn balloon into vault
|
||||||
|
reads, plink host-key pinning, and password sanity checks — when the clean answer was a single
|
||||||
|
`nslookup vwp-qbs.vwp.us 172.16.9.1` from a machine already on the VPN. The heavy path burned
|
||||||
|
tokens and his patience ("incapable of doing things cleanly like a week ago"). Now a standing
|
||||||
|
CLAUDE.md core rule.
|
||||||
|
|
||||||
|
**How to apply:** Before picking tooling, ask "what is the least-complex thing that answers
|
||||||
|
this?" — one DNS lookup, one API call, one command. Do that first and observe the result.
|
||||||
|
Only reach for SSH/plink/double-hops/multi-step orchestration when the simple probe genuinely
|
||||||
|
can't answer. Escalate deliberately, not by default. See [[feedback_verify_live_before_acting]].
|
||||||
32
.claude/memory/project_av_migration_bitdefender_to_edr.md
Normal file
32
.claude/memory/project_av_migration_bitdefender_to_edr.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: project_av_migration_bitdefender_to_edr
|
||||||
|
description: AV strategy — migrate all clients from Bitdefender to Datto EDR; ONLY exception is Glaztech
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
Standing AV direction (Howard 2026-07-03, scope narrowed 2026-07-04): ACG is moving
|
||||||
|
endpoint AV/security from **Bitdefender GravityZone -> Datto EDR** for **all clients
|
||||||
|
EXCEPT Glaz-Tech Industries (glaztech)** — the ONLY client staying on Bitdefender.
|
||||||
|
**Dataforth migrates fully** (originally excepted; Howard removed the exception
|
||||||
|
2026-07-04 — Dataforth already runs 51 EDR agents with only 5 BD endpoints left:
|
||||||
|
D1-ENGI-006, DESKTOP-L2LE31M, DATAFORTH-PC, SURFACEOPS, MING-HP).
|
||||||
|
|
||||||
|
**Why:** consolidate on Datto EDR as the security plane; Bitdefender is being retired
|
||||||
|
fleet-wide. Glaztech keeps its large established Bitdefender footprint (~242 BD
|
||||||
|
endpoint records, vs 159 GPS-billed — count includes stale ghosts).
|
||||||
|
|
||||||
|
**How to apply:** whenever setting up or reconciling a client's endpoints (e.g. the
|
||||||
|
GPS->GuruRMM coverage audit), the target end-state per machine is: GuruRMM agent +
|
||||||
|
Datto EDR agent (AV on), and Bitdefender **removed**. Do NOT deploy new Bitdefender
|
||||||
|
coverage. Use existing Bitdefender inventory only as a discovery source for which
|
||||||
|
machines exist (its company names carry the Syncro CID `_NNNNN` suffix — exact join
|
||||||
|
key to Syncro customers). Deploy Datto EDR via `[[datto-edr]]` (create-group ->
|
||||||
|
mint-key -> deploy-cmd, pushed through `/rmm`).
|
||||||
|
|
||||||
|
Migration scope quantified 2026-07-04 (tracker Phase 4): 27 clients / 141 BD
|
||||||
|
endpoints + Dataforth's 5. Datto EDR "Default RMM Org" holds ~35 unassigned agents
|
||||||
|
that belong to real clients (IMC x7, Russo x2, Len's, Rednour, Reliant, etc.) —
|
||||||
|
attribute them to proper orgs as part of the migration.
|
||||||
|
|
||||||
|
Related: GPS->RMM audit tracker `projects/gps-rmm-audit/tracker.md`.
|
||||||
27
.claude/memory/project_bug_tracker_gitea.md
Normal file
27
.claude/memory/project_bug_tracker_gitea.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: Bug & Project Tracker (Gitea Issues)
|
||||||
|
description: Gitea Issues is the official bug/feature/skill/project tracker for GuruRMM, GuruConnect, and ClaudeTools. Dashboard at projects/msp-tools/bug-tracker/dashboard.html. Skill at .claude/skills/bug-tracker/skill.md.
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
Mike and Howard want centralized tracking of bugs, features, skills, and project work across
|
||||||
|
GuruRMM, GuruConnect, and ClaudeTools — the wiki was not working as a tracker (no status,
|
||||||
|
no history, things got forgotten). Set up 2026-07-17.
|
||||||
|
|
||||||
|
**Why:** Bug fixes and feature work were getting dropped because there was no visible tracker.
|
||||||
|
Unless someone remembered to ask, work just disappeared. The forum (Discord ct-forum) is good
|
||||||
|
for discussion/decisions but has no status fields, filtering, or board view.
|
||||||
|
|
||||||
|
**How to apply:**
|
||||||
|
- When a bug surfaces during a session, file it as a Gitea issue (use the `bug-tracker` skill)
|
||||||
|
- Repos tracked: `gururmm`, `guru-connect`, `claudetools` (owner: `azcomputerguru`)
|
||||||
|
- Standard labels: bug/feature/skill/project, P1-P4 priority, component tags, status tags (in-progress/blocked/dropped/fixed/verified)
|
||||||
|
- Milestones: "Active Sprint" and "Backlog" on each repo
|
||||||
|
- **Live dashboard:** `https://tracker.azcomputerguru.com/dashboard.html` (SSL via NPM on Jupiter)
|
||||||
|
- Docker container `acg-tracker` on Jupiter (172.16.3.20:8089), nginx:alpine, files at `/mnt/user/appdata/acg-tracker/`
|
||||||
|
- NPM proxy host ID 12, SSL cert ID 13 (Let's Encrypt DNS challenge via Cloudflare)
|
||||||
|
- Cloudflare DNS A record: tracker.azcomputerguru.com -> 72.194.62.10
|
||||||
|
- Login with Gitea username/password (stored in localStorage via Basic auth)
|
||||||
|
- To update dashboard: re-upload `dashboard.html` to Jupiter via SFTP, `docker restart acg-tracker`
|
||||||
|
- Kanban board (Open / In Progress / Blocked / Done), list view, create/comment/close issues from the dashboard
|
||||||
|
- Source files: `projects/msp-tools/bug-tracker/`
|
||||||
28
.claude/memory/project_edr_rmm_autoisolation_fp.md
Normal file
28
.claude/memory/project_edr_rmm_autoisolation_fp.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: project_edr_rmm_autoisolation_fp
|
||||||
|
description: Datto EDR "Exfiltration Over HTTP" rule auto-isolates benign GuruRMM PowerShell fleet-wide; watcher + narrow suppression deployed, policy fix pending Mike
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
Incident 2026-07-07: **vwp-qbs** (Valley Wide Plastering QuickBooks server) was auto-isolated by Datto EDR — Mike lost ~4h before realizing EDR did it. No notification fired (EDR alert delivery was never wired to any channel).
|
||||||
|
|
||||||
|
**Root cause:** Datto EDR rule **"Exfiltration Over HTTP Protocol" (MITRE T1041, HIGH)** carries an automated response of **`kill-process` + `isolate-host`**. It fires on *signed* PowerShell doing an outbound HTTPS POST — which our **GuruRMM automation** does routinely. Process tree on the alerts: `gururmm-agent.exe -> cmd.exe -> powershell.exe`. Confirmed benign both times (vwp-qbs = a vSphere VM-power-on script; tps-tina = a network/connectivity diagnostic).
|
||||||
|
|
||||||
|
**This is SYSTEMIC, not a one-off.** The same rule fired + attempted auto-isolate on **tps-tina** (The Prairie Schooner) the same day — different GuruRMM script, different client. Per-command-line suppression is whack-a-mole. Note: the isolate-host response only actually cuts a host off where its endpoint policy has isolation enabled (vwp-qbs got isolated; tps-tina fired the same alert but `extensionSuccess=None`, stayed online).
|
||||||
|
|
||||||
|
**FLEET-WIDE FIX LIVE (2026-07-07):** Datto EDR suppression rule **`3365e79a`** "GuruRMM origin - Exfil-over-HTTP (fleet-wide FP)" — matches Rule Name = Exfiltration Over HTTP Protocol + Grand Parent = `gururmm-agent.exe`, org/loc empty = ALL clients. Suppresses this one rule for any GuruRMM-launched process fleet-wide (present + future scripts); every other rule still fires on RMM-origin. Created in the CONSOLE (by Howard) after the API create path proved unsafe. The two earlier narrow per-command-line rules (`e4dd55bf`, `7ea2a577`) are now redundant subsets — harmless, left in place.
|
||||||
|
|
||||||
|
**API FOOTGUN (do NOT create suppressions via `raw POST` on this tenant):** `POST /SuppressionRules` forces `active:true` and auto-creates an EMPTY match-everything version → a live "suppress all" window (I hit this with `7c36b89f`, deleted within ~2 min). `GET`/`PATCH /SuppressionRules/{id}` 400 ("undefined is not valid JSON"); `DELETE /{id}` works. Create suppressions in the CONSOLE until an atomic API create is verified. Details in `.claude/skills/datto-edr/references/api-reference.md`.
|
||||||
|
|
||||||
|
**What's deployed (done):**
|
||||||
|
- Narrow suppression rule (Datto EDR, id `e4dd55bf`) for VWP's exact script: matches Process Command Line + Grand Parent = `gururmm-agent.exe`. Does NOT blind other PowerShell. API schema for suppression is now in `.claude/skills/datto-edr/references/api-reference.md`.
|
||||||
|
- **EDR isolation watcher**: `.claude/scripts/edr-isolation-watch.sh` + `register-edr-watcher.ps1`, registered as Windows Scheduled Task **"ClaudeTools - EDR Isolation Watcher"** on Howard-Home (runs every 10 min, posts new auto-isolations to #dev-alerts — see [[feedback_fire_dev_alerts_for_client_work]]). Plain script, zero tokens. State file `.edr-watch-state.json` (gitignored) dedupes; seeded with the vwp-qbs + tps-tina event ids so it stays silent for the already-triaged ones. Retire when GuruRMM EDR webhook (Feature 6) lands.
|
||||||
|
|
||||||
|
**Pending Mike decisions (do NOT act without him):**
|
||||||
|
1. Auto-isolate vs **alert-only** on behavioral rules — the real fix (the isolation, not the alert, caused the outage).
|
||||||
|
2. Whether to allow a small curated "GuruRMM-origin + this-rule OK" exception set — but NOT a blanket `gururmm-agent.exe` whitelist (RMM = top MSP attack path; blinding EDR there is dangerous).
|
||||||
|
3. **Fix the RMM scripts** to stop looking like exfil (drop `-EncodedCommand`, least-priv accounts).
|
||||||
|
4. Hygiene: VWP's script had **ESXi root creds hardcoded** (base64, cert-validation disabled) — already in vault `clients/vwp/esxi.sops.yaml`; move to a least-priv service account + vault injection.
|
||||||
|
|
||||||
|
See [[reference_datto_edr_detection_behavior]] (behavioral rules DO fire on signed PowerShell, unlike reputation rules).
|
||||||
27
.claude/memory/project_gururmm_legacy_disabled.md
Normal file
27
.claude/memory/project_gururmm_legacy_disabled.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: project_gururmm_legacy_disabled
|
||||||
|
description: GuruRMM's legacy Rust 1.77 (Server 2008R2 / Win7) build variant is DISABLED as of 2026-07-04 — do not just re-enable it; 2008R2 support returns via a separate legacy agent (C++/.NET or clean rewrite)
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
The GuruRMM Windows build's **legacy Rust 1.77 variant is disabled** (2026-07-04) via
|
||||||
|
`LEGACY_ENABLED=false` in `deploy/build-pipeline/build-windows.sh`.
|
||||||
|
|
||||||
|
**Why:** the legacy build moves `Cargo.lock` aside and re-resolves deps unpinned; crates.io now
|
||||||
|
serves edition2024 versions of transitive deps (`chacha20`/`rand_core` 0.10.1) that Rust 1.77
|
||||||
|
cannot parse ("feature `edition2024` is required"). This fail-closed the ENTIRE Windows build and
|
||||||
|
blocked modern (amd64/x86/tray) deploys. It is a **BUG-021 recurrence** and is NOT caused by any
|
||||||
|
agent code change — it reproduces at any base commit right now. This is at least the 3rd edition2024
|
||||||
|
break of the 1.77 build (the `agent/Cargo.toml` transitive-pin list is a monument to it).
|
||||||
|
|
||||||
|
**What the disable does:** skips the legacy wave/copy/deploy/symlink and EXCLUDES legacy from the
|
||||||
|
artifact cleanup, so the last-built legacy binaries (**0.6.76**) are PRESERVED and existing Server
|
||||||
|
2008R2 / Win7 endpoints stay supported *frozen at 0.6.76*. Modern agents advance normally.
|
||||||
|
|
||||||
|
**Mike's directive (2026-07-04):** "Kill the legacy version for now, but we will come back to it" —
|
||||||
|
2008R2 support is REQUIRED (a number are still in the wild) and will return via a **different
|
||||||
|
architecture (C++/.NET) or a clean rewrite for legacy**, NOT by flipping `LEGACY_ENABLED` back on
|
||||||
|
without first solving the 1.77 edition2024 pin problem. Do NOT re-enable it blindly. Tracked in
|
||||||
|
`projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md`. Related: the policy-controlled tray feature that
|
||||||
|
was shipping when this surfaced ([[project_guruconnect]] is a different project).
|
||||||
31
.claude/memory/project_gururmm_software_removal_pr58.md
Normal file
31
.claude/memory/project_gururmm_software_removal_pr58.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: project_gururmm_software_removal_pr58
|
||||||
|
description: SPEC-030 software-removal hardening (driver exclusion, lingering-process sweep, job list/reaper) is on PR #58 awaiting merge; live testing planned 2026-07-06 on DESKTOP-MS42HNC.
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
GuruRMM SPEC-030 software removal (Assets -> Inventory tab): the 2026-06-24 session's open
|
||||||
|
requests were implemented 2026-07-05 and sit on branch `feat/software-removal-hardening`,
|
||||||
|
**PR #58** (https://git.azcomputerguru.com/azcomputerguru/gururmm/pulls/58), NOT merged —
|
||||||
|
merge deploys to beta. Live testing planned 2026-07-06 on the test box DESKTOP-MS42HNC
|
||||||
|
(agent 0de89b88-b21d-4647-ab64-96157ba87cc5).
|
||||||
|
|
||||||
|
What PR #58 contains:
|
||||||
|
- Engine `-List` emits `is_driver` (heuristic: driver/chipset/firmware name words, or hw-vendor
|
||||||
|
publisher + device words; junkware like "Driver Booster" deliberately excluded). Dashboard
|
||||||
|
hides drivers by default, keeps them out of select-all, deselects on re-hide.
|
||||||
|
- End-of-run lingering-uninstaller sweep in uninstall-engine.ps1: 45s budget-aware grace, then
|
||||||
|
taskkill of started trees (StartTime PID-reuse guard, same-session only, null CreationDate
|
||||||
|
excluded) + late re-verify that flips async-finisher false-failures to success. This is the
|
||||||
|
chosen resolution of the Launchy/AIMP "failed / no usable result" open decision.
|
||||||
|
- `GET /agents/:id/software/uninstall/jobs` (list, results omitted), stale-running-job reaper
|
||||||
|
(900s no-progress -> failed), `finish_job` guarded WHERE status='running', UI 100-target cap.
|
||||||
|
|
||||||
|
Verified pre-merge: engine live tests on Howard-Home (driver flags, sweep kill/no-kill,
|
||||||
|
orphaned/refused paths), heuristic unit cases, `verify.sh server --check` + `dashboard` PASS,
|
||||||
|
high-effort workflow code review with all confirmed findings fixed.
|
||||||
|
|
||||||
|
Remaining after this: recipe catalog (P3, [[project_gururmm_av_removal_recipes]] if created),
|
||||||
|
dashboard UI for the new job-list endpoint (client binding `softwareApi.uninstallJobs` exists,
|
||||||
|
unused), and TeamViewer retry-verify still not re-validated live.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: tech03l-systemprofile-shortcut-corruption
|
||||||
|
description: ACG-TECH03L shortcuts got rewritten to nonexistent systemprofile paths (Auto-Claude "opens then closes"); repointed via RMM 2026-07-04
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
On ACG-TECH03L (Howard's laptop, "tech-03"), the desktop + Start Menu **Auto-Claude**
|
||||||
|
shortcuts and the **UltraSearch** shortcut were found pointing at
|
||||||
|
`C:\Windows\system32\config\systemprofile\...` paths that do not exist — symptom was
|
||||||
|
"ClaudeTools opens and then closes" when Howard double-clicked the launcher. The Claude
|
||||||
|
Code CLI itself was healthy (2.1.160, node v24.16.0, repo + identity.json intact).
|
||||||
|
|
||||||
|
**Why:** something (likely an installer/updater running under SYSTEM context, e.g. via
|
||||||
|
RMM) resolved `%LOCALAPPDATA%`/`%USERPROFILE%` to the SYSTEM profile when writing the
|
||||||
|
shortcuts. If a Claude-adjacent launcher "flashes and closes" on a fleet machine, check
|
||||||
|
the .lnk TargetPath for `systemprofile` FIRST before debugging the app.
|
||||||
|
|
||||||
|
**How to apply:** repoint the .lnk to the real per-user install
|
||||||
|
(`C:\Users\<user>\AppData\Local\Programs\auto-claude-ui\Auto-Claude.exe`) via
|
||||||
|
WScript.Shell as SYSTEM — fixed 2026-07-04 (RMM cmd 0c9180df). UltraSearch was left
|
||||||
|
broken: no user-profile copy exists on disk; needs reinstall if Howard wants it.
|
||||||
|
Avoid running per-user app installers/updaters from SYSTEM context.
|
||||||
12
.claude/memory/project_vwp_rose_qb_updater.md
Normal file
12
.claude/memory/project_vwp_rose_qb_updater.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: project-vwp-rose-qb-updater
|
||||||
|
description: VWP-ROSE QuickBooks updater crash-loop - RESOLVED 2026-07-13 (repair install); open side findings NETLOGON 5719 + Syncro agent crashes
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
**RESOLVED 2026-07-13:** QuickBooks Desktop 2024 updater crash loop on VWP-ROSE (Valley Wide Plastering) - qbupdate/FCS heap corruption (0xc0000374) surviving reboot. Fixed via remote session: repair on the QuickBooks installation; update now completes. Billed 0.5 hr remote against the VWP prepay block (16.25 hrs remaining), invoice #68041, ticket #32545 Invoiced (https://computerguru.syncromsp.com/tickets/113779446). Rose's login vaulted at `clients/vwp/vwp-rose-workstation`.
|
||||||
|
|
||||||
|
**Open side findings on VWP-ROSE** (not yet addressed, from 7/13 event logs):
|
||||||
|
- NETLOGON 5719 + DNS registration failures at boot - machine can't reach a VWP domain controller at startup; may cause auth/mapped-drive issues.
|
||||||
|
- Syncro agent services (Syncro.Service.Runner / SyncroLive.Service.Runner) crashed twice on the morning of 2026-07-13 (KERNELBASE .NET exceptions).
|
||||||
25
.claude/memory/ps51_invoke_restmethod_headers_variable.md
Normal file
25
.claude/memory/ps51_invoke_restmethod_headers_variable.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: ps51-invoke-restmethod-headers-variable
|
||||||
|
description: PS5.1 via RMM powershell -File - Invoke-RestMethod -Headers $H (script-scope hashtable) sent an EMPTY Authorization header; build headers inline per call instead
|
||||||
|
metadata:
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
On ACG-DWP-X-BB (WS2019, PowerShell 5.1, script executed via GuruRMM agent as
|
||||||
|
`powershell -NoProfile -File script.ps1`), passing a script-scope hashtable variable to
|
||||||
|
`Invoke-RestMethod -Headers $H` (where `$H=@{Authorization="Bearer $tok"}`) resulted in
|
||||||
|
Graph rejecting every call with `InvalidAuthenticationToken / "Access token is empty"` —
|
||||||
|
even though the token variable was verifiably populated (fingerprint printed correctly)
|
||||||
|
and the same token + same URL worked with an inline-built header from the same machine.
|
||||||
|
|
||||||
|
**Fix that works:** build the header at each call site — e.g.
|
||||||
|
`function Hdr { return @{Authorization=("Bearer " + $script:tok)} }` then
|
||||||
|
`Invoke-RestMethod -Uri $u -Headers (Hdr)`. Inline `@{Authorization=('Bearer '+$t)}` also works.
|
||||||
|
|
||||||
|
Diagnosed 2026-07-02 during the BirthBio Datto-vs-SharePoint reconciliation after two
|
||||||
|
full failed runs (masqueraded first as token expiry, then as clock skew — the real tell
|
||||||
|
was the Graph error body "Access token is empty", captured only after adding response-body
|
||||||
|
extraction to the retry helper). Always capture the Graph error BODY, not just the
|
||||||
|
exception message: "(401) Unauthorized" alone cost three debugging cycles.
|
||||||
|
|
||||||
|
Related: [[gururmm-command-timeout-seconds]], [[sharepoint-graph-large-file-upload]]
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
---
|
---
|
||||||
name: reference_antigravity_agy_not_headless
|
name: reference_antigravity_agy_not_headless
|
||||||
description: Antigravity CLI agy.exe is the IDE embedded agent (no stdout, SQLite store) — NOT a headless CLI. The agy skill uses @google/gemini-cli, not agy.exe. Don't reinstall agy.exe expecting a headless tool.
|
description: UPDATE 2026-07-03 — agy.exe NOW works headless (v1.0.6+ --print/-p returns clean stdout). Use it as the Antigravity/Gemini second-model path. Older claim (DOA headless) is obsolete.
|
||||||
metadata:
|
metadata:
|
||||||
type: reference
|
type: reference
|
||||||
---
|
---
|
||||||
|
|
||||||
The `agy.exe` installed by Google's Antigravity CLI (`%LOCALAPPDATA%\agy\bin\agy.exe`, installer `https://antigravity.google/cli/install.ps1`) is the IDE's embedded agent, **NOT a usable headless CLI** on this fleet. Even v1.0.6's advertised `-p/--print` produces ZERO stdout and hangs when invoked non-interactively from the Bash/PowerShell tool harness — it writes only to a SQLite conversation store. First found 2026-06-05 (`session-logs/2026-06-05-mike-gururmm-platform-day.md` line 35); **re-confirmed 2026-06-06** after the GURU-5070 reinstall (reinstalled agy.exe and walked straight back into the same no-output/hang symptom).
|
**CORRECTION 2026-07-03 (GURU-BEAST-ROG):** `agy.exe` (Google Antigravity CLI,
|
||||||
|
`%LOCALAPPDATA%\agy\bin\agy.exe`) **now has a working headless print mode.** `agy -p "<prompt>"
|
||||||
|
--dangerously-skip-permissions` returns clean stdout and exits 0 (smoke-tested: returned "READY";
|
||||||
|
also drove a full multi-file spec critique reading files via `--add-dir <dir>`). Flags: `-p/--print`
|
||||||
|
(single non-interactive prompt), `--print-timeout` (default 5m), `--add-dir` (add a workspace dir so
|
||||||
|
it can read files), `--model`, `--dangerously-skip-permissions` (auto-approve tools). Subcommands:
|
||||||
|
`models`, `install`, `update`, `plugin`. Re-wire came from GURU-5070 fleet sync + a newer agy build.
|
||||||
|
So agy IS now a usable Antigravity/**Gemini second-model** path, especially when `gemini-cli` OAuth
|
||||||
|
fails (e.g. BEAST-ROG's `throwIneligibleOrProjectIdError`). NOTE: after install the binary is added to
|
||||||
|
User PATH registry but NOT the active shell PATH until terminal restart — call it by full path
|
||||||
|
`/c/Users/guru/AppData/Local/agy/bin/agy.exe` from the Bash tool.
|
||||||
|
|
||||||
The `agy` SKILL (despite the name) routes to the official **`@google/gemini-cli`** (`gemini`, npm global) — that IS the real headless second-opinion tool (Google OAuth, no API key), resolved via `identity.json .gemini.binary`. Grok (`ask-grok.sh`) is the other working second model. Both were verified returning `OK` on 2026-06-06.
|
**Historical (pre-2026-07-03, now OBSOLETE):** earlier agy.exe builds (~June 2026) produced ZERO
|
||||||
|
stdout in `-p` mode and hung when driven headless (wrote only to a SQLite conversation store), so the
|
||||||
**June 18 sunset — likely a non-issue for ACG.** Google is sunsetting gemini-cli's free/unpaid OAuth quota on **2026-06-18**, but Mike has a **paid Gemini account**, so the plan is to **stay on gemini-cli** (do NOT migrate to Antigravity). The bulletproof form is to auth gemini-cli with a paid **Gemini API key** (`GEMINI_API_KEY`) rather than the free OAuth quota — that path is unaffected by the OAuth-CLI sunset regardless of how the consumer tiers shake out, and is more stable for headless use. (Sources disagree on whether paid Pro/Ultra OAuth is also cut, so the API-key path is the safe bet.) **Do NOT reinstall agy.exe expecting it to work headless.** Related: [[feedback_agy_review_not_readonly]].
|
`agy` SKILL was pointed at `@google/gemini-cli` instead. The gemini-cli path still exists
|
||||||
|
(`ask-gemini.sh`) but on machines where its OAuth is ineligible, prefer **agy -p** now. Related:
|
||||||
|
[[reference_cdp_chrome_driver]], [[feedback_agy_review_not_readonly]].
|
||||||
|
|||||||
26
.claude/memory/reference_claude_code_bun_crash_pin.md
Normal file
26
.claude/memory/reference_claude_code_bun_crash_pin.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: reference_claude_code_bun_crash_pin
|
||||||
|
description: Claude Code v2.1.114+ (Bun-based builds) segfault at startup on Windows 11 — fix is pin to 2.1.110 + DISABLE_AUTOUPDATER; unpin when upstream fix ships
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
Claude Code v2.1.114+ ships as a Bun-compiled binary; on some Windows 11 builds (seen on
|
||||||
|
Howard-Home, Win11 build 26100, CC v2.1.207, 2026-07-13) NEW interactive sessions crash at
|
||||||
|
TUI init with `panic: Segmentation fault at address 0x3C` / "oh no: Bun has crashed".
|
||||||
|
Already-running sessions keep working (binary loaded in memory); only fresh launches die.
|
||||||
|
Tracked: anthropics/claude-code #55219 (+ matching upstream Bun issue). `--version`/`--help`
|
||||||
|
still work. It is a runtime bug inside the shipped binary — no config/cache/reinstall of the
|
||||||
|
same version helps.
|
||||||
|
|
||||||
|
**Fix (verified working 2026-07-13, Howard-Home):**
|
||||||
|
1. `npm install -g @anthropic-ai/claude-code@2.1.110` (last pure-Node build; installs fine
|
||||||
|
even while an older session is running — the EPERM "cleanup" warning is just npm failing
|
||||||
|
to delete the locked running exe, the install itself succeeds).
|
||||||
|
2. Pin it — in `~/.claude/settings.json` add:
|
||||||
|
`"env": { "DISABLE_AUTOUPDATER": "1" }`
|
||||||
|
NOTE: a field named `autoUpdate` does NOT exist in the settings schema (validation
|
||||||
|
rejects it); the env-var route is the correct kill switch.
|
||||||
|
3. **Unpin when fixed:** watch issue #55219; then `npm i -g @anthropic-ai/claude-code@latest`
|
||||||
|
and remove the env entry.
|
||||||
|
|
||||||
|
Do NOT switch to the native installer as a workaround — it is also Bun-based (same crash).
|
||||||
32
.claude/memory/reference_client_wifi_inventory.md
Normal file
32
.claude/memory/reference_client_wifi_inventory.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: reference_client_wifi_inventory
|
||||||
|
description: Where client Wi-Fi networks are recorded so techs connect onsite without asking — vault holds passwords, wiki holds the readable index.
|
||||||
|
metadata:
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
We are building a fleet-wide client Wi-Fi list so anyone onsite can connect without asking
|
||||||
|
for the SSID/password again. Established 2026-07-06 (Howard).
|
||||||
|
|
||||||
|
**Split (security-driven):**
|
||||||
|
- **Passwords/PSKs → SOPS vault**, `clients/<slug>/wifi.sops.yaml`, one file per client.
|
||||||
|
Per network use `credentials.<key>_ssid`, `credentials.<key>_password`, optional
|
||||||
|
`credentials.<key>_auth` (`<key>` = `staff`/`guest`/`voice`/`warehouse`...). Everything under
|
||||||
|
`credentials:` is encrypted at rest, so the entry is self-contained for an importer. Write it
|
||||||
|
with the `vault` skill's `vault-helper.sh new`/`set` (NEVER paste a Wi-Fi password into chat,
|
||||||
|
a ticket, a session log, or the wiki).
|
||||||
|
- **Readable index → wiki**, `wiki/reference/client-wifi.md` (registered in `wiki/index.md`
|
||||||
|
under a new **Reference** section). Client / SSID / band / location / vault-field — NO passwords.
|
||||||
|
It lives under `wiki/reference/` so `/wiki-compile` never clobbers it (compile only touches
|
||||||
|
clients/projects/systems slugs).
|
||||||
|
|
||||||
|
**Capture flow onsite:** ask "Wi-Fi name + password? staff vs guest? which should our machines
|
||||||
|
use?" → store in vault via vault-helper → add a row to the wiki inventory → `/sync`.
|
||||||
|
|
||||||
|
**Importer is DEFERRED** (Howard chose "structure only for now" 2026-07-06). Planned end state:
|
||||||
|
a `/wifi import <client>` that reads the vault file, builds a Windows WLAN profile per network
|
||||||
|
(`netsh wlan add profile`), and auto-connects. Interim: read the password with
|
||||||
|
`vault.sh get-field clients/<slug>/wifi credentials.<key>_password` and connect manually.
|
||||||
|
Build the importer/`/wifi` skill once a few real networks exist.
|
||||||
|
|
||||||
|
Keep `<slug>` identical to the client's `wiki/clients/<slug>.md` slug so vault + wiki + article line up.
|
||||||
@@ -14,6 +14,8 @@ Verified live 2026-06-25/26 on RMM-TEST-MACHINE (EDR agent `b98b3ba0-...`, group
|
|||||||
|
|
||||||
**Datto EDR is reputation-based, NOT structural.** A synthetic "looks suspicious" artifact (Run-key/scheduled task launching hidden `-EncodedCommand` powershell) is collected by the forensic scan but scored BENIGN → no alert (powershell.exe is signed/clean). To get an EDR detection you need a real reputation hit: wire a known-bad file as the **executable of an autostart** (Run-key/scheduled task) so the survey collects + hashes it. EICAR-as-autostart works → high-sev `rule` alert. A loose file on disk is NOT scanned by the EDR forensic survey (it only walks execution/persistence artifacts).
|
**Datto EDR is reputation-based, NOT structural.** A synthetic "looks suspicious" artifact (Run-key/scheduled task launching hidden `-EncodedCommand` powershell) is collected by the forensic scan but scored BENIGN → no alert (powershell.exe is signed/clean). To get an EDR detection you need a real reputation hit: wire a known-bad file as the **executable of an autostart** (Run-key/scheduled task) so the survey collects + hashes it. EICAR-as-autostart works → high-sev `rule` alert. A loose file on disk is NOT scanned by the EDR forensic survey (it only walks execution/persistence artifacts).
|
||||||
|
|
||||||
|
**BUT behavioral rules DO fire on signed PowerShell** (unlike the reputation scoring above). Rules like **"Exfiltration Over HTTP Protocol" (T1041)** key on *runtime behavior* (outbound HTTP from powershell), not file reputation, so they alert HIGH on clean signed `powershell.exe` — and can carry an automated `isolate-host`/`kill-process` response. This routinely false-positives on GuruRMM automation (`gururmm-agent.exe -> cmd.exe -> powershell`). See [[project_edr_rmm_autoisolation_fp]].
|
||||||
|
|
||||||
**AV-suppression gotchas (to isolate EDR on an endpoint):**
|
**AV-suppression gotchas (to isolate EDR on an endpoint):**
|
||||||
- Datto AV is **tamper-protected**: `Stop-Service EndpointProtectionService2 -Force` is refused ("cannot be stopped"); can't disable from the endpoint. Supported path = console policy (AV disabled / path-exclusion) — console-only, like policy assignment.
|
- Datto AV is **tamper-protected**: `Stop-Service EndpointProtectionService2 -Force` is refused ("cannot be stopped"); can't disable from the endpoint. Supported path = console policy (AV disabled / path-exclusion) — console-only, like policy assignment.
|
||||||
- Disabling Datto AV in the policy **uninstalls** the AV component on the box (services `EndpointProtectionService`/`...2` go absent; `HUNTAgent` EDR stays). Platform `dattoAvEnabled` flips to False at the console first; the on-box apply lags a few minutes.
|
- Disabling Datto AV in the policy **uninstalls** the AV component on the box (services `EndpointProtectionService`/`...2` go absent; `HUNTAgent` EDR stays). Platform `dattoAvEnabled` flips to False at the console first; the on-box apply lags a few minutes.
|
||||||
|
|||||||
32
.claude/memory/reference_exchange_online_exchangeop_tier.md
Normal file
32
.claude/memory/reference_exchange_online_exchangeop_tier.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: reference_exchange_online_exchangeop_tier
|
||||||
|
description: Exchange REST (adminapi InvokeCommand) ALWAYS uses the exchange-op tier, never investigator-exo (which 401s — lacks Exchange.ManageAsApp)
|
||||||
|
metadata:
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
**Every Exchange Online REST call in the remediation-tool suite — READ and WRITE — goes through the
|
||||||
|
`exchange-op` tier. NEVER `investigator-exo`.** This friction has recurred 6+ times (Mike, 2026-07-16).
|
||||||
|
|
||||||
|
Why: the Exchange adminapi (`https://outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand`, i.e.
|
||||||
|
Get-Mailbox / Get-InboxRule / Get-MailboxPermission / Get-RecipientPermission / Set-Mailbox / etc.)
|
||||||
|
requires the calling app to hold the **`Exchange.ManageAsApp`** application permission (Office 365
|
||||||
|
Exchange Online app-role `dc50a0fb-09a3-484d-be87-e023b12c6440`) **in addition to** the Exchange
|
||||||
|
Administrator directory role.
|
||||||
|
|
||||||
|
- **`exchange-op`** = Exchange Operator app `b43e7342-5b4b-492f-890f-bb5a4f7f40e9` — has BOTH `Exchange.ManageAsApp`
|
||||||
|
AND the Exchange Administrator role. This is the ONLY working tier for Exchange REST (read or write).
|
||||||
|
- **`investigator-exo`** = Security Investigator app `bfbc12a4-...` — has the Exchange Admin *role* but NOT
|
||||||
|
`Exchange.ManageAsApp`, so it returns **401 on every** adminapi call, including read-only `Get-*`. The
|
||||||
|
"Exchange Online read" label on this tier is aspirational/wrong.
|
||||||
|
|
||||||
|
**Diagnosis rule:** `401` on adminapi = wrong app (missing `Exchange.ManageAsApp`) → switch to `exchange-op`.
|
||||||
|
`403` = the `exchange-op` SP is missing the Exchange Administrator *directory role* on that tenant → assign it
|
||||||
|
(Entra > Roles > Exchange Administrator > add the app). Assigning Exchange Admin to the Security Investigator
|
||||||
|
SP does NOT fix the 401 — the app-permission (baked into the app registration), not the role, is the blocker.
|
||||||
|
|
||||||
|
Verified live on ACG (`azcomputerguru.com`, tenant `ce61461e-81a0-4c84-bb4a-7b354a9a356d`) 2026-07-16:
|
||||||
|
Investigator SP `9d242c15-6cd3-46ec-96d3-bcafaaaca333` `Exchange.ManageAsApp`=NO; Operator SP
|
||||||
|
`83c225f1-b38d-4063-9fdd-642b6b09ae8b`=YES. Docs corrected same day: `SKILL.md`, `references/gotchas.md`,
|
||||||
|
and `scripts/user-breach-check.sh` (was minting `investigator-exo` for the 03a-d mailbox checks → they
|
||||||
|
silently returned empty on every run; now uses `exchange-op`). See also [[reference_remediation_tool_365_access]].
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
---
|
---
|
||||||
name: reference_guru5070_rust_toolchain
|
name: reference_guru5070_rust_toolchain
|
||||||
description: GURU-5070 has the full local Rust toolchain (cargo + MSVC + protoc) — build/clippy/test the guru-connect workspace LOCALLY instead of the build host; set PROTOC first
|
description: GURU-5070 has a full Rust toolchain installed BUT a WDAC/Smart App Control policy now blocks running rustc/cargo locally (os error 4551) — build the Windows agent on Beast, not here; toolchain paths + CI gates still valid for reference
|
||||||
metadata:
|
metadata:
|
||||||
type: reference
|
type: reference
|
||||||
---
|
---
|
||||||
|
|
||||||
As of 2026-05-30, GURU-5070 has the full Rust dev toolchain installed, so GuruConnect can be
|
**BLOCKED AS OF 2026-07-04 — do NOT rely on local Rust builds on GURU-5070.** A Windows
|
||||||
built/linted/tested locally — **no more build-host (172.16.3.30) round-trips just for `cargo fmt`/clippy.**
|
Application Control (WDAC / Smart App Control) policy now blocks execution of `rustc.exe`/`cargo.exe`
|
||||||
|
(and `sops.exe`, `openssl`, `gawk`, Python `cryptography`'s native DLL) — attempts fail with
|
||||||
|
`An Application Control policy has blocked this file. (os error 4551)`. This is the same policy behind
|
||||||
|
the "Windows can't confirm who published …" blocks on unsigned binaries. Until the policy is adjusted
|
||||||
|
to trust the toolchain, **compile Windows agent/GuruConnect changes on Beast** (`guru@100.101.122.4`,
|
||||||
|
Tailscale; sccache at `C:\sccache`) via a throwaway `git worktree` + `cargo check` — see
|
||||||
|
[[gururmm-beast-windows-build-host]]. The paths/gates below remain accurate for reference and for when
|
||||||
|
the policy is relaxed. WDAC impact also breaks the `vault`/`sops` path here (use `age`+Node fallback per
|
||||||
|
[[feedback_vault_gcm_shadow_auth]]) and silently swallowed error logging (`CLAUDETOOLS_ROOT=D:/claudetools`
|
||||||
|
wrong-case → `log-skill-error.sh` can't write; real repo is `D:/ClaudeTools`).
|
||||||
|
|
||||||
|
As of 2026-05-30, GURU-5070 has the full Rust dev toolchain installed (able to build locally *until the
|
||||||
|
2026-07-04 WDAC block above*):
|
||||||
|
|
||||||
- **cargo/rustc/clippy/rustfmt:** `C:\Users\guru\.cargo\bin\` (rustup; cargo 1.96, rustfmt 1.9, clippy 0.1.96).
|
- **cargo/rustc/clippy/rustfmt:** `C:\Users\guru\.cargo\bin\` (rustup; cargo 1.96, rustfmt 1.9, clippy 0.1.96).
|
||||||
- **MSVC C++ Build Tools:** VS2022 BuildTools (VCTools workload) — provides the `x86_64-pc-windows-msvc` linker.
|
- **MSVC C++ Build Tools:** VS2022 BuildTools (VCTools workload) — provides the `x86_64-pc-windows-msvc` linker.
|
||||||
|
|||||||
12
.claude/memory/reference_gururmm_build_server_ssh.md
Normal file
12
.claude/memory/reference_gururmm_build_server_ssh.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: GuruRMM build server SSH access
|
||||||
|
description: How to SSH guru@172.16.3.30 (key file name is non-obvious); saves re-discovery during rmm-audit pipeline passes
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
SSH to the GuruRMM build/prod server as `guru@172.16.3.30` works with the key
|
||||||
|
`~/.ssh/gururmm-physical` (at least on Howard-Home) — there is no id_rsa/id_ed25519,
|
||||||
|
so tools that assume default key names report "no key / Permission denied (publickey)".
|
||||||
|
Use: `C:/Windows/System32/OpenSSH/ssh.exe -i ~/.ssh/gururmm-physical -o BatchMode=yes guru@172.16.3.30`.
|
||||||
|
Password fallback lives in vault `infrastructure/gururmm-server` (credentials.password; sudo same).
|
||||||
|
The 2026-07-13 rmm-audit Agent E failed on exactly this before the key was found.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: reference_m365_app_sharepoint_rest_vs_graph
|
||||||
|
description: ComputerGuru M365 app suite — SharePoint app-only WORKS but requires the CERT (client_assertion), not the secret. Secret => "Unsupported app only token". Use get-token.sh <tenant> sharepoint.
|
||||||
|
metadata:
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
The ACG **Tenant Admin app** (`709e6eed-0711-4875-9c44-2d3518c47063`) has **full SharePoint app-only
|
||||||
|
access** — `Sites.FullControl.All` on the SharePoint resource — but it is gated by an auth-method rule
|
||||||
|
that keeps biting:
|
||||||
|
|
||||||
|
- **SharePoint app-only REQUIRES a CERTIFICATE (client_assertion). A `client_secret` token is rejected**
|
||||||
|
with **`Unsupported app only token`** on EVERY SharePoint endpoint (REST `/_api/...` and CSOM
|
||||||
|
`/_vti_bin/client.svc/ProcessQuery`). This is a SharePoint platform rule, not a missing grant.
|
||||||
|
- **Graph** app-only accepts the secret fine — so Graph SharePoint calls (driveItem `createdBy`/
|
||||||
|
`lastModifiedBy`/`retentionLabel`, item `/permissions` + inheritance, `/groups` + members) work with
|
||||||
|
the secret and are the right tool for *investigation*.
|
||||||
|
|
||||||
|
**Do NOT hand-roll a SharePoint token from the secret. Use the remediation-tool cert tiers:**
|
||||||
|
```bash
|
||||||
|
cd .claude/skills/remediation-tool
|
||||||
|
bash scripts/get-token.sh <tenant> sharepoint # content resource <name>.sharepoint.com (cert)
|
||||||
|
bash scripts/get-token.sh <tenant> sharepoint-admin # admin resource <name>-admin.sharepoint.com (cert)
|
||||||
|
```
|
||||||
|
`get-token.sh` forces cert for the `sharepoint*` tiers automatically; the minted token's `roles` =
|
||||||
|
`["Sites.FullControl.All"]`. The cert is vaulted in `msp-tools/computerguru-tenant-admin.sops.yaml`
|
||||||
|
(`cert_thumbprint_b64url` + `cert_private_key_pem_b64`). Tenant arg = the domain (`birthbiologic.com`).
|
||||||
|
|
||||||
|
**Use the CERT (SP REST/CSOM) for:** list settings (`ForceCheckout`, `EnableModeration`/content approval,
|
||||||
|
versioning), per-item `CheckoutUserId`/checkout state, re-stamping `Author`/`Editor` (Created By /
|
||||||
|
Modified By), site lock, tenant settings. Graph can't do these.
|
||||||
|
|
||||||
|
Reference (authoritative): `.claude/skills/remediation-tool/references/app-permissions-and-sharepoint.md`
|
||||||
|
and `app-suite.md`. **Before ever telling the user "the tool can't do X" on SharePoint, use the cert
|
||||||
|
tier and verify** — the "Unsupported app only token" wall means *wrong auth method (secret)*, NOT no access.
|
||||||
|
See [[birth-biologic]] (migrated content owned by "SharePoint App" => greyed-out Move; fix = re-stamp
|
||||||
|
Author/Editor via cert CSOM `SystemUpdate`). Open question (Mike, 2026-07-06): standardize ALL M365
|
||||||
|
app-only auth on cert (cert is resource-agnostic + more secure) to kill this secret-vs-cert friction.
|
||||||
30
.claude/memory/reference_msp360_backup_monitoring.md
Normal file
30
.claude/memory/reference_msp360_backup_monitoring.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: reference_msp360_backup_monitoring
|
||||||
|
description: MSP360 MBS /api/Monitoring is the authoritative "is this client backing up" source for the whole ACG fleet
|
||||||
|
metadata:
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
For any "is client X actually backing up / when did it last run" question, the authoritative
|
||||||
|
source is the **MSP360 Managed Backup Service API**, not B2 bucket contents (buckets are just
|
||||||
|
where Cloud plans land; a bucket with files does not prove a recent backup ran).
|
||||||
|
|
||||||
|
- Creds: vault `msp-tools/msp360-api.sops.yaml` (API login+password, generated in MSP360
|
||||||
|
console Settings>General — not the console login).
|
||||||
|
- Auth: `POST https://api.mspbackups.com/api/Provider/Login` `{UserName, Password}` -> Bearer
|
||||||
|
`access_token`.
|
||||||
|
- `GET /api/Monitoring` -> every backup plan across all companies: `CompanyName`,
|
||||||
|
`ComputerName`, `PlanName`, `StorageType`, `LastStart`, `Status` (0/1=Success, 2=Warning,
|
||||||
|
3=Failed, 4=Running, 6=Interrupted, 7=completed-with-warnings), `DataCopied`, `TotalData`,
|
||||||
|
`ErrorMessage`. Also `GET /api/Companies` (37, with Destinations) and `GET /api/Users` (59).
|
||||||
|
`/api/Computers` is 404.
|
||||||
|
- Used 2026-07-05 for the GPS backup map (`projects/gps-rmm-audit/backup-map.md`): resolved 11
|
||||||
|
of 12 "no destination found" clients, surfaced 4 not-backing-up findings (AMT + T&C Sorensen
|
||||||
|
= 0 plans, Grabb GND-SERVER failing, Bill Tedards ~12mo stale). MSP360 CompanyName also IDs
|
||||||
|
B2 buckets: `ACG-PST`=Peaceful Spirit, `ACG-TCA`=Tucson Coin and Autograph.
|
||||||
|
- Backup targets are a per-client decision — report mismatches, don't deploy jobs. See
|
||||||
|
[[feedback_backup_targets_never_guessed]]. Related: [[reference_backblaze_storage_rate]].
|
||||||
|
- **Use the `msp360` skill** (`.claude/skills/msp360/`) — built 2026-07-05, full (not
|
||||||
|
read-only): reads (`status`/`monitoring --failed|--company|--stale`/`companies`/`users`/
|
||||||
|
`licenses`/`billing`) plus `--confirm`-gated writes (create/delete user+company,
|
||||||
|
grant/release/revoke license) and a `raw` passthrough for the rest of the MBS API.
|
||||||
30
.claude/memory/reference_rmm_deploy_via_screenconnect.md
Normal file
30
.claude/memory/reference_rmm_deploy_via_screenconnect.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: reference_rmm_deploy_via_screenconnect
|
||||||
|
description: Best channel to mass-deploy the GuruRMM agent to client workstations = ScreenConnect send-command (not DC remote-exec)
|
||||||
|
metadata:
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
**To push the GuruRMM agent onto a client's existing machines, the reliable channel is
|
||||||
|
ScreenConnect `send-command` (Backstage), NOT remote-exec from the domain controller.**
|
||||||
|
|
||||||
|
Discovered 2026-07-03 deploying to Instrumental Music Center (see
|
||||||
|
`projects/gps-rmm-audit/tracker.md`). Remote-exec FROM the DC to Win10/11 workstations
|
||||||
|
fails on default client settings:
|
||||||
|
- WMI/`Win32_Process` over **DCOM** -> "RPC server unavailable" (DCOM firewalled on clients)
|
||||||
|
- `schtasks /S` -> connects over SMB but Win11 **rejects the task definition** from an older
|
||||||
|
(Server 2016) DC ("The request is not supported")
|
||||||
|
- `New-PSDrive`/`sc.exe` admin-share from SYSTEM context -> access/parse failures
|
||||||
|
- WinRM is off by default on workstations
|
||||||
|
|
||||||
|
**What works cleanly:** ScreenConnect. ACG endpoints already run the SC agent, and
|
||||||
|
`send-command` runs on the guest **as SYSTEM** — no credentials, no firewall fight, no
|
||||||
|
DA-password-in-logs concern. Pattern (via the `[[screenconnect]]` skill):
|
||||||
|
1. `GetSessionsByName` with the EXACT hostname -> sessionID (no list-all method; must query by exact name).
|
||||||
|
2. Build the site installer one-liner: `irm 'https://rmm.azcomputerguru.com/install/<SITE-CODE>/windows'|iex` (site code from the client's vault `gururmm-site-*.sops.yaml`).
|
||||||
|
3. Encode it: `powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand <base64-UTF16LE>` (avoids all quoting; no /TR length limit like schtasks).
|
||||||
|
4. `send-command --session <id> --command "<that>" --confirm`. Online guests install + enroll in ~1-3 min; offline guests QUEUE in SC and install on reconnect.
|
||||||
|
5. Verify via GuruRMM `/api/agents` (hostname appears under the client, status online).
|
||||||
|
|
||||||
|
Note `iconv` is absent in this Git-Bash — compute the base64 with `py -c "import base64; ..."`.
|
||||||
|
Related: `[[project_av_migration_bitdefender_to_edr]]` (same channel can push the Datto EDR agent + remove Bitdefender once RMM is on).
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: reference_screenconnect_custom_property_slots
|
||||||
|
description: ACG ScreenConnect custom-property slot -> label mapping (CP1..CP8) for session tagging
|
||||||
|
metadata:
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
ACG's ConnectWise ScreenConnect instance (`computerguru.screenconnect.com`) custom-property
|
||||||
|
slot order, reverse-engineered 2026-07-03 from a labeled test session (HOWARD-HOME, values
|
||||||
|
"howard company / howards site / Howard department / howard type / ... / howard tag"):
|
||||||
|
|
||||||
|
- **CP1** (index 0) = **Company**
|
||||||
|
- **CP2** (index 1) = **Site**
|
||||||
|
- **CP3** (index 2) = **Department**
|
||||||
|
- **CP4** (index 3) = **Device Type**
|
||||||
|
- CP5 (index 4) = unused/other
|
||||||
|
- CP6 (index 5) = unused/other
|
||||||
|
- CP7 (index 6) = unused/other
|
||||||
|
- **CP8** (index 7) = **Tag**
|
||||||
|
|
||||||
|
The API does NOT expose the property LABELS (only values), so this mapping is the reference.
|
||||||
|
`UpdateSessionCustomProperties [sessionID, [cp1..cp8]]` REPLACES the whole 8-element array — always
|
||||||
|
read the session's current values first, modify only the slots you intend, write the full array back.
|
||||||
|
Set via the `[[screenconnect]]` skill: `sc.py set-properties --session <id> --props-json '[...8...]' --confirm`.
|
||||||
|
Read via `GetSessionsByName` -> `.CustomPropertyValues`.
|
||||||
|
|
||||||
|
Used by the GPS->RMM audit ScreenConnect cleanup (`projects/gps-rmm-audit/`): normalize Company,
|
||||||
|
set Site/Department/Device Type/Tag per machine, dedupe sessions.
|
||||||
@@ -1,15 +1,41 @@
|
|||||||
---
|
---
|
||||||
name: reference-syncro-rmm-api-gui-only
|
name: reference-syncro-rmm-api-gui-only
|
||||||
description: Syncro's public API cannot manage RMM policies/folders — creation, assignment, and asset moves are GUI-only (live-verified 2026-06-25)
|
description: Syncro asset/RMM IS API-drivable (reads, create/update, patches, installed_apps, rmm_alerts) — only policy-folder ops need a token scope our per-user keys currently lack (re-verified 2026-07-10)
|
||||||
metadata:
|
metadata:
|
||||||
type: reference
|
type: reference
|
||||||
---
|
---
|
||||||
|
|
||||||
**Syncro RMM policy management is GUI-only — the public REST API does NOT expose it.** Live-verified against the ACG production tenant (computerguru.syncromsp.com) on 2026-06-25:
|
**CORRECTION (2026-07-10): most of Syncro's asset/RMM surface IS API-drivable — the earlier
|
||||||
|
"RMM is GUI-only" conclusion was a token-scope symptom, not an API limitation.** Live-probed
|
||||||
|
the ACG production tenant with a per-user token (howard) on 2026-07-10 and confirmed against
|
||||||
|
the OpenAPI 3.0 spec (`api-docs.syncromsp.com/swagger.json`):
|
||||||
|
|
||||||
- `GET /customer_assets` objects carry a read-only **`policy_folder_id`** field (which policy folder the machine sits in). **`PUT /customer_assets/{id}` with `policy_folder_id` is silently ignored** — returns HTTP 200 but the value never changes. Proven by a flip-and-restore test on ACG-internal asset 12335235 (DESKTOP-0O8A1RL): value stayed at folder 692253. **You CANNOT move a machine between policy folders via the API.**
|
- **Works with our current per-user token (HTTP 200):** `GET/POST /customer_assets`,
|
||||||
- `/policies`, `/policy_builders`, `/rmm_policies`, `/asset_policies` all return **404** — no policy-CRUD endpoints exist. Policy Builder (the `/policy_builders` GUI page) is web-console only.
|
`PUT /customer_assets/{id}` (name/serial/type/customer/properties — `name` required),
|
||||||
- `/policy_folders` (collection and specific-ID) returns **401** — the route exists but our API token lacks RMM/policy scope. A re-issued token *might* read folders, but since assets can't be moved anyway, it's moot for the move use case.
|
`GET /customer_assets/{id}/patches`, `.../installed_applications`, `/rmm_alerts` (list +
|
||||||
- Syncro docs (docs.syncrosecure.com / docs.syncromsp.com "Work with Policies") confirm: policies are created in Policy Builder, assigned via an Organization's "Assets & Policies" subtab "Update Assigned Policy" dropdown, or "Bulk Assign Top-Level Policy" — all GUI, **no API mention**.
|
`{id}/mute` + DELETE clear), plus contacts, contracts, leads, vendors, purchase_orders,
|
||||||
|
products, payments, wiki_pages, `/me`, `/search`. The `/syncro` skill now documents the
|
||||||
|
asset-read/asset-write/rmm-alert/agent-deploy workflows; the full inventory is in
|
||||||
|
`.claude/standards/syncro/api-reference.md`.
|
||||||
|
- **Policy-folder move: VERIFIED WORKING with a full-scope token (2026-07-10).** Howard's
|
||||||
|
token was upgraded to full scope; flip-and-restore on ACG-internal asset `12335235`
|
||||||
|
(`692253 → 692278 → 692253`) succeeded, each confirmed by GET. `PUT /customer_assets/{id}`
|
||||||
|
with `policy_folder_id` (+ required `name`) moves the asset. The spec: *"only
|
||||||
|
policy_folder_id requires **Assets - Policy Change**; with other fields requires **Assets -
|
||||||
|
Edit** AND **Assets - Policy Change**; nil/nonexistent/cross-customer folder → 422."*
|
||||||
|
- **Route quirks:** `GET /policy_folders` (bare) → 404; `GET /policy_folders?customer_id=N` →
|
||||||
|
200 (enumerate a customer's folders); `GET /policy_folders/{id}` → 401 even at full scope.
|
||||||
|
- **Per-user scope differs — capability-check every scope-gated write.** A token WITHOUT
|
||||||
|
Policy-Change returns HTTP 200 and silently no-ops the move (the exact 2026-06-25 symptom
|
||||||
|
that produced the original "GUI-only" belief); `/me` shows coarse `asset:{write:true}` and
|
||||||
|
cannot detect it. The `/syncro move-asset` helper preflights `GET /policy_folders?customer_id=N`
|
||||||
|
(401 = no scope) and verifies the folder actually changed after the PUT. Only Howard's token
|
||||||
|
is confirmed full-scope so far; grant the scope to each per-user token for consistent behavior.
|
||||||
|
- **Archive (retire) IS genuinely GUI-only** — no archive endpoint, no `archived` field, and
|
||||||
|
DELETE is destructive. That part of the old note stands.
|
||||||
|
|
||||||
**How to apply:** Do NOT attempt to build `/syncro move-asset` or any Syncro RMM policy/folder/group capability — it's not buildable on the public API. Don't re-probe these endpoints. The only API-drivable policy surface in the fleet is the `bitdefender` skill (GravityZone: create/assign policies, custom groups, move endpoints). For Syncro RMM policy work, direct the user to the Syncro web console. The `/syncro` skill stays PSA-only (tickets/billing/customers/scheduling/estimates + read-only asset lookup). See [[feedback-psa-default-syncro]].
|
**How to apply:** Build/asset-manage freely via API for everything EXCEPT policy folders. To
|
||||||
|
unlock `/syncro move-asset` + policy CRUD, add **Assets - Policy Change** + policy-folder
|
||||||
|
read/manage to the Syncro API token (Admin → API Tokens), re-vault, then flip-and-restore
|
||||||
|
verify on ACG-internal asset `12335235` (folder `692253`). Do NOT re-assert "can't do assets
|
||||||
|
via API" — that was wrong. See [[feedback-psa-default-syncro]] and the api-reference doc.
|
||||||
|
|||||||
54
.claude/memory/reference_windows_edition_upgrade_rmm.md
Normal file
54
.claude/memory/reference_windows_edition_upgrade_rmm.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: reference_windows_edition_upgrade_rmm
|
||||||
|
description: Verified fleet procedure to upgrade a Windows machine Home->Pro/Pro-for-Workstations via RMM, with the three gotchas (DISM error 50, shutdown /t drop, PfW MAK auto-upgrade).
|
||||||
|
metadata:
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
Verified on ACG-Tech03L 2026-07-06 (Home 26200 -> Win 11 Pro for Workstations, permanently activated).
|
||||||
|
|
||||||
|
**Procedure (all steps as SYSTEM via `/rmm`, byte-exact via -EncodedCommand):**
|
||||||
|
1. Probe: EditionID/LicenseStatus (`.rmm-key-probe.ps1` pattern), confirm no logged-in user
|
||||||
|
(`query user`) so a reboot is safe.
|
||||||
|
2. Flip edition: `changepk.exe /ProductKey VK7JG-NPHTM-C97JM-9MPGT-3V66T` (generic Pro key).
|
||||||
|
Flips EditionID Core->Professional **live** (registry) + sets CBS RebootPending. exit 0.
|
||||||
|
3. Reboot to finalize (see gotcha #2).
|
||||||
|
4. Install MAK + activate: `slmgr /ipk <MAK>` then `slmgr /ato`; verify `slmgr /xpr` =
|
||||||
|
"permanently activated" and WMI SoftwareLicensingProduct LicenseStatus=1.
|
||||||
|
|
||||||
|
**Gotcha 1 — DISM is a dead end for online edition change.** `DISM /online /Set-Edition:Professional`
|
||||||
|
returns **Error 50 "Setting an edition is not supported with online images"** even though
|
||||||
|
`/Get-TargetEditions` lists Professional. Use `changepk.exe`, not DISM, for online Home->Pro.
|
||||||
|
|
||||||
|
**Gotcha 2 — `shutdown /r /t N` is silently DROPPED in the SYSTEM service session.** The command
|
||||||
|
returns exit 0 but the box never reboots (verified twice on tech03l). Also `shutdown /c "comment"`
|
||||||
|
fails outright because the RMM->cmd.exe layer strips the quotes ([[feedback_windows_quote_stripping]]).
|
||||||
|
Reboot reliably with `Restart-Computer -Force` (encoded PS) or `shutdown /r /t 0 /f` (no comment).
|
||||||
|
NOTE: on a fast SSD the edition-upgrade reboot can be <30s of downtime — a 30s-interval last_seen
|
||||||
|
monitor can miss the gap entirely; confirm the reboot by `LastBootUpTime` advancing, not by catching
|
||||||
|
the offline window. `is_connected` is null fleet-wide — track `last_seen` age, never that flag.
|
||||||
|
|
||||||
|
**Gotcha 3 — the ACG MAK `infrastructure/windows-pro-mak` is a Pro-for-WORKSTATIONS MAK** (mislabeled
|
||||||
|
"Pro"). `slmgr /ipk` of it on a Professional machine AUTO-UPGRADES the edition to ProfessionalWorkstation
|
||||||
|
(a strict superset of Pro), live, **no extra reboot** (verified DALLAS 2026-07-07). If a client genuinely
|
||||||
|
needs *plain* Pro, this key will over-shoot to PfW — there is no plain-Pro MAK in the vault. Billing:
|
||||||
|
each activation consumes one MAK count = $99 to the customer (ACG-internal machines: no invoice, still
|
||||||
|
burns a count).
|
||||||
|
|
||||||
|
**Gotcha 4 — `changepk.exe` TIMES OUT under SYSTEM but STILL flips the edition. Do NOT treat the timeout
|
||||||
|
as failure — VERIFY EditionID.** Run as SYSTEM via `/rmm`, `changepk.exe /ProductKey <key>` does not return
|
||||||
|
cleanly (the RMM command comes back `failed` / exit -1 `Command timeout`, or your Start-Process/`&` wrapper
|
||||||
|
hangs to the agent timeout), yet the registry `EditionID` flip to `Professional` DID happen. Always confirm
|
||||||
|
with a one-liner (`Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' EditionID`) before
|
||||||
|
concluding anything — if it reads `Professional`, proceed to the finalize reboot; the running OS still
|
||||||
|
reports Home in `Win32_OperatingSystem.Caption` until that reboot. (Verified DALLAS 2026-07-07 — the
|
||||||
|
`changepk` command timed out but EditionID was already `Professional`.)
|
||||||
|
|
||||||
|
**Gotcha 5 — Modern Standby laptops (S0 Low Power Idle) drop the RMM/SC agent when idle/lid-closed, which
|
||||||
|
mimics an endless reboot.** On a Core Ultra / S0-only machine, idle or a closed lid drops Wi-Fi -> agent
|
||||||
|
offline for long stretches (looked like a 25-min "slow reboot"; the box had actually been up 25 days and
|
||||||
|
never rebooted). Before a reboot-driven upgrade on a laptop, pin it awake first:
|
||||||
|
`powercfg /change standby-timeout-ac 0; powercfg /setacvalueindex SCHEME_CURRENT SUB_BUTTONS LIDACTION 0;
|
||||||
|
powercfg /setactive SCHEME_CURRENT` (AC only — safe; DON'T lid-nothing on DC/battery). A command dispatched
|
||||||
|
to an already-offline agent goes `pending` and fires (uncontrolled) on next wake — cancel stale ones with
|
||||||
|
`/rmm cancel`. NOTE: an RDP *target* laptop must STAY awake to be reachable, so keep the AC no-sleep in place.
|
||||||
247
.claude/scripts/ask-forum.sh
Normal file
247
.claude/scripts/ask-forum.sh
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ask-forum.sh — ask a human a question in the #ct-forum Discord forum and BLOCK
|
||||||
|
# until a person answers, then print their answer and exit. Built for a live
|
||||||
|
# three-way: the user in the terminal, THIS Claude session, and a teammate (e.g.
|
||||||
|
# Mike) in the forum — all the same session. The wait loop runs here in bash, so
|
||||||
|
# the calling model pays for ONE tool call, not a polling loop.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ask-forum.sh "question text" [--title "short title"] [--timeout SEC] [--poll SEC] [--tag @id ...]
|
||||||
|
# echo "question" | ask-forum.sh [flags]
|
||||||
|
# ask-forum.sh --read <thread_id> [limit] # one-shot read, no wait
|
||||||
|
# ask-forum.sh --wait <thread_id> [--timeout SEC] [--poll SEC] [--after <msg_id>]
|
||||||
|
#
|
||||||
|
# Flow (ask): POST a forum post (thread) with the question, then poll that thread
|
||||||
|
# for the first NON-BOT reply (human answer), print it, exit 0.
|
||||||
|
#
|
||||||
|
# Correlation is automatic: we create the thread and read only that thread.
|
||||||
|
# Exit codes: 0 answered; 1 usage; 2 token missing; 3 Discord API error; 4 timeout.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
export LC_ALL="${LC_ALL:-C.UTF-8}" # count code points, not bytes, when slicing $CONTENT
|
||||||
|
|
||||||
|
ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
||||||
|
API="https://discord.com/api/v10"
|
||||||
|
UA="ClaudeToolsBot (claudetools, 1.0)"
|
||||||
|
FORUM_ID="1522960388432465950" # #ct-forum (private forum; Howard + bot + Mike)
|
||||||
|
LIMIT_CHARS=1900 # Discord caps message content at 2000
|
||||||
|
|
||||||
|
TIMEOUT=600
|
||||||
|
POLL=8
|
||||||
|
TITLE=""
|
||||||
|
TAGS=()
|
||||||
|
MSG=""
|
||||||
|
|
||||||
|
# --- token resolver (shared by read + wait + ask) ---
|
||||||
|
resolve_token() {
|
||||||
|
local t
|
||||||
|
t="$(bash "$ROOT/.claude/scripts/vault.sh" get-field \
|
||||||
|
projects/discord-bot/bot-token.sops.yaml credentials.bot_token 2>/dev/null)"
|
||||||
|
if [ -z "$t" ] || [ "$t" = "null" ]; then
|
||||||
|
local env_file="$ROOT/projects/discord-bot/.env"
|
||||||
|
[ -f "$env_file" ] && t="$(grep -iE '^[[:space:]]*DISCORD_TOKEN[[:space:]]*=' "$env_file" | head -1 | sed -E 's/^[^=]*=[[:space:]]*//; s/^["'"'"']//; s/["'"'"'][[:space:]]*$//')"
|
||||||
|
fi
|
||||||
|
printf '%s' "$t"
|
||||||
|
}
|
||||||
|
|
||||||
|
# trim a string to the last newline within it (keeps split chunks on line boundaries)
|
||||||
|
trim_at_newline() {
|
||||||
|
local s="$1" nl
|
||||||
|
nl="${s%$'\n'*}"
|
||||||
|
if [ "${#nl}" -lt "${#s}" ] && [ "${#nl}" -gt 0 ]; then printf '%s' "$nl"; else printf '%s' "$s"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# clamp a read limit to Discord's valid 1..100 (guard huge values that overflow bash math)
|
||||||
|
clamp_limit() {
|
||||||
|
local n="$1"
|
||||||
|
printf '%s' "$n" | grep -qE '^[0-9]+$' || { printf '25'; return; }
|
||||||
|
[ "${#n}" -ge 4 ] && { printf '100'; return; } # >=1000 always exceeds 100
|
||||||
|
[ "$n" -lt 1 ] && n=1; [ "$n" -gt 100 ] && n=100
|
||||||
|
printf '%s' "$n"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Given a non-array poll response body, decide what to do. Echoes: "retry" (transient/
|
||||||
|
# rate-limited — sleeps any retry_after first) or "bail:<reason>" (permanent Discord error).
|
||||||
|
poll_disposition() {
|
||||||
|
local body="$1" ra code
|
||||||
|
ra="$(printf '%s' "$body" | jq -r '.retry_after // empty' 2>/dev/null)"
|
||||||
|
if [ -n "$ra" ]; then
|
||||||
|
# rate limited: honor Discord's back-off (ceil), then retry
|
||||||
|
sleep "$(printf '%s\n' "$ra" | awk '{printf "%d", ($1==int($1)?$1:int($1)+1)}')" 2>/dev/null || sleep 2
|
||||||
|
printf 'retry'; return
|
||||||
|
fi
|
||||||
|
code="$(printf '%s' "$body" | jq -r '.code // empty' 2>/dev/null)"
|
||||||
|
if [ -n "$code" ]; then printf 'bail:%s %s' "$code" "$(printf '%s' "$body" | jq -r '.message // ""')"; return; fi
|
||||||
|
printf 'retry' # unrecognized/transient (network blip, empty body)
|
||||||
|
}
|
||||||
|
|
||||||
|
# emit the human (non-bot) replies from a messages array as ">>> user: text" lines
|
||||||
|
emit_human() {
|
||||||
|
printf '%s' "$1" | jq -r 'sort_by(.id)[] | select(.author.bot|not) | ">>> " + .author.username + ": " + ((.content//"")|gsub("\n";" "))'
|
||||||
|
}
|
||||||
|
# newest message id in an array (string/lexical max — snowflakes are monotonic; avoids jq number precision loss)
|
||||||
|
newest_id() { printf '%s' "$1" | jq -r 'sort_by(.id)|.[-1].id // empty'; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --read: one-shot fetch of a thread's messages, no posting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [ "${1:-}" = "--read" ]; then
|
||||||
|
THREAD="${2:-}"
|
||||||
|
[ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --read <thread_id> [limit]" >&2; exit 1; }
|
||||||
|
LIMIT="$(clamp_limit "${3:-25}")"
|
||||||
|
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}")
|
||||||
|
MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?limit=${LIMIT}")"
|
||||||
|
printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1 || { echo "[ERROR] could not read thread ${THREAD}: $(printf '%s' "$MSGS" | head -c 160)" >&2; exit 3; }
|
||||||
|
echo "[OK] thread ${THREAD} (oldest first; '>>>' = human, ' ' = bot):"
|
||||||
|
printf '%s' "$MSGS" | jq -r 'sort_by(.id)[] | ((if (.author.bot // false) then " " else ">>> " end) + .author.username + ": " + ((.content // "")|gsub("\n";" ")))'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --wait: block on an EXISTING thread until a human replies, no posting
|
||||||
|
# Baselines on the starter (thread id == starter message id for a forum post),
|
||||||
|
# so a reply that already landed before --wait began is caught (not missed).
|
||||||
|
# Override the baseline with --after <msg_id> to wait for replies strictly after
|
||||||
|
# a specific message (use when resuming a thread whose earlier answer you already
|
||||||
|
# consumed and you want only the NEXT reply).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [ "${1:-}" = "--wait" ]; then
|
||||||
|
THREAD="${2:-}"; shift 2
|
||||||
|
[ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --wait <thread_id> [--timeout SEC] [--poll SEC] [--after <msg_id>]" >&2; exit 1; }
|
||||||
|
AFTER="$THREAD"
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--timeout) TIMEOUT="${2:-600}"; shift 2 ;;
|
||||||
|
--poll) POLL="${2:-8}"; shift 2 ;;
|
||||||
|
--after) AFTER="${2:-$THREAD}"; shift 2 ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf '%s' "$TIMEOUT" | grep -qE '^[0-9]+$' || TIMEOUT=600
|
||||||
|
printf '%s' "$POLL" | grep -qE '^[0-9]+$' || POLL=8
|
||||||
|
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}")
|
||||||
|
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
|
||||||
|
MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?after=${AFTER}&limit=100")"
|
||||||
|
if ! printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1; then
|
||||||
|
D="$(poll_disposition "$MSGS")"
|
||||||
|
case "$D" in bail:*) echo "[ERROR] thread ${THREAD}: ${D#bail:}" >&2; exit 3 ;; esac
|
||||||
|
sleep "$POLL"; continue
|
||||||
|
fi
|
||||||
|
HUMAN="$(emit_human "$MSGS")"
|
||||||
|
if [ -n "$HUMAN" ]; then
|
||||||
|
echo "[OK] answer received in thread ${THREAD}:"; printf '%s\n' "$HUMAN"; echo "THREAD_ID=${THREAD}"; exit 0
|
||||||
|
fi
|
||||||
|
# all-bot batch: page forward so a reply beyond the 100-msg window isn't missed
|
||||||
|
NID="$(newest_id "$MSGS")"; [ -n "$NID" ] && AFTER="$NID"
|
||||||
|
sleep "$POLL"
|
||||||
|
done
|
||||||
|
echo "[TIMEOUT] no human reply on thread ${THREAD} within ${TIMEOUT}s" >&2
|
||||||
|
echo "THREAD_ID=${THREAD}" >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ask mode (default): post a question and block for the first human reply
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--title) TITLE="${2:-}"; shift 2 ;;
|
||||||
|
--timeout) TIMEOUT="${2:-600}"; shift 2 ;;
|
||||||
|
--poll) POLL="${2:-8}"; shift 2 ;;
|
||||||
|
--tag) TAGS+=("${2:-}"); shift 2 ;;
|
||||||
|
-h|--help) sed -n '2,20p' "$0"; exit 1 ;;
|
||||||
|
*) MSG="${MSG:+$MSG }$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ -z "$MSG" ] && [ ! -t 0 ]; then MSG="$(cat)"; fi
|
||||||
|
if [ -z "$MSG" ]; then echo "[ERROR] no question given" >&2; exit 1; fi
|
||||||
|
printf '%s' "$TIMEOUT" | grep -qE '^[0-9]+$' || TIMEOUT=600
|
||||||
|
printf '%s' "$POLL" | grep -qE '^[0-9]+$' || POLL=8
|
||||||
|
|
||||||
|
if [ -z "$TITLE" ]; then
|
||||||
|
TITLE="$(printf '%s' "$MSG" | tr '\n' ' ' | cut -c1-60)"
|
||||||
|
[ -z "$TITLE" ] && TITLE="Question"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# prepend any @mentions so the tagged person gets pinged
|
||||||
|
MENTION=""
|
||||||
|
for t in "${TAGS[@]:-}"; do
|
||||||
|
[ -z "$t" ] && continue
|
||||||
|
id="${t#@}"
|
||||||
|
printf '%s' "$id" | grep -qE '^[0-9]{17,19}$' && MENTION="${MENTION}<@${id}> "
|
||||||
|
done
|
||||||
|
CONTENT="${MENTION}${MSG}"
|
||||||
|
|
||||||
|
TOKEN="$(resolve_token)"
|
||||||
|
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
|
||||||
|
echo "[ERROR] no Discord bot token (vault + .env both empty)" >&2
|
||||||
|
bash "$ROOT/.claude/scripts/log-skill-error.sh" "ask-forum" "no Discord bot token" >/dev/null 2>&1
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
auth=(-H "Authorization: Bot ${TOKEN}" -H "Content-Type: application/json" -H "User-Agent: ${UA}")
|
||||||
|
|
||||||
|
# keep the forum starter <=1900 chars; any overflow goes out as follow-up messages
|
||||||
|
STARTER_CONTENT="$CONTENT"; OVERFLOW=""
|
||||||
|
if [ "${#CONTENT}" -gt "$LIMIT_CHARS" ]; then
|
||||||
|
STARTER_CONTENT="$(trim_at_newline "${CONTENT:0:$LIMIT_CHARS}")"
|
||||||
|
OVERFLOW="${CONTENT:${#STARTER_CONTENT}}"; OVERFLOW="${OVERFLOW#$'\n'}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- create the forum post (thread) with the question ---
|
||||||
|
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)"
|
||||||
|
JSON="$(printf '%s' "$RESP" | sed '$d')"
|
||||||
|
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
|
||||||
|
echo "[ERROR] could not post forum question (HTTP ${HTTP:-none}): $(printf '%s' "$JSON" | head -c 200)" >&2
|
||||||
|
bash "$ROOT/.claude/scripts/log-skill-error.sh" "ask-forum" "forum post failed" --context "http=${HTTP:-none} resp=$(printf '%s' "$JSON" | head -c 80)" >/dev/null 2>&1
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
THREAD="$(printf '%s' "$JSON" | jq -r '.id // empty')"
|
||||||
|
STARTER="$(printf '%s' "$JSON" | jq -r '.message.id // empty')"
|
||||||
|
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 (check each; warn if one fails) ---
|
||||||
|
rest="$OVERFLOW"
|
||||||
|
while [ -n "$rest" ]; do
|
||||||
|
piece="$(trim_at_newline "${rest:0:$LIMIT_CHARS}")"
|
||||||
|
fhttp="$(printf '%s' "$(jq -nc --arg c "$piece" '{content:$c}')" | \
|
||||||
|
curl -s -m 15 -o /dev/null -w '%{http_code}' "${auth[@]}" -X POST "$API/channels/${THREAD}/messages" --data-binary @-)"
|
||||||
|
[ "$fhttp" != "200" ] && echo "[WARNING] overflow chunk failed (HTTP ${fhttp}) — question may be truncated in the forum" >&2
|
||||||
|
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:-$THREAD}" # starter id == thread id for a forum post
|
||||||
|
DEADLINE=$(( $(date +%s) + TIMEOUT ))
|
||||||
|
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
||||||
|
sleep "$POLL"
|
||||||
|
MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?after=${AFTER}&limit=100")"
|
||||||
|
if ! printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1; then
|
||||||
|
D="$(poll_disposition "$MSGS")"
|
||||||
|
case "$D" in bail:*) echo "[ERROR] thread ${THREAD}: ${D#bail:}" >&2; exit 3 ;; esac
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
HUMAN="$(emit_human "$MSGS")"
|
||||||
|
if [ -n "$HUMAN" ]; then
|
||||||
|
echo "[OK] answer received in thread ${THREAD}:"; printf '%s\n' "$HUMAN"; echo "THREAD_ID=${THREAD}"; exit 0
|
||||||
|
fi
|
||||||
|
NID="$(newest_id "$MSGS")"; [ -n "$NID" ] && AFTER="$NID" # page forward past bot-only batches
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[TIMEOUT] no human reply within ${TIMEOUT}s. Thread stays open for a later read:" >&2
|
||||||
|
echo "THREAD_ID=${THREAD}" >&2
|
||||||
|
echo " re-read: bash .claude/scripts/ask-forum.sh --read ${THREAD} | resume wait: --wait ${THREAD}" >&2
|
||||||
|
exit 4
|
||||||
268
.claude/scripts/backup_common.py
Normal file
268
.claude/scripts/backup_common.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Shared helpers for the backup-verification skills (seafile / owncloud / datto-workplace).
|
||||||
|
|
||||||
|
Three sibling skills answer the same GPS-audit question from different backends:
|
||||||
|
"which customers/machines are actually backing up here?" Each skill has its own
|
||||||
|
server-side client (Seafile Web API, ownCloud occ over SSH, Datto Workplace file
|
||||||
|
API). What they SHARE — and what lives here — is:
|
||||||
|
|
||||||
|
* ClaudeTools repo-root resolution (env -> identity.json -> derived).
|
||||||
|
* A one-field SOPS vault read via the canonical vault.sh wrapper.
|
||||||
|
* An RmmClient for the "endpoint" half of the "server + RMM endpoint" cross-check:
|
||||||
|
log in to GuruRMM, list agents, and run a detection PowerShell on an agent to
|
||||||
|
prove a given sync client (SeaDrive / ownCloud / Datto Workplace) is actually
|
||||||
|
installed and signed in on the machine.
|
||||||
|
|
||||||
|
Standalone: stdlib-only transport (urllib), no third-party dependency. Skills add
|
||||||
|
this file's directory to sys.path and `import backup_common`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
# backup_common.py lives at <root>/.claude/scripts/backup_common.py
|
||||||
|
_THIS = Path(__file__).resolve()
|
||||||
|
_DERIVED_ROOT = _THIS.parent.parent.parent # scripts -> .claude -> repo root
|
||||||
|
|
||||||
|
RMM_BASE = os.environ.get("GURURMM_API", "http://172.16.3.30:3001")
|
||||||
|
RMM_VAULT_ENTRY = "infrastructure/gururmm-server.sops.yaml"
|
||||||
|
RMM_FIELD_EMAIL = "credentials.gururmm-api.admin-email"
|
||||||
|
RMM_FIELD_PASSWORD = "credentials.gururmm-api.admin-password"
|
||||||
|
|
||||||
|
HTTP_TIMEOUT = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
class BackupError(RuntimeError):
|
||||||
|
"""Transport / auth / API error, with an optional HTTP status."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status: Optional[int] = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
# --- repo-root + vault --------------------------------------------------------
|
||||||
|
def resolve_root() -> Path:
|
||||||
|
"""Resolve the ClaudeTools repo root: env var, then identity.json, then derived."""
|
||||||
|
env_root = os.environ.get("CLAUDETOOLS_ROOT")
|
||||||
|
if env_root:
|
||||||
|
return Path(env_root)
|
||||||
|
identity = _DERIVED_ROOT / ".claude" / "identity.json"
|
||||||
|
if identity.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(identity.read_text(encoding="utf-8"))
|
||||||
|
root = data.get("claudetools_root")
|
||||||
|
if root:
|
||||||
|
return Path(root)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
return _DERIVED_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def vault_get_field(entry: str, field: str) -> str:
|
||||||
|
"""Read one field from a SOPS vault entry via the canonical vault.sh wrapper."""
|
||||||
|
root = resolve_root()
|
||||||
|
vault = root / ".claude" / "scripts" / "vault.sh"
|
||||||
|
if not vault.exists():
|
||||||
|
raise BackupError(f"vault wrapper not found at {vault}")
|
||||||
|
try:
|
||||||
|
done = subprocess.run(
|
||||||
|
["bash", str(vault), "get-field", entry, field],
|
||||||
|
capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise BackupError("'bash' not found on PATH (need Git Bash for vault reads).") from exc
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise BackupError(f"vault read timed out for {entry}:{field}") from exc
|
||||||
|
if done.returncode != 0:
|
||||||
|
raise BackupError(
|
||||||
|
f"vault read failed for {entry}:{field} (exit {done.returncode}): {done.stderr.strip()}"
|
||||||
|
)
|
||||||
|
value = done.stdout.strip()
|
||||||
|
if not value:
|
||||||
|
raise BackupError(f"vault returned empty for {entry}:{field}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# --- minimal JSON HTTP --------------------------------------------------------
|
||||||
|
def http_json(method: str, url: str, *, headers: Optional[dict] = None,
|
||||||
|
body: Optional[dict] = None, form: Optional[dict] = None,
|
||||||
|
timeout: float = HTTP_TIMEOUT,
|
||||||
|
basic: Optional[tuple[str, str]] = None) -> tuple[int, Any]:
|
||||||
|
"""Do an HTTP request and parse a JSON response. Returns (status, parsed).
|
||||||
|
|
||||||
|
`body` sends a JSON payload; `form` sends application/x-www-form-urlencoded
|
||||||
|
(list values expand via doseq, e.g. repeated fields the Seafile admin share
|
||||||
|
endpoint reads with getlist). Pass at most one. 4xx/5xx are returned (not
|
||||||
|
raised) so callers can inspect the error body; transport failures raise.
|
||||||
|
"""
|
||||||
|
hdrs = dict(headers or {})
|
||||||
|
data = None
|
||||||
|
if body is not None and form is not None:
|
||||||
|
raise BackupError("http_json: pass body OR form, not both")
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode("utf-8")
|
||||||
|
hdrs.setdefault("Content-Type", "application/json")
|
||||||
|
elif form is not None:
|
||||||
|
data = urllib.parse.urlencode(form, doseq=True).encode("utf-8")
|
||||||
|
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if basic is not None:
|
||||||
|
import base64
|
||||||
|
tok = base64.b64encode(f"{basic[0]}:{basic[1]}".encode()).decode("ascii")
|
||||||
|
hdrs["Authorization"] = f"Basic {tok}"
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
|
return resp.getcode(), _parse(raw)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raw = exc.read().decode("utf-8", errors="replace")
|
||||||
|
return exc.code, _parse(raw)
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise BackupError(f"request to {url} failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(text: str) -> Any:
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {"_raw": text[:600]}
|
||||||
|
|
||||||
|
|
||||||
|
# --- GuruRMM client (endpoint cross-check) ------------------------------------
|
||||||
|
# One detection script, parametrized by the product name-fragments to look for.
|
||||||
|
# Returns a single JSON line describing installed products, running processes, and
|
||||||
|
# per-user config folders that match — enough to say "this client is installed and
|
||||||
|
# signed in" without guessing.
|
||||||
|
_DETECT_PS = r'''
|
||||||
|
$ErrorActionPreference='SilentlyContinue'
|
||||||
|
$patterns = @(__PATTERNS__)
|
||||||
|
function match($s){ if(-not $s){return $false}; foreach($p in $patterns){ if($s -match [regex]::Escape($p)){return $true} }; return $false }
|
||||||
|
|
||||||
|
$installed=@()
|
||||||
|
foreach($hive in @('HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*','HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')){
|
||||||
|
Get-ItemProperty $hive | Where-Object { match($_.DisplayName) } | ForEach-Object {
|
||||||
|
$installed += [pscustomobject]@{ name=$_.DisplayName; version=$_.DisplayVersion; location=$_.InstallLocation }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$procs = Get-Process | Where-Object { match($_.ProcessName) } | Select-Object -Expand ProcessName -Unique
|
||||||
|
$cfg=@()
|
||||||
|
foreach($d in @('Seafile','SeaDrive','ownCloud','ownCloudSync','Datto','Workplace','Workplace2')){
|
||||||
|
foreach($base in @($env:APPDATA,$env:LOCALAPPDATA,$env:USERPROFILE,"$env:USERPROFILE\Desktop")){
|
||||||
|
$pth = Join-Path $base $d
|
||||||
|
if(Test-Path $pth){ if(match($d) -or match($pth)){ $cfg += $pth } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# signed-in heuristic: config/db files with recent writes
|
||||||
|
$fresh=@()
|
||||||
|
foreach($c in $cfg){
|
||||||
|
Get-ChildItem $c -Recurse -File -ErrorAction SilentlyContinue -Depth 2 |
|
||||||
|
Where-Object { $_.LastWriteTime -gt (Get-Date).AddDays(-14) } |
|
||||||
|
Select-Object -First 1 | ForEach-Object { $fresh += $c }
|
||||||
|
}
|
||||||
|
[pscustomobject]@{
|
||||||
|
host=$env:COMPUTERNAME
|
||||||
|
installed=$installed
|
||||||
|
processes=@($procs)
|
||||||
|
config_dirs=@($cfg)
|
||||||
|
active_config_dirs=@($fresh | Select-Object -Unique)
|
||||||
|
detected=([bool]($installed.Count -or $procs.Count))
|
||||||
|
} | ConvertTo-Json -Depth 5 -Compress
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class RmmClient:
|
||||||
|
"""Thin GuruRMM API client: login, list agents, run a detection PowerShell."""
|
||||||
|
|
||||||
|
def __init__(self, base: str = RMM_BASE):
|
||||||
|
self.base = base.rstrip("/")
|
||||||
|
self._token: Optional[str] = None
|
||||||
|
self._agents: Optional[list[dict]] = None
|
||||||
|
|
||||||
|
def login(self) -> None:
|
||||||
|
email = vault_get_field(RMM_VAULT_ENTRY, RMM_FIELD_EMAIL)
|
||||||
|
pw = vault_get_field(RMM_VAULT_ENTRY, RMM_FIELD_PASSWORD)
|
||||||
|
status, body = http_json("POST", f"{self.base}/api/auth/login",
|
||||||
|
body={"email": email, "password": pw})
|
||||||
|
if status != 200 or not isinstance(body, dict) or not body.get("token"):
|
||||||
|
raise BackupError(f"GuruRMM login failed (HTTP {status}): {body}", status=status)
|
||||||
|
self._token = body["token"]
|
||||||
|
|
||||||
|
def _auth(self) -> dict:
|
||||||
|
if not self._token:
|
||||||
|
self.login()
|
||||||
|
return {"Authorization": f"Bearer {self._token}"}
|
||||||
|
|
||||||
|
def list_agents(self) -> list[dict]:
|
||||||
|
if self._agents is None:
|
||||||
|
status, body = http_json("GET", f"{self.base}/api/agents", headers=self._auth())
|
||||||
|
if status != 200 or not isinstance(body, list):
|
||||||
|
raise BackupError(f"GET /api/agents failed (HTTP {status})", status=status)
|
||||||
|
self._agents = body
|
||||||
|
return self._agents
|
||||||
|
|
||||||
|
def find_agents(self, client_substr: Optional[str] = None,
|
||||||
|
hostname_substr: Optional[str] = None) -> list[dict]:
|
||||||
|
out = []
|
||||||
|
for a in self.list_agents():
|
||||||
|
if client_substr and client_substr.lower() not in (a.get("client_name") or "").lower():
|
||||||
|
continue
|
||||||
|
if hostname_substr and hostname_substr.lower() not in (a.get("hostname") or "").lower():
|
||||||
|
continue
|
||||||
|
out.append(a)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def run_ps(self, agent_id: str, script: str, timeout_seconds: int = 90,
|
||||||
|
poll_seconds: int = 4, max_polls: int = 30) -> dict:
|
||||||
|
"""Dispatch a PowerShell command to an agent and poll to completion."""
|
||||||
|
status, body = http_json(
|
||||||
|
"POST", f"{self.base}/api/agents/{agent_id}/command", headers=self._auth(),
|
||||||
|
body={"command_type": "powershell", "command": script,
|
||||||
|
"timeout_seconds": timeout_seconds})
|
||||||
|
if status not in (200, 201) or not isinstance(body, dict):
|
||||||
|
raise BackupError(f"command dispatch failed (HTTP {status}): {body}", status=status)
|
||||||
|
cid = body.get("command_id") or body.get("id")
|
||||||
|
if not cid:
|
||||||
|
raise BackupError(f"no command_id in dispatch response: {body}")
|
||||||
|
terminal = {"completed", "failed", "cancelled", "interrupted", "timeout"}
|
||||||
|
last: dict = {}
|
||||||
|
for _ in range(max_polls):
|
||||||
|
s, cb = http_json("GET", f"{self.base}/api/commands/{cid}", headers=self._auth())
|
||||||
|
if s == 200 and isinstance(cb, dict):
|
||||||
|
last = cb
|
||||||
|
if (cb.get("status") or "") in terminal:
|
||||||
|
break
|
||||||
|
time.sleep(poll_seconds)
|
||||||
|
return last
|
||||||
|
|
||||||
|
def detect_client(self, agent: dict, patterns: list[str], **kw) -> dict:
|
||||||
|
"""Run the detection script on one agent for the given product name-fragments."""
|
||||||
|
pat_lits = ",".join("'" + p.replace("'", "''") + "'" for p in patterns)
|
||||||
|
script = _DETECT_PS.replace("__PATTERNS__", pat_lits)
|
||||||
|
res = self.run_ps(agent["id"], script, **kw)
|
||||||
|
out = {"hostname": agent.get("hostname"), "client_name": agent.get("client_name"),
|
||||||
|
"agent_id": agent.get("id"), "status": res.get("status"),
|
||||||
|
"exit_code": res.get("exit_code"), "detected": None, "detail": None}
|
||||||
|
stdout = (res.get("stdout") or "").strip()
|
||||||
|
if stdout:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(stdout)
|
||||||
|
out["detected"] = bool(parsed.get("detected"))
|
||||||
|
out["detail"] = parsed
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
out["detail"] = {"_raw": stdout[:600]}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def eprint(*a: Any) -> None:
|
||||||
|
print(*a, file=sys.stderr)
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
# Usage:
|
# Usage:
|
||||||
# discord-dm.sh <recipient> "message text"
|
# discord-dm.sh <recipient> "message text"
|
||||||
# echo "message text" | discord-dm.sh <recipient>
|
# echo "message text" | discord-dm.sh <recipient>
|
||||||
|
# discord-dm.sh read <recipient> [limit] # READ recent messages — SEE replies (default 15)
|
||||||
# discord-dm.sh list # show known users + channels
|
# discord-dm.sh list # show known users + channels
|
||||||
#
|
#
|
||||||
# <recipient> is one of:
|
# <recipient> is one of:
|
||||||
@@ -51,11 +52,29 @@ if [ -z "$RECIPIENT" ] || [ "$RECIPIENT" = "-h" ] || [ "$RECIPIENT" = "--help" ]
|
|||||||
sed -n '2,30p' "$0"; exit 1
|
sed -n '2,30p' "$0"; exit 1
|
||||||
fi
|
fi
|
||||||
if [ "$RECIPIENT" = "list" ]; then print_dir; exit 0; fi
|
if [ "$RECIPIENT" = "list" ]; then print_dir; exit 0; fi
|
||||||
|
|
||||||
|
# --- read mode: fetch recent messages so we can SEE replies (mike/winter/etc.).
|
||||||
|
# `discord-dm.sh read <user|#channel> [limit]` — reuses the recipient resolution,
|
||||||
|
# token, and DM-channel-open below, then prints history instead of sending. The
|
||||||
|
# bot participates in each DM channel it opened, so REST history returns full
|
||||||
|
# content (the Message Content intent only gates gateway events, not this fetch).
|
||||||
|
ACTION=send
|
||||||
|
if [ "$RECIPIENT" = "read" ] || [ "$RECIPIENT" = "inbox" ]; then
|
||||||
|
ACTION=read; shift
|
||||||
|
RECIPIENT="${1:-}"
|
||||||
|
[ -z "$RECIPIENT" ] && { echo "[ERROR] usage: discord-dm.sh read <user|#channel> [limit]" >&2; exit 1; }
|
||||||
|
fi
|
||||||
shift
|
shift
|
||||||
|
|
||||||
# --- message (remaining args, else stdin) ---
|
# --- message (send) or limit (read) ---
|
||||||
if [ "$#" -gt 0 ]; then MSG="$*"; elif [ ! -t 0 ]; then MSG="$(cat)"; else MSG=""; fi
|
if [ "$ACTION" = "read" ]; then
|
||||||
if [ -z "$MSG" ]; then echo "[ERROR] empty message — nothing sent" >&2; exit 1; fi
|
READ_LIMIT="${1:-15}"
|
||||||
|
printf '%s' "$READ_LIMIT" | grep -qE '^[0-9]+$' || READ_LIMIT=15
|
||||||
|
[ "$READ_LIMIT" -gt 100 ] && READ_LIMIT=100
|
||||||
|
else
|
||||||
|
if [ "$#" -gt 0 ]; then MSG="$*"; elif [ ! -t 0 ]; then MSG="$(cat)"; else MSG=""; fi
|
||||||
|
if [ -z "$MSG" ]; then echo "[ERROR] empty message — nothing sent" >&2; exit 1; fi
|
||||||
|
fi
|
||||||
|
|
||||||
# --- resolve recipient -> mode (dm|channel) + target id ---
|
# --- resolve recipient -> mode (dm|channel) + target id ---
|
||||||
MODE=""; TARGET=""; LABEL=""
|
MODE=""; TARGET=""; LABEL=""
|
||||||
@@ -99,6 +118,22 @@ if [ "$MODE" = "dm" ]; then
|
|||||||
TARGET="$CHID"
|
TARGET="$CHID"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- read mode: fetch + print recent messages, then exit (no send) ---
|
||||||
|
if [ "$ACTION" = "read" ]; then
|
||||||
|
MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${TARGET}/messages?limit=${READ_LIMIT}")"
|
||||||
|
if ! printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] could not read $LABEL: $(printf '%s' "$MSGS" | head -c 200)" >&2
|
||||||
|
bash "$ROOT/.claude/scripts/log-skill-error.sh" "discord-dm" "read history failed for $LABEL" --context "resp=$(printf '%s' "$MSGS" | head -c 80)" >/dev/null 2>&1
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
echo "[OK] discord-dm: last ${READ_LIMIT} message(s) in ${LABEL} (oldest first; '>>>' = reply from them, ' ' = us):"
|
||||||
|
printf '%s' "$MSGS" | jq -r 'reverse[] |
|
||||||
|
((if (.author.bot // false) then " " else ">>> " end)
|
||||||
|
+ (.timestamp[0:16]) + " " + .author.username + ": "
|
||||||
|
+ ((.content // "") | gsub("\n";" ")))'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# --- chunk: Discord caps content at 2000 chars (code 50035 on overflow).
|
# --- chunk: Discord caps content at 2000 chars (code 50035 on overflow).
|
||||||
# This tool's job is delivering LONG content intact, so split into <=1900-char
|
# This tool's job is delivering LONG content intact, so split into <=1900-char
|
||||||
# pieces (preferring newline boundaries) and send them in order — no markers
|
# pieces (preferring newline boundaries) and send them in order — no markers
|
||||||
|
|||||||
6
.claude/scripts/edr-isolation-watch-hidden.vbs
Normal file
6
.claude/scripts/edr-isolation-watch-hidden.vbs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
' edr-isolation-watch-hidden.vbs — launch the EDR isolation watcher with no visible window.
|
||||||
|
' Run via the "ClaudeTools - EDR Isolation Watcher" scheduled task through wscript.exe (a GUI
|
||||||
|
' host, no console), which starts bash with window style 0 (hidden) so the every-10-min watch
|
||||||
|
' no longer flashes a console window on the desktop. Runtime env is identical to a direct bash
|
||||||
|
' launch. Mirrors gps-rmm-progress-hidden.vbs.
|
||||||
|
CreateObject("WScript.Shell").Run """C:\Program Files\Git\bin\bash.exe"" -lc ""/c/claudetools/.claude/scripts/edr-isolation-watch.sh""", 0, False
|
||||||
71
.claude/scripts/edr-isolation-watch.sh
Normal file
71
.claude/scripts/edr-isolation-watch.sh
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# edr-isolation-watch.sh — interim EDR auto-isolation alerter.
|
||||||
|
#
|
||||||
|
# Polls Datto EDR for detections whose automated response included `isolate-host`
|
||||||
|
# (i.e. a machine was auto-isolated / "quarantined") and posts each NEW one to the
|
||||||
|
# private #dev-alerts channel (Mike + Howard). Runs as a plain scheduled job — no
|
||||||
|
# LLM, no tokens. Retire once the GuruRMM EDR webhook integration (Feature 6) lands.
|
||||||
|
#
|
||||||
|
# Schedule (every 10 min), e.g. cron:
|
||||||
|
# */10 * * * * bash /path/to/claudetools/.claude/scripts/edr-isolation-watch.sh >/dev/null 2>&1
|
||||||
|
#
|
||||||
|
# State: .edr-watch-state.json (gitignored) holds already-alerted alert IDs so each
|
||||||
|
# isolation is announced exactly once, even though Mike may un-isolate before the poll.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
EDR="bash $ROOT/.claude/scripts/py.sh $ROOT/.claude/skills/datto-edr/scripts/edr.py"
|
||||||
|
DM="bash $ROOT/.claude/scripts/discord-dm.sh"
|
||||||
|
export STATE="${STATE:-$ROOT/.claude/scripts/.edr-watch-state.json}"
|
||||||
|
TARGET="dev" # #dev-alerts = Mike + Howard, private
|
||||||
|
|
||||||
|
# Pull last 24h of detections (list includes responseData) and emit any NEW
|
||||||
|
# isolate-host events as one pipe-delimited line each; update state in-place.
|
||||||
|
new_events="$($EDR --json detections --days 1 --limit 500 2>/dev/null | python -c '
|
||||||
|
import sys, json, os
|
||||||
|
state_path = os.environ["STATE"]
|
||||||
|
try:
|
||||||
|
rows = json.load(sys.stdin)
|
||||||
|
rows = rows if isinstance(rows, list) else rows.get("data", rows.get("alerts", []))
|
||||||
|
except Exception:
|
||||||
|
sys.exit(0) # API hiccup: emit nothing, do not churn state
|
||||||
|
try:
|
||||||
|
seen = set(json.load(open(state_path)))
|
||||||
|
except Exception:
|
||||||
|
seen = set()
|
||||||
|
fresh = []
|
||||||
|
for a in rows:
|
||||||
|
rd = a.get("responseData") or []
|
||||||
|
names = [r.get("name") for r in rd] if isinstance(rd, list) else []
|
||||||
|
if "isolate-host" not in names:
|
||||||
|
continue
|
||||||
|
aid = a.get("id")
|
||||||
|
if not aid or aid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(aid)
|
||||||
|
fresh.append("|".join(str(a.get(k, "")) for k in
|
||||||
|
("id", "hostname", "organizationName", "severity", "sourceName", "eventTime")))
|
||||||
|
# keep state bounded
|
||||||
|
json.dump(sorted(seen)[-500:], open(state_path, "w"))
|
||||||
|
print("\n".join(fresh))
|
||||||
|
' 2>/dev/null)"
|
||||||
|
|
||||||
|
[ -z "$new_events" ] && exit 0
|
||||||
|
|
||||||
|
while IFS='|' read -r aid host org sev rule ts; do
|
||||||
|
[ -z "$aid" ] && continue
|
||||||
|
msg="[EDR AUTO-ISOLATION] **$host** was auto-isolated by Datto EDR
|
||||||
|
- Client: $org
|
||||||
|
- Rule: $rule (severity: $sev)
|
||||||
|
- Time: $ts
|
||||||
|
- This machine was cut off the network by an EDR automated response. Verify it is intended before restoring.
|
||||||
|
- Console: https://azcomp4587.infocyte.com (alert id: $aid)"
|
||||||
|
if [ "${DRY_RUN:-0}" = "1" ]; then
|
||||||
|
printf '[DRY_RUN] would post to #%s:\n%s\n\n' "$TARGET" "$msg"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! printf '%s' "$msg" | $DM "$TARGET" 2>/dev/null; then
|
||||||
|
bash "$ROOT/.claude/scripts/log-skill-error.sh" "edr-isolation-watch" "discord post failed for $host ($aid)" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done <<< "$new_events"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Generate base64 of the WatchdogAlertsSection component."""
|
"""Generate base64 of the WatchdogAlertsSection component."""
|
||||||
import base64, sys
|
import base64, os, sys
|
||||||
|
|
||||||
BT = chr(96)
|
BT = chr(96)
|
||||||
BULLET = "\xb7"
|
BULLET = "\xb7"
|
||||||
@@ -195,7 +195,9 @@ lines = [
|
|||||||
component = "".join(lines)
|
component = "".join(lines)
|
||||||
b64 = base64.b64encode(component.encode("utf-8")).decode("ascii")
|
b64 = base64.b64encode(component.encode("utf-8")).decode("ascii")
|
||||||
|
|
||||||
with open("D:/claudetools/.claude/scripts/component.b64", "w") as f:
|
# Write next to this script — the repo root is machine-specific, never hardcode it.
|
||||||
|
out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "component.b64")
|
||||||
|
with open(out_path, "w") as f:
|
||||||
f.write(b64)
|
f.write(b64)
|
||||||
|
|
||||||
print(f"Component: {len(component)} chars")
|
print(f"Component: {len(component)} chars")
|
||||||
|
|||||||
45
.claude/scripts/get-identity.sh
Normal file
45
.claude/scripts/get-identity.sh
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# get-identity.sh — Read identity.json and export user/machine vars for attribution
|
||||||
|
#
|
||||||
|
# Source this at the start of any skill that needs attribution (bot alerts, commits,
|
||||||
|
# logs, RMM operations). Exports $USER_NAME, $USER_SHORT, $MACHINE, $USER_EMAIL.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# source .claude/scripts/get-identity.sh
|
||||||
|
# echo "[RMM] $USER_SHORT deployed to X machines..."
|
||||||
|
#
|
||||||
|
# Soft-fails: if identity.json is missing, exports "Unknown" values and returns 1
|
||||||
|
# (but does NOT exit, so the caller continues). This ensures skills never break on
|
||||||
|
# missing identity - they just attribute to "Unknown".
|
||||||
|
|
||||||
|
# Resolve the repo root from this script's own location (mirrors vault.sh) so callers
|
||||||
|
# don't have to know it. An already-set CLAUDETOOLS_ROOT still wins, and identity.json's
|
||||||
|
# claudetools_root overrides both. Never hardcode a drive letter.
|
||||||
|
if [ -z "${CLAUDETOOLS_ROOT:-}" ]; then
|
||||||
|
export CLAUDETOOLS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
IDENTITY_FILE="$CLAUDETOOLS_ROOT/.claude/identity.json"
|
||||||
|
|
||||||
|
if [ ! -f "$IDENTITY_FILE" ]; then
|
||||||
|
echo "[WARNING] identity.json not found at $IDENTITY_FILE - attribution will be 'Unknown'" >&2
|
||||||
|
export USER_NAME="Unknown User"
|
||||||
|
export USER_SHORT="unknown"
|
||||||
|
export MACHINE="unknown-machine"
|
||||||
|
export USER_EMAIL="unknown@unknown.com"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export USER_NAME=$(jq -r '.full_name // .user // "Unknown"' "$IDENTITY_FILE" 2>/dev/null || echo "Unknown")
|
||||||
|
export USER_SHORT=$(jq -r '.user // "unknown"' "$IDENTITY_FILE" 2>/dev/null || echo "unknown")
|
||||||
|
export MACHINE=$(jq -r '.machine // "unknown"' "$IDENTITY_FILE" 2>/dev/null || echo "unknown")
|
||||||
|
export USER_EMAIL=$(jq -r '.email // "unknown@unknown.com"' "$IDENTITY_FILE" 2>/dev/null || echo "unknown@unknown.com")
|
||||||
|
|
||||||
|
# identity.json is authoritative for the root and the vault location.
|
||||||
|
_root_override=$(jq -r '.claudetools_root // empty' "$IDENTITY_FILE" 2>/dev/null)
|
||||||
|
[ -n "$_root_override" ] && [ -d "$_root_override" ] && export CLAUDETOOLS_ROOT="$_root_override"
|
||||||
|
export VAULT_ROOT=$(jq -r '.vault_path // empty' "$IDENTITY_FILE" 2>/dev/null)
|
||||||
|
unset _root_override
|
||||||
|
|
||||||
|
# Success
|
||||||
|
return 0
|
||||||
36
.claude/scripts/gps-rmm-autoenroll.sh
Normal file
36
.claude/scripts/gps-rmm-autoenroll.sh
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# gps-rmm-autoenroll.sh — auto-enroll loop for the GPS->RMM audit.
|
||||||
|
# Every run: push the generic Staging installer (DARK-STORM-3150) to GPS-customer
|
||||||
|
# machines that are ONLINE in ScreenConnect but missing from GuruRMM, wait for them
|
||||||
|
# to enroll, then reassign Staging agents to their real client (hostname->Syncro).
|
||||||
|
# Registered as Windows task GPS-RMM-AutoEnroll (every 30 min). Remove the task when
|
||||||
|
# targets.json is fully enrolled. Safe to re-run: server v6.77+ dedups by device_id.
|
||||||
|
set -uo pipefail
|
||||||
|
cd /c/claudetools || exit 1
|
||||||
|
LOG="projects/gps-rmm-audit/autoenroll.log"
|
||||||
|
TS="$(date '+%Y-%m-%d %H:%M')"
|
||||||
|
|
||||||
|
eval "$(bash .claude/scripts/rmm-auth.sh 2>/dev/null)" >/dev/null 2>&1
|
||||||
|
if [ -z "${TOKEN:-}" ]; then echo "$TS auth FAILED" >> "$LOG"; exit 0; fi
|
||||||
|
SEC="$(bash .claude/scripts/vault.sh get-field msp-tools/screenconnect.sops.yaml credentials.api_secret 2>/dev/null | tr -d '\r\n')"
|
||||||
|
SK="$(bash .claude/scripts/vault.sh get-field msp-tools/syncro-howard credentials.credential 2>/dev/null | tr -d '\r\n')"
|
||||||
|
if [ -z "$SK" ]; then echo "$TS syncro key read FAILED" >> "$LOG"; exit 0; fi
|
||||||
|
|
||||||
|
OUT="$(RMM="$RMM" TOK="$TOKEN" SK="$SK" SC_SECRET="$SEC" python projects/gps-rmm-audit/tools/rebuild-and-push.py 2>/dev/null | tail -2)"
|
||||||
|
PUSHED="$(echo "$OUT" | grep -oE 'pushed: [0-9]+' | grep -oE '[0-9]+' | head -1 || true)"
|
||||||
|
PUSHED="${PUSHED:-0}"
|
||||||
|
echo "$TS sweep: $(echo "$OUT" | head -1)" >> "$LOG"
|
||||||
|
|
||||||
|
if [ "${PUSHED:-0}" -gt 0 ]; then
|
||||||
|
sleep 150
|
||||||
|
RES="$(RMM="$RMM" TOK="$TOKEN" SK="$SK" python projects/gps-rmm-audit/tools/reassign-staging.py 2>/dev/null)"
|
||||||
|
MOVED="$(echo "$RES" | grep -cE '^ .+ -> ' || true)"
|
||||||
|
MOVED="${MOVED:-0}"
|
||||||
|
echo "$TS pushed $PUSHED, reassigned $MOVED:" >> "$LOG"
|
||||||
|
echo "$RES" | grep -E '^ ' >> "$LOG"
|
||||||
|
if [ "${MOVED:-0}" -gt 0 ]; then
|
||||||
|
NAMES="$(echo "$RES" | grep -E '^ .+ -> ' | sed 's/^ //' | tr '\n' '; ')"
|
||||||
|
bash .claude/scripts/post-bot-alert.sh "[RMM] auto-enroll: $NAMES" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
63
.claude/scripts/gps-rmm-progress-check.sh
Normal file
63
.claude/scripts/gps-rmm-progress-check.sh
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# gps-rmm-progress-check.sh — daily GPS->GuruRMM enrollment progress check.
|
||||||
|
#
|
||||||
|
# Reads projects/gps-rmm-audit/targets.json (GPS device target per client),
|
||||||
|
# pulls live GuruRMM /api/agents, computes per-client enrollment gaps, and DMs
|
||||||
|
# Howard a one-message summary. When every tracked client has met its target it
|
||||||
|
# reports "COMPLETE" so the scheduled task can be retired.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash gps-rmm-progress-check.sh # check + DM Howard
|
||||||
|
# bash gps-rmm-progress-check.sh --dry-run # check + print, no Discord
|
||||||
|
#
|
||||||
|
# Registered as a daily Windows scheduled task. Read-only against RMM.
|
||||||
|
set -u
|
||||||
|
ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
||||||
|
TARGETS="$ROOT/projects/gps-rmm-audit/targets.json"
|
||||||
|
DRY=0; [ "${1:-}" = "--dry-run" ] && DRY=1
|
||||||
|
|
||||||
|
[ -f "$TARGETS" ] || { echo "[ERROR] targets.json not found at $TARGETS" >&2; exit 1; }
|
||||||
|
|
||||||
|
# --- auth + fetch agents ---
|
||||||
|
eval "$(bash "$ROOT/.claude/scripts/rmm-auth.sh" 2>/dev/null)" >/dev/null
|
||||||
|
if [ -z "${TOKEN:-}" ] || [ -z "${RMM:-}" ]; then
|
||||||
|
echo "[ERROR] RMM auth failed" >&2
|
||||||
|
bash "$ROOT/.claude/scripts/log-skill-error.sh" "gps-rmm-progress-check" "RMM auth failed" >/dev/null 2>&1
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
AGENTS=$(curl -s "$RMM/api/agents" -H "Authorization: Bearer $TOKEN" | tr -d '\000-\037')
|
||||||
|
[ "${AGENTS:0:1}" = "[" ] || { echo "[ERROR] /api/agents not an array: ${AGENTS:0:100}" >&2; \
|
||||||
|
bash "$ROOT/.claude/scripts/log-skill-error.sh" "gps-rmm-progress-check" "GET /api/agents non-array" >/dev/null 2>&1; exit 1; }
|
||||||
|
|
||||||
|
# --- per-client live agent counts (unique hostnames, to ignore dup agent rows) ---
|
||||||
|
COUNTS=$(echo "$AGENTS" | jq -r '[.[] | {c:.client_name, h:(.hostname|ascii_downcase)}]
|
||||||
|
| group_by(.c) | map({client:.[0].c, n:( [.[].h] | unique | length )}) | .[] | "\(.n)\t\(.client)"')
|
||||||
|
|
||||||
|
# --- compare targets vs live ---
|
||||||
|
REPORT=""; DONE_ALL=1; TOTAL_TARGET=0; TOTAL_HAVE=0; GAP_CLIENTS=0
|
||||||
|
while IFS=$'\t' read -r TGT NAME BUCKET; do
|
||||||
|
[ -z "$NAME" ] && continue
|
||||||
|
HAVE=$(echo "$COUNTS" | awk -F'\t' -v n="$NAME" '$2==n{print $1; found=1} END{if(!found)print 0}' | head -1)
|
||||||
|
HAVE=${HAVE:-0}
|
||||||
|
TOTAL_TARGET=$((TOTAL_TARGET + TGT))
|
||||||
|
TOTAL_HAVE=$((TOTAL_HAVE + HAVE))
|
||||||
|
if [ "$HAVE" -lt "$TGT" ]; then
|
||||||
|
DONE_ALL=0; GAP_CLIENTS=$((GAP_CLIENTS+1))
|
||||||
|
GAP=$((TGT - HAVE))
|
||||||
|
STATE=$([ "$HAVE" -eq 0 ] && echo "NO ORG/AGENTS" || echo "short")
|
||||||
|
REPORT="${REPORT} - ${NAME}: ${HAVE}/${TGT} in RMM (${STATE}, gap ${GAP}) [${BUCKET}]\n"
|
||||||
|
fi
|
||||||
|
done < <(jq -r '.clients[] | "\(.target)\t\(.client)\t\(.bucket)"' "$TARGETS")
|
||||||
|
|
||||||
|
DATE=$(date +%Y-%m-%d)
|
||||||
|
if [ "$DONE_ALL" -eq 1 ]; then
|
||||||
|
MSG="**GPS->RMM enrollment: COMPLETE (${DATE})** All tracked GPS clients meet their RMM device target (${TOTAL_HAVE}/${TOTAL_TARGET}). You can retire the daily check task (schtasks /Delete /TN GPS-RMM-Progress)."
|
||||||
|
else
|
||||||
|
MSG="**GPS->RMM enrollment check ${DATE}** — ${TOTAL_HAVE}/${TOTAL_TARGET} devices in RMM; ${GAP_CLIENTS} clients still short:\n${REPORT}(Glaz-Tech excluded pending billing review. Source: projects/gps-rmm-audit/targets.json)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DRY" -eq 1 ]; then
|
||||||
|
echo -e "$MSG"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo -e "$MSG" | bash "$ROOT/.claude/scripts/discord-dm.sh" howard
|
||||||
5
.claude/scripts/gps-rmm-progress-hidden.vbs
Normal file
5
.claude/scripts/gps-rmm-progress-hidden.vbs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
' gps-rmm-progress-hidden.vbs — launch the GPS->RMM progress check with no visible window.
|
||||||
|
' Run via the GPS-RMM-Progress scheduled task through wscript.exe (GUI host, no console),
|
||||||
|
' which starts bash with window style 0 (hidden) so the daily check no longer flashes a
|
||||||
|
' console window on the desktop. Runtime env is identical to a direct bash launch.
|
||||||
|
CreateObject("WScript.Shell").Run """C:\Program Files\Git\bin\bash.exe"" -lc ""cd /c/claudetools && bash .claude/scripts/gps-rmm-progress-check.sh""", 0, False
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# guruscan-agent-test.sh - Deploy GuruScan to a Windows GuruRMM agent and run an
|
# guruscan-agent-test.sh - Deploy GuruScan to a Windows GuruRMM agent and run an
|
||||||
# end-to-end smoke test (full 3-engine chain, EICAR-seeded, clean mode), pulling
|
# end-to-end smoke test (full 3-engine chain, clean mode), pulling every log back
|
||||||
# every log back for review.
|
# for review.
|
||||||
#
|
#
|
||||||
# Phases:
|
# Phases:
|
||||||
# prep - upload module to C:\GuruScan, Defender-exclude tool/test dirs,
|
# prep - upload module to C:\GuruScan, Defender-exclude tool dirs,
|
||||||
# download RKill+Emsisoft, fetch HitmanPro, seed EICAR, verify ready
|
# download RKill+Emsisoft, fetch HitmanPro, verify ready
|
||||||
# scan - dispatch Invoke-GuruScan.ps1 -Headless (clean mode) and poll
|
# scan - dispatch Invoke-GuruScan.ps1 -Headless (clean mode) and poll
|
||||||
# collect - pull results.json + per-scanner logs into the repo
|
# collect - pull results.json + per-scanner logs into the repo
|
||||||
# all - prep then scan then collect
|
# all - prep then scan then collect
|
||||||
@@ -14,10 +14,9 @@
|
|||||||
# bash guruscan-agent-test.sh <hostname|uuid> <prep|scan|collect|all>
|
# bash guruscan-agent-test.sh <hostname|uuid> <prep|scan|collect|all>
|
||||||
#
|
#
|
||||||
# Mirrors the RMM plumbing in run-onboarding-diagnostic.sh (vault auth -> JWT ->
|
# Mirrors the RMM plumbing in run-onboarding-diagnostic.sh (vault auth -> JWT ->
|
||||||
# chunked base64 upload -> dispatch -> poll). EICAR is the standard harmless AV
|
# chunked base64 upload -> dispatch -> poll). Detection testing uses the real
|
||||||
# test file; it is assembled on the endpoint (never written to this host) and
|
# malware-samples set via the scan-one / verify-each phases. Prep's EICAR seed is
|
||||||
# dropped only into a Defender-excluded folder so GuruScan's own engines are what
|
# DISABLED by default (kept for future scanners) -- enable with SEED_EICAR=1.
|
||||||
# detect it.
|
|
||||||
|
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
@@ -25,6 +24,11 @@ TARGET="${1:-}"
|
|||||||
PHASE="${2:-all}"
|
PHASE="${2:-all}"
|
||||||
SCANNER_ARG="${3:-}"
|
SCANNER_ARG="${3:-}"
|
||||||
|
|
||||||
|
# EICAR test-file seeding in the prep phase. DISABLED by default -- kept (not
|
||||||
|
# deleted) so it can be switched back on to smoke-test detection as new scanners
|
||||||
|
# are added to the chain. Enable per-run with: SEED_EICAR=1 bash guruscan-agent-test.sh ...
|
||||||
|
SEED_EICAR="${SEED_EICAR:-0}"
|
||||||
|
|
||||||
if [ -z "$TARGET" ]; then
|
if [ -z "$TARGET" ]; then
|
||||||
echo "[ERROR] Usage: bash guruscan-agent-test.sh <hostname|uuid> <prep|scan|collect|all>" >&2
|
echo "[ERROR] Usage: bash guruscan-agent-test.sh <hostname|uuid> <prep|scan|collect|all>" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -273,12 +277,12 @@ phase_prep() {
|
|||||||
upload_file "$GS_DIR/$f" "C:\\GuruScan\\$f" || { _logerr "upload failed" --context "file=$f host=$AGENT_HOST"; return 1; }
|
upload_file "$GS_DIR/$f" "C:\\GuruScan\\$f" || { _logerr "upload failed" --context "file=$f host=$AGENT_HOST"; return 1; }
|
||||||
done
|
done
|
||||||
|
|
||||||
# 2) Defender exclusions for tool + downloads + test dirs (so Defender does not
|
# 2) Defender exclusions for tool + downloads dirs (so Defender does not nuke
|
||||||
# nuke the scanner EXEs or grab EICAR before GuruScan's engines run).
|
# the scanner EXEs before GuruScan's engines run).
|
||||||
local sf="$WORK_DIR/defender.ps1"
|
local sf="$WORK_DIR/defender.ps1"
|
||||||
cat > "$sf" <<'PS'
|
cat > "$sf" <<'PS'
|
||||||
$ErrorActionPreference='Continue'
|
$ErrorActionPreference='Continue'
|
||||||
foreach($p in @('C:\GuruScan','C:\GuruScan\downloads','C:\GuruScanTest','C:\EmsisoftCmd')){
|
foreach($p in @('C:\GuruScan','C:\GuruScan\downloads','C:\EmsisoftCmd')){
|
||||||
try { Add-MpPreference -ExclusionPath $p -ErrorAction Stop; Write-Output "EXCLUDED $p" }
|
try { Add-MpPreference -ExclusionPath $p -ErrorAction Stop; Write-Output "EXCLUDED $p" }
|
||||||
catch { Write-Output ("EXCLUDE-SKIP " + $p + " : " + $_.Exception.Message) }
|
catch { Write-Output ("EXCLUDE-SKIP " + $p + " : " + $_.Exception.Message) }
|
||||||
}
|
}
|
||||||
@@ -310,12 +314,16 @@ try {
|
|||||||
PS
|
PS
|
||||||
run_ps "$sf3" 300 70 "download-hitmanpro" || echo "[WARN] HitmanPro download dispatch issue"
|
run_ps "$sf3" 300 70 "download-hitmanpro" || echo "[WARN] HitmanPro download dispatch issue"
|
||||||
|
|
||||||
# 5) seed EICAR into the Defender-excluded test folder (assembled on endpoint)
|
# 5) [OPTIONAL] seed EICAR into a Defender-excluded test folder (assembled on
|
||||||
local sf4="$WORK_DIR/eicar.ps1"
|
# the endpoint). DISABLED unless SEED_EICAR=1 -- retained so detection can be
|
||||||
cat > "$sf4" <<'PS'
|
# smoke-tested as new scanners are added to the chain.
|
||||||
|
if [ "$SEED_EICAR" = "1" ]; then
|
||||||
|
local sfe="$WORK_DIR/eicar.ps1"
|
||||||
|
cat > "$sfe" <<'PS'
|
||||||
$ErrorActionPreference='Continue'
|
$ErrorActionPreference='Continue'
|
||||||
$dir='C:\GuruScanTest'
|
$dir='C:\GuruScanTest'
|
||||||
if(-not (Test-Path $dir)){New-Item -ItemType Directory -Path $dir -Force|Out-Null}
|
if(-not (Test-Path $dir)){New-Item -ItemType Directory -Path $dir -Force|Out-Null}
|
||||||
|
try { Add-MpPreference -ExclusionPath $dir -ErrorAction Stop } catch {}
|
||||||
# Standard EICAR test signature, assembled from fragments so it is never stored
|
# Standard EICAR test signature, assembled from fragments so it is never stored
|
||||||
# contiguously anywhere except the on-disk test file.
|
# contiguously anywhere except the on-disk test file.
|
||||||
$e = 'X5O!P%@AP[4\PZX54(P^)7CC)7}' + '$EICAR' + '-STANDARD-ANTIVIRUS-' + 'TEST-FILE!$H+H*'
|
$e = 'X5O!P%@AP[4\PZX54(P^)7CC)7}' + '$EICAR' + '-STANDARD-ANTIVIRUS-' + 'TEST-FILE!$H+H*'
|
||||||
@@ -329,9 +337,12 @@ if(Test-Path $f){
|
|||||||
Write-Output "EICAR MISSING after write - Defender (or other AV) grabbed it despite exclusion"
|
Write-Output "EICAR MISSING after write - Defender (or other AV) grabbed it despite exclusion"
|
||||||
}
|
}
|
||||||
PS
|
PS
|
||||||
run_ps "$sf4" 60 24 "seed-eicar" || { _logerr "EICAR seed failed" --context "host=$AGENT_HOST"; return 1; }
|
run_ps "$sfe" 60 24 "seed-eicar" || { _logerr "EICAR seed failed" --context "host=$AGENT_HOST"; return 1; }
|
||||||
|
else
|
||||||
|
echo "[INFO] EICAR seeding disabled (set SEED_EICAR=1 to enable)"
|
||||||
|
fi
|
||||||
|
|
||||||
# 6) readiness check - confirm all three scanner binaries present + EICAR present
|
# 6) readiness check - confirm all three scanner binaries + module present
|
||||||
local sf5="$WORK_DIR/ready.ps1"
|
local sf5="$WORK_DIR/ready.ps1"
|
||||||
cat > "$sf5" <<'PS'
|
cat > "$sf5" <<'PS'
|
||||||
$ErrorActionPreference='Continue'
|
$ErrorActionPreference='Continue'
|
||||||
@@ -339,8 +350,7 @@ $need=@{
|
|||||||
'RKill' ='C:\GuruScan\downloads\rkill.exe';
|
'RKill' ='C:\GuruScan\downloads\rkill.exe';
|
||||||
'Emsisoft' ='C:\GuruScan\downloads\EmsisoftCommandlineScanner64.exe';
|
'Emsisoft' ='C:\GuruScan\downloads\EmsisoftCommandlineScanner64.exe';
|
||||||
'HitmanPro' ='C:\GuruScan\downloads\HitmanPro_x64.exe';
|
'HitmanPro' ='C:\GuruScan\downloads\HitmanPro_x64.exe';
|
||||||
'Module' ='C:\GuruScan\GuruScan.psm1';
|
'Module' ='C:\GuruScan\GuruScan.psm1'
|
||||||
'EICAR' ='C:\GuruScanTest\eicar_test.com'
|
|
||||||
}
|
}
|
||||||
$ok=$true
|
$ok=$true
|
||||||
foreach($k in $need.Keys){
|
foreach($k in $need.Keys){
|
||||||
@@ -353,7 +363,7 @@ PS
|
|||||||
if grep -q 'ALL-READY' "$WORK_DIR/last_result.json" 2>/dev/null || \
|
if grep -q 'ALL-READY' "$WORK_DIR/last_result.json" 2>/dev/null || \
|
||||||
echo "$(jq -r '.stdout' "$WORK_DIR/last_result.json" 2>/dev/null)" | grep -q 'ALL-READY'; then
|
echo "$(jq -r '.stdout' "$WORK_DIR/last_result.json" 2>/dev/null)" | grep -q 'ALL-READY'; then
|
||||||
echo "[OK] prep complete - endpoint is READY for scan"
|
echo "[OK] prep complete - endpoint is READY for scan"
|
||||||
post_alert "[RMM] GuruScan test: prepped $AGENT_HOST (module+3 scanners+EICAR staged)"
|
post_alert "[RMM] GuruScan test: prepped $AGENT_HOST (module+3 scanners staged)"
|
||||||
else
|
else
|
||||||
echo "[WARN] prep finished but not all components READY - review output above before scanning"
|
echo "[WARN] prep finished but not all components READY - review output above before scanning"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
# bash post-bot-alert.sh "message text" bot # force #bot-alerts
|
# bash post-bot-alert.sh "message text" bot # force #bot-alerts
|
||||||
# echo "message text" | bash post-bot-alert.sh
|
# echo "message text" | bash post-bot-alert.sh
|
||||||
#
|
#
|
||||||
|
# Identity: This script sources get-identity.sh, making $USER_SHORT, $USER_NAME,
|
||||||
|
# $MACHINE, $USER_EMAIL available. Callers can use these in messages for correct
|
||||||
|
# attribution (e.g., "[RMM] $USER_SHORT deployed..."). The script itself doesn't
|
||||||
|
# auto-inject identity - callers must explicitly use the vars when needed.
|
||||||
|
#
|
||||||
# Token resolution (first hit wins):
|
# Token resolution (first hit wins):
|
||||||
# 1. SOPS vault: projects/discord-bot/bot-token.sops.yaml field credentials.bot_token
|
# 1. SOPS vault: projects/discord-bot/bot-token.sops.yaml field credentials.bot_token
|
||||||
# 2. projects/discord-bot/.env key DISCORD_TOKEN
|
# 2. projects/discord-bot/.env key DISCORD_TOKEN
|
||||||
@@ -27,6 +32,9 @@ BOT_CHANNEL_ID="624710699771232265" # #bot-alerts — default (Syncro + gene
|
|||||||
DEV_CHANNEL_ID="1509998508198068484" # #dev-alerts — private RMM/Dev alerts (Howard + Mike only)
|
DEV_CHANNEL_ID="1509998508198068484" # #dev-alerts — private RMM/Dev alerts (Howard + Mike only)
|
||||||
ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
||||||
|
|
||||||
|
# Load identity for attribution (soft-fail if missing)
|
||||||
|
source "$ROOT/.claude/scripts/get-identity.sh" 2>/dev/null || true
|
||||||
|
|
||||||
# --- message (arg or stdin) ---
|
# --- message (arg or stdin) ---
|
||||||
MSG="${1:-}"
|
MSG="${1:-}"
|
||||||
if [ -z "$MSG" ] && [ ! -t 0 ]; then MSG="$(cat)"; fi
|
if [ -z "$MSG" ] && [ ! -t 0 ]; then MSG="$(cat)"; fi
|
||||||
|
|||||||
@@ -38,7 +38,20 @@ read_script() { # $1 = file path or "-"
|
|||||||
}
|
}
|
||||||
|
|
||||||
encode_b64() { # stdin: UTF-8 script -> stdout: UTF-16LE base64 (no BOM, no wraps)
|
encode_b64() { # stdin: UTF-8 script -> stdout: UTF-16LE base64 (no BOM, no wraps)
|
||||||
iconv -f UTF-8 -t UTF-16LE | base64 -w0
|
if command -v iconv >/dev/null 2>&1; then
|
||||||
|
iconv -f UTF-8 -t UTF-16LE | base64 -w0
|
||||||
|
else
|
||||||
|
# Git-for-Windows ships the iconv *library* (msys-iconv-2.dll) but NOT iconv.exe,
|
||||||
|
# and has no pacman to install it. Fall back to Python (present fleet-wide) for the
|
||||||
|
# UTF-16LE -> base64 encode. Binary stdin/stdout so no CRLF translation.
|
||||||
|
local py
|
||||||
|
py="$(command -v py || command -v python3 || command -v python || true)"
|
||||||
|
if [ -z "$py" ]; then
|
||||||
|
echo "[ERROR] neither iconv nor python available for UTF-16LE encoding" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
"$py" -c "import sys,base64; sys.stdout.write(base64.b64encode(sys.stdin.buffer.read().decode('utf-8').encode('utf-16-le')).decode())"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
size_gate() { # $1 = encoded string, $2 = force flag (0/1)
|
size_gate() { # $1 = encoded string, $2 = force flag (0/1)
|
||||||
|
|||||||
51
.claude/scripts/register-edr-watcher.ps1
Normal file
51
.claude/scripts/register-edr-watcher.ps1
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# register-edr-watcher.ps1
|
||||||
|
# Register the "ClaudeTools - EDR Isolation Watcher" scheduled task on this Windows
|
||||||
|
# machine (the same box that runs GPS-RMM-AutoEnroll et al). The task runs
|
||||||
|
# edr-isolation-watch.sh every 10 minutes, which polls Datto EDR for hosts auto-
|
||||||
|
# isolated by an EDR response and posts each NEW one to #dev-alerts (Mike + Howard).
|
||||||
|
#
|
||||||
|
# Interim alerting until the GuruRMM EDR webhook integration (Feature 6) lands.
|
||||||
|
# Idempotent: -Force replaces any existing task with the same name.
|
||||||
|
#
|
||||||
|
# Run from an ordinary (non-admin) PowerShell:
|
||||||
|
# powershell -ExecutionPolicy Bypass -File C:\claudetools\.claude\scripts\register-edr-watcher.ps1
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$TaskName = "ClaudeTools - EDR Isolation Watcher"
|
||||||
|
|
||||||
|
# Launch via a wscript.exe VBS wrapper (GUI-subsystem host) so bash starts hidden and
|
||||||
|
# NO console window flashes on the desktop every 10 min. Launching bash.exe directly is
|
||||||
|
# a console-subsystem app with LogonType=Interactive + Hidden=False -> visible flash.
|
||||||
|
# Mirrors the gps-rmm-progress-hidden.vbs fix. Do NOT revert to a raw bash.exe action.
|
||||||
|
$BashExe = "C:\Program Files\Git\bin\bash.exe"
|
||||||
|
if (-not (Test-Path $BashExe)) { Write-Host "[ERROR] Git bash not found at $BashExe" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
$ScriptWin = "C:\claudetools\.claude\scripts\edr-isolation-watch.sh"
|
||||||
|
if (-not (Test-Path $ScriptWin)) { Write-Host "[ERROR] Watcher not found at $ScriptWin" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
$WScript = "C:\Windows\System32\wscript.exe"
|
||||||
|
$Vbs = "C:\claudetools\.claude\scripts\edr-isolation-watch-hidden.vbs"
|
||||||
|
if (-not (Test-Path $Vbs)) { Write-Host "[ERROR] Hidden wrapper not found at $Vbs" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
$Action = New-ScheduledTaskAction -Execute $WScript -Argument "`"$Vbs`""
|
||||||
|
|
||||||
|
# Every 10 minutes, indefinitely (10-year duration avoids the MaxValue quirk).
|
||||||
|
$Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).Date `
|
||||||
|
-RepetitionInterval (New-TimeSpan -Minutes 10) `
|
||||||
|
-RepetitionDuration (New-TimeSpan -Days 3650)
|
||||||
|
|
||||||
|
# Run as the current user, only when logged on (needs the interactive vault/env,
|
||||||
|
# same posture as the GrepAI watcher). No admin required.
|
||||||
|
$Principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited
|
||||||
|
|
||||||
|
$Settings = New-ScheduledTaskSettingsSet `
|
||||||
|
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||||
|
-StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5) `
|
||||||
|
-MultipleInstances IgnoreNew -Hidden
|
||||||
|
|
||||||
|
Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger `
|
||||||
|
-Principal $Principal -Settings $Settings `
|
||||||
|
-Description "Polls Datto EDR every 10 min for auto-isolated hosts; posts new ones to #dev-alerts. Interim until GuruRMM EDR webhook (Feature 6)." -Force | Out-Null
|
||||||
|
|
||||||
|
Write-Host "[OK] Registered scheduled task: $TaskName (every 10 min)" -ForegroundColor Green
|
||||||
|
Get-ScheduledTask -TaskName $TaskName | Select-Object TaskName, State | Format-Table -AutoSize
|
||||||
@@ -47,14 +47,26 @@ if (-not (Test-Path $Script)) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Resolve the py launcher's full path (the action's Execute wants an absolute
|
# Resolve pythonW.exe (the GUI-subsystem Python host) so the detector runs with NO
|
||||||
# path; "py" alone usually resolves but we pin it for reliability under the
|
# console window flashing on the desktop at logon / every 4h. Using py.exe or
|
||||||
# Task Scheduler's environment).
|
# python.exe (console subsystem) draws a visible window each run. Prefer pythonw next
|
||||||
$PyCmd = Get-Command py -ErrorAction SilentlyContinue
|
# to the active interpreter; fall back to a PATH lookup, then py.exe as a last resort.
|
||||||
|
$PyPath = $null
|
||||||
|
$PyCmd = Get-Command py -ErrorAction SilentlyContinue
|
||||||
if ($null -ne $PyCmd) {
|
if ($null -ne $PyCmd) {
|
||||||
$PyPath = $PyCmd.Source
|
try {
|
||||||
} else {
|
$exe = (& py -c "import sys;print(sys.executable)").Trim()
|
||||||
$PyPath = "py" # fall back to PATH resolution at run time
|
$wexe = Join-Path (Split-Path $exe) "pythonw.exe"
|
||||||
|
if (Test-Path $wexe) { $PyPath = $wexe }
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
if ($null -eq $PyPath) {
|
||||||
|
$PwCmd = Get-Command pythonw -ErrorAction SilentlyContinue
|
||||||
|
if ($null -ne $PwCmd) { $PyPath = $PwCmd.Source }
|
||||||
|
}
|
||||||
|
if ($null -eq $PyPath) {
|
||||||
|
if ($null -ne $PyCmd) { $PyPath = $PyCmd.Source } else { $PyPath = "py" }
|
||||||
|
Write-Host "[WARNING] pythonw.exe not found; task may flash a console window." -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
$Action = New-ScheduledTaskAction `
|
$Action = New-ScheduledTaskAction `
|
||||||
@@ -76,7 +88,8 @@ $Settings = New-ScheduledTaskSettingsSet `
|
|||||||
-ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
|
-ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
|
||||||
-MultipleInstances IgnoreNew `
|
-MultipleInstances IgnoreNew `
|
||||||
-StartWhenAvailable `
|
-StartWhenAvailable `
|
||||||
-DontStopOnIdleEnd
|
-DontStopOnIdleEnd `
|
||||||
|
-Hidden
|
||||||
|
|
||||||
Register-ScheduledTask `
|
Register-ScheduledTask `
|
||||||
-TaskName $TaskName `
|
-TaskName $TaskName `
|
||||||
|
|||||||
82
.claude/scripts/syncro-env.sh
Normal file
82
.claude/scripts/syncro-env.sh
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# syncro-env.sh — Resolve the repo root and the caller's Syncro API key from the SOPS vault.
|
||||||
|
#
|
||||||
|
# Source this instead of hardcoding a repo path or an API key:
|
||||||
|
# source "$(dirname "${BASH_SOURCE[0]}")/syncro-env.sh" # or by absolute path
|
||||||
|
# curl -s "$SYNCRO_BASE/customers?api_key=$SYNCRO_API_KEY"
|
||||||
|
#
|
||||||
|
# Exports: CLAUDETOOLS_ROOT, VAULT_ROOT, SYNCRO_USER, SYNCRO_BASE, SYNCRO_API_KEY
|
||||||
|
#
|
||||||
|
# Root resolution mirrors vault.sh: derive from this script's own location, then let
|
||||||
|
# identity.json's claudetools_root override. Never hardcode a drive letter — the repo
|
||||||
|
# lives at C:/claudetools on some machines and D:/claudetools on others.
|
||||||
|
#
|
||||||
|
# Soft-fails like get-identity.sh: on any failure it warns, leaves SYNCRO_API_KEY empty,
|
||||||
|
# and returns 1 without exiting, so a sourcing skill degrades instead of dying.
|
||||||
|
|
||||||
|
_syncro_env_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
export CLAUDETOOLS_ROOT="$(cd "$_syncro_env_dir/../.." && pwd)"
|
||||||
|
_identity="$CLAUDETOOLS_ROOT/.claude/identity.json"
|
||||||
|
|
||||||
|
export SYNCRO_BASE="https://computerguru.syncromsp.com/api/v1"
|
||||||
|
export SYNCRO_API_KEY=""
|
||||||
|
export SYNCRO_USER=""
|
||||||
|
export VAULT_ROOT=""
|
||||||
|
|
||||||
|
if [ ! -f "$_identity" ]; then
|
||||||
|
echo "[WARNING] identity.json not found at $_identity — Syncro calls will be unauthenticated" >&2
|
||||||
|
return 1 2>/dev/null || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# identity.json is authoritative for both the repo root and the vault location.
|
||||||
|
_root_override=$(jq -r '.claudetools_root // empty' "$_identity" 2>/dev/null)
|
||||||
|
[ -n "$_root_override" ] && [ -d "$_root_override" ] && export CLAUDETOOLS_ROOT="$_root_override"
|
||||||
|
export VAULT_ROOT=$(jq -r '.vault_path // empty' "$_identity" 2>/dev/null)
|
||||||
|
export SYNCRO_USER=$(jq -r '.user // empty' "$_identity" 2>/dev/null)
|
||||||
|
|
||||||
|
# Per-user token — attribution in Syncro depends on using the right one.
|
||||||
|
# Map a user name to their vaulted key path; empty = no key vaulted for them.
|
||||||
|
_syncro_key_path() {
|
||||||
|
case "$1" in
|
||||||
|
mike) echo "msp-tools/syncro" ;;
|
||||||
|
howard) echo "msp-tools/syncro-howard" ;;
|
||||||
|
winter) echo "msp-tools/syncro-winter" ;;
|
||||||
|
*) echo "" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prefer the requester's key when running on someone's behalf (e.g. the Discord
|
||||||
|
# bot sets CLAUDETOOLS_REQUESTER_USER per thread) so Syncro attributes actions
|
||||||
|
# to the person who asked. Fall back to the identity.json user when the
|
||||||
|
# requester has no vaulted key (e.g. rob) — never hard-fail on the preference.
|
||||||
|
_vault_path=""
|
||||||
|
_requester="${CLAUDETOOLS_REQUESTER_USER:-}"
|
||||||
|
if [ -n "$_requester" ] && [ "$_requester" != "$SYNCRO_USER" ]; then
|
||||||
|
_req_path=$(_syncro_key_path "$_requester")
|
||||||
|
if [ -n "$_req_path" ]; then
|
||||||
|
_vault_path="$_req_path"
|
||||||
|
export SYNCRO_USER="$_requester"
|
||||||
|
echo "[INFO] Using Syncro key for requester '$_requester' (attribution)" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -z "$_vault_path" ] && _vault_path=$(_syncro_key_path "$SYNCRO_USER")
|
||||||
|
|
||||||
|
if [ -z "$_vault_path" ]; then
|
||||||
|
echo "[WARNING] No Syncro key vaulted for user '$SYNCRO_USER' — enrichment skipped" >&2
|
||||||
|
return 1 2>/dev/null || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SYNCRO_API_KEY=$(bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" \
|
||||||
|
get-field "$_vault_path" credentials.credential 2>/dev/null | tail -1)
|
||||||
|
export SYNCRO_API_KEY
|
||||||
|
|
||||||
|
if [ -z "$SYNCRO_API_KEY" ]; then
|
||||||
|
echo "[ERROR] Could not read $_vault_path from vault — is SOPS/age configured?" >&2
|
||||||
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/log-skill-error.sh" "syncro-env" \
|
||||||
|
"vault read failed for $_vault_path" --context "user=$SYNCRO_USER" >/dev/null 2>&1 || true
|
||||||
|
return 1 2>/dev/null || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
unset _syncro_env_dir _identity _root_override _vault_path _requester _req_path
|
||||||
|
unset -f _syncro_key_path
|
||||||
|
return 0 2>/dev/null || true
|
||||||
@@ -1,45 +1,68 @@
|
|||||||
---
|
---
|
||||||
name: agy
|
name: agy
|
||||||
description: "Route a task to the Google Gemini CLI for an independent second model (sibling of grok): different-vendor second opinion, adversarial verification, Gemini code review of files/diffs, one-shot text answers. Triggers: ask gemini, gemini verify, gemini review, agy."
|
description: "Route a task to the Google Antigravity CLI (agy) for an independent second model (sibling of grok): different-vendor second opinion, adversarial verification, code review of files/diffs, vision, live web search, one-shot text answers. Triggers: ask gemini, ask agy, agy verify, agy review, agy."
|
||||||
---
|
---
|
||||||
|
|
||||||
# AGY — Gemini capability router
|
# AGY — Antigravity CLI capability router
|
||||||
|
|
||||||
Claude shells out to the locally-installed **Google Gemini CLI** (`gemini`, npm
|
Claude shells out to the locally-installed **Google Antigravity CLI** (`agy`, a
|
||||||
global, v0.45.2) for a genuinely independent, different-vendor second model.
|
native Go binary at `~/AppData/Local/agy/bin/agy`, v1.0.16) for a genuinely
|
||||||
AGY is the sibling of [`grok`](../grok/SKILL.md): both are second-opinion /
|
independent, different-vendor second model. AGY is the sibling of
|
||||||
review routers. Use whichever you want a second model from (or both, to triangulate).
|
[`grok`](../grok/SKILL.md): both are second-opinion / review routers. Use whichever
|
||||||
Verified working on this machine (2026-06-05; re-validated 2026-06-17 against the
|
you want a second model from (or both, to triangulate). Modes verified on this
|
||||||
CLI's bundled help/README — JSON schema, all flags, pinned model, and live search
|
machine (2026-07-02): text, verify, review (single file / file set / git diff),
|
||||||
all confirmed): text, verify, review (single file / file set / git diff),
|
image-analyze (vision), search (live web search + source URLs).
|
||||||
image-analyze (vision input), search (live Google web search). All KEYLESS — they
|
|
||||||
work on Google OAuth, no API key.
|
|
||||||
|
|
||||||
**Auth:** Gemini uses **Google login (OAuth)** — **no API key**. Creds live at
|
> **REWIRED 2026-07-02 — `agy` replaces the old `gemini` npm CLI.** The former
|
||||||
`~/.gemini/oauth_creds.json`. If calls fail with an auth error, run `gemini`
|
> Google `gemini` CLI (npm `@google/gemini-cli`) stopped working on this account
|
||||||
interactively once and choose **"Login with Google"**, then retry.
|
> with a Cloud-project eligibility error (`throwIneligibleOrProjectIdError` — it
|
||||||
|
> demanded a `GOOGLE_CLOUD_PROJECT` the personal account couldn't provide). The
|
||||||
|
> Antigravity CLI (`agy`) uses Antigravity's OWN auth and works headless with no
|
||||||
|
> project ID. The wrapper is `scripts/ask-agy.sh`; `scripts/ask-gemini.sh` is kept
|
||||||
|
> as a thin **deprecated shim** that `exec`s ask-agy.sh so old references still work.
|
||||||
|
|
||||||
|
**Backend models (multi-vendor, `--model`):** `agy models` lists them — Gemini 3.5
|
||||||
|
Flash (Low/Medium/High), Gemini 3.1 Pro (Low/High), Claude Sonnet 4.6 (Thinking),
|
||||||
|
Claude Opus 4.6 (Thinking), GPT-OSS 120B. `text` uses the CLI default (Flash, fast);
|
||||||
|
`verify`/`review*`/`image-analyze`/`search` pin the strong model
|
||||||
|
`Gemini 3.1 Pro (High)`. Override any mode with `AGY_MODEL="<friendly name>"`.
|
||||||
|
|
||||||
|
**Auth:** Antigravity's own login (`~/.gemini/antigravity-cli/`) — **no API key, no
|
||||||
|
`GOOGLE_CLOUD_PROJECT`**. If a call fails with an auth error, run `agy` interactively
|
||||||
|
once to sign in, then retry.
|
||||||
|
|
||||||
## The wrapper
|
## The wrapper
|
||||||
|
|
||||||
```
|
```
|
||||||
bash "$CLAUDETOOLS_ROOT/.claude/skills/agy/scripts/ask-gemini.sh" <mode> ...
|
bash "$CLAUDETOOLS_ROOT/.claude/skills/agy/scripts/ask-agy.sh" <mode> ...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Authoritative headless flags (from `agy --help`, Go flag package — the web docs at
|
||||||
|
antigravity.google/docs/cli are JS-rendered SPAs and unreadable by fetch tools, so
|
||||||
|
`--help` is the source of truth): `-p/--print/--prompt` runs one prompt and prints
|
||||||
|
the response (**the prompt is the flag's VALUE and MUST be last** — a bare `-p --model`
|
||||||
|
makes "--model" the prompt); `--model "<name>"`; `--add-dir <dir>` (repeatable, gives
|
||||||
|
the file tools access to a review/vision target); `--dangerously-skip-permissions`
|
||||||
|
(auto-approve tool calls — without it print mode HANGS on an approval prompt);
|
||||||
|
`--print-timeout <dur>` (default 5m). There is **no JSON output flag** — stdout is
|
||||||
|
plain text/markdown, so the wrapper does no JSON parsing.
|
||||||
|
|
||||||
| Mode | Usage | What it does |
|
| Mode | Usage | What it does |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| `text` | `ask-gemini.sh text "<prompt>"` or `text --prompt-file <path>` | One-shot text answer from an independent model. `--prompt-file` for long content (review/summarize a doc). Default model routing. |
|
| `text` | `ask-agy.sh text "<prompt>"` or `text --prompt-file <path>` | One-shot text answer from an independent model. `--prompt-file` for long content (review/summarize a doc). Default model routing. |
|
||||||
| `verify` | `ask-gemini.sh verify "<claim/finding>"` or `verify --prompt-file <path>` | Adversarial second opinion — Gemini tries to REFUTE / find gaps, returns a verdict + reasons. Pinned to the strong model. |
|
| `verify` | `ask-agy.sh verify "<claim/finding>"` or `verify --prompt-file <path>` | Adversarial second opinion — Gemini tries to REFUTE / find gaps, returns a verdict + reasons. Pinned to the strong model. |
|
||||||
| `review` | `ask-gemini.sh review <file-path> ["<instructions>"]` | Gemini reads the file itself (its `read_file` tool, read-only `plan` mode) and reviews it. Path resolution: absolute, CWD-relative, or relative to `$CLAUDETOOLS_ROOT` — **see the path gotcha below**. Spaces OK. Works even on gitignored files. |
|
| `review` | `ask-agy.sh review <file-path> ["<instructions>"]` | agy reads the file itself (copied into an `--add-dir` workspace) and reviews it. Path resolution: absolute, CWD-relative, or relative to `$CLAUDETOOLS_ROOT` — **see the path gotcha below**. Spaces OK. Works even on gitignored files. |
|
||||||
| `review-files` | `ask-gemini.sh review-files [-i "<instr>"] <f1> [f2 …]` | Review a **set** of files together (cross-file consistency, multi-file change). Same path resolution as `review` (**see gotcha below**); spaces OK. No code passed as a shell arg. |
|
| `review-files` | `ask-agy.sh review-files [-i "<instr>"] <f1> [f2 …]` | Review a **set** of files together (cross-file consistency, multi-file change). Same path resolution as `review` (**see gotcha below**); spaces OK. No code passed as a shell arg. |
|
||||||
| `review-diff` | `ask-gemini.sh review-diff [-C <repo-dir>] [-i "<instr>"] <gitref> [-- <pathspec>]` | Review a **git diff** (`git diff <gitref>` from `<repo-dir>`; default repo root, use `-C` for a submodule e.g. `-C projects/msp-tools/guru-rmm`). Diff goes via the prompt file; Gemini can `read_file` changed files for full context. |
|
| `review-diff` | `ask-agy.sh review-diff [-C <repo-dir>] [-i "<instr>"] <gitref> [-- <pathspec>]` | Review a **git diff** (`git diff <gitref>` from `<repo-dir>`; default repo root, use `-C` for a submodule e.g. `-C projects/msp-tools/guru-rmm`). Diff goes via the prompt file; Gemini can `read_file` changed files for full context. |
|
||||||
| `image-analyze` | `ask-gemini.sh image-analyze <image-path> ["<question>"]` | **Vision** — Gemini `read_file`s the image and describes/answers about it. Pins the **pro vision model** (the default flash-lite router hallucinates image content). Path absolute or repo-relative; spaces OK. KEYLESS (works on OAuth). |
|
| `image-analyze` | `ask-agy.sh image-analyze <image-path> ["<question>"]` | **Vision** — agy reads the image (via `--add-dir`) and describes/answers about it. Pinned to the strong model. Path absolute or repo-relative; spaces OK. |
|
||||||
| `search` | `ask-gemini.sh search "<query>"` (or `search --prompt-file <path>`) | **Live Google web search** (sibling of `grok xsearch`) — Gemini uses its `google_web_search` tool and returns the answer **with source URLs**. KEYLESS (works on OAuth). |
|
| `search` | `ask-agy.sh search "<query>"` (or `search --prompt-file <path>`) | **Live web search** (sibling of `grok xsearch`) — agy searches the web and returns the answer **with source URLs**. |
|
||||||
| `raw` | `ask-gemini.sh raw <gemini args...>` | Escape hatch — passes args straight to `gemini`. |
|
| `raw` | `ask-agy.sh raw <agy args...>` | Escape hatch — passes args straight to `agy`. |
|
||||||
|
|
||||||
The script runs Gemini headless with `-o json`, extracts the answer from
|
The script runs `agy` headless (`--dangerously-skip-permissions ... -p "<prompt>"`),
|
||||||
`.response` (parsing from the first `{` so the CLI's cosmetic warning lines are
|
captures plain-text stdout (no JSON — agy has no structured-output mode), keeps stderr
|
||||||
ignored), and keeps stderr separate from the JSON so 429-backoff / warning noise
|
separate for diagnostics, and retries a couple times with backoff on an empty turn.
|
||||||
never corrupts the parse.
|
For review/vision it copies the target into a temp dir and `--add-dir`s it so agy's
|
||||||
|
file tools can read it regardless of location/spaces.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> **Path gotcha for `review` / `review-files` (this has bitten us repeatedly).**
|
> **Path gotcha for `review` / `review-files` (this has bitten us repeatedly).**
|
||||||
@@ -55,96 +78,84 @@ never corrupts the parse.
|
|||||||
|
|
||||||
### Model
|
### Model
|
||||||
|
|
||||||
- `text` uses Gemini's **default routing** (currently a flash-tier model) — fast, cheap.
|
- `text` uses the CLI **default** (Gemini 3.5 Flash) — fast, cheap.
|
||||||
- `verify` / `review*` pin a **strong** model — `gemini-3.1-pro-preview` (verified
|
- `verify` / `review*` / `image-analyze` / `search` pin the strong model
|
||||||
available 2026-06-05, still valid 2026-06-17; the GA-looking `gemini-3.1-pro` and
|
**`Gemini 3.1 Pro (High)`** via `--model`.
|
||||||
`gemini-3-pro` both `ModelNotFoundError`, so keep the `-preview` suffix).
|
- Override any mode with `AGY_MODEL="<friendly name>"` (exact strings from
|
||||||
- Override either with `GEMINI_MODEL=<id>` (e.g. `GEMINI_MODEL=gemini-2.5-pro`).
|
`agy models`, e.g. `AGY_MODEL="Claude Opus 4.6 (Thinking)"`).
|
||||||
- `image-analyze` and `search` also pin the strong model (`GEMINI_MODEL` still honored).
|
|
||||||
|
|
||||||
### Multimodal: image INPUT works, image GENERATION does not
|
### Vision
|
||||||
|
|
||||||
- **Image INPUT (vision) works on OAuth** — `image-analyze` reads an image with the
|
`image-analyze` copies the image into an `--add-dir` workspace and asks agy to read
|
||||||
pinned **pro vision model** and describes it correctly. The default flash-lite
|
and describe it (pinned to the strong model). Report-only — describes an image you
|
||||||
router HALLUCINATES image content, which is why the pro model is pinned.
|
give it. Image/video **generation** stays [GROK](../grok/SKILL.md)'s lane
|
||||||
- **Image GENERATION (nano-banana) does NOT work on OAuth** — it needs a Google AI
|
(`grok image` / `grok video`).
|
||||||
Studio `NANOBANANA_API_KEY` plus the `nanobanana` extension. **Deferred** for now.
|
|
||||||
Image/video **generation** stays [GROK](../grok/SKILL.md)'s lane (`grok image` /
|
|
||||||
`grok video`); AGY's multimodal support is read/analyze only.
|
|
||||||
|
|
||||||
## Machine availability (fleet)
|
## Machine availability (fleet)
|
||||||
|
|
||||||
AGY is **per-machine** — the skill syncs fleet-wide but the `gemini` binary does
|
AGY is **per-machine** — the skill syncs fleet-wide but the `agy` binary does not.
|
||||||
not. Availability is gated by `identity.json` (per-machine, gitignored):
|
Availability is gated by `identity.json` (per-machine, gitignored):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"gemini": { "installed": true,
|
"agy": { "installed": true,
|
||||||
"binary": "C:/Users/guru/AppData/Roaming/npm/gemini",
|
"binary": "C:/Users/guru/AppData/Local/agy/bin/agy",
|
||||||
"auth": "oauth", "is_fleet_host": true,
|
"auth": "antigravity", "is_fleet_host": true,
|
||||||
"capabilities": ["text","verify","review","image-analyze","search"] }
|
"capabilities": ["text","verify","review","review-files","review-diff","image-analyze","search"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
- If `gemini.installed` is `false` (or the block is absent), `ask-gemini.sh` exits
|
- If `agy.installed` is `false` (or the block is absent), `ask-agy.sh` exits **3**
|
||||||
**3** with routing guidance instead of failing obscurely. Claude on such a
|
with routing guidance instead of failing obscurely. Claude on such a machine
|
||||||
machine should NOT attempt local Gemini.
|
should NOT attempt local agy. (The wrapper falls back to the legacy `.gemini`
|
||||||
- **Fleet Gemini hosts: `GURU-5070`, `GURU-BEAST-ROG`** — machines with the Gemini
|
block if `.agy` is absent, for machines mid-migration.)
|
||||||
CLI installed and Google-OAuth'd. When others get it, install
|
- **Fleet host: `GURU-5070`** (agy installed + Antigravity-authed). When others get
|
||||||
`@google/gemini-cli`, run `gemini` once to log in with Google, then set their
|
it, install the Antigravity CLI, run `agy` once to sign in, then set their
|
||||||
`identity.json` `gemini` block (and update this line).
|
`identity.json` `agy` block (and update this line).
|
||||||
|
|
||||||
**Remote routing (NOT yet wired):** a non-host machine cannot run Gemini locally.
|
**Remote routing (NOT yet wired):** a non-host machine cannot run agy locally. Route
|
||||||
To fulfill an AGY request from elsewhere, route it to the host (`GURU-5070`) —
|
the request to the host (`GURU-5070`) — same pending channels as Grok (GuruRMM agent
|
||||||
same pending channels as Grok (GuruRMM agent exec, a relay, or a coord-API job
|
exec, a relay, or a coord-API job queue). Until built, AGY requests originate on the host.
|
||||||
queue). Until that's built, AGY requests originate on the host machine.
|
|
||||||
|
|
||||||
## When to route to Gemini (AGY)
|
## When to route to AGY
|
||||||
|
|
||||||
- **Independent verification** — a genuinely different vendor/model to red-team a
|
- **Independent verification** — a different vendor/model to red-team a Claude
|
||||||
Claude finding or design before acting on it. (`verify`)
|
finding or design before acting on it. (`verify`)
|
||||||
- **Second-model code review** — have Gemini read and critique a file, a set of
|
- **Second-model code review** — agy reads and critiques a file, a set, or a diff
|
||||||
files, or a diff independently of Claude. (`review`, `review-files`, `review-diff`)
|
independently of Claude. (`review`, `review-files`, `review-diff`)
|
||||||
- **Diverse drafts / second opinion** — alternative phrasing or approach to
|
- **Diverse drafts / second opinion** — alternative phrasing or approach. (`text`)
|
||||||
compare. (`text`)
|
- **Live web facts** — current info past Claude's cutoff, with source URLs. (`search`)
|
||||||
- **Google-ecosystem reach** — when a Google-side model/behavior is specifically
|
|
||||||
wanted as the comparison point.
|
|
||||||
|
|
||||||
AGY and [GROK](../grok/SKILL.md) are sibling second-opinion routers. Pick one, or
|
AGY and [GROK](../grok/SKILL.md) are sibling second-opinion routers. Pick one, or run
|
||||||
run both and compare — disagreement between them is a strong signal to slow down.
|
both and compare — disagreement between them is a strong signal to slow down.
|
||||||
|
|
||||||
## When NOT to
|
## When NOT to
|
||||||
|
|
||||||
- Pure classify / extract / summarize → cheaper via Tier-0 Ollama (`.claude/OLLAMA.md`).
|
- Pure classify / extract / summarize → cheaper via Tier-0 Ollama (`.claude/OLLAMA.md`).
|
||||||
- Editing this repo's code → Claude's own agents own the codebase work. Gemini's
|
- Editing this repo's code → Claude's own agents own the codebase work. The wrapper
|
||||||
`review*` modes are read-only (`--approval-mode plan`) by design; do not give
|
runs agy read-only in intent (review prompts say "do not modify anything"); do not
|
||||||
Gemini write access to this repo.
|
point it at write tasks in this repo.
|
||||||
- Image / video **generation** → that's GROK's lane (`grok image` / `grok video`),
|
- Image / video **generation** → GROK's lane. AGY vision is read/analyze only.
|
||||||
not Gemini here (nano-banana needs an API key — deferred). Gemini CAN analyze an
|
- **Never** delegate unsupervised destructive / production actions to agy. Always
|
||||||
image you give it (`image-analyze`, vision input on OAuth).
|
review its output before acting — like Grok, it can over-claim.
|
||||||
- **Never** delegate unsupervised destructive / production actions to Gemini.
|
|
||||||
Always review Gemini output before acting on it — like Grok, it can over-claim.
|
|
||||||
|
|
||||||
## Safety / operational notes
|
## Safety / operational notes
|
||||||
|
|
||||||
- `--skip-trust` is REQUIRED for headless runs (the CWD isn't a Gemini "trusted
|
- `--dangerously-skip-permissions` is passed so print mode never HANGS on a tool
|
||||||
folder"). Equivalent env: `GEMINI_CLI_TRUST_WORKSPACE=true`. The wrapper passes it.
|
approval prompt (headless). It only lets agy run its own read/search tools inside
|
||||||
- `review*` runs under `--approval-mode plan` (read-only): Gemini can read files
|
its sandbox to answer — the wrapper never asks it to write to this repo.
|
||||||
but cannot modify anything. Do not change this to `auto_edit`/`yolo`.
|
- The prompt is passed as the VALUE of `-p` and MUST be last on the command line;
|
||||||
- Gemini's `read_file` honors `.gitignore` **and** a workspace sandbox (only files
|
all other flags (`--model`, `--add-dir`, `--print-timeout`) come before it.
|
||||||
inside the workspace are readable). The wrapper sidesteps both by copying each
|
- Prompts are built in a temp file and read with `-p "$(cat <file>)"` (avoids
|
||||||
review target into a temp dir added via `--include-directories` — so review
|
quote hell); stdin is closed (`</dev/null`) so `-p` never waits on stdin.
|
||||||
works for tracked, gitignored, and spaced-path files alike.
|
- Output is plain text/markdown. Tool-using turns (review/search/vision) may emit a
|
||||||
- Prompts are passed via `-p "$(cat <prompt-file>)"` built from a temp file, not
|
line or two of tool narration before the answer; the prompts ask agy to suppress
|
||||||
inline shell args (avoids quote hell with long/structured content).
|
it, and residual narration is harmless.
|
||||||
- stdin is always closed (`</dev/null`) so `-p` never hangs waiting on stdin.
|
|
||||||
- stdout carries two cosmetic warning lines ("True color (24-bit) support not
|
|
||||||
detected", "Ripgrep is not available...") before output; JSON extraction from
|
|
||||||
the first `{` ignores them. A transient `429 No capacity` backoff may appear on
|
|
||||||
**stderr** and self-recovers — it does not affect the parsed answer.
|
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
- Binary: npm global `gemini` (`C:/Users/guru/AppData/Roaming/npm/gemini` on the
|
- Binary: native Go `agy` (`C:/Users/guru/AppData/Local/agy/bin/agy`; on PATH). The
|
||||||
host; the npm global dir is on PATH). The wrapper auto-locates it or honors `GEMINI=`.
|
wrapper auto-locates it, honors `AGY=`, or reads `identity.json` `agy.binary`.
|
||||||
- Version 0.45.2. Auth: Google OAuth (`~/.gemini/oauth_creds.json`), no API key.
|
- Version 1.0.16. Auth: Antigravity login (`~/.gemini/antigravity-cli/`), no API key,
|
||||||
- Headless contract: `gemini -p "<prompt>" -o json --skip-trust </dev/null` →
|
no `GOOGLE_CLOUD_PROJECT`.
|
||||||
`{session_id, response, stats}`; answer is `.response`.
|
- Headless contract: `agy [--model "<name>"] [--add-dir <dir>] --dangerously-skip-permissions -p "<prompt>"` → plain-text answer on stdout (no JSON).
|
||||||
|
- Migration: replaced the `gemini` npm CLI 2026-07-02 (that CLI hit
|
||||||
|
`throwIneligibleOrProjectIdError`). `ask-gemini.sh` is a deprecated shim → `ask-agy.sh`.
|
||||||
- Sibling router: [`grok`](../grok/SKILL.md) (image/video/live-data + second opinion).
|
- Sibling router: [`grok`](../grok/SKILL.md) (image/video/live-data + second opinion).
|
||||||
|
|||||||
280
.claude/skills/agy/scripts/ask-agy.sh
Normal file
280
.claude/skills/agy/scripts/ask-agy.sh
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ask-agy.sh — Claude -> Google Antigravity CLI (`agy`) router (independent second model).
|
||||||
|
#
|
||||||
|
# Sibling of ask-grok.sh. Routes a task to the Antigravity CLI (`agy`, a native Go
|
||||||
|
# binary at ~/AppData/Local/agy/bin/agy) for an independent, different-vendor second
|
||||||
|
# opinion, verification, code review, vision, or live web search. Multi-model backend
|
||||||
|
# (Gemini 3.5 Flash / Gemini 3.1 Pro / Claude / GPT-OSS) selectable via --model.
|
||||||
|
#
|
||||||
|
# REPLACES the old `gemini` npm CLI (ask-gemini.sh), which broke on this account with
|
||||||
|
# a Cloud-project eligibility error (throwIneligibleOrProjectIdError). `agy` uses
|
||||||
|
# Antigravity's own auth (~/.gemini/antigravity-cli/), no GOOGLE_CLOUD_PROJECT needed.
|
||||||
|
#
|
||||||
|
# Authoritative flag reference (from `agy --help`, Go flag package — 2026-07-02, v1.0.16):
|
||||||
|
# -p | --print | --prompt Run ONE prompt non-interactively and print the response.
|
||||||
|
# STRING flag: the prompt is its VALUE. MUST be LAST on the
|
||||||
|
# line (a bare `-p --model` makes "--model" the prompt).
|
||||||
|
# --model "<name>" Model for the session. Friendly names from `agy models`,
|
||||||
|
# e.g. "Gemini 3.1 Pro (High)", "Gemini 3.5 Flash (Medium)",
|
||||||
|
# "Claude Opus 4.6 (Thinking)". Omit -> CLI default (Flash).
|
||||||
|
# --add-dir <dir> Add a directory to the workspace (repeatable). Needed so
|
||||||
|
# the agent's file tools can read a review/vision target.
|
||||||
|
# --dangerously-skip-permissions Auto-approve tool calls (else print mode can HANG
|
||||||
|
# on an approval prompt until --print-timeout). Headless-safe.
|
||||||
|
# --print-timeout <dur> Print-mode wait timeout (default 5m0s). We also wrap in the
|
||||||
|
# shell `timeout` as a hard belt-and-suspenders bound.
|
||||||
|
# (There is NO JSON/structured-output flag — stdout is plain text/markdown.)
|
||||||
|
#
|
||||||
|
# Output contract: plain text on stdout. Tool-using turns (review/search/vision) may
|
||||||
|
# emit a line or two of tool narration ("I will view the content of ...") before the
|
||||||
|
# answer; we ask the model to suppress it but tolerate residual. No JSON parsing.
|
||||||
|
#
|
||||||
|
# Usage (unchanged from the old skill so callers/SKILL.md stay valid):
|
||||||
|
# ask-agy.sh text "<prompt>" # one-shot answer
|
||||||
|
# ask-agy.sh text --prompt-file <path> # long content
|
||||||
|
# ask-agy.sh verify "<claim or finding to refute>" # adversarial check
|
||||||
|
# ask-agy.sh verify --prompt-file <path>
|
||||||
|
# ask-agy.sh review <file> [instructions] # agy reads + reviews one file
|
||||||
|
# ask-agy.sh review-files [-i "instr"] <f1> [f2 ...] # review a SET together
|
||||||
|
# ask-agy.sh review-diff [-C <repo-dir>] [-i "instr"] <gitref> [-- <pathspec>]
|
||||||
|
# ask-agy.sh image-analyze <image-path> ["question"] # vision: read image + describe
|
||||||
|
# ask-agy.sh search "<query>" # live web search + sources
|
||||||
|
# ask-agy.sh raw <agy args...> # escape hatch
|
||||||
|
#
|
||||||
|
# Exit: 0 ok, 1 no result, 2 usage, 3 not installed here, 127 agy not found.
|
||||||
|
set -uo pipefail
|
||||||
|
SELF="ask-agy"
|
||||||
|
|
||||||
|
# --- path conversion: native-Windows path for agy args (no-op off Windows) ---
|
||||||
|
# agy is a native Windows binary; Git Bash hands it POSIX paths it cannot resolve.
|
||||||
|
if command -v cygpath >/dev/null 2>&1; then
|
||||||
|
winpath() { cygpath -w -- "$1" 2>/dev/null || printf '%s' "$1"; }
|
||||||
|
else
|
||||||
|
winpath() { printf '%s' "$1"; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- identity.json (per-machine, gitignored): is agy installed here? ---
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)"
|
||||||
|
PYBIN="$(command -v py 2>/dev/null || command -v python 2>/dev/null || command -v python3 2>/dev/null || true)"
|
||||||
|
IDFILE=""
|
||||||
|
[ -n "${CLAUDETOOLS_ROOT:-}" ] && [ -f "$CLAUDETOOLS_ROOT/.claude/identity.json" ] && IDFILE="$CLAUDETOOLS_ROOT/.claude/identity.json"
|
||||||
|
[ -z "$IDFILE" ] && IDFILE="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd)/identity.json"
|
||||||
|
idagy() { # read field $1 from identity.json .agy (fallback .gemini), empty if absent
|
||||||
|
[ -f "$IDFILE" ] || { echo ""; return; }
|
||||||
|
[ -n "$PYBIN" ] || { echo ""; return; }
|
||||||
|
"$PYBIN" -c "import json,sys
|
||||||
|
try:
|
||||||
|
d=json.load(sys.stdin); g=(d.get('agy') or d.get('gemini') or {}); v=g.get('$1','')
|
||||||
|
print('' if v is None else (str(v).lower() if isinstance(v,bool) else v))
|
||||||
|
except Exception: print('')" < "$IDFILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$(idagy installed)" = "false" ]; then
|
||||||
|
echo "[$SELF] agy is not installed on this machine (identity.json agy.installed=false)." >&2
|
||||||
|
echo "[$SELF] Antigravity CLI runs only on the fleet host. Route this request there, or install agy + set identity.json agy.installed=true." >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- locate the agy binary: AGY env > identity.json agy.binary > PATH > known paths ---
|
||||||
|
AGY="${AGY:-}"
|
||||||
|
if [ -n "$AGY" ] && [ ! -x "$AGY" ] && ! command -v "$AGY" >/dev/null 2>&1; then
|
||||||
|
echo "[$SELF] AGY='$AGY' is not an executable agy binary." >&2; exit 127
|
||||||
|
fi
|
||||||
|
cand="$(idagy binary)"
|
||||||
|
[ -z "$AGY" ] && [ -n "$cand" ] && [ -x "$cand" ] && AGY="$cand"
|
||||||
|
if [ -z "$AGY" ]; then
|
||||||
|
if command -v agy >/dev/null 2>&1; then AGY="$(command -v agy)"; else
|
||||||
|
for c in "/c/Users/${USERNAME:-${USER:-x}}/AppData/Local/agy/bin/agy" \
|
||||||
|
"$HOME/AppData/Local/agy/bin/agy" "$LOCALAPPDATA/agy/bin/agy" \
|
||||||
|
"/usr/local/bin/agy" "$HOME/.local/bin/agy"; do
|
||||||
|
[ -n "$c" ] && [ -x "$c" ] && { AGY="$c"; break; }
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -z "$AGY" ] && { echo "[$SELF] agy CLI not found (set identity.json agy.binary, AGY=, or install the Antigravity CLI)" >&2; exit 127; }
|
||||||
|
|
||||||
|
# Strong model for verify/review*/vision/search; text uses the CLI default (Flash).
|
||||||
|
STRONG_MODEL="${AGY_MODEL:-Gemini 3.1 Pro (High)}"
|
||||||
|
|
||||||
|
MODE="${1:-}"; shift 2>/dev/null || true
|
||||||
|
[ -z "$MODE" ] && { echo "usage: $SELF {text|verify|review|review-files|review-diff|image-analyze|search|raw} ..." >&2; exit 2; }
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||||
|
PF="$TMP/prompt.txt"; OUT="$TMP/out.txt"; ERR="$TMP/err.txt"
|
||||||
|
REPO_ROOT="${CLAUDETOOLS_ROOT:-$(cd "$SCRIPT_DIR/../../../.." 2>/dev/null && pwd)}"
|
||||||
|
_logerr() { bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "agy" "$@" >/dev/null 2>&1 || true; }
|
||||||
|
|
||||||
|
TIMEOUT_CMD="timeout"
|
||||||
|
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||||||
|
TIMEOUT_CMD="$(command -v gtimeout 2>/dev/null || echo timeout)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# run agy headless. $1 = hard timeout secs; remaining args = flags placed BEFORE -p.
|
||||||
|
# The prompt (from $PF) is always the LAST token, as the value of -p. agy has no JSON
|
||||||
|
# mode, so stdout is the answer (plain text); stderr kept separate for diagnostics.
|
||||||
|
LAST_FLAGS=()
|
||||||
|
run_agy() {
|
||||||
|
local to="$1"; shift
|
||||||
|
LAST_FLAGS=("$@")
|
||||||
|
"$TIMEOUT_CMD" "$to" "$AGY" "$@" --print-timeout "${to}s" -p "$(cat "$PF")" \
|
||||||
|
>"$OUT" 2>"$ERR" </dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# agy occasionally returns an empty turn; retry a couple times with backoff, then fail.
|
||||||
|
emit_or_fail() {
|
||||||
|
local txt tries=0 max="${AGY_MAX_TRIES:-3}"
|
||||||
|
txt="$(sed -e 's/[[:space:]]*$//' "$OUT" | sed -e :a -e '/^\s*$/{$d;N;ba}')"
|
||||||
|
while [ -z "${txt//[[:space:]]/}" ] && [ "$tries" -lt $((max-1)) ] && [ ${#LAST_FLAGS[@]} -ge 0 ]; do
|
||||||
|
tries=$((tries+1))
|
||||||
|
echo "[$SELF] empty response - retry $tries/$((max-1)) (backoff ${tries}x3s)..." >&2
|
||||||
|
sleep $((tries*3))
|
||||||
|
run_agy 240 "${LAST_FLAGS[@]}"
|
||||||
|
txt="$(cat "$OUT")"
|
||||||
|
done
|
||||||
|
if [ -n "${txt//[[:space:]]/}" ]; then cat "$OUT"; return 0; fi
|
||||||
|
if grep -qiE 'not authenticated|please (log|sign).?in|auth(entication)? (failed|error|required)|token (has )?expired|unauthorized' "$ERR" 2>/dev/null; then
|
||||||
|
echo "[$SELF] agy auth error - run 'agy' interactively once to sign in, then retry." >&2
|
||||||
|
_logerr "agy auth/login failure" --context "mode=$MODE"
|
||||||
|
else
|
||||||
|
echo "[$SELF] no response from agy after $max attempts. stderr tail:" >&2
|
||||||
|
tail -3 "$ERR" >&2 2>/dev/null || true
|
||||||
|
_logerr "agy returned no response (empty after $max attempts)" --context "mode=$MODE err=$(tail -1 "$ERR" 2>/dev/null | tr -d '\n' | cut -c1-80)"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copy review/vision targets into an included workspace dir so agy's file tools can
|
||||||
|
# reach them regardless of location/spaces; --add-dir this dir.
|
||||||
|
INCLUDE_DIR="$TMP/inbox"
|
||||||
|
prep_includes() { mkdir -p "$INCLUDE_DIR"; }
|
||||||
|
|
||||||
|
NO_TOOLS='Do not use any tools; answer directly in text.'
|
||||||
|
NO_NARRATE='Do not narrate your tool use or steps; output only the final answer.'
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
text|verify)
|
||||||
|
SRC=""
|
||||||
|
if [ "${1:-}" = "--prompt-file" ]; then
|
||||||
|
[ -f "${2:-}" ] || { echo "[$SELF] prompt file not found: ${2:-}" >&2; exit 2; }
|
||||||
|
SRC="$(cat "$2")"
|
||||||
|
else
|
||||||
|
SRC="${1:-}"
|
||||||
|
fi
|
||||||
|
[ -z "$SRC" ] && { echo "usage: $SELF $MODE \"<prompt>\" | $SELF $MODE --prompt-file <path>" >&2; exit 2; }
|
||||||
|
if [ "$MODE" = "verify" ]; then
|
||||||
|
printf 'You are an adversarial reviewer giving an independent second opinion. Evaluate the following claim/finding/document: try hard to find any way it is WRONG, incomplete, unsupported, or overstated. Then give a clear VERDICT (correct / partly correct / incorrect) plus specific justification. %s\n\nContent:\n%s' "$NO_TOOLS" "$SRC" > "$PF"
|
||||||
|
run_agy 240 --dangerously-skip-permissions --model "$STRONG_MODEL"
|
||||||
|
else
|
||||||
|
printf '%s\n\n%s' "$NO_TOOLS" "$SRC" > "$PF"
|
||||||
|
run_agy 240 --dangerously-skip-permissions
|
||||||
|
fi
|
||||||
|
emit_or_fail
|
||||||
|
;;
|
||||||
|
|
||||||
|
review|file)
|
||||||
|
[ -z "${1:-}" ] && { echo "usage: $SELF review <file-path> [instructions]" >&2; exit 2; }
|
||||||
|
target="$1"
|
||||||
|
instr="${2:-Give an independent, critical review of this file: accuracy, gaps/omissions, bugs, and concrete improvements. Be specific.}"
|
||||||
|
if [ -f "$target" ]; then resolved="$target"
|
||||||
|
elif [ -f "$REPO_ROOT/$target" ]; then resolved="$REPO_ROOT/$target"
|
||||||
|
else echo "[$SELF] file not found: $target" >&2; exit 2; fi
|
||||||
|
prep_includes
|
||||||
|
base="$(basename "$resolved")"; cp -f "$resolved" "$INCLUDE_DIR/$base"
|
||||||
|
tgt_win="$(winpath "$INCLUDE_DIR/$base")"; inc_win="$(winpath "$INCLUDE_DIR")"
|
||||||
|
printf 'Read the file at this absolute path, then perform the task and stop. Do not modify anything. %s\nPath: %s\n\nTask: %s' "$NO_NARRATE" "$tgt_win" "$instr" > "$PF"
|
||||||
|
run_agy 300 --dangerously-skip-permissions --model "$STRONG_MODEL" --add-dir "$inc_win"
|
||||||
|
emit_or_fail
|
||||||
|
;;
|
||||||
|
|
||||||
|
review-files)
|
||||||
|
instr='Independently review these files together as a unit: correctness/bugs, gaps, cross-file consistency, and concrete improvements. Be specific and cite file:line.'
|
||||||
|
files=()
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-i|--instr) instr="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||||
|
*) files+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ ${#files[@]} -eq 0 ] && { echo "usage: $SELF review-files [-i \"instructions\"] <file> [file ...]" >&2; exit 2; }
|
||||||
|
prep_includes
|
||||||
|
list=""; declare -A seen=()
|
||||||
|
for f in "${files[@]}"; do
|
||||||
|
if [ -f "$f" ]; then r="$f"
|
||||||
|
elif [ -f "$REPO_ROOT/$f" ]; then r="$REPO_ROOT/$f"
|
||||||
|
else echo "[$SELF] file not found: $f" >&2; exit 2; fi
|
||||||
|
base="$(basename "$r")"
|
||||||
|
if [ -n "${seen[$base]:-}" ]; then
|
||||||
|
n=1; while [ -e "$INCLUDE_DIR/${n}_${base}" ]; do n=$((n+1)); done; base="${n}_${base}"
|
||||||
|
fi
|
||||||
|
seen[$base]=1; cp -f "$r" "$INCLUDE_DIR/$base"
|
||||||
|
list+="- $(winpath "$INCLUDE_DIR/$base")
|
||||||
|
"
|
||||||
|
done
|
||||||
|
inc_win="$(winpath "$INCLUDE_DIR")"
|
||||||
|
printf 'Read EACH of these files (absolute paths), then perform the task across ALL of them and stop. Do not modify anything. %s\n\nFiles:\n%s\nTask: %s' "$NO_NARRATE" "$list" "$instr" > "$PF"
|
||||||
|
run_agy 360 --dangerously-skip-permissions --model "$STRONG_MODEL" --add-dir "$inc_win"
|
||||||
|
emit_or_fail
|
||||||
|
;;
|
||||||
|
|
||||||
|
review-diff)
|
||||||
|
gdir="$REPO_ROOT"
|
||||||
|
instr='Review this git diff: correctness/bugs introduced, regressions, missing edge cases, and concrete fixes. Focus on the CHANGES. Be specific and cite file:line.'
|
||||||
|
ref=""; pathspec=()
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-C|--dir) gdir="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||||
|
-i|--instr) instr="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||||
|
--) shift; while [ $# -gt 0 ]; do pathspec+=("$1"); shift; done ;;
|
||||||
|
*) if [ -z "$ref" ]; then ref="$1"; else pathspec+=("$1"); fi; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -z "$ref" ] && { echo "usage: $SELF review-diff [-C <repo-dir>] [-i \"instr\"] <gitref> [-- <pathspec>]" >&2; exit 2; }
|
||||||
|
[ -d "$gdir" ] || { [ -d "$REPO_ROOT/$gdir" ] && gdir="$REPO_ROOT/$gdir"; }
|
||||||
|
git -C "$gdir" rev-parse --git-dir >/dev/null 2>&1 || { echo "[$SELF] not a git repo: $gdir" >&2; exit 2; }
|
||||||
|
if [ ${#pathspec[@]} -gt 0 ]; then
|
||||||
|
git -C "$gdir" diff "$ref" -- "${pathspec[@]}" > "$TMP/diff.txt" 2>"$TMP/differr.txt"
|
||||||
|
else
|
||||||
|
git -C "$gdir" diff "$ref" > "$TMP/diff.txt" 2>"$TMP/differr.txt"
|
||||||
|
fi
|
||||||
|
[ -s "$TMP/diff.txt" ] || { echo "[$SELF] empty/failed diff for '$ref' in $gdir: $(head -1 "$TMP/differr.txt" 2>/dev/null)" >&2; exit 1; }
|
||||||
|
gdir_win="$(winpath "$gdir")"
|
||||||
|
{ printf 'Review the following unified git diff. %s\n%s\nYou may read any changed file for full context (paths are relative to %s; strip a/ b/ prefixes). Do not modify anything.\n\n=== BEGIN DIFF ===\n' "$instr" "$NO_NARRATE" "$gdir_win"; cat "$TMP/diff.txt"; printf '\n=== END DIFF ===\n'; } > "$PF"
|
||||||
|
run_agy 360 --dangerously-skip-permissions --model "$STRONG_MODEL" --add-dir "$gdir_win"
|
||||||
|
emit_or_fail
|
||||||
|
;;
|
||||||
|
|
||||||
|
image-analyze|image|vision)
|
||||||
|
[ -z "${1:-}" ] && { echo "usage: $SELF image-analyze <image-path> [\"question\"]" >&2; exit 2; }
|
||||||
|
target="$1"; question="${2:-Describe exactly what is in this image.}"
|
||||||
|
if [ -f "$target" ]; then resolved="$target"
|
||||||
|
elif [ -f "$REPO_ROOT/$target" ]; then resolved="$REPO_ROOT/$target"
|
||||||
|
else echo "[$SELF] image not found: $target" >&2; exit 2; fi
|
||||||
|
prep_includes
|
||||||
|
base="$(basename "$resolved")"; cp -f "$resolved" "$INCLUDE_DIR/$base"
|
||||||
|
img_win="$(winpath "$INCLUDE_DIR/$base")"; inc_win="$(winpath "$INCLUDE_DIR")"
|
||||||
|
printf 'Read the image at this absolute path and describe exactly what you see. Report only what is actually present; do not guess or invent content. Then stop. %s\nImage path: %s\n\nQuestion: %s' "$NO_NARRATE" "$img_win" "$question" > "$PF"
|
||||||
|
run_agy 300 --dangerously-skip-permissions --model "$STRONG_MODEL" --add-dir "$inc_win"
|
||||||
|
emit_or_fail
|
||||||
|
;;
|
||||||
|
|
||||||
|
search|websearch)
|
||||||
|
SRC=""
|
||||||
|
if [ "${1:-}" = "--prompt-file" ]; then
|
||||||
|
[ -f "${2:-}" ] || { echo "[$SELF] prompt file not found: ${2:-}" >&2; exit 2; }
|
||||||
|
SRC="$(cat "$2")"
|
||||||
|
else
|
||||||
|
SRC="${1:-}"
|
||||||
|
fi
|
||||||
|
[ -z "$SRC" ] && { echo "usage: $SELF search \"<query>\" | $SELF search --prompt-file <path>" >&2; exit 2; }
|
||||||
|
printf 'Search the web for current, live information answering the following, then stop. Answer concisely and ALWAYS include the source URLs you used (a Sources list of full URLs). Do not fabricate URLs. %s\n\nQuery: %s' "$NO_NARRATE" "$SRC" > "$PF"
|
||||||
|
run_agy 240 --dangerously-skip-permissions --model "$STRONG_MODEL"
|
||||||
|
emit_or_fail
|
||||||
|
;;
|
||||||
|
|
||||||
|
raw)
|
||||||
|
"$AGY" "$@"
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "[$SELF] unknown mode '$MODE' (use text|verify|review|review-files|review-diff|image-analyze|search|raw)" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -1,388 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ask-gemini.sh — Claude -> Google Gemini CLI router (independent second model).
|
# ask-gemini.sh — DEPRECATED shim. The AGY skill now routes to the Antigravity CLI
|
||||||
#
|
# (`agy`), not the old Google `gemini` npm CLI (which broke on this account with a
|
||||||
# Sibling of ask-grok.sh. Routes a task to the official Google Gemini CLI
|
# Cloud-project eligibility error). This shim forwards to ask-agy.sh so any existing
|
||||||
# (`gemini`, npm global) for an independent, different-vendor second opinion,
|
# references keep working. New callers should use ask-agy.sh directly.
|
||||||
# verification, or a Gemini code review. Headless, safe-by-default, JSON-parsed.
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)"
|
||||||
#
|
exec bash "$DIR/ask-agy.sh" "$@"
|
||||||
# Auth is Google login (OAuth) — NO API key. Creds: ~/.gemini/oauth_creds.json.
|
|
||||||
# If a call fails with an auth error, run `gemini` interactively once and pick
|
|
||||||
# "Login with Google".
|
|
||||||
#
|
|
||||||
# Output contract (VERIFIED on GURU-5070, gemini 0.45.2):
|
|
||||||
# - Prefer JSON: `gemini -p ... -o json` -> {session_id, response, stats}.
|
|
||||||
# The answer text is `.response`. stdout may carry two cosmetic warning lines
|
|
||||||
# ("True color..." / "Ripgrep is not available...") before the JSON; we extract
|
|
||||||
# the object starting at the FIRST '{' to ignore them. stderr (429 backoff,
|
|
||||||
# warnings) is captured SEPARATELY and never fed to the JSON parser.
|
|
||||||
# - `--skip-trust` is REQUIRED headless (the CWD isn't a trusted folder).
|
|
||||||
# - stdin is always closed (</dev/null) so `-p` never hangs waiting on stdin.
|
|
||||||
#
|
|
||||||
# File reads (review*): Gemini's read_file honors .gitignore AND a workspace
|
|
||||||
# sandbox (only files under the workspace/included dirs are readable). To make
|
|
||||||
# review robust for ANY file (tracked, gitignored, with spaces), we copy each
|
|
||||||
# target into a temp dir and add it to the workspace via --include-directories.
|
|
||||||
# review-diff runs with the repo dir included so changed files read in place.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ask-gemini.sh text "<prompt>" # one-shot answer
|
|
||||||
# ask-gemini.sh text --prompt-file <path> # long content
|
|
||||||
# ask-gemini.sh verify "<claim or finding to refute>" # adversarial check
|
|
||||||
# ask-gemini.sh verify --prompt-file <path>
|
|
||||||
# ask-gemini.sh review <file> [instructions] # gemini reads + reviews one file
|
|
||||||
# ask-gemini.sh review-files [-i "instr"] <f1> [f2 ...] # review a SET of files together
|
|
||||||
# ask-gemini.sh review-diff [-C <repo-dir>] [-i "instr"] <gitref> [-- <pathspec>]
|
|
||||||
# ask-gemini.sh image-analyze <image-path> ["question"] # vision: read_file image + describe (PRO model)
|
|
||||||
# ask-gemini.sh search "<query>" # Google-grounded live web search + sources
|
|
||||||
# ask-gemini.sh raw <gemini args...> # escape hatch
|
|
||||||
#
|
|
||||||
# Exit: 0 ok, 1 no result, 2 usage, 3 not installed here, 127 gemini/python not found.
|
|
||||||
set -uo pipefail
|
|
||||||
SELF="ask-gemini"
|
|
||||||
|
|
||||||
PY="$(command -v py 2>/dev/null || command -v python 2>/dev/null || command -v python3 2>/dev/null || true)"
|
|
||||||
[ -z "$PY" ] && { echo "[$SELF] python (py/python/python3) required for JSON parsing" >&2; exit 127; }
|
|
||||||
|
|
||||||
# --- path conversion: native-Windows path for the gemini args (no-op off Windows) ---
|
|
||||||
# gemini is a native Windows binary (npm shim -> node.exe); Git Bash hands it POSIX
|
|
||||||
# paths (/tmp, /c/.., /d/..) it cannot resolve. cygpath -w converts to C:\... on
|
|
||||||
# MSYS/Cygwin; on Linux/macOS it passes through unchanged. Explicit conversion
|
|
||||||
# removes reliance on MSYS auto-conversion (which breaks on spaces/edge cases).
|
|
||||||
if command -v cygpath >/dev/null 2>&1; then
|
|
||||||
winpath() { cygpath -w -- "$1" 2>/dev/null || printf '%s' "$1"; }
|
|
||||||
else
|
|
||||||
winpath() { printf '%s' "$1"; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- identity.json (per-machine, gitignored) declares whether gemini is installed here ---
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)"
|
|
||||||
IDFILE=""
|
|
||||||
[ -n "${CLAUDETOOLS_ROOT:-}" ] && [ -f "$CLAUDETOOLS_ROOT/.claude/identity.json" ] && IDFILE="$CLAUDETOOLS_ROOT/.claude/identity.json"
|
|
||||||
[ -z "$IDFILE" ] && IDFILE="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd)/identity.json"
|
|
||||||
idgem() { # read field $1 from identity.json .gemini (empty if absent)
|
|
||||||
[ -f "$IDFILE" ] || { echo ""; return; }
|
|
||||||
"$PY" -c "import json,sys
|
|
||||||
try:
|
|
||||||
g=(json.load(sys.stdin).get('gemini') or {}); v=g.get('$1','')
|
|
||||||
print('' if v is None else (str(v).lower() if isinstance(v,bool) else v))
|
|
||||||
except Exception: print('')" < "$IDFILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
# If identity explicitly says gemini is NOT installed here, fail fast with guidance.
|
|
||||||
if [ "$(idgem installed)" = "false" ]; then
|
|
||||||
echo "[$SELF] gemini is not installed on this machine (identity.json gemini.installed=false)." >&2
|
|
||||||
echo "[$SELF] Gemini runs only on the fleet host. Route this request there, or install the gemini CLI (npm i -g @google/gemini-cli) + set identity.json gemini.installed=true." >&2
|
|
||||||
exit 3
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- locate the gemini binary: GEMINI env > identity.json gemini.binary > auto-locate ---
|
|
||||||
# An explicit GEMINI= override that isn't runnable is a user error -> fail clearly up front
|
|
||||||
# (covers absolute paths AND a bare name resolvable on PATH, e.g. GEMINI=gemini).
|
|
||||||
GEMINI="${GEMINI:-}"
|
|
||||||
if [ -n "$GEMINI" ] && [ ! -x "$GEMINI" ] && ! command -v "$GEMINI" >/dev/null 2>&1; then
|
|
||||||
echo "[$SELF] GEMINI='$GEMINI' is not an executable gemini binary." >&2; exit 127
|
|
||||||
fi
|
|
||||||
cand="$(idgem binary)"
|
|
||||||
[ -z "$GEMINI" ] && [ -n "$cand" ] && [ -x "$cand" ] && GEMINI="$cand"
|
|
||||||
if [ -z "$GEMINI" ]; then
|
|
||||||
if command -v gemini >/dev/null 2>&1; then GEMINI="$(command -v gemini)"; else
|
|
||||||
for c in "${APPDATA:-}/npm/gemini" "/c/Users/${USERNAME:-${USER:-x}}/AppData/Roaming/npm/gemini" \
|
|
||||||
"$HOME/AppData/Roaming/npm/gemini" "/usr/local/bin/gemini" "$HOME/.npm-global/bin/gemini"; do
|
|
||||||
[ -n "$c" ] && [ -x "$c" ] && { GEMINI="$c"; break; }
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
[ -z "$GEMINI" ] && { echo "[$SELF] gemini CLI not found (set identity.json gemini.binary, GEMINI=, or install: npm i -g @google/gemini-cli)" >&2; exit 127; }
|
|
||||||
|
|
||||||
# Model: default routing for text; a strong pinned model for verify/review.
|
|
||||||
# gemini-3.1-pro-preview verified available on this account (2026-06-05); overridable.
|
|
||||||
STRONG_MODEL="${GEMINI_MODEL:-gemini-3.1-pro-preview}"
|
|
||||||
|
|
||||||
MODE="${1:-}"; shift 2>/dev/null || true
|
|
||||||
[ -z "$MODE" ] && { echo "usage: $SELF {text|verify|review|review-files|review-diff|image-analyze|search|raw} ..." >&2; exit 2; }
|
|
||||||
|
|
||||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
||||||
PF="$TMP/prompt.txt"; OUT="$TMP/out.txt"; ERR="$TMP/err.txt"
|
|
||||||
REPO_ROOT="${CLAUDETOOLS_ROOT:-$(cd "$SCRIPT_DIR/../../../.." 2>/dev/null && pwd)}"
|
|
||||||
# Functional-error logger (skill name "agy"); soft-fails, never breaks the caller.
|
|
||||||
_logerr() { bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "agy" "$@" >/dev/null 2>&1 || true; }
|
|
||||||
|
|
||||||
# gtimeout on macOS (brew coreutils), timeout elsewhere.
|
|
||||||
TIMEOUT_CMD="timeout"
|
|
||||||
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
|
||||||
TIMEOUT_CMD="$(command -v gtimeout 2>/dev/null || echo timeout)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# run gemini headless reading the prompt file. $1=timeout secs; rest=extra flags.
|
|
||||||
# stdout -> $OUT, stderr -> $ERR (kept separate so warning/429 noise never reaches
|
|
||||||
# the JSON parser). Never fail the script on gemini's exit code; we judge by output.
|
|
||||||
# Records the invocation so emit_or_fail can replay it once on a transient empty turn.
|
|
||||||
LAST_RUN=()
|
|
||||||
run_gemini() {
|
|
||||||
local to="$1"; shift
|
|
||||||
LAST_RUN=("$to" "$@")
|
|
||||||
"$TIMEOUT_CMD" "$to" "$GEMINI" -p "$(cat "$PF")" -o json --skip-trust "$@" \
|
|
||||||
>"$OUT" 2>"$ERR" </dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
# extract .response from the JSON object starting at the first '{' in $OUT.
|
|
||||||
# Parsed via stdin so Windows python never resolves a git-bash (/c/...) path.
|
|
||||||
#
|
|
||||||
# Some pinned-pro tool-using turns (notably image-analyze) leak the model's
|
|
||||||
# internal reasoning stream into .response: a stray token + a 'thought' marker
|
|
||||||
# followed by 'CRITICAL INSTRUCTION N:' lines, then the real answer. We strip
|
|
||||||
# that preamble ONLY when the signature is clearly present, so clean responses
|
|
||||||
# (text/verify/review/search) pass through byte-for-byte unchanged.
|
|
||||||
gresponse() { "$PY" -c "import json,sys,re,os
|
|
||||||
raw=sys.stdin.read()
|
|
||||||
i=raw.find('{')
|
|
||||||
if i < 0:
|
|
||||||
print(''); sys.exit(0)
|
|
||||||
try:
|
|
||||||
r=json.loads(raw[i:]).get('response','') or ''
|
|
||||||
except Exception:
|
|
||||||
print(''); sys.exit(0)
|
|
||||||
head=r[:40].lower()
|
|
||||||
leak=('thought' in head) or ('critical instruction' in r.lower()[:600])
|
|
||||||
if leak:
|
|
||||||
lines=r.split('\n')
|
|
||||||
keep=[]; dropping=True
|
|
||||||
for ln in lines:
|
|
||||||
s=ln.strip()
|
|
||||||
low=s.lower()
|
|
||||||
if dropping and (
|
|
||||||
low.endswith('thought') or low.startswith('critical instruction')
|
|
||||||
or low.startswith('thought:') or low=='' ):
|
|
||||||
continue
|
|
||||||
dropping=False
|
|
||||||
keep.append(ln)
|
|
||||||
cleaned='\n'.join(keep).strip()
|
|
||||||
r=cleaned if cleaned else r.strip()
|
|
||||||
# AGY_CLEAN: aggressive prefix scrub for tool-using turns (image-analyze), which
|
|
||||||
# can fuse a stray stream/tool token onto the front of the answer (e.g. '.',
|
|
||||||
# '.94>', 'uem_image_0_0_png}'). Off by default so text/verify/review/search are
|
|
||||||
# byte-exact. We only remove a junk run that ends in a stream delimiter (} > :)
|
|
||||||
# or a lone leading punctuation char, immediately before the first real sentence.
|
|
||||||
if os.environ.get('AGY_CLEAN') == '1' and r:
|
|
||||||
# The pro-preview tool loop sometimes prepends a numbered/markdown reasoning
|
|
||||||
# block before the actual answer. If a clear answer pivot follows such a
|
|
||||||
# preamble, keep from the pivot onward (the user-facing answer).
|
|
||||||
if re.search(r'(?im)^\s*\d+[.)]\s', r) or 'thought' in r[:60].lower():
|
|
||||||
pivs=list(re.finditer(r'(?i)(Based on the image\b|\*\*Answer:?\*\*|The image (?:contains|shows|displays)\b)', r))
|
|
||||||
if pivs:
|
|
||||||
r=r[pivs[-1].start():]
|
|
||||||
m=re.match(r'^[^\n]{0,40}?(?:\.png\)|\.jpe?g\)|[}>:)])\s*([\"A-Z].*)$', r, re.S)
|
|
||||||
if m and m.group(1):
|
|
||||||
r=m.group(1)
|
|
||||||
else:
|
|
||||||
# a short leading junk run (ASCII punctuation/digits or non-Latin stream
|
|
||||||
# tokens) before a capitalized/quoted sentence start. Bounded length so we
|
|
||||||
# never eat a real lowercase sentence or real prose.
|
|
||||||
m=re.match(r'^(?:[^A-Za-z\"]|[^\x00-\x7f]){1,8}([A-Z\"].*)$', r, re.S)
|
|
||||||
if m and m.group(1):
|
|
||||||
r=m.group(1)
|
|
||||||
r=r.strip()
|
|
||||||
print(r)" < "$OUT"; }
|
|
||||||
|
|
||||||
# detect a GENUINE auth failure in stderr (precise remediation hint). Tightened 2026-06-17 -
|
|
||||||
# the old broad regex (bare login|credential|authenticat|oauth|401) matched benign mid-run
|
|
||||||
# token-refresh lines and false-flagged working sessions as auth failures.
|
|
||||||
auth_failed() { grep -qiE 'invalid_grant|unauthorized|not authenticated|authentication failed|re-?authenticat|please (log|sign).?in|login with google|token (has )?expired|no (valid )?credentials' "$ERR" 2>/dev/null; }
|
|
||||||
# detect a quota / rate-capacity exhaustion (the pinned strong model can be capped mid-session)
|
|
||||||
quota_exhausted() { grep -qiE 'exhausted your capacity|quota|resource[_ ]?exhausted|rate limit|too many requests|429' "$ERR" 2>/dev/null; }
|
|
||||||
|
|
||||||
emit_or_fail() { # print .response; gemini intermittently returns an empty turn, so retry a few
|
|
||||||
# times with backoff before giving up (single retry was insufficient - 2 empties
|
|
||||||
# in a row caused spurious failures during live research, 2026-06-17).
|
|
||||||
# Do ALL retries first; only classify the failure (auth vs generic) AFTER exhausting them.
|
|
||||||
# (Checking auth_failed INSIDE the loop caused false aborts: a benign mid-run credential-refresh
|
|
||||||
# line in stderr matched the auth regex and killed the retries even though auth was fine. 2026-06-17.)
|
|
||||||
local txt tries=0 max="${AGY_MAX_TRIES:-3}"
|
|
||||||
txt="$(gresponse)"
|
|
||||||
while [ -z "$txt" ] && [ "$tries" -lt $((max-1)) ] && [ ${#LAST_RUN[@]} -gt 0 ]; do
|
|
||||||
tries=$((tries+1))
|
|
||||||
echo "[$SELF] empty response - retry $tries/$((max-1)) (backoff ${tries}x3s)..." >&2
|
|
||||||
sleep $((tries*3)) # 3s, 6s backoff (covers transient empties / 429s / token refresh)
|
|
||||||
run_gemini "${LAST_RUN[@]}"
|
|
||||||
txt="$(gresponse)"
|
|
||||||
done
|
|
||||||
if [ -n "$txt" ]; then printf '%s\n' "$txt"; return 0; fi
|
|
||||||
# Quota fallback: if the pinned strong model is capacity/quota-capped, retry ONCE on the default
|
|
||||||
# (lighter) model by stripping -m from the last invocation - the default model has a separate quota.
|
|
||||||
if quota_exhausted && [ ${#LAST_RUN[@]} -gt 0 ]; then
|
|
||||||
echo "[$SELF] '$STRONG_MODEL' quota exhausted - retrying once on the default (lighter) model..." >&2
|
|
||||||
local nr=() a skip=0
|
|
||||||
for a in "${LAST_RUN[@]}"; do
|
|
||||||
if [ "$skip" = 1 ]; then skip=0; continue; fi
|
|
||||||
if [ "$a" = "-m" ]; then skip=1; continue; fi
|
|
||||||
nr+=("$a")
|
|
||||||
done
|
|
||||||
run_gemini "${nr[@]}"
|
|
||||||
txt="$(gresponse)"
|
|
||||||
if [ -n "$txt" ]; then printf '%s\n' "$txt"; return 0; fi
|
|
||||||
fi
|
|
||||||
if auth_failed; then
|
|
||||||
echo "[$SELF] Gemini auth error - run 'gemini' interactively and choose 'Login with Google', then retry." >&2
|
|
||||||
_logerr "gemini auth/login failure" --context "mode=$MODE"
|
|
||||||
else
|
|
||||||
echo "[$SELF] no response from gemini after $max attempts. stderr tail:" >&2
|
|
||||||
tail -3 "$ERR" >&2 2>/dev/null || true
|
|
||||||
_logerr "gemini returned no response (empty after $max attempts)" --context "mode=$MODE err=$(tail -1 "$ERR" 2>/dev/null | tr -d '\n' | cut -c1-80)"
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Copy target files into an included temp workspace dir so gemini's read_file can
|
|
||||||
# reach them regardless of .gitignore / workspace sandbox. Echoes the included dir.
|
|
||||||
INCLUDE_DIR="$TMP/inbox"
|
|
||||||
prep_includes() { mkdir -p "$INCLUDE_DIR"; }
|
|
||||||
|
|
||||||
case "$MODE" in
|
|
||||||
text|verify)
|
|
||||||
SRC=""
|
|
||||||
if [ "${1:-}" = "--prompt-file" ]; then
|
|
||||||
[ -f "${2:-}" ] || { echo "[$SELF] prompt file not found: ${2:-}" >&2; exit 2; }
|
|
||||||
SRC="$(cat "$2")"
|
|
||||||
else
|
|
||||||
SRC="${1:-}"
|
|
||||||
fi
|
|
||||||
[ -z "$SRC" ] && { echo "usage: $SELF $MODE \"<prompt>\" | $SELF $MODE --prompt-file <path>" >&2; exit 2; }
|
|
||||||
if [ "$MODE" = "verify" ]; then
|
|
||||||
printf 'You are an adversarial reviewer giving an independent second opinion. Evaluate the following claim/finding/document: try hard to find any way it is WRONG, incomplete, unsupported, or overstated. Then give a clear VERDICT (e.g. correct / partly correct / incorrect) plus specific justification. Answer in text only; do not use any tools.\n\nContent:\n%s' "$SRC" > "$PF"
|
|
||||||
run_gemini 180 -m "$STRONG_MODEL"
|
|
||||||
else
|
|
||||||
printf 'Answer the following directly in text. Do not use any tools.\n\n%s' "$SRC" > "$PF"
|
|
||||||
run_gemini 180
|
|
||||||
fi
|
|
||||||
emit_or_fail
|
|
||||||
;;
|
|
||||||
|
|
||||||
review|file)
|
|
||||||
[ -z "${1:-}" ] && { echo "usage: $SELF review <file-path> [instructions]" >&2; exit 2; }
|
|
||||||
target="$1"
|
|
||||||
instr="${2:-Give an independent, critical review of this file: accuracy, gaps/omissions, bugs, and concrete improvements. Be specific.}"
|
|
||||||
# GOTCHA: a relative path resolves against ONLY CWD or $REPO_ROOT ($CLAUDETOOLS_ROOT) --
|
|
||||||
# NOT a submodule/subdir. "server/src/x.rs" relative to a submodule fails ("file not found")
|
|
||||||
# unless CWD is that submodule. Pass ABSOLUTE paths for submodule/subtree files.
|
|
||||||
if [ -f "$target" ]; then resolved="$target"
|
|
||||||
elif [ -f "$REPO_ROOT/$target" ]; then resolved="$REPO_ROOT/$target"
|
|
||||||
else echo "[$SELF] file not found: $target" >&2; exit 2; fi
|
|
||||||
prep_includes
|
|
||||||
base="$(basename "$resolved")"
|
|
||||||
cp -f "$resolved" "$INCLUDE_DIR/$base"
|
|
||||||
tgt_win="$(winpath "$INCLUDE_DIR/$base")"
|
|
||||||
inc_win="$(winpath "$INCLUDE_DIR")"
|
|
||||||
printf 'Use your read_file tool to read the file at this absolute path, then perform the task and stop. Do not modify anything.\nPath: %s\n\nTask: %s' "$tgt_win" "$instr" > "$PF"
|
|
||||||
run_gemini 240 -m "$STRONG_MODEL" --approval-mode plan --include-directories "$inc_win"
|
|
||||||
emit_or_fail
|
|
||||||
;;
|
|
||||||
|
|
||||||
review-files)
|
|
||||||
instr='Independently review these files together as a unit: correctness/bugs, gaps, cross-file consistency, and concrete improvements. Be specific and cite file:line.'
|
|
||||||
files=()
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
-i|--instr) instr="${2:-}"; shift 2 2>/dev/null || shift ;;
|
|
||||||
*) files+=("$1"); shift ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
[ ${#files[@]} -eq 0 ] && { echo "usage: $SELF review-files [-i \"instructions\"] <file> [file ...]" >&2; exit 2; }
|
|
||||||
prep_includes
|
|
||||||
list=""
|
|
||||||
declare -A seen=()
|
|
||||||
# GOTCHA: each relative path resolves against ONLY CWD or $REPO_ROOT ($CLAUDETOOLS_ROOT) --
|
|
||||||
# NOT a submodule/subdir. Paths relative to a submodule fail unless CWD is that submodule.
|
|
||||||
# Pass ABSOLUTE paths for submodule/subtree files (e.g. build the list with `find "$(pwd)/..."`).
|
|
||||||
for f in "${files[@]}"; do
|
|
||||||
if [ -f "$f" ]; then r="$f"
|
|
||||||
elif [ -f "$REPO_ROOT/$f" ]; then r="$REPO_ROOT/$f"
|
|
||||||
else echo "[$SELF] file not found: $f" >&2; exit 2; fi
|
|
||||||
base="$(basename "$r")"
|
|
||||||
# de-collide identical basenames from different dirs
|
|
||||||
if [ -n "${seen[$base]:-}" ]; then
|
|
||||||
n=1; while [ -e "$INCLUDE_DIR/${n}_${base}" ]; do n=$((n+1)); done; base="${n}_${base}"
|
|
||||||
fi
|
|
||||||
seen[$base]=1
|
|
||||||
cp -f "$r" "$INCLUDE_DIR/$base"
|
|
||||||
list+="- $(winpath "$INCLUDE_DIR/$base")
|
|
||||||
"
|
|
||||||
done
|
|
||||||
inc_win="$(winpath "$INCLUDE_DIR")"
|
|
||||||
printf 'Use your read_file tool to read EACH of these files (absolute paths), then perform the task across ALL of them and stop. Do not modify anything.\n\nFiles:\n%s\nTask: %s' "$list" "$instr" > "$PF"
|
|
||||||
run_gemini 300 -m "$STRONG_MODEL" --approval-mode plan --include-directories "$inc_win"
|
|
||||||
emit_or_fail
|
|
||||||
;;
|
|
||||||
|
|
||||||
review-diff)
|
|
||||||
gdir="$REPO_ROOT"
|
|
||||||
instr='Review this git diff: correctness/bugs introduced, regressions, missing edge cases, and concrete fixes. Focus on the CHANGES. Be specific and cite file:line.'
|
|
||||||
ref=""; pathspec=()
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
-C|--dir) gdir="${2:-}"; shift 2 2>/dev/null || shift ;;
|
|
||||||
-i|--instr) instr="${2:-}"; shift 2 2>/dev/null || shift ;;
|
|
||||||
--) shift; while [ $# -gt 0 ]; do pathspec+=("$1"); shift; done ;;
|
|
||||||
*) if [ -z "$ref" ]; then ref="$1"; else pathspec+=("$1"); fi; shift ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
[ -z "$ref" ] && { echo "usage: $SELF review-diff [-C <repo-dir>] [-i \"instr\"] <gitref> [-- <pathspec>]" >&2; exit 2; }
|
|
||||||
[ -d "$gdir" ] || { [ -d "$REPO_ROOT/$gdir" ] && gdir="$REPO_ROOT/$gdir"; }
|
|
||||||
git -C "$gdir" rev-parse --git-dir >/dev/null 2>&1 || { echo "[$SELF] not a git repo: $gdir" >&2; exit 2; }
|
|
||||||
if [ ${#pathspec[@]} -gt 0 ]; then
|
|
||||||
git -C "$gdir" diff "$ref" -- "${pathspec[@]}" > "$TMP/diff.txt" 2>"$TMP/differr.txt"
|
|
||||||
else
|
|
||||||
git -C "$gdir" diff "$ref" > "$TMP/diff.txt" 2>"$TMP/differr.txt"
|
|
||||||
fi
|
|
||||||
[ -s "$TMP/diff.txt" ] || { echo "[$SELF] empty/failed diff for '$ref' in $gdir: $(head -1 "$TMP/differr.txt" 2>/dev/null)" >&2; exit 1; }
|
|
||||||
gdir_win="$(winpath "$gdir")"
|
|
||||||
{ printf 'Review the following unified git diff. %s\nYou may use your read_file tool on any changed file for full context (paths in the diff are relative to %s; strip the a/ b/ prefixes). Do not modify anything.\n\n=== BEGIN DIFF ===\n' "$instr" "$gdir_win"; cat "$TMP/diff.txt"; printf '\n=== END DIFF ===\n'; } > "$PF"
|
|
||||||
run_gemini 300 -m "$STRONG_MODEL" --approval-mode plan --include-directories "$gdir_win"
|
|
||||||
emit_or_fail
|
|
||||||
;;
|
|
||||||
|
|
||||||
image-analyze|image|vision)
|
|
||||||
# Independent second-model VISION. The default flash-lite router hallucinates
|
|
||||||
# image content, so we PIN the pro vision model (STRONG_MODEL) and run with
|
|
||||||
# yolo approval so read_file can execute. The image is copied into an included
|
|
||||||
# temp dir (like the review modes) and handed to Gemini by absolute winpath.
|
|
||||||
[ -z "${1:-}" ] && { echo "usage: $SELF image-analyze <image-path> [\"question\"]" >&2; exit 2; }
|
|
||||||
target="$1"
|
|
||||||
question="${2:-Describe exactly what is in this image.}"
|
|
||||||
if [ -f "$target" ]; then resolved="$target"
|
|
||||||
elif [ -f "$REPO_ROOT/$target" ]; then resolved="$REPO_ROOT/$target"
|
|
||||||
else echo "[$SELF] image not found: $target" >&2; exit 2; fi
|
|
||||||
prep_includes
|
|
||||||
base="$(basename "$resolved")"
|
|
||||||
cp -f "$resolved" "$INCLUDE_DIR/$base"
|
|
||||||
img_win="$(winpath "$INCLUDE_DIR/$base")"
|
|
||||||
inc_win="$(winpath "$INCLUDE_DIR")"
|
|
||||||
# Image path goes in via %s (never as a printf format string).
|
|
||||||
printf 'Use your read_file tool to read the image at this absolute path, then describe exactly what you see. Report only what is actually present in the image; do not guess or invent content. Then stop. Do not modify anything.\nImage path: %s\n\nQuestion: %s' "$img_win" "$question" > "$PF"
|
|
||||||
run_gemini 240 -m "$STRONG_MODEL" --approval-mode yolo --include-directories "$inc_win"
|
|
||||||
AGY_CLEAN=1 emit_or_fail
|
|
||||||
;;
|
|
||||||
|
|
||||||
search|websearch)
|
|
||||||
# Google-grounded LIVE web search (mirrors grok xsearch). Gemini's
|
|
||||||
# google_web_search tool works on OAuth; run with yolo so the tool can fire.
|
|
||||||
# Query goes via the prompt file so long queries don't hit shell-quote limits.
|
|
||||||
SRC=""
|
|
||||||
if [ "${1:-}" = "--prompt-file" ]; then
|
|
||||||
[ -f "${2:-}" ] || { echo "[$SELF] prompt file not found: ${2:-}" >&2; exit 2; }
|
|
||||||
SRC="$(cat "$2")"
|
|
||||||
else
|
|
||||||
SRC="${1:-}"
|
|
||||||
fi
|
|
||||||
[ -z "$SRC" ] && { echo "usage: $SELF search \"<query>\" | $SELF search --prompt-file <path>" >&2; exit 2; }
|
|
||||||
printf 'Use your google_web_search tool to find current, live information answering the following, then stop. Answer concisely and ALWAYS include the source URLs you used (a Sources list of full URLs). Do not fabricate URLs.\n\nQuery: %s' "$SRC" > "$PF"
|
|
||||||
run_gemini 180 -m "$STRONG_MODEL" --approval-mode yolo
|
|
||||||
emit_or_fail
|
|
||||||
;;
|
|
||||||
|
|
||||||
raw)
|
|
||||||
"$GEMINI" "$@"
|
|
||||||
;;
|
|
||||||
|
|
||||||
*)
|
|
||||||
echo "[$SELF] unknown mode '$MODE' (use text|verify|review|review-files|review-diff|image-analyze|search|raw)" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
|
|||||||
128
.claude/skills/ask-forum/SKILL.md
Normal file
128
.claude/skills/ask-forum/SKILL.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
---
|
||||||
|
name: ask-forum
|
||||||
|
description: "Ask a teammate (Mike/Winter/Howard/anyone with forum access) a question in the private #ct-forum Discord forum and get their human answer back IN THIS SAME SESSION, then act on it - a live three-way between the user, this Claude session, and the teammate. No second bot, no DMs, one tool call (the wait runs server-side in bash, zero tokens while waiting). Triggers: ask Mike/Winter in the forum, ask the team, post a question to ct-forum, get a human answer/decision/sign-off, human-in-the-loop, run something by <person>, wait for their reply."
|
||||||
|
---
|
||||||
|
|
||||||
|
# ask-forum — human-in-the-loop questions via #ct-forum
|
||||||
|
|
||||||
|
Thin wrapper over `.claude/scripts/ask-forum.sh`. Lets THIS session put a question to a
|
||||||
|
human in the private **#ct-forum** Discord forum and receive their answer back in-session,
|
||||||
|
so it can keep going. Use it whenever you need a person's answer, decision, or sign-off
|
||||||
|
mid-task and want it to flow back to the running session — instead of stopping and asking
|
||||||
|
the user to relay it.
|
||||||
|
|
||||||
|
**USE THIS SKILL — do not hand-roll `curl` against the Discord API or the bot token.**
|
||||||
|
Free-handing the raw call is exactly what produced the `&`-on-background bug (a wait that
|
||||||
|
exited instantly and never delivered the answer). The script + this contract encode the
|
||||||
|
correct thread-create, the non-bot reply filter, and the background-wait discipline. Reach
|
||||||
|
for raw API only if this skill genuinely can't do what's needed — and say so.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
- You need a teammate's **answer / decision / approval** and want it back in this session
|
||||||
|
(e.g. "ask Mike whether to deploy", "run this naming by Winter", "get a go/no-go").
|
||||||
|
- A back-and-forth where the human's reply should drive the session's next action.
|
||||||
|
|
||||||
|
Not for: one-way copy-paste delivery of a link/command (that's `discord-dm` → `mike`), or
|
||||||
|
routine `[SYNCRO]`/`[RMM]` status alerts (`post-bot-alert.sh`).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ask + BLOCK until a human replies, then act on the answer:
|
||||||
|
bash .claude/scripts/ask-forum.sh "your question" --tag @264814939619721216
|
||||||
|
|
||||||
|
# Re-read a thread you already asked in (one-shot, no waiting):
|
||||||
|
bash .claude/scripts/ask-forum.sh --read <thread_id>
|
||||||
|
|
||||||
|
# Resume blocking on an already-posted thread (e.g. after a timeout):
|
||||||
|
bash .claude/scripts/ask-forum.sh --wait <thread_id> --timeout 1800 --poll 10
|
||||||
|
# add --after <msg_id> to wait for replies STRICTLY AFTER a message you already
|
||||||
|
# consumed (default baseline catches any reply since the thread's starter).
|
||||||
|
```
|
||||||
|
|
||||||
|
## Robustness (hardened after the 2026-07-08 review)
|
||||||
|
- **Pages past the 100-message window:** the poll advances its `after` cursor each
|
||||||
|
cycle, so a reply is found even behind many bot/overflow messages (no false timeout).
|
||||||
|
- **Rate-limit aware:** a `429` is honored (sleeps `retry_after`); a permanent Discord
|
||||||
|
error (Unknown Channel 10003, Missing Access 50001, etc.) **bails immediately** instead
|
||||||
|
of burning the full timeout; only genuine transients keep polling.
|
||||||
|
- **Long questions** (>1900 chars) chunk into the starter + follow-up messages, and each
|
||||||
|
overflow POST is status-checked (warns if one drops, so the question isn't silently
|
||||||
|
truncated).
|
||||||
|
- **`--wait` baseline** is the thread's starter id (starter id == thread id for a forum
|
||||||
|
post), so a reply that arrived *before* the wait started is caught, not missed. Because
|
||||||
|
of this, `--wait` on a thread that already holds a consumed answer re-returns it — pass
|
||||||
|
`--after <msg_id>` when you only want the NEXT reply.
|
||||||
|
- Residual caveat: content slicing assumes a UTF-8 locale (the script sets
|
||||||
|
`LC_ALL=C.UTF-8`); a forced byte-counting `LC_ALL=C` could split a multibyte char at the
|
||||||
|
1900-char cut. Not an issue under the fleet default locale.
|
||||||
|
|
||||||
|
Flags: `--title "short title"` (forum post name; defaults to first 60 chars of the
|
||||||
|
question) · `--timeout SEC` (default 600) · `--poll SEC` (default 8) · `--tag @<id>`
|
||||||
|
(repeatable — pings that person). Discord IDs: mike `264814939619721216`,
|
||||||
|
howard `624667664501178379`, winter `624666486362996755`, rob `261978810713505792`
|
||||||
|
(also in `.claude/users.json`).
|
||||||
|
|
||||||
|
## HARD RULE — long waits run as a proper background task (no shell `&`)
|
||||||
|
|
||||||
|
A blocking `--wait`/ask can sit for many minutes. Run it with the tool's
|
||||||
|
**`run_in_background: true`** and **nothing else** — do NOT append a shell `&`, `disown`,
|
||||||
|
or `nohup`. Adding `&` forks the wait off and the shell exits 0 immediately, orphaning the
|
||||||
|
poll loop so the answer is never delivered (logged friction, twice). Done right, the wait
|
||||||
|
costs **zero tokens** while pending and the harness notifies you the instant a human
|
||||||
|
replies — then you surface the answer to the user **unprompted**. See
|
||||||
|
[[feedback_background_task_no_ampersand]].
|
||||||
|
|
||||||
|
## How it works / correlation
|
||||||
|
- Posts a forum post via `POST /channels/<forum>/threads`, returns a **`thread_id`**.
|
||||||
|
- The session reads only **its own** `thread_id`, so an answer can never be confused with
|
||||||
|
another question's. Replies route to whoever asked — Mike's thread → Mike's session,
|
||||||
|
yours → yours, no crossover.
|
||||||
|
- **Any non-bot reply counts as the answer** (first human wins). Anyone with #ct-forum
|
||||||
|
access can answer; the script never filters by person. Bot chatter is filtered out.
|
||||||
|
|
||||||
|
## Model & scope (decided with Mike, 2026-07-08)
|
||||||
|
- **Forum-only.** No DM replies — answers stay team-visible.
|
||||||
|
- **Good-enough durability:** answers are captured while the session is open; Discord keeps
|
||||||
|
the history, so any thread is cheap to re-read (`--read`) later in the same session. It is
|
||||||
|
NOT restart-safe (close the session → nothing is watching); that was an accepted tradeoff
|
||||||
|
for the cheapest option. Full restart/close-safe capture would be the coord-backed
|
||||||
|
registry or the event-driven bot-router — build only if asked.
|
||||||
|
- The **BEAST bot ignores #ct-forum** (patch in `projects/discord-bot/bot/main.py`) so it
|
||||||
|
doesn't auto-answer and collide with the asking session.
|
||||||
|
|
||||||
|
## Access & permissions
|
||||||
|
- **Channel:** #ct-forum (`1522960388432465950`), private — `@everyone` View DENY; Howard +
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
open — resume with `--wait <thread_id>`).
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
- Bot token resolves from the SOPS vault
|
||||||
|
(`projects/discord-bot/bot-token.sops.yaml` field `credentials.bot_token`), falling back
|
||||||
|
to `projects/discord-bot/.env` `DISCORD_TOKEN` — works from any machine.
|
||||||
|
- Engine: `.claude/scripts/ask-forum.sh`. Command entry point: `/ask-forum`. Related:
|
||||||
|
[[discord-dm]] (person-targeted DMs / channel posts).
|
||||||
90
.claude/skills/bug-tracker/skill.md
Normal file
90
.claude/skills/bug-tracker/skill.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
name: bug-tracker
|
||||||
|
description: "File, list, update, and close bugs/features/tasks in the Gitea issue tracker across GuruRMM, GuruConnect, and ClaudeTools repos. Visual dashboard at projects/msp-tools/bug-tracker/dashboard.html."
|
||||||
|
triggers:
|
||||||
|
- "file a bug"
|
||||||
|
- "track a bug"
|
||||||
|
- "open an issue"
|
||||||
|
- "bug tracker"
|
||||||
|
- "what bugs are open"
|
||||||
|
- "list bugs"
|
||||||
|
- "close issue"
|
||||||
|
- "update issue"
|
||||||
|
- "mark fixed"
|
||||||
|
- "what's being worked on"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug & Project Tracker — Gitea Issues
|
||||||
|
|
||||||
|
Manage the ACG bug/feature/skill/project tracker via Gitea Issues API.
|
||||||
|
Dashboard: `projects/msp-tools/bug-tracker/dashboard.html`
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- Gitea: `https://git.azcomputerguru.com/api/v1`
|
||||||
|
- Owner: `azcomputerguru`
|
||||||
|
- Token: vault `services/gitea` → `credentials.api.api-token`
|
||||||
|
- Tracked repos: `gururmm`, `guru-connect`, `claudetools`
|
||||||
|
|
||||||
|
## Label scheme
|
||||||
|
|
||||||
|
| Label | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `bug` | Something broken |
|
||||||
|
| `feature` | New capability |
|
||||||
|
| `skill` | Skill work |
|
||||||
|
| `project` | Project-level |
|
||||||
|
| `P1-critical` / `P2-high` / `P3-normal` / `P4-low` | Priority |
|
||||||
|
| `in-progress` | Being worked |
|
||||||
|
| `blocked` | Blocked |
|
||||||
|
| `dropped` | Stalled |
|
||||||
|
| `fixed` | Fix applied |
|
||||||
|
| `verified` | Confirmed working |
|
||||||
|
| `security` | Security-related |
|
||||||
|
| `component:rmm-agent` / `rmm-server` / `rmm-dashboard` / `guruconnect` / `harness` | Component |
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
### File a new issue
|
||||||
|
Read the token from vault. POST to `/repos/{owner}/{repo}/issues`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "...",
|
||||||
|
"body": "## Problem\n...\n\n## Steps to reproduce\n...\n\n## Expected behavior\n...\n\n## Session context\nFiled from session on MACHINE at DATE",
|
||||||
|
"labels": [label_id, label_id, ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Always include: one type label (bug/feature/skill/project), one priority label, component label(s) if applicable.
|
||||||
|
|
||||||
|
### List open issues
|
||||||
|
GET `/repos/{owner}/{repo}/issues?state=open&type=issues&limit=50`
|
||||||
|
For cross-repo view, query all tracked repos and merge results.
|
||||||
|
|
||||||
|
### Update an issue (add progress, change status)
|
||||||
|
- Add a comment: POST `/repos/{owner}/{repo}/issues/{number}/comments` with `{"body": "..."}`
|
||||||
|
- Change labels: PATCH `/repos/{owner}/{repo}/issues/{number}` or use label endpoints
|
||||||
|
- Close: PATCH with `{"state": "closed"}`
|
||||||
|
|
||||||
|
### Mark fixed
|
||||||
|
Add `fixed` label, add a comment describing the fix, reference the commit if available.
|
||||||
|
|
||||||
|
### Mark verified
|
||||||
|
Add `verified` label, close the issue.
|
||||||
|
|
||||||
|
## Auto-filing (IMPORTANT)
|
||||||
|
|
||||||
|
When a bug surfaces during a Claude session — a command fails unexpectedly, a skill hits an error that needs follow-up, or the user reports something broken — **file it as an issue automatically** (with user confirmation for the title/priority). This is the whole point: bugs should not require someone to remember them.
|
||||||
|
|
||||||
|
The issue body should include:
|
||||||
|
- What happened (error message, unexpected behavior)
|
||||||
|
- Context (what was being attempted, which machine, which session)
|
||||||
|
- Any workaround applied
|
||||||
|
- Links to relevant session logs if available
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Open `projects/msp-tools/bug-tracker/dashboard.html` in a browser.
|
||||||
|
Pass the Gitea token as a URL parameter: `?token=xxx`
|
||||||
|
It shows a kanban board (Open / In Progress / Blocked / Done) with filtering by repo and type.
|
||||||
|
|
||||||
|
$ARGUMENTS
|
||||||
@@ -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
|
remote-exec channel. `RegKey` auto-approves + adds to the key's target group; without
|
||||||
it the agent registers but awaits manual approval.
|
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
|
## Safety gating
|
||||||
|
|
||||||
- Reads never mutate and run without confirmation.
|
- Reads never mutate and run without confirmation.
|
||||||
|
|||||||
@@ -108,6 +108,57 @@ installed, hook, network, events`, plus `extensions:[{id,args,order}]`.
|
|||||||
the extension id via `GET /Extensions` (e.g. `Host Isolation [Win/Linux]`) and test on
|
the extension id via `GET /Extensions` (e.g. `Host Isolation [Win/Linux]`) and test on
|
||||||
an ACG-internal box first.
|
an ACG-internal box first.
|
||||||
|
|
||||||
|
## Alert Suppression Rules (verified live 2026-07-07)
|
||||||
|
|
||||||
|
Suppress a false-positive detection so it stops firing (and stops triggering any
|
||||||
|
attached auto-response like `isolate-host`). LoopBack models are API-reachable:
|
||||||
|
|
||||||
|
| Op | Method/path |
|
||||||
|
|---|---|
|
||||||
|
| List rules | `GET /SuppressionRules` (`[id, alertId, name, versionCount, description, organizationId, locationId, active, deleted]`) |
|
||||||
|
| Count | `GET /SuppressionRules/count` |
|
||||||
|
| Match criteria (per rule) | `GET /SuppressionRules/{id}/versions` → `[{id, suppressionRuleId, name, description, metadata:{...}}]` |
|
||||||
|
|
||||||
|
**`metadata`** is the full match-field map; each key is `{value, active, display, dataType,
|
||||||
|
operator}`. A field participates in the match ONLY when `active:true`; all active fields are
|
||||||
|
**AND**-ed. Available fields (`display`): Alert Type, Item Type, Organization Name, Location
|
||||||
|
Name, Hostname, IP Address, Name, File Path, File SHA1, File SHA256, File Signature Issuer,
|
||||||
|
**Process Command Line**, AV Threat Name, Operating System, Threat Status, Severity, Rule
|
||||||
|
Name, EPP Type, Threat Category, Process Owner, Process Owner UID, AV Hits, Parent Process
|
||||||
|
Name, Grand Parent Process Name.
|
||||||
|
|
||||||
|
**Scoping guidance (learned from the vwp-qbs RMM false-positive incident):** the tightest
|
||||||
|
safe suppression matches on **Process Command Line** (a unique fingerprint of the exact
|
||||||
|
script) plus **Grand Parent Process Name** (the launching agent, e.g. `gururmm-agent.exe`).
|
||||||
|
That trusts one exact known-good automation without blinding a whole binary. NEVER suppress
|
||||||
|
on `Name`/`File Path`/`File SHA*` of a LOLBin (powershell/rundll32/etc.) alone — it disables
|
||||||
|
the rule for that binary everywhere. Add `Rule Name` to limit to one rule; add `Hostname`/
|
||||||
|
`Organization Name`/`Location Name` to limit scope (empty `organizationId`/`locationId` on the
|
||||||
|
rule = fleet-wide, gated by the metadata match). **Do NOT blanket-whitelist an RMM agent as
|
||||||
|
grandparent by itself** — the RMM is a SYSTEM-level RCE channel and the top MSP attack path;
|
||||||
|
whitelisting all of it blinds EDR exactly where it matters.
|
||||||
|
|
||||||
|
**[DANGER] API create footgun (verified live 2026-07-07).** `POST /SuppressionRules` on this
|
||||||
|
tenant (a) **ignores `active:false` and forces the rule LIVE immediately**, and (b) auto-creates
|
||||||
|
an **EMPTY-metadata version that matches EVERYTHING**. So the two-step "create rule → then add
|
||||||
|
version" path leaves a live match-all suppression window (observed ~2 min → deleted). Also note
|
||||||
|
`GET`/`PATCH /SuppressionRules/{id}` (single-resource route) **HTTP 400** "undefined is not valid
|
||||||
|
JSON" on this tenant — read via `GET /SuppressionRules?filter={"where":{"id":"..."}}` instead;
|
||||||
|
**`DELETE /SuppressionRules/{id}` DOES work** (returns null, sets `deleted:true`). Until an
|
||||||
|
**atomic** create (metadata embedded in the POST body) is verified, **create suppressions in the
|
||||||
|
CONSOLE**, not via `raw POST`. If you must POST, verify the version's active fields IMMEDIATELY
|
||||||
|
and `DELETE` on any doubt.
|
||||||
|
|
||||||
|
**Create — CONSOLE-observed, API create RUN-unverified.** Console flow: open the alert →
|
||||||
|
**Create Suppression Rule** → check the desired Match fields → Save. This POSTs a
|
||||||
|
`SuppressionRule` (carrying `alertId`, `name`, `description`, `organizationId`, `locationId`,
|
||||||
|
`active`) plus a version whose `metadata` has the chosen fields flipped to `active:true`. To
|
||||||
|
replicate via API, POST `/SuppressionRules` then the version to `/SuppressionRules/{id}/
|
||||||
|
versions` with that metadata shape — verify the exact envelope on the next real create (build
|
||||||
|
→ read back via the GET above → delete if wrong) before relying on it. Example on this tenant:
|
||||||
|
rule `e4dd55bf-…` (name "Exfiltration Over HTTP Protocol", alertId `5d5f39b1-…`) matches
|
||||||
|
Process Command Line + Grand Parent = `gururmm-agent.exe`.
|
||||||
|
|
||||||
## Deployment (not a REST call)
|
## Deployment (not a REST call)
|
||||||
|
|
||||||
The agent installs by running the binary on the endpoint:
|
The agent installs by running the binary on the endpoint:
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
sp.add_argument("method", help="GET/POST/PUT/DELETE")
|
sp.add_argument("method", help="GET/POST/PUT/DELETE")
|
||||||
sp.add_argument("path", help="e.g. Agents or Agents/scan")
|
sp.add_argument("path", help="e.g. Agents or Agents/scan")
|
||||||
sp.add_argument("--filter", help="LoopBack filter JSON (GET)")
|
sp.add_argument("--filter", help="LoopBack filter JSON (GET)")
|
||||||
sp.add_argument("--data", help="request body JSON")
|
sp.add_argument("--data", help="request body JSON, or @path to read JSON from a file")
|
||||||
sp.add_argument("--confirm", action="store_true",
|
sp.add_argument("--confirm", action="store_true",
|
||||||
help="required for non-GET methods")
|
help="required for non-GET methods")
|
||||||
return p
|
return p
|
||||||
@@ -408,7 +408,13 @@ def main(argv=None) -> int:
|
|||||||
print(f"[BLOCKED] {method} requires --confirm.", file=sys.stderr)
|
print(f"[BLOCKED] {method} requires --confirm.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
filt = json.loads(args.filter) if args.filter else None
|
filt = json.loads(args.filter) if args.filter else None
|
||||||
body = json.loads(args.data) if args.data else None
|
if args.data and args.data.startswith("@"):
|
||||||
|
with open(args.data[1:], "r", encoding="utf-8") as _f:
|
||||||
|
body = json.load(_f)
|
||||||
|
elif args.data:
|
||||||
|
body = json.loads(args.data)
|
||||||
|
else:
|
||||||
|
body = None
|
||||||
# Same tenant-wide footgun guard the scan command has: a POST to any
|
# Same tenant-wide footgun guard the scan command has: a POST to any
|
||||||
# */scan endpoint with no non-empty `where` scans the ENTIRE tenant.
|
# */scan endpoint with no non-empty `where` scans the ENTIRE tenant.
|
||||||
if method == "POST" and args.path.rstrip("/").lower().endswith("scan"):
|
if method == "POST" and args.path.rstrip("/").lower().endswith("scan"):
|
||||||
|
|||||||
3
.claude/skills/datto-workplace/.gitignore
vendored
Normal file
3
.claude/skills/datto-workplace/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# No live secrets are cached (Basic auth is per-request), but keep scratch out.
|
||||||
|
.cache/
|
||||||
|
__pycache__/
|
||||||
74
.claude/skills/datto-workplace/SKILL.md
Normal file
74
.claude/skills/datto-workplace/SKILL.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
name: datto-workplace
|
||||||
|
description: "Read-only Datto Workplace backup verification for the GPS audit: confirm the file API is reachable under Basic auth, and (primary path) detect which GuruRMM endpoints actually run the Datto Workplace sync client. The file API is file-access only and this key sees no shared projects, so RMM endpoint detection is the real answer. Triggers: datto workplace, dattoworkplace, dwp, who backs up to datto workplace, datto sync."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Datto Workplace Skill
|
||||||
|
|
||||||
|
Read-only client for ACG's live **Datto Workplace** tenant, built for GPS backup
|
||||||
|
verification. Answers: **which enrolled machines actually run the Datto Workplace
|
||||||
|
sync client** (and, where the file API allows, what projects/files are visible).
|
||||||
|
|
||||||
|
This skill is one of the backup-verification siblings alongside `seafile`,
|
||||||
|
`b2` (Backblaze), and `owncloud`. It never writes.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DWP=".claude/skills/datto-workplace/scripts/datto_workplace.py"
|
||||||
|
py "$DWP" status # file API reachable + endpoint/project count
|
||||||
|
py "$DWP" projects [--json] # file-API projects (likely empty for this key)
|
||||||
|
py "$DWP" audit --rmm --client "Birth" # GPS view: detect the client on GuruRMM endpoints
|
||||||
|
py "$DWP" audit --rmm --client Birth --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `--json` to any command for machine-readable output. `--url` overrides the
|
||||||
|
file-API base.
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
Loaded at runtime from the SOPS vault (never hardcoded):
|
||||||
|
`msp-tools/datto-workplace.sops.yaml` -> `credentials.api-key` / `credentials.api-secret`.
|
||||||
|
Auth is **HTTP Basic**: username = api-key, password = api-secret, sent on every
|
||||||
|
request (no token exchange, nothing cached).
|
||||||
|
|
||||||
|
## The file API is limited (why RMM detection is primary)
|
||||||
|
|
||||||
|
Datto Workplace exposes a **file-access REST API v1** only. Base:
|
||||||
|
`https://acg.workplace.datto.com/api/v1` (the team path `/6/api/v1` is normalized
|
||||||
|
by the server to `/api/v1`). Endpoints (confirm with `GET /openapi.json`, surfaced
|
||||||
|
by `status`): `GET /file/projects`, `GET /file/{parentID}/files`,
|
||||||
|
`GET /file/{fileID}`, `GET /file/{fileID}/data`, `GET /file/search`, plus
|
||||||
|
`/webhook*`. There are **NO** user / device / member / team endpoints.
|
||||||
|
|
||||||
|
GOTCHA: `GET /file/projects` currently returns `{"result": []}` for this key -- the
|
||||||
|
API principal has no projects shared to it. So the file API alone **cannot
|
||||||
|
enumerate the team's backups**. `projects` handles this and says so.
|
||||||
|
|
||||||
|
Because of that, **RMM endpoint detection is the PRIMARY source of truth**.
|
||||||
|
|
||||||
|
## The `--rmm` cross-check (primary path)
|
||||||
|
|
||||||
|
`audit --rmm` logs in to GuruRMM (`infrastructure/gururmm-server.sops.yaml`) and,
|
||||||
|
for each agent (scope with `--client` so it doesn't sweep all 350+ agents), runs a
|
||||||
|
**read-only** detection PowerShell that reports installed products / running
|
||||||
|
processes / config folders matching the Datto Workplace client. An agent is
|
||||||
|
reported only if the client is actually present.
|
||||||
|
|
||||||
|
**Detection patterns must be precise:** this skill uses `["Datto Workplace",
|
||||||
|
"Workplace2"]`. A bare `"Datto"` false-matches **Datto EDR Agent**, Datto RMM, and
|
||||||
|
Datto AV -- do NOT use it. The v10 client installs as DisplayName
|
||||||
|
"Datto Workplace v10.x" in `C:\Program Files\Datto\Workplace2\` (process
|
||||||
|
`Workplace.exe`); the legacy v8 is "Datto Workplace Desktop". Matching
|
||||||
|
`Datto Workplace` + `Workplace2` catches both without catching EDR.
|
||||||
|
|
||||||
|
## Notes / gotchas
|
||||||
|
|
||||||
|
- Always scope `audit --rmm` with `--client` unless you truly mean to sweep the
|
||||||
|
whole fleet -- detection dispatches a live command to each endpoint.
|
||||||
|
- Shared plumbing (repo-root resolve, vault read, HTTP, GuruRMM client + endpoint
|
||||||
|
detection) lives in `.claude/scripts/backup_common.py`, used by all the
|
||||||
|
backup-verification siblings. Detection patterns are per-backend; this skill's
|
||||||
|
are the precise Datto Workplace set above.
|
||||||
|
- Known user: **Birth Biologic** backs up to Datto Workplace (agent hostnames
|
||||||
|
include `BB-*`, `KSTEEN*`, `EVO-X1`) -- a good `--client "Birth"` smoke test.
|
||||||
164
.claude/skills/datto-workplace/scripts/datto_client.py
Normal file
164
.claude/skills/datto-workplace/scripts/datto_client.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Datto Workplace REST API v1 client for the `datto-workplace` skill.
|
||||||
|
|
||||||
|
Talks to ACG's live Datto Workplace tenant file API. Read-only: this is a
|
||||||
|
FILE-ACCESS API (projects + files), NOT an admin/reporting API -- there are no
|
||||||
|
user/device/member/team endpoints. It answers "what shared projects + files does
|
||||||
|
this API principal see" for the GPS backup audit.
|
||||||
|
|
||||||
|
Auth: HTTP Basic, username = api-key, password = api-secret (no token exchange;
|
||||||
|
Basic is sent on every request). Credentials come from the SOPS vault.
|
||||||
|
|
||||||
|
Base URL defaults to the team file-API host. The team path /6/api/v1 is normalized
|
||||||
|
by the server to /api/v1; override with DATTO_WORKPLACE_URL if needed.
|
||||||
|
|
||||||
|
IMPORTANT GOTCHA: GET /file/projects currently returns {"result": []} for this
|
||||||
|
key -- the principal has no projects shared to it, so the file API alone CANNOT
|
||||||
|
enumerate the team's backups. Handle empty gracefully. Because of that, RMM
|
||||||
|
endpoint detection (see datto_workplace.py `audit --rmm`) is the PRIMARY source
|
||||||
|
of "who backs up to Datto Workplace".
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
# Import the shared backup helpers from .claude/scripts.
|
||||||
|
_SKILL_DIR = Path(__file__).resolve().parent.parent # .../skills/datto-workplace
|
||||||
|
_SCRIPTS_DIR = _SKILL_DIR.parent.parent / "scripts" # .../.claude/scripts
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
import backup_common as bc # noqa: E402
|
||||||
|
|
||||||
|
VAULT_ENTRY = "msp-tools/datto-workplace.sops.yaml"
|
||||||
|
VAULT_FIELD_KEY = "credentials.api-key"
|
||||||
|
VAULT_FIELD_SECRET = "credentials.api-secret"
|
||||||
|
|
||||||
|
DEFAULT_URL = os.environ.get("DATTO_WORKPLACE_URL", "https://acg.workplace.datto.com/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
class DattoWorkplaceClient:
|
||||||
|
"""Basic-auth GET client for the Datto Workplace file API.
|
||||||
|
|
||||||
|
No token caching is needed -- HTTP Basic is sent per request. Credentials are
|
||||||
|
read once from the vault on first use and reused for the process lifetime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base: Optional[str] = None):
|
||||||
|
self.base = (base or DEFAULT_URL).rstrip("/")
|
||||||
|
self._basic: Optional[tuple[str, str]] = None
|
||||||
|
|
||||||
|
# -- auth ------------------------------------------------------------------
|
||||||
|
def _creds(self) -> tuple[str, str]:
|
||||||
|
if self._basic is None:
|
||||||
|
key = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_KEY)
|
||||||
|
secret = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_SECRET)
|
||||||
|
self._basic = (key, secret)
|
||||||
|
return self._basic
|
||||||
|
|
||||||
|
def _get_raw(self, path: str, params: Optional[dict] = None) -> tuple[int, Any]:
|
||||||
|
"""Low-level GET returning (status, body); callers decide how to react."""
|
||||||
|
url = f"{self.base}{path}"
|
||||||
|
if params:
|
||||||
|
url += "?" + urllib.parse.urlencode(params)
|
||||||
|
return bc.http_json("GET", url, basic=self._creds())
|
||||||
|
|
||||||
|
def _get(self, path: str, params: Optional[dict] = None) -> Any:
|
||||||
|
status, body = self._get_raw(path, params)
|
||||||
|
if status == 401:
|
||||||
|
raise bc.BackupError(
|
||||||
|
f"Datto Workplace auth failed (HTTP 401) for {path}: check api-key/api-secret",
|
||||||
|
status=status)
|
||||||
|
if status != 200:
|
||||||
|
raise bc.BackupError(
|
||||||
|
f"Datto Workplace GET {path} failed (HTTP {status}): {body}", status=status)
|
||||||
|
return body
|
||||||
|
|
||||||
|
# -- read methods ----------------------------------------------------------
|
||||||
|
def openapi(self) -> dict:
|
||||||
|
"""Fetch the OpenAPI spec (confirms reachability + exact endpoint list)."""
|
||||||
|
body = self._get("/openapi.json")
|
||||||
|
return body if isinstance(body, dict) else {"_raw": body}
|
||||||
|
|
||||||
|
def endpoint_paths(self) -> list[str]:
|
||||||
|
"""Sorted list of paths the OpenAPI spec exposes."""
|
||||||
|
spec = self.openapi()
|
||||||
|
paths = spec.get("paths") if isinstance(spec, dict) else None
|
||||||
|
return sorted(paths.keys()) if isinstance(paths, dict) else []
|
||||||
|
|
||||||
|
def projects(self) -> list[dict]:
|
||||||
|
"""Top-level file projects visible to this API principal.
|
||||||
|
|
||||||
|
Returns [] gracefully -- for this key the principal currently has no
|
||||||
|
projects shared to it. The server is inconsistent here: some responses
|
||||||
|
are 200 {"result": []}, others are 403 "Access denied" for the exact same
|
||||||
|
Basic auth that authorizes /openapi.json seconds earlier. Both mean the
|
||||||
|
same thing for the audit (no project-level file access), so a 403/401 on
|
||||||
|
this endpoint is treated as empty rather than a hard error. A genuine
|
||||||
|
credential failure surfaces on /openapi.json (status/openapi).
|
||||||
|
"""
|
||||||
|
status, body = self._get_raw("/file/projects")
|
||||||
|
if status in (401, 403):
|
||||||
|
return []
|
||||||
|
if status != 200:
|
||||||
|
raise bc.BackupError(
|
||||||
|
f"Datto Workplace GET /file/projects failed (HTTP {status}): {body}",
|
||||||
|
status=status)
|
||||||
|
return self._as_list(body)
|
||||||
|
|
||||||
|
def files(self, parent_id: str) -> list[dict]:
|
||||||
|
"""List files/folders under a parent project or folder ID."""
|
||||||
|
body = self._get(f"/file/{parent_id}/files")
|
||||||
|
return self._as_list(body)
|
||||||
|
|
||||||
|
def file_info(self, file_id: str) -> dict:
|
||||||
|
"""Metadata for a single file/folder ID."""
|
||||||
|
body = self._get(f"/file/{file_id}")
|
||||||
|
if isinstance(body, dict):
|
||||||
|
return body.get("result") if isinstance(body.get("result"), dict) else body
|
||||||
|
return {"_raw": body}
|
||||||
|
|
||||||
|
def search(self, query: str, parent_id: Optional[str] = None) -> list[dict]:
|
||||||
|
"""Search files by name. Optional parent scope."""
|
||||||
|
params = {"query": query}
|
||||||
|
if parent_id:
|
||||||
|
params["parentID"] = parent_id
|
||||||
|
body = self._get("/file/search", params)
|
||||||
|
return self._as_list(body)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_list(body: Any) -> list[dict]:
|
||||||
|
"""Normalize the Datto file-API envelope to a list.
|
||||||
|
|
||||||
|
Responses wrap payloads as {"result": [...]} (or a bare list on some
|
||||||
|
endpoints); anything else yields [].
|
||||||
|
"""
|
||||||
|
if isinstance(body, dict):
|
||||||
|
res = body.get("result")
|
||||||
|
if isinstance(res, list):
|
||||||
|
return res
|
||||||
|
if isinstance(res, dict):
|
||||||
|
return [res]
|
||||||
|
return []
|
||||||
|
if isinstance(body, list):
|
||||||
|
return body
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
c = DattoWorkplaceClient()
|
||||||
|
paths = c.endpoint_paths()
|
||||||
|
projects = c.projects()
|
||||||
|
print(f"[OK] Datto Workplace file API reachable at {c.base}; "
|
||||||
|
f"{len(paths)} endpoints, {len(projects)} project(s) visible")
|
||||||
|
return 0
|
||||||
|
except bc.BackupError as exc:
|
||||||
|
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
165
.claude/skills/datto-workplace/scripts/datto_workplace.py
Normal file
165
.claude/skills/datto-workplace/scripts/datto_workplace.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CLI for the `datto-workplace` skill -- read-only Datto Workplace backup
|
||||||
|
verification for the GPS audit.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
status file API reachable under Basic auth + endpoint/project count
|
||||||
|
projects [--json] list file-API projects (likely empty for this principal)
|
||||||
|
audit [--client NAME] [--rmm] [--json]
|
||||||
|
the GPS view: server-side projects (if any), optionally
|
||||||
|
cross-checked against GuruRMM endpoints (Datto Workplace
|
||||||
|
client installed + signed in?)
|
||||||
|
|
||||||
|
The Datto Workplace file API is FILE-ACCESS ONLY and this principal sees no shared
|
||||||
|
projects, so it CANNOT enumerate the team's backups on its own. RMM endpoint
|
||||||
|
detection (`audit --rmm`) is therefore the PRIMARY source: it detects the Datto
|
||||||
|
Workplace client on GuruRMM agents using precise patterns that do NOT false-match
|
||||||
|
Datto EDR / Datto RMM / Datto AV.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from datto_client import DattoWorkplaceClient # noqa: E402
|
||||||
|
import backup_common as bc # noqa: E402
|
||||||
|
|
||||||
|
# Precise detection patterns. "Datto Workplace" catches the DisplayName of both the
|
||||||
|
# v10 client ("Datto Workplace v10.x") and legacy v8 ("Datto Workplace Desktop");
|
||||||
|
# "Workplace2" catches the v10 install path / process family. A bare "Datto" would
|
||||||
|
# false-match "Datto EDR Agent", Datto RMM, and Datto AV -- do NOT use it.
|
||||||
|
DETECT_PATTERNS = ["Datto Workplace", "Workplace2"]
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(c: DattoWorkplaceClient, args) -> int:
|
||||||
|
paths = c.endpoint_paths()
|
||||||
|
projects = c.projects()
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({"base": c.base, "reachable": True,
|
||||||
|
"endpoints": paths, "project_count": len(projects)}, indent=2))
|
||||||
|
return 0
|
||||||
|
print(f"[OK] Datto Workplace file API reachable at {c.base} (Basic auth 200)")
|
||||||
|
print(f"[INFO] {len(paths)} endpoints exposed:")
|
||||||
|
for p in paths:
|
||||||
|
print(f" {p}")
|
||||||
|
print(f"[INFO] {len(projects)} project(s) visible to this API principal", end="")
|
||||||
|
if not projects:
|
||||||
|
print(" - file API cannot enumerate backups; use `audit --rmm`")
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_projects(c: DattoWorkplaceClient, args) -> int:
|
||||||
|
projects = c.projects()
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(projects, indent=2))
|
||||||
|
return 0
|
||||||
|
if not projects:
|
||||||
|
print("[INFO] no file-API projects visible to this principal.")
|
||||||
|
print("[INFO] The api-key has no projects shared to it, so GET /file/projects "
|
||||||
|
"returns []. This is expected - the Datto Workplace file API is file-access "
|
||||||
|
"only. Use `audit --rmm` to verify backups via GuruRMM endpoint detection.")
|
||||||
|
return 0
|
||||||
|
print(f"{'NAME':40} {'ID':38} {'FILES':>8}")
|
||||||
|
for p in projects:
|
||||||
|
print(f"{str(p.get('name') or '')[:40]:40} "
|
||||||
|
f"{str(p.get('id') or p.get('projectID') or '')[:38]:38} "
|
||||||
|
f"{str(p.get('fileCount') or p.get('count') or '?'):>8}")
|
||||||
|
print(f"\n{len(projects)} project(s)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_audit(c: DattoWorkplaceClient, args) -> int:
|
||||||
|
projects = c.projects()
|
||||||
|
result = {"backend": "datto-workplace", "base": c.base,
|
||||||
|
"file_api_projects": projects, "file_api_limited": not projects}
|
||||||
|
|
||||||
|
if args.rmm:
|
||||||
|
rmm = bc.RmmClient()
|
||||||
|
rmm.login()
|
||||||
|
checks = []
|
||||||
|
agents = rmm.find_agents(client_substr=args.client) if args.client else rmm.list_agents()
|
||||||
|
for a in agents:
|
||||||
|
det = rmm.detect_client(a, DETECT_PATTERNS)
|
||||||
|
if det.get("detected"):
|
||||||
|
checks.append(det)
|
||||||
|
result["rmm_endpoints_with_client"] = checks
|
||||||
|
result["rmm_agents_scanned"] = len(agents)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"=== Datto Workplace backup audit - {c.base} ===")
|
||||||
|
if projects:
|
||||||
|
print(f"{'PROJECT':40} {'ID':38} {'FILES':>8}")
|
||||||
|
for p in projects:
|
||||||
|
print(f"{str(p.get('name') or '')[:40]:40} "
|
||||||
|
f"{str(p.get('id') or p.get('projectID') or '')[:38]:38} "
|
||||||
|
f"{str(p.get('fileCount') or p.get('count') or '?'):>8}")
|
||||||
|
print(f"\n{len(projects)} file-API project(s)")
|
||||||
|
else:
|
||||||
|
print("[INFO] file API shows 0 projects (principal has none shared) - "
|
||||||
|
"server-side enumeration not available; relying on RMM detection.")
|
||||||
|
|
||||||
|
if args.rmm:
|
||||||
|
eps = result.get("rmm_endpoints_with_client", [])
|
||||||
|
scanned = result.get("rmm_agents_scanned", 0)
|
||||||
|
scope = f"client~'{args.client}'" if args.client else "ALL agents"
|
||||||
|
print(f"\n--- GuruRMM endpoints running the Datto Workplace client "
|
||||||
|
f"({len(eps)} of {scanned} scanned, {scope}) ---")
|
||||||
|
for e in eps:
|
||||||
|
det = e.get("detail") or {}
|
||||||
|
installed = det.get("installed") or []
|
||||||
|
prod = "-"
|
||||||
|
if isinstance(installed, list) and installed:
|
||||||
|
first = installed[0]
|
||||||
|
if isinstance(first, dict):
|
||||||
|
prod = f"{first.get('name') or '?'} {first.get('version') or ''}".strip()
|
||||||
|
elif isinstance(installed, dict):
|
||||||
|
prod = f"{installed.get('name') or '?'} {installed.get('version') or ''}".strip()
|
||||||
|
print(f" {str(e.get('hostname') or '')[:25]:25} "
|
||||||
|
f"client={str(e.get('client_name') or ''):20} "
|
||||||
|
f"product={prod}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(prog="datto-workplace",
|
||||||
|
description="Datto Workplace backup verification (GPS audit)")
|
||||||
|
p.add_argument("--url", help="override file-API base URL (default team file-API host)")
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
s = sub.add_parser("status"); s.add_argument("--json", action="store_true")
|
||||||
|
pr = sub.add_parser("projects"); pr.add_argument("--json", action="store_true")
|
||||||
|
a = sub.add_parser("audit")
|
||||||
|
a.add_argument("--client", help="scope RMM detection to agents whose client_name matches")
|
||||||
|
a.add_argument("--rmm", action="store_true", help="cross-check GuruRMM endpoints (primary path)")
|
||||||
|
a.add_argument("--json", action="store_true")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
c = DattoWorkplaceClient(args.url)
|
||||||
|
handlers = {"status": cmd_status, "projects": cmd_projects, "audit": cmd_audit}
|
||||||
|
try:
|
||||||
|
return handlers[args.cmd](c, args)
|
||||||
|
except bc.BackupError as exc:
|
||||||
|
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||||
|
# best-effort error logging per house rule
|
||||||
|
try:
|
||||||
|
root = bc.resolve_root()
|
||||||
|
bc.subprocess.run(["bash", str(root / ".claude/scripts/log-skill-error.sh"),
|
||||||
|
"datto-workplace", str(exc)[:200]], timeout=15)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: discord-dm
|
name: discord-dm
|
||||||
description: "Send a Discord message to an org member DM or team channel via the ClaudeTools bot - copy-paste-friendly delivery of links/commands the terminal would mangle; address people by name (mike/howard/rob/winter). Triggers: DM <person> in discord, send that link to my discord, ping <person>."
|
description: "Send OR read Discord messages to/from an org member DM or team channel via the ClaudeTools bot - copy-paste-friendly delivery of links/commands the terminal would mangle, and READ replies (coord/repo sync does not carry Discord answers); address people by name (mike/howard/rob/winter). Triggers: DM <person> in discord, send that link to my discord, ping <person>, did <person> reply/answer, read discord replies, check discord DMs."
|
||||||
---
|
---
|
||||||
|
|
||||||
# discord-dm — direct Discord messaging to the org
|
# discord-dm — direct Discord messaging to the org
|
||||||
@@ -27,9 +27,26 @@ DMs** and deliberate channel posts.
|
|||||||
```bash
|
```bash
|
||||||
bash .claude/scripts/discord-dm.sh <recipient> "message text"
|
bash .claude/scripts/discord-dm.sh <recipient> "message text"
|
||||||
echo "message text" | bash .claude/scripts/discord-dm.sh <recipient>
|
echo "message text" | bash .claude/scripts/discord-dm.sh <recipient>
|
||||||
|
bash .claude/scripts/discord-dm.sh read <recipient> [limit] # READ recent messages — SEE replies (default 15)
|
||||||
bash .claude/scripts/discord-dm.sh list # print known users + channels
|
bash .claude/scripts/discord-dm.sh list # print known users + channels
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Reading replies (`read`)
|
||||||
|
|
||||||
|
Sends are one-way, but people **answer** — always `read` before assuming silence. The bot
|
||||||
|
participates in every DM channel it opened, so `GET /channels/<id>/messages` returns full
|
||||||
|
content (the Message Content privileged intent only gates *gateway* events, not this REST
|
||||||
|
fetch). Works for users and channels:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .claude/scripts/discord-dm.sh read mike 10 # last 10 in the mike DM
|
||||||
|
bash .claude/scripts/discord-dm.sh read #dev-alerts # last 15 in #dev-alerts
|
||||||
|
```
|
||||||
|
|
||||||
|
Output is oldest-first; lines from **them** are marked `>>>`, lines from us are indented.
|
||||||
|
After DMing someone a question, `read` that user to check for their answer — coord/repo sync
|
||||||
|
does NOT carry Discord replies, so this is the only way to see them.
|
||||||
|
|
||||||
`<recipient>`:
|
`<recipient>`:
|
||||||
|
|
||||||
| Form | Effect |
|
| Form | Effect |
|
||||||
|
|||||||
@@ -238,12 +238,12 @@ for ln in sys.stdin:
|
|||||||
if e.get("type")=="text": t.append(e.get("data",""))
|
if e.get("type")=="text": t.append(e.get("data",""))
|
||||||
print("".join(t).strip())' < "$OUT")"
|
print("".join(t).strip())' < "$OUT")"
|
||||||
if [ "$GRC" -eq 0 ] && [ -n "$ans" ]; then printf '%s\n' "$ans"; exit 0; fi
|
if [ "$GRC" -eq 0 ] && [ -n "$ans" ]; then printf '%s\n' "$ans"; exit 0; fi
|
||||||
echo "[$SELF] grok xsearch did not finish (rc=$GRC) -> falling back to gemini search" >&2
|
echo "[$SELF] grok xsearch did not finish (rc=$GRC) -> falling back to agy search" >&2
|
||||||
_logerr "grok xsearch incomplete (rc=$GRC); auto-fell back to gemini" --context "mode=xsearch"
|
_logerr "grok xsearch incomplete (rc=$GRC); auto-fell back to agy" --context "mode=xsearch"
|
||||||
GEM="$REPO_ROOT/.claude/skills/agy/scripts/ask-gemini.sh"
|
GEM="$REPO_ROOT/.claude/skills/agy/scripts/ask-agy.sh"
|
||||||
if [ -f "$GEM" ]; then echo "[grok xsearch timed out -> answered via gemini search]"; exec bash "$GEM" search "$Q"; fi
|
if [ -f "$GEM" ]; then echo "[grok xsearch timed out -> answered via agy search]"; exec bash "$GEM" search "$Q"; fi
|
||||||
[ -n "$ans" ] && { printf '%s\n' "$ans"; exit 0; } # last resort: whatever partial streamed
|
[ -n "$ans" ] && { printf '%s\n' "$ans"; exit 0; } # last resort: whatever partial streamed
|
||||||
echo "[$SELF] no result (grok timed out; gemini fallback unavailable)" >&2; exit 1
|
echo "[$SELF] no result (grok timed out; agy fallback unavailable)" >&2; exit 1
|
||||||
;;
|
;;
|
||||||
review|file)
|
review|file)
|
||||||
[ -z "${1:-}" ] && { echo "usage: $SELF review <file-path> [instructions]" >&2; exit 2; }
|
[ -z "${1:-}" ] && { echo "usage: $SELF review <file-path> [instructions]" >&2; exit 2; }
|
||||||
|
|||||||
3
.claude/skills/msp360/.gitignore
vendored
Normal file
3
.claude/skills/msp360/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Skill scratch/cache - never commit. Auth token is per-run (not cached to disk).
|
||||||
|
.cache/
|
||||||
|
__pycache__/
|
||||||
108
.claude/skills/msp360/SKILL.md
Normal file
108
.claude/skills/msp360/SKILL.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
name: msp360
|
||||||
|
description: "Manage ACG's MSP360 Managed Backup Service (MBS) tenant via the provider REST API: the authoritative fleet backup-status source (who is backing up, last run, failures, stale plans) plus user/company/license provisioning. Reads run freely; writes (create/delete user or company, grant/release/revoke license) are gated --confirm. Triggers: msp360, mspbackups, managed backup, is X backing up, backup status, backup failures, create backup user, add backup company, grant backup license."
|
||||||
|
---
|
||||||
|
|
||||||
|
# MSP360 Managed Backup Service Skill
|
||||||
|
|
||||||
|
Live client for ACG's **MSP360 Managed Backup Service (MBS)** provider tenant. This is the
|
||||||
|
**authoritative answer to "is client X actually backing up"** — the monitoring feed reports
|
||||||
|
every backup plan's last run, status, and data across the whole fleet. B2/Seafile/ownCloud
|
||||||
|
are just where the data lands; MSP360 is where you see whether a backup *ran*.
|
||||||
|
|
||||||
|
Built as a sibling to the `b2` / `seafile` / `owncloud` / `datto-workplace` backup skills;
|
||||||
|
shares `.claude/scripts/backup_common.py` for repo-root/vault/HTTP plumbing.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MSP=".claude/skills/msp360/scripts/msp360.py"
|
||||||
|
|
||||||
|
# --- reads (free) ---
|
||||||
|
py "$MSP" status # reachable + counts + plans needing attention
|
||||||
|
py "$MSP" monitoring # every plan: company/computer/status/last-run/data
|
||||||
|
py "$MSP" monitoring --failed # only plans NOT in a clean state (the triage view)
|
||||||
|
py "$MSP" monitoring --company Dataforth # scope to one client
|
||||||
|
py "$MSP" monitoring --stale 30 # plans whose last run is >=30 days old (or never)
|
||||||
|
py "$MSP" companies # 37 companies with ids + storage limits
|
||||||
|
py "$MSP" users --company "Business Services" # backup users (+ space used) in a company
|
||||||
|
py "$MSP" licenses --available # free vs taken licenses
|
||||||
|
py "$MSP" billing --json # billing feed
|
||||||
|
py "$MSP" raw GET /api/Users/{id} # generic passthrough for any GET endpoint
|
||||||
|
|
||||||
|
# --- writes (require --confirm; without it you get a dry-run preview) ---
|
||||||
|
py "$MSP" create-company --name "New Client LLC" --confirm
|
||||||
|
py "$MSP" create-user --email backup@client.com --company "New Client LLC" \
|
||||||
|
--first Jane --last Doe --notify admin@acg.com --confirm
|
||||||
|
py "$MSP" grant-license --user-id <UUID> --license-id <UUID> --confirm
|
||||||
|
py "$MSP" release-license --user-id <UUID> --license-id <UUID> --confirm
|
||||||
|
py "$MSP" delete-user --id <UUID> [--keep-data] --confirm
|
||||||
|
py "$MSP" delete-company --id <UUID> --confirm
|
||||||
|
py "$MSP" raw POST /api/Accounts/CreateDestination --data '{...}' --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `--json` to any read for machine-readable output. `--base` overrides the API URL.
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
Loaded at runtime from the SOPS vault (never hardcoded):
|
||||||
|
**`msp-tools/msp360-api.sops.yaml`** -> `credentials.login` / `credentials.password`.
|
||||||
|
These are the **API-specific** creds
|
||||||
|
generated in the MSP360 console (Settings > General) by the main admin account — NOT the
|
||||||
|
console login. Auth flow: `POST /api/Provider/Login {UserName, Password}` -> Bearer
|
||||||
|
`access_token` (per-run, never cached to disk) -> `Authorization: Bearer <token>` on every
|
||||||
|
request. Base: `https://api.mspbackups.com`.
|
||||||
|
|
||||||
|
## What "backing up?" looks like
|
||||||
|
|
||||||
|
`monitoring` returns one row per backup plan with `Status` (0/1=Success, 2=Warning,
|
||||||
|
3=Failed, 4=Running, 6=Interrupted, 7=Completed-with-warnings), `LastStart`, `DataCopied`,
|
||||||
|
`CompanyName`, `ComputerName`, `PlanName`. Key reads:
|
||||||
|
|
||||||
|
- **A company with 0 rows in `monitoring` is not backing up** even if it exists (this is how
|
||||||
|
the GPS audit found Arizona Medical Transit + T&C Sorensen billed-but-unprotected).
|
||||||
|
- `--failed` surfaces failing/warning plans; `--stale N` surfaces plans that stopped running.
|
||||||
|
- A B2 bucket with files does NOT prove a recent backup — always confirm with `monitoring`.
|
||||||
|
|
||||||
|
## Writes & safety
|
||||||
|
|
||||||
|
- **Every write is gated `--confirm`.** Without it the command prints a `[DRY-RUN]` line (and,
|
||||||
|
for create-*, the exact JSON payload) and changes nothing.
|
||||||
|
- `delete-user` defaults to removing metadata **and all backup data**; pass `--keep-data` to
|
||||||
|
drop only the metadata (`DELETE /api/Users/{id}/Account`) and retain the backups.
|
||||||
|
- `delete-company` / `delete-user` print a `[WARNING]` describing the blast radius first.
|
||||||
|
- create-user / grant-license payloads are built from the live API schema (User fields:
|
||||||
|
Email/FirstName/LastName/Company/Enabled/NotificationEmails; grant: UserID + LicenseID).
|
||||||
|
On first real provisioning, eyeball the API's response — it returns the created object or a
|
||||||
|
validation error, which the command prints verbatim.
|
||||||
|
|
||||||
|
## Full endpoint surface (via `raw` when a typed command doesn't exist)
|
||||||
|
|
||||||
|
The MBS API groups (all reachable through `raw <METHOD> <path>`): **Provider** (login),
|
||||||
|
**Users** (GET/POST/PUT, DELETE `/{id}` or `/{id}/Account`), **Companies** (GET/POST/PUT/DELETE),
|
||||||
|
**Monitoring** (GET), **Licenses** (GET, POST Grant/Release/Revoke), **Destinations**
|
||||||
|
(GET/POST/PUT/DELETE), **Accounts** (GET/POST/PUT + Add/Create/Edit/RemoveDestination),
|
||||||
|
**Packages** (GET/POST/PUT/DELETE), **Billing** (GET/PUT), **Builds** (GET, POST
|
||||||
|
RequestCustomBuilds), **Administrators** (GET/POST/PUT/DELETE). Reference:
|
||||||
|
`https://help.mspbackups.com/mbs-api-specification/methods`.
|
||||||
|
|
||||||
|
## Notes / gotchas
|
||||||
|
|
||||||
|
- **`/api/Computers` returns HTTP 400 "Remote Management API methods are not enabled"** —
|
||||||
|
that feature is off on this account. `computers` prints a clean [INFO] and points you at
|
||||||
|
`users` + `monitoring`, which cover endpoint/backup data without it.
|
||||||
|
- **Licensing API is also feature-gated:** `POST /api/Licenses/Grant|Release|Revoke` returns
|
||||||
|
HTTP 400 "Licensing API methods are not enabled for your account" — license moves must be
|
||||||
|
done in the mspbackups.com console (Mike's admin account) until that's enabled.
|
||||||
|
- **Release/Revoke require `--license-id`:** the API rejects a Release/Revoke payload without
|
||||||
|
`LicenseID` (HTTP 400 ModelState). The CLI now enforces it; get the ID from `licenses --json`.
|
||||||
|
- **Git-Bash path mangling:** a leading-slash `raw` path like `/api/Users` gets rewritten to
|
||||||
|
`C:/Program Files/Git/api/Users` by MSYS. The `raw` command auto-recovers the `/api/...`
|
||||||
|
tail, so pass the path normally.
|
||||||
|
- MSP360 CompanyName is the join key to B2 buckets (e.g. `ACG-PST`=Peaceful Spirit,
|
||||||
|
`ACG-TCA`=Tucson Coin and Autograph) — useful when reconciling destinations.
|
||||||
|
- Backup *targets* are a per-client decision: report mismatches, don't provision jobs off a
|
||||||
|
billing count. See memory `feedback_backup_targets_never_guessed` +
|
||||||
|
`reference_msp360_backup_monitoring`.
|
||||||
|
- Failures are logged to the fleet `errorlog.md` via the canonical helper; expected
|
||||||
|
conditions (feature-gated endpoint, no matching rows) are not.
|
||||||
432
.claude/skills/msp360/scripts/msp360.py
Normal file
432
.claude/skills/msp360/scripts/msp360.py
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""MSP360 Managed Backup Service (MBS) CLI.
|
||||||
|
|
||||||
|
Read the fleet backup state and manage users / companies / licenses via the MBS
|
||||||
|
provider API. Reads run freely; every WRITE requires --confirm (a dry-run preview is
|
||||||
|
printed without it). See SKILL.md for the trigger list and examples.
|
||||||
|
|
||||||
|
Reads: status | monitoring | companies | users | computers | licenses | billing
|
||||||
|
Writes: create-company | delete-company | create-user | delete-user
|
||||||
|
grant-license | release-license | revoke-license | raw
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as _dt
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from msp360_client import Msp360Client, Msp360Error, STATUS_TEXT, BAD_STATUS # noqa: E402
|
||||||
|
import backup_common as bc # noqa: E402 (msp360_client put .claude/scripts on sys.path)
|
||||||
|
|
||||||
|
|
||||||
|
# --- helpers ------------------------------------------------------------------
|
||||||
|
def _root() -> Path:
|
||||||
|
# honor CLAUDETOOLS_ROOT / identity.json, same as the vault/HTTP plumbing.
|
||||||
|
return bc.resolve_root()
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(data, what: str) -> list:
|
||||||
|
"""A read endpoint promised a JSON array; a non-list 2xx body is anomalous."""
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
raise Msp360Error(f"expected a list from {what}, got {type(data).__name__}: {str(data)[:160]}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_error(brief: str, context: str = "") -> None:
|
||||||
|
"""Best-effort fleet error log; never breaks the caller (per CLAUDE.md)."""
|
||||||
|
try:
|
||||||
|
script = _root() / ".claude" / "scripts" / "log-skill-error.sh"
|
||||||
|
if not script.exists():
|
||||||
|
return
|
||||||
|
cmd = ["bash", str(script), "msp360", brief]
|
||||||
|
if context:
|
||||||
|
cmd += ["--context", context]
|
||||||
|
subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def out(obj, as_json: bool) -> None:
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(obj, indent=2, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def human(n) -> str:
|
||||||
|
try:
|
||||||
|
n = float(n)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(n)
|
||||||
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||||
|
if abs(n) < 1024:
|
||||||
|
return f"{n:.1f}{unit}"
|
||||||
|
n /= 1024
|
||||||
|
return f"{n:.1f}PB"
|
||||||
|
|
||||||
|
|
||||||
|
def _days_since(ts: str):
|
||||||
|
"""Whole days since an ISO timestamp. None if empty OR unparseable (callers must
|
||||||
|
distinguish 'never ran' from 'could not parse' via the raw value themselves)."""
|
||||||
|
if not ts:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = _dt.datetime.fromisoformat(ts.strip().replace("Z", "+00:00"))
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=_dt.timezone.utc)
|
||||||
|
return (_dt.datetime.now(_dt.timezone.utc) - dt).days
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def require_confirm(args, action: str) -> bool:
|
||||||
|
"""Return True to proceed; if --confirm absent, print the dry-run and return False."""
|
||||||
|
if getattr(args, "confirm", False):
|
||||||
|
return True
|
||||||
|
print(f"[DRY-RUN] Would: {action}")
|
||||||
|
print("[DRY-RUN] Re-run with --confirm to execute. Nothing was changed.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --- read commands ------------------------------------------------------------
|
||||||
|
def cmd_status(c: Msp360Client, args) -> int:
|
||||||
|
comps = c.companies()
|
||||||
|
users = c.users()
|
||||||
|
mon = c.monitoring()
|
||||||
|
if args.json:
|
||||||
|
out({"companies": len(comps), "users": len(users), "plans": len(mon)}, True)
|
||||||
|
return 0
|
||||||
|
bad = sum(1 for m in mon if m.get("Status") in BAD_STATUS)
|
||||||
|
print(f"[OK] MSP360 MBS reachable at {c.base}")
|
||||||
|
print(f"[INFO] companies={len(comps)} users={len(users)} backup plans={len(mon)} "
|
||||||
|
f"plans needing attention={bad}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_monitoring(c: Msp360Client, args) -> int:
|
||||||
|
mon = _as_list(c.monitoring(), "monitoring")
|
||||||
|
rows = []
|
||||||
|
for m in mon:
|
||||||
|
if args.company and args.company.lower() not in (m.get("CompanyName") or "").lower():
|
||||||
|
continue
|
||||||
|
status = m.get("Status")
|
||||||
|
last_raw = m.get("LastStart") or ""
|
||||||
|
never = not last_raw
|
||||||
|
age = _days_since(last_raw)
|
||||||
|
# --failed = triage: anything that is NOT a clean completed run and is not
|
||||||
|
# in-progress. Includes never-run, Unknown(5) and null status (dead plans).
|
||||||
|
if args.failed:
|
||||||
|
clean_success = status in (0, 1) and not never
|
||||||
|
if clean_success or status == 4: # 4 = Running
|
||||||
|
continue
|
||||||
|
# --stale = last run older than N days, OR never ran. An unparseable-but-present
|
||||||
|
# timestamp is NOT claimed stale (avoid a false "not backing up").
|
||||||
|
if args.stale is not None:
|
||||||
|
if never:
|
||||||
|
pass
|
||||||
|
elif age is not None and age >= args.stale:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
rows.append((m, age))
|
||||||
|
if args.json:
|
||||||
|
out([r[0] for r in rows], True)
|
||||||
|
return 0
|
||||||
|
if not rows:
|
||||||
|
print("[INFO] no plans match the filter.")
|
||||||
|
return 0
|
||||||
|
print(f"{'COMPANY':<26} {'COMPUTER':<20} {'PLAN':<26} {'STATUS':<12} {'LAST START':<19} DATA")
|
||||||
|
for m, age in sorted(rows, key=lambda r: ((r[0].get('CompanyName') or '').lower(),
|
||||||
|
r[0].get('ComputerName') or '')):
|
||||||
|
st = STATUS_TEXT.get(m.get("Status"), str(m.get("Status")))
|
||||||
|
last = m.get("LastStart") or "never"
|
||||||
|
agestr = f" ({age}d)" if age is not None and age >= 30 else ""
|
||||||
|
print(f"{(m.get('CompanyName') or '')[:25]:<26} {(m.get('ComputerName') or '')[:19]:<20} "
|
||||||
|
f"{(m.get('PlanName') or '')[:25]:<26} {st:<12} {last[:19]:<19}{agestr} "
|
||||||
|
f"{human(m.get('DataCopied', 0))}")
|
||||||
|
print(f"\n[INFO] {len(rows)} plan(s). Filters: "
|
||||||
|
f"company={args.company or '-'} failed={args.failed} stale>={args.stale}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_companies(c: Msp360Client, args) -> int:
|
||||||
|
comps = _as_list(c.companies(), "companies")
|
||||||
|
if args.json:
|
||||||
|
out(comps, True)
|
||||||
|
return 0
|
||||||
|
for co in sorted(comps, key=lambda x: (x.get("Name") or "").lower()):
|
||||||
|
lim = co.get("StorageLimit")
|
||||||
|
limstr = "unlimited" if lim in (-1, None) else human(lim)
|
||||||
|
print(f" {(co.get('Name') or ''):<34} id={co.get('Id')} storage_limit={limstr}")
|
||||||
|
print(f"\n[INFO] {len(comps)} companies")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_users(c: Msp360Client, args) -> int:
|
||||||
|
users = _as_list(c.users(), "users")
|
||||||
|
if args.company:
|
||||||
|
users = [u for u in users if args.company.lower() in (u.get("Company") or "").lower()]
|
||||||
|
if args.json:
|
||||||
|
out(users, True)
|
||||||
|
return 0
|
||||||
|
for u in sorted(users, key=lambda x: (x.get("Company") or "", x.get("Email") or "")):
|
||||||
|
en = "enabled" if u.get("Enabled") else "DISABLED"
|
||||||
|
print(f" {(u.get('Email') or ''):<34} {(u.get('Company') or ''):<26} "
|
||||||
|
f"{en:<9} used={human(u.get('SpaceUsed', 0))} id={u.get('ID')}")
|
||||||
|
print(f"\n[INFO] {len(users)} users" + (f" in '{args.company}'" if args.company else ""))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_computers(c: Msp360Client, args) -> int:
|
||||||
|
try:
|
||||||
|
comps = _as_list(c.computers(), "computers")
|
||||||
|
except Msp360Error as exc:
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "remote management" in msg or "not enabled" in msg or "not available" in msg:
|
||||||
|
print("[INFO] /api/Computers is gated behind the Remote Management API feature, "
|
||||||
|
"which is not enabled on this MSP360 account. Use `users` + `monitoring` "
|
||||||
|
"for endpoint/backup data instead.")
|
||||||
|
return 0
|
||||||
|
raise
|
||||||
|
if args.json:
|
||||||
|
out(comps, True)
|
||||||
|
return 0
|
||||||
|
for m in comps:
|
||||||
|
print(f" {(m.get('Name') or m.get('ComputerName') or ''):<24} "
|
||||||
|
f"user={m.get('User') or m.get('UserEmail') or ''} hid={m.get('Hid') or m.get('ID')}")
|
||||||
|
print(f"\n[INFO] {len(comps) if isinstance(comps, list) else 0} computers")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_licenses(c: Msp360Client, args) -> int:
|
||||||
|
lic = _as_list(c.licenses(available=True if args.available else None), "licenses")
|
||||||
|
if args.json:
|
||||||
|
out(lic, True)
|
||||||
|
return 0
|
||||||
|
taken = sum(1 for x in lic if x.get("IsTaken"))
|
||||||
|
for x in lic:
|
||||||
|
state = "TAKEN" if x.get("IsTaken") else "free"
|
||||||
|
num = x.get("Number")
|
||||||
|
num = "" if num is None else num
|
||||||
|
print(f" #{str(num):<5} {(x.get('LicenseType') or ''):<22} {state:<6} "
|
||||||
|
f"exp={(x.get('DateExpired') or '')[:10]} user={x.get('User') or '-'}")
|
||||||
|
print(f"\n[INFO] {len(lic)} licenses ({taken} taken, {len(lic) - taken} free)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_billing(c: Msp360Client, args) -> int:
|
||||||
|
out(c.billing(), True) # billing has no table form; always JSON
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- write commands -----------------------------------------------------------
|
||||||
|
def cmd_create_company(c: Msp360Client, args) -> int:
|
||||||
|
if not require_confirm(args, f"create company '{args.name}'"):
|
||||||
|
return 0
|
||||||
|
res = c.create_company(args.name)
|
||||||
|
print(f"[OK] created company '{args.name}'")
|
||||||
|
out(res, True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_delete_company(c: Msp360Client, args) -> int:
|
||||||
|
print(f"[WARNING] Deleting company id={args.id} removes the company record.")
|
||||||
|
if not require_confirm(args, f"DELETE company id={args.id}"):
|
||||||
|
return 0
|
||||||
|
res = c.delete_company(args.id)
|
||||||
|
print(f"[OK] deleted company id={args.id}")
|
||||||
|
out(res, True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_create_user(c: Msp360Client, args) -> int:
|
||||||
|
payload = {
|
||||||
|
"Email": args.email,
|
||||||
|
"FirstName": args.first or "",
|
||||||
|
"LastName": args.last or "",
|
||||||
|
"Company": args.company,
|
||||||
|
"Enabled": True,
|
||||||
|
"NotificationEmails": [args.notify] if args.notify else [],
|
||||||
|
}
|
||||||
|
if args.password:
|
||||||
|
payload["Password"] = args.password
|
||||||
|
if not require_confirm(args, f"create user '{args.email}' in company '{args.company}'"):
|
||||||
|
preview = dict(payload)
|
||||||
|
if "Password" in preview:
|
||||||
|
preview["Password"] = "***redacted***" # never echo a secret to the transcript
|
||||||
|
out(preview, True)
|
||||||
|
return 0
|
||||||
|
res = c.create_user(payload)
|
||||||
|
print(f"[OK] created user '{args.email}'")
|
||||||
|
out(res, True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_delete_user(c: Msp360Client, args) -> int:
|
||||||
|
scope = "metadata only (backup data KEPT)" if args.keep_data else "metadata AND all backup data"
|
||||||
|
print(f"[WARNING] Deleting user id={args.id}: {scope}.")
|
||||||
|
if not require_confirm(args, f"DELETE user id={args.id} ({scope})"):
|
||||||
|
return 0
|
||||||
|
res = c.delete_user(args.id, keep_data=args.keep_data)
|
||||||
|
print(f"[OK] deleted user id={args.id}")
|
||||||
|
out(res, True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_grant_license(c: Msp360Client, args) -> int:
|
||||||
|
payload = {"UserID": args.user_id}
|
||||||
|
if args.license_id:
|
||||||
|
payload["LicenseID"] = args.license_id
|
||||||
|
if not require_confirm(args, f"grant license {args.license_id or '(auto)'} to user {args.user_id}"):
|
||||||
|
out(payload, True)
|
||||||
|
return 0
|
||||||
|
res = c.grant_license(payload)
|
||||||
|
print("[OK] license granted")
|
||||||
|
out(res, True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_release_license(c: Msp360Client, args) -> int:
|
||||||
|
payload = {"UserID": args.user_id}
|
||||||
|
if args.license_id:
|
||||||
|
payload["LicenseID"] = args.license_id
|
||||||
|
if not require_confirm(args, f"release license from user {args.user_id}"):
|
||||||
|
return 0
|
||||||
|
res = c.release_license(payload)
|
||||||
|
print("[OK] license released")
|
||||||
|
out(res, True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_revoke_license(c: Msp360Client, args) -> int:
|
||||||
|
payload = {"UserID": args.user_id}
|
||||||
|
if args.license_id:
|
||||||
|
payload["LicenseID"] = args.license_id
|
||||||
|
if not require_confirm(args, f"revoke license from user {args.user_id}"):
|
||||||
|
return 0
|
||||||
|
res = c.revoke_license(payload)
|
||||||
|
print("[OK] license revoked")
|
||||||
|
out(res, True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_raw(c: Msp360Client, args) -> int:
|
||||||
|
method = args.method.upper()
|
||||||
|
body = json.loads(args.data) if args.data else None
|
||||||
|
# Git-Bash rewrites a leading-slash arg into a Windows path (e.g.
|
||||||
|
# '/api/Companies' -> 'C:/Program Files/Git/api/Companies'). Recover the /api/... tail.
|
||||||
|
path = args.path
|
||||||
|
if "/api/" in path and not path.startswith("/api"):
|
||||||
|
path = path[path.index("/api/"):]
|
||||||
|
elif not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
if method != "GET" and not require_confirm(args, f"{method} {path} {args.data or ''}"):
|
||||||
|
return 0
|
||||||
|
res = c.request(method, path, body=body)
|
||||||
|
out(res, True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- argparse -----------------------------------------------------------------
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(prog="msp360", description="MSP360 Managed Backup Service CLI")
|
||||||
|
p.add_argument("--base", help="override API base URL")
|
||||||
|
sub = p.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
def add_json(sp):
|
||||||
|
sp.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
||||||
|
return sp
|
||||||
|
|
||||||
|
add_json(sub.add_parser("status", help="reachability + fleet counts")).set_defaults(func=cmd_status)
|
||||||
|
|
||||||
|
sp = sub.add_parser("monitoring", help="fleet backup status (the primary read)")
|
||||||
|
sp.add_argument("--company", help="filter by company name substring")
|
||||||
|
sp.add_argument("--failed", action="store_true", help="only plans not in a clean state")
|
||||||
|
sp.add_argument("--stale", type=int, metavar="DAYS",
|
||||||
|
help="only plans whose last run is >= DAYS old (or never)")
|
||||||
|
add_json(sp)
|
||||||
|
sp.set_defaults(func=cmd_monitoring)
|
||||||
|
|
||||||
|
add_json(sub.add_parser("companies", help="list companies")).set_defaults(func=cmd_companies)
|
||||||
|
|
||||||
|
sp = sub.add_parser("users", help="list backup users")
|
||||||
|
sp.add_argument("--company", help="filter by company name substring")
|
||||||
|
add_json(sp)
|
||||||
|
sp.set_defaults(func=cmd_users)
|
||||||
|
|
||||||
|
add_json(sub.add_parser("computers", help="list managed endpoints")).set_defaults(func=cmd_computers)
|
||||||
|
|
||||||
|
sp = sub.add_parser("licenses", help="list licenses")
|
||||||
|
sp.add_argument("--available", action="store_true", help="only free/available licenses")
|
||||||
|
add_json(sp)
|
||||||
|
sp.set_defaults(func=cmd_licenses)
|
||||||
|
|
||||||
|
sub.add_parser("billing", help="billing info (JSON)").set_defaults(func=cmd_billing)
|
||||||
|
|
||||||
|
# writes
|
||||||
|
sp = sub.add_parser("create-company", help="create a company [--confirm]")
|
||||||
|
sp.add_argument("--name", required=True)
|
||||||
|
sp.add_argument("--confirm", action="store_true")
|
||||||
|
sp.set_defaults(func=cmd_create_company)
|
||||||
|
|
||||||
|
sp = sub.add_parser("delete-company", help="delete a company by id [--confirm]")
|
||||||
|
sp.add_argument("--id", required=True)
|
||||||
|
sp.add_argument("--confirm", action="store_true")
|
||||||
|
sp.set_defaults(func=cmd_delete_company)
|
||||||
|
|
||||||
|
sp = sub.add_parser("create-user", help="create a backup user [--confirm]")
|
||||||
|
sp.add_argument("--email", required=True)
|
||||||
|
sp.add_argument("--company", required=True)
|
||||||
|
sp.add_argument("--first")
|
||||||
|
sp.add_argument("--last")
|
||||||
|
sp.add_argument("--password")
|
||||||
|
sp.add_argument("--notify", help="notification email")
|
||||||
|
sp.add_argument("--confirm", action="store_true")
|
||||||
|
sp.set_defaults(func=cmd_create_user)
|
||||||
|
|
||||||
|
sp = sub.add_parser("delete-user", help="delete a user by id [--confirm]")
|
||||||
|
sp.add_argument("--id", required=True)
|
||||||
|
sp.add_argument("--keep-data", action="store_true", help="keep backup data (metadata only)")
|
||||||
|
sp.add_argument("--confirm", action="store_true")
|
||||||
|
sp.set_defaults(func=cmd_delete_user)
|
||||||
|
|
||||||
|
# API requires LicenseID for Release/Revoke (HTTP 400 without it); Grant can
|
||||||
|
# auto-pick a license, so it stays optional there.
|
||||||
|
for name, fn, verb, lic_required in (("grant-license", cmd_grant_license, "grant", False),
|
||||||
|
("release-license", cmd_release_license, "release", True),
|
||||||
|
("revoke-license", cmd_revoke_license, "revoke", True)):
|
||||||
|
sp = sub.add_parser(name, help=f"{verb} a license [--confirm]")
|
||||||
|
sp.add_argument("--user-id", required=True)
|
||||||
|
sp.add_argument("--license-id", required=lic_required)
|
||||||
|
sp.add_argument("--confirm", action="store_true")
|
||||||
|
sp.set_defaults(func=fn)
|
||||||
|
|
||||||
|
sp = sub.add_parser("raw", help="raw API call (writes need --confirm)")
|
||||||
|
sp.add_argument("method")
|
||||||
|
sp.add_argument("path", help="e.g. /api/Users/{id}")
|
||||||
|
sp.add_argument("--data", help="JSON request body")
|
||||||
|
sp.add_argument("--confirm", action="store_true")
|
||||||
|
sp.set_defaults(func=cmd_raw)
|
||||||
|
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
client = Msp360Client(base=args.base)
|
||||||
|
try:
|
||||||
|
return args.func(client, args)
|
||||||
|
except bc.BackupError as exc: # includes Msp360Error + vault/HTTP transport errors
|
||||||
|
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||||
|
log_error(str(exc)[:200], context=f"command={args.command}")
|
||||||
|
return 1
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"[ERROR] unexpected: {exc}", file=sys.stderr)
|
||||||
|
log_error(f"unexpected: {exc}"[:200], context=f"command={args.command}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
119
.claude/skills/msp360/scripts/msp360_client.py
Normal file
119
.claude/skills/msp360/scripts/msp360_client.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""MSP360 Managed Backup Service (MBS) API client.
|
||||||
|
|
||||||
|
Full CRUD over the provider REST API at https://api.mspbackups.com. Auth is a Bearer
|
||||||
|
token from POST /api/Provider/Login (login+password generated in MSP360 console
|
||||||
|
Settings > General, vaulted at msp-tools/msp360-api.sops.yaml).
|
||||||
|
|
||||||
|
Reuses `.claude/scripts/backup_common.py` for repo-root resolution, the canonical SOPS
|
||||||
|
vault read, and stdlib JSON HTTP -- same plumbing as the seafile/owncloud/datto-workplace
|
||||||
|
sibling skills. Standalone: stdlib-only transport.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
# backup_common lives at <root>/.claude/scripts/ ; this file is
|
||||||
|
# <root>/.claude/skills/msp360/scripts/msp360_client.py -> parents[3] == <root>/.claude
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts"))
|
||||||
|
import backup_common as bc # noqa: E402
|
||||||
|
|
||||||
|
VAULT_ENTRY = "msp-tools/msp360-api.sops.yaml"
|
||||||
|
DEFAULT_BASE = "https://api.mspbackups.com"
|
||||||
|
|
||||||
|
# Monitoring plan Status codes (observed + MBS convention).
|
||||||
|
STATUS_TEXT = {
|
||||||
|
0: "Success", 1: "Success", 2: "Warning", 3: "Failed",
|
||||||
|
4: "Running", 5: "Unknown", 6: "Interrupted", 7: "Completed/warnings",
|
||||||
|
}
|
||||||
|
# Statuses that mean "this run did NOT produce a clean backup".
|
||||||
|
BAD_STATUS = {2, 3, 6, 7}
|
||||||
|
|
||||||
|
|
||||||
|
class Msp360Error(bc.BackupError):
|
||||||
|
"""MSP360 transport / auth / API error (carries optional HTTP status)."""
|
||||||
|
|
||||||
|
|
||||||
|
class Msp360Client:
|
||||||
|
"""Thin MSP360 MBS API client: login once, then typed helpers over the endpoints."""
|
||||||
|
|
||||||
|
def __init__(self, base: Optional[str] = None):
|
||||||
|
self.base = (base or DEFAULT_BASE).rstrip("/")
|
||||||
|
self._token: Optional[str] = None
|
||||||
|
|
||||||
|
# --- auth -----------------------------------------------------------------
|
||||||
|
def login(self) -> None:
|
||||||
|
login = bc.vault_get_field(VAULT_ENTRY, "credentials.login")
|
||||||
|
pw = bc.vault_get_field(VAULT_ENTRY, "credentials.password")
|
||||||
|
st, body = bc.http_json(
|
||||||
|
"POST", f"{self.base}/api/Provider/Login",
|
||||||
|
body={"UserName": login, "Password": pw})
|
||||||
|
if st != 200 or not isinstance(body, dict) or not body.get("access_token"):
|
||||||
|
raise Msp360Error(f"MSP360 login failed (HTTP {st}): {body}", status=st)
|
||||||
|
self._token = body["access_token"]
|
||||||
|
|
||||||
|
def _auth(self) -> dict:
|
||||||
|
if not self._token:
|
||||||
|
self.login()
|
||||||
|
return {"Authorization": "Bearer " + str(self._token)}
|
||||||
|
|
||||||
|
def request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
|
||||||
|
"""One API call. Raises Msp360Error on any non-2xx (with the API's error body)."""
|
||||||
|
st, data = bc.http_json(method, f"{self.base}{path}", headers=self._auth(), body=body)
|
||||||
|
if st < 200 or st >= 300:
|
||||||
|
raise Msp360Error(f"{method} {path} -> HTTP {st}: {data}", status=st)
|
||||||
|
return data
|
||||||
|
|
||||||
|
# --- reads ----------------------------------------------------------------
|
||||||
|
def monitoring(self, user_id: Optional[str] = None) -> list:
|
||||||
|
return self.request("GET", f"/api/Monitoring/{user_id}" if user_id else "/api/Monitoring")
|
||||||
|
|
||||||
|
def companies(self) -> list:
|
||||||
|
return self.request("GET", "/api/Companies")
|
||||||
|
|
||||||
|
def users(self) -> list:
|
||||||
|
return self.request("GET", "/api/Users")
|
||||||
|
|
||||||
|
def computers(self, offset: int = 0, count: int = 1000) -> list:
|
||||||
|
return self.request("GET", f"/api/Computers/{offset}/{count}")
|
||||||
|
|
||||||
|
def licenses(self, available: Optional[bool] = None) -> list:
|
||||||
|
path = "/api/Licenses"
|
||||||
|
if available is not None:
|
||||||
|
path += f"?isAvailable={'true' if available else 'false'}"
|
||||||
|
return self.request("GET", path)
|
||||||
|
|
||||||
|
def billing(self) -> Any:
|
||||||
|
return self.request("GET", "/api/Billing")
|
||||||
|
|
||||||
|
# --- writes (companies) ---------------------------------------------------
|
||||||
|
def create_company(self, name: str, **extra: Any) -> Any:
|
||||||
|
payload = {"Name": name, "StorageLimit": -1}
|
||||||
|
payload.update(extra)
|
||||||
|
return self.request("POST", "/api/Companies", body=payload)
|
||||||
|
|
||||||
|
def delete_company(self, company_id: str) -> Any:
|
||||||
|
return self.request("DELETE", f"/api/Companies/{company_id}")
|
||||||
|
|
||||||
|
# --- writes (users) -------------------------------------------------------
|
||||||
|
# Property edits (PUT /api/Users, /api/Companies) are reachable via the CLI's
|
||||||
|
# `raw PUT ...` passthrough; not wrapped here until a typed edit command exists.
|
||||||
|
def create_user(self, payload: dict) -> Any:
|
||||||
|
return self.request("POST", "/api/Users", body=payload)
|
||||||
|
|
||||||
|
def delete_user(self, user_id: str, keep_data: bool = False) -> Any:
|
||||||
|
# /{id}/Account = drop metadata but KEEP backup data; /{id} = drop everything.
|
||||||
|
suffix = f"/api/Users/{user_id}/Account" if keep_data else f"/api/Users/{user_id}"
|
||||||
|
return self.request("DELETE", suffix)
|
||||||
|
|
||||||
|
# --- writes (licenses) ----------------------------------------------------
|
||||||
|
def grant_license(self, payload: dict) -> Any:
|
||||||
|
return self.request("POST", "/api/Licenses/Grant", body=payload)
|
||||||
|
|
||||||
|
def release_license(self, payload: dict) -> Any:
|
||||||
|
return self.request("POST", "/api/Licenses/Release", body=payload)
|
||||||
|
|
||||||
|
def revoke_license(self, payload: dict) -> Any:
|
||||||
|
return self.request("POST", "/api/Licenses/Revoke", body=payload)
|
||||||
3
.claude/skills/owncloud/.gitignore
vendored
Normal file
3
.claude/skills/owncloud/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Skill scratch/cache - never commit.
|
||||||
|
.cache/
|
||||||
|
__pycache__/
|
||||||
175
.claude/skills/owncloud/SKILL.md
Normal file
175
.claude/skills/owncloud/SKILL.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
---
|
||||||
|
name: owncloud
|
||||||
|
description: "Manage ACG's ownCloud server (cloud.acghosting.com / 172.16.3.22, ownCloud 10.16 Community) via occ over SSH: read-only GPS backup inventory (who has an account, per-user storage + quota + last login, which GuruRMM endpoints run the desktop client) PLUS gated admin writes - user lifecycle, quota, groups, apps/config, maintenance mode, files scan/transfer, trashbin/versions cleanup. Reads run freely; writes require --confirm. Triggers: owncloud, cloud.acghosting, who backs up to owncloud, owncloud usage, owncloud audit, create/disable owncloud user, set owncloud quota, owncloud group, owncloud maintenance mode."
|
||||||
|
---
|
||||||
|
|
||||||
|
# ownCloud Skill
|
||||||
|
|
||||||
|
Client for ACG's live **ownCloud 10.16.0 Community** server
|
||||||
|
(`cloud.acghosting.com`, VM `172.16.3.22`, Rocky Linux 9.6), driven entirely
|
||||||
|
through the `occ` admin CLI over SSH.
|
||||||
|
|
||||||
|
Two halves:
|
||||||
|
- **Read (GPS audit, ungated):** which customers back up here and how much data
|
||||||
|
they hold - and, with `--rmm`, which enrolled machines actually run the
|
||||||
|
ownCloud desktop client.
|
||||||
|
- **Manage (gated):** user/quota/group lifecycle, apps/config, maintenance mode,
|
||||||
|
filesystem scans, ownership transfer, trashbin/versions cleanup. Every
|
||||||
|
state-changing command **refuses without `--confirm`** (prints the WOULD line,
|
||||||
|
exits 3), mirroring the `b2` / `seafile` fleet convention.
|
||||||
|
|
||||||
|
This skill is one of the backup-verification siblings alongside `seafile`,
|
||||||
|
`b2` (Backblaze) and `datto-workplace`.
|
||||||
|
|
||||||
|
## Running - read (GPS audit)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OWNCLOUD=".claude/skills/owncloud/scripts/owncloud.py"
|
||||||
|
py "$OWNCLOUD" status # SSH ok + occ version / edition
|
||||||
|
py "$OWNCLOUD" users [--json] # all users + enabled/quota/used/last login
|
||||||
|
py "$OWNCLOUD" usage [--json] # per-user rollup, used GB desc (the audit view)
|
||||||
|
py "$OWNCLOUD" audit [--client NAME] [--rmm] [--json]
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `--json` to `users` / `usage` / `audit` for machine-readable output.
|
||||||
|
`--host` overrides the server IP (default `172.16.3.22`).
|
||||||
|
|
||||||
|
## Running - manage (writes gated behind `--confirm`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# users
|
||||||
|
py "$OWNCLOUD" user-add jdoe --display-name "J Doe" --email j@x.com -g Clients --gen-password --confirm
|
||||||
|
py "$OWNCLOUD" user-disable jdoe --confirm # user-enable to re-enable
|
||||||
|
py "$OWNCLOUD" user-reset-password jdoe --password-env OC_NEW_PW --confirm
|
||||||
|
py "$OWNCLOUD" user-modify jdoe --email new@x.com --confirm
|
||||||
|
py "$OWNCLOUD" user-delete jdoe --confirm # DELETES the user AND all their data
|
||||||
|
|
||||||
|
# quota (quota <uid> reads it; value like '5 GB', 'none' = unlimited)
|
||||||
|
py "$OWNCLOUD" quota jdoe
|
||||||
|
py "$OWNCLOUD" quota-set jdoe "50 GB" --confirm # quota-reset -> back to default
|
||||||
|
|
||||||
|
# groups (groups / group-members / user-groups are read-only)
|
||||||
|
py "$OWNCLOUD" groups
|
||||||
|
py "$OWNCLOUD" group-add Clients --confirm
|
||||||
|
py "$OWNCLOUD" group-add-member Clients -m jdoe -m asmith --confirm
|
||||||
|
py "$OWNCLOUD" group-remove-member Clients -m jdoe --confirm
|
||||||
|
py "$OWNCLOUD" group-delete Clients --confirm
|
||||||
|
|
||||||
|
# shares (read-only - occ cannot create shares; see Notes)
|
||||||
|
py "$OWNCLOUD" shares [--user jdoe]
|
||||||
|
|
||||||
|
# server / apps / config
|
||||||
|
py "$OWNCLOUD" maintenance status|on|off [--confirm] # on/off need --confirm
|
||||||
|
py "$OWNCLOUD" apps
|
||||||
|
py "$OWNCLOUD" app-enable|app-disable <app> --confirm
|
||||||
|
py "$OWNCLOUD" config-get [name] # no name -> dump all system config
|
||||||
|
py "$OWNCLOUD" config-set <name> --value V [--type string|integer|double|boolean|json] --confirm
|
||||||
|
|
||||||
|
# files admin
|
||||||
|
py "$OWNCLOUD" files-scan (--user jdoe | --all) [--path sub/dir] --confirm
|
||||||
|
py "$OWNCLOUD" transfer-ownership <src> <dst> [--path folder] --confirm
|
||||||
|
py "$OWNCLOUD" trashbin-cleanup --user jdoe --confirm
|
||||||
|
py "$OWNCLOUD" versions-cleanup --user jdoe --confirm
|
||||||
|
# fleet-wide purge needs the extra ack flag on top of --confirm:
|
||||||
|
py "$OWNCLOUD" versions-cleanup --all-users --force-all-users --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Passwords + the credential rule
|
||||||
|
|
||||||
|
`user-add` / `user-reset-password` take the password one of three ways:
|
||||||
|
`--gen-password` (generates a strong one, prints it **once**), `--password-env VAR`
|
||||||
|
(reads it from that env var - preferred, keeps it out of shell history), or a
|
||||||
|
literal `--password` (discouraged: lands in history + `ps`). On the server the
|
||||||
|
value is passed via occ's own `OC_PASS` + `--password-from-env`.
|
||||||
|
|
||||||
|
**Any password created or reset here MUST be stored in the SOPS vault** (house
|
||||||
|
rule) - the command prints a `[CRITICAL]` reminder with the exact `vault.sh`
|
||||||
|
line (suggested path `infrastructure/owncloud-users/<uid>.sops.yaml`).
|
||||||
|
|
||||||
|
### Gating / exit codes
|
||||||
|
|
||||||
|
- write command without `--confirm` -> prints `Would: ...`, **exit 3**, no change.
|
||||||
|
- `trashbin-cleanup` / `versions-cleanup` with **no** `--user` and **no**
|
||||||
|
`--all-users` -> **hard-fail exit 2** (occ treats "no user" as *every* user; a
|
||||||
|
fleet-wide purge must be made explicit with `--all-users`).
|
||||||
|
- `--all-users` on those two also **hard-fails exit 2** unless
|
||||||
|
`--force-all-users` is passed **in addition to** `--confirm` - a single stray
|
||||||
|
`--confirm` must never be enough to irreversibly purge every user's history.
|
||||||
|
- invalid uid on `user-add` -> **exit 2**.
|
||||||
|
|
||||||
|
## Access channel
|
||||||
|
|
||||||
|
There is **no ownCloud admin web / OCS account** in the vault, so the channel is
|
||||||
|
**SSH + the `occ` CLI**, not the OCS HTTP API (the OCS endpoint is reachable at
|
||||||
|
`https://cloud.acghosting.com/status.php` but we lack an OCS admin cred). The
|
||||||
|
client SSHes to `root@172.16.3.22:22` with **paramiko** (password auth,
|
||||||
|
AutoAddPolicy) and runs occ **as the web user**:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo -u apache php /var/www/owncloud/occ <cmd>
|
||||||
|
```
|
||||||
|
|
||||||
|
Credentials load lazily from the SOPS vault (never hardcoded):
|
||||||
|
`infrastructure/owncloud-vm.sops.yaml` -> `credentials.username` (root) /
|
||||||
|
`credentials.password`.
|
||||||
|
|
||||||
|
## The occ / DB commands it settled on
|
||||||
|
|
||||||
|
- **status:** `occ status --output=json` (+ `occ -V` for the display version).
|
||||||
|
- **users:** `occ user:list --show-all-attributes --output=json` -> per user:
|
||||||
|
`uid, displayName, email, quota, enabled, lastLogin, creationTime, home,
|
||||||
|
backend`. `lastLogin`/`creationTime` are Unix epoch seconds (0 = never); the
|
||||||
|
client renders them as UTC.
|
||||||
|
- **used bytes:** occ has **no `user:info` / per-user usage** on this version, so
|
||||||
|
used storage is read from ownCloud's own **filecache** (the exact figure the
|
||||||
|
UI shows). DB creds are read live from `config.php` (we already have root), then:
|
||||||
|
`SELECT s.id, fc.size FROM oc_storages s JOIN oc_filecache fc ON
|
||||||
|
fc.storage=s.numeric_id WHERE s.id LIKE 'home::%' AND fc.path='';`
|
||||||
|
Each row is `home::<uid>` -> root size in bytes.
|
||||||
|
|
||||||
|
## What the data means
|
||||||
|
|
||||||
|
- **used_bytes** is the authoritative per-account storage used; an `enabled` user
|
||||||
|
with non-zero usage is actively backing up here. `usage`/`audit` roll this up,
|
||||||
|
sorted desc, with a total.
|
||||||
|
- **quota** is a string: `default`, `none` (unlimited), or a byte/size value.
|
||||||
|
- Usage is concentrated: as of the build (2026-07-05) a single account (`pavon`)
|
||||||
|
holds ~14 TB and `sysadmin` ~2.9 TB, dwarfing the rest - ownCloud is used by a
|
||||||
|
handful of clients, not the whole GPS base.
|
||||||
|
|
||||||
|
## The "both" cross-check (`--rmm`)
|
||||||
|
|
||||||
|
Server-side (occ) tells you who has an account; `--rmm` adds the endpoint half. It
|
||||||
|
logs in to GuruRMM (`infrastructure/gururmm-server.sops.yaml`) and, for each agent
|
||||||
|
(filter with `--client`), runs a **read-only** detection PowerShell reporting
|
||||||
|
installed products / running processes / config folders matching **`ownCloud`**.
|
||||||
|
An agent is reported only if the client is actually present. This dispatches a
|
||||||
|
command to live endpoints - **always scope with `--client`** rather than sweeping
|
||||||
|
all 350+ agents.
|
||||||
|
|
||||||
|
## Notes / gotchas / limitations
|
||||||
|
|
||||||
|
- **Dirty filecache (-1):** if a user's cached root size is `-1` (cache not yet
|
||||||
|
computed), the client falls back to `du -sb <home>/files` for that one user.
|
||||||
|
That du can take minutes on a multi-hundred-GB home (timeout is generous, 900s);
|
||||||
|
`used_source` in `--json` reports `filecache` vs `du` per user. At build time
|
||||||
|
one account (`anaise`) was dirty and resolved via du (~0.6 TB).
|
||||||
|
- **Orphan storages:** the filecache can contain `home::<uid>` rows for deleted
|
||||||
|
users (e.g. a former `QWM-Sheila`); the skill keys off the live `occ user:list`,
|
||||||
|
so orphans do not appear in `users`/`usage`.
|
||||||
|
- **No OCS/web API:** deeper per-file / sharing queries would need an OCS admin
|
||||||
|
cred we do not have; occ + filecache cover the audit needs.
|
||||||
|
- **Shares are read-only here:** occ on this version exposes no share create/list
|
||||||
|
(only `sharing:cleanup-remote-storages`), so `shares` reads straight from the
|
||||||
|
`oc_share` DB table (joined to `oc_filecache` for the real path). Creating a
|
||||||
|
share would need the OCS API / an admin web cred we do not have.
|
||||||
|
- **occ command surface was verified live** against this exact 10.16 box (`occ
|
||||||
|
list` / `occ help <cmd>`) before wiring the writes - flags differ by version.
|
||||||
|
Notable ones used: `user:modify <uid> displayname|email`, quota via
|
||||||
|
`user:setting <uid> files quota --value=`, passwords via `--password-from-env`,
|
||||||
|
`files:transfer-ownership -s`, `trashbin:cleanup`/`versions:cleanup` (no user
|
||||||
|
arg = ALL users, hence the `--all-users` guard).
|
||||||
|
- Shared plumbing (repo-root resolve, vault read, GuruRMM client + endpoint
|
||||||
|
detection) lives in `.claude/scripts/backup_common.py`, used by all the
|
||||||
|
server-backup skills. Detection patterns must be precise per backend - this
|
||||||
|
skill uses `ownCloud` (not a bare `cloud`, which would over-match).
|
||||||
76
.claude/skills/owncloud/scripts/dry_test.sh
Normal file
76
.claude/skills/owncloud/scripts/dry_test.sh
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Non-destructive verification for the owncloud skill.
|
||||||
|
# - every WRITE command is run WITHOUT --confirm -> must refuse (rc 3), no SSH write
|
||||||
|
# - hard-fail guards are run (validation precedes the gate, so no execution) -> rc 2
|
||||||
|
# - read-only commands must succeed (rc 0) and JSON reads must parse
|
||||||
|
# NOTHING here runs a state change on the server.
|
||||||
|
OC=".claude/skills/owncloud/scripts/owncloud.py"
|
||||||
|
pass=0; fail=0
|
||||||
|
chk() { # chk <expected_rc> <label> -- <cmd...>
|
||||||
|
local exp="$1" label="$2"; shift 3
|
||||||
|
"$@" >/dev/null 2>&1; local rc=$?
|
||||||
|
if [ "$rc" = "$exp" ]; then printf ' [PASS] %-40s (rc %s)\n' "$label" "$rc"; pass=$((pass+1))
|
||||||
|
else printf ' [FAIL] %-40s (got rc %s, want %s)\n' "$label" "$rc" "$exp"; fail=$((fail+1)); fi
|
||||||
|
}
|
||||||
|
jchk() { # jchk <label> -- <cmd...> (stdout must be valid JSON)
|
||||||
|
local label="$1"; shift 2
|
||||||
|
if "$@" 2>/dev/null | py -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then
|
||||||
|
printf ' [PASS] %-40s (valid JSON)\n' "$label"; pass=$((pass+1))
|
||||||
|
else printf ' [FAIL] %-40s (bad JSON)\n' "$label"; fail=$((fail+1)); fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== WRITE commands without --confirm must REFUSE (rc 3) =="
|
||||||
|
chk 3 "user-add" -- py "$OC" user-add zz.dry --gen-password
|
||||||
|
chk 3 "user-delete" -- py "$OC" user-delete zz.dry
|
||||||
|
chk 3 "user-enable" -- py "$OC" user-enable zz.dry
|
||||||
|
chk 3 "user-disable" -- py "$OC" user-disable zz.dry
|
||||||
|
chk 3 "user-reset-password" -- py "$OC" user-reset-password zz.dry --gen-password
|
||||||
|
chk 3 "user-modify" -- py "$OC" user-modify zz.dry --email a@b.com
|
||||||
|
chk 3 "quota-set" -- py "$OC" quota-set zz.dry "5 GB"
|
||||||
|
chk 3 "quota-reset" -- py "$OC" quota-reset zz.dry
|
||||||
|
chk 3 "group-add" -- py "$OC" group-add zz.dry
|
||||||
|
chk 3 "group-delete" -- py "$OC" group-delete zz.dry
|
||||||
|
chk 3 "group-add-member" -- py "$OC" group-add-member zz.dry -m sysadmin
|
||||||
|
chk 3 "group-remove-member" -- py "$OC" group-remove-member zz.dry -m sysadmin
|
||||||
|
chk 3 "maintenance on" -- py "$OC" maintenance on
|
||||||
|
chk 3 "maintenance off" -- py "$OC" maintenance off
|
||||||
|
chk 3 "app-enable" -- py "$OC" app-enable activity
|
||||||
|
chk 3 "app-disable" -- py "$OC" app-disable activity
|
||||||
|
chk 3 "config-set" -- py "$OC" config-set zz.key --value v
|
||||||
|
chk 3 "files-scan --user" -- py "$OC" files-scan --user zz.dry
|
||||||
|
chk 3 "files-scan --all" -- py "$OC" files-scan --all
|
||||||
|
chk 3 "transfer-ownership" -- py "$OC" transfer-ownership a b
|
||||||
|
chk 3 "trashbin-cleanup" -- py "$OC" trashbin-cleanup --user zz.dry
|
||||||
|
chk 3 "versions-cleanup" -- py "$OC" versions-cleanup --user zz.dry
|
||||||
|
|
||||||
|
echo "== hard-fail guards (validation precedes the gate; no execution) rc 2 =="
|
||||||
|
chk 2 "user-add bad uid" -- py "$OC" user-add "bad uid!" --gen-password --confirm
|
||||||
|
chk 2 "user-add no password" -- py "$OC" user-add validuid --confirm
|
||||||
|
chk 2 "user-modify no fields" -- py "$OC" user-modify zz.dry --confirm
|
||||||
|
chk 2 "files-scan no target" -- py "$OC" files-scan --confirm
|
||||||
|
chk 2 "trashbin-cleanup no target"-- py "$OC" trashbin-cleanup --confirm
|
||||||
|
chk 2 "versions-cleanup no target"-- py "$OC" versions-cleanup --confirm
|
||||||
|
# --all-users must NOT execute on --confirm alone (needs --force-all-users) -> rc 2
|
||||||
|
chk 2 "versions --all-users no force" -- py "$OC" versions-cleanup --all-users --confirm
|
||||||
|
chk 2 "trashbin --all-users no force" -- py "$OC" trashbin-cleanup --all-users --confirm
|
||||||
|
|
||||||
|
echo "== read-only commands must succeed (rc 0) =="
|
||||||
|
chk 0 "status" -- py "$OC" status
|
||||||
|
chk 0 "groups" -- py "$OC" groups
|
||||||
|
chk 0 "group-members" -- py "$OC" group-members admin
|
||||||
|
chk 0 "user-groups" -- py "$OC" user-groups sysadmin
|
||||||
|
chk 0 "shares" -- py "$OC" shares
|
||||||
|
chk 0 "shares --user" -- py "$OC" shares --user sysadmin
|
||||||
|
chk 0 "apps" -- py "$OC" apps
|
||||||
|
chk 0 "maintenance status" -- py "$OC" maintenance status
|
||||||
|
chk 0 "config-get key" -- py "$OC" config-get maintenance
|
||||||
|
chk 0 "quota (read)" -- py "$OC" quota sysadmin
|
||||||
|
|
||||||
|
echo "== JSON read outputs must parse =="
|
||||||
|
jchk "groups --json" -- py "$OC" groups --json
|
||||||
|
jchk "apps --json" -- py "$OC" apps --json
|
||||||
|
jchk "shares --json" -- py "$OC" shares --json
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== $pass passed, $fail failed ===="
|
||||||
|
[ "$fail" = 0 ]
|
||||||
626
.claude/skills/owncloud/scripts/owncloud.py
Normal file
626
.claude/skills/owncloud/scripts/owncloud.py
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CLI for the `owncloud` skill - read-only ownCloud 10.16 inventory for the GPS
|
||||||
|
backup audit (server: cloud.acghosting.com / 172.16.3.22, driven via occ over SSH).
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
status SSH ok + occ version / edition
|
||||||
|
users [--json] all users + display name, enabled, quota, used, last login
|
||||||
|
usage [--json] per-user rollup: used GB (desc) + quota + last login (audit view)
|
||||||
|
audit [--client NAME] [--rmm] [--json]
|
||||||
|
active users holding data; with --rmm cross-check GuruRMM
|
||||||
|
endpoints for the ownCloud desktop client (scope with --client)
|
||||||
|
|
||||||
|
Server-side (occ) is the source of "who has an account + how much data"; --rmm adds
|
||||||
|
the "which enrolled machine actually runs the ownCloud client" half.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from owncloud_client import OwncloudClient # noqa: E402
|
||||||
|
import backup_common as bc # noqa: E402
|
||||||
|
|
||||||
|
GB = 1_000_000_000
|
||||||
|
|
||||||
|
# occ enforces this uid charset (a-z A-Z 0-9 and + _ . @ - ').
|
||||||
|
UID_RE = re.compile(r"^[A-Za-z0-9+_.@'\-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def _gb(n) -> float:
|
||||||
|
try:
|
||||||
|
return round(int(n) / GB, 2)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- write-op gating (mirrors the fleet convention: refuse without --confirm) --
|
||||||
|
def _gated(desc: str, confirm: bool) -> bool:
|
||||||
|
if not confirm:
|
||||||
|
print("[WARNING] Refusing state-changing action without --confirm.")
|
||||||
|
print(f"[INFO] Would: {desc}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_password(n: int = 20) -> str:
|
||||||
|
alpha = string.ascii_letters + string.digits + "!@#%^*-_=+"
|
||||||
|
return "".join(secrets.choice(alpha) for _ in range(n))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_password(args):
|
||||||
|
"""Return (password, was_generated) from --password / --password-env / --gen-password.
|
||||||
|
Prefer --password-env or --gen-password: a literal --password lands in shell
|
||||||
|
history and `ps` on the operator's machine."""
|
||||||
|
if getattr(args, "password", None):
|
||||||
|
return args.password, False
|
||||||
|
env_var = getattr(args, "password_env", None)
|
||||||
|
if env_var:
|
||||||
|
v = os.environ.get(env_var)
|
||||||
|
if not v:
|
||||||
|
raise SystemExit(f"[ERROR] env var {env_var} is unset/empty")
|
||||||
|
return v, False
|
||||||
|
if getattr(args, "gen_password", False):
|
||||||
|
return _gen_password(), True
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
def _vault_reminder(uid: str, stream=None) -> None:
|
||||||
|
out = stream or sys.stdout
|
||||||
|
print("[CRITICAL] House rule: store this credential in the SOPS vault now, e.g.:",
|
||||||
|
file=out)
|
||||||
|
print(f" bash .claude/scripts/vault.sh set-field "
|
||||||
|
f"infrastructure/owncloud-users/{uid}.sops.yaml credentials.password '<paste-here>'",
|
||||||
|
file=out)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(c: OwncloudClient, args) -> int:
|
||||||
|
s = c.server_status()
|
||||||
|
print(f"[OK] {c.host}")
|
||||||
|
print(json.dumps(s, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_users(c: OwncloudClient, args) -> int:
|
||||||
|
users = c.list_users()
|
||||||
|
users.sort(key=lambda u: int(u.get("used_bytes") or 0), reverse=True)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(users, indent=2))
|
||||||
|
return 0
|
||||||
|
print(f"{'UID':20} {'NAME':22} {'EN':3} {'USED':>10} {'QUOTA':>12} {'LAST LOGIN':20}")
|
||||||
|
for u in users:
|
||||||
|
print(f"{(u.get('uid') or '')[:20]:20} "
|
||||||
|
f"{(u.get('display_name') or '')[:22]:22} "
|
||||||
|
f"{'yes' if u.get('enabled') else 'NO':3} "
|
||||||
|
f"{_gb(u.get('used_bytes')):>7} GB "
|
||||||
|
f"{str(u.get('quota') or '-')[:12]:>12} "
|
||||||
|
f"{str(u.get('last_login') or '-')[:19]:20}")
|
||||||
|
print(f"\n{len(users)} users; total used "
|
||||||
|
f"{round(sum(_gb(u.get('used_bytes')) for u in users), 1)} GB")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_rollup(c: OwncloudClient) -> list[dict]:
|
||||||
|
users = c.list_users()
|
||||||
|
rollup = []
|
||||||
|
for u in users:
|
||||||
|
rollup.append({
|
||||||
|
"uid": u.get("uid"),
|
||||||
|
"display_name": u.get("display_name"),
|
||||||
|
"email": u.get("email"),
|
||||||
|
"enabled": bool(u.get("enabled")),
|
||||||
|
"used_bytes": int(u.get("used_bytes") or 0),
|
||||||
|
"used_gb": _gb(u.get("used_bytes")),
|
||||||
|
"used_source": u.get("used_source"),
|
||||||
|
"quota": u.get("quota"),
|
||||||
|
"last_login": u.get("last_login"),
|
||||||
|
})
|
||||||
|
rollup.sort(key=lambda x: x["used_bytes"], reverse=True)
|
||||||
|
return rollup
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_usage(c: OwncloudClient, args) -> int:
|
||||||
|
rollup = _usage_rollup(c)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(rollup, indent=2))
|
||||||
|
return 0
|
||||||
|
print(f"{'UID':20} {'EN':3} {'USED':>10} {'QUOTA':>12} {'LAST LOGIN':20}")
|
||||||
|
for r in rollup:
|
||||||
|
print(f"{(r['uid'] or '')[:20]:20} "
|
||||||
|
f"{'yes' if r['enabled'] else 'NO':3} "
|
||||||
|
f"{r['used_gb']:>7} GB "
|
||||||
|
f"{str(r['quota'] or '-')[:12]:>12} "
|
||||||
|
f"{str(r['last_login'] or '-')[:19]:20}")
|
||||||
|
print(f"\n{len(rollup)} users; total used "
|
||||||
|
f"{round(sum(r['used_gb'] for r in rollup), 1)} GB")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_audit(c: OwncloudClient, args) -> int:
|
||||||
|
rollup = _usage_rollup(c)
|
||||||
|
# enabled users holding data are the ones actually backing up here
|
||||||
|
active = [r for r in rollup if r["enabled"] and r["used_bytes"] > 0]
|
||||||
|
result = {"backend": "owncloud", "server": c.host, "accounts": active}
|
||||||
|
|
||||||
|
if args.rmm:
|
||||||
|
rmm = bc.RmmClient()
|
||||||
|
rmm.login()
|
||||||
|
checks = []
|
||||||
|
agents = rmm.find_agents(client_substr=args.client) if args.client else rmm.list_agents()
|
||||||
|
for a in agents:
|
||||||
|
det = rmm.detect_client(a, ["ownCloud"])
|
||||||
|
if det.get("detected"):
|
||||||
|
checks.append(det)
|
||||||
|
result["rmm_endpoints_with_client"] = checks
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"=== ownCloud backup accounts - {c.host} ===")
|
||||||
|
print(f"{'UID':20} {'USED':>10} {'QUOTA':>12} {'LAST LOGIN':20}")
|
||||||
|
for r in active:
|
||||||
|
print(f"{(r['uid'] or '')[:20]:20} {r['used_gb']:>7} GB "
|
||||||
|
f"{str(r['quota'] or '-')[:12]:>12} {str(r['last_login'] or '-')[:19]:20}")
|
||||||
|
print(f"\n{len(active)} active accounts with data; "
|
||||||
|
f"{round(sum(r['used_gb'] for r in active), 1)} GB total")
|
||||||
|
if args.rmm:
|
||||||
|
eps = result.get("rmm_endpoints_with_client", [])
|
||||||
|
print(f"\n--- GuruRMM endpoints running an ownCloud client ({len(eps)}) ---")
|
||||||
|
for e in eps:
|
||||||
|
print(f" {e['hostname']:25} client={e.get('client_name')}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Management commands (writes gated behind --confirm; reads are ungated)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _ok(msg: str, payload: dict, as_json: bool) -> int:
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(payload, indent=2))
|
||||||
|
else:
|
||||||
|
print(f"[OK] {msg}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# -- user lifecycle ---------------------------------------------------------
|
||||||
|
def cmd_user_add(c: OwncloudClient, args) -> int:
|
||||||
|
if not UID_RE.match(args.uid):
|
||||||
|
print(f"[ERROR] invalid uid {args.uid!r}: allowed chars are "
|
||||||
|
"a-z A-Z 0-9 + _ . @ - '", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
pw, generated = _resolve_password(args)
|
||||||
|
if not pw:
|
||||||
|
print("[ERROR] supply --password, --password-env VAR, or --gen-password",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
desc = f"create ownCloud user '{args.uid}'"
|
||||||
|
if args.group:
|
||||||
|
desc += f" in group(s) {', '.join(args.group)}"
|
||||||
|
if not _gated(desc, args.confirm):
|
||||||
|
return 3
|
||||||
|
res = c.create_user(args.uid, pw, args.display_name, args.email, args.group)
|
||||||
|
if args.json:
|
||||||
|
# keep stdout parseable: password + vault reminder go to stderr
|
||||||
|
if generated:
|
||||||
|
print(f"[CRITICAL] generated password for '{args.uid}' (shown once); "
|
||||||
|
"store in vault", file=sys.stderr)
|
||||||
|
_vault_reminder(args.uid, stream=sys.stderr)
|
||||||
|
print(json.dumps({"uid": args.uid, "created": True,
|
||||||
|
"generated_password": pw if generated else None,
|
||||||
|
"detail": res}, indent=2))
|
||||||
|
return 0
|
||||||
|
print(f"[OK] created user '{args.uid}'")
|
||||||
|
if generated:
|
||||||
|
print(f"[CRITICAL] generated password (shown once): {pw}")
|
||||||
|
_vault_reminder(args.uid)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_user_delete(c: OwncloudClient, args) -> int:
|
||||||
|
if not _gated(f"DELETE user '{args.uid}' and ALL their data"
|
||||||
|
+ (" (forced)" if args.force else ""), args.confirm):
|
||||||
|
return 3
|
||||||
|
out = c.delete_user(args.uid, force=args.force)
|
||||||
|
return _ok(f"deleted user '{args.uid}'", {"uid": args.uid, "deleted": True,
|
||||||
|
"detail": out}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_user_enable(c: OwncloudClient, args) -> int:
|
||||||
|
if not _gated(f"enable user '{args.uid}'", args.confirm):
|
||||||
|
return 3
|
||||||
|
out = c.set_user_enabled(args.uid, True)
|
||||||
|
return _ok(f"enabled '{args.uid}'", {"uid": args.uid, "enabled": True, "detail": out}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_user_disable(c: OwncloudClient, args) -> int:
|
||||||
|
if not _gated(f"disable user '{args.uid}'", args.confirm):
|
||||||
|
return 3
|
||||||
|
out = c.set_user_enabled(args.uid, False)
|
||||||
|
return _ok(f"disabled '{args.uid}'", {"uid": args.uid, "enabled": False, "detail": out}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_user_reset_password(c: OwncloudClient, args) -> int:
|
||||||
|
pw, generated = _resolve_password(args)
|
||||||
|
if not pw:
|
||||||
|
print("[ERROR] supply --password, --password-env VAR, or --gen-password",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not _gated(f"reset password for user '{args.uid}'", args.confirm):
|
||||||
|
return 3
|
||||||
|
c.reset_password(args.uid, pw)
|
||||||
|
if args.json:
|
||||||
|
if generated:
|
||||||
|
print(f"[CRITICAL] generated password for '{args.uid}' (shown once); "
|
||||||
|
"store in vault", file=sys.stderr)
|
||||||
|
_vault_reminder(args.uid, stream=sys.stderr)
|
||||||
|
print(json.dumps({"uid": args.uid, "password_reset": True,
|
||||||
|
"generated_password": pw if generated else None},
|
||||||
|
indent=2))
|
||||||
|
return 0
|
||||||
|
print(f"[OK] reset password for '{args.uid}'")
|
||||||
|
if generated:
|
||||||
|
print(f"[CRITICAL] generated password (shown once): {pw}")
|
||||||
|
_vault_reminder(args.uid)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_user_modify(c: OwncloudClient, args) -> int:
|
||||||
|
changes = []
|
||||||
|
if args.display_name is not None:
|
||||||
|
changes.append(("displayname", args.display_name))
|
||||||
|
if args.email is not None:
|
||||||
|
changes.append(("email", args.email))
|
||||||
|
if not changes:
|
||||||
|
print("[ERROR] nothing to change: pass --display-name and/or --email",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
desc = "set " + ", ".join(f"{k}={v!r}" for k, v in changes) + f" on '{args.uid}'"
|
||||||
|
if not _gated(desc, args.confirm):
|
||||||
|
return 3
|
||||||
|
for key, val in changes:
|
||||||
|
c.modify_user(args.uid, key, val)
|
||||||
|
return _ok(f"modified '{args.uid}'", {"uid": args.uid,
|
||||||
|
"changed": dict(changes)}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
# -- quota ------------------------------------------------------------------
|
||||||
|
def cmd_quota(c: OwncloudClient, args) -> int:
|
||||||
|
"""Read a user's quota (ungated)."""
|
||||||
|
q = c.get_quota(args.uid)
|
||||||
|
return _ok(f"{args.uid} quota: {q or 'default'}",
|
||||||
|
{"uid": args.uid, "quota": q}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_quota_set(c: OwncloudClient, args) -> int:
|
||||||
|
if not _gated(f"set quota for '{args.uid}' to {args.value!r}", args.confirm):
|
||||||
|
return 3
|
||||||
|
c.set_quota(args.uid, args.value)
|
||||||
|
return _ok(f"set '{args.uid}' quota = {args.value}",
|
||||||
|
{"uid": args.uid, "quota": args.value}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_quota_reset(c: OwncloudClient, args) -> int:
|
||||||
|
if not _gated(f"reset '{args.uid}' quota to default", args.confirm):
|
||||||
|
return 3
|
||||||
|
c.reset_quota(args.uid)
|
||||||
|
return _ok(f"reset '{args.uid}' quota to default",
|
||||||
|
{"uid": args.uid, "quota": "default"}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
# -- groups -----------------------------------------------------------------
|
||||||
|
def cmd_groups(c: OwncloudClient, args) -> int:
|
||||||
|
g = c.list_groups()
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(g, indent=2))
|
||||||
|
return 0
|
||||||
|
names = g if isinstance(g, list) else list(g.keys())
|
||||||
|
for n in names:
|
||||||
|
print(f" {n}")
|
||||||
|
print(f"\n{len(names)} groups")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_group_members(c: OwncloudClient, args) -> int:
|
||||||
|
m = c.group_members(args.group)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(m, indent=2))
|
||||||
|
return 0
|
||||||
|
members = m if isinstance(m, list) else list(m.keys())
|
||||||
|
for u in members:
|
||||||
|
print(f" {u}")
|
||||||
|
print(f"\n{len(members)} members of '{args.group}'")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_user_groups(c: OwncloudClient, args) -> int:
|
||||||
|
g = c.user_groups(args.uid)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(g, indent=2))
|
||||||
|
return 0
|
||||||
|
groups = g if isinstance(g, list) else list(g.keys())
|
||||||
|
for n in groups:
|
||||||
|
print(f" {n}")
|
||||||
|
print(f"\n'{args.uid}' is in {len(groups)} group(s)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_group_add(c: OwncloudClient, args) -> int:
|
||||||
|
if not _gated(f"create group '{args.group}'", args.confirm):
|
||||||
|
return 3
|
||||||
|
c.create_group(args.group)
|
||||||
|
return _ok(f"created group '{args.group}'", {"group": args.group, "created": True}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_group_delete(c: OwncloudClient, args) -> int:
|
||||||
|
if not _gated(f"delete group '{args.group}'", args.confirm):
|
||||||
|
return 3
|
||||||
|
c.delete_group(args.group)
|
||||||
|
return _ok(f"deleted group '{args.group}'", {"group": args.group, "deleted": True}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_group_add_member(c: OwncloudClient, args) -> int:
|
||||||
|
if not _gated(f"add {', '.join(args.member)} to group '{args.group}'", args.confirm):
|
||||||
|
return 3
|
||||||
|
c.add_group_members(args.group, args.member)
|
||||||
|
return _ok(f"added {len(args.member)} member(s) to '{args.group}'",
|
||||||
|
{"group": args.group, "added": args.member}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_group_remove_member(c: OwncloudClient, args) -> int:
|
||||||
|
if not _gated(f"remove {', '.join(args.member)} from group '{args.group}'", args.confirm):
|
||||||
|
return 3
|
||||||
|
c.remove_group_members(args.group, args.member)
|
||||||
|
return _ok(f"removed {len(args.member)} member(s) from '{args.group}'",
|
||||||
|
{"group": args.group, "removed": args.member}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
# -- shares (read-only) -----------------------------------------------------
|
||||||
|
def cmd_shares(c: OwncloudClient, args) -> int:
|
||||||
|
shares = c.list_shares(args.user)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(shares, indent=2))
|
||||||
|
return 0
|
||||||
|
print(f"{'ID':>7} {'TYPE':12} {'OWNER':16} {'SHARE WITH':16} {'PATH/TARGET':30}")
|
||||||
|
for s in shares:
|
||||||
|
print(f"{str(s['id']):>7} {s['share_type'][:12]:12} "
|
||||||
|
f"{str(s['owner'] or '-')[:16]:16} {str(s['share_with'] or '-')[:16]:16} "
|
||||||
|
f"{str(s['path'] or s['target'] or '-')[:30]:30}")
|
||||||
|
print(f"\n{len(shares)} share(s)"
|
||||||
|
+ (f" involving '{args.user}'" if args.user else ""))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# -- server / apps / config -------------------------------------------------
|
||||||
|
def cmd_maintenance(c: OwncloudClient, args) -> int:
|
||||||
|
if args.state == "status":
|
||||||
|
on = c.maintenance_status()
|
||||||
|
return _ok(f"maintenance mode is {'ON' if on else 'off'}",
|
||||||
|
{"maintenance": on}, args.json)
|
||||||
|
want_on = args.state == "on"
|
||||||
|
if not _gated(f"turn maintenance mode {'ON' if want_on else 'off'}", args.confirm):
|
||||||
|
return 3
|
||||||
|
c.maintenance_mode(want_on)
|
||||||
|
return _ok(f"maintenance mode {'ON' if want_on else 'off'}",
|
||||||
|
{"maintenance": want_on}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_apps(c: OwncloudClient, args) -> int:
|
||||||
|
apps = c.list_apps()
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(apps, indent=2))
|
||||||
|
return 0
|
||||||
|
enabled = apps.get("enabled", {}) if isinstance(apps, dict) else {}
|
||||||
|
disabled = apps.get("disabled", {}) if isinstance(apps, dict) else {}
|
||||||
|
print(f"--- enabled ({len(enabled)}) ---")
|
||||||
|
for a in sorted(enabled):
|
||||||
|
print(f" {a}")
|
||||||
|
print(f"--- disabled ({len(disabled)}) ---")
|
||||||
|
for a in sorted(disabled):
|
||||||
|
print(f" {a}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_app_enable(c: OwncloudClient, args) -> int:
|
||||||
|
if not _gated(f"enable app '{args.app}'", args.confirm):
|
||||||
|
return 3
|
||||||
|
out = c.enable_app(args.app)
|
||||||
|
return _ok(f"enabled app '{args.app}'", {"app": args.app, "enabled": True, "detail": out}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_app_disable(c: OwncloudClient, args) -> int:
|
||||||
|
if not _gated(f"disable app '{args.app}'", args.confirm):
|
||||||
|
return 3
|
||||||
|
out = c.disable_app(args.app)
|
||||||
|
return _ok(f"disabled app '{args.app}'", {"app": args.app, "enabled": False, "detail": out}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_config_get(c: OwncloudClient, args) -> int:
|
||||||
|
if args.name:
|
||||||
|
val = c.config_get(args.name)
|
||||||
|
return _ok(f"{args.name} = {val}", {"name": args.name, "value": val}, args.json)
|
||||||
|
cfg = c.config_list()
|
||||||
|
print(json.dumps(cfg, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_config_set(c: OwncloudClient, args) -> int:
|
||||||
|
if not _gated(f"set system config {args.name} = {args.value!r} ({args.type})", args.confirm):
|
||||||
|
return 3
|
||||||
|
c.config_set(args.name, args.value, args.type)
|
||||||
|
return _ok(f"set {args.name} = {args.value}",
|
||||||
|
{"name": args.name, "value": args.value, "type": args.type}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
# -- files admin ------------------------------------------------------------
|
||||||
|
def cmd_files_scan(c: OwncloudClient, args) -> int:
|
||||||
|
if not args.all and not args.user:
|
||||||
|
print("[ERROR] specify a user or --all", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
target = "ALL users" if args.all else f"'{args.user}'"
|
||||||
|
if not _gated(f"filesystem rescan for {target}"
|
||||||
|
+ (f" path={args.path}" if args.path else ""), args.confirm):
|
||||||
|
return 3
|
||||||
|
out = c.files_scan(uid=args.user, all_users=args.all, path=args.path)
|
||||||
|
return _ok(f"scanned {target}", {"scanned": target, "detail": out.splitlines()[-1:] if out else []}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_transfer_ownership(c: OwncloudClient, args) -> int:
|
||||||
|
if not _gated(f"transfer files from '{args.source}' to '{args.dest}'"
|
||||||
|
+ (f" path={args.path}" if args.path else ""), args.confirm):
|
||||||
|
return 3
|
||||||
|
out = c.transfer_ownership(args.source, args.dest, args.path)
|
||||||
|
return _ok(f"transferred '{args.source}' -> '{args.dest}'",
|
||||||
|
{"source": args.source, "dest": args.dest, "detail": out}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup(c: OwncloudClient, args, kind: str) -> int:
|
||||||
|
"""Shared guard for trashbin/versions cleanup: refuse a fleet-wide sweep
|
||||||
|
unless it is made explicit with --all-users (occ treats 'no user' as ALL)."""
|
||||||
|
users = args.user or []
|
||||||
|
if not users and not args.all_users:
|
||||||
|
print(f"[ERROR] {kind} cleanup with no --user targets ALL users; "
|
||||||
|
"pass explicit --user, or --all-users to sweep everyone.",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
# A fleet-wide, irreversible purge must not be reachable by a single stray
|
||||||
|
# --confirm: require a dedicated acknowledgement flag on top of it.
|
||||||
|
if args.all_users and not args.force_all_users:
|
||||||
|
print(f"[ERROR] {kind}-cleanup --all-users irreversibly purges EVERY "
|
||||||
|
f"user's {kind} history. This needs --force-all-users IN ADDITION "
|
||||||
|
"to --confirm to proceed.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
target = "ALL users" if args.all_users else ", ".join(users)
|
||||||
|
if not _gated(f"permanently delete {kind} for {target}", args.confirm):
|
||||||
|
return 3
|
||||||
|
fn = c.trashbin_cleanup if kind == "trashbin" else c.versions_cleanup
|
||||||
|
out = fn([] if args.all_users else users)
|
||||||
|
return _ok(f"{kind} cleaned for {target}",
|
||||||
|
{"cleanup": kind, "target": target, "detail": out}, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_trashbin_cleanup(c: OwncloudClient, args) -> int:
|
||||||
|
return _cleanup(c, args, "trashbin")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_versions_cleanup(c: OwncloudClient, args) -> int:
|
||||||
|
return _cleanup(c, args, "versions")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(prog="owncloud", description="ownCloud backup inventory (GPS audit)")
|
||||||
|
p.add_argument("--host", help="override server host/IP (default 172.16.3.22)")
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
sub.add_parser("status")
|
||||||
|
u = sub.add_parser("users"); u.add_argument("--json", action="store_true")
|
||||||
|
us = sub.add_parser("usage"); us.add_argument("--json", action="store_true")
|
||||||
|
a = sub.add_parser("audit"); a.add_argument("--client"); a.add_argument("--rmm", action="store_true"); a.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
def _pw_opts(sp):
|
||||||
|
sp.add_argument("--password", help="password literal (avoid: lands in shell history/ps)")
|
||||||
|
sp.add_argument("--password-env", metavar="VAR", help="read password from this env var")
|
||||||
|
sp.add_argument("--gen-password", action="store_true", help="generate a strong password")
|
||||||
|
|
||||||
|
# --- user lifecycle (writes) ---
|
||||||
|
ua = sub.add_parser("user-add")
|
||||||
|
ua.add_argument("uid"); ua.add_argument("--display-name"); ua.add_argument("--email")
|
||||||
|
ua.add_argument("-g", "--group", action="append", help="group to add to (repeatable)")
|
||||||
|
_pw_opts(ua); ua.add_argument("--confirm", action="store_true"); ua.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
ud = sub.add_parser("user-delete"); ud.add_argument("uid")
|
||||||
|
ud.add_argument("-f", "--force", action="store_true"); ud.add_argument("--confirm", action="store_true"); ud.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
ue = sub.add_parser("user-enable"); ue.add_argument("uid"); ue.add_argument("--confirm", action="store_true"); ue.add_argument("--json", action="store_true")
|
||||||
|
udi = sub.add_parser("user-disable"); udi.add_argument("uid"); udi.add_argument("--confirm", action="store_true"); udi.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
urp = sub.add_parser("user-reset-password"); urp.add_argument("uid")
|
||||||
|
_pw_opts(urp); urp.add_argument("--confirm", action="store_true"); urp.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
um = sub.add_parser("user-modify"); um.add_argument("uid")
|
||||||
|
um.add_argument("--display-name"); um.add_argument("--email")
|
||||||
|
um.add_argument("--confirm", action="store_true"); um.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
# --- quota ---
|
||||||
|
q = sub.add_parser("quota"); q.add_argument("uid"); q.add_argument("--json", action="store_true")
|
||||||
|
qs = sub.add_parser("quota-set"); qs.add_argument("uid"); qs.add_argument("value", help="e.g. '5 GB', 'none' (unlimited)")
|
||||||
|
qs.add_argument("--confirm", action="store_true"); qs.add_argument("--json", action="store_true")
|
||||||
|
qr = sub.add_parser("quota-reset"); qr.add_argument("uid"); qr.add_argument("--confirm", action="store_true"); qr.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
# --- groups ---
|
||||||
|
gl = sub.add_parser("groups"); gl.add_argument("--json", action="store_true")
|
||||||
|
gm = sub.add_parser("group-members"); gm.add_argument("group"); gm.add_argument("--json", action="store_true")
|
||||||
|
ug = sub.add_parser("user-groups"); ug.add_argument("uid"); ug.add_argument("--json", action="store_true")
|
||||||
|
gadd = sub.add_parser("group-add"); gadd.add_argument("group"); gadd.add_argument("--confirm", action="store_true"); gadd.add_argument("--json", action="store_true")
|
||||||
|
gdel = sub.add_parser("group-delete"); gdel.add_argument("group"); gdel.add_argument("--confirm", action="store_true"); gdel.add_argument("--json", action="store_true")
|
||||||
|
gam = sub.add_parser("group-add-member"); gam.add_argument("group"); gam.add_argument("-m", "--member", action="append", required=True); gam.add_argument("--confirm", action="store_true"); gam.add_argument("--json", action="store_true")
|
||||||
|
grm = sub.add_parser("group-remove-member"); grm.add_argument("group"); grm.add_argument("-m", "--member", action="append", required=True); grm.add_argument("--confirm", action="store_true"); grm.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
# --- shares (read-only) ---
|
||||||
|
sh = sub.add_parser("shares"); sh.add_argument("--user"); sh.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
# --- server / apps / config ---
|
||||||
|
mnt = sub.add_parser("maintenance"); mnt.add_argument("state", choices=["status", "on", "off"]); mnt.add_argument("--confirm", action="store_true"); mnt.add_argument("--json", action="store_true")
|
||||||
|
ap = sub.add_parser("apps"); ap.add_argument("--json", action="store_true")
|
||||||
|
ape = sub.add_parser("app-enable"); ape.add_argument("app"); ape.add_argument("--confirm", action="store_true"); ape.add_argument("--json", action="store_true")
|
||||||
|
apd = sub.add_parser("app-disable"); apd.add_argument("app"); apd.add_argument("--confirm", action="store_true"); apd.add_argument("--json", action="store_true")
|
||||||
|
cg = sub.add_parser("config-get"); cg.add_argument("name", nargs="?", help="omit to dump all system config"); cg.add_argument("--json", action="store_true")
|
||||||
|
cs = sub.add_parser("config-set"); cs.add_argument("name"); cs.add_argument("--value", required=True); cs.add_argument("--type", default="string", choices=["string", "integer", "double", "boolean", "json"]); cs.add_argument("--confirm", action="store_true"); cs.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
# --- files admin ---
|
||||||
|
fs = sub.add_parser("files-scan"); fs.add_argument("--user"); fs.add_argument("--all", action="store_true"); fs.add_argument("--path"); fs.add_argument("--confirm", action="store_true"); fs.add_argument("--json", action="store_true")
|
||||||
|
to = sub.add_parser("transfer-ownership"); to.add_argument("source"); to.add_argument("dest"); to.add_argument("--path"); to.add_argument("--confirm", action="store_true"); to.add_argument("--json", action="store_true")
|
||||||
|
tc = sub.add_parser("trashbin-cleanup"); tc.add_argument("--user", action="append"); tc.add_argument("--all-users", action="store_true"); tc.add_argument("--force-all-users", action="store_true", help="required with --all-users + --confirm to purge everyone"); tc.add_argument("--confirm", action="store_true"); tc.add_argument("--json", action="store_true")
|
||||||
|
vc = sub.add_parser("versions-cleanup"); vc.add_argument("--user", action="append"); vc.add_argument("--all-users", action="store_true"); vc.add_argument("--force-all-users", action="store_true", help="required with --all-users + --confirm to purge everyone"); vc.add_argument("--confirm", action="store_true"); vc.add_argument("--json", action="store_true")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
c = OwncloudClient(args.host)
|
||||||
|
handlers = {"status": cmd_status, "users": cmd_users,
|
||||||
|
"usage": cmd_usage, "audit": cmd_audit,
|
||||||
|
"user-add": cmd_user_add, "user-delete": cmd_user_delete,
|
||||||
|
"user-enable": cmd_user_enable, "user-disable": cmd_user_disable,
|
||||||
|
"user-reset-password": cmd_user_reset_password,
|
||||||
|
"user-modify": cmd_user_modify,
|
||||||
|
"quota": cmd_quota, "quota-set": cmd_quota_set,
|
||||||
|
"quota-reset": cmd_quota_reset,
|
||||||
|
"groups": cmd_groups, "group-members": cmd_group_members,
|
||||||
|
"user-groups": cmd_user_groups,
|
||||||
|
"group-add": cmd_group_add, "group-delete": cmd_group_delete,
|
||||||
|
"group-add-member": cmd_group_add_member,
|
||||||
|
"group-remove-member": cmd_group_remove_member,
|
||||||
|
"shares": cmd_shares,
|
||||||
|
"maintenance": cmd_maintenance, "apps": cmd_apps,
|
||||||
|
"app-enable": cmd_app_enable, "app-disable": cmd_app_disable,
|
||||||
|
"config-get": cmd_config_get, "config-set": cmd_config_set,
|
||||||
|
"files-scan": cmd_files_scan,
|
||||||
|
"transfer-ownership": cmd_transfer_ownership,
|
||||||
|
"trashbin-cleanup": cmd_trashbin_cleanup,
|
||||||
|
"versions-cleanup": cmd_versions_cleanup}
|
||||||
|
try:
|
||||||
|
return handlers[args.cmd](c, args)
|
||||||
|
except bc.BackupError as exc:
|
||||||
|
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||||
|
# best-effort error logging per house rule
|
||||||
|
try:
|
||||||
|
root = bc.resolve_root()
|
||||||
|
bc.subprocess.run(["bash", str(root / ".claude/scripts/log-skill-error.sh"),
|
||||||
|
"owncloud", str(exc)[:200]], timeout=15)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
510
.claude/skills/owncloud/scripts/owncloud_client.py
Normal file
510
.claude/skills/owncloud/scripts/owncloud_client.py
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ownCloud server client for the `owncloud` skill (GPS backup verification).
|
||||||
|
|
||||||
|
Talks to ACG's live ownCloud 10.16 server (cloud.acghosting.com / 172.16.3.22)
|
||||||
|
over SSH and drives the `occ` admin CLI. Read-only: enumerates users, their
|
||||||
|
quota + actual storage used, and last login so the GPS backup audit can see who
|
||||||
|
is actually backing up to ownCloud and how much data they hold.
|
||||||
|
|
||||||
|
There is NO ownCloud admin WEB/OCS account in the vault, so the access channel is
|
||||||
|
SSH (root@172.16.3.22, paramiko password auth) + `occ`, not the OCS HTTP API.
|
||||||
|
`occ` must run as the web/service user, so every invocation is:
|
||||||
|
|
||||||
|
sudo -u apache php /var/www/owncloud/occ <cmd>
|
||||||
|
|
||||||
|
Per-user "used bytes" is not exposed by occ on this version (no `user:info`).
|
||||||
|
Instead we read the authoritative figure straight from ownCloud's filecache: the
|
||||||
|
size ownCloud itself reports for each user's root. DB creds are read live from
|
||||||
|
the server's config.php (we already have root), so nothing is duplicated and it
|
||||||
|
survives a DB-password rotation. If a user's cached size is dirty (-1), we fall
|
||||||
|
back to `du -sb <home>/files` for that one user (slower, generous timeout).
|
||||||
|
|
||||||
|
Credentials load lazily from the SOPS vault (never hardcoded):
|
||||||
|
`infrastructure/owncloud-vm.sops.yaml` -> credentials.username / credentials.password.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
# Import the shared backup helpers from .claude/scripts.
|
||||||
|
_SKILL_DIR = Path(__file__).resolve().parent.parent # .../skills/owncloud
|
||||||
|
_SCRIPTS_DIR = _SKILL_DIR.parent.parent / "scripts" # .../.claude/scripts
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
import backup_common as bc # noqa: E402
|
||||||
|
|
||||||
|
VAULT_ENTRY = "infrastructure/owncloud-vm.sops.yaml"
|
||||||
|
VAULT_FIELD_USER = "credentials.username"
|
||||||
|
VAULT_FIELD_PASS = "credentials.password"
|
||||||
|
|
||||||
|
DEFAULT_HOST = "172.16.3.22"
|
||||||
|
SSH_PORT = 22
|
||||||
|
# occ must run as the web user (apache) with the system php against the install dir.
|
||||||
|
OCC_INSTALL_DIR = "/var/www/owncloud"
|
||||||
|
OCC = f"sudo -u apache php {OCC_INSTALL_DIR}/occ"
|
||||||
|
CONFIG_PHP = f"{OCC_INSTALL_DIR}/config/config.php"
|
||||||
|
|
||||||
|
EXEC_TIMEOUT = 60 # seconds for normal occ / mysql calls
|
||||||
|
DU_TIMEOUT = 900 # du on a dirty-cache home can be hundreds of GB
|
||||||
|
|
||||||
|
|
||||||
|
class OwncloudClient:
|
||||||
|
def __init__(self, host: Optional[str] = None):
|
||||||
|
self.host = host or DEFAULT_HOST
|
||||||
|
self._ssh: Optional[paramiko.SSHClient] = None
|
||||||
|
self._db: Optional[dict] = None
|
||||||
|
|
||||||
|
# -- ssh -------------------------------------------------------------------
|
||||||
|
def _connect(self) -> paramiko.SSHClient:
|
||||||
|
if self._ssh is not None:
|
||||||
|
return self._ssh
|
||||||
|
user = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_USER)
|
||||||
|
pw = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_PASS)
|
||||||
|
cli = paramiko.SSHClient()
|
||||||
|
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
cli.connect(self.host, port=SSH_PORT, username=user, password=pw,
|
||||||
|
timeout=20, look_for_keys=False, allow_agent=False)
|
||||||
|
except Exception as exc: # paramiko raises several distinct types
|
||||||
|
raise bc.BackupError(f"SSH connect to {user}@{self.host} failed: {exc}") from exc
|
||||||
|
self._ssh = cli
|
||||||
|
return cli
|
||||||
|
|
||||||
|
def run(self, cmd: str, timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]:
|
||||||
|
"""Run a shell command over SSH; return (exit_status, stdout, stderr)."""
|
||||||
|
cli = self._connect()
|
||||||
|
try:
|
||||||
|
_in, out, err = cli.exec_command(cmd, timeout=timeout)
|
||||||
|
stdout = out.read().decode("utf-8", "replace")
|
||||||
|
stderr = err.read().decode("utf-8", "replace")
|
||||||
|
rc = out.channel.recv_exit_status()
|
||||||
|
except Exception as exc:
|
||||||
|
raise bc.BackupError(f"SSH command failed ({cmd[:60]}...): {exc}") from exc
|
||||||
|
return rc, stdout, stderr
|
||||||
|
|
||||||
|
def occ(self, args: str, timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]:
|
||||||
|
return self.run(f"{OCC} {args}", timeout=timeout)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._ssh is not None:
|
||||||
|
try:
|
||||||
|
self._ssh.close()
|
||||||
|
finally:
|
||||||
|
self._ssh = None
|
||||||
|
|
||||||
|
# -- server status ---------------------------------------------------------
|
||||||
|
def server_status(self) -> dict:
|
||||||
|
"""occ status (installed/version/edition) plus the `occ -V` version string."""
|
||||||
|
rc, out, err = self.occ("status --output=json")
|
||||||
|
if rc != 0:
|
||||||
|
raise bc.BackupError(f"occ status failed (rc={rc}): {err.strip()[:300]}")
|
||||||
|
try:
|
||||||
|
status = json.loads(out)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise bc.BackupError(f"occ status returned non-JSON: {out.strip()[:300]}")
|
||||||
|
rc2, ver, _ = self.occ("-V")
|
||||||
|
status["occ_version"] = ver.strip() if rc2 == 0 else None
|
||||||
|
status["host"] = self.host
|
||||||
|
return status
|
||||||
|
|
||||||
|
# -- storage used (filecache primary, du fallback) -------------------------
|
||||||
|
def _db_config(self) -> dict:
|
||||||
|
"""Read DB name/user/password/prefix live from config.php (we have root)."""
|
||||||
|
if self._db is not None:
|
||||||
|
return self._db
|
||||||
|
rc, out, err = self.run(
|
||||||
|
"grep -E \"'db(name|user|password|tableprefix)'\" " + CONFIG_PHP)
|
||||||
|
if rc != 0:
|
||||||
|
raise bc.BackupError(f"reading {CONFIG_PHP} failed (rc={rc}): {err.strip()[:200]}")
|
||||||
|
cfg = {"dbname": "owncloud", "dbuser": "owncloud",
|
||||||
|
"dbpassword": "", "dbtableprefix": "oc_"}
|
||||||
|
for line in out.splitlines():
|
||||||
|
# form: 'dbname' => 'value',
|
||||||
|
if "=>" not in line:
|
||||||
|
continue
|
||||||
|
key, _, val = line.partition("=>")
|
||||||
|
k = key.strip().strip("'\" ")
|
||||||
|
v = val.strip().rstrip(",").strip().strip("'\"")
|
||||||
|
if k in cfg:
|
||||||
|
cfg[k] = v
|
||||||
|
self._db = cfg
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
def _mysql(self, sql: str) -> str:
|
||||||
|
cfg = self._db_config()
|
||||||
|
cmd = (f"MYSQL_PWD={shlex.quote(cfg['dbpassword'])} "
|
||||||
|
f"mysql -N -B -u {shlex.quote(cfg['dbuser'])} "
|
||||||
|
f"{shlex.quote(cfg['dbname'])} -e {shlex.quote(sql)}")
|
||||||
|
rc, out, err = self.run(cmd)
|
||||||
|
if rc != 0:
|
||||||
|
raise bc.BackupError(f"mysql query failed (rc={rc}): {err.strip()[:200]}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _filecache_sizes(self) -> dict[str, int]:
|
||||||
|
"""uid -> cached used bytes for each per-user home storage (ownCloud's own
|
||||||
|
figure). A size of -1 means the cache is dirty; such uids are omitted here
|
||||||
|
and handled by the du fallback."""
|
||||||
|
pfx = self._db_config()["dbtableprefix"]
|
||||||
|
sql = (f"SELECT s.id, fc.size FROM {pfx}storages s "
|
||||||
|
f"JOIN {pfx}filecache fc ON fc.storage=s.numeric_id "
|
||||||
|
f"WHERE s.id LIKE 'home::%' AND fc.path='';")
|
||||||
|
out = self._mysql(sql)
|
||||||
|
sizes: dict[str, int] = {}
|
||||||
|
for row in out.splitlines():
|
||||||
|
if "\t" not in row:
|
||||||
|
continue
|
||||||
|
sid, _, val = row.partition("\t")
|
||||||
|
if not sid.startswith("home::"):
|
||||||
|
continue
|
||||||
|
uid = sid[len("home::"):]
|
||||||
|
try:
|
||||||
|
n = int(val.strip())
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if n >= 0:
|
||||||
|
sizes[uid] = n
|
||||||
|
return sizes
|
||||||
|
|
||||||
|
def _du_bytes(self, uid: str, home: str) -> Optional[int]:
|
||||||
|
"""Fallback used-bytes via du on the on-disk home (dirty-cache users only)."""
|
||||||
|
if not home:
|
||||||
|
return None
|
||||||
|
rc, out, err = self.run(
|
||||||
|
f"du -sb {shlex.quote(home + '/files')} 2>/dev/null | cut -f1",
|
||||||
|
timeout=DU_TIMEOUT)
|
||||||
|
try:
|
||||||
|
return int(out.strip())
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -- users -----------------------------------------------------------------
|
||||||
|
def list_users(self, with_usage: bool = True) -> list[dict]:
|
||||||
|
"""All users with attributes (uid, display name, email, quota, enabled,
|
||||||
|
last login, home, backend). When with_usage, enrich each with used bytes
|
||||||
|
from the filecache, falling back to du for dirty-cache users."""
|
||||||
|
rc, out, err = self.occ("user:list --show-all-attributes --output=json")
|
||||||
|
if rc != 0:
|
||||||
|
raise bc.BackupError(f"occ user:list failed (rc={rc}): {err.strip()[:300]}")
|
||||||
|
try:
|
||||||
|
raw = json.loads(out)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise bc.BackupError(f"occ user:list returned non-JSON: {out.strip()[:300]}")
|
||||||
|
|
||||||
|
users: list[dict] = []
|
||||||
|
for uid, attrs in raw.items():
|
||||||
|
attrs = attrs or {}
|
||||||
|
users.append({
|
||||||
|
"uid": uid,
|
||||||
|
"display_name": attrs.get("displayName"),
|
||||||
|
"email": attrs.get("email"),
|
||||||
|
"quota": attrs.get("quota"),
|
||||||
|
"enabled": _as_bool(attrs.get("enabled")),
|
||||||
|
"last_login": _epoch(attrs.get("lastLogin")),
|
||||||
|
"creation_time": _epoch(attrs.get("creationTime")),
|
||||||
|
"backend": attrs.get("backend"),
|
||||||
|
"home": attrs.get("home"),
|
||||||
|
"used_bytes": None,
|
||||||
|
"used_source": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
if with_usage:
|
||||||
|
sizes = self._filecache_sizes()
|
||||||
|
for u in users:
|
||||||
|
uid = u["uid"]
|
||||||
|
if uid in sizes:
|
||||||
|
u["used_bytes"] = sizes[uid]
|
||||||
|
u["used_source"] = "filecache"
|
||||||
|
else:
|
||||||
|
# dirty cache (-1) or no storage row: compute on disk
|
||||||
|
du = self._du_bytes(uid, u.get("home"))
|
||||||
|
u["used_bytes"] = du
|
||||||
|
u["used_source"] = "du" if du is not None else None
|
||||||
|
return users
|
||||||
|
|
||||||
|
# -- occ helpers for management ops ----------------------------------------
|
||||||
|
def occ_env(self, env: dict, args: str,
|
||||||
|
timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]:
|
||||||
|
"""Run occ with extra environment (e.g. OC_PASS) exported for the apache
|
||||||
|
user. `sudo -u apache env KEY=val php occ ...` — the explicit `env` sets
|
||||||
|
the var inside apache's context regardless of sudo's env_reset policy.
|
||||||
|
|
||||||
|
Caveat: the value is briefly visible in `ps` on the server to root; this
|
||||||
|
is occ's own documented mechanism for passwords and the box is root-only.
|
||||||
|
"""
|
||||||
|
prefix = " ".join(f"{k}={shlex.quote(str(v))}" for k, v in env.items())
|
||||||
|
cmd = f"sudo -u apache env {prefix} php {OCC_INSTALL_DIR}/occ {args}"
|
||||||
|
return self.run(cmd, timeout=timeout)
|
||||||
|
|
||||||
|
def _occ_ok(self, args: str, timeout: int = EXEC_TIMEOUT) -> str:
|
||||||
|
"""Run occ, raise BackupError on non-zero, return stdout."""
|
||||||
|
rc, out, err = self.occ(args, timeout=timeout)
|
||||||
|
if rc != 0:
|
||||||
|
label = args.split()[0] if args else "occ"
|
||||||
|
raise bc.BackupError(f"occ {label} failed (rc={rc}): "
|
||||||
|
f"{(err or out).strip()[:300]}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _occ_env_ok(self, env: dict, args: str,
|
||||||
|
timeout: int = EXEC_TIMEOUT) -> str:
|
||||||
|
"""occ_env variant of _occ_ok (occ_env is not wrapped by _occ_ok)."""
|
||||||
|
rc, out, err = self.occ_env(env, args, timeout=timeout)
|
||||||
|
if rc != 0:
|
||||||
|
label = args.split()[0] if args else "occ"
|
||||||
|
raise bc.BackupError(f"occ {label} failed (rc={rc}): "
|
||||||
|
f"{(err or out).strip()[:300]}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _occ_json(self, args: str, timeout: int = EXEC_TIMEOUT) -> Any:
|
||||||
|
"""occ with --output=json; parse defensively so a non-JSON banner
|
||||||
|
(maintenance notice, PHP deprecation, update line) surfaces as a clean
|
||||||
|
BackupError rather than an uncaught JSONDecodeError traceback."""
|
||||||
|
out = self._occ_ok(args, timeout=timeout).strip()
|
||||||
|
try:
|
||||||
|
return json.loads(out or "null")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
label = args.split()[0] if args else "occ"
|
||||||
|
raise bc.BackupError(f"occ {label} returned non-JSON: {out[:300]}")
|
||||||
|
|
||||||
|
# -- user lifecycle --------------------------------------------------------
|
||||||
|
def create_user(self, uid: str, password: str,
|
||||||
|
display_name: Optional[str] = None,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
groups: Optional[list[str]] = None) -> dict:
|
||||||
|
# options first, then `--`, then the positional uid so a uid that
|
||||||
|
# happens to start with '-' is never parsed by occ as an option flag.
|
||||||
|
parts = ["user:add --password-from-env -n"]
|
||||||
|
if display_name:
|
||||||
|
parts.append(f"--display-name={shlex.quote(display_name)}")
|
||||||
|
if email:
|
||||||
|
parts.append(f"--email={shlex.quote(email)}")
|
||||||
|
for g in (groups or []):
|
||||||
|
parts.append(f"--group={shlex.quote(g)}")
|
||||||
|
parts.append(f"-- {shlex.quote(uid)}")
|
||||||
|
out = self._occ_env_ok({"OC_PASS": password}, " ".join(parts))
|
||||||
|
return {"uid": uid, "output": out.strip()}
|
||||||
|
|
||||||
|
def delete_user(self, uid: str, force: bool = False) -> str:
|
||||||
|
return self._occ_ok(f"user:delete{' -f' if force else ''} -- "
|
||||||
|
f"{shlex.quote(uid)}", timeout=DU_TIMEOUT).strip()
|
||||||
|
|
||||||
|
def set_user_enabled(self, uid: str, enabled: bool) -> str:
|
||||||
|
verb = "user:enable" if enabled else "user:disable"
|
||||||
|
return self._occ_ok(f"{verb} -- {shlex.quote(uid)}").strip()
|
||||||
|
|
||||||
|
def reset_password(self, uid: str, password: str) -> str:
|
||||||
|
return self._occ_env_ok(
|
||||||
|
{"OC_PASS": password},
|
||||||
|
f"user:resetpassword --password-from-env -- {shlex.quote(uid)}").strip()
|
||||||
|
|
||||||
|
def modify_user(self, uid: str, key: str, value: str) -> str:
|
||||||
|
"""key is one of: displayname, email (per occ user:modify)."""
|
||||||
|
return self._occ_ok(
|
||||||
|
f"user:modify -- {shlex.quote(uid)} {shlex.quote(key)} "
|
||||||
|
f"{shlex.quote(value)}").strip()
|
||||||
|
|
||||||
|
# -- quota (occ user:setting <uid> files quota) ----------------------------
|
||||||
|
def get_quota(self, uid: str) -> Optional[str]:
|
||||||
|
"""Return the user's quota string, or None when it is unset (= default).
|
||||||
|
A non-existent user or a genuine occ failure raises BackupError - occ
|
||||||
|
returns rc=1 for both 'setting unset' and 'user missing', so we key off
|
||||||
|
the message ('setting does not exist' == unset)."""
|
||||||
|
rc, out, err = self.occ(f"user:setting -- {shlex.quote(uid)} files quota")
|
||||||
|
if rc == 0:
|
||||||
|
return out.strip() or None
|
||||||
|
msg = (out or err).strip()
|
||||||
|
if "setting does not exist" in msg.lower():
|
||||||
|
return None
|
||||||
|
raise bc.BackupError(f"occ user:setting quota failed (rc={rc}): {msg[:300]}")
|
||||||
|
|
||||||
|
def set_quota(self, uid: str, value: str) -> str:
|
||||||
|
return self._occ_ok(
|
||||||
|
f"user:setting --value={shlex.quote(value)} "
|
||||||
|
f"-- {shlex.quote(uid)} files quota").strip()
|
||||||
|
|
||||||
|
def reset_quota(self, uid: str) -> str:
|
||||||
|
return self._occ_ok(
|
||||||
|
f"user:setting --delete -- {shlex.quote(uid)} files quota").strip()
|
||||||
|
|
||||||
|
# -- groups ----------------------------------------------------------------
|
||||||
|
def list_groups(self) -> Any:
|
||||||
|
return self._occ_json("group:list --output=json")
|
||||||
|
|
||||||
|
def group_members(self, group: str) -> Any:
|
||||||
|
return self._occ_json(
|
||||||
|
f"group:list-members --output=json -- {shlex.quote(group)}")
|
||||||
|
|
||||||
|
def user_groups(self, uid: str) -> Any:
|
||||||
|
return self._occ_json(
|
||||||
|
f"user:list-groups --output=json -- {shlex.quote(uid)}")
|
||||||
|
|
||||||
|
def create_group(self, name: str) -> str:
|
||||||
|
return self._occ_ok(f"group:add -- {shlex.quote(name)}").strip()
|
||||||
|
|
||||||
|
def delete_group(self, name: str) -> str:
|
||||||
|
return self._occ_ok(f"group:delete -- {shlex.quote(name)}").strip()
|
||||||
|
|
||||||
|
def add_group_members(self, group: str, members: list[str]) -> str:
|
||||||
|
# --member= binds each value even if it starts with '-'.
|
||||||
|
ms = " ".join(f"--member={shlex.quote(m)}" for m in members)
|
||||||
|
return self._occ_ok(
|
||||||
|
f"group:add-member {ms} -- {shlex.quote(group)}").strip()
|
||||||
|
|
||||||
|
def remove_group_members(self, group: str, members: list[str]) -> str:
|
||||||
|
ms = " ".join(f"--member={shlex.quote(m)}" for m in members)
|
||||||
|
return self._occ_ok(
|
||||||
|
f"group:remove-member {ms} -- {shlex.quote(group)}").strip()
|
||||||
|
|
||||||
|
# -- shares (read-only, straight from the DB; occ has no share list) --------
|
||||||
|
SHARE_TYPES = {0: "user", 1: "group", 3: "public_link",
|
||||||
|
4: "email", 6: "federated"}
|
||||||
|
|
||||||
|
def list_shares(self, uid: Optional[str] = None) -> list[dict]:
|
||||||
|
# NB: uid is NOT inlined into SQL. MySQL honours backslash escapes by
|
||||||
|
# default, so ''-doubling would not safely contain a hostile/odd uid
|
||||||
|
# (and occ allows "'" in uids). Fetch all rows, filter in Python.
|
||||||
|
pfx = self._db_config()["dbtableprefix"]
|
||||||
|
sql = (f"SELECT s.id, s.share_type, s.share_with, s.uid_owner, "
|
||||||
|
f"s.file_target, s.permissions, s.stime, fc.path "
|
||||||
|
f"FROM {pfx}share s "
|
||||||
|
f"LEFT JOIN {pfx}filecache fc ON fc.fileid=s.file_source "
|
||||||
|
f"ORDER BY s.stime DESC;")
|
||||||
|
out = self._mysql(sql)
|
||||||
|
shares: list[dict] = []
|
||||||
|
for row in out.splitlines():
|
||||||
|
f = [None if v in ("NULL", "") else v for v in row.split("\t")]
|
||||||
|
if len(f) < 8:
|
||||||
|
continue
|
||||||
|
if uid and uid not in (f[2], f[3]): # share_with / owner
|
||||||
|
continue
|
||||||
|
st = _int(f[1])
|
||||||
|
shares.append({
|
||||||
|
"id": _int(f[0]),
|
||||||
|
"share_type": self.SHARE_TYPES.get(st, str(st)),
|
||||||
|
"share_with": f[2],
|
||||||
|
"owner": f[3],
|
||||||
|
"target": f[4],
|
||||||
|
"permissions": _int(f[5]),
|
||||||
|
"created": _epoch(f[6]),
|
||||||
|
"path": f[7],
|
||||||
|
})
|
||||||
|
return shares
|
||||||
|
|
||||||
|
# -- server / apps / config ------------------------------------------------
|
||||||
|
def maintenance_mode(self, on: bool) -> str:
|
||||||
|
return self._occ_ok("maintenance:mode " + ("--on" if on else "--off")).strip()
|
||||||
|
|
||||||
|
def maintenance_status(self) -> bool:
|
||||||
|
# reuse config_get so a genuine occ failure raises (not a false 'off');
|
||||||
|
# the maintenance key is always set, so None would itself be anomalous.
|
||||||
|
val = self.config_get("maintenance")
|
||||||
|
return (val or "").strip().lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
def list_apps(self) -> Any:
|
||||||
|
return self._occ_json("app:list --output=json")
|
||||||
|
|
||||||
|
def enable_app(self, app: str) -> str:
|
||||||
|
return self._occ_ok(f"app:enable -- {shlex.quote(app)}").strip()
|
||||||
|
|
||||||
|
def disable_app(self, app: str) -> str:
|
||||||
|
return self._occ_ok(f"app:disable -- {shlex.quote(app)}").strip()
|
||||||
|
|
||||||
|
def config_get(self, name: str) -> Optional[str]:
|
||||||
|
"""Return the system config value, or None when the key is genuinely
|
||||||
|
unset (occ rc=1 with empty output). A non-zero rc that carries an error
|
||||||
|
message is a real failure and raises, so callers never mistake a broken
|
||||||
|
occ read for 'value is unset'."""
|
||||||
|
rc, out, err = self.occ(f"config:system:get -- {shlex.quote(name)}")
|
||||||
|
if rc == 0:
|
||||||
|
return out.strip()
|
||||||
|
msg = (err or out).strip()
|
||||||
|
if not msg:
|
||||||
|
return None
|
||||||
|
raise bc.BackupError(f"occ config:system:get failed (rc={rc}): {msg[:300]}")
|
||||||
|
|
||||||
|
def config_list(self) -> Any:
|
||||||
|
return self._occ_json("config:list system --output=json")
|
||||||
|
|
||||||
|
def config_set(self, name: str, value: str, vtype: str = "string") -> str:
|
||||||
|
return self._occ_ok(
|
||||||
|
f"config:system:set {shlex.quote(name)} "
|
||||||
|
f"--value={shlex.quote(value)} --type={shlex.quote(vtype)}").strip()
|
||||||
|
|
||||||
|
# -- files admin -----------------------------------------------------------
|
||||||
|
def files_scan(self, uid: Optional[str] = None, all_users: bool = False,
|
||||||
|
path: Optional[str] = None) -> str:
|
||||||
|
opts = "files:scan"
|
||||||
|
if path:
|
||||||
|
opts += f" --path={shlex.quote(path)}"
|
||||||
|
if all_users:
|
||||||
|
args = f"{opts} --all"
|
||||||
|
elif uid:
|
||||||
|
args = f"{opts} -- {shlex.quote(uid)}"
|
||||||
|
else:
|
||||||
|
raise bc.BackupError("files_scan requires a uid or all_users=True")
|
||||||
|
return self._occ_ok(args, timeout=DU_TIMEOUT).strip()
|
||||||
|
|
||||||
|
def transfer_ownership(self, src: str, dst: str,
|
||||||
|
path: Optional[str] = None) -> str:
|
||||||
|
args = "files:transfer-ownership -s"
|
||||||
|
if path:
|
||||||
|
args += f" --path={shlex.quote(path)}"
|
||||||
|
args += f" -- {shlex.quote(src)} {shlex.quote(dst)}"
|
||||||
|
return self._occ_ok(args, timeout=DU_TIMEOUT).strip()
|
||||||
|
|
||||||
|
def trashbin_cleanup(self, users: list[str]) -> str:
|
||||||
|
us = (" -- " + " ".join(shlex.quote(u) for u in users)) if users else ""
|
||||||
|
return self._occ_ok(f"trashbin:cleanup{us}", timeout=DU_TIMEOUT).strip()
|
||||||
|
|
||||||
|
def versions_cleanup(self, users: list[str]) -> str:
|
||||||
|
us = (" -- " + " ".join(shlex.quote(u) for u in users)) if users else ""
|
||||||
|
return self._occ_ok(f"versions:cleanup{us}", timeout=DU_TIMEOUT).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _int(v: Any) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
return int(str(v).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _epoch(v: Any) -> Optional[str]:
|
||||||
|
"""occ emits lastLogin/creationTime as Unix epoch seconds; 0 = never."""
|
||||||
|
try:
|
||||||
|
n = int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if n <= 0:
|
||||||
|
return None
|
||||||
|
return datetime.fromtimestamp(n, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bool(v: Any) -> bool:
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return v
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.strip().lower() in ("true", "yes", "1", "enabled")
|
||||||
|
return bool(v)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
c = OwncloudClient()
|
||||||
|
try:
|
||||||
|
s = c.server_status()
|
||||||
|
print(f"[OK] ownCloud reachable at {c.host}; version={s.get('versionstring')} "
|
||||||
|
f"edition={s.get('edition')}")
|
||||||
|
return 0
|
||||||
|
except bc.BackupError as exc:
|
||||||
|
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
464
.claude/skills/packetdial/docs/VOIP_ONBOARDING_WORKFLOW.md
Normal file
464
.claude/skills/packetdial/docs/VOIP_ONBOARDING_WORKFLOW.md
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
# VoIP Client Onboarding Workflow - Complete Guide
|
||||||
|
|
||||||
|
**Last Updated:** 2026-07-09
|
||||||
|
**Validated Against:** russo.91912.service (live production), vwp.91912.service (test)
|
||||||
|
**Platforms:** PacketDial/NetSapiens v44.4.10 + Yealink YMCS v2
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Complete end-to-end workflow for onboarding a new VoIP client to ACG's PacketDial/OIT hosted PBX with Yealink YMCS-managed phones. **~95% API-automatable** with proper credential management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Credentials Required
|
||||||
|
|
||||||
|
| Credential | Vault Path | Purpose |
|
||||||
|
|------------|------------|---------|
|
||||||
|
| NetSapiens Reseller API Key | `msp-tools/oitvoip.sops.yaml` → `credentials.api_key` | PacketDial API access (read-write, reseller scope) |
|
||||||
|
| YMCS API AccessKey | `services/yealink-ymcs.sops.yaml` → `credentials.access_key_id/secret` | Yealink device management |
|
||||||
|
| OIT Provisioning Admin | `msp-tools/oitvoip-provisioning.sops.yaml` → `credentials.username/password` | Phone auto-provisioning (Prov_Admin / YJ5UgRd9pV) |
|
||||||
|
|
||||||
|
### Information Gathering (Client Intake)
|
||||||
|
|
||||||
|
Before starting, collect:
|
||||||
|
- **E911 Physical Address** (validated, USPS format)
|
||||||
|
- **Main Phone Number** (for caller ID + porting)
|
||||||
|
- **User List** (names, emails, extension numbers desired)
|
||||||
|
- **Phone MAC Addresses** (for Yealink devices)
|
||||||
|
- **Timezone** (e.g., America/Phoenix)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Workflow (Sequential Order)
|
||||||
|
|
||||||
|
### Phase 1: Domain Creation (PacketDial)
|
||||||
|
|
||||||
|
**Purpose:** Create the PBX domain (tenant) and register E911 address
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create domain with E911 address in one shot
|
||||||
|
bash .claude/scripts/py.sh .claude/skills/packetdial/scripts/ns.py onboard-domain \
|
||||||
|
--body-file client-config.json \
|
||||||
|
--confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
**`client-config.json` Template:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"domain": "clientname.91912.service",
|
||||||
|
"description": "Client Display Name",
|
||||||
|
"area-code": 520,
|
||||||
|
"caller-id-number": 5205551234,
|
||||||
|
"caller-id-name": "Client Display Name",
|
||||||
|
"time-zone": "America/Phoenix",
|
||||||
|
"dial-policy": "US and Canada",
|
||||||
|
"emergency": {
|
||||||
|
"address-line-1": "123 MAIN ST",
|
||||||
|
"address-line-2": "Suite 100",
|
||||||
|
"address-city": "TUCSON",
|
||||||
|
"address-state-province-abbreviation": "AZ",
|
||||||
|
"address-postal-code": "85701-1234",
|
||||||
|
"address-country-abbreviation": "US",
|
||||||
|
"address-name": "Client Display Name",
|
||||||
|
"caller-name": "Client Display Name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What This Does:**
|
||||||
|
1. `POST /domains` - Creates domain with basic config + limits
|
||||||
|
2. `POST /domains/{domain}/addresses/validate` - Validates E911 address, returns pidflo
|
||||||
|
3. `POST /domains/{domain}/addresses` - Creates E911 address record
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
- Domain created
|
||||||
|
- `emergency-address-id` (e.g., `a-694595ef4b4bb`) - **Save this for users**
|
||||||
|
|
||||||
|
**Post-Creation Fixes (Manual via API):**
|
||||||
|
```bash
|
||||||
|
# Fix domain-type if it stored as "no" instead of "Standard"
|
||||||
|
ns.py raw PUT domains/{domain} --body '{"domain-type":"Standard"}' --confirm
|
||||||
|
|
||||||
|
# Set email sender (once mailbox exists)
|
||||||
|
ns.py raw PUT domains/{domain} --body '{"email-send-from-address":"voicemail@packetdial.com"}' --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: User Creation (PacketDial)
|
||||||
|
|
||||||
|
**Purpose:** Create extensions/users (one per person)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ns.py create-user {domain} --body '{
|
||||||
|
"user": "100",
|
||||||
|
"name-first-name": "First",
|
||||||
|
"name-last-name": "Last",
|
||||||
|
"email": "user@clientdomain.com",
|
||||||
|
"emergency-address-id": "a-694595ef4b4bb"
|
||||||
|
}' --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Repeat for Each User**
|
||||||
|
|
||||||
|
**Expected State After Creation:**
|
||||||
|
- `account-status`: **"pwd reset"** (this is NORMAL - does NOT block SIP registration)
|
||||||
|
- `login-username`: Generated (e.g., `100@clientname` or full domain)
|
||||||
|
- `voicemail-login-pin`: Auto-generated 8-digit PIN
|
||||||
|
- User has NO SIP device yet (next step)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: SIP Device Creation (PacketDial) **[CRITICAL]**
|
||||||
|
|
||||||
|
**Purpose:** Create SIP registration credentials for each user
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ns.py create-device {domain} {user} --body '{
|
||||||
|
"device": "100",
|
||||||
|
"user": "100"
|
||||||
|
}' --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
**What This Does:**
|
||||||
|
- Creates a SIP device record matching the user extension
|
||||||
|
- **Auto-generates 16-character SIP password** (v2 API default)
|
||||||
|
- Returns: `device-sip-registration-password` (e.g., `ySjl5J2Tiv2FBT6M`)
|
||||||
|
|
||||||
|
**Retrieve the Password:**
|
||||||
|
```bash
|
||||||
|
ns.py devices {domain} {user}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Extract:** `device-sip-registration-password` - **This is the password YMCS needs**
|
||||||
|
|
||||||
|
**Why This Matters:**
|
||||||
|
- **User 100 with NO device = cannot register**
|
||||||
|
- Device password ≠ user password
|
||||||
|
- The device-sip-registration-password is what phones use to authenticate
|
||||||
|
|
||||||
|
**Verified Pattern (russo.91912.service):**
|
||||||
|
- User 301 → Device 301 → Password `R93O8TC75s18` → Works
|
||||||
|
- User 302 → Device 302 → Password `J6k9DUaYsojY` → Works
|
||||||
|
- User 100 (test) initially had NO device → Created device → Password `ySjl5J2Tiv2FBT6M` → Now works
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: DID Assignment (PacketDial)
|
||||||
|
|
||||||
|
**Purpose:** Assign phone numbers and route them to users
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ns.py create-did {domain} --body '{
|
||||||
|
"phonenumber": "15205551234",
|
||||||
|
"dial-rule-application": "to-user",
|
||||||
|
"dial-rule-translation-destination-user": "100",
|
||||||
|
"dial-rule-description": "Main line - User 100"
|
||||||
|
}' --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
**For Ported Numbers:** Update description with porting case number
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Add Physical Phones to YMCS
|
||||||
|
|
||||||
|
**Purpose:** Register Yealink phone hardware in YMCS cloud management
|
||||||
|
|
||||||
|
**Option A: Bulk Add by MAC**
|
||||||
|
```bash
|
||||||
|
ymcs.py add-devices-by-mac --body '{
|
||||||
|
"siteId": "{site-id}",
|
||||||
|
"macs": ["805e0cdd71b1", "805e0cdd71b2", "805e0cdd71b3"]
|
||||||
|
}' --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Single Device via RPS** (for zero-touch provisioning)
|
||||||
|
```bash
|
||||||
|
ymcs.py rps-add --body '{
|
||||||
|
"siteId": "{site-id}",
|
||||||
|
"macs": ["805e0cdd71b1"]
|
||||||
|
}' --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Get Site ID:**
|
||||||
|
```bash
|
||||||
|
ymcs.py sites
|
||||||
|
# Find client site, extract "id" field
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
```bash
|
||||||
|
ymcs.py devices
|
||||||
|
# Phones should show online status, LAN/WAN IPs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6: Push SIP Credentials to Phones (YMCS)
|
||||||
|
|
||||||
|
**Purpose:** Configure each phone with PacketDial SIP account
|
||||||
|
|
||||||
|
**For Each User/Phone:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create SIP account in YMCS with PacketDial credentials
|
||||||
|
ymcs.py add-sipaccount --body '{
|
||||||
|
"registerName": "100@clientname.91912.service",
|
||||||
|
"username": "100@clientname.91912.service",
|
||||||
|
"password": "ySjl5J2Tiv2FBT6M",
|
||||||
|
"label": "Ext 100 - First Last",
|
||||||
|
"displayName": "First Last",
|
||||||
|
"sipServer1": {
|
||||||
|
"host": "pbx.packetdial.com",
|
||||||
|
"port": 5060
|
||||||
|
},
|
||||||
|
"remark": "Client Name - User 100",
|
||||||
|
"siteId": "{site-id}"
|
||||||
|
}' --confirm
|
||||||
|
|
||||||
|
# Response includes: "id": "{account-id}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**CRITICAL:** The `password` MUST be the exact `device-sip-registration-password` from Phase 3.
|
||||||
|
|
||||||
|
**Schema Notes:**
|
||||||
|
- `sipServer1` is an **object** `{host, port}`, NOT a string
|
||||||
|
- `registerName` and `username` should match
|
||||||
|
- `accountType: 0` = SIP (auto-set)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 7: Bind SIP Account to Physical Phone (YMCS)
|
||||||
|
|
||||||
|
**Purpose:** Associate the SIP credentials with specific hardware
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get device ID from MAC
|
||||||
|
ymcs.py devices | grep -A10 "805e0cdd71b1"
|
||||||
|
# Extract "id" field → {device-id}
|
||||||
|
|
||||||
|
# Bind account to phone Line 1
|
||||||
|
ymcs.py raw POST /v2/dm/devices/{device-id}/bindAccounts --body '[{
|
||||||
|
"lineId": 1,
|
||||||
|
"accountType": 0,
|
||||||
|
"accountId": "{account-id}"
|
||||||
|
}]' --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body Format:** Array of binding objects (supports multi-line phones)
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
```bash
|
||||||
|
ymcs.py raw GET /v2/dm/devices/{device-id}/boundAccounts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** Shows account bound to lineId 1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 8: Phone Configuration Sync
|
||||||
|
|
||||||
|
**Purpose:** Force phones to pull updated config from YMCS
|
||||||
|
|
||||||
|
**Option A: Wait for Auto-Sync** (phones check YMCS every ~15 min)
|
||||||
|
|
||||||
|
**Option B: Trigger Reboot** (immediate)
|
||||||
|
```bash
|
||||||
|
ymcs.py reboot --body '{
|
||||||
|
"deviceIds": ["{device-id}"],
|
||||||
|
"deviceType": 1
|
||||||
|
}' --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
**deviceType Values:**
|
||||||
|
- 1 = Phone Device
|
||||||
|
- 3 = Room Device (Teams panels, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 9: Verification
|
||||||
|
|
||||||
|
**Check SIP Registration (PacketDial):**
|
||||||
|
```bash
|
||||||
|
ns.py devices {domain} {user}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Look for:**
|
||||||
|
- `device-sip-registration-state`: **"registered"** (success) or "unregistered" (problem)
|
||||||
|
- `device-sip-registration-contact`: Shows phone IP/port
|
||||||
|
- `device-sip-registration-user-agent`: Phone model/firmware
|
||||||
|
- `device-sip-registration-datetime`: Last registration timestamp
|
||||||
|
|
||||||
|
**Check Phone Status (YMCS):**
|
||||||
|
```bash
|
||||||
|
ymcs.py raw GET /v2/dm/devices/{device-id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Look for:**
|
||||||
|
- `deviceStatus`: "online"
|
||||||
|
- `accounts[].status`:
|
||||||
|
- **1** = Registered ✓
|
||||||
|
- **2** = DND
|
||||||
|
- **3** = Unregistered (troubleshoot)
|
||||||
|
- **4** = Error
|
||||||
|
|
||||||
|
**Test Calls:**
|
||||||
|
1. Dial extension-to-extension
|
||||||
|
2. Dial out to external number (verify caller ID)
|
||||||
|
3. Dial in from external number (verify DID routing)
|
||||||
|
4. Test voicemail (dial *97 or voicemail button)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Issues & Fixes
|
||||||
|
|
||||||
|
### Phone Shows "No Service" / Status 3 (Unregistered)
|
||||||
|
|
||||||
|
**Cause:** Password mismatch between YMCS and PacketDial
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
1. Get correct password: `ns.py devices {domain} {user}`
|
||||||
|
2. Update YMCS account:
|
||||||
|
```bash
|
||||||
|
ymcs.py raw PATCH /v2/dm/sipAccounts/{account-id} --body '{
|
||||||
|
"registerName": "100@domain.91912.service",
|
||||||
|
"username": "100@domain.91912.service",
|
||||||
|
"password": "{correct-password}",
|
||||||
|
"sipServer1": {"host": "pbx.packetdial.com", "port": 5060}
|
||||||
|
}' --confirm
|
||||||
|
```
|
||||||
|
3. Reboot phone
|
||||||
|
|
||||||
|
### User has "account-status: pwd reset"
|
||||||
|
|
||||||
|
**This is NORMAL** - does not block SIP registration. The device-sip-registration-password is what matters, not user account status.
|
||||||
|
|
||||||
|
### Phone Not Pulling Config from YMCS
|
||||||
|
|
||||||
|
1. Check `ymcs.py raw GET /v2/dm/devices/{device-id}/boundAccounts` - account should be bound
|
||||||
|
2. Reboot phone manually or via `ymcs.py reboot`
|
||||||
|
3. Check firewall - phone needs HTTPS access to `us-api.ymcs.yealink.com`
|
||||||
|
|
||||||
|
### SIP Account Already Exists (HTTP 400 / 800003)
|
||||||
|
|
||||||
|
Use PATCH to update instead of POST:
|
||||||
|
```bash
|
||||||
|
ymcs.py raw PATCH /v2/dm/sipAccounts/{account-id} --body '{...}' --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
### domain-type Stored as "no"
|
||||||
|
|
||||||
|
Known PacketDial behavior. Fix post-creation:
|
||||||
|
```bash
|
||||||
|
ns.py raw PUT domains/{domain} --body '{"domain-type":"Standard"}' --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Coverage Summary
|
||||||
|
|
||||||
|
| Step | API Method | Status | Notes |
|
||||||
|
|------|------------|--------|-------|
|
||||||
|
| Domain creation | `POST /domains` | ✓ Automated | Via `onboard-domain` wrapper |
|
||||||
|
| E911 address | `POST /domains/{domain}/addresses` | ✓ Automated | Included in `onboard-domain` |
|
||||||
|
| User creation | `POST /domains/{domain}/users` | ✓ Automated | Via `create-user` |
|
||||||
|
| SIP device creation | `POST /domains/{domain}/users/{user}/devices` | ✓ Automated | Via `create-device` |
|
||||||
|
| DID assignment | `POST /domains/{domain}/phonenumbers` | ✓ Automated | Via `create-did` |
|
||||||
|
| Add phones to YMCS | `POST /v2/dm/devices/batch` | ✓ Automated | Via `add-devices-by-mac` |
|
||||||
|
| Create SIP account (YMCS) | `POST /v2/dm/sipAccounts` | ✓ Automated | Via `add-sipaccount` |
|
||||||
|
| Bind account to phone | `POST /v2/dm/devices/{id}/bindAccounts` | ✓ Automated | Via `raw` (no wrapper yet) |
|
||||||
|
| Reboot phone | `POST /v2/dm/device/reboot` | ✓ Automated | Via `reboot` |
|
||||||
|
| Set email-send-from | `PUT /domains/{domain}` | ⚠ Manual | Requires mailbox to exist first |
|
||||||
|
| Fix domain-type | `PUT /domains/{domain}` | ⚠ Manual | Post-creation fix |
|
||||||
|
|
||||||
|
**Automation Level:** ~95% - only 2 fields require post-creation manual fixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference: russo.91912.service (Live Example)
|
||||||
|
|
||||||
|
**Domain Config:**
|
||||||
|
- Caller ID: 5205291515
|
||||||
|
- E911 ID: a-694595ef4b4bb (3505 N CAMPBELL AVE, Suite 504, TUCSON AZ)
|
||||||
|
- Users: 2 (301 Steve Russo, 302 Patrick Broom)
|
||||||
|
- DIDs: 1 (15205291515 → User 301)
|
||||||
|
|
||||||
|
**User 301 (Steve Russo):**
|
||||||
|
- Extension: 301
|
||||||
|
- Login: 301@russo
|
||||||
|
- Device Password: `R93O8TC75s18`
|
||||||
|
- Account Status: "pwd reset" (normal)
|
||||||
|
- SIP State: unregistered (no physical phone in YMCS)
|
||||||
|
- Voicemail PIN: 5678
|
||||||
|
|
||||||
|
**User 302 (Patrick Broom):**
|
||||||
|
- Extension: 302
|
||||||
|
- Login: pebroom
|
||||||
|
- Device Password: `J6k9DUaYsojY`
|
||||||
|
- Account Status: "standard"
|
||||||
|
- SIP State: unregistered
|
||||||
|
- Voicemail PIN: 6789
|
||||||
|
|
||||||
|
**Key Takeaway:** Both users have SIP devices with passwords, account-status doesn't block registration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Skill Commands Quick Reference
|
||||||
|
|
||||||
|
**PacketDial (`ns.py`):**
|
||||||
|
```bash
|
||||||
|
ns.py onboard-domain --body-file client.json --confirm
|
||||||
|
ns.py create-user {domain} --body '{...}' --confirm
|
||||||
|
ns.py create-device {domain} {user} --body '{...}' --confirm
|
||||||
|
ns.py devices {domain} {user} # Get SIP password
|
||||||
|
ns.py create-did {domain} --body '{...}' --confirm
|
||||||
|
ns.py user {domain} {user} # Check user status
|
||||||
|
```
|
||||||
|
|
||||||
|
**Yealink YMCS (`ymcs.py`):**
|
||||||
|
```bash
|
||||||
|
ymcs.py sites # List sites, get IDs
|
||||||
|
ymcs.py devices # List all phones
|
||||||
|
ymcs.py add-devices-by-mac --body '{...}' --confirm
|
||||||
|
ymcs.py add-sipaccount --body '{...}' --confirm
|
||||||
|
ymcs.py raw POST /v2/dm/devices/{id}/bindAccounts --body '[{...}]' --confirm
|
||||||
|
ymcs.py raw GET /v2/dm/devices/{id}/boundAccounts
|
||||||
|
ymcs.py reboot --body '{"deviceIds":["{id}"],"deviceType":1}' --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps: Full Automation Script
|
||||||
|
|
||||||
|
Create `provision-voip-client.sh` that:
|
||||||
|
1. Takes client intake JSON
|
||||||
|
2. Executes all 9 phases sequentially
|
||||||
|
3. Validates each step before proceeding
|
||||||
|
4. Generates final report with all credentials/IDs
|
||||||
|
5. Updates vault with credentials
|
||||||
|
6. Creates wiki entry for client
|
||||||
|
|
||||||
|
**Estimated Time Savings:** Manual GUI provisioning ~2-3 hours → Automated script ~15 minutes + client intake
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- **PacketDial Skill:** `.claude/skills/packetdial/SKILL.md`
|
||||||
|
- **YMCS Skill:** `.claude/skills/yealink-ymcs/SKILL.md`
|
||||||
|
- **Wiki:** `wiki/systems/packetdial.md`
|
||||||
|
- **YMCS SIP Schema:** `.claude/skills/yealink-ymcs/docs/SIPSERVER_SCHEMA.md`
|
||||||
|
- **NetSapiens API Docs:** https://docs.ns-api.com
|
||||||
|
- **NetSapiens v1→v2 Migration:** https://docs.ns-api.com/docs/v1-migration-to-v2
|
||||||
|
- **OIT/PacketDial Docs:** https://pbx.readme.io
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Sources:**
|
||||||
|
- [NetSapiens API v1→v2 Migration](https://docs.ns-api.com/docs/v1-migration-to-v2)
|
||||||
|
- [NetSapiens Docs Homepage](https://docs.ns-api.com/)
|
||||||
|
- [Create User Device API](https://pbx.readme.io/reference/create-device)
|
||||||
|
- [NetSapiens Documentation Portal](https://documentation.netsapiens.com/ns/)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# OIT/PacketDial Client Onboarding Checklist (reusable template)
|
||||||
|
|
||||||
|
Reusable runbook for onboarding a new client to VoIP on the **OIT/PacketDial (NetSapiens)**
|
||||||
|
platform with **Yealink YMCS** device management. Structured on the NetSapiens 17-step
|
||||||
|
white-label onboarding flow (voipdocs.io/onboarding-recommendations), annotated with the
|
||||||
|
ACG/OIT specifics and gotchas learned in the field.
|
||||||
|
|
||||||
|
**How to use:** copy this file to `clients/<slug>/voip/ONBOARDING-CHECKLIST.md`, fill the
|
||||||
|
placeholders, and work the boxes. Keep client-specific detail in the client copy; keep this
|
||||||
|
template generic.
|
||||||
|
|
||||||
|
Legend: `[x]` done · `[ ]` not started · `[~]` in progress · `[?]` verify
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key references (fill per client)
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|---|---|
|
||||||
|
| Client / slug | `<name>` / `<slug>` |
|
||||||
|
| PacketDial domain | `<domain>.<reseller>.service` |
|
||||||
|
| Reseller | `91912.service` (ACG) |
|
||||||
|
| Main number | `<E.164 / 10-digit>` |
|
||||||
|
| Timezone / dial policy | `America/Phoenix` / `US and Canada` |
|
||||||
|
| **RPS provisioning URL** | `http://ndp.ucaasnetwork.com/cfg` (OIT/PacketDial) |
|
||||||
|
| YMCS site / site ID | `<site name>` / `<site id>` |
|
||||||
|
| E911 address ID | `<a-...>` |
|
||||||
|
| Credentials | vault `msp-tools/oitvoip-provisioning.sops.yaml` (Prov_Admin) + `msp-tools/oitvoip.sops.yaml` (reseller API key) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client intake / prep (steps 1-8)
|
||||||
|
|
||||||
|
- [ ] **1. Welcome correspondence** sent to the client.
|
||||||
|
- [ ] **2. Payment authorization (CC/ACH)** on file — NetSapiens flags this as the prerequisite for every step after. Do not go live without it.
|
||||||
|
- [ ] **3. Service start timing** decided — port-dependent (wait for numbers) or immediate with temporary numbers.
|
||||||
|
- [ ] **4. Recent billing docs** collected from the current carrier (bill copy / CSR) for the port.
|
||||||
|
- [ ] **5. Numbers-to-port list** — every DID + its intended purpose (main line, fax, direct dials).
|
||||||
|
- [ ] **6. User roster** — names, contact info, desired extensions, voicemail-to-email preference. Match to the client's M365 tenant where possible (emails drive vmail delivery).
|
||||||
|
- [ ] **7. Equipment source + MAC addresses** — who supplies phones (ACG vs client), models, tracking, and **MACs** (Yealink RPS + YMCS bind key on the MAC).
|
||||||
|
- [ ] **8. Client orientation call** scheduled/held.
|
||||||
|
|
||||||
|
## Platform build-out (steps 9-17)
|
||||||
|
|
||||||
|
- [ ] **9. Billing account / domain** created under the ACG reseller (`<domain>.<reseller>.service`), timezone + dial policy set.
|
||||||
|
- [ ] **10. Port application** submitted (after steps 4-5); track FOC date.
|
||||||
|
- [ ] **11. Domain infrastructure** configured — domain settings, dial plan, main caller ID.
|
||||||
|
- [ ] **12. DID numbers** created and ready to route.
|
||||||
|
- [ ] **13. User accounts** registered. For >5 users use the **CSV bulk import** (PacketDial: Domains → `<domain>` → Users → Import). Create **users only, no devices** — assign devices later as phones are physically distributed.
|
||||||
|
- [ ] **14. Auto-attendants + call queues** built (reception queue, main AA; add dept queues as needed).
|
||||||
|
- [ ] **15. Number routing** configured — point DIDs at the AA/queue; add business-hours vs after-hours **timeframes** for time-based routing.
|
||||||
|
- [ ] **16. E911 / emergency services** activated — register the physical service address, capture the E911 address ID, confirm each device/extension maps to a valid address.
|
||||||
|
- [ ] **17. Hardware deployed** — provision + register each phone (device-assignment workflow below).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-phone device-assignment workflow (step 17 detail)
|
||||||
|
|
||||||
|
Users are created without SIP devices; bind a device to a user when the physical phone is handed out.
|
||||||
|
|
||||||
|
1. **User provides the last 4 of the phone's MAC.**
|
||||||
|
2. **Create the SIP device in PacketDial** (auto-generates the SIP registration password):
|
||||||
|
```bash
|
||||||
|
bash .claude/scripts/py.sh ns.py create-device <domain> <ext> \
|
||||||
|
--body '{"device": "sip:<ext>a@<domain>"}' --confirm
|
||||||
|
```
|
||||||
|
3. **Read the SIP password** back from the device (`device-sip-registration-password`).
|
||||||
|
4. **Create the YMCS SIP account** with that password, pointing `sipServer1.host` at `pbx.packetdial.com:5060` and `siteId` at the client's YMCS site.
|
||||||
|
5. **Find the physical phone** in YMCS by the last-4 MAC (`ymcs.py devices | grep <last4>`).
|
||||||
|
6. **Bind** the account to the phone (`/v2/dm/devices/{id}/bindAccounts`) and **reboot** to apply.
|
||||||
|
|
||||||
|
Detailed commands: copy the client's `DEVICE-ASSIGNMENT-COMMANDS.md` (see the VWP example).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gotchas learned in the field
|
||||||
|
|
||||||
|
- **TWO whitelabel accounts — use the right RPS URL.** ACG has both Whitelabel Communication (WLC, `ftp://p.packetdials.net`) and OIT/PacketDial (`http://ndp.ucaasnetwork.com/cfg`). A phone provisioned against the wrong RPS URL stays **unregistered** with otherwise-correct SIP creds. OIT clients MUST use `http://ndp.ucaasnetwork.com/cfg`. (Root cause of the VWP registration failure, 2026-07-09.)
|
||||||
|
- **YMCS `sipServer1` schema is strict.** The SIP-account create expects `sipServer1` as an object (`{"host": ..., "port": 5060}`), not a string; a bad shape returns HTTP 412 "Parameter error". Confirm against `.claude/skills/yealink-ymcs/docs/SIPSERVER_SCHEMA.md`.
|
||||||
|
- **Match extensions to M365 by email** so voicemail-to-email lands correctly; park unmatched/shared extensions (kitchen, conference, warehouse) and confirm names with the client before creating accounts.
|
||||||
|
- **Bulk import = users only.** Import creates users without devices by design; devices are bound per-phone at hand-out. This keeps unassigned phones from registering to the wrong person.
|
||||||
|
|
||||||
|
## Tooling & support
|
||||||
|
|
||||||
|
- **Skills:** `.claude/skills/packetdial/` (PacketDial/NetSapiens API — `ns.py`), `.claude/skills/yealink-ymcs/` (YMCS — `ymcs.py`)
|
||||||
|
- **Credentials:** vault `msp-tools/oitvoip*.sops.yaml`
|
||||||
|
- **PacketDial UI:** https://pbx.packetdial.com/ · **API:** `https://pbx.packetdial.com/ns-api/v2` (fallback `https://api.ucaasnetwork.com/ns-api/v2`) · **docs:** https://docs.ns-api.com
|
||||||
|
- **YMCS UI:** https://us.ymcs.yealink.com/
|
||||||
|
- **OIT support:** support@oitvoip.com · **Onboarding best-practices:** https://voipdocs.io/onboarding-recommendations
|
||||||
|
- **Worked example:** `clients/valleywide/voip/` (Valley Wide Plastering)
|
||||||
50
.claude/skills/packetdial/scripts/provision-vwp-devices.sh
Normal file
50
.claude/skills/packetdial/scripts/provision-vwp-devices.sh
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Create SIP devices for VWP extensions 101-118
|
||||||
|
# This auto-generates the device-sip-registration-password for each user
|
||||||
|
|
||||||
|
DOMAIN="vwp.91912.service"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PY_WRAPPER="bash $SCRIPT_DIR/../../../.claude/scripts/py.sh"
|
||||||
|
|
||||||
|
EXTENSIONS=(101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118)
|
||||||
|
|
||||||
|
echo "[INFO] Creating SIP devices for 18 VWP extensions..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
SUCCESS=0
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
for ext in "${EXTENSIONS[@]}"; do
|
||||||
|
echo "[INFO] Creating device for extension $ext"
|
||||||
|
|
||||||
|
# Create device (device name = user extension)
|
||||||
|
BODY="{\"device\":\"$ext\",\"user\":\"$ext\"}"
|
||||||
|
RESULT=$($PY_WRAPPER "$SCRIPT_DIR/ns.py" create-device "$DOMAIN" "$ext" --body "$BODY" --confirm 2>&1)
|
||||||
|
|
||||||
|
if echo "$RESULT" | grep -q "HTTP 400.*already exists"; then
|
||||||
|
echo " [SKIP] Device $ext already exists"
|
||||||
|
((SUCCESS++))
|
||||||
|
elif echo "$RESULT" | grep -qE "HTTP [45]"; then
|
||||||
|
echo " [ERROR] Failed to create device $ext: $RESULT"
|
||||||
|
((FAILED++))
|
||||||
|
else
|
||||||
|
echo " [OK] Created device $ext"
|
||||||
|
((SUCCESS++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[SUMMARY] Devices: $SUCCESS created/existing, $FAILED failed"
|
||||||
|
echo ""
|
||||||
|
echo "[INFO] Fetching all device passwords..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Get all passwords
|
||||||
|
for ext in "${EXTENSIONS[@]}"; do
|
||||||
|
PASSWORD=$($PY_WRAPPER "$SCRIPT_DIR/ns.py" devices "$DOMAIN" "$ext" 2>/dev/null | grep -o '"device-sip-registration-password": "[^"]*"' | cut -d'"' -f4)
|
||||||
|
if [ -n "$PASSWORD" ]; then
|
||||||
|
echo "Extension $ext: $PASSWORD"
|
||||||
|
else
|
||||||
|
echo "Extension $ext: [ERROR - Could not retrieve password]"
|
||||||
|
fi
|
||||||
|
done
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Provision 11 matched VWP extensions with correct M365 emails
|
||||||
|
# Domain: vwp.91912.service
|
||||||
|
# E911 Address: a-6a395c03d4cfe
|
||||||
|
|
||||||
|
DOMAIN="vwp.91912.service"
|
||||||
|
E911="a-6a395c03d4cfe"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PY_WRAPPER="bash $SCRIPT_DIR/../../../.claude/scripts/py.sh"
|
||||||
|
|
||||||
|
# Matched users: extension|first_name|last_name|email
|
||||||
|
USERS=(
|
||||||
|
"102|Jesse|Guerrero|jesse@valleywideplastering.com"
|
||||||
|
"103|Rose|Guerrero|rose@valleywideplastering.com"
|
||||||
|
"104|Shelly|Dooley|shelly@valleywideplastering.com"
|
||||||
|
"105|JR|Guerrero|j-r@valleywideplastering.com"
|
||||||
|
"107|Payroll|Department|payroll@valleywideplastering.com"
|
||||||
|
"109|Kayla|Guerrero|kayla@valleywideplastering.com"
|
||||||
|
"110|Ron|Winger|ron@valleywideplastering.com"
|
||||||
|
"113|Chris|Guerrero|chris@valleywideplastering.com"
|
||||||
|
"114|Ty|Fetters|Ty@CASARICA.NET"
|
||||||
|
"115|Toni|Billing|billing@valleywideplastering.onmicrosoft.com"
|
||||||
|
"116|Bart|Graffin|estimating@valleywideplastering.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "[INFO] Creating 11 matched VWP users in PacketDial..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
SUCCESS=0
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
for user_data in "${USERS[@]}"; do
|
||||||
|
IFS='|' read -r ext first last email <<< "$user_data"
|
||||||
|
|
||||||
|
echo "[INFO] Creating extension $ext: $first $last ($email)"
|
||||||
|
|
||||||
|
# Build JSON body (name-last-name is required)
|
||||||
|
BODY="{\"user\":\"$ext\",\"name-first-name\":\"$first\",\"name-last-name\":\"$last\",\"email\":\"$email\",\"emergency-address-id\":\"$E911\"}"
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
RESULT=$($PY_WRAPPER "$SCRIPT_DIR/ns.py" create-user "$DOMAIN" --body "$BODY" --confirm 2>&1)
|
||||||
|
|
||||||
|
if echo "$RESULT" | grep -q "HTTP 400.*already exists"; then
|
||||||
|
echo " [SKIP] Extension $ext already exists"
|
||||||
|
((SUCCESS++))
|
||||||
|
elif echo "$RESULT" | grep -qE "HTTP [45]"; then
|
||||||
|
echo " [ERROR] Failed to create extension $ext: $RESULT"
|
||||||
|
((FAILED++))
|
||||||
|
else
|
||||||
|
echo " [OK] Created extension $ext"
|
||||||
|
((SUCCESS++))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Small delay to avoid rate limiting
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[SUMMARY] Users: $SUCCESS created/existing, $FAILED failed"
|
||||||
|
echo "[INFO] Waiting 30 seconds for async processing..."
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[INFO] Verifying user creation..."
|
||||||
|
ACTUAL=$($PY_WRAPPER "$SCRIPT_DIR/ns.py" users "$DOMAIN" 2>/dev/null | grep -c '"user"')
|
||||||
|
echo "[INFO] PacketDial reports $ACTUAL users total"
|
||||||
64
.claude/skills/packetdial/scripts/provision-vwp-users.sh
Normal file
64
.claude/skills/packetdial/scripts/provision-vwp-users.sh
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Provision VWP extensions 101-118
|
||||||
|
# Domain: vwp.91912.service
|
||||||
|
# E911 Address: a-6a395c03d4cfe
|
||||||
|
|
||||||
|
DOMAIN="vwp.91912.service"
|
||||||
|
E911="a-6a395c03d4cfe"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PY_WRAPPER="bash $SCRIPT_DIR/../../../.claude/scripts/py.sh"
|
||||||
|
|
||||||
|
# User list: extension|first_name|last_name|email
|
||||||
|
USERS=(
|
||||||
|
"101|Natalya||natalya@vwp.com"
|
||||||
|
"102|Jesse||jesse@vwp.com"
|
||||||
|
"103|Rose||rose@vwp.com"
|
||||||
|
"104|Shelly||shelly@vwp.com"
|
||||||
|
"105|J.R.||jr@vwp.com"
|
||||||
|
"106|Tammy||tammy@vwp.com"
|
||||||
|
"107|Payroll|Department|payroll@vwp.com"
|
||||||
|
"108|Shannon||shannon@vwp.com"
|
||||||
|
"109|Kayla||kayla@vwp.com"
|
||||||
|
"110|Ron||ron@vwp.com"
|
||||||
|
"111|Kitchen|Phone|kitchen@vwp.com"
|
||||||
|
"112|Conference|Room|conferenceroom@vwp.com"
|
||||||
|
"113|Chris||chris@vwp.com"
|
||||||
|
"114|TY||ty@vwp.com"
|
||||||
|
"115|Toni||toni@vwp.com"
|
||||||
|
"116|Bart||bart@vwp.com"
|
||||||
|
"117|Jesse|III|jesse3@vwp.com"
|
||||||
|
"118|Warehouse|Phone|warehouse@vwp.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "[INFO] Creating 18 VWP users in PacketDial..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
SUCCESS=0
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
for user_data in "${USERS[@]}"; do
|
||||||
|
IFS='|' read -r ext first last email <<< "$user_data"
|
||||||
|
|
||||||
|
echo "[INFO] Creating extension $ext: $first $last"
|
||||||
|
|
||||||
|
# Build JSON body (name-last-name is required even if empty)
|
||||||
|
LAST_NAME="${last:-.}"
|
||||||
|
BODY="{\"user\":\"$ext\",\"name-first-name\":\"$first\",\"name-last-name\":\"$LAST_NAME\",\"email\":\"$email\",\"emergency-address-id\":\"$E911\"}"
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
RESULT=$($PY_WRAPPER "$SCRIPT_DIR/ns.py" create-user "$DOMAIN" --body "$BODY" --confirm 2>&1)
|
||||||
|
|
||||||
|
if echo "$RESULT" | grep -q "HTTP 400.*already exists"; then
|
||||||
|
echo " [SKIP] Extension $ext already exists"
|
||||||
|
((SUCCESS++))
|
||||||
|
elif echo "$RESULT" | grep -qE "HTTP [45]"; then
|
||||||
|
echo " [ERROR] Failed to create extension $ext: $RESULT"
|
||||||
|
((FAILED++))
|
||||||
|
else
|
||||||
|
echo " [OK] Created extension $ext"
|
||||||
|
((SUCCESS++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[SUMMARY] Users: $SUCCESS created/existing, $FAILED failed"
|
||||||
1
.claude/skills/remediation-tool/.gitignore
vendored
Normal file
1
.claude/skills/remediation-tool/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.bb-tenant.json
|
||||||
@@ -14,7 +14,7 @@ Five multi-tenant apps cover distinct privilege tiers. Use only what the task re
|
|||||||
| Tier | App display name | App ID | Vault file | Scope |
|
| Tier | App display name | App ID | Vault file | Scope |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `investigator` | ComputerGuru Security Investigator | `bfbc12a4-f0dd-4e12-b06d-997e7271e10c` | `computerguru-security-investigator.sops.yaml` | Graph read-only |
|
| `investigator` | ComputerGuru Security Investigator | `bfbc12a4-f0dd-4e12-b06d-997e7271e10c` | `computerguru-security-investigator.sops.yaml` | Graph read-only |
|
||||||
| `investigator-exo` | ComputerGuru Security Investigator | `bfbc12a4-f0dd-4e12-b06d-997e7271e10c` | `computerguru-security-investigator.sops.yaml` | Exchange Online read |
|
| `investigator-exo` | ComputerGuru Security Investigator | `bfbc12a4-f0dd-4e12-b06d-997e7271e10c` | `computerguru-security-investigator.sops.yaml` | Exchange Online read — **BROKEN: app lacks `Exchange.ManageAsApp`, adminapi `InvokeCommand` 401s. Use `exchange-op` for ALL Exchange REST (read + write).** |
|
||||||
| `exchange-op` | ComputerGuru Exchange Operator | `b43e7342-5b4b-492f-890f-bb5a4f7f40e9` | `computerguru-exchange-operator.sops.yaml` | Exchange Online write |
|
| `exchange-op` | ComputerGuru Exchange Operator | `b43e7342-5b4b-492f-890f-bb5a4f7f40e9` | `computerguru-exchange-operator.sops.yaml` | Exchange Online write |
|
||||||
| `user-manager` | ComputerGuru User Manager | `64fac46b-8b44-41ad-93ee-7da03927576c` | `computerguru-user-manager.sops.yaml` | Graph user/group write |
|
| `user-manager` | ComputerGuru User Manager | `64fac46b-8b44-41ad-93ee-7da03927576c` | `computerguru-user-manager.sops.yaml` | Graph user/group write |
|
||||||
| `tenant-admin` | ComputerGuru Tenant Admin | `709e6eed-0711-4875-9c44-2d3518c47063` | `computerguru-tenant-admin.sops.yaml` | Graph high-privilege |
|
| `tenant-admin` | ComputerGuru Tenant Admin | `709e6eed-0711-4875-9c44-2d3518c47063` | `computerguru-tenant-admin.sops.yaml` | Graph high-privilege |
|
||||||
@@ -23,7 +23,7 @@ Five multi-tenant apps cover distinct privilege tiers. Use only what the task re
|
|||||||
|
|
||||||
**The suite has broad, working access across ALL of M365 — Graph, Exchange Online, Defender, AND SharePoint Online.** Before concluding "the tool can't do X / access denied," verify against the live permission map in `references/app-permissions-and-sharepoint.md` (decode the token `roles` claim). An `accessDenied` usually means wrong tier or wrong endpoint for a scope we DO hold — not a real gap. Two recurring traps: (1) **SharePoint app-only requires a certificate** — a `client_secret` token is rejected everywhere in SharePoint with `"Unsupported app only token"` (get-token.sh forces cert for the `sharepoint*` tiers); (2) Graph `GET /admin/sharepoint/settings` needs a scope no app holds — read/write SharePoint tenant settings via the **CSOM/REST admin API** (`sharepoint-admin` tier) instead. Full map, gotchas, and CSOM examples: `references/app-permissions-and-sharepoint.md`.
|
**The suite has broad, working access across ALL of M365 — Graph, Exchange Online, Defender, AND SharePoint Online.** Before concluding "the tool can't do X / access denied," verify against the live permission map in `references/app-permissions-and-sharepoint.md` (decode the token `roles` claim). An `accessDenied` usually means wrong tier or wrong endpoint for a scope we DO hold — not a real gap. Two recurring traps: (1) **SharePoint app-only requires a certificate** — a `client_secret` token is rejected everywhere in SharePoint with `"Unsupported app only token"` (get-token.sh forces cert for the `sharepoint*` tiers); (2) Graph `GET /admin/sharepoint/settings` needs a scope no app holds — read/write SharePoint tenant settings via the **CSOM/REST admin API** (`sharepoint-admin` tier) instead. Full map, gotchas, and CSOM examples: `references/app-permissions-and-sharepoint.md`.
|
||||||
|
|
||||||
**Default for breach checks:** use `investigator` (Graph) + `investigator-exo` (Exchange read). Escalate to write tiers only when remediating.
|
**Default for breach checks:** use `investigator` (Graph) + **`exchange-op`** for ALL Exchange REST (read AND write). NOTE: `investigator-exo` 401s on the Exchange adminapi — the Security Investigator app holds the Exchange Admin *role* but NOT the `Exchange.ManageAsApp` *app-permission* that `InvokeCommand` requires — so even read-only `Get-*` cmdlets go through `exchange-op`. Escalate nothing extra; `exchange-op` is the read path too. Full detail: `references/gotchas.md`.
|
||||||
|
|
||||||
## Auto-Invocation Behavior
|
## Auto-Invocation Behavior
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ When triggered automatically (vs. via `/remediation-tool`), follow the same work
|
|||||||
|
|
||||||
- The SOPS vault is accessible via `.claude/identity.json` `vault_path` field. The scripts auto-resolve the vault location from identity.json — no hardcoded paths.
|
- The SOPS vault is accessible via `.claude/identity.json` `vault_path` field. The scripts auto-resolve the vault location from identity.json — no hardcoded paths.
|
||||||
- `jq`, `curl`, `bash` are available.
|
- `jq`, `curl`, `bash` are available.
|
||||||
- For Exchange REST checks: confirm the target tenant has **Exchange Administrator** role assigned to the **Security Investigator** SP (for reads) or **Exchange Operator** SP (for writes). If any Exchange REST call returns 403, emit the tenant-scoped Entra Roles link from `references/gotchas.md`.
|
- **Exchange REST (adminapi `InvokeCommand`) — read AND write — ALWAYS use the `exchange-op` tier.** The Exchange Operator app (`b43e7342-5b4b-492f-890f-bb5a4f7f40e9`) is the ONLY suite app that holds `Exchange.ManageAsApp` (required for adminapi) PLUS the Exchange Administrator directory role. `investigator-exo` returns **401** on every Exchange cmdlet — the Security Investigator app has the Exchange Admin *role* but NOT the `Exchange.ManageAsApp` *permission* (verified live on ACG 2026-07-16: Investigator SP `Exchange.ManageAsApp`=NO, Operator SP=YES). Diagnosis rule: **401 = wrong app (missing `Exchange.ManageAsApp`) → switch to `exchange-op`; 403 = the `exchange-op` SP is missing the Exchange Administrator directory role on that tenant** → emit the Entra Roles link from `references/gotchas.md`. Do NOT "assign Exchange Admin to the Security Investigator SP" — it does not fix the 401 (the app-permission, not the role, is the blocker).
|
||||||
- For Identity Protection checks: `IdentityRiskyUser.Read.All` is in the Security Investigator manifest AND the tenant has consented to that app. If 403, emit the per-app consent URL from `references/gotchas.md`.
|
- For Identity Protection checks: `IdentityRiskyUser.Read.All` is in the Security Investigator manifest AND the tenant has consented to that app. If 403, emit the per-app consent URL from `references/gotchas.md`.
|
||||||
- For Defender checks: confirm tenant has Microsoft Defender for Endpoint (MDE) license before using `defender` tier — it returns AADSTS650052 otherwise.
|
- For Defender checks: confirm tenant has Microsoft Defender for Endpoint (MDE) license before using `defender` tier — it returns AADSTS650052 otherwise.
|
||||||
|
|
||||||
|
|||||||
@@ -38,13 +38,32 @@ Graph API permissions alone are not enough. Most privileged operations require d
|
|||||||
|
|
||||||
| Operation | App tier | Required directory role on that SP |
|
| Operation | App tier | Required directory role on that SP |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Exchange REST read (Get-InboxRule, Get-Mailbox) | `investigator-exo` | Exchange Administrator |
|
| Exchange REST read (Get-InboxRule, Get-Mailbox, Get-MailboxPermission, Get-RecipientPermission) | **`exchange-op`** (NOT `investigator-exo` — see callout) | Exchange Administrator |
|
||||||
| Exchange REST write (Set-Mailbox, Remove-InboxRule) | `exchange-op` | Exchange Administrator |
|
| Exchange REST write (Set-Mailbox, Remove-InboxRule) | `exchange-op` | Exchange Administrator |
|
||||||
| Password reset, user property updates | `user-manager` | User Administrator |
|
| Password reset, user property updates | `user-manager` | User Administrator |
|
||||||
| MFA method reset | `user-manager` | Authentication Administrator |
|
| MFA method reset | `user-manager` | Authentication Administrator |
|
||||||
| Conditional Access reads/writes | `tenant-admin` | Conditional Access Administrator OR Security Administrator |
|
| Conditional Access reads/writes | `tenant-admin` | Conditional Access Administrator OR Security Administrator |
|
||||||
| Teams policies | `tenant-admin` | Teams Administrator |
|
| Teams policies | `tenant-admin` | Teams Administrator |
|
||||||
|
|
||||||
|
> **[AUTHORITATIVE — stop re-deriving this] Exchange REST goes through `exchange-op`, NEVER `investigator-exo`.**
|
||||||
|
> The Exchange adminapi (`https://outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand`) requires the
|
||||||
|
> calling app to hold the **`Exchange.ManageAsApp`** application permission (Office 365 Exchange Online app role
|
||||||
|
> `dc50a0fb-09a3-484d-be87-e023b12c6440`) **in addition to** the Exchange Administrator directory role. Only the
|
||||||
|
> **Exchange Operator** app (`exchange-op`, `b43e7342-5b4b-492f-890f-bb5a4f7f40e9`) has BOTH. The **Security
|
||||||
|
> Investigator** app (`investigator`/`investigator-exo`, `bfbc12a4-...`) has the Exchange Admin *role* but NOT
|
||||||
|
> `Exchange.ManageAsApp`, so **every** `investigator-exo` adminapi call returns **401** — including read-only
|
||||||
|
> `Get-*` cmdlets. Verified live on ACG (`azcomputerguru.com`) 2026-07-16: Investigator SP `Exchange.ManageAsApp`=**NO**,
|
||||||
|
> Operator SP=**YES**. **So use `exchange-op` for ALL Exchange cmdlets, read and write.**
|
||||||
|
>
|
||||||
|
> **401 vs 403:** `401` on adminapi = wrong app (missing `Exchange.ManageAsApp`) → you are on `investigator-exo`,
|
||||||
|
> switch to `exchange-op`. `403` = the `exchange-op` SP is missing the **Exchange Administrator** directory role on
|
||||||
|
> that specific tenant → assign it (steps below). Assigning Exchange Admin to the *Security Investigator* SP does
|
||||||
|
> **not** help — the missing piece there is the app-permission, which is baked into the app registration, not a
|
||||||
|
> per-tenant role. Authoritative ACG SP→role map (verified 2026-07-16): `exchange-op` SP `83c225f1-b38d-4063-9fdd-642b6b09ae8b`
|
||||||
|
> = Exchange Administrator (+ `Exchange.ManageAsApp`); `investigator` SP `9d242c15-6cd3-46ec-96d3-bcafaaaca333` = Exchange
|
||||||
|
> Administrator only (no ManageAsApp); `user-manager` SP `04020a4e-...` = Authentication Administrator + User Administrator;
|
||||||
|
> `tenant-admin` SP `046a7f70-...` = Conditional Access Administrator.
|
||||||
|
|
||||||
### How to assign a role to an SP in a customer tenant
|
### How to assign a role to an SP in a customer tenant
|
||||||
|
|
||||||
1. Sign into the customer's Entra admin center as Global Admin:
|
1. Sign into the customer's Entra admin center as Global Admin:
|
||||||
|
|||||||
2
.claude/skills/remediation-tool/scripts/.gitignore
vendored
Normal file
2
.claude/skills/remediation-tool/scripts/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.breakglass-lastcheck
|
||||||
|
.breakglass-alert.log
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# breakglass-signin-alert.sh — alert #dev-alerts on ANY sign-in by the ACG M365
|
||||||
|
# break-glass account (sysadmin@azcomputerguru.onmicrosoft.com). A break-glass login
|
||||||
|
# should be rare and always investigated. Run on a schedule (hourly/daily).
|
||||||
|
#
|
||||||
|
# State: tracks the last-checked UTC timestamp in a sidecar so it only alerts on NEW
|
||||||
|
# sign-ins. First run seeds the timestamp (no alert for history) unless --backfill <ISO>.
|
||||||
|
# DRY_RUN=1 prints what it would post instead of posting.
|
||||||
|
#
|
||||||
|
# Usage: breakglass-signin-alert.sh [--since <ISO8601>]
|
||||||
|
set -u
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" # skill scripts -> repo root
|
||||||
|
TEN="ce61461e-81a0-4c84-bb4a-7b354a9a356d" # azcomputerguru.com
|
||||||
|
BG="8c363d17-02ed-45e1-97d0-49e5819e6ff6" # sysadmin@azcomputerguru.onmicrosoft.com
|
||||||
|
STATE="$SCRIPT_DIR/.breakglass-lastcheck"
|
||||||
|
|
||||||
|
NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
if [ "${1:-}" = "--since" ] && [ -n "${2:-}" ]; then SINCE="$2";
|
||||||
|
elif [ -f "$STATE" ]; then SINCE="$(cat "$STATE")";
|
||||||
|
else SINCE="$NOW"; echo "$NOW" > "$STATE"; echo "[seed] first run — seeded $NOW, no history alerted"; exit 0; fi
|
||||||
|
|
||||||
|
G=$("$SCRIPT_DIR/get-token.sh" "$TEN" investigator 2>/dev/null) || { echo "[err] token"; exit 1; }
|
||||||
|
RESP=$(curl -s "https://graph.microsoft.com/v1.0/auditLogs/signIns?\$filter=userId+eq+'$BG'+and+createdDateTime+ge+$SINCE&\$top=50" -H "Authorization: Bearer $G" | tr -d '\000-\037')
|
||||||
|
N=$(echo "$RESP" | jq -r '.value | length' 2>/dev/null)
|
||||||
|
[ -z "$N" ] && { echo "[err] query"; exit 1; }
|
||||||
|
|
||||||
|
if [ "$N" -gt 0 ]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -z "$line" ] && continue
|
||||||
|
MSG="[SECURITY] BREAK-GLASS SIGN-IN - sysadmin@azcomputerguru.onmicrosoft.com used: $line -- investigate if unplanned."
|
||||||
|
if [ "${DRY_RUN:-0}" = "1" ]; then echo "WOULD POST: $MSG";
|
||||||
|
else bash "$ROOT/.claude/scripts/post-bot-alert.sh" "$MSG" >/dev/null 2>&1; echo "[alert] posted: $line"; fi
|
||||||
|
done < <(echo "$RESP" | jq -r '.value[] | "\(.createdDateTime) result=\(.status.errorCode) app=\(.appDisplayName) ip=\(.ipAddress) loc=\(.location.countryOrRegion)/\(.location.city // "?")"')
|
||||||
|
else
|
||||||
|
echo "[ok] no break-glass sign-ins since $SINCE"
|
||||||
|
fi
|
||||||
|
echo "$NOW" > "$STATE"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
REM Wrapper for the ACG M365 break-glass sign-in alert. Invoked by the
|
||||||
|
REM "ACG-BreakGlass-SignIn-Alert" scheduled task. Uses a Git login shell so the
|
||||||
|
REM SOPS/age vault env loads, then runs the check from the repo root and logs output.
|
||||||
|
"C:\Program Files\Git\bin\bash.exe" -lc "cd /d/ClaudeTools && bash .claude/skills/remediation-tool/scripts/breakglass-signin-alert.sh >> .claude/skills/remediation-tool/scripts/.breakglass-alert.log 2>&1"
|
||||||
@@ -13,7 +13,10 @@ UPN="${2:?usage: user-breach-check.sh <tenant-id|domain> <upn>}"
|
|||||||
|
|
||||||
TENANT_ID=$("$SCRIPT_DIR/resolve-tenant.sh" "$TENANT_INPUT")
|
TENANT_ID=$("$SCRIPT_DIR/resolve-tenant.sh" "$TENANT_INPUT")
|
||||||
GT=$("$SCRIPT_DIR/get-token.sh" "$TENANT_ID" investigator)
|
GT=$("$SCRIPT_DIR/get-token.sh" "$TENANT_ID" investigator)
|
||||||
EXO=$("$SCRIPT_DIR/get-token.sh" "$TENANT_ID" investigator-exo) || EXO=""
|
# Exchange REST (adminapi InvokeCommand) MUST use exchange-op: it holds Exchange.ManageAsApp
|
||||||
|
# + Exchange Administrator. investigator-exo 401s (Security Investigator app lacks ManageAsApp)
|
||||||
|
# — see references/gotchas.md "Exchange REST goes through exchange-op". (fixed 2026-07-16)
|
||||||
|
EXO=$("$SCRIPT_DIR/get-token.sh" "$TENANT_ID" exchange-op) || EXO=""
|
||||||
|
|
||||||
USER_SLUG=$(echo "$UPN" | tr '@.' '__')
|
USER_SLUG=$(echo "$UPN" | tr '@.' '__')
|
||||||
OUT="/tmp/remediation-tool/$TENANT_ID/user-breach/$USER_SLUG"
|
OUT="/tmp/remediation-tool/$TENANT_ID/user-breach/$USER_SLUG"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user