@jhytabest/plashboard 0.1.10 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -1
- package/openclaw.plugin.json +2 -1
- package/package.json +3 -2
- package/skills/plashboard-admin/SKILL.md +15 -1
- package/src/command-runner.ts +120 -0
- package/src/config.ts +1 -0
- package/src/fill-runner.test.ts +18 -1
- package/src/fill-runner.ts +8 -103
- package/src/plugin.test.ts +186 -0
- package/src/plugin.ts +389 -120
- package/src/publisher.ts +22 -54
- package/src/runtime.test.ts +52 -2
- package/src/runtime.ts +9 -1
- package/src/types.ts +5 -0
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ openclaw plugins update plashboard
|
|
|
23
23
|
|
|
24
24
|
No manual config is required for first use. Defaults are safe:
|
|
25
25
|
- `fill_provider=openclaw`
|
|
26
|
+
- `allow_command_fill=false`
|
|
26
27
|
- `openclaw_fill_agent_id=main`
|
|
27
28
|
- automatic init on service start
|
|
28
29
|
- automatic starter template seed when template store is empty
|
|
@@ -33,6 +34,9 @@ In chat, run:
|
|
|
33
34
|
/plashboard onboard <what this dashboard should focus on>
|
|
34
35
|
```
|
|
35
36
|
|
|
37
|
+
For end users (Telegram/other channels), no slash command is required:
|
|
38
|
+
natural-language requests such as "I want a dashboard for X" are handled by the bundled `plashboard-admin` skill via tool calls.
|
|
39
|
+
|
|
36
40
|
## Optional Config
|
|
37
41
|
|
|
38
42
|
Add to `openclaw.json`:
|
|
@@ -52,6 +56,7 @@ Add to `openclaw.json`:
|
|
|
52
56
|
"session_timeout_seconds": 90,
|
|
53
57
|
"auto_seed_template": true,
|
|
54
58
|
"fill_provider": "openclaw",
|
|
59
|
+
"allow_command_fill": false,
|
|
55
60
|
"openclaw_fill_agent_id": "main",
|
|
56
61
|
"display_profile": {
|
|
57
62
|
"width_px": 1920,
|
|
@@ -69,7 +74,20 @@ Add to `openclaw.json`:
|
|
|
69
74
|
```
|
|
70
75
|
|
|
71
76
|
`fill_provider: "openclaw"` is the default real mode and calls `openclaw agent` directly.
|
|
72
|
-
|
|
77
|
+
`fill_provider: "command"` requires explicit opt-in with `allow_command_fill: true`.
|
|
78
|
+
Use command mode only if you need a custom external runner.
|
|
79
|
+
|
|
80
|
+
For production stability, use a dedicated fill agent instead of `main`:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
openclaw agents add plashboard-fill --non-interactive --workspace /var/lib/openclaw/.openclaw/workspace-plashboard-fill
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Then run:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
/plashboard setup openclaw plashboard-fill
|
|
90
|
+
```
|
|
73
91
|
|
|
74
92
|
## Runtime Command
|
|
75
93
|
|
|
@@ -78,6 +96,7 @@ Use `fill_provider: "command"` only if you need a custom external runner.
|
|
|
78
96
|
/plashboard setup [openclaw [agent_id]|mock|command <fill_command>]
|
|
79
97
|
/plashboard quickstart <description>
|
|
80
98
|
/plashboard doctor [local_url] [https_port] [repo_dir]
|
|
99
|
+
/plashboard fix-permissions [dashboard_output_path]
|
|
81
100
|
/plashboard web-guide [local_url] [repo_dir]
|
|
82
101
|
/plashboard expose-guide [local_url] [https_port]
|
|
83
102
|
/plashboard expose-check [local_url] [https_port]
|
|
@@ -97,12 +116,21 @@ Recommended first run:
|
|
|
97
116
|
/plashboard onboard "Focus on service health, priorities, blockers, and next actions."
|
|
98
117
|
```
|
|
99
118
|
|
|
119
|
+
For command mode, explicit opt-in is required:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
/plashboard setup command <fill_command>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
This command writes `allow_command_fill=true` with `fill_provider=command`.
|
|
126
|
+
|
|
100
127
|
If `onboard` returns web/exposure warnings:
|
|
101
128
|
|
|
102
129
|
```text
|
|
103
130
|
/plashboard web-guide
|
|
104
131
|
/plashboard expose-guide
|
|
105
132
|
/plashboard doctor
|
|
133
|
+
/plashboard fix-permissions
|
|
106
134
|
```
|
|
107
135
|
|
|
108
136
|
Tailscale helper flow:
|
|
@@ -116,3 +144,5 @@ Tailscale helper flow:
|
|
|
116
144
|
|
|
117
145
|
- The plugin includes an admin skill (`plashboard-admin`) for tool-guided management.
|
|
118
146
|
- Trusted publishing (OIDC) is enabled in CI/CD for npm releases.
|
|
147
|
+
- If you see `plugins.allow is empty`, add explicit trust list in OpenClaw config:
|
|
148
|
+
- `"plugins": { "allow": ["plashboard"] }`
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "plashboard",
|
|
3
3
|
"name": "Plashboard",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "1.0.0",
|
|
5
5
|
"description": "Template-driven dashboard runtime with scheduled OpenClaw fills and safe publish.",
|
|
6
6
|
"entry": "./src/index.ts",
|
|
7
7
|
"skills": ["./skills/plashboard-admin"],
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"session_timeout_seconds": { "type": "integer", "minimum": 10, "maximum": 600, "default": 90 },
|
|
18
18
|
"auto_seed_template": { "type": "boolean", "default": true },
|
|
19
19
|
"fill_provider": { "type": "string", "enum": ["command", "mock", "openclaw"], "default": "openclaw" },
|
|
20
|
+
"allow_command_fill": { "type": "boolean", "default": false },
|
|
20
21
|
"fill_command": { "type": "string" },
|
|
21
22
|
"openclaw_fill_agent_id": { "type": "string", "default": "main" },
|
|
22
23
|
"python_bin": { "type": "string", "default": "python3" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhytabest/plashboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Plashboard OpenClaw plugin runtime",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"openclaw.plugin.json",
|
|
19
19
|
"schema",
|
|
20
|
-
"scripts",
|
|
20
|
+
"scripts/dashboard_write.py",
|
|
21
21
|
"skills",
|
|
22
22
|
"src",
|
|
23
23
|
"tsconfig.json",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
|
+
"security:scan": "node ./dev-security-scan.mjs",
|
|
30
31
|
"test": "vitest run",
|
|
31
32
|
"test:watch": "vitest",
|
|
32
33
|
"typecheck": "tsc --noEmit"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plashboard-admin
|
|
3
|
-
description: Manage plashboard templates
|
|
3
|
+
description: Manage plashboard templates and autonomously convert natural-language dashboard requests into plashboard tool actions.
|
|
4
4
|
command-dispatch: tool
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -9,6 +9,7 @@ command-dispatch: tool
|
|
|
9
9
|
Use this skill for plashboard runtime administration.
|
|
10
10
|
|
|
11
11
|
## Use Cases
|
|
12
|
+
- Convert user requests like "I want dashboard X" into created/activated dashboards automatically.
|
|
12
13
|
- Create, update, copy, delete, and validate dashboard templates.
|
|
13
14
|
- Activate a template as the live dashboard source.
|
|
14
15
|
- Trigger immediate runs.
|
|
@@ -23,6 +24,7 @@ Always use plugin tools:
|
|
|
23
24
|
- `plashboard_exposure_check`
|
|
24
25
|
- `plashboard_web_guide`
|
|
25
26
|
- `plashboard_doctor`
|
|
27
|
+
- `plashboard_permissions_fix`
|
|
26
28
|
- `plashboard_init`
|
|
27
29
|
- `plashboard_quickstart`
|
|
28
30
|
- `plashboard_template_create`
|
|
@@ -41,12 +43,24 @@ Always use plugin tools:
|
|
|
41
43
|
- Never edit template/state/run JSON files directly.
|
|
42
44
|
- Never perform Docker, Tailscale, or systemd operations.
|
|
43
45
|
- Never ask the model to generate full dashboard structure when filling values.
|
|
46
|
+
- Do not tell end users to run slash commands when tool calls can do the action directly.
|
|
47
|
+
|
|
48
|
+
## Intent Automation
|
|
49
|
+
- If the user asks for a new dashboard in natural language, call `plashboard_onboard` with:
|
|
50
|
+
- `description`: user request rewritten as a concrete dashboard objective
|
|
51
|
+
- `force_quickstart`: `true`
|
|
52
|
+
- `activate`: `true`
|
|
53
|
+
- `run_now`: `true`
|
|
54
|
+
- If onboarding returns readiness issues, call `plashboard_web_guide` and `plashboard_exposure_guide`, then present exact operator commands.
|
|
55
|
+
- If the user asks to modify an existing dashboard, call `plashboard_template_list` first, then update/copy/activate/run via tools.
|
|
56
|
+
- Prefer tool execution over conversational planning; only ask clarifying questions if the request is ambiguous.
|
|
44
57
|
|
|
45
58
|
## Command Shortcuts
|
|
46
59
|
- `/plashboard onboard <description> [local_url] [https_port] [repo_dir]`
|
|
47
60
|
- `/plashboard quickstart <description>`
|
|
48
61
|
- `/plashboard setup [openclaw [agent_id]|mock|command <fill_command>]`
|
|
49
62
|
- `/plashboard doctor [local_url] [https_port] [repo_dir]`
|
|
63
|
+
- `/plashboard fix-permissions [dashboard_output_path]`
|
|
50
64
|
- `/plashboard web-guide [local_url] [repo_dir]`
|
|
51
65
|
- `/plashboard expose-guide [local_url] [https_port]`
|
|
52
66
|
- `/plashboard expose-check [local_url] [https_port]`
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
type CommandRunnerOptions = {
|
|
2
|
+
timeoutMs: number;
|
|
3
|
+
cwd?: string;
|
|
4
|
+
input?: string;
|
|
5
|
+
env?: Record<string, string>;
|
|
6
|
+
windowsVerbatimArguments?: boolean;
|
|
7
|
+
noOutputTimeoutMs?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type CommandRunResult = {
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
code: number | null;
|
|
14
|
+
signal?: NodeJS.Signals | null;
|
|
15
|
+
killed?: boolean;
|
|
16
|
+
termination?: 'exit' | 'timeout' | 'signal' | string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type CommandRunner = (
|
|
20
|
+
argv: string[],
|
|
21
|
+
optionsOrTimeout: number | CommandRunnerOptions
|
|
22
|
+
) => Promise<CommandRunResult>;
|
|
23
|
+
|
|
24
|
+
export type RuntimeCommandWithTimeout = (
|
|
25
|
+
argv: string[],
|
|
26
|
+
optionsOrTimeout: number | CommandRunnerOptions
|
|
27
|
+
) => Promise<{
|
|
28
|
+
stdout: string;
|
|
29
|
+
stderr: string;
|
|
30
|
+
code: number | null;
|
|
31
|
+
signal?: NodeJS.Signals | null;
|
|
32
|
+
killed?: boolean;
|
|
33
|
+
termination?: string;
|
|
34
|
+
}>;
|
|
35
|
+
|
|
36
|
+
export type CommandExecResult = {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
stdout: string;
|
|
39
|
+
stderr: string;
|
|
40
|
+
code: number | null;
|
|
41
|
+
signal?: NodeJS.Signals | null;
|
|
42
|
+
killed?: boolean;
|
|
43
|
+
termination?: string;
|
|
44
|
+
error?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function asErrorMessage(error: unknown): string {
|
|
48
|
+
if (error instanceof Error && error.message) return error.message;
|
|
49
|
+
if (typeof error === 'string' && error.trim()) return error;
|
|
50
|
+
return 'unknown error';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createRuntimeCommandRunner(runtimeCommand?: RuntimeCommandWithTimeout): CommandRunner | null {
|
|
54
|
+
if (!runtimeCommand) return null;
|
|
55
|
+
return async (argv: string[], optionsOrTimeout: number | CommandRunnerOptions): Promise<CommandRunResult> => {
|
|
56
|
+
const result = await runtimeCommand(argv, optionsOrTimeout);
|
|
57
|
+
return {
|
|
58
|
+
stdout: result.stdout,
|
|
59
|
+
stderr: result.stderr,
|
|
60
|
+
code: result.code,
|
|
61
|
+
signal: result.signal,
|
|
62
|
+
killed: result.killed,
|
|
63
|
+
termination: result.termination
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function runCommand(
|
|
69
|
+
commandRunner: CommandRunner | null | undefined,
|
|
70
|
+
argv: string[],
|
|
71
|
+
optionsOrTimeout: number | CommandRunnerOptions,
|
|
72
|
+
label: string
|
|
73
|
+
): Promise<CommandExecResult> {
|
|
74
|
+
if (!commandRunner) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
stdout: '',
|
|
78
|
+
stderr: '',
|
|
79
|
+
code: null,
|
|
80
|
+
error: `${label} is unavailable: OpenClaw runtime command runner is not available`
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const result = await commandRunner(argv, optionsOrTimeout);
|
|
86
|
+
return {
|
|
87
|
+
ok: result.code === 0,
|
|
88
|
+
stdout: result.stdout.trim(),
|
|
89
|
+
stderr: result.stderr.trim(),
|
|
90
|
+
code: result.code,
|
|
91
|
+
signal: result.signal,
|
|
92
|
+
killed: result.killed,
|
|
93
|
+
termination: result.termination
|
|
94
|
+
};
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
stdout: '',
|
|
99
|
+
stderr: '',
|
|
100
|
+
code: null,
|
|
101
|
+
error: `${label} failed: ${asErrorMessage(error)}`
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function runAndReadStdout(
|
|
107
|
+
commandRunner: CommandRunner | null | undefined,
|
|
108
|
+
argv: string[],
|
|
109
|
+
optionsOrTimeout: number | CommandRunnerOptions,
|
|
110
|
+
label: string
|
|
111
|
+
): Promise<string> {
|
|
112
|
+
const result = await runCommand(commandRunner, argv, optionsOrTimeout, label);
|
|
113
|
+
if (!result.ok) {
|
|
114
|
+
const reason = result.error || result.stderr || result.stdout || result.termination || `exit=${String(result.code)}`;
|
|
115
|
+
throw new Error(`${label} failed: ${reason}`);
|
|
116
|
+
}
|
|
117
|
+
return result.stdout.trim();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type { CommandRunnerOptions };
|
package/src/config.ts
CHANGED
|
@@ -77,6 +77,7 @@ export function resolveConfig(api: unknown): PlashboardConfig {
|
|
|
77
77
|
session_timeout_seconds: Math.max(10, Math.floor(asNumber(raw.session_timeout_seconds, 90))),
|
|
78
78
|
auto_seed_template: asBoolean(raw.auto_seed_template, true),
|
|
79
79
|
fill_provider: fillProvider,
|
|
80
|
+
allow_command_fill: asBoolean(raw.allow_command_fill, false),
|
|
80
81
|
fill_command: typeof raw.fill_command === 'string' ? raw.fill_command : undefined,
|
|
81
82
|
openclaw_fill_agent_id: asString(raw.openclaw_fill_agent_id, 'main'),
|
|
82
83
|
python_bin: asString(raw.python_bin, 'python3'),
|
package/src/fill-runner.test.ts
CHANGED
|
@@ -39,6 +39,7 @@ function config(overrides: Partial<PlashboardConfig>): PlashboardConfig {
|
|
|
39
39
|
session_timeout_seconds: 30,
|
|
40
40
|
auto_seed_template: false,
|
|
41
41
|
fill_provider: 'mock',
|
|
42
|
+
allow_command_fill: false,
|
|
42
43
|
fill_command: undefined,
|
|
43
44
|
openclaw_fill_agent_id: 'main',
|
|
44
45
|
python_bin: 'python3',
|
|
@@ -105,7 +106,7 @@ describe('createFillRunner', () => {
|
|
|
105
106
|
}));
|
|
106
107
|
|
|
107
108
|
const runner = createFillRunner(
|
|
108
|
-
config({ fill_provider: 'command', fill_command: 'echo "$PLASHBOARD_PROMPT_JSON"' }),
|
|
109
|
+
config({ fill_provider: 'command', allow_command_fill: true, fill_command: 'echo "$PLASHBOARD_PROMPT_JSON"' }),
|
|
109
110
|
{ commandRunner }
|
|
110
111
|
);
|
|
111
112
|
const response = await runner.run(context());
|
|
@@ -118,4 +119,20 @@ describe('createFillRunner', () => {
|
|
|
118
119
|
const options = firstCall[1] as { env?: Record<string, string> };
|
|
119
120
|
expect(options.env?.PLASHBOARD_PROMPT_JSON).toContain('"template"');
|
|
120
121
|
});
|
|
122
|
+
|
|
123
|
+
it('rejects command fill when allow_command_fill is false', async () => {
|
|
124
|
+
const commandRunner = vi.fn(async () => ({
|
|
125
|
+
stdout: '{"values":{"summary":"from command"}}',
|
|
126
|
+
stderr: '',
|
|
127
|
+
code: 0
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
const runner = createFillRunner(
|
|
131
|
+
config({ fill_provider: 'command', allow_command_fill: false, fill_command: 'echo hello' }),
|
|
132
|
+
{ commandRunner }
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
await expect(runner.run(context())).rejects.toThrow(/allow_command_fill=true/);
|
|
136
|
+
expect(commandRunner).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
121
138
|
});
|
package/src/fill-runner.ts
CHANGED
|
@@ -1,28 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { runAndReadStdout, type CommandRunner } from './command-runner.js';
|
|
2
2
|
import type { FillResponse, FillRunContext, FillRunner, PlashboardConfig } from './types.js';
|
|
3
3
|
|
|
4
|
-
type CommandOptions = {
|
|
5
|
-
timeoutMs: number;
|
|
6
|
-
env?: NodeJS.ProcessEnv;
|
|
7
|
-
input?: string;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
export type CommandRunResult = {
|
|
11
|
-
stdout: string;
|
|
12
|
-
stderr: string;
|
|
13
|
-
code: number | null;
|
|
14
|
-
signal?: NodeJS.Signals | null;
|
|
15
|
-
killed?: boolean;
|
|
16
|
-
termination?: 'exit' | 'timeout' | 'signal' | string;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export type CommandRunner = (
|
|
20
|
-
argv: string[],
|
|
21
|
-
optionsOrTimeout: number | CommandOptions
|
|
22
|
-
) => Promise<CommandRunResult>;
|
|
23
|
-
|
|
24
4
|
export interface FillRunnerDeps {
|
|
25
|
-
commandRunner?: CommandRunner;
|
|
5
|
+
commandRunner?: CommandRunner | null;
|
|
26
6
|
}
|
|
27
7
|
|
|
28
8
|
function buildPromptPayload(context: FillRunContext): Record<string, unknown> {
|
|
@@ -74,70 +54,6 @@ function mockValue(type: string, currentValue: unknown, fieldId: string): unknow
|
|
|
74
54
|
return `updated ${fieldId} at ${now}`;
|
|
75
55
|
}
|
|
76
56
|
|
|
77
|
-
function normalizeCommandOptions(optionsOrTimeout: number | CommandOptions): CommandOptions {
|
|
78
|
-
if (typeof optionsOrTimeout === 'number') {
|
|
79
|
-
return { timeoutMs: optionsOrTimeout };
|
|
80
|
-
}
|
|
81
|
-
return { timeoutMs: optionsOrTimeout.timeoutMs, env: optionsOrTimeout.env, input: optionsOrTimeout.input };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function defaultCommandRunner(argv: string[], optionsOrTimeout: number | CommandOptions): Promise<CommandRunResult> {
|
|
85
|
-
const options = normalizeCommandOptions(optionsOrTimeout);
|
|
86
|
-
return new Promise((resolve, reject) => {
|
|
87
|
-
if (!Array.isArray(argv) || argv.length === 0 || !argv[0]) {
|
|
88
|
-
reject(new Error('command argv must include a binary name'));
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const child = spawn(argv[0], argv.slice(1), {
|
|
93
|
-
env: {
|
|
94
|
-
...process.env,
|
|
95
|
-
...(options.env || {})
|
|
96
|
-
},
|
|
97
|
-
stdio: 'pipe'
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
const timeoutMs = Math.max(1000, Math.floor(options.timeoutMs));
|
|
101
|
-
let terminatedByTimeout = false;
|
|
102
|
-
const timer = setTimeout(() => {
|
|
103
|
-
terminatedByTimeout = true;
|
|
104
|
-
child.kill('SIGKILL');
|
|
105
|
-
}, timeoutMs);
|
|
106
|
-
|
|
107
|
-
let stdout = '';
|
|
108
|
-
let stderr = '';
|
|
109
|
-
|
|
110
|
-
child.stdout.on('data', (chunk) => {
|
|
111
|
-
stdout += String(chunk);
|
|
112
|
-
});
|
|
113
|
-
child.stderr.on('data', (chunk) => {
|
|
114
|
-
stderr += String(chunk);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
child.on('error', (error) => {
|
|
118
|
-
clearTimeout(timer);
|
|
119
|
-
reject(error);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
child.on('close', (code, signal) => {
|
|
123
|
-
clearTimeout(timer);
|
|
124
|
-
resolve({
|
|
125
|
-
stdout: stdout.trim(),
|
|
126
|
-
stderr: stderr.trim(),
|
|
127
|
-
code,
|
|
128
|
-
signal,
|
|
129
|
-
killed: terminatedByTimeout || code === null,
|
|
130
|
-
termination: terminatedByTimeout ? 'timeout' : signal ? 'signal' : 'exit'
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
if (typeof options.input === 'string') {
|
|
135
|
-
child.stdin.write(options.input);
|
|
136
|
-
}
|
|
137
|
-
child.stdin.end();
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
57
|
function tryParseJson(input: string): unknown | undefined {
|
|
142
58
|
try {
|
|
143
59
|
return JSON.parse(input);
|
|
@@ -226,20 +142,6 @@ function parseFillResponse(output: string, source: string): FillResponse {
|
|
|
226
142
|
return extracted;
|
|
227
143
|
}
|
|
228
144
|
|
|
229
|
-
async function runAndReadStdout(
|
|
230
|
-
commandRunner: CommandRunner,
|
|
231
|
-
argv: string[],
|
|
232
|
-
optionsOrTimeout: number | CommandOptions,
|
|
233
|
-
label: string
|
|
234
|
-
): Promise<string> {
|
|
235
|
-
const result = await commandRunner(argv, optionsOrTimeout);
|
|
236
|
-
if (result.code !== 0) {
|
|
237
|
-
const reason = result.stderr || result.stdout || result.termination || `exit=${String(result.code)}`;
|
|
238
|
-
throw new Error(`${label} failed: ${reason}`);
|
|
239
|
-
}
|
|
240
|
-
return result.stdout.trim();
|
|
241
|
-
}
|
|
242
|
-
|
|
243
145
|
class MockFillRunner implements FillRunner {
|
|
244
146
|
async run(context: FillRunContext): Promise<FillResponse> {
|
|
245
147
|
const values: Record<string, unknown> = {};
|
|
@@ -253,10 +155,13 @@ class MockFillRunner implements FillRunner {
|
|
|
253
155
|
class CommandFillRunner implements FillRunner {
|
|
254
156
|
constructor(
|
|
255
157
|
private readonly config: PlashboardConfig,
|
|
256
|
-
private readonly commandRunner: CommandRunner
|
|
158
|
+
private readonly commandRunner: CommandRunner | null
|
|
257
159
|
) {}
|
|
258
160
|
|
|
259
161
|
async run(context: FillRunContext): Promise<FillResponse> {
|
|
162
|
+
if (!this.config.allow_command_fill) {
|
|
163
|
+
throw new Error('fill_provider=command is disabled; set allow_command_fill=true to enable it');
|
|
164
|
+
}
|
|
260
165
|
if (!this.config.fill_command) {
|
|
261
166
|
throw new Error('fill_provider=command but fill_command is not configured');
|
|
262
167
|
}
|
|
@@ -280,7 +185,7 @@ class CommandFillRunner implements FillRunner {
|
|
|
280
185
|
class OpenClawFillRunner implements FillRunner {
|
|
281
186
|
constructor(
|
|
282
187
|
private readonly config: PlashboardConfig,
|
|
283
|
-
private readonly commandRunner: CommandRunner
|
|
188
|
+
private readonly commandRunner: CommandRunner | null
|
|
284
189
|
) {}
|
|
285
190
|
|
|
286
191
|
async run(context: FillRunContext): Promise<FillResponse> {
|
|
@@ -302,7 +207,7 @@ class OpenClawFillRunner implements FillRunner {
|
|
|
302
207
|
}
|
|
303
208
|
|
|
304
209
|
export function createFillRunner(config: PlashboardConfig, deps: FillRunnerDeps = {}): FillRunner {
|
|
305
|
-
const commandRunner = deps.commandRunner
|
|
210
|
+
const commandRunner = deps.commandRunner ?? null;
|
|
306
211
|
if (config.fill_provider === 'command') {
|
|
307
212
|
return new CommandFillRunner(config, commandRunner);
|
|
308
213
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { registerPlashboardPlugin } from './plugin.js';
|
|
6
|
+
|
|
7
|
+
type ToolDef = {
|
|
8
|
+
name: string;
|
|
9
|
+
execute?: (toolCallId: unknown, params?: Record<string, unknown>) => Promise<{
|
|
10
|
+
content: Array<{ type: string; text: string }>;
|
|
11
|
+
}>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function parseToolJson(result: { content: Array<{ type: string; text: string }> }) {
|
|
15
|
+
const text = result.content[0]?.text || '{}';
|
|
16
|
+
return JSON.parse(text) as Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('registerPlashboardPlugin', () => {
|
|
20
|
+
it('doctor reports readiness flags when runtime command runner is unavailable', async () => {
|
|
21
|
+
const root = await mkdtemp(join(tmpdir(), 'plashboard-plugin-test-'));
|
|
22
|
+
try {
|
|
23
|
+
const tools = new Map<string, ToolDef>();
|
|
24
|
+
|
|
25
|
+
registerPlashboardPlugin({
|
|
26
|
+
pluginConfig: {
|
|
27
|
+
config: {
|
|
28
|
+
data_dir: root,
|
|
29
|
+
dashboard_output_path: join(root, 'dashboard.json'),
|
|
30
|
+
fill_provider: 'openclaw',
|
|
31
|
+
allow_command_fill: false
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
registerTool: (definition: unknown) => {
|
|
35
|
+
const tool = definition as ToolDef;
|
|
36
|
+
tools.set(tool.name, tool);
|
|
37
|
+
},
|
|
38
|
+
registerCommand: () => {},
|
|
39
|
+
registerService: () => {},
|
|
40
|
+
runtime: {
|
|
41
|
+
config: {
|
|
42
|
+
loadConfig: () => ({}),
|
|
43
|
+
writeConfigFile: async () => {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const doctor = tools.get('plashboard_doctor');
|
|
49
|
+
expect(doctor?.execute).toBeTypeOf('function');
|
|
50
|
+
|
|
51
|
+
const result = await doctor!.execute!('tool-1', {
|
|
52
|
+
local_url: 'http://127.0.0.1:9'
|
|
53
|
+
});
|
|
54
|
+
const payload = parseToolJson(result);
|
|
55
|
+
const data = (payload.data || {}) as Record<string, unknown>;
|
|
56
|
+
|
|
57
|
+
expect(payload.ok).toBe(false);
|
|
58
|
+
expect(data.fill_provider_ready).toBe(false);
|
|
59
|
+
expect(data.writer_runner_ready).toBe(false);
|
|
60
|
+
} finally {
|
|
61
|
+
await rm(root, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('setup rejects command provider unless allow_command_fill is true', async () => {
|
|
66
|
+
const root = await mkdtemp(join(tmpdir(), 'plashboard-plugin-test-'));
|
|
67
|
+
try {
|
|
68
|
+
const tools = new Map<string, ToolDef>();
|
|
69
|
+
let writtenConfig: unknown;
|
|
70
|
+
|
|
71
|
+
registerPlashboardPlugin({
|
|
72
|
+
pluginConfig: {
|
|
73
|
+
config: {
|
|
74
|
+
data_dir: root,
|
|
75
|
+
dashboard_output_path: join(root, 'dashboard.json'),
|
|
76
|
+
fill_provider: 'openclaw',
|
|
77
|
+
allow_command_fill: false
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
registerTool: (definition: unknown) => {
|
|
81
|
+
const tool = definition as ToolDef;
|
|
82
|
+
tools.set(tool.name, tool);
|
|
83
|
+
},
|
|
84
|
+
registerCommand: () => {},
|
|
85
|
+
registerService: () => {},
|
|
86
|
+
runtime: {
|
|
87
|
+
config: {
|
|
88
|
+
loadConfig: () => ({}),
|
|
89
|
+
writeConfigFile: async (nextConfig: unknown) => {
|
|
90
|
+
writtenConfig = nextConfig;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
system: {
|
|
94
|
+
runCommandWithTimeout: async (argv: string[]) => {
|
|
95
|
+
if (argv[0] === 'python3' && argv[1] === '--version') {
|
|
96
|
+
return {
|
|
97
|
+
stdout: 'Python 3.12.0',
|
|
98
|
+
stderr: '',
|
|
99
|
+
code: 0,
|
|
100
|
+
termination: 'exit'
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
stdout: '',
|
|
105
|
+
stderr: `unsupported command: ${argv.join(' ')}`,
|
|
106
|
+
code: 1,
|
|
107
|
+
termination: 'exit'
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const setup = tools.get('plashboard_setup');
|
|
115
|
+
expect(setup?.execute).toBeTypeOf('function');
|
|
116
|
+
|
|
117
|
+
const rejected = parseToolJson(await setup!.execute!('tool-2', {
|
|
118
|
+
fill_provider: 'command',
|
|
119
|
+
fill_command: 'echo hello'
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
expect(rejected.ok).toBe(false);
|
|
123
|
+
expect((rejected.errors as string[]).join(' ')).toMatch(/allow_command_fill=true/i);
|
|
124
|
+
expect(writtenConfig).toBeUndefined();
|
|
125
|
+
|
|
126
|
+
const accepted = parseToolJson(await setup!.execute!('tool-3', {
|
|
127
|
+
fill_provider: 'command',
|
|
128
|
+
allow_command_fill: true,
|
|
129
|
+
fill_command: 'echo hello'
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
expect(accepted.ok).toBe(true);
|
|
133
|
+
expect((accepted.data as Record<string, unknown>).allow_command_fill).toBe(true);
|
|
134
|
+
expect(writtenConfig).toBeTruthy();
|
|
135
|
+
} finally {
|
|
136
|
+
await rm(root, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('permissions fix normalizes directory and dashboard file modes', async () => {
|
|
141
|
+
const root = await mkdtemp(join(tmpdir(), 'plashboard-plugin-test-'));
|
|
142
|
+
const dashboardPath = join(root, 'dashboard.json');
|
|
143
|
+
try {
|
|
144
|
+
const tools = new Map<string, ToolDef>();
|
|
145
|
+
await writeFile(dashboardPath, '{"ok":true}\n', 'utf8');
|
|
146
|
+
await chmod(root, 0o700);
|
|
147
|
+
await chmod(dashboardPath, 0o600);
|
|
148
|
+
|
|
149
|
+
registerPlashboardPlugin({
|
|
150
|
+
pluginConfig: {
|
|
151
|
+
config: {
|
|
152
|
+
data_dir: root,
|
|
153
|
+
dashboard_output_path: dashboardPath,
|
|
154
|
+
fill_provider: 'openclaw',
|
|
155
|
+
allow_command_fill: false
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
registerTool: (definition: unknown) => {
|
|
159
|
+
const tool = definition as ToolDef;
|
|
160
|
+
tools.set(tool.name, tool);
|
|
161
|
+
},
|
|
162
|
+
registerCommand: () => {},
|
|
163
|
+
registerService: () => {},
|
|
164
|
+
runtime: {
|
|
165
|
+
config: {
|
|
166
|
+
loadConfig: () => ({}),
|
|
167
|
+
writeConfigFile: async () => {}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const fix = tools.get('plashboard_permissions_fix');
|
|
173
|
+
expect(fix?.execute).toBeTypeOf('function');
|
|
174
|
+
|
|
175
|
+
const result = parseToolJson(await fix!.execute!('tool-4', {}));
|
|
176
|
+
expect(result.ok).toBe(true);
|
|
177
|
+
|
|
178
|
+
const dirMode = (await stat(root)).mode & 0o777;
|
|
179
|
+
const fileMode = (await stat(dashboardPath)).mode & 0o777;
|
|
180
|
+
expect(dirMode).toBe(0o755);
|
|
181
|
+
expect(fileMode).toBe(0o644);
|
|
182
|
+
} finally {
|
|
183
|
+
await rm(root, { recursive: true, force: true });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|