@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 +57 -1
- package/openclaw.plugin.json +3 -1
- package/package.json +3 -2
- package/skills/plashboard-admin/SKILL.md +2 -0
- package/src/command-runner.ts +120 -0
- package/src/config.ts +6 -0
- package/src/fill-runner.test.ts +122 -2
- package/src/fill-runner.ts +57 -111
- package/src/plugin.test.ts +186 -0
- package/src/plugin.ts +397 -120
- package/src/publisher.ts +22 -54
- package/src/runtime.test.ts +53 -2
- package/src/runtime.ts +9 -1
- package/src/types.ts +8 -0
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
|
-
|
|
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"] }`
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "plashboard",
|
|
3
3
|
"name": "Plashboard",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "1.0.0",
|
|
5
5
|
"description": "Template-driven dashboard runtime with scheduled OpenClaw fills and safe publish.",
|
|
6
6
|
"entry": "./src/index.ts",
|
|
7
7
|
"skills": ["./skills/plashboard-admin"],
|
|
@@ -17,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
|
|
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,
|
package/src/fill-runner.test.ts
CHANGED
|
@@ -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('
|
|
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
|
});
|
package/src/fill-runner.ts
CHANGED
|
@@ -1,30 +1,12 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
251
|
+
const commandRunner = deps.commandRunner ?? null;
|
|
306
252
|
if (config.fill_provider === 'command') {
|
|
307
253
|
return new CommandFillRunner(config, commandRunner);
|
|
308
254
|
}
|