@principles/pd-cli 1.147.0 → 1.147.2
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/rulecode.d.ts +11 -0
- package/dist/commands/rulecode.d.ts.map +1 -1
- package/dist/commands/rulecode.js +4 -1
- package/dist/commands/rulecode.js.map +1 -1
- package/dist/commands/runtime-activation.d.ts.map +1 -1
- package/dist/commands/runtime-activation.js +83 -3
- package/dist/commands/runtime-activation.js.map +1 -1
- package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
- package/dist/commands/runtime-internalization-run-once.js +41 -1
- package/dist/commands/runtime-internalization-run-once.js.map +1 -1
- package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
- package/dist/services/rulehost-pipeline-runner.js +22 -1
- package/dist/services/rulehost-pipeline-runner.js.map +1 -1
- package/dist/services/workspace-tool-semantics.d.ts +19 -0
- package/dist/services/workspace-tool-semantics.d.ts.map +1 -0
- package/dist/services/workspace-tool-semantics.js +20 -0
- package/dist/services/workspace-tool-semantics.js.map +1 -0
- package/package.json +1 -1
- package/scripts/llm-dogfood.ts +24 -1
- package/src/commands/rulecode.ts +9 -1
- package/src/commands/runtime-activation.ts +83 -3
- package/src/commands/runtime-internalization-run-once.ts +42 -1
- package/src/services/rulehost-pipeline-runner.ts +21 -1
- package/src/services/workspace-tool-semantics.ts +26 -0
- package/tests/commands/health.test.ts +5 -0
- package/tests/commands/rulecode.test.ts +42 -0
- package/tests/commands/runtime-activation.test.ts +83 -1
- package/tests/commands/runtime-internalization-run-once-evaluator-parity.test.ts +269 -0
- package/tests/commands/runtime-internalization-run-once.test.ts +37 -0
- package/tests/e2e/cross-package-acceptance.test.ts +12 -0
- package/tests/services/rulehost-pipeline-runner.test.ts +14 -0
- package/tests/services/workspace-tool-semantics.test.ts +76 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Tool Semantics Resolution — PRI-634-F R2 (review P1-2), R3
|
|
3
|
+
* (SPEC §五 P1-1): thin delegation to the ONE host-runtime resolver so CLI,
|
|
4
|
+
* Console, and any future entry point share the same multi-host
|
|
5
|
+
* load/merge/validate/resolve path. The CLI refusal policy (code_tool_hook
|
|
6
|
+
* refuses when unresolvable — no host guessing, no silent skip) stays here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ToolSemanticRegistry } from '@principles/core/runtime-v2';
|
|
10
|
+
import { resolveWorkspaceHostToolSemantics } from '@principles/host-runtime';
|
|
11
|
+
|
|
12
|
+
export type WorkspaceToolSemanticsResolution =
|
|
13
|
+
| { readonly ok: true; readonly registry: ToolSemanticRegistry; readonly hostKind: string }
|
|
14
|
+
| { readonly ok: false; readonly reason: string; readonly nextAction: string };
|
|
15
|
+
|
|
16
|
+
export function resolveWorkspaceToolSemantics(workspaceDir: string): WorkspaceToolSemanticsResolution {
|
|
17
|
+
const resolved = resolveWorkspaceHostToolSemantics(workspaceDir);
|
|
18
|
+
if (!resolved.ok) {
|
|
19
|
+
return { ok: false, reason: resolved.reason, nextAction: resolved.nextAction };
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
ok: true,
|
|
23
|
+
registry: resolved.registry,
|
|
24
|
+
hostKind: resolved.hostKinds.join('+'),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -31,6 +31,11 @@ const {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
vi.mock('@principles/core/runtime-v2', () => ({
|
|
34
|
+
// PRI-634-F R2: full-module mocks must track new public exports — the
|
|
35
|
+
// activation graph imports buildToolSemanticRegistry transitively
|
|
36
|
+
// (workspace-tool-semantics), and vitest throws on the missing property
|
|
37
|
+
// access at import time even when never called.
|
|
38
|
+
buildToolSemanticRegistry: vi.fn().mockReturnValue({ ok: true, registry: {} }),
|
|
34
39
|
PruningReadModel: vi.fn().mockImplementation(function () {
|
|
35
40
|
return { getHealthSummary: mockPruningGetHealthSummary };
|
|
36
41
|
}),
|
|
@@ -14,6 +14,7 @@ import * as fs from 'fs';
|
|
|
14
14
|
import * as path from 'path';
|
|
15
15
|
import os from 'os';
|
|
16
16
|
import { loadGoldenTraceCases } from '../../src/commands/rulecode.js';
|
|
17
|
+
import { vi } from 'vitest';
|
|
17
18
|
|
|
18
19
|
const VALID_CASES = JSON.stringify([
|
|
19
20
|
{
|
|
@@ -133,3 +134,44 @@ describe('loadGoldenTraceCases containment', () => {
|
|
|
133
134
|
expect(result.error?.reason).toContain('not valid JSON');
|
|
134
135
|
});
|
|
135
136
|
});
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
// ── PRI-634-F R2 (review P2): replay --json carries structured failure ──────
|
|
140
|
+
|
|
141
|
+
describe('handleRulecodeReplay — structured failure attribution on --json', () => {
|
|
142
|
+
it('a failing replay surfaces {layer, reasonCode, evidence, nextAction} in the JSON output', async () => {
|
|
143
|
+
const { handleRulecodeReplay } = await import('../../src/commands/rulecode.js');
|
|
144
|
+
const code = [
|
|
145
|
+
'const evaluate = (input) => {',
|
|
146
|
+
' return { decision: "allow", matched: false, reason: "never blocks" };',
|
|
147
|
+
'};',
|
|
148
|
+
].join(String.fromCharCode(10));
|
|
149
|
+
// The --golden-trace CLI input is a JSON ARRAY of cases (not the
|
|
150
|
+
// persisted GoldenTrace envelope) — see loadGoldenTraceCases.
|
|
151
|
+
const cases = [
|
|
152
|
+
{ caseId: 'negative-1', kind: 'negative', toolName: 'write_file', params: { file_path: '/prod.env' }, expectedDecision: 'block' },
|
|
153
|
+
{ caseId: 'positive-1', kind: 'positive', toolName: 'write_file', params: { file_path: '/repo/a.ts' }, expectedDecision: 'allow' },
|
|
154
|
+
];
|
|
155
|
+
const traceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-replay-'));
|
|
156
|
+
const traceFile = path.join(traceDir, 'golden-trace-cases.json');
|
|
157
|
+
fs.writeFileSync(traceFile, JSON.stringify(cases), 'utf8');
|
|
158
|
+
|
|
159
|
+
const logs: string[] = [];
|
|
160
|
+
const spy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { logs.push(String(args[0])); });
|
|
161
|
+
try {
|
|
162
|
+
await handleRulecodeReplay({ code, goldenTrace: traceFile, json: true });
|
|
163
|
+
} finally {
|
|
164
|
+
spy.mockRestore();
|
|
165
|
+
fs.rmSync(traceDir, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
expect(process.exitCode).toBe(1);
|
|
168
|
+
process.exitCode = 0;
|
|
169
|
+
const output = JSON.parse(logs[0]);
|
|
170
|
+
expect(output.status).toBe('failed');
|
|
171
|
+
expect(output.decision).toBe('rejected_validation_failed');
|
|
172
|
+
expect(output.failure).toBeDefined();
|
|
173
|
+
expect(output.failure.layer).toBe('rule');
|
|
174
|
+
expect(output.failure.reasonCode).toBe('replay_decision_mismatch');
|
|
175
|
+
expect(typeof output.failure.nextAction).toBe('string');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { resolveWorkspaceToolSemantics } from '../../src/services/workspace-tool-semantics.js';
|
|
5
|
+
import { vi as _vi } from 'vitest';
|
|
4
6
|
import { tmpdir } from 'node:os';
|
|
5
7
|
import path from 'node:path';
|
|
6
8
|
|
|
@@ -25,6 +27,14 @@ const mockListCodeToolHookActivations = vi.fn().mockResolvedValue([
|
|
|
25
27
|
{ activationId: 'act-hook-1', artifactId: 'art-002', channel: 'code_tool_hook', action: 'code_tool_hook_shadow_activate', targetRef: 'rule-001', activatedAt: '2026-06-18T00:00:00.000Z', promotedAt: null, deactivatedAt: null },
|
|
26
28
|
]);
|
|
27
29
|
|
|
30
|
+
vi.mock('../../src/services/workspace-tool-semantics.js', () => ({
|
|
31
|
+
// Unit boundary: dispatch/approve tests exercise flag wiring and approval
|
|
32
|
+
// state transitions; host-provenance resolution has its own contract tests
|
|
33
|
+
// (workspace-tool-semantics.test.ts). Tests that need the refusal path
|
|
34
|
+
// override the return value via vi.mocked.
|
|
35
|
+
resolveWorkspaceToolSemantics: vi.fn().mockReturnValue({ ok: true, registry: { version: 1, hasHostLayer: true }, hostKind: 'test-host' }),
|
|
36
|
+
}));
|
|
37
|
+
|
|
28
38
|
vi.mock('../../src/resolve-workspace.js', () => ({
|
|
29
39
|
resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
|
|
30
40
|
}));
|
|
@@ -97,7 +107,9 @@ vi.mock('@principles/core/runtime-v2', async (importOriginal) => {
|
|
|
97
107
|
};
|
|
98
108
|
}),
|
|
99
109
|
ApprovalQueue: vi.fn().mockImplementation(function () {
|
|
100
|
-
|
|
110
|
+
// PRI-634-F R2: getById backs the pre-approve host-provenance check
|
|
111
|
+
// (null = no record; the approve path handles not-found itself).
|
|
112
|
+
return { approve: mockApprovalApprove, resetToPending: mockApprovalResetToPending, getById: async () => null };
|
|
101
113
|
}),
|
|
102
114
|
ApprovalCompletionService: vi.fn().mockImplementation(function () {
|
|
103
115
|
return { completeApproval: mockCompletionComplete };
|
|
@@ -1966,3 +1978,73 @@ describe('PRI-499: cli-2-exit-stops — failure paths set exitCode and do not co
|
|
|
1966
1978
|
expect(RuntimeStateManager).not.toHaveBeenCalled();
|
|
1967
1979
|
});
|
|
1968
1980
|
});
|
|
1981
|
+
|
|
1982
|
+
|
|
1983
|
+
describe('handleRuntimeActivationDispatch — PRI-634-F R2 host provenance refusal', () => {
|
|
1984
|
+
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
|
1985
|
+
|
|
1986
|
+
beforeEach(() => {
|
|
1987
|
+
vi.clearAllMocks();
|
|
1988
|
+
mockRuleHostWriterConfigs.length = 0;
|
|
1989
|
+
mockGetArtifactById.mockResolvedValue(makeArtifact());
|
|
1990
|
+
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
1991
|
+
});
|
|
1992
|
+
|
|
1993
|
+
afterEach(() => {
|
|
1994
|
+
consoleLogSpy.mockRestore();
|
|
1995
|
+
process.exitCode = 0;
|
|
1996
|
+
});
|
|
1997
|
+
|
|
1998
|
+
it('refuses code_tool_hook activation when host provenance is unresolvable — structured reason + nextAction, no writer constructed (review P1)', async () => {
|
|
1999
|
+
vi.mocked(resolveWorkspaceToolSemantics).mockReturnValueOnce({
|
|
2000
|
+
ok: false,
|
|
2001
|
+
reason: 'host_tool_declaration_missing',
|
|
2002
|
+
nextAction: 'start the workspace host once so it persists its tool declaration',
|
|
2003
|
+
});
|
|
2004
|
+
await handleRuntimeActivationDispatch({
|
|
2005
|
+
workspace: WS,
|
|
2006
|
+
artifactId: 'art-001',
|
|
2007
|
+
channel: 'code_tool_hook',
|
|
2008
|
+
json: true,
|
|
2009
|
+
});
|
|
2010
|
+
|
|
2011
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
|
|
2012
|
+
expect(output.decision).toBe('refused');
|
|
2013
|
+
expect(output.reason).toBe('host_tool_declaration_missing');
|
|
2014
|
+
expect(output.nextAction).toContain('host');
|
|
2015
|
+
// Fail-closed: the dispatcher (and its writers) must never run —
|
|
2016
|
+
// cli-5-failure-no-mutation.
|
|
2017
|
+
expect(mockRuleHostWriterConfigs).toHaveLength(0);
|
|
2018
|
+
expect(process.exitCode).toBe(1);
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
it('non-code_tool_hook channels are unaffected by unresolvable provenance', async () => {
|
|
2022
|
+
vi.mocked(resolveWorkspaceToolSemantics).mockReturnValueOnce({
|
|
2023
|
+
ok: false,
|
|
2024
|
+
reason: 'host_tool_declaration_missing',
|
|
2025
|
+
nextAction: 'start the workspace host once',
|
|
2026
|
+
});
|
|
2027
|
+
await handleRuntimeActivationDispatch({
|
|
2028
|
+
workspace: WS,
|
|
2029
|
+
artifactId: 'art-001',
|
|
2030
|
+
channel: 'prompt',
|
|
2031
|
+
json: true,
|
|
2032
|
+
});
|
|
2033
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
|
|
2034
|
+
expect(output.decision).not.toBe('refused');
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
it('code_tool_hook dispatch threads the resolved registry into RuleHostWriter and gateDeps', async () => {
|
|
2038
|
+
const registry = { version: 1, hasHostLayer: true, resolve: () => 'write', lookup: () => null, hasHostTool: () => true };
|
|
2039
|
+
vi.mocked(resolveWorkspaceToolSemantics).mockReturnValueOnce({ ok: true, registry, hostKind: 'openclaw' });
|
|
2040
|
+
await handleRuntimeActivationDispatch({
|
|
2041
|
+
workspace: WS,
|
|
2042
|
+
artifactId: 'art-001',
|
|
2043
|
+
channel: 'code_tool_hook',
|
|
2044
|
+
json: true,
|
|
2045
|
+
});
|
|
2046
|
+
expect(mockRuleHostWriterConfigs).toHaveLength(1);
|
|
2047
|
+
expect(mockRuleHostWriterConfigs[0]?.toolSemantics).toBe(registry);
|
|
2048
|
+
expect(mockRuleHostWriterConfigs[0]?.gateDeps).toBeDefined();
|
|
2049
|
+
});
|
|
2050
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRI-661 — run-once evaluator runtime parity (E2E through the REAL CLI
|
|
3
|
+
* handler).
|
|
4
|
+
*
|
|
5
|
+
* Drives `handleRuntimeInternalizationRunOnce --runner evaluator` against a
|
|
6
|
+
* REAL sqlite workspace + REAL persisted host declaration + the REAL resolver
|
|
7
|
+
* and sandbox. Only the LLM boundary is the CLI's own test-double runtime.
|
|
8
|
+
*
|
|
9
|
+
* Pins the acceptance from the Linear issue:
|
|
10
|
+
* 1. a declared workspace runs the deterministic adversarial replay to
|
|
11
|
+
* completion through run-once (the gap this PR closes);
|
|
12
|
+
* 2. the SAME artifact replays to the SAME verdict through the
|
|
13
|
+
* consumer-cycle assembly formula (live registry from the same mapping
|
|
14
|
+
* constants) — replay/production parity, not just "some gate ran";
|
|
15
|
+
* 3. an undeclared workspace refuses BEFORE leasing any task, with the same
|
|
16
|
+
* structured semantics as runtime-activation (no baseline fallback);
|
|
17
|
+
* 4. a replay-failing rule records adversarialResult.passed=false — the
|
|
18
|
+
* deterministic verdict the downstream approval chain consumes.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as os from 'node:os';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
import {
|
|
25
|
+
RuntimeStateManager,
|
|
26
|
+
createPITaskDiagnosticJson,
|
|
27
|
+
buildToolSemanticRegistry,
|
|
28
|
+
createProductionGateDeps,
|
|
29
|
+
type ToolSemanticMappingV1,
|
|
30
|
+
} from '@principles/core/runtime-v2';
|
|
31
|
+
import { saveHostToolDeclaration } from '@principles/host-runtime';
|
|
32
|
+
import { handleRuntimeInternalizationRunOnce } from '../../src/commands/runtime-internalization-run-once.js';
|
|
33
|
+
|
|
34
|
+
const HOST_MAPPINGS: readonly ToolSemanticMappingV1[] = [
|
|
35
|
+
{ rawToolName: 'Write', canonicalKind: 'write' },
|
|
36
|
+
{ rawToolName: 'Edit', canonicalKind: 'write' },
|
|
37
|
+
{ rawToolName: 'write_file', canonicalKind: 'write' },
|
|
38
|
+
{ rawToolName: 'Bash', canonicalKind: 'execute' },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A realistic write-after-read guard that satisfies both the fixture's golden
|
|
43
|
+
* cases and the runner's 5 auto-generated v2 adversarial templates:
|
|
44
|
+
* risk-path writes always block (v1 action check dominates); otherwise a
|
|
45
|
+
* write is allowed only with prior-read evidence or an explicitly benign
|
|
46
|
+
* context posture (unavailable/truncated/unknown fail open).
|
|
47
|
+
* Registry-sensitive: it keys off action.canonicalKind — the field the host
|
|
48
|
+
* tool registry injects into replay inputs.
|
|
49
|
+
*/
|
|
50
|
+
const PASSING_CODE = [
|
|
51
|
+
'function evaluate(input) {',
|
|
52
|
+
' const action = input && input.action ? input.action : {};',
|
|
53
|
+
' const kind = action.canonicalKind ? action.canonicalKind : "other";',
|
|
54
|
+
' const params = action.paramsSummary ? action.paramsSummary : {};',
|
|
55
|
+
' const p = typeof params.path === "string" ? params.path : "";',
|
|
56
|
+
' if (kind !== "write") {',
|
|
57
|
+
' return { decision: "allow", matched: false, reason: "not a write" };',
|
|
58
|
+
' }',
|
|
59
|
+
' if (p.indexOf("/etc/") === 0) {',
|
|
60
|
+
' return { decision: "block", matched: true, reason: "risk path write" };',
|
|
61
|
+
' }',
|
|
62
|
+
' const ctx = input && input.context ? input.context : null;',
|
|
63
|
+
' const facts = ctx && ctx.facts ? ctx.facts : {};',
|
|
64
|
+
' if (facts.priorReadOfTarget === "yes") {',
|
|
65
|
+
' return { decision: "allow", matched: false, reason: "write after read" };',
|
|
66
|
+
' }',
|
|
67
|
+
' if (facts.priorReadOfTarget === "no") {',
|
|
68
|
+
' return { decision: "block", matched: true, reason: "write without prior read" };',
|
|
69
|
+
' }',
|
|
70
|
+
' return { decision: "allow", matched: false, reason: "unknown history fails open" };',
|
|
71
|
+
'}',
|
|
72
|
+
].join('\n');
|
|
73
|
+
|
|
74
|
+
/** Same shape, but never blocks — the negative golden case fails under replay. */
|
|
75
|
+
const REPLAY_FAILING_CODE = [
|
|
76
|
+
'function evaluate(input) {',
|
|
77
|
+
' return { decision: "allow", matched: false, reason: "never blocks" };',
|
|
78
|
+
'}',
|
|
79
|
+
].join('\n');
|
|
80
|
+
|
|
81
|
+
const GOLDEN_CASES = [
|
|
82
|
+
{ caseId: 'c-neg', kind: 'negative', toolName: 'write_file', params: { path: '/etc/passwd' }, expectedDecision: 'block' },
|
|
83
|
+
{ caseId: 'c-pos', kind: 'positive', toolName: 'write_file', params: { path: '/project/src/safe.ts' }, expectedDecision: 'allow' },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
function codeBearingArtificerContent(implementationCode: string): string {
|
|
87
|
+
return JSON.stringify({
|
|
88
|
+
implementationCode,
|
|
89
|
+
goldenTraceCases: GOLDEN_CASES,
|
|
90
|
+
affectedTools: ['write_file'],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const dirs: string[] = [];
|
|
95
|
+
|
|
96
|
+
function makeWorkspace(withDeclaration: boolean): string {
|
|
97
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-runonce-parity-'));
|
|
98
|
+
dirs.push(dir);
|
|
99
|
+
if (withDeclaration) {
|
|
100
|
+
saveHostToolDeclaration(dir, {
|
|
101
|
+
version: 1,
|
|
102
|
+
hostKind: 'openclaw',
|
|
103
|
+
mappings: HOST_MAPPINGS,
|
|
104
|
+
declaredAt: new Date().toISOString(),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return dir;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
vi.restoreAllMocks();
|
|
112
|
+
for (const dir of dirs.splice(0)) {
|
|
113
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* temp */ }
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Seed the dependency chain exactly like the core gate-authority fixture:
|
|
119
|
+
* artificer(succeeded) + its code-bearing artifact + evaluator(pending,
|
|
120
|
+
* dep=artificer). The artifact id matches the CLI test-double's default echo.
|
|
121
|
+
*/
|
|
122
|
+
async function seedEvaluatorTask(workspaceDir: string, implementationCode: string): Promise<void> {
|
|
123
|
+
const stateManager = new RuntimeStateManager({ workspaceDir });
|
|
124
|
+
await stateManager.initialize();
|
|
125
|
+
try {
|
|
126
|
+
const meta = (deps: string[]): string =>
|
|
127
|
+
createPITaskDiagnosticJson({
|
|
128
|
+
dependencyTaskIds: deps, channel: 'prompt', timeoutMs: 300_000,
|
|
129
|
+
inputArtifactRefs: [], outputArtifactRefs: [],
|
|
130
|
+
});
|
|
131
|
+
await stateManager.createTask({ taskId: 'artificer-seed', taskKind: 'artificer', status: 'pending', attemptCount: 0, maxAttempts: 3, diagnosticJson: meta([]) });
|
|
132
|
+
await stateManager.acquireLease({ taskId: 'artificer-seed', owner: 'seed', runtimeKind: 'test-double' });
|
|
133
|
+
await stateManager.markTaskSucceeded('artificer-seed');
|
|
134
|
+
await stateManager.createTask({ taskId: 'evaluator-under-test', taskKind: 'evaluator', status: 'pending', attemptCount: 0, maxAttempts: 3, diagnosticJson: meta(['artificer-seed']) });
|
|
135
|
+
await stateManager.piArtifactStore.upsertArtifact({
|
|
136
|
+
artifactId: 'pi-art-test-artificer',
|
|
137
|
+
artifactKind: 'principle',
|
|
138
|
+
sourceTaskId: 'artificer-seed',
|
|
139
|
+
lineageArtifactIds: [],
|
|
140
|
+
validationStatus: 'validated',
|
|
141
|
+
contentJson: codeBearingArtificerContent(implementationCode),
|
|
142
|
+
createdAt: new Date().toISOString(),
|
|
143
|
+
updatedAt: new Date().toISOString(),
|
|
144
|
+
});
|
|
145
|
+
} finally {
|
|
146
|
+
await stateManager.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function readEvaluatorArtifact(workspaceDir: string): Promise<Record<string, unknown> | null> {
|
|
151
|
+
const stateManager = new RuntimeStateManager({ workspaceDir });
|
|
152
|
+
await stateManager.initialize();
|
|
153
|
+
try {
|
|
154
|
+
const artifacts = await stateManager.piArtifactStore.listBySourceTaskId('evaluator-under-test');
|
|
155
|
+
const principle = artifacts.find((a) => a.artifactKind === 'principle');
|
|
156
|
+
return principle ? (JSON.parse(principle.contentJson) as Record<string, unknown>) : null;
|
|
157
|
+
} finally {
|
|
158
|
+
await stateManager.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The consumer-cycle assembly formula: live registry from the same constants a host passes in memory. */
|
|
163
|
+
function consumerCycleFormulaReplay(implementationCode: string): { passed: boolean; failedCaseCount: number } {
|
|
164
|
+
const liveRegistry = buildToolSemanticRegistry(HOST_MAPPINGS);
|
|
165
|
+
if (!liveRegistry.ok) throw new Error(liveRegistry.errors.join('; '));
|
|
166
|
+
const gateDeps = createProductionGateDeps({ toolSemantics: liveRegistry.registry });
|
|
167
|
+
// RUNTIME_CONTRACT: the gate structurally validates the trace at runtime;
|
|
168
|
+
// the fabricated minimal object intentionally omits the full GoldenTrace
|
|
169
|
+
// provenance fields, so the literal cannot satisfy the parameter type
|
|
170
|
+
// directly (same documented pattern as evaluator-gate-authority fixtures).
|
|
171
|
+
const result = gateDeps.evaluateInSandbox(implementationCode, {
|
|
172
|
+
version: 1,
|
|
173
|
+
cases: GOLDEN_CASES.map((c) => ({ ...c })),
|
|
174
|
+
createdAt: '2026-09-04T00:00:00.000Z',
|
|
175
|
+
} as never);
|
|
176
|
+
return {
|
|
177
|
+
passed: result.success,
|
|
178
|
+
failedCaseCount: result.failedCases.length,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function runOnceJson(workspaceDir: string): Promise<{ output: Record<string, unknown>; logSpy: ReturnType<typeof vi.spyOn> }> {
|
|
183
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
184
|
+
const prevExitCode = process.exitCode;
|
|
185
|
+
try {
|
|
186
|
+
await handleRuntimeInternalizationRunOnce({
|
|
187
|
+
workspace: workspaceDir,
|
|
188
|
+
runner: 'evaluator',
|
|
189
|
+
runtime: 'test-double',
|
|
190
|
+
allowTestDouble: true,
|
|
191
|
+
enqueueNext: false,
|
|
192
|
+
json: true,
|
|
193
|
+
});
|
|
194
|
+
const printed = logSpy.mock.calls.map((c) => String(c[0])).filter((s) => s.trim().startsWith('{'));
|
|
195
|
+
expect(printed.length).toBeGreaterThan(0);
|
|
196
|
+
return { output: JSON.parse(printed[0]) as Record<string, unknown>, logSpy };
|
|
197
|
+
} finally {
|
|
198
|
+
vi.restoreAllMocks();
|
|
199
|
+
process.exitCode = prevExitCode;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
describe('run-once evaluator runtime parity (PRI-661)', () => {
|
|
204
|
+
it('declared workspace: adversarial replay runs to completion and matches the consumer-cycle verdict', async () => {
|
|
205
|
+
const workspaceDir = makeWorkspace(true);
|
|
206
|
+
await seedEvaluatorTask(workspaceDir, PASSING_CODE);
|
|
207
|
+
|
|
208
|
+
const { output } = await runOnceJson(workspaceDir);
|
|
209
|
+
expect(output.decision).toBe('would_lease');
|
|
210
|
+
expect(output.runnerResult, JSON.stringify(output)).toMatchObject({ status: 'succeeded' });
|
|
211
|
+
|
|
212
|
+
const artifact = await readEvaluatorArtifact(workspaceDir);
|
|
213
|
+
expect(artifact).not.toBeNull();
|
|
214
|
+
const adversarial = (artifact?.adversarialResult ?? null) as { passed?: boolean; failedCases?: unknown[] } | null;
|
|
215
|
+
expect(adversarial, 'deterministic replay must record adversarialResult').not.toBeNull();
|
|
216
|
+
expect(adversarial?.passed).toBe(true);
|
|
217
|
+
expect(adversarial?.failedCases ?? []).toEqual([]);
|
|
218
|
+
|
|
219
|
+
// Parity: the SAME code replays to the SAME verdict through the
|
|
220
|
+
// consumer-cycle assembly formula (live registry, same constants).
|
|
221
|
+
expect(consumerCycleFormulaReplay(PASSING_CODE)).toEqual({ passed: true, failedCaseCount: 0 });
|
|
222
|
+
}, 60_000);
|
|
223
|
+
|
|
224
|
+
it('declared workspace: a replay-failing rule records passed=false with structured failed cases', async () => {
|
|
225
|
+
const workspaceDir = makeWorkspace(true);
|
|
226
|
+
await seedEvaluatorTask(workspaceDir, REPLAY_FAILING_CODE);
|
|
227
|
+
|
|
228
|
+
const { output } = await runOnceJson(workspaceDir);
|
|
229
|
+
|
|
230
|
+
const artifact = await readEvaluatorArtifact(workspaceDir);
|
|
231
|
+
expect(artifact).not.toBeNull();
|
|
232
|
+
const adversarial = (artifact?.adversarialResult ?? null) as { passed?: boolean; failedCases?: Array<{ caseId?: string }> } | null;
|
|
233
|
+
expect(adversarial).not.toBeNull();
|
|
234
|
+
expect(adversarial?.passed).toBe(false);
|
|
235
|
+
// The auto-generated v2 templates expecting block are the ones a
|
|
236
|
+
// never-blocking rule fails (path-boundary + combination at minimum).
|
|
237
|
+
const failedIds = (adversarial?.failedCases ?? []).map((c) => c.caseId);
|
|
238
|
+
expect(failedIds).toContain('v2-path-boundary');
|
|
239
|
+
expect(failedIds).toContain('v2-combination');
|
|
240
|
+
|
|
241
|
+
// The deterministic verdict the approval chain would consume matches the
|
|
242
|
+
// consumer-cycle formula for the same artifact content.
|
|
243
|
+
expect(consumerCycleFormulaReplay(REPLAY_FAILING_CODE)).toMatchObject({ passed: false });
|
|
244
|
+
void output;
|
|
245
|
+
}, 60_000);
|
|
246
|
+
|
|
247
|
+
it('undeclared workspace: refuses BEFORE leasing, structured reason, queue untouched', async () => {
|
|
248
|
+
const workspaceDir = makeWorkspace(false);
|
|
249
|
+
await seedEvaluatorTask(workspaceDir, PASSING_CODE);
|
|
250
|
+
|
|
251
|
+
const { output } = await runOnceJson(workspaceDir);
|
|
252
|
+
expect(output.decision).toBe('refused');
|
|
253
|
+
expect(output.reason).toBe('host_tool_declaration_missing');
|
|
254
|
+
expect(typeof output.nextAction).toBe('string');
|
|
255
|
+
expect((output.nextAction as string).length).toBeGreaterThan(0);
|
|
256
|
+
|
|
257
|
+
// cli-5: refusal performed NO queue mutation — the evaluator task is
|
|
258
|
+
// still pending and unleased.
|
|
259
|
+
const stateManager = new RuntimeStateManager({ workspaceDir });
|
|
260
|
+
await stateManager.initialize();
|
|
261
|
+
try {
|
|
262
|
+
const task = await stateManager.getTask('evaluator-under-test');
|
|
263
|
+
expect(task?.status).toBe('pending');
|
|
264
|
+
expect(task?.leasedBy ?? null).toBeNull();
|
|
265
|
+
} finally {
|
|
266
|
+
await stateManager.close();
|
|
267
|
+
}
|
|
268
|
+
}, 60_000);
|
|
269
|
+
});
|
|
@@ -170,6 +170,21 @@ vi.mock('../../src/config-reader.js', () => ({
|
|
|
170
170
|
readOutputLanguageFromWorkspace: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
|
|
171
171
|
}));
|
|
172
172
|
|
|
173
|
+
// PRI-661: run-once now builds the evaluator replay context through the ONE
|
|
174
|
+
// host-runtime builder. Mock just that seam here — the unit tests below prove
|
|
175
|
+
// dispatch wiring; the real resolver + declaration fixture path is proven by
|
|
176
|
+
// runtime-internalization-run-once-evaluator-parity.test.ts.
|
|
177
|
+
const { mockCreateEvaluatorRuntimeContext } = vi.hoisted(() => {
|
|
178
|
+
const mockCreateEvaluatorRuntimeContext = vi.fn().mockReturnValue({
|
|
179
|
+
ok: true,
|
|
180
|
+
gateDeps: { evaluateInSandbox: vi.fn() },
|
|
181
|
+
});
|
|
182
|
+
return { mockCreateEvaluatorRuntimeContext };
|
|
183
|
+
});
|
|
184
|
+
vi.mock('@principles/host-runtime', () => ({
|
|
185
|
+
createEvaluatorRuntimeContext: mockCreateEvaluatorRuntimeContext,
|
|
186
|
+
}));
|
|
187
|
+
|
|
173
188
|
import { handleRuntimeInternalizationRunOnce } from '../../src/commands/runtime-internalization-run-once.js';
|
|
174
189
|
|
|
175
190
|
const WS = '/fake/workspace';
|
|
@@ -1136,6 +1151,28 @@ describe('handleRuntimeInternalizationRunOnce', () => {
|
|
|
1136
1151
|
expect(output.runnerResult.status).toBe('succeeded');
|
|
1137
1152
|
});
|
|
1138
1153
|
|
|
1154
|
+
it('--runner evaluator refuses BEFORE leasing when the runtime context is unresolvable (PRI-661)', async () => {
|
|
1155
|
+
mockCreateEvaluatorRuntimeContext.mockReturnValueOnce({
|
|
1156
|
+
ok: false,
|
|
1157
|
+
reason: 'host_tool_declaration_missing',
|
|
1158
|
+
nextAction: 'start a host (OpenClaw/Codex) once so it persists its tool declaration',
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
await handleRuntimeInternalizationRunOnce({ workspace: WS, runner: 'evaluator', runtime: 'test-double', allowTestDouble: true, json: true });
|
|
1162
|
+
|
|
1163
|
+
// cli-5: refusal must not lease/advance any task.
|
|
1164
|
+
expect(mockWakeOnce).not.toHaveBeenCalled();
|
|
1165
|
+
const EvaluatorRunnerMock = vi.mocked(
|
|
1166
|
+
await import('@principles/core/runtime-v2').then(m => m.EvaluatorRunner),
|
|
1167
|
+
);
|
|
1168
|
+
expect(EvaluatorRunnerMock).not.toHaveBeenCalled();
|
|
1169
|
+
|
|
1170
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
|
|
1171
|
+
expect(output.decision).toBe('refused');
|
|
1172
|
+
expect(output.reason).toBe('host_tool_declaration_missing');
|
|
1173
|
+
expect(output.nextAction).toContain('host');
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1139
1176
|
it('auto-enqueue: --runner evaluator returns successor decision', async () => {
|
|
1140
1177
|
mockWakeOnce.mockResolvedValue({
|
|
1141
1178
|
decision: 'would_lease',
|
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
createSandboxGateDeps,
|
|
38
38
|
} from '../../src/services/rulehost-pipeline-runner.js';
|
|
39
39
|
import { RuleHost } from '../../../openclaw-plugin/src/core/rule-host.js';
|
|
40
|
+
import { OPENCLAW_TOOL_SEMANTIC_MAPPINGS } from '../../../openclaw-plugin/src/constants/tool-semantics.js';
|
|
41
|
+
import { saveHostToolDeclaration } from '@principles/host-runtime';
|
|
40
42
|
import type { CodeRuleCapability } from '../../src/services/rulehost-pipeline-runner.js';
|
|
41
43
|
import type { PDRuntimeAdapter, RunHandle, RunStatus, PIArtifactStore, RuntimeCapabilities, RuntimeHealth, RuntimeArtifactRef, ContextItem, StructuredRunOutput, StartRunInput } from '@principles/core/runtime-v2';
|
|
42
44
|
import {
|
|
@@ -204,6 +206,16 @@ let tmpDir = '';
|
|
|
204
206
|
function makeTmpDir(): string {
|
|
205
207
|
const dir = path.join(os.tmpdir(), `pd-xpkg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
206
208
|
fs.mkdirSync(dir, { recursive: true });
|
|
209
|
+
// PRI-661: the pipeline's evaluator replay resolves the production gate
|
|
210
|
+
// context from durable workspace provenance. Seed the declaration the real
|
|
211
|
+
// OpenClaw host would persist on startup (same mapping constants), so the
|
|
212
|
+
// generation-time replay and the activation gate see the same registry.
|
|
213
|
+
saveHostToolDeclaration(dir, {
|
|
214
|
+
version: 1,
|
|
215
|
+
hostKind: 'openclaw',
|
|
216
|
+
mappings: OPENCLAW_TOOL_SEMANTIC_MAPPINGS,
|
|
217
|
+
declaredAt: new Date().toISOString(),
|
|
218
|
+
});
|
|
207
219
|
return dir;
|
|
208
220
|
}
|
|
209
221
|
|
|
@@ -20,6 +20,7 @@ import { runRuleHostPipeline } from '../../src/services/rulehost-pipeline-runner
|
|
|
20
20
|
import type { CodeRuleCapability } from '../../src/services/rulehost-pipeline-runner.js';
|
|
21
21
|
import type { PDRuntimeAdapter, RunHandle, RunStatus, PIArtifactStore, RuntimeCapabilities, RuntimeHealth, RuntimeArtifactRef, ContextItem, StructuredRunOutput, StartRunInput } from '@principles/core/runtime-v2';
|
|
22
22
|
import { RuntimeStateManager, createPITaskDiagnosticJson } from '@principles/core/runtime-v2';
|
|
23
|
+
import { saveHostToolDeclaration } from '@principles/host-runtime';
|
|
23
24
|
|
|
24
25
|
type StageFactory = (taskId: string, priorArtifactId?: string) => unknown;
|
|
25
26
|
type EvaluatorFactory = (taskId: string, artificerArtifactId: string) => unknown;
|
|
@@ -192,6 +193,19 @@ let tmpDir = '';
|
|
|
192
193
|
function makeTmpDir(): string {
|
|
193
194
|
const dir = path.join(os.tmpdir(), `pd-pipe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
194
195
|
fs.mkdirSync(dir, { recursive: true });
|
|
196
|
+
// PRI-661: the pipeline's evaluator replay resolves the production gate
|
|
197
|
+
// context from durable workspace provenance — seed a declaration like every
|
|
198
|
+
// real host does on startup.
|
|
199
|
+
saveHostToolDeclaration(dir, {
|
|
200
|
+
version: 1,
|
|
201
|
+
hostKind: 'testhost',
|
|
202
|
+
mappings: [
|
|
203
|
+
{ rawToolName: 'Write', canonicalKind: 'write' },
|
|
204
|
+
{ rawToolName: 'Edit', canonicalKind: 'write' },
|
|
205
|
+
{ rawToolName: 'Bash', canonicalKind: 'execute' },
|
|
206
|
+
],
|
|
207
|
+
declaredAt: new Date().toISOString(),
|
|
208
|
+
});
|
|
195
209
|
return dir;
|
|
196
210
|
}
|
|
197
211
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Tool Semantics Resolution tests — PRI-634-F R2 (review P1-2)
|
|
3
|
+
*
|
|
4
|
+
* The CLI must resolve the host registry from DURABLE workspace provenance
|
|
5
|
+
* and refuse (never guess, never silently skip) when unavailable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { resolveWorkspaceToolSemantics } from '../../src/services/workspace-tool-semantics.js';
|
|
13
|
+
|
|
14
|
+
let ws: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
ws = mkdtempSync(path.join(tmpdir(), 'pd-wts-'));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(ws, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function writeDeclaration(payload: unknown, hostKind = 'openclaw'): void {
|
|
25
|
+
// R3: per-host partition — <ws>/.pd/host-tool-semantics/<hostKind>.json
|
|
26
|
+
mkdirSync(path.join(ws, '.pd', 'host-tool-semantics'), { recursive: true });
|
|
27
|
+
writeFileSync(path.join(ws, '.pd', 'host-tool-semantics', `${hostKind}.json`), JSON.stringify(payload), 'utf8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('resolveWorkspaceToolSemantics', () => {
|
|
31
|
+
it('resolves a valid persisted host declaration into a host-layered registry', () => {
|
|
32
|
+
writeDeclaration({
|
|
33
|
+
version: 1,
|
|
34
|
+
hostKind: 'openclaw',
|
|
35
|
+
mappings: [
|
|
36
|
+
{ rawToolName: 'shell', canonicalKind: 'execute' },
|
|
37
|
+
{ rawToolName: 'write_file', canonicalKind: 'write' },
|
|
38
|
+
],
|
|
39
|
+
declaredAt: '2026-09-04T00:00:00.000Z',
|
|
40
|
+
});
|
|
41
|
+
const result = resolveWorkspaceToolSemantics(ws);
|
|
42
|
+
expect(result.ok).toBe(true);
|
|
43
|
+
if (!result.ok) return;
|
|
44
|
+
expect(result.hostKind).toBe('openclaw');
|
|
45
|
+
expect(result.registry.hasHostTool('shell')).toBe(true);
|
|
46
|
+
// Core baseline names still classify, but are NOT host-declared here.
|
|
47
|
+
expect(result.registry.hasHostTool('execute_command')).toBe(false);
|
|
48
|
+
expect(result.registry.hasHostTool('read_file')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('missing declaration → structured failure with actionable nextAction (no guessing)', () => {
|
|
52
|
+
const result = resolveWorkspaceToolSemantics(ws);
|
|
53
|
+
expect(result.ok).toBe(false);
|
|
54
|
+
if (result.ok) return;
|
|
55
|
+
expect(result.reason).toBe('host_tool_declaration_missing');
|
|
56
|
+
expect(result.nextAction).toContain('host');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('malformed declaration → host_tool_declaration_invalid, never baseline fallback', () => {
|
|
60
|
+
writeDeclaration({ version: 1, hostKind: 'x', mappings: [{ rawToolName: 'bad', canonicalKind: 'nope' }], declaredAt: 't' });
|
|
61
|
+
const result = resolveWorkspaceToolSemantics(ws);
|
|
62
|
+
expect(result.ok).toBe(false);
|
|
63
|
+
if (result.ok) return;
|
|
64
|
+
expect(result.reason).toContain('host_tool_declaration_invalid');
|
|
65
|
+
expect(result.reason).toContain('openclaw');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('not JSON at all → host_tool_declaration_invalid (per-host file)', () => {
|
|
69
|
+
mkdirSync(path.join(ws, '.pd', 'host-tool-semantics'), { recursive: true });
|
|
70
|
+
writeFileSync(path.join(ws, '.pd', 'host-tool-semantics', 'openclaw.json'), '{not json', 'utf8');
|
|
71
|
+
const result = resolveWorkspaceToolSemantics(ws);
|
|
72
|
+
expect(result.ok).toBe(false);
|
|
73
|
+
if (result.ok) return;
|
|
74
|
+
expect(result.reason).toBe('host_tool_declaration_invalid (openclaw: not valid JSON)');
|
|
75
|
+
});
|
|
76
|
+
});
|