Compare commits
5
Commits
ad03d23150
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c25837d63e | ||
|
|
c4737f33a5 | ||
|
|
0b0196893e | ||
|
|
3e51472b4c | ||
|
|
eb833dc990 |
@@ -4,3 +4,9 @@ implementation/dist
|
|||||||
implementation/dist/
|
implementation/dist/
|
||||||
implementation/coverage
|
implementation/coverage
|
||||||
implementation/coverage/
|
implementation/coverage/
|
||||||
|
openclaw-plugin/node_modules
|
||||||
|
openclaw-plugin/node_modules/
|
||||||
|
openclaw-plugin/dist
|
||||||
|
openclaw-plugin/dist/
|
||||||
|
openclaw-plugin/coverage
|
||||||
|
openclaw-plugin/coverage/
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Findings — 2026-09-04
|
||||||
|
|
||||||
|
**Author:** Clawdia
|
||||||
|
**Reason:** Preserve a clear record of what was actually true at this point in the project, and what is still unknown / pending. The previous evening's chat log was incomplete and did not fully capture the state of the repo, the Gitea PRs, or the live OpenClaw Gateway. This file is the canonical "where we are right now" snapshot so we don't lose progress while the project is paused.
|
||||||
|
|
||||||
|
No code or runtime state was changed to write this file. No live MeshCentral calls were made.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Verified reality at this checkpoint
|
||||||
|
|
||||||
|
### 1.1 Local repository
|
||||||
|
|
||||||
|
- **Path:** `~/.gitea-staging/meshcentral-openclaw-bridge/`
|
||||||
|
- **Branch:** `main` — clean working tree, in sync with Gitea.
|
||||||
|
- **Remote:** `origin` → `https://gitea.ledrew.me/ledadmin/meshcentral-openclaw-bridge.git` (auth: `ledadmin`).
|
||||||
|
|
||||||
|
**Merged PRs on `main` (Gitea confirmed):**
|
||||||
|
|
||||||
|
| PR | Title | Merge commit |
|
||||||
|
|---|---|---|
|
||||||
|
| #1 | Integrate upstream-compatible read-only MeshCentral transport | `ad03d23` |
|
||||||
|
| #2 | Add narrow OpenClaw MeshCentral read-only plugin | `3e51472` |
|
||||||
|
| #3 | Fix OpenClaw plugin approved target matching | `c4737f3` |
|
||||||
|
|
||||||
|
PR #3 was the "case-insensitive device matching + remove redundant meshid filter" fix. It is in the merged source but is **not yet reflected in the running OpenClaw plugin artifact** — see §1.3.
|
||||||
|
|
||||||
|
**Local verification gates (re-run today, all green):**
|
||||||
|
|
||||||
|
| Gate | Result |
|
||||||
|
|---|---|
|
||||||
|
| `implementation/` — `npm test --runInBand` | 13 suites, 93 tests passed |
|
||||||
|
| `implementation/` — `npm run build` (tsc) | passed |
|
||||||
|
| `implementation/` — `npm run lint` (eslint + tsc --noEmit) | passed |
|
||||||
|
| `openclaw-plugin/` — `npm run build` (tsc) | passed |
|
||||||
|
| `openclaw-plugin/` — `npm test` (static safety tests) | 2/2 passed |
|
||||||
|
| `openclaw-plugin/` — `npm run plugin:validate` | `Plugin meshcentral-readonly is valid.` |
|
||||||
|
|
||||||
|
**Repository hygiene:** `openclaw-plugin/{dist,node_modules,coverage}/` are properly listed in `.gitignore` and are not tracked. `openclaw-plugin/package-lock.json` **is** tracked, which is the intended behavior for an installable package.
|
||||||
|
|
||||||
|
### 1.2 Bridge library (`implementation/`)
|
||||||
|
|
||||||
|
- Read-only upstream-compatible transport merged in PR #1.
|
||||||
|
- MeshCentral 1.2.5 on `mesh.ledrew.me` (LXC on PVE2, internal `10.10.10.177:80`).
|
||||||
|
- Live read-only probe against `Servers` / `PVE3` succeeded previously (per `RESUME-HANDOFF.md` and `PROJECT-STATUS-2026-08-25.md`); the standalone bridge path is verified.
|
||||||
|
- Allowed operations are still strictly: `serverversion` (probe-only), `meshes`, `nodes`, `deviceinfo`. Prohibited: commands, diagnostics, power ops, file transfer, administration, state changes, PVE1, PVE2.
|
||||||
|
|
||||||
|
### 1.3 OpenClaw Gateway (live, runtime state)
|
||||||
|
|
||||||
|
- **Service:** `openclaw-gateway.service` — user systemd, **active/running** (pid 1467816, OpenClaw 2026.7.1-2).
|
||||||
|
- **Plugin installed:** `meshcentral-readonly` v0.1.0, status `loaded`, format `openclaw`, install path `~/.openclaw/npm/projects/ledrew-openclaw-meshcentral-readonly-…/node_modules/@ledrew/openclaw-meshcentral-readonly/`.
|
||||||
|
- **Plugin config (current):**
|
||||||
|
- `url`: `https://mesh.ledrew.me`
|
||||||
|
- `username`: `sa_clawdia`
|
||||||
|
- `passwordEnv`: `__OPENCLAW_REDACTED__` (stored as env-var name only; password is in the protected runtime env file, not in `openclaw.json`)
|
||||||
|
- `groupName`: `Servers`
|
||||||
|
- `deviceName`: `PVE3`
|
||||||
|
- **Plugin allowlist:** `plugins.allow` contains `meshcentral-readonly`.
|
||||||
|
- **Tool policy:** `tools.alsoAllow` was set to `["meshcentral-readonly"]` during the prior session (the additive form that survived the existing coding profile).
|
||||||
|
- **Installed artifact vs. merged source:** the running `dist/index.js` is the **pre-PR#3 build** (installed at `2026-09-03T23:34:00Z` UTC). The PR #3 case-insensitive + meshid-filter fix is in the repo source but **has not been rebuilt and reinstalled** in the live Gateway. A rebuild + reinstall is pending — see §2.
|
||||||
|
|
||||||
|
### 1.4 Plugin package (`openclaw-plugin/`)
|
||||||
|
|
||||||
|
- **Manifest (`openclaw.plugin.json`):** declares exactly the four read-only tools — `meshcentral_health`, `meshcentral_list_groups`, `meshcentral_list_devices`, `meshcentral_device_status`.
|
||||||
|
- **Config schema:** requires `url`, `username`, `passwordEnv`, `groupName`, `deviceName`. `additionalProperties: false`. `activation.onStartup: true`.
|
||||||
|
- **Source (`openclaw-plugin/src/meshcentral-readonly.ts`):** implements a single authenticated WSS connection per call, with 750 ms post-open startup delay (to match upstream `meshctrl.js` timing), 1 MB response cap, 30 s timeouts, TLS verification on, and clean connection close in `finally`.
|
||||||
|
- **Target matching:** the `meshes` filter and the device filter are both **case-insensitive** on `groupName` / `deviceName`; the redundant `node.meshid` filter that the chat log mentioned was removed.
|
||||||
|
- **Test surface:** two static safety tests (tool allowlist, fixed-target + env-var password only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Open items (pending, not yet done)
|
||||||
|
|
||||||
|
These are the things the prior session did **not** fully close out, in priority order:
|
||||||
|
|
||||||
|
1. **Live PVE3 status call never confirmed working.** The previous session's chat log ends at `7:35 PM` mid-retry after the Gateway restart. There is no recorded evidence that `meshcentral_device_status` (or any of the four tools) was ever called successfully **through the live OpenClaw Gateway** after PR #3's fix. The standalone bridge path was verified; the Gateway path is unverified in writing.
|
||||||
|
2. **Running plugin artifact is stale relative to merged source.** PR #3 is in the repo but the Gateway's installed `dist/index.js` predates it. A rebuild + reinstall (and a one-time gateway restart) would bring runtime to match source.
|
||||||
|
3. **Stale in-repo documentation.** The following files still describe a much earlier state and contradict reality. They will confuse future-us (and any audit) if left as-is:
|
||||||
|
- `PROJECT-PROGRESS.json` — `status: working`, `lastActivityAt: 2026-08-26`, milestones stop at 2026-09-03 18:25.
|
||||||
|
- `TODO.md` — Build / Live Verification sections are marked complete but do not reflect PR #2 or #3, the plugin package, or the Gateway activation. The "Remaining Deployment/Review Work" section still says "Review the uncommitted diff" — false.
|
||||||
|
- `RESUME-HANDOFF.md` — checkpoint labeled `2026-09-03 18:25 EDT` and the file's own status line says "Local changes are uncommitted and require review before any push or deployment." That is no longer true.
|
||||||
|
4. **No single CHANGELOG / PR history doc** in the repo. Adding one (or refreshing `PROJECT-STATUS-2026-08-25.md` to a current status) would make the project easier to resume later.
|
||||||
|
5. **`story-so-far.md`** has not been updated for PR #2 and #3.
|
||||||
|
6. **Scheduler state:** the `meshcentral-project-progress` cron was paused on Rob's instruction; remains paused. Re-enable only when Rob asks.
|
||||||
|
7. **The chat log's "Exec failed" entries** (e.g. `~/.gitea-staging/meshcentral-openclaw-bridge` inspection, `~/.openclaw/workspace` Gitea token lookup, the runtime plugin cleanup loop) were operational noise during the prior session; they did not result in any untracked file changes that need to be recovered. No data loss from them is suspected, but they are worth knowing about for context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Recommended resume order (when time is available)
|
||||||
|
|
||||||
|
The previous session's plan, refreshed to match actual state:
|
||||||
|
|
||||||
|
1. **Update project docs** (`PROJECT-PROGRESS.json`, `TODO.md`, `RESUME-HANDOFF.md`, optionally `CHANGELOG.md` / `PROJECT-STATUS-2026-09-04.md`) to match reality, push as a docs-only PR. This is purely additive; no live calls, no gateway restart.
|
||||||
|
2. **Rebuild and reinstall the plugin** in the Gateway so the running artifact includes PR #3's case-insensitive + meshid-filter fix. Steps: `cd openclaw-plugin && npm run plugin:build`, then `openclaw plugins install …`, then restart the user Gateway. Confirm the install timestamp moves past the merge.
|
||||||
|
3. **Live verification** — invoke the four tools through the Gateway, starting with `meshcentral_device_status` for `Servers` / `PVE3`. Already inside the approved read-only / PVE3 scope; no new approval needed beyond Rob's standing project-level approval, but explicitly get Rob's go-ahead before connecting.
|
||||||
|
4. **Optional hardening** — capture the verification result, then mark the project `complete` for the read-only MVP and archive the work loop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. What I did and did not do today
|
||||||
|
|
||||||
|
- ✅ Read-only inspection of the repo, the live OpenClaw Gateway, the installed plugin, and the Gitea PR list.
|
||||||
|
- ✅ Re-ran the local verification gates (bridge tests/build/lint, plugin build/tests/manifest validation) — all green.
|
||||||
|
- ✅ Wrote this `FINDINGS-2026-09-04.md` and committed it.
|
||||||
|
- ❌ Did **not** call any MeshCentral endpoint.
|
||||||
|
- ❌ Did **not** rebuild, reinstall, or restart the plugin in the live Gateway.
|
||||||
|
- ❌ Did **not** modify `PROJECT-PROGRESS.json`, `TODO.md`, `RESUME-HANDOFF.md`, or any other existing project file beyond adding this note.
|
||||||
|
- ❌ Did **not** enable, re-enable, or modify the paused `meshcentral-project-progress` cron.
|
||||||
|
|
||||||
|
Nothing in the live Gateway or MeshCentral was touched.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { MeshCtrlAction } from '../meshctrl-client';
|
||||||
|
import { normalizeGroupedNodes, normalizeMeshes } from '../upstream-transport';
|
||||||
|
|
||||||
|
describe('upstream MeshCentral response normalization', () => {
|
||||||
|
it('normalizes native mesh identifiers and descriptions', () => {
|
||||||
|
expect(normalizeMeshes({
|
||||||
|
action: MeshCtrlAction.Meshes,
|
||||||
|
meshes: [{ _id: 'mesh-servers', name: 'Servers', desc: 'Managed servers' }],
|
||||||
|
})).toEqual([{ id: 'mesh-servers', name: 'Servers', description: 'Managed servers' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flattens grouped nodes and maps native node fields', () => {
|
||||||
|
expect(normalizeGroupedNodes({
|
||||||
|
action: MeshCtrlAction.Nodes,
|
||||||
|
nodes: {
|
||||||
|
'mesh-servers': [{
|
||||||
|
_id: 'node-pve3',
|
||||||
|
name: 'PVE3',
|
||||||
|
conn: 1,
|
||||||
|
osdesc: 'Ubuntu',
|
||||||
|
ip: ['10.10.10.13'],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})).toEqual([{
|
||||||
|
id: 'node-pve3',
|
||||||
|
name: 'PVE3',
|
||||||
|
meshid: 'mesh-servers',
|
||||||
|
online: true,
|
||||||
|
device: { os: 'Ubuntu', ip: ['10.10.10.13'] },
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -122,3 +122,51 @@ export class UpstreamMeshTransport {
|
|||||||
await this.close();
|
await this.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse and normalize MeshCentral's native grouped node response. */
|
||||||
|
export function normalizeGroupedNodes(response: MeshCtrlResponse): Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
meshid: string;
|
||||||
|
online: boolean;
|
||||||
|
device?: { os?: string; ip?: string[]; uptime?: number; lastseen?: number; agentver?: string };
|
||||||
|
}> {
|
||||||
|
const grouped = (response.nodes ?? {}) as Record<string, Array<{
|
||||||
|
id?: string;
|
||||||
|
_id?: string;
|
||||||
|
name: string;
|
||||||
|
meshid?: string;
|
||||||
|
online?: boolean;
|
||||||
|
conn?: number;
|
||||||
|
osdesc?: string;
|
||||||
|
ip?: string[];
|
||||||
|
device?: { os?: string; ip?: string[]; uptime?: number; lastseen?: number; agentver?: string };
|
||||||
|
}>>;
|
||||||
|
|
||||||
|
return Object.entries(grouped).flatMap(([groupId, nodes]) => nodes.map((node) => ({
|
||||||
|
id: node.id ?? node._id ?? '',
|
||||||
|
name: node.name,
|
||||||
|
meshid: node.meshid ?? groupId,
|
||||||
|
online: node.online ?? node.conn === 1,
|
||||||
|
device: node.device ?? { os: node.osdesc, ip: node.ip },
|
||||||
|
}))).filter((node) => node.id !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize MeshCentral's native mesh identifiers and description field. */
|
||||||
|
export function normalizeMeshes(response: MeshCtrlResponse): Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}> {
|
||||||
|
return ((response.meshes ?? []) as Array<{
|
||||||
|
id?: string;
|
||||||
|
_id?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
desc?: string;
|
||||||
|
}>).map((mesh) => ({
|
||||||
|
id: mesh.id ?? mesh._id ?? '',
|
||||||
|
name: mesh.name,
|
||||||
|
description: mesh.description ?? mesh.desc,
|
||||||
|
})).filter((mesh) => mesh.id !== '');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# MeshCentral Read-only OpenClaw plugin
|
||||||
|
|
||||||
|
This package exposes a deliberately narrow OpenClaw tool surface for one approved MeshCentral group and device:
|
||||||
|
|
||||||
|
- `meshcentral_health`
|
||||||
|
- `meshcentral_list_groups`
|
||||||
|
- `meshcentral_list_devices`
|
||||||
|
- `meshcentral_device_status`
|
||||||
|
|
||||||
|
The group and device are configured, not selected by tool parameters. The password is read only from the configured environment-variable name at execution time and is never stored in plugin configuration or returned in results.
|
||||||
|
|
||||||
|
## Safety boundary
|
||||||
|
|
||||||
|
This plugin is inventory/status only. It does not expose MeshCentral commands, diagnostics, console, remote control, file transfer, power operations, relay, administration, or state-changing actions. TLS certificate validation is enabled, response sizes are bounded, requests time out, and the connection is closed after each operation.
|
||||||
|
|
||||||
|
Example configuration values for a trusted runtime:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://mesh.ledrew.me",
|
||||||
|
"username": "sa_clawdia",
|
||||||
|
"passwordEnv": "MESHCENTRAL_READONLY_PASSWORD",
|
||||||
|
"groupName": "Servers",
|
||||||
|
"deviceName": "PVE3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not put the password itself in OpenClaw configuration, source, logs, or this repository. This package has been built and statically validated locally; it has not been installed or enabled in the OpenClaw Gateway.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"id": "meshcentral-readonly",
|
||||||
|
"name": "MeshCentral Read-only Inventory",
|
||||||
|
"description": "Read-only inventory and status for one approved MeshCentral group and device.",
|
||||||
|
"activation": {
|
||||||
|
"onStartup": true
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"url",
|
||||||
|
"username",
|
||||||
|
"passwordEnv",
|
||||||
|
"groupName",
|
||||||
|
"deviceName"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "MeshCentral base URL."
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Dedicated least-privilege MeshCentral username."
|
||||||
|
},
|
||||||
|
"passwordEnv": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment variable containing the password; the password itself is never configured here."
|
||||||
|
},
|
||||||
|
"groupName": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Approved MeshCentral group name."
|
||||||
|
},
|
||||||
|
"deviceName": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Approved device name within the approved group."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"version": "0.1.0",
|
||||||
|
"contracts": {
|
||||||
|
"tools": [
|
||||||
|
"meshcentral_health",
|
||||||
|
"meshcentral_list_groups",
|
||||||
|
"meshcentral_list_devices",
|
||||||
|
"meshcentral_device_status"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5080
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "@ledrew/openclaw-meshcentral-readonly",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "OpenClaw read-only MeshCentral inventory and status tools",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"openclaw.plugin.json",
|
||||||
|
"package.json",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"plugin:build": "npm run build && openclaw plugins build --entry ./dist/index.js",
|
||||||
|
"plugin:validate": "npm run plugin:build && openclaw plugins validate --entry ./dist/index.js",
|
||||||
|
"test": "node --test test/*.test.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"typebox": "^1.1.39",
|
||||||
|
"ws": "^8.21.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"openclaw": ">=2026.5.17"
|
||||||
|
},
|
||||||
|
"openclaw": {
|
||||||
|
"extensions": [
|
||||||
|
"./dist/index.js"
|
||||||
|
],
|
||||||
|
"compat": {
|
||||||
|
"pluginApi": ">=2026.5.17",
|
||||||
|
"minGatewayVersion": ">=2026.5.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.5",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Type } from 'typebox';
|
||||||
|
import { defineToolPlugin } from 'openclaw/plugin-sdk/tool-plugin';
|
||||||
|
import { inventory, type PluginConfig } from './meshcentral-readonly.js';
|
||||||
|
|
||||||
|
const configSchema = Type.Object({
|
||||||
|
url: Type.String({ description: 'MeshCentral base URL.' }),
|
||||||
|
username: Type.String({ description: 'Dedicated least-privilege MeshCentral username.' }),
|
||||||
|
passwordEnv: Type.String({ description: 'Environment variable containing the password; the password itself is never configured here.' }),
|
||||||
|
groupName: Type.String({ description: 'Approved MeshCentral group name.' }),
|
||||||
|
deviceName: Type.String({ description: 'Approved device name within the approved group.' }),
|
||||||
|
}, { additionalProperties: false });
|
||||||
|
|
||||||
|
const emptyParameters = Type.Object({}, { additionalProperties: false });
|
||||||
|
|
||||||
|
export default defineToolPlugin({
|
||||||
|
id: 'meshcentral-readonly',
|
||||||
|
name: 'MeshCentral Read-only Inventory',
|
||||||
|
description: 'Read-only inventory and status for one approved MeshCentral group and device.',
|
||||||
|
configSchema,
|
||||||
|
tools: (tool) => [
|
||||||
|
tool({
|
||||||
|
name: 'meshcentral_health',
|
||||||
|
label: 'MeshCentral Health',
|
||||||
|
description: 'Check authenticated read-only access to the approved MeshCentral group.',
|
||||||
|
parameters: emptyParameters,
|
||||||
|
async execute(_params, config) {
|
||||||
|
const result = await inventory(config as PluginConfig);
|
||||||
|
return { healthy: result.groups.length === 1, group: config.groupName };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tool({
|
||||||
|
name: 'meshcentral_list_groups',
|
||||||
|
label: 'MeshCentral Groups',
|
||||||
|
description: 'List the configured approved MeshCentral group.',
|
||||||
|
parameters: emptyParameters,
|
||||||
|
async execute(_params, config) {
|
||||||
|
const result = await inventory(config as PluginConfig);
|
||||||
|
return result.groups;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tool({
|
||||||
|
name: 'meshcentral_list_devices',
|
||||||
|
label: 'MeshCentral Devices',
|
||||||
|
description: 'List the configured approved device in the approved MeshCentral group.',
|
||||||
|
parameters: emptyParameters,
|
||||||
|
async execute(_params, config) {
|
||||||
|
const result = await inventory(config as PluginConfig);
|
||||||
|
return result.devices;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tool({
|
||||||
|
name: 'meshcentral_device_status',
|
||||||
|
label: 'MeshCentral Device Status',
|
||||||
|
description: 'Get status and safe metadata for the configured approved device.',
|
||||||
|
parameters: emptyParameters,
|
||||||
|
async execute(_params, config) {
|
||||||
|
const result = await inventory(config as PluginConfig);
|
||||||
|
const device = result.devices[0];
|
||||||
|
if (!device) throw new Error('Approved MeshCentral device was not found');
|
||||||
|
return device;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { WebSocket } from 'ws';
|
||||||
|
|
||||||
|
const CONTROL_PATH = '/control.ashx';
|
||||||
|
const MAX_RESPONSE_SIZE = 1024 * 1024;
|
||||||
|
const STARTUP_DELAY_MS = 750;
|
||||||
|
const REQUEST_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
export type PluginConfig = {
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
passwordEnv: string;
|
||||||
|
groupName: string;
|
||||||
|
deviceName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MeshResponse = {
|
||||||
|
action?: string;
|
||||||
|
responseid?: string;
|
||||||
|
meshes?: Array<{ id?: string; _id?: string; name: string; description?: string; desc?: string }>;
|
||||||
|
nodes?: Record<string, Array<{
|
||||||
|
id?: string; _id?: string; name: string; meshid?: string; online?: boolean;
|
||||||
|
conn?: number; osdesc?: string; ip?: string[]; device?: Record<string, unknown>;
|
||||||
|
}>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function authHeader(username: string, password: string): string {
|
||||||
|
return `${Buffer.from(username).toString('base64')},${Buffer.from(password).toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpoint(url: string): string {
|
||||||
|
const wsUrl = url.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
|
||||||
|
return wsUrl.endsWith(CONTROL_PATH) ? wsUrl : `${wsUrl.replace(/\/$/, '')}${CONTROL_PATH}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inventory(config: PluginConfig): Promise<{ groups: unknown[]; devices: unknown[] }> {
|
||||||
|
const password = process.env[config.passwordEnv];
|
||||||
|
if (!password) throw new Error('MeshCentral credential environment variable is not configured');
|
||||||
|
|
||||||
|
const ws = new WebSocket(endpoint(config.url), {
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
perMessageDeflate: false,
|
||||||
|
headers: { 'x-meshauth': authHeader(config.username, password) },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => { ws.terminate(); reject(new Error('MeshCentral connection timed out')); }, REQUEST_TIMEOUT_MS);
|
||||||
|
ws.once('open', () => { clearTimeout(timeout); setTimeout(resolve, STARTUP_DELAY_MS); });
|
||||||
|
ws.once('error', (error: Error) => { clearTimeout(timeout); reject(error); });
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = async (action: string, extra: Record<string, unknown> = {}): Promise<MeshResponse> => {
|
||||||
|
const responseid = `openclaw-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => { ws.removeListener('message', onMessage); reject(new Error('MeshCentral request timed out')); }, REQUEST_TIMEOUT_MS);
|
||||||
|
const onMessage = (raw: Buffer | string): void => {
|
||||||
|
const text = Buffer.isBuffer(raw) ? raw.toString() : raw;
|
||||||
|
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_SIZE) return;
|
||||||
|
let parsed: MeshResponse;
|
||||||
|
try { parsed = JSON.parse(text) as MeshResponse; } catch { return; }
|
||||||
|
if (parsed.responseid !== responseid && !(action === 'meshes' && parsed.action === 'meshes')) return;
|
||||||
|
clearTimeout(timeout); ws.removeListener('message', onMessage); resolve(parsed);
|
||||||
|
};
|
||||||
|
ws.on('message', onMessage);
|
||||||
|
ws.send(JSON.stringify({ action, responseid, ...extra }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const meshesResponse = await request('meshes');
|
||||||
|
const meshes = (meshesResponse.meshes ?? []).map((mesh) => ({
|
||||||
|
id: mesh.id ?? mesh._id ?? '', name: mesh.name, description: mesh.description ?? mesh.desc,
|
||||||
|
})).filter((mesh) => mesh.id && mesh.name.toLowerCase() === config.groupName.toLowerCase());
|
||||||
|
if (meshes.length !== 1) throw new Error('Approved MeshCentral group was not found uniquely');
|
||||||
|
|
||||||
|
const group = meshes[0];
|
||||||
|
const nodesResponse = await request('nodes', { meshid: group.id });
|
||||||
|
const devices = Object.entries(nodesResponse.nodes ?? {}).flatMap(([groupId, nodes]) => nodes.map((node) => ({
|
||||||
|
id: node.id ?? node._id ?? '', name: node.name, groupId: node.meshid ?? groupId,
|
||||||
|
online: node.online ?? node.conn === 1, os: node.device?.os ?? node.osdesc,
|
||||||
|
ipAddresses: node.device?.ip ?? node.ip,
|
||||||
|
// The nodes request is already scoped to the approved group. MeshCentral
|
||||||
|
// may omit or normalize the per-node meshid differently, so do not apply
|
||||||
|
// a second brittle group-id comparison here.
|
||||||
|
}))).filter((node) => node.id && node.name.toLowerCase() === config.deviceName.toLowerCase());
|
||||||
|
return { groups: meshes, devices };
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (ws.readyState === WebSocket.CLOSED) return resolve();
|
||||||
|
const timeout = setTimeout(() => { ws.terminate(); resolve(); }, 2_000);
|
||||||
|
ws.once('close', () => { clearTimeout(timeout); resolve(); });
|
||||||
|
ws.close(1000, 'bridge-close');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const entry = await readFile(new URL('../src/index.ts', import.meta.url), 'utf8');
|
||||||
|
const transport = await readFile(new URL('../src/meshcentral-readonly.ts', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
test('declares exactly the four read-only tools', () => {
|
||||||
|
for (const name of [
|
||||||
|
'meshcentral_health',
|
||||||
|
'meshcentral_list_groups',
|
||||||
|
'meshcentral_list_devices',
|
||||||
|
'meshcentral_device_status',
|
||||||
|
]) assert.match(entry, new RegExp(`name: '${name}'`));
|
||||||
|
assert.doesNotMatch(entry, /runcommand|power|console|filetransfer|remotecontrol|diagnostic/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses fixed configured targets and an environment variable for the password', () => {
|
||||||
|
assert.match(entry, /passwordEnv/);
|
||||||
|
assert.match(transport, /process\.env\[config\.passwordEnv\]/);
|
||||||
|
assert.match(transport, /groupName/);
|
||||||
|
assert.match(transport, /deviceName/);
|
||||||
|
assert.doesNotMatch(transport, /rejectUnauthorized:\s*false/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user