@jhytabest/plashboard 0.1.11 → 1.0.1

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