@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/README.md CHANGED
@@ -23,7 +23,9 @@ 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`
28
+ - `session_strategy=persistent`
27
29
  - automatic init on service start
28
30
  - automatic starter template seed when template store is empty
29
31
 
@@ -55,7 +57,9 @@ Add to `openclaw.json`:
55
57
  "session_timeout_seconds": 90,
56
58
  "auto_seed_template": true,
57
59
  "fill_provider": "openclaw",
60
+ "allow_command_fill": false,
58
61
  "openclaw_fill_agent_id": "main",
62
+ "session_strategy": "persistent",
59
63
  "display_profile": {
60
64
  "width_px": 1920,
61
65
  "height_px": 1080,
@@ -72,7 +76,47 @@ Add to `openclaw.json`:
72
76
  ```
73
77
 
74
78
  `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.
79
+ `fill_provider: "command"` requires explicit opt-in with `allow_command_fill: true`.
80
+ Use command mode only if you need a custom external runner.
81
+
82
+ `session_strategy` controls OpenClaw session reuse for fills:
83
+ - `persistent` (default): reuses the agent's normal long-lived session behavior.
84
+ - `ephemeral`: each fill run gets a unique `--session-id`; after the run, plugin performs best-effort cleanup via official CLI API: `openclaw sessions delete --agent <id> --session-id <id>`.
85
+
86
+ Tradeoffs:
87
+ - `persistent` keeps conversational memory/context between runs.
88
+ - `ephemeral` isolates runs and avoids long-lived context drift, but loses cross-run memory and adds one cleanup CLI call per run.
89
+
90
+ Example ephemeral config:
91
+
92
+ ```json
93
+ {
94
+ "plugins": {
95
+ "entries": {
96
+ "plashboard": {
97
+ "enabled": true,
98
+ "config": {
99
+ "fill_provider": "openclaw",
100
+ "openclaw_fill_agent_id": "plashboard-fill",
101
+ "session_strategy": "ephemeral"
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ For production stability, use a dedicated fill agent instead of `main`:
110
+
111
+ ```bash
112
+ openclaw agents add plashboard-fill --non-interactive --workspace /var/lib/openclaw/.openclaw/workspace-plashboard-fill
113
+ ```
114
+
115
+ Then run:
116
+
117
+ ```text
118
+ /plashboard setup openclaw plashboard-fill
119
+ ```
76
120
 
77
121
  ## Runtime Command
78
122
 
@@ -81,6 +125,7 @@ Use `fill_provider: "command"` only if you need a custom external runner.
81
125
  /plashboard setup [openclaw [agent_id]|mock|command <fill_command>]
82
126
  /plashboard quickstart <description>
83
127
  /plashboard doctor [local_url] [https_port] [repo_dir]
128
+ /plashboard fix-permissions [dashboard_output_path]
84
129
  /plashboard web-guide [local_url] [repo_dir]
85
130
  /plashboard expose-guide [local_url] [https_port]
86
131
  /plashboard expose-check [local_url] [https_port]
@@ -100,12 +145,21 @@ Recommended first run:
100
145
  /plashboard onboard "Focus on service health, priorities, blockers, and next actions."
101
146
  ```
102
147
 
148
+ For command mode, explicit opt-in is required:
149
+
150
+ ```text
151
+ /plashboard setup command <fill_command>
152
+ ```
153
+
154
+ This command writes `allow_command_fill=true` with `fill_provider=command`.
155
+
103
156
  If `onboard` returns web/exposure warnings:
104
157
 
105
158
  ```text
106
159
  /plashboard web-guide
107
160
  /plashboard expose-guide
108
161
  /plashboard doctor
162
+ /plashboard fix-permissions
109
163
  ```
110
164
 
111
165
  Tailscale helper flow:
@@ -119,3 +173,5 @@ Tailscale helper flow:
119
173
 
120
174
  - The plugin includes an admin skill (`plashboard-admin`) for tool-guided management.
121
175
  - Trusted publishing (OIDC) is enabled in CI/CD for npm releases.
176
+ - If you see `plugins.allow is empty`, add explicit trust list in OpenClaw config:
177
+ - `"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,8 +17,10 @@
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" },
23
+ "session_strategy": { "type": "string", "enum": ["persistent", "ephemeral"], "default": "persistent" },
22
24
  "python_bin": { "type": "string", "default": "python3" },
23
25
  "writer_script_path": { "type": "string" },
24
26
  "dashboard_output_path": { "type": "string" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhytabest/plashboard",
3
- "version": "0.1.11",
3
+ "version": "1.0.1",
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
@@ -38,6 +38,10 @@ function asObject(value: unknown): Record<string, unknown> {
38
38
  : {};
39
39
  }
40
40
 
41
+ function asSessionStrategy(value: unknown): PlashboardConfig['session_strategy'] {
42
+ return value === 'ephemeral' ? 'ephemeral' : 'persistent';
43
+ }
44
+
41
45
  function resolveDisplayProfile(raw: unknown): DisplayProfile {
42
46
  const data = asObject(raw);
43
47
  return {
@@ -77,8 +81,10 @@ export function resolveConfig(api: unknown): PlashboardConfig {
77
81
  session_timeout_seconds: Math.max(10, Math.floor(asNumber(raw.session_timeout_seconds, 90))),
78
82
  auto_seed_template: asBoolean(raw.auto_seed_template, true),
79
83
  fill_provider: fillProvider,
84
+ allow_command_fill: asBoolean(raw.allow_command_fill, false),
80
85
  fill_command: typeof raw.fill_command === 'string' ? raw.fill_command : undefined,
81
86
  openclaw_fill_agent_id: asString(raw.openclaw_fill_agent_id, 'main'),
87
+ session_strategy: asSessionStrategy(raw.session_strategy),
82
88
  python_bin: asString(raw.python_bin, 'python3'),
83
89
  writer_script_path: asString(raw.writer_script_path, DEFAULT_WRITER_PATH),
84
90
  dashboard_output_path: outputPath,
@@ -39,8 +39,10 @@ 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',
45
+ session_strategy: 'persistent',
44
46
  python_bin: 'python3',
45
47
  writer_script_path: '/tmp/writer.py',
46
48
  dashboard_output_path: '/tmp/dashboard.json',
@@ -67,7 +69,7 @@ function context(): FillRunContext {
67
69
  }
68
70
 
69
71
  describe('createFillRunner', () => {
70
- it('parses openclaw json envelope output', async () => {
72
+ it('persistent mode keeps standard openclaw agent session behavior', async () => {
71
73
  const commandRunner = vi.fn(async (_argv: string[], _options: unknown) => ({
72
74
  stdout: JSON.stringify({
73
75
  result: {
@@ -95,6 +97,108 @@ describe('createFillRunner', () => {
95
97
  expect(argv.slice(0, 2)).toEqual(['openclaw', 'agent']);
96
98
  expect(argv).toContain('--agent');
97
99
  expect(argv).toContain('ops');
100
+ expect(argv).not.toContain('--session-id');
101
+ });
102
+
103
+ it('ephemeral mode uses unique session ids and official cleanup command', async () => {
104
+ const calls: string[][] = [];
105
+ const commandRunner = vi.fn(async (argv: string[], _options: unknown) => {
106
+ calls.push(argv);
107
+ if (argv[0] === 'openclaw' && argv[1] === 'agent') {
108
+ return {
109
+ stdout: '{"values":{"summary":"new summary"}}',
110
+ stderr: '',
111
+ code: 0
112
+ };
113
+ }
114
+ if (argv[0] === 'openclaw' && argv[1] === 'sessions' && argv[2] === 'delete') {
115
+ return {
116
+ stdout: '{"ok":true}',
117
+ stderr: '',
118
+ code: 0
119
+ };
120
+ }
121
+ return {
122
+ stdout: '',
123
+ stderr: `unsupported command: ${argv.join(' ')}`,
124
+ code: 1
125
+ };
126
+ });
127
+
128
+ const runner = createFillRunner(
129
+ config({
130
+ fill_provider: 'openclaw',
131
+ openclaw_fill_agent_id: 'ops',
132
+ session_strategy: 'ephemeral'
133
+ }),
134
+ { commandRunner }
135
+ );
136
+
137
+ await runner.run(context());
138
+ await runner.run(context());
139
+
140
+ const agentCalls = calls.filter((argv) => argv[0] === 'openclaw' && argv[1] === 'agent');
141
+ const cleanupCalls = calls.filter((argv) => argv[0] === 'openclaw' && argv[1] === 'sessions' && argv[2] === 'delete');
142
+
143
+ expect(agentCalls).toHaveLength(2);
144
+ expect(cleanupCalls).toHaveLength(2);
145
+
146
+ const firstSessionFlagIndex = agentCalls[0].indexOf('--session-id');
147
+ const secondSessionFlagIndex = agentCalls[1].indexOf('--session-id');
148
+ expect(firstSessionFlagIndex).toBeGreaterThan(-1);
149
+ expect(secondSessionFlagIndex).toBeGreaterThan(-1);
150
+ const firstSessionId = agentCalls[0][firstSessionFlagIndex + 1];
151
+ const secondSessionId = agentCalls[1][secondSessionFlagIndex + 1];
152
+ expect(firstSessionId).toBeTruthy();
153
+ expect(secondSessionId).toBeTruthy();
154
+ expect(firstSessionId).not.toBe(secondSessionId);
155
+
156
+ for (const [index, cleanupCall] of cleanupCalls.entries()) {
157
+ expect(cleanupCall.slice(0, 3)).toEqual(['openclaw', 'sessions', 'delete']);
158
+ expect(cleanupCall).toContain('--agent');
159
+ expect(cleanupCall).toContain('ops');
160
+ const cleanupSessionFlagIndex = cleanupCall.indexOf('--session-id');
161
+ expect(cleanupSessionFlagIndex).toBeGreaterThan(-1);
162
+ expect(cleanupCall[cleanupSessionFlagIndex + 1]).toBe(index === 0 ? firstSessionId : secondSessionId);
163
+ }
164
+ });
165
+
166
+ it('ephemeral cleanup failure is safe and does not fail fill output', async () => {
167
+ const commandRunner = vi.fn(async (argv: string[], _options: unknown) => {
168
+ if (argv[0] === 'openclaw' && argv[1] === 'agent') {
169
+ return {
170
+ stdout: '{"values":{"summary":"new summary"}}',
171
+ stderr: '',
172
+ code: 0
173
+ };
174
+ }
175
+ if (argv[0] === 'openclaw' && argv[1] === 'sessions' && argv[2] === 'delete') {
176
+ return {
177
+ stdout: '',
178
+ stderr: 'session cleanup failed',
179
+ code: 1
180
+ };
181
+ }
182
+ return {
183
+ stdout: '',
184
+ stderr: `unsupported command: ${argv.join(' ')}`,
185
+ code: 1
186
+ };
187
+ });
188
+
189
+ const runner = createFillRunner(
190
+ config({
191
+ fill_provider: 'openclaw',
192
+ openclaw_fill_agent_id: 'ops',
193
+ session_strategy: 'ephemeral'
194
+ }),
195
+ { commandRunner }
196
+ );
197
+ const response = await runner.run(context());
198
+
199
+ expect(response.values.summary).toBe('new summary');
200
+ expect(commandRunner).toHaveBeenCalledTimes(2);
201
+ expect(commandRunner.mock.calls[1][0].slice(0, 3)).toEqual(['openclaw', 'sessions', 'delete']);
98
202
  });
99
203
 
100
204
  it('parses command runner fenced json output', async () => {
@@ -105,7 +209,7 @@ describe('createFillRunner', () => {
105
209
  }));
106
210
 
107
211
  const runner = createFillRunner(
108
- config({ fill_provider: 'command', fill_command: 'echo "$PLASHBOARD_PROMPT_JSON"' }),
212
+ config({ fill_provider: 'command', allow_command_fill: true, fill_command: 'echo "$PLASHBOARD_PROMPT_JSON"' }),
109
213
  { commandRunner }
110
214
  );
111
215
  const response = await runner.run(context());
@@ -118,4 +222,20 @@ describe('createFillRunner', () => {
118
222
  const options = firstCall[1] as { env?: Record<string, string> };
119
223
  expect(options.env?.PLASHBOARD_PROMPT_JSON).toContain('"template"');
120
224
  });
225
+
226
+ it('rejects command fill when allow_command_fill is false', async () => {
227
+ const commandRunner = vi.fn(async () => ({
228
+ stdout: '{"values":{"summary":"from command"}}',
229
+ stderr: '',
230
+ code: 0
231
+ }));
232
+
233
+ const runner = createFillRunner(
234
+ config({ fill_provider: 'command', allow_command_fill: false, fill_command: 'echo hello' }),
235
+ { commandRunner }
236
+ );
237
+
238
+ await expect(runner.run(context())).rejects.toThrow(/allow_command_fill=true/);
239
+ expect(commandRunner).not.toHaveBeenCalled();
240
+ });
121
241
  });
@@ -1,30 +1,12 @@
1
- import { spawn } from 'node:child_process';
1
+ import { runAndReadStdout, runCommand, 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
 
8
+ let ephemeralSessionCounter = 0;
9
+
28
10
  function buildPromptPayload(context: FillRunContext): Record<string, unknown> {
29
11
  return {
30
12
  instructions: {
@@ -74,70 +56,6 @@ function mockValue(type: string, currentValue: unknown, fieldId: string): unknow
74
56
  return `updated ${fieldId} at ${now}`;
75
57
  }
76
58
 
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
59
  function tryParseJson(input: string): unknown | undefined {
142
60
  try {
143
61
  return JSON.parse(input);
@@ -226,18 +144,23 @@ function parseFillResponse(output: string, source: string): FillResponse {
226
144
  return extracted;
227
145
  }
228
146
 
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();
147
+ function sanitizeSessionToken(input: string): string {
148
+ return input.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'x';
149
+ }
150
+
151
+ function nextEphemeralSessionCounter(): number {
152
+ ephemeralSessionCounter += 1;
153
+ return ephemeralSessionCounter;
154
+ }
155
+
156
+ function buildEphemeralSessionId(agentId: string, context: FillRunContext): string {
157
+ const templateId = sanitizeSessionToken(context.template.id || 'template');
158
+ const agent = sanitizeSessionToken(agentId || 'agent');
159
+ const attempt = Math.max(1, Math.floor(context.attempt || 1));
160
+ const now = Date.now().toString(36);
161
+ const pid = process.pid.toString(36);
162
+ const seq = nextEphemeralSessionCounter().toString(36);
163
+ return `plash-${agent}-${templateId}-a${attempt}-${pid}-${now}-${seq}`;
241
164
  }
242
165
 
243
166
  class MockFillRunner implements FillRunner {
@@ -253,10 +176,13 @@ class MockFillRunner implements FillRunner {
253
176
  class CommandFillRunner implements FillRunner {
254
177
  constructor(
255
178
  private readonly config: PlashboardConfig,
256
- private readonly commandRunner: CommandRunner
179
+ private readonly commandRunner: CommandRunner | null
257
180
  ) {}
258
181
 
259
182
  async run(context: FillRunContext): Promise<FillResponse> {
183
+ if (!this.config.allow_command_fill) {
184
+ throw new Error('fill_provider=command is disabled; set allow_command_fill=true to enable it');
185
+ }
260
186
  if (!this.config.fill_command) {
261
187
  throw new Error('fill_provider=command but fill_command is not configured');
262
188
  }
@@ -280,29 +206,49 @@ class CommandFillRunner implements FillRunner {
280
206
  class OpenClawFillRunner implements FillRunner {
281
207
  constructor(
282
208
  private readonly config: PlashboardConfig,
283
- private readonly commandRunner: CommandRunner
209
+ private readonly commandRunner: CommandRunner | null
284
210
  ) {}
285
211
 
286
212
  async run(context: FillRunContext): Promise<FillResponse> {
287
213
  const agentId = (this.config.openclaw_fill_agent_id || 'main').trim() || 'main';
288
214
  const timeoutSeconds = Math.max(10, Math.floor(this.config.session_timeout_seconds));
289
215
  const message = buildOpenClawMessage(context);
216
+ const ephemeral = this.config.session_strategy === 'ephemeral';
217
+ const sessionId = ephemeral ? buildEphemeralSessionId(agentId, context) : undefined;
218
+ const argv = ['openclaw', 'agent', '--agent', agentId, '--message', message, '--json', '--timeout', String(timeoutSeconds)];
219
+ if (sessionId) {
220
+ argv.push('--session-id', sessionId);
221
+ }
290
222
 
291
- const output = await runAndReadStdout(
292
- this.commandRunner,
293
- ['openclaw', 'agent', '--agent', agentId, '--message', message, '--json', '--timeout', String(timeoutSeconds)],
294
- {
295
- timeoutMs: (timeoutSeconds + 30) * 1000
296
- },
297
- 'openclaw fill'
298
- );
299
-
300
- return parseFillResponse(output, 'openclaw fill');
223
+ try {
224
+ const output = await runAndReadStdout(
225
+ this.commandRunner,
226
+ argv,
227
+ {
228
+ timeoutMs: (timeoutSeconds + 30) * 1000
229
+ },
230
+ 'openclaw fill'
231
+ );
232
+
233
+ return parseFillResponse(output, 'openclaw fill');
234
+ } finally {
235
+ if (ephemeral && sessionId) {
236
+ // Best-effort cleanup through official CLI API; never mutate session files directly.
237
+ await runCommand(
238
+ this.commandRunner,
239
+ ['openclaw', 'sessions', 'delete', '--agent', agentId, '--session-id', sessionId, '--json'],
240
+ {
241
+ timeoutMs: Math.max(5, timeoutSeconds) * 1000
242
+ },
243
+ 'openclaw ephemeral session cleanup'
244
+ );
245
+ }
246
+ }
301
247
  }
302
248
  }
303
249
 
304
250
  export function createFillRunner(config: PlashboardConfig, deps: FillRunnerDeps = {}): FillRunner {
305
- const commandRunner = deps.commandRunner || defaultCommandRunner;
251
+ const commandRunner = deps.commandRunner ?? null;
306
252
  if (config.fill_provider === 'command') {
307
253
  return new CommandFillRunner(config, commandRunner);
308
254
  }