@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/src/plugin.ts CHANGED
@@ -1,9 +1,15 @@
1
- import { spawn } from 'node:child_process';
2
1
  import { constants as fsConstants } from 'node:fs';
3
- import { access, stat } from 'node:fs/promises';
2
+ import { access, chmod, stat } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
4
  import type { DisplayProfile, ToolResponse } from './types.js';
5
5
  import { resolveConfig } from './config.js';
6
6
  import { PlashboardRuntime } from './runtime.js';
7
+ import {
8
+ createRuntimeCommandRunner,
9
+ runCommand,
10
+ type CommandRunner,
11
+ type RuntimeCommandWithTimeout
12
+ } from './command-runner.js';
7
13
 
8
14
  type UnknownApi = {
9
15
  registerTool?: (definition: unknown) => void;
@@ -20,24 +26,7 @@ type UnknownApi = {
20
26
  writeConfigFile?: (nextConfig: unknown) => Promise<void>;
21
27
  };
22
28
  system?: {
23
- runCommandWithTimeout?: (
24
- argv: string[],
25
- optionsOrTimeout: number | {
26
- timeoutMs: number;
27
- cwd?: string;
28
- input?: string;
29
- env?: NodeJS.ProcessEnv;
30
- windowsVerbatimArguments?: boolean;
31
- noOutputTimeoutMs?: number;
32
- }
33
- ) => Promise<{
34
- stdout: string;
35
- stderr: string;
36
- code: number | null;
37
- signal?: NodeJS.Signals | null;
38
- killed?: boolean;
39
- termination?: string;
40
- }>;
29
+ runCommandWithTimeout?: RuntimeCommandWithTimeout;
41
30
  };
42
31
  };
43
32
  config?: unknown;
@@ -88,6 +77,7 @@ function asErrorMessage(error: unknown): string {
88
77
 
89
78
  type SetupParams = {
90
79
  fill_provider?: 'mock' | 'command' | 'openclaw';
80
+ allow_command_fill?: boolean;
91
81
  fill_command?: string;
92
82
  openclaw_fill_agent_id?: string;
93
83
  auto_seed_template?: boolean;
@@ -126,16 +116,12 @@ type DoctorParams = ExposureParams & {
126
116
  repo_dir?: string;
127
117
  };
128
118
 
129
- type OnboardParams = DoctorParams & QuickstartParams & {
130
- force_quickstart?: boolean;
119
+ type PermissionsFixParams = {
120
+ dashboard_output_path?: string;
131
121
  };
132
122
 
133
- type CommandExecResult = {
134
- ok: boolean;
135
- stdout: string;
136
- stderr: string;
137
- code: number | null;
138
- error?: string;
123
+ type OnboardParams = DoctorParams & QuickstartParams & {
124
+ force_quickstart?: boolean;
139
125
  };
140
126
 
141
127
  function normalizeLocalUrl(raw: string | undefined): string {
@@ -155,61 +141,99 @@ function normalizePort(raw: number | undefined, fallback: number): number {
155
141
  return Math.max(1, Math.min(65535, value));
156
142
  }
157
143
 
158
- function runCommand(binary: string, args: string[], timeoutMs: number): Promise<CommandExecResult> {
159
- return new Promise((resolve) => {
160
- const child = spawn(binary, args, {
161
- env: process.env
162
- });
144
+ function parseJsonLoose(input: string): unknown | undefined {
145
+ const trimmed = input.trim();
146
+ if (!trimmed) return undefined;
147
+ try {
148
+ return JSON.parse(trimmed);
149
+ } catch {
150
+ // continue
151
+ }
152
+
153
+ const starts = ['{', '['];
154
+ for (const opener of starts) {
155
+ const start = trimmed.indexOf(opener);
156
+ if (start < 0) continue;
157
+ const closer = opener === '{' ? '}' : ']';
158
+ const end = trimmed.lastIndexOf(closer);
159
+ if (end <= start) continue;
160
+ const candidate = trimmed.slice(start, end + 1);
161
+ try {
162
+ return JSON.parse(candidate);
163
+ } catch {
164
+ // continue
165
+ }
166
+ }
167
+ return undefined;
168
+ }
163
169
 
164
- let stdout = '';
165
- let stderr = '';
166
- let settled = false;
170
+ function octalMode(value: number | undefined): string | undefined {
171
+ if (!Number.isFinite(value)) return undefined;
172
+ return `0${(value! & 0o777).toString(8).padStart(3, '0')}`;
173
+ }
167
174
 
168
- const finish = (result: CommandExecResult) => {
169
- if (settled) return;
170
- settled = true;
171
- resolve(result);
175
+ async function listOpenClawAgentIds(commandRunner: CommandRunner | null): Promise<{
176
+ ok: boolean;
177
+ ids: string[];
178
+ error?: string;
179
+ }> {
180
+ const result = await runCommand(
181
+ commandRunner,
182
+ ['openclaw', 'agents', 'list', '--json'],
183
+ 12_000,
184
+ 'openclaw agents list'
185
+ );
186
+ if (!result.ok) {
187
+ return {
188
+ ok: false,
189
+ ids: [],
190
+ error: result.error || result.stderr || `exit ${String(result.code)}`
172
191
  };
192
+ }
173
193
 
174
- const timer = setTimeout(() => {
175
- child.kill('SIGKILL');
176
- finish({
177
- ok: false,
178
- stdout,
179
- stderr,
180
- code: null,
181
- error: `timed out after ${Math.floor(timeoutMs / 1000)}s`
182
- });
183
- }, timeoutMs);
194
+ const parsed = parseJsonLoose(result.stdout);
195
+ if (!Array.isArray(parsed)) {
196
+ return { ok: false, ids: [], error: 'unable to parse openclaw agents list output' };
197
+ }
184
198
 
185
- child.stdout.on('data', (chunk) => {
186
- stdout += String(chunk);
187
- });
188
- child.stderr.on('data', (chunk) => {
189
- stderr += String(chunk);
190
- });
199
+ const ids = parsed
200
+ .map((entry) => asObject(entry))
201
+ .map((entry) => asString(entry.id))
202
+ .filter(Boolean);
203
+ return { ok: true, ids };
204
+ }
191
205
 
192
- child.on('error', (error) => {
193
- clearTimeout(timer);
194
- finish({
195
- ok: false,
196
- stdout,
197
- stderr,
198
- code: null,
199
- error: asString((error as { message?: unknown }).message) || 'spawn failed'
200
- });
201
- });
206
+ async function checkWriterPreflight(
207
+ resolvedConfig: ReturnType<typeof resolveConfig>,
208
+ commandRunner: CommandRunner | null
209
+ ): Promise<{
210
+ ready: boolean;
211
+ errors: string[];
212
+ python_version?: string;
213
+ }> {
214
+ const errors: string[] = [];
202
215
 
203
- child.on('close', (code) => {
204
- clearTimeout(timer);
205
- finish({
206
- ok: code === 0,
207
- stdout: stdout.trim(),
208
- stderr: stderr.trim(),
209
- code
210
- });
211
- });
212
- });
216
+ try {
217
+ await access(resolvedConfig.writer_script_path, fsConstants.R_OK);
218
+ } catch {
219
+ errors.push(`writer script is not readable: ${resolvedConfig.writer_script_path}`);
220
+ }
221
+
222
+ const version = await runCommand(
223
+ commandRunner,
224
+ [resolvedConfig.python_bin, '--version'],
225
+ 8_000,
226
+ 'python runtime preflight'
227
+ );
228
+ if (!version.ok) {
229
+ errors.push(`python runtime check failed: ${version.error || version.stderr || `exit ${String(version.code)}`}`);
230
+ }
231
+
232
+ return {
233
+ ready: errors.length === 0,
234
+ errors,
235
+ python_version: version.ok ? (version.stdout || version.stderr) : undefined
236
+ };
213
237
  }
214
238
 
215
239
  async function buildExposureGuide(resolvedConfig: ReturnType<typeof resolveConfig>, params: ExposureParams = {}) {
@@ -232,7 +256,8 @@ async function buildExposureGuide(resolvedConfig: ReturnType<typeof resolveConfi
232
256
  ],
233
257
  checks: [
234
258
  `test -f ${dashboardPath}`,
235
- `curl -I ${localUrl}`
259
+ `curl -I ${localUrl}`,
260
+ `curl -I ${localUrl.replace(/\/$/, '')}/data/dashboard.json`
236
261
  ],
237
262
  notes: [
238
263
  'plashboard only writes dashboard JSON; your local UI/server must serve it.',
@@ -277,15 +302,21 @@ async function buildWebGuide(resolvedConfig: ReturnType<typeof resolveConfig>, p
277
302
  } satisfies ToolResponse<Record<string, unknown>>;
278
303
  }
279
304
 
280
- async function runExposureCheck(resolvedConfig: ReturnType<typeof resolveConfig>, params: ExposureParams = {}) {
305
+ async function runExposureCheck(
306
+ resolvedConfig: ReturnType<typeof resolveConfig>,
307
+ commandRunner: CommandRunner | null,
308
+ params: ExposureParams = {}
309
+ ) {
281
310
  const localUrl = normalizeLocalUrl(params.local_url);
282
311
  const httpsPort = normalizePort(asNumber(params.tailscale_https_port), 8444);
283
312
  const dashboardPath = (params.dashboard_output_path || resolvedConfig.dashboard_output_path).trim();
313
+ const dataDirPath = dirname(dashboardPath);
284
314
  const errors: string[] = [];
285
315
 
286
316
  let dashboardExists = false;
287
317
  let dashboardSizeBytes: number | undefined;
288
318
  let dashboardMtimeIso: string | undefined;
319
+ let dataDirMode: number | undefined;
289
320
 
290
321
  try {
291
322
  await access(dashboardPath, fsConstants.R_OK);
@@ -296,10 +327,20 @@ async function runExposureCheck(resolvedConfig: ReturnType<typeof resolveConfig>
296
327
  } catch {
297
328
  errors.push(`dashboard file is not readable: ${dashboardPath}`);
298
329
  }
330
+ try {
331
+ const dirInfo = await stat(dataDirPath);
332
+ dataDirMode = dirInfo.mode & 0o777;
333
+ } catch {
334
+ // ignore
335
+ }
299
336
 
300
337
  let localUrlOk = false;
301
338
  let localStatusCode: number | undefined;
302
339
  let localError: string | undefined;
340
+ const localDashboardUrl = new URL('/data/dashboard.json', localUrl).toString();
341
+ let localDashboardOk = false;
342
+ let localDashboardStatusCode: number | undefined;
343
+ let localDashboardError: string | undefined;
303
344
 
304
345
  try {
305
346
  const controller = new AbortController();
@@ -319,17 +360,43 @@ async function runExposureCheck(resolvedConfig: ReturnType<typeof resolveConfig>
319
360
  errors.push(`local dashboard URL is not reachable: ${localUrl} (${localError})`);
320
361
  }
321
362
 
322
- const tailscale = await runCommand('tailscale', ['serve', 'status'], 8000);
363
+ try {
364
+ const controller = new AbortController();
365
+ const timer = setTimeout(() => controller.abort(), 5000);
366
+ const response = await fetch(localDashboardUrl, {
367
+ method: 'GET',
368
+ signal: controller.signal
369
+ });
370
+ clearTimeout(timer);
371
+ localDashboardStatusCode = response.status;
372
+ localDashboardOk = response.status >= 200 && response.status < 300;
373
+ if (!localDashboardOk) {
374
+ errors.push(`dashboard JSON URL returned status ${response.status}: ${localDashboardUrl}`);
375
+ if (response.status === 403) {
376
+ errors.push(`dashboard JSON access denied; check directory permissions for ${dataDirPath}`);
377
+ }
378
+ }
379
+ } catch (error) {
380
+ localDashboardError = asErrorMessage(error);
381
+ errors.push(`dashboard JSON URL is not reachable: ${localDashboardUrl} (${localDashboardError})`);
382
+ }
383
+
384
+ const tailscale = await runCommand(commandRunner, ['tailscale', 'serve', 'status'], 8000, 'tailscale serve status');
323
385
  const tailscaleOutput = `${tailscale.stdout}\n${tailscale.stderr}`.trim();
324
386
  let tailscalePortConfigured = false;
387
+ let tailscaleTargetConfigured = false;
325
388
 
326
389
  if (!tailscale.ok) {
327
390
  errors.push(`tailscale serve status failed: ${tailscale.error || tailscale.stderr || `exit ${tailscale.code}`}`);
328
391
  } else {
329
392
  tailscalePortConfigured = tailscaleOutput.includes(`:${httpsPort}`);
393
+ tailscaleTargetConfigured = tailscaleOutput.includes(`proxy ${localUrl.replace(/\/$/, '')}`);
330
394
  if (!tailscalePortConfigured) {
331
395
  errors.push(`tailscale serve has no mapping for https port ${httpsPort}`);
332
396
  }
397
+ if (!tailscaleTargetConfigured) {
398
+ errors.push(`tailscale serve mapping does not target ${localUrl}`);
399
+ }
333
400
  }
334
401
 
335
402
  return {
@@ -344,15 +411,28 @@ async function runExposureCheck(resolvedConfig: ReturnType<typeof resolveConfig>
344
411
  local_url_ok: localUrlOk,
345
412
  local_status_code: localStatusCode,
346
413
  local_error: localError,
414
+ local_dashboard_url: localDashboardUrl,
415
+ local_dashboard_ok: localDashboardOk,
416
+ local_dashboard_status_code: localDashboardStatusCode,
417
+ local_dashboard_error: localDashboardError,
418
+ data_dir_path: dataDirPath,
419
+ data_dir_mode: dataDirMode,
420
+ data_dir_mode_octal: octalMode(dataDirMode),
347
421
  tailscale_https_port: httpsPort,
348
422
  tailscale_status_ok: tailscale.ok,
349
423
  tailscale_port_configured: tailscalePortConfigured,
424
+ tailscale_target_configured: tailscaleTargetConfigured,
350
425
  tailscale_status_excerpt: tailscaleOutput.slice(0, 1200)
351
426
  }
352
427
  } satisfies ToolResponse<Record<string, unknown>>;
353
428
  }
354
429
 
355
- async function runSetup(api: UnknownApi, resolvedConfig: ReturnType<typeof resolveConfig>, params: SetupParams = {}) {
430
+ async function runSetup(
431
+ api: UnknownApi,
432
+ resolvedConfig: ReturnType<typeof resolveConfig>,
433
+ commandRunner: CommandRunner | null,
434
+ params: SetupParams = {}
435
+ ) {
356
436
  const loadConfig = api.runtime?.config?.loadConfig;
357
437
  const writeConfigFile = api.runtime?.config?.writeConfigFile;
358
438
 
@@ -421,6 +501,13 @@ async function runSetup(api: UnknownApi, resolvedConfig: ReturnType<typeof resol
421
501
  ? currentPluginConfig.auto_seed_template
422
502
  : resolvedConfig.auto_seed_template
423
503
  );
504
+ const selectedAllowCommandFill = (
505
+ typeof params.allow_command_fill === 'boolean'
506
+ ? params.allow_command_fill
507
+ : typeof currentPluginConfig.allow_command_fill === 'boolean'
508
+ ? Boolean(currentPluginConfig.allow_command_fill)
509
+ : resolvedConfig.allow_command_fill
510
+ );
424
511
  const selectedAgentId = (
425
512
  params.openclaw_fill_agent_id
426
513
  || asString(currentPluginConfig.openclaw_fill_agent_id)
@@ -434,12 +521,50 @@ async function runSetup(api: UnknownApi, resolvedConfig: ReturnType<typeof resol
434
521
  errors: ['fill_provider=command requires fill_command']
435
522
  } satisfies ToolResponse<Record<string, unknown>>;
436
523
  }
524
+ if (selectedProvider === 'command' && !selectedAllowCommandFill) {
525
+ return {
526
+ ok: false,
527
+ errors: ['fill_provider=command requires allow_command_fill=true']
528
+ } satisfies ToolResponse<Record<string, unknown>>;
529
+ }
530
+ if (selectedProvider === 'command' && !commandRunner) {
531
+ return {
532
+ ok: false,
533
+ errors: ['fill_provider=command requires runtime command runner support in this OpenClaw build']
534
+ } satisfies ToolResponse<Record<string, unknown>>;
535
+ }
437
536
  if (selectedProvider === 'openclaw' && !selectedAgentId) {
438
537
  return {
439
538
  ok: false,
440
539
  errors: ['fill_provider=openclaw requires openclaw_fill_agent_id']
441
540
  } satisfies ToolResponse<Record<string, unknown>>;
442
541
  }
542
+ if (selectedProvider === 'openclaw') {
543
+ const agents = await listOpenClawAgentIds(commandRunner);
544
+ if (!agents.ok) {
545
+ return {
546
+ ok: false,
547
+ errors: [`unable to validate openclaw_fill_agent_id: ${agents.error || 'unknown error'}`]
548
+ } satisfies ToolResponse<Record<string, unknown>>;
549
+ }
550
+ if (!agents.ids.includes(selectedAgentId)) {
551
+ return {
552
+ ok: false,
553
+ errors: [
554
+ `openclaw_fill_agent_id not found: ${selectedAgentId}`,
555
+ `available agent ids: ${agents.ids.join(', ') || '(none)'}`
556
+ ]
557
+ } satisfies ToolResponse<Record<string, unknown>>;
558
+ }
559
+ }
560
+
561
+ const preflight = await checkWriterPreflight(resolvedConfig, commandRunner);
562
+ if (!preflight.ready) {
563
+ return {
564
+ ok: false,
565
+ errors: preflight.errors
566
+ } satisfies ToolResponse<Record<string, unknown>>;
567
+ }
443
568
 
444
569
  const nextPluginConfig: Record<string, unknown> = {
445
570
  ...currentPluginConfig,
@@ -461,6 +586,7 @@ async function runSetup(api: UnknownApi, resolvedConfig: ReturnType<typeof resol
461
586
  )
462
587
  ),
463
588
  fill_provider: selectedProvider,
589
+ allow_command_fill: selectedAllowCommandFill,
464
590
  auto_seed_template: selectedAutoSeed,
465
591
  display_profile: displayProfile
466
592
  };
@@ -501,6 +627,7 @@ async function runSetup(api: UnknownApi, resolvedConfig: ReturnType<typeof resol
501
627
  restart_required: true,
502
628
  plugin_id: 'plashboard',
503
629
  fill_provider: selectedProvider,
630
+ allow_command_fill: selectedAllowCommandFill,
504
631
  fill_command: selectedProvider === 'command' ? selectedCommand : undefined,
505
632
  openclaw_fill_agent_id: selectedProvider === 'openclaw' ? selectedAgentId : undefined,
506
633
  auto_seed_template: selectedAutoSeed,
@@ -508,6 +635,7 @@ async function runSetup(api: UnknownApi, resolvedConfig: ReturnType<typeof resol
508
635
  scheduler_tick_seconds: nextPluginConfig.scheduler_tick_seconds,
509
636
  session_timeout_seconds: nextPluginConfig.session_timeout_seconds,
510
637
  display_profile: displayProfile,
638
+ python_version: preflight.python_version,
511
639
  next_steps: [
512
640
  'restart OpenClaw gateway',
513
641
  'run /plashboard init'
@@ -516,13 +644,72 @@ async function runSetup(api: UnknownApi, resolvedConfig: ReturnType<typeof resol
516
644
  } satisfies ToolResponse<Record<string, unknown>>;
517
645
  }
518
646
 
647
+ async function runPermissionsFix(
648
+ resolvedConfig: ReturnType<typeof resolveConfig>,
649
+ params: PermissionsFixParams = {}
650
+ ): Promise<ToolResponse<Record<string, unknown>>> {
651
+ const dashboardPath = (params.dashboard_output_path || resolvedConfig.dashboard_output_path).trim();
652
+ const dataDirPath = dirname(dashboardPath);
653
+ const errors: string[] = [];
654
+
655
+ let beforeDataDirMode: number | undefined;
656
+ let beforeDashboardMode: number | undefined;
657
+ let afterDataDirMode: number | undefined;
658
+ let afterDashboardMode: number | undefined;
659
+
660
+ try {
661
+ beforeDataDirMode = (await stat(dataDirPath)).mode & 0o777;
662
+ } catch (error) {
663
+ return {
664
+ ok: false,
665
+ errors: [`data directory is missing or unreadable: ${dataDirPath} (${asErrorMessage(error)})`]
666
+ };
667
+ }
668
+
669
+ try {
670
+ beforeDashboardMode = (await stat(dashboardPath)).mode & 0o777;
671
+ } catch {
672
+ // dashboard file may not exist yet
673
+ }
674
+
675
+ try {
676
+ await chmod(dataDirPath, 0o755);
677
+ afterDataDirMode = (await stat(dataDirPath)).mode & 0o777;
678
+ } catch (error) {
679
+ errors.push(`failed to set directory mode for ${dataDirPath}: ${asErrorMessage(error)}`);
680
+ }
681
+
682
+ try {
683
+ await access(dashboardPath, fsConstants.F_OK);
684
+ await chmod(dashboardPath, 0o644);
685
+ afterDashboardMode = (await stat(dashboardPath)).mode & 0o777;
686
+ } catch {
687
+ // dashboard file may not exist yet
688
+ }
689
+
690
+ return {
691
+ ok: errors.length === 0,
692
+ errors,
693
+ data: {
694
+ dashboard_output_path: dashboardPath,
695
+ data_dir_path: dataDirPath,
696
+ before_data_dir_mode_octal: octalMode(beforeDataDirMode),
697
+ after_data_dir_mode_octal: octalMode(afterDataDirMode),
698
+ before_dashboard_mode_octal: octalMode(beforeDashboardMode),
699
+ after_dashboard_mode_octal: octalMode(afterDashboardMode),
700
+ note: 'This is an explicit compatibility fix for dashboard web servers that read through bind mounts.'
701
+ }
702
+ };
703
+ }
704
+
519
705
  async function runQuickstart(
520
706
  runtime: PlashboardRuntime,
521
707
  resolvedConfig: ReturnType<typeof resolveConfig>,
708
+ commandRunner: CommandRunner | null,
522
709
  params: QuickstartParams = {}
523
710
  ): Promise<ToolResponse<Record<string, unknown>>> {
524
711
  const quickstart = await runtime.quickstart(params);
525
- const exposure = await runExposureCheck(resolvedConfig, {});
712
+ const exposure = await runExposureCheck(resolvedConfig, commandRunner, {});
526
713
  const guide = await buildExposureGuide(resolvedConfig, {});
527
714
  const webGuide = await buildWebGuide(resolvedConfig, {});
528
715
 
@@ -562,19 +749,62 @@ async function runQuickstart(
562
749
  async function runDoctor(
563
750
  runtime: PlashboardRuntime,
564
751
  resolvedConfig: ReturnType<typeof resolveConfig>,
752
+ commandRunner: CommandRunner | null,
565
753
  params: DoctorParams = {}
566
754
  ): Promise<ToolResponse<Record<string, unknown>>> {
567
755
  const status = await runtime.status();
568
- const exposure = await runExposureCheck(resolvedConfig, params);
756
+ const templateList = await runtime.templateList();
757
+ const exposure = await runExposureCheck(resolvedConfig, commandRunner, params);
569
758
  const exposureGuide = await buildExposureGuide(resolvedConfig, params);
570
759
  const webGuide = await buildWebGuide(resolvedConfig, params);
760
+ const writerPreflight = await checkWriterPreflight(resolvedConfig, commandRunner);
571
761
 
572
762
  const issues: string[] = [];
763
+ const warnings: string[] = [];
573
764
  const statusData = status.data;
574
765
  const templateCount = Number(statusData?.template_count ?? 0);
575
766
  const activeTemplateId = statusData?.active_template_id || null;
767
+ const runtimeCommandRunnerAvailable = Boolean(statusData?.capabilities?.runtime_command_runner_available);
768
+ const commandFillAllowed = Boolean(statusData?.capabilities?.command_fill_allowed);
769
+
770
+ let fillProviderReady = resolvedConfig.fill_provider === 'mock'
771
+ ? true
772
+ : resolvedConfig.fill_provider === 'openclaw'
773
+ ? runtimeCommandRunnerAvailable && Boolean((resolvedConfig.openclaw_fill_agent_id || '').trim())
774
+ : runtimeCommandRunnerAvailable && commandFillAllowed && Boolean((resolvedConfig.fill_command || '').trim());
775
+
776
+ let fillAgentIds: string[] = [];
777
+ if (resolvedConfig.fill_provider === 'openclaw') {
778
+ const agents = await listOpenClawAgentIds(commandRunner);
779
+ if (!agents.ok) {
780
+ fillProviderReady = false;
781
+ issues.push(`unable to validate openclaw_fill_agent_id: ${agents.error || 'unknown error'}`);
782
+ } else {
783
+ fillAgentIds = agents.ids;
784
+ if (!agents.ids.includes(resolvedConfig.openclaw_fill_agent_id || 'main')) {
785
+ fillProviderReady = false;
786
+ issues.push(`openclaw_fill_agent_id not found: ${resolvedConfig.openclaw_fill_agent_id || 'main'}`);
787
+ }
788
+ if ((resolvedConfig.openclaw_fill_agent_id || 'main').trim() === 'main') {
789
+ warnings.push('openclaw_fill_agent_id=main can cause session lock contention; prefer a dedicated fill agent.');
790
+ }
791
+ }
792
+ }
793
+
794
+ const writerRunnerReady = writerPreflight.ready;
576
795
 
577
796
  if (!status.ok) issues.push(...status.errors);
797
+ if (!templateList.ok) issues.push(...templateList.errors);
798
+ if (!fillProviderReady) {
799
+ if (resolvedConfig.fill_provider === 'command' && !commandFillAllowed) {
800
+ issues.push('fill_provider=command is disabled; set allow_command_fill=true');
801
+ } else {
802
+ issues.push(`fill provider "${resolvedConfig.fill_provider}" is not ready`);
803
+ }
804
+ }
805
+ if (!writerRunnerReady) {
806
+ issues.push(...writerPreflight.errors);
807
+ }
578
808
  if (templateCount === 0) issues.push('no templates exist; run /plashboard quickstart "<description>"');
579
809
  if (!activeTemplateId) issues.push('no active template; activate one with /plashboard activate <template-id>');
580
810
  if (exposure.data?.dashboard_exists !== true) {
@@ -583,10 +813,38 @@ async function runDoctor(
583
813
  if (exposure.data?.local_url_ok !== true) {
584
814
  issues.push(`local dashboard server is not reachable at ${String(exposure.data?.local_url || 'http://127.0.0.1:18888')}`);
585
815
  }
816
+ if (exposure.data?.local_dashboard_ok !== true) {
817
+ issues.push(`dashboard JSON endpoint is not reachable at ${String(exposure.data?.local_dashboard_url || `${String(exposure.data?.local_url || 'http://127.0.0.1:18888')}/data/dashboard.json`)}`);
818
+ }
586
819
  if (exposure.data?.tailscale_status_ok !== true) {
587
820
  issues.push('tailscale serve status failed');
588
821
  } else if (exposure.data?.tailscale_port_configured !== true) {
589
822
  issues.push(`tailscale serve mapping missing for port ${String(exposure.data?.tailscale_https_port || 8444)}`);
823
+ } else if (exposure.data?.tailscale_target_configured !== true) {
824
+ issues.push(`tailscale serve mapping does not target ${String(exposure.data?.local_url || 'http://127.0.0.1:18888')}`);
825
+ }
826
+ if (Number(exposure.data?.local_dashboard_status_code) === 403) {
827
+ warnings.push(`dashboard JSON returned 403; run /plashboard fix-permissions to apply compatible read modes.`);
828
+ }
829
+
830
+ const templates = Array.isArray(templateList.data?.templates) ? templateList.data?.templates : [];
831
+ const activeTemplate = templates?.find((entry) => asString(entry.id) === activeTemplateId);
832
+ const everyMinutes = asNumber(asObject(activeTemplate?.schedule).every_minutes);
833
+ const mtimeIso = asString(exposure.data?.dashboard_mtime_utc);
834
+ if (everyMinutes && mtimeIso) {
835
+ const ageMs = Date.now() - Date.parse(mtimeIso);
836
+ const maxAgeMs = Math.max(everyMinutes * 2 * 60_000, 10 * 60_000);
837
+ if (Number.isFinite(ageMs) && ageMs > maxAgeMs) {
838
+ issues.push(`dashboard appears stale: last update ${mtimeIso} (age ${Math.floor(ageMs / 60_000)}m)`);
839
+ }
840
+ }
841
+
842
+ if (!runtimeCommandRunnerAvailable) {
843
+ warnings.push('runtime command runner unavailable; fill/publish checks may fail in this OpenClaw build.');
844
+ }
845
+ const dataDirMode = asNumber(exposure.data?.data_dir_mode);
846
+ if (typeof dataDirMode === 'number' && (dataDirMode & 0o005) === 0) {
847
+ warnings.push(`data directory mode ${String(exposure.data?.data_dir_mode_octal || '')} may block containerized web readers.`);
590
848
  }
591
849
 
592
850
  const ready = issues.length === 0;
@@ -595,6 +853,14 @@ async function runDoctor(
595
853
  errors: issues,
596
854
  data: {
597
855
  ready,
856
+ fill_provider_ready: fillProviderReady,
857
+ writer_runner_ready: writerRunnerReady,
858
+ warnings,
859
+ writer_preflight: {
860
+ ready: writerPreflight.ready,
861
+ python_version: writerPreflight.python_version
862
+ },
863
+ fill_agent_ids: fillAgentIds,
598
864
  status: statusData,
599
865
  exposure: exposure.data,
600
866
  exposure_guide: exposureGuide.data,
@@ -605,6 +871,7 @@ async function runDoctor(
605
871
  'run /plashboard quickstart "<description>" if no templates exist',
606
872
  'run /plashboard web-guide and start local UI server',
607
873
  'run /plashboard expose-guide and apply tailscale mapping',
874
+ 'run /plashboard fix-permissions if dashboard JSON returns 403',
608
875
  're-run /plashboard doctor'
609
876
  ]
610
877
  }
@@ -614,6 +881,7 @@ async function runDoctor(
614
881
  async function runOnboard(
615
882
  runtime: PlashboardRuntime,
616
883
  resolvedConfig: ReturnType<typeof resolveConfig>,
884
+ commandRunner: CommandRunner | null,
617
885
  params: OnboardParams = {}
618
886
  ): Promise<ToolResponse<Record<string, unknown>>> {
619
887
  const initResult = await runtime.init();
@@ -625,7 +893,7 @@ async function runOnboard(
625
893
 
626
894
  let quickstartResult: ToolResponse<Record<string, unknown>> | null = null;
627
895
  if (shouldQuickstart) {
628
- quickstartResult = await runQuickstart(runtime, resolvedConfig, {
896
+ quickstartResult = await runQuickstart(runtime, resolvedConfig, commandRunner, {
629
897
  description: params.description,
630
898
  template_id: params.template_id,
631
899
  template_name: params.template_name,
@@ -635,7 +903,7 @@ async function runOnboard(
635
903
  });
636
904
  }
637
905
 
638
- const doctorResult = await runDoctor(runtime, resolvedConfig, {
906
+ const doctorResult = await runDoctor(runtime, resolvedConfig, commandRunner, {
639
907
  local_url: params.local_url,
640
908
  tailscale_https_port: params.tailscale_https_port,
641
909
  dashboard_output_path: params.dashboard_output_path,
@@ -658,36 +926,13 @@ async function runOnboard(
658
926
 
659
927
  export function registerPlashboardPlugin(api: UnknownApi): void {
660
928
  const config = resolveConfig(api);
661
- const runtimeCommand = api.runtime?.system?.runCommandWithTimeout;
662
- const fillCommandRunner = runtimeCommand
663
- ? async (
664
- argv: string[],
665
- optionsOrTimeout: number | {
666
- timeoutMs: number;
667
- cwd?: string;
668
- input?: string;
669
- env?: NodeJS.ProcessEnv;
670
- windowsVerbatimArguments?: boolean;
671
- noOutputTimeoutMs?: number;
672
- }
673
- ) => {
674
- const result = await runtimeCommand(argv, optionsOrTimeout);
675
- return {
676
- stdout: result.stdout,
677
- stderr: result.stderr,
678
- code: result.code,
679
- signal: result.signal,
680
- killed: result.killed,
681
- termination: result.termination
682
- };
683
- }
684
- : undefined;
929
+ const commandRunner = createRuntimeCommandRunner(api.runtime?.system?.runCommandWithTimeout);
685
930
  const runtime = new PlashboardRuntime(config, {
686
931
  info: (...args) => api.logger?.info?.(...args),
687
932
  warn: (...args) => api.logger?.warn?.(...args),
688
933
  error: (...args) => api.logger?.error?.(...args)
689
934
  }, {
690
- commandRunner: fillCommandRunner
935
+ commandRunner
691
936
  });
692
937
 
693
938
  api.registerService?.({
@@ -722,7 +967,7 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
722
967
  additionalProperties: false
723
968
  },
724
969
  execute: async (_toolCallId: unknown, params: OnboardParams = {}) =>
725
- toToolResult(await runOnboard(runtime, config, params))
970
+ toToolResult(await runOnboard(runtime, config, commandRunner, params))
726
971
  });
727
972
 
728
973
  api.registerTool?.({
@@ -756,7 +1001,7 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
756
1001
  additionalProperties: false
757
1002
  },
758
1003
  execute: async (_toolCallId: unknown, params: ExposureParams = {}) =>
759
- toToolResult(await runExposureCheck(config, params))
1004
+ toToolResult(await runExposureCheck(config, commandRunner, params))
760
1005
  });
761
1006
 
762
1007
  api.registerTool?.({
@@ -790,7 +1035,22 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
790
1035
  additionalProperties: false
791
1036
  },
792
1037
  execute: async (_toolCallId: unknown, params: DoctorParams = {}) =>
793
- toToolResult(await runDoctor(runtime, config, params))
1038
+ toToolResult(await runDoctor(runtime, config, commandRunner, params))
1039
+ });
1040
+
1041
+ api.registerTool?.({
1042
+ name: 'plashboard_permissions_fix',
1043
+ description: 'Apply compatibility file modes for dashboard web readers (explicit action).',
1044
+ optional: true,
1045
+ parameters: {
1046
+ type: 'object',
1047
+ properties: {
1048
+ dashboard_output_path: { type: 'string' }
1049
+ },
1050
+ additionalProperties: false
1051
+ },
1052
+ execute: async (_toolCallId: unknown, params: PermissionsFixParams = {}) =>
1053
+ toToolResult(await runPermissionsFix(config, params))
794
1054
  });
795
1055
 
796
1056
  api.registerTool?.({
@@ -801,6 +1061,7 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
801
1061
  type: 'object',
802
1062
  properties: {
803
1063
  fill_provider: { type: 'string', enum: ['mock', 'command', 'openclaw'] },
1064
+ allow_command_fill: { type: 'boolean' },
804
1065
  fill_command: { type: 'string' },
805
1066
  openclaw_fill_agent_id: { type: 'string' },
806
1067
  auto_seed_template: { type: 'boolean' },
@@ -817,7 +1078,7 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
817
1078
  additionalProperties: false
818
1079
  },
819
1080
  execute: async (_toolCallId: unknown, params: SetupParams = {}) =>
820
- toToolResult(await runSetup(api, config, params))
1081
+ toToolResult(await runSetup(api, config, commandRunner, params))
821
1082
  });
822
1083
 
823
1084
  api.registerTool?.({
@@ -849,7 +1110,7 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
849
1110
  additionalProperties: false
850
1111
  },
851
1112
  execute: async (_toolCallId: unknown, params: QuickstartParams = {}) =>
852
- toToolResult(await runQuickstart(runtime, config, params))
1113
+ toToolResult(await runQuickstart(runtime, config, commandRunner, params))
853
1114
  });
854
1115
 
855
1116
  api.registerTool?.({
@@ -1049,7 +1310,7 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
1049
1310
  const localUrl = rest.find((token) => token.startsWith('http://') || token.startsWith('https://'));
1050
1311
  const portToken = rest.find((token) => /^[0-9]+$/.test(token));
1051
1312
  return toCommandResult(
1052
- await runExposureCheck(config, {
1313
+ await runExposureCheck(config, commandRunner, {
1053
1314
  local_url: localUrl,
1054
1315
  tailscale_https_port: portToken ? Number(portToken) : undefined
1055
1316
  })
@@ -1070,13 +1331,20 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
1070
1331
  const portToken = rest.find((token) => /^[0-9]+$/.test(token));
1071
1332
  const repoDir = rest.find((token) => token.startsWith('/'));
1072
1333
  return toCommandResult(
1073
- await runDoctor(runtime, config, {
1334
+ await runDoctor(runtime, config, commandRunner, {
1074
1335
  local_url: localUrl,
1075
1336
  tailscale_https_port: portToken ? Number(portToken) : undefined,
1076
1337
  repo_dir: repoDir
1077
1338
  })
1078
1339
  );
1079
1340
  }
1341
+ if (cmd === 'fix-permissions') {
1342
+ return toCommandResult(
1343
+ await runPermissionsFix(config, {
1344
+ dashboard_output_path: rest[0]
1345
+ })
1346
+ );
1347
+ }
1080
1348
  if (cmd === 'onboard') {
1081
1349
  const localUrl = rest.find((token) => token.startsWith('http://') || token.startsWith('https://'));
1082
1350
  const portToken = rest.find((token) => /^[0-9]+$/.test(token));
@@ -1084,7 +1352,7 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
1084
1352
  const descriptionTokens = rest.filter((token) => token !== localUrl && token !== portToken && token !== repoDir);
1085
1353
  const description = descriptionTokens.join(' ').trim() || undefined;
1086
1354
  return toCommandResult(
1087
- await runOnboard(runtime, config, {
1355
+ await runOnboard(runtime, config, commandRunner, {
1088
1356
  description,
1089
1357
  local_url: localUrl,
1090
1358
  tailscale_https_port: portToken ? Number(portToken) : undefined,
@@ -1098,8 +1366,9 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
1098
1366
  const fillCommand = fillProvider === 'command' ? rest.slice(1).join(' ').trim() || undefined : undefined;
1099
1367
  const fillAgentId = fillProvider === 'openclaw' ? (rest[1] || '').trim() || undefined : undefined;
1100
1368
  return toCommandResult(
1101
- await runSetup(api, config, {
1369
+ await runSetup(api, config, commandRunner, {
1102
1370
  fill_provider: fillProvider,
1371
+ allow_command_fill: fillProvider === 'command' ? true : undefined,
1103
1372
  fill_command: fillCommand,
1104
1373
  openclaw_fill_agent_id: fillAgentId
1105
1374
  })
@@ -1107,7 +1376,7 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
1107
1376
  }
1108
1377
  if (cmd === 'quickstart') {
1109
1378
  const description = rest.join(' ').trim() || undefined;
1110
- return toCommandResult(await runQuickstart(runtime, config, { description }));
1379
+ return toCommandResult(await runQuickstart(runtime, config, commandRunner, { description }));
1111
1380
  }
1112
1381
  if (cmd === 'init') return toCommandResult(await runtime.init());
1113
1382
  if (cmd === 'status') return toCommandResult(await runtime.status());
@@ -1133,7 +1402,7 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
1133
1402
  return toCommandResult({
1134
1403
  ok: false,
1135
1404
  errors: [
1136
- 'unknown command. supported: onboard <description> [local_url] [https_port] [repo_dir], setup [openclaw [agent_id]|mock|command <fill_command>], quickstart <description>, doctor [local_url] [https_port] [repo_dir], web-guide [local_url] [repo_dir], expose-guide [local_url] [https_port], expose-check [local_url] [https_port], init, status, list, activate <id>, delete <id>, copy <src> <new-id> [new-name] [activate], run <id>, set-display <width> <height> <top> <bottom>'
1405
+ 'unknown command. supported: onboard <description> [local_url] [https_port] [repo_dir], setup [openclaw [agent_id]|mock|command <fill_command>], quickstart <description>, doctor [local_url] [https_port] [repo_dir], fix-permissions [dashboard_output_path], web-guide [local_url] [repo_dir], expose-guide [local_url] [https_port], expose-check [local_url] [https_port], init, status, list, activate <id>, delete <id>, copy <src> <new-id> [new-name] [activate], run <id>, set-display <width> <height> <top> <bottom>'
1137
1406
  ]
1138
1407
  });
1139
1408
  }