@principles/pd-cli 1.83.0 → 1.85.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/dist/commands/pain-evidence.d.ts +41 -0
- package/dist/commands/pain-evidence.d.ts.map +1 -0
- package/dist/commands/pain-evidence.js +177 -0
- package/dist/commands/pain-evidence.js.map +1 -0
- package/dist/commands/runtime-uat.d.ts +1 -0
- package/dist/commands/runtime-uat.d.ts.map +1 -1
- package/dist/commands/runtime-uat.guard.test.d.ts +2 -0
- package/dist/commands/runtime-uat.guard.test.d.ts.map +1 -0
- package/dist/commands/runtime-uat.guard.test.js +155 -0
- package/dist/commands/runtime-uat.guard.test.js.map +1 -0
- package/dist/commands/runtime-uat.js +29 -3
- package/dist/commands/runtime-uat.js.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/production-workspace-guard.d.ts +77 -0
- package/dist/utils/production-workspace-guard.d.ts.map +1 -0
- package/dist/utils/production-workspace-guard.js +170 -0
- package/dist/utils/production-workspace-guard.js.map +1 -0
- package/dist/utils/production-workspace-guard.test.d.ts +2 -0
- package/dist/utils/production-workspace-guard.test.d.ts.map +1 -0
- package/dist/utils/production-workspace-guard.test.js +95 -0
- package/dist/utils/production-workspace-guard.test.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/pain-evidence.ts +210 -0
- package/src/commands/runtime-uat.guard.test.ts +177 -0
- package/src/commands/runtime-uat.ts +42 -4
- package/src/index.ts +13 -0
- package/src/utils/production-workspace-guard.test.ts +108 -0
- package/src/utils/production-workspace-guard.ts +219 -0
- package/tests/commands/pain-evidence.test.ts +479 -0
- package/tests/commands/pain-retry.test.ts +64 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pd pain evidence command — PEAT-B2
|
|
3
|
+
*
|
|
4
|
+
* Query recent admission/trigger decisions from PD logs.
|
|
5
|
+
* This is a read-only diagnostic command — no side effects.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* pd pain evidence [--workspace <path>] [--limit N] [--json]
|
|
9
|
+
*
|
|
10
|
+
* Shows the most recent TRIGGER_DECISION log entries.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import { resolveWorkspaceDir } from '../resolve-workspace.js';
|
|
16
|
+
|
|
17
|
+
interface EvidenceOptions {
|
|
18
|
+
workspace?: string;
|
|
19
|
+
limit?: number;
|
|
20
|
+
json?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface TriggerDecisionEntry {
|
|
24
|
+
timestamp: string;
|
|
25
|
+
outcome: string;
|
|
26
|
+
sourceKind: string;
|
|
27
|
+
reason: string;
|
|
28
|
+
nextAction: string;
|
|
29
|
+
tool?: string;
|
|
30
|
+
path?: string;
|
|
31
|
+
painId?: string;
|
|
32
|
+
score?: number;
|
|
33
|
+
sessionId?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get the memory/logs directory for a workspace.
|
|
38
|
+
* SystemLogger writes to <workspace>/memory/logs/SYSTEM_YYYY-MM-DD.log.
|
|
39
|
+
*/
|
|
40
|
+
function getLogDir(workspaceDir: string): string {
|
|
41
|
+
return path.join(workspaceDir, 'memory', 'logs');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse TRIGGER_DECISION entries from a log file.
|
|
46
|
+
* Returns entries in reverse chronological order (newest first).
|
|
47
|
+
*/
|
|
48
|
+
function parseTriggerDecisions(logContent: string): TriggerDecisionEntry[] {
|
|
49
|
+
const entries: TriggerDecisionEntry[] = [];
|
|
50
|
+
const lines = logContent.split('\n');
|
|
51
|
+
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
if (!line.includes('TRIGGER_DECISION')) continue;
|
|
54
|
+
|
|
55
|
+
// Extract JSON payload from log line
|
|
56
|
+
const jsonStart = line.indexOf('{');
|
|
57
|
+
if (jsonStart === -1) continue;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const payload = JSON.parse(line.slice(jsonStart));
|
|
61
|
+
// Extract timestamp from log line prefix.
|
|
62
|
+
// SystemLogger produces two formats:
|
|
63
|
+
// - Plain: "2026-06-08 10:16:00 [INFO] ..."
|
|
64
|
+
// - Bracketed ISO: "[2026-06-08T10:16:00.123Z] [INFO] ..."
|
|
65
|
+
const tsMatch = /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/.exec(line)
|
|
66
|
+
?? /^\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\]/.exec(line);
|
|
67
|
+
entries.push({
|
|
68
|
+
timestamp: tsMatch?.[1] ?? new Date().toISOString(),
|
|
69
|
+
outcome: typeof payload.outcome === 'string' ? payload.outcome : 'unknown',
|
|
70
|
+
sourceKind: typeof payload.sourceKind === 'string' ? payload.sourceKind : 'unknown',
|
|
71
|
+
reason: typeof payload.reason === 'string' ? payload.reason : '',
|
|
72
|
+
nextAction: typeof payload.nextAction === 'string' ? payload.nextAction : '',
|
|
73
|
+
tool: typeof payload.tool === 'string' ? payload.tool : undefined,
|
|
74
|
+
path: typeof payload.path === 'string' ? payload.path : undefined,
|
|
75
|
+
painId: typeof payload.painId === 'string' ? payload.painId : undefined,
|
|
76
|
+
score: typeof payload.score === 'number' ? payload.score : undefined,
|
|
77
|
+
sessionId: typeof payload.sessionId === 'string' ? payload.sessionId : undefined,
|
|
78
|
+
});
|
|
79
|
+
} catch (e) {
|
|
80
|
+
// Skip malformed entries — always log for operator visibility (EP-03)
|
|
81
|
+
console.error(`WARN: Malformed TRIGGER_DECISION entry: ${String(e).slice(0, 100)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return entries;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read recent trigger decisions from memory/logs files.
|
|
90
|
+
* SystemLogger writes date-stamped files: SYSTEM_YYYY-MM-DD.log.
|
|
91
|
+
*/
|
|
92
|
+
function readRecentDecisions(logDir: string, limit: number): TriggerDecisionEntry[] {
|
|
93
|
+
if (!fs.existsSync(logDir)) return [];
|
|
94
|
+
|
|
95
|
+
const logFiles = fs.readdirSync(logDir)
|
|
96
|
+
.filter(f => f.startsWith('SYSTEM_') && f.endsWith('.log'))
|
|
97
|
+
.sort()
|
|
98
|
+
.reverse(); // newest first
|
|
99
|
+
|
|
100
|
+
const allEntries: TriggerDecisionEntry[] = [];
|
|
101
|
+
|
|
102
|
+
for (const logFile of logFiles) {
|
|
103
|
+
if (allEntries.length >= limit) break;
|
|
104
|
+
|
|
105
|
+
const filePath = path.join(logDir, logFile);
|
|
106
|
+
try {
|
|
107
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
108
|
+
const entries = parseTriggerDecisions(content);
|
|
109
|
+
// Reverse per-file entries so combined list is newest-first.
|
|
110
|
+
// Log files are already sorted newest-first, but entries within
|
|
111
|
+
// each file are in chronological (oldest-first) order.
|
|
112
|
+
allEntries.push(...entries.reverse());
|
|
113
|
+
} catch (e) {
|
|
114
|
+
// Skip unreadable files — always log for operator visibility (EP-03)
|
|
115
|
+
console.error(`WARN: Could not read log file ${logFile}: ${String(e).slice(0, 100)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return allEntries.slice(0, limit);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getOutcomeEmoji(outcome: string): string {
|
|
123
|
+
switch (outcome) {
|
|
124
|
+
case 'diagnosis_created': return '🔧';
|
|
125
|
+
case 'manual_owner_admitted': return '✅';
|
|
126
|
+
case 'evidence_only': return '📋';
|
|
127
|
+
case 'health_only': return '💚';
|
|
128
|
+
case 'cooldown_skipped': return '⏳';
|
|
129
|
+
case 'owner_confirm_required': return '❓';
|
|
130
|
+
case 'refused': return '🚫';
|
|
131
|
+
default: return '❔';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function truncate(s: string, maxLen: number): string {
|
|
136
|
+
if (s.length <= maxLen) return s;
|
|
137
|
+
return s.slice(0, maxLen - 3) + '...';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function handlePainEvidence(opts: EvidenceOptions): Promise<void> {
|
|
141
|
+
const { workspace, limit: rawLimit, json } = opts;
|
|
142
|
+
const workspaceDir = resolveWorkspaceDir(workspace);
|
|
143
|
+
const logDir = getLogDir(workspaceDir);
|
|
144
|
+
|
|
145
|
+
// Validate limit — fail loud for invalid values
|
|
146
|
+
let effectiveLimit = 20;
|
|
147
|
+
if (rawLimit !== undefined) {
|
|
148
|
+
if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 10000) {
|
|
149
|
+
const err = {
|
|
150
|
+
status: 'refused',
|
|
151
|
+
reason: `invalid_limit: limit must be an integer between 1 and 10000, got ${rawLimit}`,
|
|
152
|
+
nextAction: 'Pass --limit with a valid integer (1-10000)',
|
|
153
|
+
};
|
|
154
|
+
const { reason, nextAction } = err;
|
|
155
|
+
if (json) {
|
|
156
|
+
console.log(JSON.stringify({ count: 0, error: err }));
|
|
157
|
+
} else {
|
|
158
|
+
console.error('Error: ' + reason);
|
|
159
|
+
console.error('Next: ' + nextAction);
|
|
160
|
+
}
|
|
161
|
+
process.exit(1);
|
|
162
|
+
return; // guard: test stubs of process.exit continue execution
|
|
163
|
+
}
|
|
164
|
+
effectiveLimit = rawLimit;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const decisions = readRecentDecisions(logDir, effectiveLimit);
|
|
168
|
+
|
|
169
|
+
if (json) {
|
|
170
|
+
console.log(JSON.stringify({ count: decisions.length, decisions, searchedPath: path.join(logDir, 'SYSTEM_*.log') }, null, 2));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (decisions.length === 0) {
|
|
175
|
+
console.log('No trigger decisions found in logs.');
|
|
176
|
+
console.log(`Searched: ${path.join(logDir, 'SYSTEM_*.log')}`);
|
|
177
|
+
console.log('Tip: Enable painEvidenceAdmission feature flag to start recording trigger decisions.');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log(`Recent Pain Evidence Admission Decisions (${decisions.length}):`);
|
|
182
|
+
console.log('─'.repeat(80));
|
|
183
|
+
|
|
184
|
+
for (const d of decisions) {
|
|
185
|
+
const outcomeEmoji = getOutcomeEmoji(d.outcome);
|
|
186
|
+
console.log(` ${outcomeEmoji} [${d.timestamp}] ${d.outcome}`);
|
|
187
|
+
console.log(` Source: ${d.sourceKind} | Score: ${d.score ?? 'N/A'}`);
|
|
188
|
+
console.log(` Reason: ${truncate(d.reason, 100)}`);
|
|
189
|
+
if (d.nextAction && d.nextAction !== 'none') {
|
|
190
|
+
console.log(` Next: ${truncate(d.nextAction, 80)}`);
|
|
191
|
+
}
|
|
192
|
+
if (d.tool) console.log(` Tool: ${d.tool} | Path: ${d.path ?? 'N/A'}`);
|
|
193
|
+
console.log();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Summary
|
|
197
|
+
const byOutcome = new Map<string, number>();
|
|
198
|
+
for (const d of decisions) {
|
|
199
|
+
byOutcome.set(d.outcome, (byOutcome.get(d.outcome) ?? 0) + 1);
|
|
200
|
+
}
|
|
201
|
+
console.log('─'.repeat(80));
|
|
202
|
+
console.log('Summary:');
|
|
203
|
+
for (const [outcome, count] of byOutcome) {
|
|
204
|
+
console.log(` ${getOutcomeEmoji(outcome)} ${outcome}: ${count}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Export for testing
|
|
209
|
+
// istanbul ignore next
|
|
210
|
+
export { parseTriggerDecisions, getLogDir };
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRI-334: Runtime UAT guard — command-level integration tests.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the guard is wired into the real Commander command
|
|
5
|
+
* and that refused paths do NOT call execFileSync (mutation prevention).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach, afterAll, beforeAll } from 'vitest';
|
|
8
|
+
import { Command } from 'commander';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import * as os from 'os';
|
|
11
|
+
|
|
12
|
+
// ─── Mocks ─────────────────────────────────────────────────────────────────
|
|
13
|
+
const mockExecFileSync = vi.fn();
|
|
14
|
+
vi.mock('child_process', () => ({
|
|
15
|
+
execFileSync: mockExecFileSync,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const { handleRuntimeUat } = await import('./runtime-uat.js');
|
|
19
|
+
const { guardUatWorkspace } = await import('../utils/production-workspace-guard.js');
|
|
20
|
+
|
|
21
|
+
// ─── Test Setup ─────────────────────────────────────────────────────────────
|
|
22
|
+
const capturedStderr: string[] = [];
|
|
23
|
+
const capturedStdout: string[] = [];
|
|
24
|
+
let capturedExitCode: number | null = null;
|
|
25
|
+
|
|
26
|
+
const originalExit = process.exit;
|
|
27
|
+
const originalError = console.error;
|
|
28
|
+
const originalLog = console.log;
|
|
29
|
+
const originalEnv = { ...process.env };
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
process.exit = vi.fn(((code?: number) => {
|
|
33
|
+
capturedExitCode = code ?? 0;
|
|
34
|
+
}) as typeof process.exit);
|
|
35
|
+
console.error = vi.fn((...args: unknown[]) => { capturedStderr.push(args.join(' ')); });
|
|
36
|
+
console.log = vi.fn((...args: unknown[]) => { capturedStdout.push(args.join(' ')); });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
process.exit = originalExit;
|
|
41
|
+
console.error = originalError;
|
|
42
|
+
console.log = originalLog;
|
|
43
|
+
process.env = originalEnv;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
capturedExitCode = null;
|
|
48
|
+
capturedStderr.length = 0;
|
|
49
|
+
capturedStdout.length = 0;
|
|
50
|
+
mockExecFileSync.mockReset();
|
|
51
|
+
process.env = { ...process.env, MINIMAX_CN_API_KEY: 'test-key-for-pri-334' };
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
55
|
+
describe('PRI-334: production workspace refusal', () => {
|
|
56
|
+
it('refuses D: workspace with exit code 1', async () => {
|
|
57
|
+
mockExecFileSync.mockClear();
|
|
58
|
+
await handleRuntimeUat({ workspace: 'D:\\.openclaw\\workspace', count: 1 });
|
|
59
|
+
expect(capturedExitCode).toBe(1);
|
|
60
|
+
expect(mockExecFileSync).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('refuses C: workspace with exit code 1', async () => {
|
|
64
|
+
mockExecFileSync.mockClear();
|
|
65
|
+
await handleRuntimeUat({ workspace: 'C:\\.openclaw\\workspace', count: 1 });
|
|
66
|
+
expect(capturedExitCode).toBe(1);
|
|
67
|
+
expect(mockExecFileSync).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('refuses production workspace subdirectory', async () => {
|
|
71
|
+
mockExecFileSync.mockClear();
|
|
72
|
+
await handleRuntimeUat({ workspace: 'D:\\.openclaw\\workspace\\sub\\path', count: 1 });
|
|
73
|
+
expect(capturedExitCode).toBe(1);
|
|
74
|
+
expect(mockExecFileSync).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('PRI-334: allowed paths (guard level)', () => {
|
|
79
|
+
it('allows temp workspace', () => {
|
|
80
|
+
const tempPath = path.join(os.tmpdir(), 'pd-test-' + Date.now());
|
|
81
|
+
expect(guardUatWorkspace(tempPath, 'test').refused).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('allows workspace-test sibling (ERR-030)', () => {
|
|
85
|
+
expect(guardUatWorkspace('D:\\.openclaw\\workspace-test', 'test').refused).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('allows workspace-backup sibling (ERR-030)', () => {
|
|
89
|
+
expect(guardUatWorkspace('D:\\.openclaw\\workspace-backup', 'test').refused).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('PRI-334: JSON output (EP-04)', () => {
|
|
94
|
+
it('outputs single JSON object with reason and nextAction', async () => {
|
|
95
|
+
mockExecFileSync.mockClear();
|
|
96
|
+
capturedStdout.length = 0;
|
|
97
|
+
await handleRuntimeUat({ workspace: 'D:\\.openclaw\\workspace', count: 1, json: true });
|
|
98
|
+
expect(capturedExitCode).toBe(1);
|
|
99
|
+
expect(mockExecFileSync).not.toHaveBeenCalled();
|
|
100
|
+
|
|
101
|
+
const jsonOutput = capturedStdout.join('');
|
|
102
|
+
const parsed = JSON.parse(jsonOutput);
|
|
103
|
+
expect(parsed).toMatchObject({
|
|
104
|
+
status: 'refused', reason: expect.any(String), nextAction: expect.any(String),
|
|
105
|
+
workspace: expect.any(String), isProduction: true,
|
|
106
|
+
});
|
|
107
|
+
expect(Array.isArray(parsed)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('PRI-334: escape hatch', () => {
|
|
112
|
+
it('warns when allow-production-workspace-for-uat flag is set', async () => {
|
|
113
|
+
mockExecFileSync.mockClear();
|
|
114
|
+
mockExecFileSync.mockImplementation(() => '{"painId":"test","taskId":"t1","runId":"r1","candidateIds":["c1"],"ledgerEntryIds":["l1"]}');
|
|
115
|
+
capturedStderr.length = 0;
|
|
116
|
+
capturedExitCode = null;
|
|
117
|
+
|
|
118
|
+
await handleRuntimeUat({ workspace: 'D:\\.openclaw\\workspace', count: 1, allowProductionWorkspaceForUat: true });
|
|
119
|
+
const stderrText = capturedStderr.join('\n');
|
|
120
|
+
expect(stderrText).toContain('WARNING');
|
|
121
|
+
expect(stderrText).toContain('--allow-production-workspace-for-uat');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('PRI-334: mutation prevention', () => {
|
|
126
|
+
it('does not call execFileSync after guard refusal', async () => {
|
|
127
|
+
mockExecFileSync.mockClear();
|
|
128
|
+
await handleRuntimeUat({ workspace: 'D:\\.openclaw\\workspace', count: 1 });
|
|
129
|
+
expect(mockExecFileSync).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('PRI-334: Commander flag wiring (ERR-063)', () => {
|
|
134
|
+
it('parses --allow-production-workspace-for-uat', () => {
|
|
135
|
+
const program = new Command();
|
|
136
|
+
const rt = program.command('runtime');
|
|
137
|
+
rt.command('uat')
|
|
138
|
+
.option('--allow-production-workspace-for-uat', 'Escape hatch')
|
|
139
|
+
.action(() => { /* noop */ });
|
|
140
|
+
const cmd = rt.commands.find((c) => c.name() === 'uat') as Command;
|
|
141
|
+
const opt = cmd.options.find((o) => o.long === '--allow-production-workspace-for-uat');
|
|
142
|
+
expect(opt).toBeDefined();
|
|
143
|
+
expect(opt?.long).toBe('--allow-production-workspace-for-uat');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('negated form is NOT registered', () => {
|
|
147
|
+
const program = new Command();
|
|
148
|
+
const rt = program.command('runtime');
|
|
149
|
+
rt.command('uat')
|
|
150
|
+
.option('--allow-production-workspace-for-uat', 'Escape hatch')
|
|
151
|
+
.action(() => { /* noop */ });
|
|
152
|
+
const cmd = rt.commands.find((c) => c.name() === 'uat') as Command;
|
|
153
|
+
const noForm = cmd.options.find((o) => o.long === '--no-allow-production-workspace-for-uat');
|
|
154
|
+
expect(noForm).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('PRI-334: shouldExitWithError exit path (EP-04)', () => {
|
|
159
|
+
it('exits 1 and does not print ALL CHECKS PASSED when shouldExitWithError is true', async () => {
|
|
160
|
+
const tempWorkspace = path.join(os.tmpdir(), 'pd-exit-test-' + Date.now());
|
|
161
|
+
mockExecFileSync.mockClear();
|
|
162
|
+
// Make all iterations fail (execFileSync throws) so shouldExitWithError returns true
|
|
163
|
+
mockExecFileSync.mockRejectedValue(new Error('simulated failure'));
|
|
164
|
+
capturedStderr.length = 0;
|
|
165
|
+
capturedExitCode = null;
|
|
166
|
+
|
|
167
|
+
await handleRuntimeUat({
|
|
168
|
+
workspace: tempWorkspace,
|
|
169
|
+
count: 1,
|
|
170
|
+
minSuccessRate: 1.0,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(capturedExitCode).toBe(1);
|
|
174
|
+
const stderrText = capturedStderr.join('\n');
|
|
175
|
+
expect(stderrText).not.toContain('ALL CHECKS PASSED');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -17,6 +17,12 @@ import { execFileSync } from 'child_process';
|
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import * as fs from 'fs';
|
|
20
|
+
import {
|
|
21
|
+
guardUatWorkspace,
|
|
22
|
+
formatGuardRefusal,
|
|
23
|
+
type GuardResult,
|
|
24
|
+
type GuardRefusal,
|
|
25
|
+
} from '../utils/production-workspace-guard.js';
|
|
20
26
|
|
|
21
27
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
22
28
|
|
|
@@ -25,6 +31,7 @@ interface UatOptions {
|
|
|
25
31
|
count?: number;
|
|
26
32
|
minSuccessRate?: number;
|
|
27
33
|
json?: boolean;
|
|
34
|
+
allowProductionWorkspaceForUat?: boolean;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
interface PainRecordResult {
|
|
@@ -73,6 +80,8 @@ export function parseUatArgs(argv: string[]): UatOptions {
|
|
|
73
80
|
} else if (argv[i] === '--min-success-rate') {
|
|
74
81
|
const rate = parseFloat(argv[++i] ?? '1.0');
|
|
75
82
|
args.minSuccessRate = isNaN(rate) ? 1.0 : rate;
|
|
83
|
+
} else if (argv[i] === '--allow-production-workspace-for-uat') {
|
|
84
|
+
args.allowProductionWorkspaceForUat = true;
|
|
76
85
|
}
|
|
77
86
|
}
|
|
78
87
|
return args;
|
|
@@ -153,8 +162,6 @@ interface IterationConfig {
|
|
|
153
162
|
export function runUatIteration(config: IterationConfig): PainRecordResult {
|
|
154
163
|
const { iteration, reason, workspace, timeoutMs = 300_000 } = config;
|
|
155
164
|
const iterStart = Date.now();
|
|
156
|
-
|
|
157
|
-
// eslint-disable-next-line @typescript-eslint/init-declarations
|
|
158
165
|
let recordOutput: string;
|
|
159
166
|
try {
|
|
160
167
|
recordOutput = pd(['pain', 'record', '--reason', reason, '--score', '85', '--source', 'manual', '--json'], workspace, timeoutMs);
|
|
@@ -173,7 +180,6 @@ export function runUatIteration(config: IterationConfig): PainRecordResult {
|
|
|
173
180
|
}
|
|
174
181
|
|
|
175
182
|
const wallTimeMs = Date.now() - iterStart;
|
|
176
|
-
// eslint-disable-next-line @typescript-eslint/init-declarations
|
|
177
183
|
let parsed: Record<string, unknown>;
|
|
178
184
|
try {
|
|
179
185
|
parsed = parseJsonOutput(recordOutput) as Record<string, unknown>;
|
|
@@ -190,7 +196,6 @@ export function runUatIteration(config: IterationConfig): PainRecordResult {
|
|
|
190
196
|
};
|
|
191
197
|
}
|
|
192
198
|
|
|
193
|
-
// eslint-disable-next-line @typescript-eslint/init-declarations
|
|
194
199
|
let auditStatus: string;
|
|
195
200
|
try {
|
|
196
201
|
const auditOut = pd(['candidate', 'audit', '--json'], workspace, 30_000);
|
|
@@ -276,6 +281,37 @@ export async function handleRuntimeUat(opts: UatOptions): Promise<void> {
|
|
|
276
281
|
if (!workspace) {
|
|
277
282
|
console.error('Error: --workspace <path> is required');
|
|
278
283
|
process.exit(1);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// PRI-334: Guard against writing to production workspace
|
|
288
|
+
const guardResult: GuardResult = guardUatWorkspace(workspace, 'pd runtime uat');
|
|
289
|
+
|
|
290
|
+
if (guardResult.refused && !opts.allowProductionWorkspaceForUat) {
|
|
291
|
+
// Fail loud with structured reason and nextAction (EP-03/EP-04)
|
|
292
|
+
const refused: GuardRefusal = guardResult;
|
|
293
|
+
const refusalOutput = formatGuardRefusal(
|
|
294
|
+
refused,
|
|
295
|
+
'pd runtime uat',
|
|
296
|
+
!!opts.json
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
if (opts.json) {
|
|
300
|
+
console.log(refusalOutput);
|
|
301
|
+
} else {
|
|
302
|
+
console.error(refusalOutput);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
process.exit(1);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (guardResult.refused && opts.allowProductionWorkspaceForUat) {
|
|
310
|
+
// Escape hatch used: warn but allow
|
|
311
|
+
console.error('[pd-cli] WARNING: --allow-production-workspace-for-uat is set.');
|
|
312
|
+
console.error(' Test/synthetic data will be written to your production workspace.');
|
|
313
|
+
console.error(' This is not recommended and may pollute your real PD state.');
|
|
314
|
+
console.error('');
|
|
279
315
|
}
|
|
280
316
|
|
|
281
317
|
console.error(`[${new Date().toISOString()}] Runtime V2 Chain UAT — workspace: ${workspace}, count: ${count}`);
|
|
@@ -284,6 +320,7 @@ export async function handleRuntimeUat(opts: UatOptions): Promise<void> {
|
|
|
284
320
|
if (!process.env.MINIMAX_CN_API_KEY) {
|
|
285
321
|
console.error('Error: MINIMAX_CN_API_KEY environment variable not set');
|
|
286
322
|
process.exit(1);
|
|
323
|
+
return;
|
|
287
324
|
}
|
|
288
325
|
|
|
289
326
|
const results: PainRecordResult[] = [];
|
|
@@ -332,6 +369,7 @@ export async function handleRuntimeUat(opts: UatOptions): Promise<void> {
|
|
|
332
369
|
console.error(`FAIL: successRate=${summary.successRate} (threshold: ${minSuccessRate}) ` +
|
|
333
370
|
`ledger=${summary.ledgerConsistencyOk} candidates=${summary.allHaveCandidates} ledger=${summary.allHaveLedger}`);
|
|
334
371
|
process.exit(1);
|
|
372
|
+
return;
|
|
335
373
|
}
|
|
336
374
|
|
|
337
375
|
console.error('');
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { Command } from 'commander';
|
|
10
10
|
import { handlePainRecord } from './commands/pain-record.js';
|
|
11
11
|
import { handlePainRetry } from './commands/pain-retry.js';
|
|
12
|
+
import { handlePainEvidence } from './commands/pain-evidence.js';
|
|
12
13
|
import { handleSamplesList } from './commands/samples-list.js';
|
|
13
14
|
import { handleSamplesReview } from './commands/samples-review.js';
|
|
14
15
|
import { handleEvolutionTasksList } from './commands/evolution-tasks-list.js';
|
|
@@ -98,6 +99,16 @@ painCmd
|
|
|
98
99
|
await handlePainRetry(opts);
|
|
99
100
|
});
|
|
100
101
|
|
|
102
|
+
painCmd
|
|
103
|
+
.command('evidence')
|
|
104
|
+
.description('Show recent pain admission/trigger decisions (PEAT-B2)')
|
|
105
|
+
.option('-w, --workspace <path>', 'Workspace directory')
|
|
106
|
+
.option('-l, --limit <number>', 'Max entries to show (default: 20)', parseInt)
|
|
107
|
+
.option('--json', 'Output raw JSON')
|
|
108
|
+
.action(async (opts) => {
|
|
109
|
+
await handlePainEvidence(opts);
|
|
110
|
+
});
|
|
111
|
+
|
|
101
112
|
const samplesCmd = program
|
|
102
113
|
.command('samples')
|
|
103
114
|
.description('Correction sample management');
|
|
@@ -475,12 +486,14 @@ runtimeCmd
|
|
|
475
486
|
.option('--count <n>', 'Number of iterations (default: 5, max: 50)', parseInt)
|
|
476
487
|
.option('--min-success-rate <rate>', 'Minimum success rate threshold (default: 1.0)', parseFloat)
|
|
477
488
|
.option('--json', 'Output machine-readable JSON summary')
|
|
489
|
+
.option('--allow-production-workspace-for-uat', 'DANGEROUS: Allow UAT to write to production workspace (NOT RECOMMENDED)')
|
|
478
490
|
.action(async (opts) => {
|
|
479
491
|
await handleRuntimeUat({
|
|
480
492
|
workspace: opts.workspace,
|
|
481
493
|
count: opts.count,
|
|
482
494
|
minSuccessRate: opts.minSuccessRate,
|
|
483
495
|
json: opts.json,
|
|
496
|
+
allowProductionWorkspaceForUat: opts.allowProductionWorkspaceForUat,
|
|
484
497
|
});
|
|
485
498
|
});
|
|
486
499
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import {
|
|
5
|
+
isProductionWorkspace,
|
|
6
|
+
guardUatWorkspace,
|
|
7
|
+
getSafeUatWorkspacePath,
|
|
8
|
+
formatGuardRefusal,
|
|
9
|
+
type GuardRefusal,
|
|
10
|
+
} from './production-workspace-guard.js';
|
|
11
|
+
|
|
12
|
+
const PRODUCTION_PATHS = [
|
|
13
|
+
'D:\\.openclaw\\workspace',
|
|
14
|
+
'C:\\.openclaw\\workspace',
|
|
15
|
+
'C:\\Users\\Administrator\\.openclaw\\workspace',
|
|
16
|
+
path.join(os.homedir(), '.openclaw', 'workspace'),
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const SAFE_PATHS = [
|
|
20
|
+
'D:\\.openclaw\\workspace-test',
|
|
21
|
+
'D:\\.openclaw\\workspace-backup',
|
|
22
|
+
path.join(os.tmpdir(), 'pd-uat-workspace'),
|
|
23
|
+
path.join(os.tmpdir(), 'pd-test-any'),
|
|
24
|
+
'C:\\completely-unrelated\\work',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
describe('isProductionWorkspace', () => {
|
|
28
|
+
it.each(PRODUCTION_PATHS)('detects production: %s', (prodPath) => {
|
|
29
|
+
expect(isProductionWorkspace(path.resolve(prodPath))).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
it.each(SAFE_PATHS)('allows safe: %s', (safePath) => {
|
|
32
|
+
expect(isProductionWorkspace(path.resolve(safePath))).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
it('detects descendant', () => {
|
|
35
|
+
expect(isProductionWorkspace(path.resolve('D:\\.openclaw\\workspace\\sub\\child'))).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
it('rejects sibling workspace-test (ERR-030)', () => {
|
|
38
|
+
expect(isProductionWorkspace(path.resolve('D:\\.openclaw\\workspace-test'))).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
it('rejects sibling workspace-backup (ERR-030)', () => {
|
|
41
|
+
expect(isProductionWorkspace(path.resolve('D:\\.openclaw\\workspace-backup'))).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('guardUatWorkspace', () => {
|
|
46
|
+
describe('refused', () => {
|
|
47
|
+
it.each(PRODUCTION_PATHS)('refuses production: %s', (prodPath) => {
|
|
48
|
+
const r = guardUatWorkspace(prodPath, 'test');
|
|
49
|
+
expect(r.refused).toBe(true);
|
|
50
|
+
if (r.refused) {
|
|
51
|
+
expect(r.reason).toContain('UAT/runtime test commands are not allowed');
|
|
52
|
+
expect(r.nextAction).toContain('temporary workspace');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
it('refuses descendant', () => {
|
|
56
|
+
expect(guardUatWorkspace('D:\\.openclaw\\workspace\\subdir', 'test').refused).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe('allowed', () => {
|
|
60
|
+
it.each(SAFE_PATHS)('allows safe: %s', (safePath) => {
|
|
61
|
+
const r = guardUatWorkspace(safePath, 'test');
|
|
62
|
+
expect(r.refused).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
it.each(['D:\\.openclaw\\workspace-test', 'D:\\.openclaw\\workspace-backup'])(
|
|
65
|
+
'allows sibling: %s (ERR-030)', (p) => {
|
|
66
|
+
expect(guardUatWorkspace(p, 'test').refused).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('JSON output (EP-04)', () => {
|
|
72
|
+
it('outputs single object with reason and nextAction', () => {
|
|
73
|
+
const r = guardUatWorkspace('D:\\.openclaw\\workspace', 'test');
|
|
74
|
+
const json = formatGuardRefusal(r as GuardRefusal, 'test', true);
|
|
75
|
+
const parsed = JSON.parse(json);
|
|
76
|
+
expect(parsed).toMatchObject({
|
|
77
|
+
status: 'refused', reason: expect.any(String), nextAction: expect.any(String),
|
|
78
|
+
workspace: expect.any(String), isProduction: true,
|
|
79
|
+
});
|
|
80
|
+
expect(Array.isArray(parsed)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
it('no console prefixes in JSON', () => {
|
|
83
|
+
const r = guardUatWorkspace('D:\\.openclaw\\workspace', 'test');
|
|
84
|
+
const json = formatGuardRefusal(r as GuardRefusal, 'test', true);
|
|
85
|
+
expect(json).not.toContain('[pd-cli]');
|
|
86
|
+
expect(json).not.toContain('ERROR:');
|
|
87
|
+
expect(json.trim().startsWith('{')).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('text output (EP-03)', () => {
|
|
92
|
+
it('includes reason and nextAction', () => {
|
|
93
|
+
const r = guardUatWorkspace('D:\\.openclaw\\workspace', 'test');
|
|
94
|
+
const text = formatGuardRefusal(r as GuardRefusal, 'test', false);
|
|
95
|
+
expect(text).toContain('Reason:');
|
|
96
|
+
expect(text).toContain('Next Action:');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('getSafeUatWorkspacePath', () => {
|
|
101
|
+
it('returns deterministic temp path', () => {
|
|
102
|
+
const p1 = getSafeUatWorkspacePath();
|
|
103
|
+
const p2 = getSafeUatWorkspacePath();
|
|
104
|
+
expect(p1).toBe(p2);
|
|
105
|
+
expect(p1).toContain(os.tmpdir());
|
|
106
|
+
expect(p1).toContain('pd-uat-workspace');
|
|
107
|
+
});
|
|
108
|
+
});
|