--- name: pulse-overshell-b2-evidence description: Use when running PowerShell on a Pulse-managed Windows endpoint via Datto RMM Overshell, uploading large output to Backblaze B2, and retrieving the parsed evidence — covers both the stdout-only and B2-transport pipelines, the dispatch API, webhook receiver, presigned download, and where results land in rmm_executions. --- # Pulse Overshell → B2 → Retrieve Pulse has two PowerShell evidence pipelines, both terminating in the same `rmm_executions` table. Pick the right one for the payload size, dispatch via the API, then read the row back. ## Decision: which transport? | Output size | Transport (`transport` column) | How results come back | |---|---|---| | ≤ ~50 KB stdout | `overshell_stdout` (default) | Worker polls Datto job → `raw_stdout` + `parsed_evidence` | | Large (event logs, dumps) | `b2_upload` (LogLift) | Collector gzips → B2 PUT → webhook → Pulse downloads + slims | **There is no ad-hoc PowerShell input.** Only registered `RmmScript`s in `lib/services/rmm/scripts/index.ts` can run. To add one, create `lib/services/rmm/scripts/.ts` with a `parseOutput` that JSON-parses a `ConvertTo-Json -Compress` tail, then add to `_all` in `index.ts` and bump `version`. Registry test enforces uniqueness + credential-shape ban. ## Dispatch (both transports) `POST /api/rmm/executions` (requires `rmm.execute` permission — admin/super-admin only): ```ts // site-anchored (runs on the client's WNP endpoint) { scriptId: 'get-ad-health', target: { type: 'site_anchor', companyId: 29861375 } } // asset-self (runs on a specific Datto device) { scriptId: 'loglift-eventlogs', target: { type: 'asset_self', deviceUid: 'f7a8…', assetType: 'configuration', assetId: '12345' } } ``` Returns `{ executionId, status: 'queued' | 'running', ... }`. Rate limit: **50 executions per user per 24h** — trips before `runQuickJob` and logs to `analyzer_cost_audit` with `decision='blocked'`. Hard timeout: 5 min. ## How LogLift uses B2 For `loglift-eventlogs` (the only B2-transport script today), Pulse passes the collector four `runQuickJob` variables — `RunId`, `ClientId` (Datto site uid), `WebhookUrl` (`${BETTER_AUTH_URL}/api/rmm/loglift/upload`), `WebhookSecret` (`OPENCLAW_API_KEY`). The collector script (registered inside Datto, not stored in Pulse) gzips its JSON and PUTs to: ``` {datto_site_uid}/{computer_name}/eventlogs_{YYYYMMDD_HHMMSS}.json.gz ``` …in bucket `B2_BUCKET` (default `wulf-audits`, region `us-west-002`). Object-key shape is enforced by `OBJECT_KEY_REGEX` in `lib/services/b2/client.ts` — anything else is rejected at the webhook. Then it POSTs to `/api/rmm/loglift/upload` with `x-openclaw-key: $WebhookSecret` and the metadata body (`runId`, `clientId`, `computerName`, `objectKey`, `summary`, etc. — see `docs/loglift-eventlog-pipeline-spec.md` for the exact shape). Pulse calls `downloadToBuffer(objectKey)`, gunzips, runs `redact()`, slims to `parsed_evidence`, and flips the row to `complete`. ## Retrieving results ### From an executionId you already have ```ts const r = await fetch(`/api/rmm/executions/${id}`).then(r => r.json()); // r.execution.status, .raw_stdout (overshell_stdout), .parsed_evidence (both), .evidence_object_key (b2_upload) ``` ### From SQL ```sql -- Most-recent successful run per (company, script): SELECT DISTINCT ON (target_company_id, script_id) id, target_company_id, script_id, target_hostname, transport, evidence_object_key, completed_at, jsonb_pretty(parsed_evidence) AS parsed FROM rmm_executions WHERE status = 'complete' AND completed_at >= NOW() - INTERVAL '14 days' ORDER BY target_company_id, script_id, completed_at DESC; ``` ### Re-fetching the original gzip from B2 The slim payload is in Postgres; the **full gzip stays in B2 forever** for forensic replay. From a Node script or API route: ```ts import { downloadToBuffer, presignDownload } from '@/lib/services/b2/client'; // In-process (capped at 25 MB by MAX_DOWNLOAD_BYTES): const buf = await downloadToBuffer(row.evidence_object_key); // Or hand a short-lived URL to a human / external tool: const url = presignDownload(row.evidence_object_key, 600); // 10-minute GET ``` `presignDownload` / `presignUpload` validate `OBJECT_KEY_REGEX` and sign with SigV4 against `B2_KEY_ID` / `B2_APP_KEY` from env. They throw `B2NotConfiguredError` if either is unset. ## Where things live - Dispatch: `lib/services/rmm/executor.ts` (`queueExecution`) - Worker (stdout polling + timeout sweep): `lib/services/rmm/worker.ts` - LogLift receiver: `lib/services/rmm/loglift-receiver.ts` → `app/api/rmm/loglift/upload/route.ts` - B2 SigV4 client: `lib/services/b2/client.ts` - Script registry: `lib/services/rmm/scripts/index.ts` - Settings (component_uids, variable name): `lib/services/rmm/settings.ts` → `/admin/rmm-overshell` for the UI + "Re-discover" buttons - Schema: `migrations/077_rmm_overshell.sql` + `078_loglift_uploads.sql` (adds `transport`, `evidence_object_key`, `run_id`) - Specs: `docs/rmm-overshell-evidence-spec.md`, `docs/loglift-eventlog-pipeline-spec.md` ## Gotchas - **The worker is a side-effect import.** Anything that needs it running must `import '@/lib/services/rmm/worker'` somewhere on the server. The POST `/api/rmm/executions` route already does this. Don't eager-import from hot paths or shared utilities. - **Worker ignores `transport='b2_upload'` rows for stdout polling.** The webhook is the completion event. If the webhook never arrives, the 5-minute timeout sweep marks the row `timeout`. - **Redaction runs twice.** `lib/services/analyzer/itglue-redact.ts:redact()` strips anything keyed `/password|secret|key|token|credential|api[_-]?key/i` before persistence and again before the LLM prompt sees it. Don't try to surface those fields — they're gone by the time you read the row. - **B2 download cap is 25 MB hard.** Refuses content-length over the cap and aborts mid-stream if it exceeds. Decompress cap is 100 MB ISIZE (zip-bomb defense). - **Object-key shape is strict.** Custom prefixes won't work — must be `{client_id_or_uuid}/{computer_name}/eventlogs_{timestamp}.json.gz`. If you need a new payload type, add a new key regex + a new transport rather than loosening the existing one. - **Auto-audit fires only on single-match Configurations.** Multi-match hostnames land the evidence but skip the audit (logged with `rmm.loglift.matched`, no `audit_triggered`). - **Webhook is not HMAC-signed** — `x-openclaw-key` is the auth boundary. If you expose the endpoint to a new collector, rotate `OPENCLAW_API_KEY`. - **`B2_*` env vars are required.** `isB2Configured()` checks only `B2_KEY_ID + B2_APP_KEY`; bucket/region/endpoint fall back to `wulf-audits` / `us-west-002` / `s3.us-west-002.backblazeb2.com`.