@jhytabest/plashboard 0.1.11 → 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 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
@@ -55,6 +56,7 @@ Add to `openclaw.json`:
55
56
  "session_timeout_seconds": 90,
56
57
  "auto_seed_template": true,
57
58
  "fill_provider": "openclaw",
59
+ "allow_command_fill": false,
58
60
  "openclaw_fill_agent_id": "main",
59
61
  "display_profile": {
60
62
  "width_px": 1920,
@@ -72,7 +74,20 @@ Add to `openclaw.json`:
72
74
  ```
73
75
 
74
76
  `fill_provider: "openclaw"` is the default real mode and calls `openclaw agent` directly.
75
- Use `fill_provider: "command"` only if you need a custom external runner.
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
+ ```
76
91
 
77
92
  ## Runtime Command
78
93
 
@@ -81,6 +96,7 @@ Use `fill_provider: "command"` only if you need a custom external runner.
81
96
  /plashboard setup [openclaw [agent_id]|mock|command <fill_command>]
82
97
  /plashboard quickstart <description>
83
98
  /plashboard doctor [local_url] [https_port] [repo_dir]
99
+ /plashboard fix-permissions [dashboard_output_path]
84
100
  /plashboard web-guide [local_url] [repo_dir]
85
101
  /plashboard expose-guide [local_url] [https_port]
86
102
  /plashboard expose-check [local_url] [https_port]
@@ -100,12 +116,21 @@ Recommended first run:
100
116
  /plashboard onboard "Focus on service health, priorities, blockers, and next actions."
101
117
  ```
102
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
+
103
127
  If `onboard` returns web/exposure warnings:
104
128
 
105
129
  ```text
106
130
  /plashboard web-guide
107
131
  /plashboard expose-guide
108
132
  /plashboard doctor
133
+ /plashboard fix-permissions
109
134
  ```
110
135
 
111
136
  Tailscale helper flow:
@@ -119,3 +144,5 @@ Tailscale helper flow:
119
144
 
120
145
  - The plugin includes an admin skill (`plashboard-admin`) for tool-guided management.
121
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"] }`
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "plashboard",
3
3
  "name": "Plashboard",
4
- "version": "0.1.11",
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.1.11",
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"
@@ -24,6 +24,7 @@ Always use plugin tools:
24
24
  - `plashboard_exposure_check`
25
25
  - `plashboard_web_guide`
26
26
  - `plashboard_doctor`
27
+ - `plashboard_permissions_fix`
27
28
  - `plashboard_init`
28
29
  - `plashboard_quickstart`
29
30
  - `plashboard_template_create`
@@ -59,6 +60,7 @@ Always use plugin tools:
59
60
  - `/plashboard quickstart <description>`
60
61
  - `/plashboard setup [openclaw [agent_id]|mock|command <fill_command>]`
61
62
  - `/plashboard doctor [local_url] [https_port] [repo_dir]`
63
+ - `/plashboard fix-permissions [dashboard_output_path]`
62
64
  - `/plashboard web-guide [local_url] [repo_dir]`
63
65
  - `/plashboard expose-guide [local_url] [https_port]`
64
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'),
@@ -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
  });
@@ -1,28 +1,8 @@
1
- import { spawn } from 'node:child_process';
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 || defaultCommandRunner;
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
+ });