sync: auto-sync from HOWARD-HOME at 2026-06-25 21:48:38
Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-06-25 21:48:38
This commit is contained in:
120
.claude/skills/drive-map/SKILL.md
Normal file
120
.claude/skills/drive-map/SKILL.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: drive-map
|
||||
description: Reliably create/repoint Windows network drive maps and share shortcuts on a remote endpoint via GuruRMM. Bakes in the things that make this fight every time — runs in the user session (not SYSTEM, so maps actually appear), stores the per-host credential with cmdkey (the workgroup-PC-to-domain-share case), makes maps persistent, repoints/removes stale NAS shortcuts, and verifies access. Built for the Cascades NAS -> CS-SERVER migration but generic.
|
||||
---
|
||||
|
||||
# drive-map — remote drive maps & share shortcuts that actually stick
|
||||
|
||||
`net use` from RMM "doesn't work" for predictable reasons, and we kept re-solving
|
||||
them by hand. This skill encodes the fixes so a repoint is one command.
|
||||
|
||||
## The four things that make mapped drives fight you (and the fix this skill bakes in)
|
||||
|
||||
1. **SYSTEM context is invisible to the user.** RMM runs as SYSTEM by default. A
|
||||
drive mapped/shortcut written as SYSTEM lands in SYSTEM's profile, NOT the
|
||||
logged-on user's — so the user sees nothing and you think it "failed."
|
||||
**Fix:** every operation here runs `context: user_session`. It uses the user's
|
||||
token, `[Environment]::GetFolderPath('Desktop')`, and the user's credential
|
||||
vault. **Requires an active (logged-on) desktop session** — locked is usually
|
||||
OK, logged-off is not. If no session, the skill reports it instead of silently
|
||||
no-opping.
|
||||
2. **Workgroup PC -> domain share = Access Denied** unless a credential is stored.
|
||||
A machine that is not domain-joined (e.g. `DESKTOP-LPOPV30`, WORKGROUP, local
|
||||
login) has no Kerberos/NTLM identity the server trusts. It must present a
|
||||
**stored** credential for that exact server host.
|
||||
**Fix:** `cred`/`migrate` run `cmdkey /add:<HOST> /user:<DOMAIN\user> /pass:…`
|
||||
in the user session so the credential lands in the user's Credential Manager,
|
||||
keyed to the server host used in the UNC. (This is precisely how Karen reaches
|
||||
the NAS today: `cmdkey` target `CASCADESDS`, user `karen rossini`.)
|
||||
3. **Maps vanish on logoff.** `net use` without persistence is gone next login.
|
||||
**Fix:** persistent by default (`/persistent:yes`); cmdkey credential is
|
||||
persistent too, so the map reconnects without a prompt.
|
||||
4. **Stale NAS shortcuts/maps linger.** The old `\\cascadesds\…` shortcut and the
|
||||
live connection confuse users mid-migration.
|
||||
**Fix:** `--remove-old <UNC-prefix>` repoints or deletes desktop shortcuts that
|
||||
target the old prefix, drops the old drive letter, and removes the old cmdkey.
|
||||
|
||||
## Credential handling (read this)
|
||||
|
||||
- The password is read from the **SOPS vault** (`--cred-vault` + `--cred-field`),
|
||||
never passed as plaintext on the command line (CLAUDE.md rule).
|
||||
- **Caveat — it transits RMM.** cmdkey needs the plaintext on the endpoint, so the
|
||||
password appears in the dispatched command text, which RMM stores in command
|
||||
history (admin-only, internal). For a sensitive account, purge that history
|
||||
entry afterward or rotate. The skill never prints the password to stdout/errorlog.
|
||||
- For a domain account whose password we don't have, the correct move on a DC we
|
||||
control is to set it deliberately and vault it first — do that, then run `cred`.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
bash .claude/skills/drive-map/scripts/drive-map.sh <verb> --host <name> [opts]
|
||||
```
|
||||
|
||||
| Verb | Does |
|
||||
|------|------|
|
||||
| `verify` | Test-Path the target UNC/letter from the user session; report reachable or not. Read-only. |
|
||||
| `cred` | Store a per-host credential (`cmdkey /add`) so the user can reach a server. |
|
||||
| `map` | Map a drive letter to `\\HOST\Share` (persistent), optionally storing the cred first. |
|
||||
| `shortcut` | Drop a desktop `.lnk` to a UNC target (optionally pin to Quick Access). |
|
||||
| `unmap` | Remove a drive letter and/or desktop shortcuts pointing at `--remove-old`, and the old cmdkey. |
|
||||
| `migrate` | The all-in-one repoint: remove/repoint old shortcut, store new cred, map and/or shortcut the new target, verify. |
|
||||
|
||||
### Options
|
||||
|
||||
```
|
||||
--host NAME RMM hostname (required; resolved to agent id, must be Windows + online)
|
||||
--server '\\HOST\Share' target share for a drive map
|
||||
--target '\\HOST\Share\Sub' UNC for a shortcut / verify
|
||||
--letter X drive letter for map/unmap (no colon)
|
||||
--name NAME shortcut filename (default: leaf of --target)
|
||||
--cred-user 'DOMAIN\user' identity to store (e.g. CASCADES\karen.rossini)
|
||||
--cred-vault PATH sops path holding the password (e.g. clients/cascades-tucson/...sops.yaml)
|
||||
--cred-field FIELD field within the vault entry (default: credentials.password)
|
||||
--remove-old '\\oldhost\share' prefix of stale shortcuts/connections to strip (migrate/unmap)
|
||||
--quick-access also pin --target to Quick Access (best-effort; Shell verb)
|
||||
--no-persistent non-persistent map (default is persistent)
|
||||
--profile-hint NAME substring to disambiguate the user when several are logged on
|
||||
--dry-run print the generated PowerShell, do not dispatch
|
||||
--timeout N dispatch timeout seconds (default 90)
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Karen: workgroup PC, repoint NAS ALDocs shortcut to CS-SERVER, store her domain cred
|
||||
bash .claude/skills/drive-map/scripts/drive-map.sh migrate \
|
||||
--host DESKTOP-LPOPV30 \
|
||||
--target '\\CS-SERVER\Server\ALDocs' --name ALDocs --quick-access \
|
||||
--remove-old '\\cascadesds\Server' \
|
||||
--cred-user 'CASCADES\karen.rossini' \
|
||||
--cred-vault clients/cascades-tucson/karen-rossini.sops.yaml
|
||||
|
||||
# Just check a user can reach the new share
|
||||
bash .claude/skills/drive-map/scripts/drive-map.sh verify --host DESKTOP-LPOPV30 \
|
||||
--target '\\CS-SERVER\Server\ALDocs'
|
||||
|
||||
# Map a letter for a domain-joined user (no cred needed)
|
||||
bash .claude/skills/drive-map/scripts/drive-map.sh map --host SOME-PC \
|
||||
--server '\\CS-SERVER\SalesDept' --letter S
|
||||
```
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **Always `verify` first and last.** Confirm the target is reachable for that user
|
||||
before declaring success — a green `net use` line is not proof of access.
|
||||
- **One user at a time, with a session.** If no interactive user is logged on, stop
|
||||
and say so; do not "succeed" against SYSTEM's profile.
|
||||
- **Additive to permissions.** This skill never touches share/NTFS ACLs. If the user
|
||||
lacks rights on the target, fix that on the server side (group membership), not here.
|
||||
- **Confirm before mutating a live user's desktop** during business hours unless told
|
||||
to proceed — it is outward-facing (the user sees their desktop change).
|
||||
- On any genuine failure the script calls `log-skill-error.sh` (per CLAUDE.md).
|
||||
|
||||
## Implementation
|
||||
|
||||
- `scripts/drive-map.sh` — bash orchestrator: RMM auth (`rmm-auth.sh`), agent
|
||||
resolution, vault read for the password, generates the endpoint PowerShell,
|
||||
dispatches it `context: user_session`, polls, reports. All endpoint logic runs
|
||||
in the user session by design (see fix #1).
|
||||
- No endpoint install; the PowerShell is generated per call and dispatched via RMM.
|
||||
181
.claude/skills/drive-map/scripts/drive-map.sh
Normal file
181
.claude/skills/drive-map/scripts/drive-map.sh
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# drive-map.sh — create/repoint Windows network drive maps + share shortcuts on a
|
||||
# remote endpoint via GuruRMM, run in the user's session so they actually appear.
|
||||
# See SKILL.md for the why. Verbs: verify | cred | map | shortcut | unmap | migrate.
|
||||
set -uo pipefail
|
||||
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
|
||||
VAULT="$REPO/.claude/scripts/vault.sh"
|
||||
logerr(){ bash "$REPO/.claude/scripts/log-skill-error.sh" "drive-map" "$1" --context "${2:-}" >/dev/null 2>&1 || true; }
|
||||
die(){ echo "[ERROR] $1" >&2; exit "${2:-2}"; }
|
||||
|
||||
VERB="${1:-}"; shift || true
|
||||
[ -n "$VERB" ] || die "usage: drive-map.sh <verify|cred|map|shortcut|unmap|migrate> --host <name> [opts]"
|
||||
|
||||
HOST=""; SERVER=""; TARGET=""; LETTER=""; NAME=""; CRED_USER=""; CRED_VAULT=""
|
||||
CRED_FIELD="credentials.password"; REMOVE_OLD=""; QUICK=0; PERSIST="yes"
|
||||
PROFILE_HINT=""; DRYRUN=0; TIMEOUT=90
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--host) HOST="${2:?}"; shift 2;;
|
||||
--server) SERVER="${2:?}"; shift 2;;
|
||||
--target) TARGET="${2:?}"; shift 2;;
|
||||
--letter) LETTER="${2:?}"; LETTER="${LETTER%%:}"; shift 2;;
|
||||
--name) NAME="${2:?}"; shift 2;;
|
||||
--cred-user) CRED_USER="${2:?}"; shift 2;;
|
||||
--cred-vault) CRED_VAULT="${2:?}"; shift 2;;
|
||||
--cred-field) CRED_FIELD="${2:?}"; shift 2;;
|
||||
--remove-old) REMOVE_OLD="${2:?}"; shift 2;;
|
||||
--quick-access) QUICK=1; shift;;
|
||||
--no-persistent) PERSIST="no"; shift;;
|
||||
--profile-hint) PROFILE_HINT="${2:?}"; shift 2;;
|
||||
--dry-run) DRYRUN=1; shift;;
|
||||
--timeout) TIMEOUT="${2:?}"; shift 2;;
|
||||
*) die "unknown option: $1";;
|
||||
esac
|
||||
done
|
||||
[ -n "$HOST" ] || die "--host is required"
|
||||
|
||||
# Normalize a UNC: strip any/all leading backslashes, force exactly two. Shell-arg
|
||||
# layers (Git-bash on Windows) collapse a leading "\\" to "\", which breaks the host
|
||||
# parse; this makes the tool robust to 0/1/2 leading backslashes on input.
|
||||
norm_unc(){ [ -n "$1" ] || return 0; printf '\\\\%s' "$(printf '%s' "$1" | sed -E 's#^\\+##')"; }
|
||||
# host portion of a UNC: \\HOST\share\... -> HOST
|
||||
unc_host(){ printf '%s' "$1" | sed -E 's#^\\+##; s#\\.*##'; }
|
||||
# leaf of a UNC path: \\h\share\a\b -> b
|
||||
unc_leaf(){ printf '%s' "$1" | sed 's#\\*$##; s#.*\\##'; }
|
||||
SERVER="$(norm_unc "$SERVER")"; TARGET="$(norm_unc "$TARGET")"; REMOVE_OLD="$(norm_unc "$REMOVE_OLD")"
|
||||
|
||||
# ---- resolve the credential password (vault only; never plaintext on CLI) ----
|
||||
CRED_PASS=""
|
||||
if [ -n "$CRED_USER" ]; then
|
||||
[ -n "$CRED_VAULT" ] || die "--cred-user given but no --cred-vault to read the password from"
|
||||
CRED_PASS="$(bash "$VAULT" get-field "$CRED_VAULT" "$CRED_FIELD" 2>/dev/null)"
|
||||
[ -n "$CRED_PASS" ] || { logerr "vault read empty for $CRED_VAULT:$CRED_FIELD" "host=$HOST"; die "could not read password at vault:$CRED_VAULT field:$CRED_FIELD"; }
|
||||
fi
|
||||
|
||||
# ---- generate the endpoint PowerShell for the verb (all user_session) ----
|
||||
# PS variable values are injected via single-quoted PS literals; we escape any
|
||||
# embedded single quote by doubling it (PS rule). Password is masked in all output.
|
||||
psq(){ printf "'%s'" "$(printf '%s' "$1" | sed "s/'/''/g")"; }
|
||||
|
||||
build_ps(){
|
||||
local ps=""
|
||||
# append one PS line with a REAL newline. Never use printf %b here: UNC paths
|
||||
# contain backslashes (\c, \S) that %b would eat as escapes and truncate output.
|
||||
add(){ ps+="$1"$'\n'; }
|
||||
add "\$ErrorActionPreference='Continue'"
|
||||
add "\$who = (Get-CimInstance Win32_ComputerSystem).UserName; if(-not \$who){ Write-Host '[WARN] no interactive user resolved; user-session ops may no-op' }"
|
||||
add "Write-Host (\"[INFO] running as: \$env:USERNAME active: \$who\")"
|
||||
|
||||
# remove-old: strip stale shortcuts/letter/cred pointing at the old prefix
|
||||
if [ -n "$REMOVE_OLD" ] && { [ "$VERB" = "migrate" ] || [ "$VERB" = "unmap" ]; }; then
|
||||
local oldhost; oldhost="$(unc_host "$REMOVE_OLD")"
|
||||
add "\$old=$(psq "$REMOVE_OLD")"
|
||||
add "\$desk=[Environment]::GetFolderPath('Desktop')"
|
||||
add "\$wsh=New-Object -ComObject WScript.Shell"
|
||||
add "Get-ChildItem \$desk -Filter *.lnk -EA SilentlyContinue | ForEach-Object { \$t=\$wsh.CreateShortcut(\$_.FullName).TargetPath; if(\$t -like (\$old+'*')){ Remove-Item \$_.FullName -Force -EA SilentlyContinue; Write-Host (\"[OK] removed stale shortcut: \"+\$_.Name+\" -> \"+\$t) } }"
|
||||
add "cmdkey /delete:$oldhost *>\$null; Write-Host '[OK] cleared old cmdkey: $oldhost'"
|
||||
[ -n "$LETTER" ] && add "net use ${LETTER}: /delete /y *>\$null"
|
||||
fi
|
||||
|
||||
# cred: store per-host credential
|
||||
if [ -n "$CRED_USER" ] && { [ "$VERB" = "cred" ] || [ "$VERB" = "map" ] || [ "$VERB" = "migrate" ]; }; then
|
||||
local credhost=""
|
||||
[ -n "$SERVER" ] && credhost="$(unc_host "$SERVER")"
|
||||
[ -z "$credhost" ] && [ -n "$TARGET" ] && credhost="$(unc_host "$TARGET")"
|
||||
[ -n "$credhost" ] || die "cred needs --server or --target to derive the server host"
|
||||
add "cmdkey /add:$credhost /user:$(psq "$CRED_USER") /pass:$(psq "$CRED_PASS") | Out-Null"
|
||||
add "Write-Host '[OK] stored credential for $credhost as $CRED_USER'"
|
||||
fi
|
||||
|
||||
# map: persistent drive letter
|
||||
if [ "$VERB" = "map" ]; then
|
||||
[ -n "$SERVER" ] || die "map needs --server"
|
||||
[ -n "$LETTER" ] || die "map needs --letter"
|
||||
add "net use ${LETTER}: /delete /y *>\$null"
|
||||
add "\$r = net use ${LETTER}: $(psq "$SERVER") /persistent:$PERSIST 2>&1; Write-Host (\"\$r\")"
|
||||
add "if(Test-Path ${LETTER}:\\){ Write-Host '[OK] mapped ${LETTER}: -> $SERVER' } else { Write-Host '[FAIL] map ${LETTER}: failed'; exit 1 }"
|
||||
fi
|
||||
|
||||
# migrate may also map a letter if both server+letter given
|
||||
if [ "$VERB" = "migrate" ] && [ -n "$SERVER" ] && [ -n "$LETTER" ]; then
|
||||
add "net use ${LETTER}: /delete /y *>\$null"
|
||||
add "\$r = net use ${LETTER}: $(psq "$SERVER") /persistent:$PERSIST 2>&1; Write-Host (\"\$r\")"
|
||||
add "if(Test-Path ${LETTER}:\\){ Write-Host '[OK] mapped ${LETTER}: -> $SERVER' } else { Write-Host '[WARN] map ${LETTER}: did not verify' }"
|
||||
fi
|
||||
|
||||
# shortcut: desktop .lnk (+ optional quick access)
|
||||
if [ -n "$TARGET" ] && { [ "$VERB" = "shortcut" ] || [ "$VERB" = "migrate" ]; }; then
|
||||
local lname; lname="${NAME:-$(unc_leaf "$TARGET")}"
|
||||
add "\$desk2=[Environment]::GetFolderPath('Desktop')"
|
||||
add "\$wsh2=New-Object -ComObject WScript.Shell"
|
||||
add "\$lp=Join-Path \$desk2 ($(psq "$lname")+'.lnk')"
|
||||
add "\$lnk=\$wsh2.CreateShortcut(\$lp); \$lnk.TargetPath=$(psq "$TARGET"); \$lnk.Save()"
|
||||
add "Write-Host ('[OK] shortcut: '+\$lp+' -> $TARGET')"
|
||||
if [ "$QUICK" = "1" ]; then
|
||||
add "try { \$sa=New-Object -ComObject Shell.Application; \$sa.Namespace($(psq "$TARGET")).Self.InvokeVerb('pintohome'); Write-Host '[OK] pinned to Quick Access' } catch { Write-Host '[WARN] Quick Access pin failed (non-fatal)' }"
|
||||
fi
|
||||
fi
|
||||
|
||||
# verify: target reachable from this user
|
||||
if [ -n "$TARGET" ] && { [ "$VERB" = "verify" ] || [ "$VERB" = "migrate" ]; }; then
|
||||
add "if(Test-Path -LiteralPath $(psq "$TARGET")){ Write-Host '[OK] reachable: $TARGET' } else { Write-Host '[FAIL] NOT reachable: $TARGET'; exit 1 }"
|
||||
elif [ "$VERB" = "verify" ] && [ -n "$LETTER" ]; then
|
||||
add "if(Test-Path ${LETTER}:\\){ Write-Host '[OK] reachable: ${LETTER}:' } else { Write-Host '[FAIL] NOT reachable: ${LETTER}:'; exit 1 }"
|
||||
fi
|
||||
printf '%s' "$ps"
|
||||
}
|
||||
|
||||
PS_SCRIPT="$(build_ps)"
|
||||
[ -n "$PS_SCRIPT" ] || die "verb '$VERB' produced no actions — check options"
|
||||
|
||||
if [ "$DRYRUN" = "1" ]; then
|
||||
echo "[DRY-RUN] would dispatch to $HOST (user_session), timeout ${TIMEOUT}s:"
|
||||
echo "------------------------------------------------------------"
|
||||
# mask the password in the preview
|
||||
if [ -n "$CRED_PASS" ]; then printf '%s\n' "$PS_SCRIPT" | sed "s/$(printf '%s' "$CRED_PASS" | sed 's/[.[\*^$()+?{|]/\\&/g')/********/g"; else printf '%s\n' "$PS_SCRIPT"; fi
|
||||
echo "------------------------------------------------------------"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- RMM auth + agent resolution ----
|
||||
eval "$(bash "$REPO/.claude/scripts/rmm-auth.sh")" >/dev/null 2>&1 || { logerr "rmm auth failed"; die "RMM auth failed"; }
|
||||
AGENTS="$(curl -s "$RMM/api/agents" -H "Authorization: Bearer $TOKEN")"
|
||||
AGENT="$(echo "$AGENTS" | jq --arg h "$HOST" '[.[] | select(.hostname|ascii_downcase|contains($h|ascii_downcase))] | .[0]')"
|
||||
AID="$(echo "$AGENT" | jq -r '.id // empty')"
|
||||
AHOST="$(echo "$AGENT" | jq -r '.hostname // empty')"
|
||||
AOS="$(echo "$AGENT" | jq -r '.os_type // empty')"
|
||||
ACONN="$(echo "$AGENT" | jq -r '.is_connected // false')"
|
||||
[ -n "$AID" ] || { logerr "agent not found: $HOST"; die "no RMM agent matching '$HOST'"; }
|
||||
[ "$AOS" = "windows" ] || die "agent $AHOST is os_type=$AOS — drive-map is Windows-only"
|
||||
echo "[INFO] target: $AHOST (id=$AID, connected=$ACONN) verb=$VERB"
|
||||
[ "$ACONN" = "true" ] || echo "[WARN] agent offline — command will queue until it reconnects"
|
||||
|
||||
PAYLOAD="$(jq -n --arg ct powershell --arg cmd "$PS_SCRIPT" --argjson to "$TIMEOUT" \
|
||||
'{command_type:$ct, command:$cmd, timeout_seconds:$to, context:"user_session"}')"
|
||||
CMD_ID="$(curl -s -X POST "$RMM/api/agents/$AID/command" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$PAYLOAD" | jq -r '.command_id // empty')"
|
||||
[ -n "$CMD_ID" ] || { logerr "dispatch failed" "host=$AHOST verb=$VERB"; die "dispatch failed"; }
|
||||
echo "[INFO] dispatched cmd=$CMD_ID (user_session)"
|
||||
|
||||
S=""; R=""
|
||||
for i in $(seq 1 30); do
|
||||
R="$(curl -s "$RMM/api/commands/$CMD_ID" -H "Authorization: Bearer $TOKEN")"
|
||||
S="$(echo "$R" | jq -r '.status')"
|
||||
{ [ "$S" = "completed" ] || [ "$S" = "failed" ] || [ "$S" = "cancelled" ] || [ "$S" = "interrupted" ]; } && break
|
||||
sleep 4
|
||||
done
|
||||
echo "[INFO] status=$S exit=$(echo "$R" | jq -r '.exit_code // "—"')"
|
||||
echo "--- stdout ---"; echo "$R" | jq -r '.stdout // ""'
|
||||
STDERR="$(echo "$R" | jq -r '.stderr // ""')"
|
||||
[ -n "$STDERR" ] && { echo "--- stderr ---"; echo "$STDERR"; }
|
||||
|
||||
# alert per RMM convention (write op only)
|
||||
if [ "$VERB" != "verify" ]; then
|
||||
bash "$REPO/.claude/scripts/post-bot-alert.sh" "[RMM] drive-map $VERB on $AHOST -> ${TARGET:-$SERVER} (status=$S)" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
case "$S" in
|
||||
completed) exit 0;;
|
||||
failed) logerr "drive-map $VERB failed on $AHOST" "cmd=$CMD_ID"; exit 1;;
|
||||
*) logerr "drive-map $VERB ended status=$S on $AHOST" "cmd=$CMD_ID"; exit 1;;
|
||||
esac
|
||||
Reference in New Issue
Block a user