@principles/pd-cli 1.134.0 → 1.135.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/dist/commands/console.d.ts.map +1 -1
- package/dist/commands/console.js +2 -0
- package/dist/commands/console.js.map +1 -1
- package/dist/commands/legacy-cleanup.d.ts.map +1 -1
- package/dist/commands/legacy-cleanup.js +19 -2
- package/dist/commands/legacy-cleanup.js.map +1 -1
- package/dist/commands/pain-evidence.d.ts +3 -1
- package/dist/commands/pain-evidence.d.ts.map +1 -1
- package/dist/commands/pain-evidence.js +12 -3
- package/dist/commands/pain-evidence.js.map +1 -1
- package/dist/commands/rulecode.d.ts +13 -0
- package/dist/commands/rulecode.d.ts.map +1 -1
- package/dist/commands/rulecode.js +23 -2
- package/dist/commands/rulecode.js.map +1 -1
- package/dist/resolve-workspace.d.ts.map +1 -1
- package/dist/resolve-workspace.js +33 -12
- package/dist/resolve-workspace.js.map +1 -1
- package/dist/services/console-launcher.d.ts +14 -1
- package/dist/services/console-launcher.d.ts.map +1 -1
- package/dist/services/console-launcher.js +45 -3
- package/dist/services/console-launcher.js.map +1 -1
- package/dist/services/pd-config-loader.d.ts.map +1 -1
- package/dist/services/pd-config-loader.js +34 -1
- package/dist/services/pd-config-loader.js.map +1 -1
- package/dist/services/quality-scorecard/strong-model-gate.d.ts +19 -0
- package/dist/services/quality-scorecard/strong-model-gate.d.ts.map +1 -1
- package/dist/services/quality-scorecard/strong-model-gate.js +44 -2
- package/dist/services/quality-scorecard/strong-model-gate.js.map +1 -1
- package/dist/utils/path-security.d.ts +60 -0
- package/dist/utils/path-security.d.ts.map +1 -0
- package/dist/utils/path-security.js +90 -0
- package/dist/utils/path-security.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/console.ts +1 -0
- package/src/commands/legacy-cleanup.ts +19 -2
- package/src/commands/pain-evidence.ts +11 -3
- package/src/commands/rulecode.ts +25 -2
- package/src/resolve-workspace.ts +41 -17
- package/src/services/console-launcher.ts +51 -3
- package/src/services/pd-config-loader.ts +35 -1
- package/src/services/quality-scorecard/strong-model-gate.ts +44 -2
- package/src/utils/path-security.ts +96 -0
- package/tests/commands/console-open.test.ts +213 -35
- package/tests/commands/legacy-cleanup.test.ts +148 -0
- package/tests/commands/pain-evidence.test.ts +37 -0
- package/tests/commands/pri-393-runtime-config-unification.test.ts +5 -1
- package/tests/commands/product-path-regression.test.ts +9 -4
- package/tests/commands/rulecode.test.ts +135 -0
- package/tests/commands/runtime-diagnostics-export.test.ts +6 -2
- package/tests/resolve-workspace.test.ts +21 -0
- package/tests/services/console-launcher.test.ts +114 -0
- package/tests/services/pd-config-loader.test.ts +8 -1
- package/tests/services/quality-scorecard/strong-model-gate.test.ts +133 -0
- package/tests/utils/path-security.test.ts +180 -0
|
@@ -496,14 +496,22 @@ describe('CLI command wiring (pd console open)', () => {
|
|
|
496
496
|
workspaceRoot = path.resolve(__dirname, '../../../..');
|
|
497
497
|
cliPath = path.join(workspaceRoot, 'packages', 'pd-cli', 'dist', 'index.js');
|
|
498
498
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-console-open-test-'));
|
|
499
|
-
//
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
499
|
+
// Isolated fake HOME so the tests never read from — or rmSync-destroy — a
|
|
500
|
+
// real ~/.openclaw install on developer machines. runPd() points the CLI
|
|
501
|
+
// child at this home via __PD_CONSOLE_TEST_FAKE_HOME.
|
|
502
|
+
const fakeHome = path.join(tmp, 'home');
|
|
503
|
+
process.env.__PD_CONSOLE_TEST_FAKE_HOME = fakeHome;
|
|
504
|
+
// Fake a console install: dir + ESM package.json + dist/server.js with a
|
|
505
|
+
// minimal HTTP server. The package.json "type": "module" makes the fake
|
|
506
|
+
// server's module system deterministic regardless of whether a real
|
|
507
|
+
// extension-root package.json exists above the console dir.
|
|
508
|
+
const consoleDir = path.join(fakeHome, '.openclaw', 'extensions', 'principles-disciple', 'console');
|
|
503
509
|
fs.mkdirSync(path.join(consoleDir, 'dist', 'web'), { recursive: true });
|
|
510
|
+
fs.writeFileSync(path.join(consoleDir, 'package.json'), JSON.stringify({ name: 'fake-pd-console', version: '0.0.0', type: 'module' }, null, 2));
|
|
511
|
+
// EP-06 regression guard: dist/web/index.html must exist (PR #1169 fix)
|
|
504
512
|
fs.writeFileSync(path.join(consoleDir, 'dist', 'web', 'index.html'), '<!DOCTYPE html><html></html>');
|
|
505
513
|
fs.writeFileSync(path.join(consoleDir, 'dist', 'server.js'), `
|
|
506
|
-
|
|
514
|
+
import http from 'node:http';
|
|
507
515
|
const args = process.argv.slice(2);
|
|
508
516
|
const portIdx = args.indexOf('--port');
|
|
509
517
|
const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) : 3100;
|
|
@@ -525,11 +533,8 @@ describe('CLI command wiring (pd console open)', () => {
|
|
|
525
533
|
});
|
|
526
534
|
|
|
527
535
|
afterEach(() => {
|
|
536
|
+
delete process.env.__PD_CONSOLE_TEST_FAKE_HOME;
|
|
528
537
|
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
529
|
-
try {
|
|
530
|
-
const consoleDir = path.join(os.homedir(), '.openclaw', 'extensions', 'principles-disciple', 'console');
|
|
531
|
-
fs.rmSync(consoleDir, { recursive: true, force: true });
|
|
532
|
-
} catch { /* ignore */ }
|
|
533
538
|
});
|
|
534
539
|
|
|
535
540
|
it('console open subcommand is registered (pd console open --help)', () => {
|
|
@@ -572,6 +577,58 @@ describe('CLI command wiring (pd console open)', () => {
|
|
|
572
577
|
expect(parsed).toHaveProperty('browserOpened');
|
|
573
578
|
}, 10_000);
|
|
574
579
|
|
|
580
|
+
it('pd console open --json fresh spawn includes a positive integer serverPid (PRI-526)', async () => {
|
|
581
|
+
// Async stream-parse + guaranteed teardown. execFileSync patterns are
|
|
582
|
+
// unreliable here on Windows: the CLI keeps running after printing JSON
|
|
583
|
+
// (fresh spawn) or may unexpectedly take another branch, and killing via
|
|
584
|
+
// timeout can leave the grandchild server holding pipe handles.
|
|
585
|
+
let run: CliJsonRun | undefined;
|
|
586
|
+
try {
|
|
587
|
+
run = await runPdUntilJson(
|
|
588
|
+
['console', 'open', '--workspace', tmp, '--port', '49390', '--json', '--no-browser'],
|
|
589
|
+
workspaceRoot,
|
|
590
|
+
);
|
|
591
|
+
if (!isRecord(run.parsed)) throw new Error('CLI JSON output was not an object');
|
|
592
|
+
expect(run.parsed.status).toBe('started');
|
|
593
|
+
expect(run.parsed.reused).toBe(false);
|
|
594
|
+
const serverPid = run.parsed.serverPid;
|
|
595
|
+
expect(typeof serverPid === 'number' && Number.isInteger(serverPid) && serverPid > 0).toBe(true);
|
|
596
|
+
} finally {
|
|
597
|
+
await teardownCliTree(run);
|
|
598
|
+
}
|
|
599
|
+
}, 20_000);
|
|
600
|
+
|
|
601
|
+
it('pd console open --json reused path does NOT include serverPid (PRI-526)', async () => {
|
|
602
|
+
// Stand up a fake healthy console in-process, then point the CLI at its
|
|
603
|
+
// port: planConsoleLaunch probes /api/health → 200 → reused, no spawn.
|
|
604
|
+
const server = http.createServer((req, res) => {
|
|
605
|
+
if (req.url === '/api/health') {
|
|
606
|
+
res.statusCode = 200;
|
|
607
|
+
res.end(JSON.stringify({ success: true }));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
res.statusCode = 404;
|
|
611
|
+
res.end();
|
|
612
|
+
});
|
|
613
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
|
|
614
|
+
const addr = server.address();
|
|
615
|
+
if (typeof addr !== 'object' || !addr) throw new Error('no addr');
|
|
616
|
+
let run: CliJsonRun | undefined;
|
|
617
|
+
try {
|
|
618
|
+
run = await runPdUntilJson(
|
|
619
|
+
['console', 'open', '--workspace', tmp, '--port', String(addr.port), '--json', '--no-browser'],
|
|
620
|
+
workspaceRoot,
|
|
621
|
+
);
|
|
622
|
+
if (!isRecord(run.parsed)) throw new Error('CLI JSON output was not an object');
|
|
623
|
+
expect(run.parsed.status).toBe('reused');
|
|
624
|
+
expect(run.parsed.reused).toBe(true);
|
|
625
|
+
expect(run.parsed).not.toHaveProperty('serverPid');
|
|
626
|
+
} finally {
|
|
627
|
+
await teardownCliTree(run);
|
|
628
|
+
server.close();
|
|
629
|
+
}
|
|
630
|
+
}, 20_000);
|
|
631
|
+
|
|
575
632
|
it('pd console open --port 99999 --json returns a structured failure (invalid port)', () => {
|
|
576
633
|
const out = runPd(['console', 'open', '--workspace', tmp, '--port', '99999', '--json', '--no-browser'], workspaceRoot);
|
|
577
634
|
const parsed = JSON.parse(out);
|
|
@@ -669,11 +726,22 @@ describe('CLI command wiring (pd console open)', () => {
|
|
|
669
726
|
|
|
670
727
|
it('sets browserOpened: false when browser fails to open', async () => {
|
|
671
728
|
const { handleConsoleOpen } = await import('../../src/commands/console.js');
|
|
672
|
-
|
|
729
|
+
|
|
673
730
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
|
674
731
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
675
732
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
676
733
|
|
|
734
|
+
// The in-process handler resolves the console dir via getConsoleDir(),
|
|
735
|
+
// which reads HOME/USERPROFILE directly. Point them at the isolated
|
|
736
|
+
// fake home — a clean CI runner has no real ~/.openclaw console, and
|
|
737
|
+
// relying on one (as this test implicitly did before) makes the result
|
|
738
|
+
// machine-dependent.
|
|
739
|
+
const fakeHome = process.env.__PD_CONSOLE_TEST_FAKE_HOME ?? '';
|
|
740
|
+
const savedHome = process.env.HOME;
|
|
741
|
+
const savedUserProfile = process.env.USERPROFILE;
|
|
742
|
+
process.env.HOME = fakeHome;
|
|
743
|
+
process.env.USERPROFILE = fakeHome;
|
|
744
|
+
|
|
677
745
|
const mockChild = new EventEmitter() as any;
|
|
678
746
|
mockChild.unref = vi.fn();
|
|
679
747
|
let spawnCalled = false;
|
|
@@ -701,23 +769,29 @@ describe('CLI command wiring (pd console open)', () => {
|
|
|
701
769
|
};
|
|
702
770
|
};
|
|
703
771
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
772
|
+
try {
|
|
773
|
+
await handleConsoleOpen({
|
|
774
|
+
workspace: tmp,
|
|
775
|
+
json: false,
|
|
776
|
+
});
|
|
708
777
|
|
|
709
|
-
|
|
778
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
710
779
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
expect(loggedOutput).not.toContain('Browser opened');
|
|
716
|
-
expect(loggedOutput).toContain('Open http://127.0.0.1:3100 in your browser');
|
|
780
|
+
const loggedOutput = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
781
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
782
|
+
expect(spawnCalled).toBe(true);
|
|
717
783
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
784
|
+
expect(loggedOutput).not.toContain('Browser opened');
|
|
785
|
+
expect(loggedOutput).toContain('Open http://127.0.0.1:3100 in your browser');
|
|
786
|
+
} finally {
|
|
787
|
+
if (savedHome === undefined) delete process.env.HOME;
|
|
788
|
+
else process.env.HOME = savedHome;
|
|
789
|
+
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
|
790
|
+
else process.env.USERPROFILE = savedUserProfile;
|
|
791
|
+
exitSpy.mockRestore();
|
|
792
|
+
logSpy.mockRestore();
|
|
793
|
+
errorSpy.mockRestore();
|
|
794
|
+
}
|
|
721
795
|
});
|
|
722
796
|
});
|
|
723
797
|
|
|
@@ -755,6 +829,13 @@ describe('CLI command wiring (pd console open)', () => {
|
|
|
755
829
|
it('[::1] is accepted and normalized to ::1 (not refused)', () => {
|
|
756
830
|
// May spawn a long-lived server, use timeout.
|
|
757
831
|
const out = runPd(['console', 'open', '--workspace', tmp, '--host', '[::1]', '--json', '--no-browser'], workspaceRoot, 8_000);
|
|
832
|
+
if (out.trim() === '') {
|
|
833
|
+
// The CLI was still in its 15s ready-poll when execFileSync timed out.
|
|
834
|
+
// Known environment limitation: on Windows hosts where IPv6 loopback
|
|
835
|
+
// connections are refused (EACCES), the health probe on ::1 never
|
|
836
|
+
// succeeds. CI (Linux) is unaffected.
|
|
837
|
+
throw new Error('CLI produced no JSON within 8s — ::1 health probe never succeeded (IPv6 loopback may be blocked on this machine)');
|
|
838
|
+
}
|
|
758
839
|
const parsed = JSON.parse(out);
|
|
759
840
|
// Should NOT be refused — [::1] is loopback
|
|
760
841
|
expect(parsed.status).not.toBe('refused');
|
|
@@ -767,8 +848,8 @@ describe('CLI command wiring (pd console open)', () => {
|
|
|
767
848
|
it('returns console_web_ui_missing when dist/web/index.html does not exist', () => {
|
|
768
849
|
// beforeEach creates the fake console dir WITH dist/web/index.html.
|
|
769
850
|
// Remove it to simulate a corrupted install (the PR #1169 regression).
|
|
770
|
-
const
|
|
771
|
-
const webIndex = path.join(
|
|
851
|
+
const fakeHome = process.env.__PD_CONSOLE_TEST_FAKE_HOME ?? '';
|
|
852
|
+
const webIndex = path.join(fakeHome, '.openclaw', 'extensions', 'principles-disciple', 'console', 'dist', 'web', 'index.html');
|
|
772
853
|
fs.rmSync(webIndex, { force: true });
|
|
773
854
|
|
|
774
855
|
const out = runPd(['console', 'open', '--workspace', tmp, '--json', '--no-browser'], workspaceRoot, 5_000);
|
|
@@ -782,18 +863,10 @@ describe('CLI command wiring (pd console open)', () => {
|
|
|
782
863
|
|
|
783
864
|
function runPd(args: string[], cwd: string, timeoutMs?: number): string {
|
|
784
865
|
try {
|
|
785
|
-
const env: Record<string, string> = { ...process.env };
|
|
786
|
-
if (!args.includes('--workspace') && !args.includes('--help') && !args.includes('-h')) {
|
|
787
|
-
env.USERPROFILE = '/nonexistent';
|
|
788
|
-
env.HOME = '/nonexistent';
|
|
789
|
-
env.HOMEPATH = '/nonexistent';
|
|
790
|
-
env.HOMEDRIVE = '/nonexistent';
|
|
791
|
-
delete env.PD_WORKSPACE_DIR;
|
|
792
|
-
}
|
|
793
866
|
return execFileSync('node', [getBuiltPdCliPath(), ...args], {
|
|
794
867
|
encoding: 'utf8',
|
|
795
868
|
cwd,
|
|
796
|
-
env,
|
|
869
|
+
env: buildChildEnv(),
|
|
797
870
|
timeout: timeoutMs,
|
|
798
871
|
});
|
|
799
872
|
} catch (err: unknown) {
|
|
@@ -806,3 +879,108 @@ function runPd(args: string[], cwd: string, timeoutMs?: number): string {
|
|
|
806
879
|
throw err;
|
|
807
880
|
}
|
|
808
881
|
}
|
|
882
|
+
|
|
883
|
+
/** Env for spawned CLI processes: isolated fake HOME, no workspace env leaks. */
|
|
884
|
+
function buildChildEnv(): Record<string, string> {
|
|
885
|
+
const env = { ...process.env } as Record<string, string>;
|
|
886
|
+
const fakeHome = process.env.__PD_CONSOLE_TEST_FAKE_HOME;
|
|
887
|
+
if (fakeHome) {
|
|
888
|
+
env.USERPROFILE = fakeHome;
|
|
889
|
+
env.HOME = fakeHome;
|
|
890
|
+
delete env.HOMEPATH;
|
|
891
|
+
delete env.HOMEDRIVE;
|
|
892
|
+
delete env.PD_WORKSPACE_DIR;
|
|
893
|
+
}
|
|
894
|
+
return env;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** Runtime type guard for unknown CLI JSON output (rc-1/rc-2). */
|
|
898
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
899
|
+
return typeof value === 'object' && value !== null;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ─── Async CLI runner with guaranteed teardown (PRI-526 tests) ───────────────
|
|
903
|
+
|
|
904
|
+
interface CliJsonRun {
|
|
905
|
+
parsed: unknown;
|
|
906
|
+
child: childProcessModule.ChildProcess;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Spawn the CLI and resolve as soon as stdout contains one parseable JSON
|
|
911
|
+
* object. Fails fast (bounded timeout / early exit) with the collected
|
|
912
|
+
* stdout in the error message. The caller MUST call teardownCliTree(run)
|
|
913
|
+
* in a finally block — the CLI process may still be alive after the JSON
|
|
914
|
+
* is emitted (fresh-spawn mode keeps the console server attached).
|
|
915
|
+
*/
|
|
916
|
+
async function runPdUntilJson(args: string[], cwd: string, timeoutMs = 12_000): Promise<CliJsonRun> {
|
|
917
|
+
const child = childProcessModule.spawn('node', [getBuiltPdCliPath(), ...args], {
|
|
918
|
+
cwd,
|
|
919
|
+
env: buildChildEnv(),
|
|
920
|
+
});
|
|
921
|
+
let stdout = '';
|
|
922
|
+
child.stdout?.on('data', (d: Buffer) => { stdout += d.toString('utf8'); });
|
|
923
|
+
|
|
924
|
+
const parsed: unknown = await new Promise<unknown>((resolve, reject) => {
|
|
925
|
+
let done = false;
|
|
926
|
+
const fail = (msg: string): void => {
|
|
927
|
+
if (done) return;
|
|
928
|
+
done = true;
|
|
929
|
+
reject(new Error(`${msg}; stdout so far: ${stdout.slice(0, 400)}`));
|
|
930
|
+
};
|
|
931
|
+
const timer = setTimeout(() => fail('timed out waiting for CLI JSON'), timeoutMs);
|
|
932
|
+
const tryParse = (): boolean => {
|
|
933
|
+
const trimmed = stdout.trim();
|
|
934
|
+
if (!trimmed.startsWith('{')) return false;
|
|
935
|
+
try {
|
|
936
|
+
const value = JSON.parse(trimmed) as unknown;
|
|
937
|
+
done = true;
|
|
938
|
+
clearTimeout(timer);
|
|
939
|
+
resolve(value);
|
|
940
|
+
return true;
|
|
941
|
+
} catch {
|
|
942
|
+
return false; // partial pretty-printed JSON — wait for more chunks
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
if (tryParse()) return;
|
|
946
|
+
const onData = (): void => {
|
|
947
|
+
if (tryParse()) child.stdout?.off('data', onData);
|
|
948
|
+
};
|
|
949
|
+
child.stdout?.on('data', onData);
|
|
950
|
+
child.on('error', (err: Error) => fail(`CLI spawn error: ${err.message}`));
|
|
951
|
+
child.on('exit', (code: number | null) => fail(`CLI exited with code ${code} before emitting JSON`));
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
return { parsed, child };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Deterministic teardown of a runPdUntilJson run: kill the reported console
|
|
959
|
+
* server PID (grandchild) first so the CLI can exit on its own, then wait a
|
|
960
|
+
* bounded time, then force-kill the whole CLI tree. Safe to call multiple
|
|
961
|
+
* times and with undefined.
|
|
962
|
+
*/
|
|
963
|
+
async function teardownCliTree(run: CliJsonRun | undefined): Promise<void> {
|
|
964
|
+
if (!run) return;
|
|
965
|
+
const record = isRecord(run.parsed) ? run.parsed : undefined;
|
|
966
|
+
const serverPid = record !== undefined && typeof record.serverPid === 'number' ? record.serverPid : undefined;
|
|
967
|
+
if (serverPid !== undefined) {
|
|
968
|
+
try { process.kill(serverPid); } catch { /* already gone */ }
|
|
969
|
+
}
|
|
970
|
+
await new Promise<void>((resolve) => {
|
|
971
|
+
const t = setTimeout(() => { killTreeForce(run.child); resolve(); }, 4_000);
|
|
972
|
+
run.child.once('exit', () => { clearTimeout(t); resolve(); });
|
|
973
|
+
});
|
|
974
|
+
killTreeForce(run.child);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Last-resort kill of the CLI child process. The console server grandchild is
|
|
979
|
+
* reaped separately via its reported serverPid (see teardownCliTree), so a
|
|
980
|
+
* plain signal kill suffices — no shell/argv command wrapper needed.
|
|
981
|
+
*/
|
|
982
|
+
function killTreeForce(child: childProcessModule.ChildProcess): void {
|
|
983
|
+
try {
|
|
984
|
+
child.kill('SIGKILL');
|
|
985
|
+
} catch { /* already gone */ }
|
|
986
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for pd legacy cleanup command.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Relative workspace root works (regression: canonical containment)
|
|
6
|
+
* - Traversal escape rejected
|
|
7
|
+
* - Filesystem root rejected
|
|
8
|
+
* - Dry-run default with no artifacts found
|
|
9
|
+
* - Apply mode with legacy targets
|
|
10
|
+
* - V1 artifact identification
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import os from 'os';
|
|
16
|
+
import {
|
|
17
|
+
handleLegacyCleanup,
|
|
18
|
+
isV1ArtificerArtifact,
|
|
19
|
+
} from '../../src/commands/legacy-cleanup.js';
|
|
20
|
+
|
|
21
|
+
// ── Pure logic: V1 artifact identification ─────────────────────────────────
|
|
22
|
+
|
|
23
|
+
describe('isV1ArtificerArtifact', () => {
|
|
24
|
+
it('returns false for V2 artifact (non-empty implementationCode)', () => {
|
|
25
|
+
const v2 = JSON.stringify({ id: 'a', implementationCode: 'code here', plan: 'plan' });
|
|
26
|
+
expect(isV1ArtificerArtifact(v2)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns true for V1 artifact (plan-only, no implementationCode)', () => {
|
|
30
|
+
const v1 = JSON.stringify({ id: 'b', plan: 'plan only', implementationCode: '' });
|
|
31
|
+
expect(isV1ArtificerArtifact(v1)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns false for invalid JSON', () => {
|
|
35
|
+
expect(isV1ArtificerArtifact('{not json')).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns false for non-object JSON', () => {
|
|
39
|
+
expect(isV1ArtificerArtifact('"string"')).toBe(false);
|
|
40
|
+
expect(isV1ArtificerArtifact('42')).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns false for null JSON', () => {
|
|
44
|
+
expect(isV1ArtificerArtifact('null')).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ── Integration: relative workspace + boundary validation ─────────────────
|
|
49
|
+
|
|
50
|
+
describe('legacy cleanup workspace boundary', () => {
|
|
51
|
+
it('accepts a relative workspace root (regression: canonical containment)', async () => {
|
|
52
|
+
// A relative workspace must canonicalize consistently so cleanup scans
|
|
53
|
+
// inside it, without the old startsWith-on-relative-root failure.
|
|
54
|
+
const relTmp = fs.mkdtempSync(path.join(process.cwd(), '.tmp-rel-cleanup-'));
|
|
55
|
+
try {
|
|
56
|
+
// Create a legacy artifact the scanner looks for
|
|
57
|
+
const stateDir = path.join(relTmp, '.state');
|
|
58
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
59
|
+
const legacyDb = path.join(stateDir, 'sessions.db');
|
|
60
|
+
fs.writeFileSync(legacyDb, 'not a real db', 'utf8');
|
|
61
|
+
|
|
62
|
+
const relWorkspace = path.relative(process.cwd(), relTmp);
|
|
63
|
+
expect(path.isAbsolute(relWorkspace)).toBe(false);
|
|
64
|
+
|
|
65
|
+
const result = await handleLegacyCleanup({
|
|
66
|
+
workspacePath: relWorkspace,
|
|
67
|
+
dryRun: true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(result.status).toBe('ok');
|
|
71
|
+
expect(result.mode).toBe('dry-run');
|
|
72
|
+
} finally {
|
|
73
|
+
fs.rmSync(relTmp, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('rejects parent traversal escape', async () => {
|
|
78
|
+
await expect(
|
|
79
|
+
handleLegacyCleanup({ workspacePath: '../evil', dryRun: true }),
|
|
80
|
+
).rejects.toThrow(/parent traversal/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects empty workspace', async () => {
|
|
84
|
+
await expect(
|
|
85
|
+
handleLegacyCleanup({ workspacePath: '', dryRun: true }),
|
|
86
|
+
).rejects.toThrow(/path is empty/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rejects filesystem root', async () => {
|
|
90
|
+
await expect(
|
|
91
|
+
handleLegacyCleanup({ workspacePath: path.parse(process.cwd()).root, dryRun: true }),
|
|
92
|
+
).rejects.toThrow(/filesystem root/);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── Integration: normal cleanup flow ───────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe('legacy cleanup flow', () => {
|
|
99
|
+
let tmpDir: string;
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
// mkdtempSync: CodeQL-safe random directory under os.tmpdir
|
|
103
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-test-cleanup-'));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('dry-run with no artifacts returns ok with zero targets', async () => {
|
|
111
|
+
const result = await handleLegacyCleanup({ workspacePath: tmpDir, dryRun: true });
|
|
112
|
+
expect(result.status).toBe('ok');
|
|
113
|
+
expect(result.mode).toBe('dry-run');
|
|
114
|
+
expect(result.fileTargets).toEqual([]);
|
|
115
|
+
expect(result.errors).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('scans legacy session files under .state/sessions', async () => {
|
|
119
|
+
const sessionsDir = path.join(tmpDir, '.state', 'sessions');
|
|
120
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
121
|
+
fs.writeFileSync(
|
|
122
|
+
path.join(sessionsDir, 'old-session.json'),
|
|
123
|
+
JSON.stringify({ sessionKey: 'cron:pd-empathy-optimizer-abc' }),
|
|
124
|
+
'utf8',
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const result = await handleLegacyCleanup({ workspacePath: tmpDir, dryRun: true });
|
|
128
|
+
expect(result.status).toBe('ok');
|
|
129
|
+
expect(result.fileTargets.length).toBeGreaterThanOrEqual(1);
|
|
130
|
+
expect(result.fileTargets.some((t) => t.path.endsWith('old-session.json'))).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('apply mode deletes legacy session files', async () => {
|
|
134
|
+
const sessionsDir = path.join(tmpDir, '.state', 'sessions');
|
|
135
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
136
|
+
const legacyFile = path.join(sessionsDir, 'old-session.json');
|
|
137
|
+
fs.writeFileSync(
|
|
138
|
+
legacyFile,
|
|
139
|
+
JSON.stringify({ sessionKey: 'cron:pd-empathy-optimizer-abc' }),
|
|
140
|
+
'utf8',
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const result = await handleLegacyCleanup({ workspacePath: tmpDir, apply: true });
|
|
144
|
+
expect(result.status).toBe('ok');
|
|
145
|
+
expect(result.mode).toBe('apply');
|
|
146
|
+
expect(fs.existsSync(legacyFile)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -271,6 +271,43 @@ describe('real SYSTEM log fixture', () => {
|
|
|
271
271
|
logSpy.mockRestore();
|
|
272
272
|
exitSpy.mockRestore();
|
|
273
273
|
});
|
|
274
|
+
|
|
275
|
+
it('FIXTURE-06: relative --workspace reads SYSTEM logs (regression)', async () => {
|
|
276
|
+
// Regression: a relative workspace root must not break containment.
|
|
277
|
+
// getLogDir canonicalizes via assertSafeDirectoryRoot and the log-file
|
|
278
|
+
// check uses isPathInside (canonical-vs-canonical), so a relative root
|
|
279
|
+
// must resolve and contain its own log dir.
|
|
280
|
+
const { handlePainEvidence } = await import('../../src/commands/pain-evidence.js');
|
|
281
|
+
|
|
282
|
+
// Create a workspace under cwd so a true relative path is possible.
|
|
283
|
+
const relTmp = fs.mkdtempSync(path.join(process.cwd(), '.tmp-rel-ws-'));
|
|
284
|
+
try {
|
|
285
|
+
const relLogDir = path.join(relTmp, 'memory', 'logs');
|
|
286
|
+
fs.mkdirSync(relLogDir, { recursive: true });
|
|
287
|
+
fs.writeFileSync(path.join(relLogDir, 'SYSTEM_2026-06-08.log'), FULL_LOG_CONTENT, 'utf8');
|
|
288
|
+
const relWorkspace = path.relative(process.cwd(), relTmp);
|
|
289
|
+
expect(path.isAbsolute(relWorkspace)).toBe(false);
|
|
290
|
+
|
|
291
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
292
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
293
|
+
|
|
294
|
+
await handlePainEvidence({ workspace: relWorkspace, limit: 10, json: true });
|
|
295
|
+
|
|
296
|
+
const jsonCall = logSpy.mock.calls.find((call) => {
|
|
297
|
+
try { JSON.parse(call[0] as string); return true; } catch { return false; }
|
|
298
|
+
});
|
|
299
|
+
expect(jsonCall).toBeDefined();
|
|
300
|
+
const output = JSON.parse(jsonCall![0] as string);
|
|
301
|
+
expect(output.count).toBe(3);
|
|
302
|
+
expect(output.searchedPath).toContain(path.join('memory', 'logs', 'SYSTEM_*.log'));
|
|
303
|
+
expect(output.decisions[0].outcome).toBe('manual_owner_admitted');
|
|
304
|
+
|
|
305
|
+
logSpy.mockRestore();
|
|
306
|
+
exitSpy.mockRestore();
|
|
307
|
+
} finally {
|
|
308
|
+
fs.rmSync(relTmp, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
274
311
|
});
|
|
275
312
|
|
|
276
313
|
// ── Commander Registration Tests ───────────────────────────────────────────
|
|
@@ -100,7 +100,11 @@ describe('PRI-393: runtime config unification', () => {
|
|
|
100
100
|
];
|
|
101
101
|
|
|
102
102
|
for (const file of commandFiles) {
|
|
103
|
-
|
|
103
|
+
// CWE-22: resolve against the repo root (this test file lives at
|
|
104
|
+
// packages/pd-cli/tests/commands/) and refuse paths that escape it.
|
|
105
|
+
const repoRoot = path.resolve(__dirname, '../../..');
|
|
106
|
+
const fullPath = path.resolve(repoRoot, file);
|
|
107
|
+
if (!fullPath.startsWith(repoRoot + path.sep)) continue;
|
|
104
108
|
if (!fs.existsSync(fullPath)) continue;
|
|
105
109
|
const source = fs.readFileSync(fullPath, 'utf8');
|
|
106
110
|
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import * as os from 'os';
|
|
5
|
-
import {
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
|
|
8
8
|
// Resolve __dirname in ESM
|
|
@@ -40,11 +40,16 @@ ui:
|
|
|
40
40
|
|
|
41
41
|
// Resolve CLI binary path relative to this file to be workspace-independent
|
|
42
42
|
const cliBin = path.resolve(__dirname, '../../dist/index.js');
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// Parameterized exec (no shell): tmpDir and reason are passed as separate
|
|
44
|
+
// argv entries, so shell metacharacters in them cannot be interpreted as
|
|
45
|
+
// commands (CWE-78 mitigation).
|
|
45
46
|
let stdoutStr: string;
|
|
46
47
|
try {
|
|
47
|
-
stdoutStr =
|
|
48
|
+
stdoutStr = execFileSync(
|
|
49
|
+
process.execPath,
|
|
50
|
+
[cliBin, 'pain', 'record', '--reason', 'Regression test frustration', '--json', '--workspace', tmpDir],
|
|
51
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit'], windowsHide: true },
|
|
52
|
+
);
|
|
48
53
|
} finally {
|
|
49
54
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
50
55
|
}
|