create-principles-disciple 1.145.42 → 1.146.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/_release/product-identity.json +2 -2
- package/console/dist/server/index.js +8 -0
- package/console/dist/server/routes/examiner-executor.d.ts +260 -0
- package/console/dist/server/routes/examiner-executor.js +628 -0
- package/console/dist/server/routes/examiner.d.ts +52 -0
- package/console/dist/server/routes/examiner.js +299 -0
- package/console/dist/ui/App.js +6 -3
- package/console/dist/ui/api.d.ts +138 -0
- package/console/dist/ui/api.js +56 -0
- package/console/dist/ui/components/layout/app-sidebar.d.ts +10 -0
- package/console/dist/ui/components/layout/app-sidebar.js +10 -2
- package/console/dist/ui/i18n/en.json +63 -0
- package/console/dist/ui/i18n/zh-CN.json +63 -0
- package/console/dist/ui/pages/examiner/ExaminerPage.d.ts +15 -0
- package/console/dist/ui/pages/examiner/ExaminerPage.js +158 -0
- package/console/dist/ui/pages/principles/PrincipleDetailPage.js +2 -1
- package/console/dist/ui/utils/runtime-mode.d.ts +11 -0
- package/console/dist/ui/utils/runtime-mode.js +11 -0
- package/console/dist/web/assets/app.css +44 -0
- package/console/dist/web/assets/app.js +33 -31
- package/console/lab/lib/challenge-actor.mjs +394 -0
- package/console/lab/lib/challenge-arms.mjs +102 -0
- package/console/lab/lib/challenge-behavior-report.mjs +2484 -0
- package/console/lab/lib/challenge-compile.mjs +211 -0
- package/console/lab/lib/challenge-freeze.mjs +215 -0
- package/console/lab/lib/challenge-isolation-probe.mjs +550 -0
- package/console/lab/lib/challenge-ledger.mjs +206 -0
- package/console/lab/lib/challenge-openclaw-host.mjs +630 -0
- package/console/lab/lib/challenge-runner.mjs +868 -0
- package/console/lab/lib/challenge-stage-r-gate.mjs +458 -0
- package/console/lab/lib/challenge-statistics.mjs +591 -0
- package/console/lab/lib/claim-evidence-env.mjs +652 -0
- package/console/lab/lib/claim-evidence-scorer.mjs +784 -0
- package/console/lab/lib/examiner-injection-evidence.mjs +367 -0
- package/console/lab/lib/examiner-study.mjs +1203 -0
- package/console/lab/lib/examiner-v07-cases.mjs +187 -0
- package/console/lab/lib/examiner-v2-cases.mjs +61 -0
- package/console/lab/lib/fixture.mjs +382 -0
- package/console/lab/lib/g2.mjs +146 -0
- package/console/lab/lib/host-actor.mjs +987 -0
- package/console/lab/lib/sidecar.mjs +273 -0
- package/package.json +1 -1
- package/pd-cli/package.json +1 -1
- package/release-manager/package.json +1 -1
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
// SPEC-PD-EXAMINER-001 v0.4 / PRI-1037 (Owner S4, 2026-10-28; S5 hardening) —
|
|
2
|
+
// the OpenClaw host adapter for the real-host isolation probe and F2.
|
|
3
|
+
//
|
|
4
|
+
// What this module is:
|
|
5
|
+
// - a host transport implementing the SAME contract as the Codex adapter in
|
|
6
|
+
// host-actor.mjs ({ complete({taskId,systemPrompt,userPrompt}) ->
|
|
7
|
+
// { payload, hostRound: { sessionId } }, hostSnapshot() }) so the probe
|
|
8
|
+
// engine (challenge-isolation-probe.mjs) runs unchanged;
|
|
9
|
+
// - backed by the OpenClaw headless exec surface: `openclaw agent exec
|
|
10
|
+
// --config <pinned> --state-dir <per-round> --model <id> --json
|
|
11
|
+
// --message-file <file> --cwd <round-dir>` — every exec creates a FRESH
|
|
12
|
+
// explicit session (the CLI generates the session id and reports it in
|
|
13
|
+
// its JSON envelope), so probe-round session independence is a
|
|
14
|
+
// transport-level property, not a caller discipline;
|
|
15
|
+
// - the OpenClaw evidence reader: `readOpenClawTrajectoryEvidence` resolves
|
|
16
|
+
// the session's own trajectory-pointer file (openclaw-trajectory-pointer
|
|
17
|
+
// → runtimeFile) and corroborates provider/modelId from the
|
|
18
|
+
// openclaw-trajectory JSONL the RUNTIME wrote — record fields alone never
|
|
19
|
+
// corroborate (the verifyF2ProbeEvidence discipline, OpenClaw flavor).
|
|
20
|
+
//
|
|
21
|
+
// System-prompt channel (S5 P1-1, honest semantics): `openclaw agent exec`
|
|
22
|
+
// has NO standalone system-prompt CLI flag (verified against the 2026.10.5
|
|
23
|
+
// source: the exec request carries message/model/cwd/... but no systemPrompt
|
|
24
|
+
// field). The host's OFFICIAL channel for per-workspace instruction content
|
|
25
|
+
// is the workspace AGENTS.md file, which the runtime loads into its system
|
|
26
|
+
// prompt composition (loadWorkspaceBootstrapFiles → prompt-composition). This
|
|
27
|
+
// adapter therefore writes the probe's system instructions to
|
|
28
|
+
// `<round-dir>/AGENTS.md` and points `--cwd` at that round directory — the
|
|
29
|
+
// host genuinely loads them as system context. This is NOT claimed to be a
|
|
30
|
+
// provider-level `system` role message; the evidence record carries
|
|
31
|
+
// `systemPromptChannel: 'workspace-agents-md'` so no report can upgrade it.
|
|
32
|
+
//
|
|
33
|
+
// Workspace isolation (S5 P1-3): every round gets its OWN directory under the
|
|
34
|
+
// caller-provided roundsRoot (inside the runner's controlled workRoot) and
|
|
35
|
+
// `--cwd` points there — no probe round ever runs inside the configured
|
|
36
|
+
// OpenClaw agent workspace, and no round directory is shared with the next.
|
|
37
|
+
// `--isolated` isolates session/state; the per-round cwd isolates the FILE
|
|
38
|
+
// system workspace; both facts are recorded as such. Runtime trajectories
|
|
39
|
+
// under openclawHome are RESEARCH EVIDENCE and are never cleaned by this
|
|
40
|
+
// adapter; round dirs live under the runner's .tmp workRoot and are governed
|
|
41
|
+
// by the runner's cleanup policy.
|
|
42
|
+
//
|
|
43
|
+
// What this module is NOT:
|
|
44
|
+
// - not a gateway client: the probe spawns the owner's `openclaw` CLI
|
|
45
|
+
// directly (process boundary, same class as the Codex adapter);
|
|
46
|
+
// - not identity proof by itself: envelope.model/provider are HOST-REPORTED
|
|
47
|
+
// fields; corroboration always goes through the runtime trajectory file;
|
|
48
|
+
// - not a rewrite of the Codex path: host-actor.mjs stays untouched; the
|
|
49
|
+
// probe authorization's targetHost.kind selects the adapter.
|
|
50
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
51
|
+
import fs from 'node:fs';
|
|
52
|
+
import os from 'node:os';
|
|
53
|
+
import path from 'node:path';
|
|
54
|
+
import { createRequire } from 'node:module';
|
|
55
|
+
|
|
56
|
+
export const OPENCLAW_TRAJECTORY_POINTER_SCHEMA = 'openclaw-trajectory-pointer';
|
|
57
|
+
export const OPENCLAW_TRAJECTORY_SCHEMA = 'openclaw-trajectory';
|
|
58
|
+
export const OPENCLAW_SYSTEM_PROMPT_CHANNEL = 'workspace-agents-md';
|
|
59
|
+
/** S7 §四: the AMBIENT OpenClaw state root — where the shared auth store
|
|
60
|
+
* (existing zai credentials) and the agents' persisted sessions live. The
|
|
61
|
+
* probe deliberately does NOT repoint this (no --state-dir): repointing
|
|
62
|
+
* yields an empty auth store (attempt-3 could never authenticate) and an
|
|
63
|
+
* environment that no real user session resembles. */
|
|
64
|
+
export function resolveAmbientStateRoot(env = process.env) {
|
|
65
|
+
const explicit = typeof env.OPENCLAW_STATE_DIR === 'string' && env.OPENCLAW_STATE_DIR.trim().length > 0 ? env.OPENCLAW_STATE_DIR.trim() : null;
|
|
66
|
+
const home = env.OPENCLAW_HOME || env.USERPROFILE || env.HOME;
|
|
67
|
+
if (explicit) return explicit;
|
|
68
|
+
if (home) return path.join(home, '.openclaw');
|
|
69
|
+
throw new Error('resolveAmbientStateRoot: cannot resolve the ambient OpenClaw state root (no OPENCLAW_STATE_DIR / OPENCLAW_HOME / USERPROFILE / HOME)');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** S9 (PRI-1037 runtime repair): child-process TEMP/TMP normalization.
|
|
73
|
+
*
|
|
74
|
+
* Windows can hand a process an 8.3 short temp path (C:\Users\ADMINI~1\...).
|
|
75
|
+
* OpenClaw's plugin capture then fails with
|
|
76
|
+
* FsSafeError: copy path changed during operation
|
|
77
|
+
* because the same file is addressed by two different spellings during the
|
|
78
|
+
* copy/verify pass. The fix is scoped to the host child we spawn: resolve the
|
|
79
|
+
* real (long) directory and hand ONLY that child TEMP/TMP. The parent/user
|
|
80
|
+
* environment is never mutated.
|
|
81
|
+
*
|
|
82
|
+
* Fail-closed (rc-3): a temp directory that cannot be resolved, is not a
|
|
83
|
+
* directory, or is not writable REFUSES the transport — passing the short
|
|
84
|
+
* path through would reproduce the failure on user machines. */
|
|
85
|
+
export function resolveChildTempEnv({ sourceEnv = process.env, platform = process.platform } = {}) {
|
|
86
|
+
if (platform !== 'win32') return {};
|
|
87
|
+
const declared = [sourceEnv.TEMP, sourceEnv.TMP].find((value) => typeof value === 'string' && value.trim().length > 0);
|
|
88
|
+
const candidate = declared ? declared.trim() : os.tmpdir();
|
|
89
|
+
let resolved;
|
|
90
|
+
try {
|
|
91
|
+
// realpathSync.native resolves 8.3 short names to their long form on Windows.
|
|
92
|
+
resolved = fs.realpathSync.native(candidate);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
throw new Error(`openclaw transport: cannot resolve a real temporary directory for the child process (${candidate}): ${err instanceof Error ? err.message : String(err)}. Refusing to spawn with an unusable TEMP.`);
|
|
95
|
+
}
|
|
96
|
+
// Keep drive roots intact (C:\ must not become C:).
|
|
97
|
+
const normalized = /^[A-Za-z]:\\$/.test(resolved) ? resolved : resolved.replace(/[\\/]+$/, '');
|
|
98
|
+
let stats;
|
|
99
|
+
try {
|
|
100
|
+
stats = fs.statSync(normalized);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
throw new Error(`openclaw transport: resolved temporary directory is not accessible (${normalized}): ${err instanceof Error ? err.message : String(err)}`);
|
|
103
|
+
}
|
|
104
|
+
if (!stats.isDirectory()) {
|
|
105
|
+
throw new Error(`openclaw transport: temporary directory path is not a directory (${normalized}) — the child would receive an unusable TEMP/TMP.`);
|
|
106
|
+
}
|
|
107
|
+
let probeDir;
|
|
108
|
+
try {
|
|
109
|
+
// accessSync(W_OK) is unreliable for directories on Windows; a real
|
|
110
|
+
// create/remove proves the child can use the directory.
|
|
111
|
+
probeDir = fs.mkdtempSync(path.join(normalized, 'pd-child-temp-probe-'));
|
|
112
|
+
} catch (err) {
|
|
113
|
+
throw new Error(`openclaw transport: temporary directory is not writable (${normalized}): ${err instanceof Error ? err.message : String(err)}`);
|
|
114
|
+
}
|
|
115
|
+
fs.rmSync(probeDir, { recursive: true, force: true });
|
|
116
|
+
return { TEMP: normalized, TMP: normalized };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const OPENCLAW_PROBE_AGENTS_HEADER = '<!-- PRI-1037 isolation-probe system instructions (loaded by the OpenClaw runtime as workspace instruction context; NOT a provider-level system role message) -->\n\n';
|
|
120
|
+
|
|
121
|
+
/** Fail-closed local probe: no openclaw binary ⇒ refuse transport creation
|
|
122
|
+
* before any round could ever be attempted. (--version is a local process,
|
|
123
|
+
* not a model call; result doubles as the recorded cliVersion.) */
|
|
124
|
+
function probeOpenclawVersion(openclawBin, openclawPrefixArgs, versionProbe) {
|
|
125
|
+
const probe = versionProbe
|
|
126
|
+
? versionProbe(openclawBin, openclawPrefixArgs)
|
|
127
|
+
: (() => {
|
|
128
|
+
try {
|
|
129
|
+
return spawnSync(openclawBin, [...openclawPrefixArgs, '--version'], { encoding: 'utf8', timeout: 20_000 });
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return { error: err, status: null };
|
|
132
|
+
}
|
|
133
|
+
})();
|
|
134
|
+
const version = typeof probe?.stdout === 'string' && probe.stdout.trim().length > 0 ? probe.stdout.trim().split('\n')[0] : null;
|
|
135
|
+
if (probe?.error || (typeof probe?.status === 'number' && probe.status !== 0)) {
|
|
136
|
+
throw new Error(`createOpenClawHostTransport: openclaw CLI probe failed (${openclawBin} --version): ${probe?.error?.message ?? `exit ${probe?.status}`}. No host rounds can run without the owner's OpenClaw CLI (fail-closed).`);
|
|
137
|
+
}
|
|
138
|
+
return version;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Same suffix discipline as verifyF2ProbeEvidence.sameModel — a provider-
|
|
142
|
+
* prefixed model id ("zai/glm-5.3-flash") matches its bare id. */
|
|
143
|
+
const sameModel = (a, b) => a === b || (typeof a === 'string' && typeof b === 'string' && (a.endsWith(`/${b}`) || b.endsWith(`/${a}`)));
|
|
144
|
+
|
|
145
|
+
/** Extract the exec's JSON envelope from raw stdout. OpenClaw 2026.10.x
|
|
146
|
+
* prints the envelope through writeRuntimeJson, which PRETTY-PRINTS it
|
|
147
|
+
* (JSON.stringify(value, undefined, 2)) — a multi-line document whose only
|
|
148
|
+
* line starting with '{' is the bare opening brace. A single-line scan
|
|
149
|
+
* therefore feeds JSON.parse a lone '{' and always fails: on 2026-10-29 this
|
|
150
|
+
* made the exit-code contract unable to observe a REAL cleanup-kind envelope
|
|
151
|
+
* (probe cycle-2 round-1) and would have broken the success path the same
|
|
152
|
+
* way. Ordered extraction, most-specific first:
|
|
153
|
+
* 1. the whole trimmed output IS the envelope (production --json shape);
|
|
154
|
+
* 2. the span from the first '{' to the last '}' (envelope embedded in
|
|
155
|
+
* other stdout text);
|
|
156
|
+
* 3. the last single-line JSON value (compact variant).
|
|
157
|
+
* Only objects are returned; anything else is treated as no envelope. */
|
|
158
|
+
function extractExecEnvelope(stdoutText) {
|
|
159
|
+
const tryParseObject = (text) => {
|
|
160
|
+
try {
|
|
161
|
+
const value = JSON.parse(text);
|
|
162
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null;
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const trimmed = stdoutText.trim();
|
|
168
|
+
if (trimmed.length === 0) return null;
|
|
169
|
+
const whole = tryParseObject(trimmed);
|
|
170
|
+
if (whole !== null) return whole;
|
|
171
|
+
const first = trimmed.indexOf('{');
|
|
172
|
+
const last = trimmed.lastIndexOf('}');
|
|
173
|
+
if (first >= 0 && last > first) {
|
|
174
|
+
const spanned = tryParseObject(trimmed.slice(first, last + 1));
|
|
175
|
+
if (spanned !== null) return spanned;
|
|
176
|
+
}
|
|
177
|
+
const line = trimmed.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('{')).pop();
|
|
178
|
+
if (line !== undefined) {
|
|
179
|
+
const single = tryParseObject(line);
|
|
180
|
+
if (single !== null) return single;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function createOpenClawHostTransport({
|
|
186
|
+
model,
|
|
187
|
+
expectedProvider = null, // S5 P1-2: the approved provider id (e.g. 'zai'); envelope.provider must match EXACTLY
|
|
188
|
+
agent = 'main',
|
|
189
|
+
openclawBin = 'openclaw',
|
|
190
|
+
openclawPrefixArgs = [], // S5.2 env fix: e.g. [<node>, <openclaw.mjs>] when the npm shim is not directly spawnable on Windows
|
|
191
|
+
openclawHome, // S7 §四: the ambient OpenClaw state root (explicit — never silently derived from process.env)
|
|
192
|
+
env = process.env, // S7.1 §3: injectable environment — the ACTUAL state root is derived from THIS, then checked against openclawHome
|
|
193
|
+
configPath, // S5.2.1: the config file pinned via --config (owner's openclaw.json — zai provider + credentials)
|
|
194
|
+
roundsRoot, // S5 P1-3: controlled root for per-round directories (inside the runner's workRoot)
|
|
195
|
+
timeoutMsPerCall = 180_000,
|
|
196
|
+
spawnProcess = spawn,
|
|
197
|
+
versionProbe = null,
|
|
198
|
+
now = () => Date.now(),
|
|
199
|
+
} = {}) {
|
|
200
|
+
if (typeof model !== 'string' || model.trim().length === 0) {
|
|
201
|
+
throw new Error('createOpenClawHostTransport: model (requested model id, e.g. zai/glm-5.3-flash) is required');
|
|
202
|
+
}
|
|
203
|
+
if (typeof roundsRoot !== 'string' || roundsRoot.trim().length === 0) {
|
|
204
|
+
throw new Error('createOpenClawHostTransport: roundsRoot (controlled root for per-round dirs, S5 P1-3) is required');
|
|
205
|
+
}
|
|
206
|
+
if (typeof configPath !== 'string' || configPath.trim().length === 0) {
|
|
207
|
+
throw new Error('createOpenClawHostTransport: configPath (--config pin, S5.2.1 — the approved provider lives in the owner config) is required');
|
|
208
|
+
}
|
|
209
|
+
if (!fs.existsSync(configPath)) {
|
|
210
|
+
throw new Error(`createOpenClawHostTransport: pinned config not found: ${configPath} (fail-closed)`);
|
|
211
|
+
}
|
|
212
|
+
if (!Number.isFinite(timeoutMsPerCall) || timeoutMsPerCall <= 0) {
|
|
213
|
+
throw new Error('createOpenClawHostTransport: timeoutMsPerCall must be a positive finite number');
|
|
214
|
+
}
|
|
215
|
+
// S9: resolve once, before any process exists — a temp dir the host child
|
|
216
|
+
// cannot use is a refusal, not a round-level failure.
|
|
217
|
+
const childTempEnv = resolveChildTempEnv({ sourceEnv: env });
|
|
218
|
+
const cliVersion = probeOpenclawVersion(openclawBin, openclawPrefixArgs, versionProbe);
|
|
219
|
+
// S7.1 §3 — ambient state root contract: the sessions evidence dir is
|
|
220
|
+
// derived from the DECLARED openclawHome, and the ACTUAL state root (from
|
|
221
|
+
// the injected env: OPENCLAW_STATE_DIR, else the home directory) must be
|
|
222
|
+
// the same directory. A declared root that disagrees with the environment
|
|
223
|
+
// (e.g. OPENCLAW_STATE_DIR redirected elsewhere) is refused BEFORE any
|
|
224
|
+
// spawn — the openclawHome string alone is never treated as evidence that
|
|
225
|
+
// the runtime will actually use it. Comparison is normalized-resolved and
|
|
226
|
+
// case-insensitive (Windows drive casing).
|
|
227
|
+
const actualStateRoot = resolveAmbientStateRoot(env);
|
|
228
|
+
const norm = (p) => path.resolve(String(p)).replace(/[\\/]+$/, '').toLowerCase();
|
|
229
|
+
if (norm(actualStateRoot) !== norm(openclawHome)) {
|
|
230
|
+
throw new Error(`createOpenClawHostTransport: ambient state root mismatch — the environment resolves to '${actualStateRoot}' but the authorization declares '${openclawHome}'. Refusing before any spawn (S7.1 §3: a declared openclawHome is not evidence of the actual runtime root).`);
|
|
231
|
+
}
|
|
232
|
+
// S7 §四/S9 cycle-2: the ambient root above remains the AUTH anchor (the
|
|
233
|
+
// zai credentials live there and agent-exec bridges them regardless of
|
|
234
|
+
// --state-dir). Per-round session EVIDENCE now lives in each round's own
|
|
235
|
+
// state dir (see complete()) — the ambient sessions dir is never read.
|
|
236
|
+
fs.mkdirSync(roundsRoot, { recursive: true });
|
|
237
|
+
|
|
238
|
+
const identity = {
|
|
239
|
+
requestedModel: model,
|
|
240
|
+
observedModel: null,
|
|
241
|
+
observedModelProviderId: null,
|
|
242
|
+
evidenceSource: null,
|
|
243
|
+
cliVersion,
|
|
244
|
+
status: 'MODEL_IDENTITY_UNVERIFIED', // until the HOST's own envelope reports its model
|
|
245
|
+
};
|
|
246
|
+
const sessionRecords = [];
|
|
247
|
+
let rounds = 0;
|
|
248
|
+
|
|
249
|
+
async function complete({ taskId, systemPrompt, userPrompt }) {
|
|
250
|
+
if (typeof taskId !== 'string' || taskId.length === 0) throw new Error('openclaw transport: taskId is required (opportunity segment → fresh session)');
|
|
251
|
+
if (typeof userPrompt !== 'string' || userPrompt.length === 0) throw new Error('openclaw transport: userPrompt is required');
|
|
252
|
+
if (typeof systemPrompt !== 'string' || systemPrompt.length === 0) throw new Error('openclaw transport: systemPrompt is required (S5 P1-1: the probe must always declare its instructions)');
|
|
253
|
+
rounds += 1;
|
|
254
|
+
// Per-round directory: fresh file-system workspace per probe round; never
|
|
255
|
+
// shared with another round; never the configured agent workspace.
|
|
256
|
+
const roundDir = fs.mkdtempSync(path.join(roundsRoot, `round-${String(rounds).padStart(2, '0')}-`));
|
|
257
|
+
const messageFile = path.join(roundDir, 'probe-message.txt');
|
|
258
|
+
const agentsFile = path.join(roundDir, 'AGENTS.md');
|
|
259
|
+
fs.writeFileSync(messageFile, userPrompt, 'utf8');
|
|
260
|
+
fs.writeFileSync(agentsFile, OPENCLAW_PROBE_AGENTS_HEADER + systemPrompt + '\n', 'utf8');
|
|
261
|
+
// S9 cycle-2 (2026-10-29 mechanism-verified): every round runs with its OWN
|
|
262
|
+
// `--state-dir` under the controlled round directory. Two facts verified
|
|
263
|
+
// against the 2026.10.5 source and a real exec make this strictly better
|
|
264
|
+
// than the S7 ambient-state posture:
|
|
265
|
+
// 1. AUTH is NOT repointed: agent-exec resolves the auth-profile store
|
|
266
|
+
// dirs (storedAuthAgentDir/storedAuthStateDir) from the AMBIENT root
|
|
267
|
+
// BEFORE setAgentExecEnvironment repoints the state dir, and bridges
|
|
268
|
+
// them via withAuthProfileStoreAgentDir — the zai credentials stay
|
|
269
|
+
// reachable (re-run of the attempt-3 scenario; the S7 "repointing
|
|
270
|
+
// empties the auth store" diagnosis was a misattribution: the real
|
|
271
|
+
// attempt-3 killer was per-round plugin installation).
|
|
272
|
+
// 2. A user-provided state dir is NEVER temporary: temporaryStateDir is
|
|
273
|
+
// undefined, so the post-run `fs.rm` that fails EBUSY on Windows (the
|
|
274
|
+
// exec's own still-open agent sqlite) NEVER RUNS — rounds exit 0 with
|
|
275
|
+
// clean ok:true envelopes instead of depending on the cleanup-kind
|
|
276
|
+
// contract, AND the session's runtime trajectory persists in
|
|
277
|
+
// <stateDir>/agents/<agent>/agent/openclaw-agent.sqlite
|
|
278
|
+
// (trajectory_runtime_events) as durable corroboration evidence.
|
|
279
|
+
// (Exec sessions NEVER write the gateway-era sessions/*.jsonl + pointer
|
|
280
|
+
// files — verified 2026-10-29: ambient sessions dir untouched by exec
|
|
281
|
+
// rounds; the S5.2 pointer-file reader remains for gateway-era
|
|
282
|
+
// evidence but cannot corroborate exec sessions.)
|
|
283
|
+
// Session isolation does NOT depend on the state dir: every exec creates a
|
|
284
|
+
// fresh explicit session (own sessionId); workspace isolation comes from
|
|
285
|
+
// the per-round --cwd; state isolation now comes from the per-round
|
|
286
|
+
// --state-dir (real per-round state separation, stronger than ambient).
|
|
287
|
+
//
|
|
288
|
+
// S5.2.1 probe-round fix (real-execution evidence): the CLI option surface
|
|
289
|
+
// is `agent exec --help` (2026.10.5) — there is NO --agent flag, and
|
|
290
|
+
// --isolated means "read NO config". The owner's config file is pinned via
|
|
291
|
+
// --config (read-only reference; reproducible; credentials loaded).
|
|
292
|
+
const roundStateDir = path.join(roundDir, 'state');
|
|
293
|
+
fs.mkdirSync(roundStateDir, { recursive: true }); // --state-dir requires an EXISTING directory (2026.10.5 CLI)
|
|
294
|
+
const args = [
|
|
295
|
+
'agent', 'exec',
|
|
296
|
+
'--config', configPath, // pin the owner's config (zai provider + credentials); never the ambient runtime snapshot
|
|
297
|
+
'--state-dir', roundStateDir, // S9 cycle-2: per-round state — trajectories persist here as evidence; no temp rm ⇒ no EBUSY
|
|
298
|
+
'--model', model,
|
|
299
|
+
'--cwd', roundDir, // S5 P1-3: the round dir IS the workspace for this round
|
|
300
|
+
'--timeout', String(Math.ceil(timeoutMsPerCall / 1000)),
|
|
301
|
+
'--json',
|
|
302
|
+
'--message-file', messageFile,
|
|
303
|
+
];
|
|
304
|
+
const t0 = now();
|
|
305
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
306
|
+
// S9: the child alone gets the normalized long-form TEMP/TMP; everything
|
|
307
|
+
// else stays exactly the inherited environment.
|
|
308
|
+
const child = spawnProcess(openclawBin, [...openclawPrefixArgs, ...args], { cwd: roundDir, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ...childTempEnv } });
|
|
309
|
+
let out = '';
|
|
310
|
+
let err = '';
|
|
311
|
+
const timer = setTimeout(() => {
|
|
312
|
+
child.kill('SIGKILL');
|
|
313
|
+
// code:'timeout' — the adapter-classified ambiguity marker (createHostActorAdapter
|
|
314
|
+
// rethrows it as ambiguous: the exec was dispatched and killed at the wall
|
|
315
|
+
// clock, so whether the model ran is unknowable → RESULT_UNKNOWN, never
|
|
316
|
+
// a clean failure).
|
|
317
|
+
reject(Object.assign(new Error(`openclaw exec timed out after ${timeoutMsPerCall}ms (round ${rounds}, task ${taskId})`), { code: 'timeout' }));
|
|
318
|
+
}, timeoutMsPerCall);
|
|
319
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
320
|
+
child.stderr.on('data', (d) => { err += d; });
|
|
321
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
322
|
+
child.on('close', (code) => {
|
|
323
|
+
clearTimeout(timer);
|
|
324
|
+
// Exit-code contract (S9.1, task E): OpenClaw stamps POST-RUN cleanup
|
|
325
|
+
// failures with a dedicated error kind ("cleanup") and OVERWRITES an
|
|
326
|
+
// otherwise-successful envelope (ok flipped false, exit 1) while the
|
|
327
|
+
// round data (final/payloads/usage/model/provider/sessionId) stays
|
|
328
|
+
// intact — observed on Windows where the exec's own agent owner.sqlite
|
|
329
|
+
// stays locked until process exit. A non-zero exit therefore refuses
|
|
330
|
+
// ONLY when no complete successful-round envelope is present; a
|
|
331
|
+
// cleanup-kind envelope with full round data resolves and carries the
|
|
332
|
+
// anomaly in the evidence (rc-9, never silent). Every other non-zero
|
|
333
|
+
// exit (model failure, no output, timeout kill) still rejects.
|
|
334
|
+
const envelopeHint = extractExecEnvelope(out);
|
|
335
|
+
if (code !== 0) {
|
|
336
|
+
let cleanupRound = false;
|
|
337
|
+
if (envelopeHint) {
|
|
338
|
+
cleanupRound = Boolean(envelopeHint.error) && envelopeHint.error.kind === 'cleanup'
|
|
339
|
+
&& typeof envelopeHint.sessionId === 'string' && envelopeHint.sessionId.length > 0
|
|
340
|
+
&& typeof envelopeHint.model === 'string' && envelopeHint.model.length > 0
|
|
341
|
+
&& typeof envelopeHint.final === 'string' && envelopeHint.final.length > 0;
|
|
342
|
+
}
|
|
343
|
+
if (!cleanupRound) {
|
|
344
|
+
// rc-9 diagnostic priority: when the host printed a structured
|
|
345
|
+
// envelope, ITS error is the truth — 2026.10.x stderr leads with
|
|
346
|
+
// multi-line config warnings that bury the actual failure line
|
|
347
|
+
// (observed 2026-10-29: 400-char head was 100% config warnings).
|
|
348
|
+
// A success-shaped envelope without an error field carries no
|
|
349
|
+
// explanation, so stderr keeps priority there.
|
|
350
|
+
const envelopeDiagnostic = envelopeHint && envelopeHint.error
|
|
351
|
+
? `envelope(status=${JSON.stringify(envelopeHint.status ?? null)}, error.kind=${JSON.stringify(envelopeHint.error.kind ?? null)}, error.message=${String(envelopeHint.error.message ?? '').slice(0, 200)})`
|
|
352
|
+
: null;
|
|
353
|
+
const hostDiagnostic = (envelopeDiagnostic ?? (err.trim().length > 0 ? err.trim() : 'no host diagnostic')).slice(0, 400);
|
|
354
|
+
reject(new Error(`openclaw exec exited ${code}: ${hostDiagnostic}`));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// fall through: the cleanup-failed round's envelope is the evidence.
|
|
358
|
+
}
|
|
359
|
+
resolve(out);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
const elapsedMs = now() - t0;
|
|
363
|
+
|
|
364
|
+
// The envelope is the HOST's own report: final text, usage, model,
|
|
365
|
+
// provider, and the session id the runtime assigned this fresh session.
|
|
366
|
+
// 2026.10.x pretty-prints it (writeRuntimeJson, space=2) — extraction is
|
|
367
|
+
// shape-agnostic (extractExecEnvelope), never single-line.
|
|
368
|
+
const envelope = extractExecEnvelope(stdout);
|
|
369
|
+
if (!envelope) {
|
|
370
|
+
throw new Error(`openclaw exec produced no parseable JSON envelope (round ${rounds}, task ${taskId}); stdout head: ${stdout.slice(0, 200)}`);
|
|
371
|
+
}
|
|
372
|
+
if (!envelope || typeof envelope.sessionId !== 'string' || envelope.sessionId.length === 0) {
|
|
373
|
+
throw new Error(`openclaw exec envelope carries no sessionId (round ${rounds}, task ${taskId}) — session independence unprovable, refusing`);
|
|
374
|
+
}
|
|
375
|
+
// S9.1 task E: a cleanup-kind envelope reached here only through the
|
|
376
|
+
// exit-code contract above (round data complete). The envelope's ok=false
|
|
377
|
+
// reflects POST-RUN cleanup only; the round itself is classified below by
|
|
378
|
+
// its model/provider identity and final text. Any non-cleanup error still
|
|
379
|
+
// refuses here.
|
|
380
|
+
const cleanupKind = envelope.error && envelope.error.kind === 'cleanup';
|
|
381
|
+
if (envelope.status === 'timeout' || (envelope.ok !== true && !cleanupKind)) {
|
|
382
|
+
throw new Error(`openclaw exec failed (status=${String(envelope.status)}, error=${String(envelope.error && envelope.error.message).slice(0, 200)})`);
|
|
383
|
+
}
|
|
384
|
+
// host-reported identity → snapshot (S5 P1-2: BOTH model and provider must
|
|
385
|
+
// match; a model-name match with a provider mismatch is a REFUSAL, and a
|
|
386
|
+
// missing provider field cannot verify anything).
|
|
387
|
+
let roundIdentityStatus = 'MODEL_IDENTITY_UNVERIFIED';
|
|
388
|
+
if (typeof envelope.model === 'string' && envelope.model.length > 0) {
|
|
389
|
+
identity.observedModel = envelope.model;
|
|
390
|
+
identity.observedModelProviderId = typeof envelope.provider === 'string' && envelope.provider.length > 0 ? envelope.provider : null;
|
|
391
|
+
identity.evidenceSource = `exec-envelope(round ${rounds}, sessionId ${envelope.sessionId}) + runtime trajectory corroboration`;
|
|
392
|
+
if (identity.observedModelProviderId === null) {
|
|
393
|
+
roundIdentityStatus = 'PROVIDER_UNREPORTED';
|
|
394
|
+
} else if (expectedProvider !== null && envelope.provider !== expectedProvider) {
|
|
395
|
+
roundIdentityStatus = 'PROVIDER_MISMATCH';
|
|
396
|
+
} else if (!sameModel(envelope.model, model)) {
|
|
397
|
+
roundIdentityStatus = 'MODEL_IDENTITY_MISMATCH';
|
|
398
|
+
} else {
|
|
399
|
+
roundIdentityStatus = 'VERIFIED';
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
identity.status = roundIdentityStatus;
|
|
403
|
+
if (roundIdentityStatus !== 'VERIFIED') {
|
|
404
|
+
throw new Error(`openclaw exec round ${rounds} identity not VERIFIED (status=${roundIdentityStatus}, envelope model=${JSON.stringify(envelope.model ?? null)}, provider=${JSON.stringify(envelope.provider ?? null)}, expected provider=${JSON.stringify(expectedProvider)}) — refusing (S5 P1-2: provider mismatch is a hard refusal, never normalized)`);
|
|
405
|
+
}
|
|
406
|
+
sessionRecords.push({ taskId, sessionId: envelope.sessionId, rounds: 1, elapsedMs, model: envelope.model ?? null, provider: envelope.provider ?? null, roundDir, stateDir: roundStateDir, stateAgentDb: path.join(roundStateDir, 'agents', agent, 'agent', 'openclaw-agent.sqlite'), systemPromptChannel: OPENCLAW_SYSTEM_PROMPT_CHANNEL, hostCleanupAnomaly: envelope.ok !== true && envelope.error && envelope.error.kind === 'cleanup' ? String(envelope.error.message).slice(0, 200) : null });
|
|
407
|
+
|
|
408
|
+
const payloadText = typeof envelope.final === 'string' && envelope.final.length > 0
|
|
409
|
+
? envelope.final
|
|
410
|
+
: (envelope.payloads || []).filter((p) => p.isError !== true).map((p) => p.text || '').join('\n');
|
|
411
|
+
return { payload: payloadText, hostRound: { sessionId: envelope.sessionId, elapsedMs, envelope, usage: envelope.usage ?? null, roundDir } };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function hostSnapshot() {
|
|
415
|
+
return {
|
|
416
|
+
requestedModel: identity.requestedModel,
|
|
417
|
+
observedModel: identity.observedModel,
|
|
418
|
+
observedModelProviderId: identity.observedModelProviderId,
|
|
419
|
+
identityStatus: identity.status,
|
|
420
|
+
identityEvidenceSource: identity.evidenceSource,
|
|
421
|
+
cliVersion,
|
|
422
|
+
hostKind: 'openclaw',
|
|
423
|
+
agent,
|
|
424
|
+
childTempDir: childTempEnv.TEMP ?? null,
|
|
425
|
+
rounds,
|
|
426
|
+
systemPromptChannel: OPENCLAW_SYSTEM_PROMPT_CHANNEL,
|
|
427
|
+
roundsRoot,
|
|
428
|
+
sessions: sessionRecords.map((s) => s.sessionId),
|
|
429
|
+
sessionDetails: sessionRecords.map((s) => ({ sessionId: s.sessionId, roundDir: s.roundDir, stateDir: s.stateDir, stateAgentDb: s.stateAgentDb, hostCleanupAnomaly: s.hostCleanupAnomaly ?? null })),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return { complete, hostSnapshot, identity, sessionRecords, cliVersion };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Corroborate one OpenClaw session against the RUNTIME's own trajectory.
|
|
437
|
+
* Resolution is id-exact (the pointer file names the session id; the runtime
|
|
438
|
+
* file is read from the pointer, never guessed by timestamp/cwd).
|
|
439
|
+
*
|
|
440
|
+
* S5 P1-4 evidence trustworthiness: `trustedRoot` (the OpenClaw runtime
|
|
441
|
+
* evidence root, e.g. <openclawHome>/agents/<agent>/sessions) is MANDATORY —
|
|
442
|
+
* the runtime file must resolve INSIDE it (realpath comparison defeats `..`
|
|
443
|
+
* and symlink escapes); a corrupt trajectory line, or more than one
|
|
444
|
+
* session.started for the session, is refused (never "a similar line
|
|
445
|
+
* somewhere" as identity). */
|
|
446
|
+
export function readOpenClawTrajectoryEvidence({ sessionsDir, sessionId, trustedRoot = null }) {
|
|
447
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
448
|
+
return { found: false, ambiguous: false, reason: 'empty session id' };
|
|
449
|
+
}
|
|
450
|
+
if (typeof sessionsDir !== 'string' || sessionsDir.length === 0) {
|
|
451
|
+
return { found: false, ambiguous: false, reason: 'sessionsDir not provided' };
|
|
452
|
+
}
|
|
453
|
+
if (typeof trustedRoot !== 'string' || trustedRoot.length === 0) {
|
|
454
|
+
return { found: false, ambiguous: false, reason: 'trustedRoot not provided — runtime files outside a verified evidence root are never accepted (S5 P1-4, fail-closed)' };
|
|
455
|
+
}
|
|
456
|
+
if (sessionId.includes('/') || sessionId.includes('\\') || sessionId.includes('..')) {
|
|
457
|
+
return { found: false, ambiguous: false, reason: 'session id must be a bare id (no path separators)' };
|
|
458
|
+
}
|
|
459
|
+
const pointerFile = path.join(sessionsDir, `${sessionId}.trajectory-path.json`);
|
|
460
|
+
if (!fs.existsSync(pointerFile)) {
|
|
461
|
+
return { found: false, ambiguous: false, reason: `no trajectory pointer for session ${sessionId} under ${sessionsDir}` };
|
|
462
|
+
}
|
|
463
|
+
let pointer = null;
|
|
464
|
+
try {
|
|
465
|
+
pointer = JSON.parse(fs.readFileSync(pointerFile, 'utf8'));
|
|
466
|
+
} catch (e) {
|
|
467
|
+
return { found: false, ambiguous: false, reason: `trajectory pointer unreadable: ${e.message}` };
|
|
468
|
+
}
|
|
469
|
+
if (!pointer || pointer.traceSchema !== OPENCLAW_TRAJECTORY_POINTER_SCHEMA || pointer.sessionId !== sessionId || typeof pointer.runtimeFile !== 'string') {
|
|
470
|
+
return { found: false, ambiguous: false, reason: 'trajectory pointer schema/session mismatch' };
|
|
471
|
+
}
|
|
472
|
+
// Containment: the runtime file must live inside the trusted evidence root
|
|
473
|
+
// (realpath on both sides defeats `..` segments and symlink escapes).
|
|
474
|
+
let runtimeReal = null;
|
|
475
|
+
let trustedReal = null;
|
|
476
|
+
try {
|
|
477
|
+
runtimeReal = fs.realpathSync(pointer.runtimeFile);
|
|
478
|
+
trustedReal = fs.realpathSync(trustedRoot);
|
|
479
|
+
} catch (e) {
|
|
480
|
+
return { found: false, ambiguous: false, reason: `runtime trajectory path unresolvable: ${e.message}` };
|
|
481
|
+
}
|
|
482
|
+
if (!(runtimeReal === trustedReal || runtimeReal.startsWith(trustedReal + path.sep))) {
|
|
483
|
+
return { found: false, ambiguous: false, reason: `runtime trajectory file escapes the trusted evidence root (${pointer.runtimeFile}) — refusing (S5 P1-4)` };
|
|
484
|
+
}
|
|
485
|
+
let rawLines = null;
|
|
486
|
+
try {
|
|
487
|
+
rawLines = fs.readFileSync(pointer.runtimeFile, 'utf8').split('\n').filter((l) => l.trim());
|
|
488
|
+
} catch (e) {
|
|
489
|
+
return { found: false, ambiguous: false, reason: `runtime trajectory unreadable: ${e.message}` };
|
|
490
|
+
}
|
|
491
|
+
let lines = null;
|
|
492
|
+
try {
|
|
493
|
+
lines = rawLines.map((l) => JSON.parse(l));
|
|
494
|
+
} catch (e) {
|
|
495
|
+
return { found: false, ambiguous: false, reason: `runtime trajectory corrupt (unparseable line): ${e.message}` };
|
|
496
|
+
}
|
|
497
|
+
// S5.1 §五: every event line must declare the openclaw-trajectory schema —
|
|
498
|
+
// a file that merely CONTAINS a similar line is not host evidence.
|
|
499
|
+
const badSchema = lines.findIndex((l) => !l || l.traceSchema !== OPENCLAW_TRAJECTORY_SCHEMA);
|
|
500
|
+
if (badSchema >= 0) {
|
|
501
|
+
return { found: false, ambiguous: false, reason: `runtime trajectory line ${badSchema + 1} does not declare traceSchema '${OPENCLAW_TRAJECTORY_SCHEMA}' — not host evidence (S5.1)` };
|
|
502
|
+
}
|
|
503
|
+
const starteds = lines.filter((l) => l && l.type === 'session.started' && l.sessionId === sessionId);
|
|
504
|
+
if (starteds.length === 0) {
|
|
505
|
+
return { found: false, ambiguous: false, reason: `trajectory carries no session.started for ${sessionId}` };
|
|
506
|
+
}
|
|
507
|
+
if (starteds.length > 1) {
|
|
508
|
+
return { found: false, ambiguous: true, reason: `trajectory carries ${starteds.length} session.started records for ${sessionId} — cannot uniquely locate (S5 P1-4)` };
|
|
509
|
+
}
|
|
510
|
+
const started = starteds[0];
|
|
511
|
+
const model = typeof started.modelId === 'string' && started.modelId.length > 0 ? started.modelId : (typeof started.model === 'string' && started.model.length > 0 ? started.model : null);
|
|
512
|
+
const provider = typeof started.provider === 'string' && started.provider.length > 0 ? started.provider : null;
|
|
513
|
+
if (!model || !provider) {
|
|
514
|
+
return { found: false, ambiguous: false, reason: 'trajectory session.started carries no model or provider identity — cannot corroborate (S5 P1-2: both are required)' };
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
found: true,
|
|
518
|
+
ambiguous: false,
|
|
519
|
+
sessionId,
|
|
520
|
+
model,
|
|
521
|
+
provider,
|
|
522
|
+
modelApi: typeof started.modelApi === 'string' ? started.modelApi : null,
|
|
523
|
+
workspaceDir: started.workspaceDir ?? null,
|
|
524
|
+
trajectoryFile: pointer.runtimeFile,
|
|
525
|
+
source: 'openclaw-runtime-trajectory',
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Corroborate one OpenClaw EXEC session against the runtime's own trajectory
|
|
530
|
+
* inside the exec's per-round state database. `agent exec` (2026.10.x)
|
|
531
|
+
* persists trajectories in
|
|
532
|
+
* <stateDir>/agents/<agent>/agent/openclaw-agent.sqlite (table
|
|
533
|
+
* trajectory_runtime_events, one JSON line per event) — it NEVER writes the
|
|
534
|
+
* gateway-era sessions/*.jsonl + pointer files (verified 2026-10-29: a real
|
|
535
|
+
* exec leaves the ambient sessions dir untouched; the trajectory only ever
|
|
536
|
+
* existed in the temp state db, which a clean run deletes — the per-round
|
|
537
|
+
* --state-dir posture is what makes the evidence durable at all).
|
|
538
|
+
*
|
|
539
|
+
* Same fail-closed discipline as readOpenClawTrajectoryEvidence: the db must
|
|
540
|
+
* resolve INSIDE trustedRoot (the round's controlled state dir), every event
|
|
541
|
+
* line must declare the openclaw-trajectory schema, exactly one
|
|
542
|
+
* session.started may carry the session id, and BOTH model and provider are
|
|
543
|
+
* required. */
|
|
544
|
+
export function readOpenClawExecStateTrajectory({ stateAgentDb, sessionId, trustedRoot = null }) {
|
|
545
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
546
|
+
return { found: false, ambiguous: false, reason: 'empty session id' };
|
|
547
|
+
}
|
|
548
|
+
if (typeof stateAgentDb !== 'string' || stateAgentDb.length === 0) {
|
|
549
|
+
return { found: false, ambiguous: false, reason: 'stateAgentDb not provided' };
|
|
550
|
+
}
|
|
551
|
+
if (typeof trustedRoot !== 'string' || trustedRoot.length === 0) {
|
|
552
|
+
return { found: false, ambiguous: false, reason: 'trustedRoot not provided — runtime evidence outside a verified root is never accepted (S5 P1-4, fail-closed)' };
|
|
553
|
+
}
|
|
554
|
+
if (sessionId.includes('/') || sessionId.includes('\\') || sessionId.includes('..')) {
|
|
555
|
+
return { found: false, ambiguous: false, reason: 'session id must be a bare id (no path separators)' };
|
|
556
|
+
}
|
|
557
|
+
if (!fs.existsSync(stateAgentDb)) {
|
|
558
|
+
return { found: false, ambiguous: false, reason: `exec state database does not exist: ${stateAgentDb}` };
|
|
559
|
+
}
|
|
560
|
+
// Containment: the db must live inside the round's controlled state root
|
|
561
|
+
// (realpath on both sides defeats `..` segments and symlink escapes).
|
|
562
|
+
let dbReal = null;
|
|
563
|
+
let trustedReal = null;
|
|
564
|
+
try {
|
|
565
|
+
dbReal = fs.realpathSync(stateAgentDb);
|
|
566
|
+
trustedReal = fs.realpathSync(trustedRoot);
|
|
567
|
+
} catch (e) {
|
|
568
|
+
return { found: false, ambiguous: false, reason: `exec state path unresolvable: ${e.message}` };
|
|
569
|
+
}
|
|
570
|
+
if (!(dbReal === trustedReal || dbReal.startsWith(trustedReal + path.sep))) {
|
|
571
|
+
return { found: false, ambiguous: false, reason: `exec state database escapes the controlled state root (${stateAgentDb}) — refusing (S5 P1-4)` };
|
|
572
|
+
}
|
|
573
|
+
let Database = null;
|
|
574
|
+
try {
|
|
575
|
+
// Lazy + synchronous (better-sqlite3 is CJS): the native module is a
|
|
576
|
+
// repository dependency of the dev lab, not a runtime contract of this
|
|
577
|
+
// transport's spawn path.
|
|
578
|
+
Database = createRequire(import.meta.url)('better-sqlite3');
|
|
579
|
+
} catch (e) {
|
|
580
|
+
return { found: false, ambiguous: false, reason: `cannot load better-sqlite3 for exec-state corroboration: ${e.message}` };
|
|
581
|
+
}
|
|
582
|
+
let db = null;
|
|
583
|
+
let rows = null;
|
|
584
|
+
try {
|
|
585
|
+
db = new Database(dbReal, { readonly: true, fileMustExist: true });
|
|
586
|
+
rows = db.prepare('SELECT seq, event_json FROM trajectory_runtime_events WHERE session_id = ? ORDER BY seq').all(sessionId);
|
|
587
|
+
} catch (e) {
|
|
588
|
+
return { found: false, ambiguous: false, reason: `exec state database unreadable: ${e.message}` };
|
|
589
|
+
} finally {
|
|
590
|
+
if (db !== null) db.close();
|
|
591
|
+
}
|
|
592
|
+
if (rows.length === 0) {
|
|
593
|
+
return { found: false, ambiguous: false, reason: `no trajectory events for session ${sessionId} in the exec state database` };
|
|
594
|
+
}
|
|
595
|
+
let lines = null;
|
|
596
|
+
try {
|
|
597
|
+
lines = rows.map((r) => JSON.parse(r.event_json));
|
|
598
|
+
} catch (e) {
|
|
599
|
+
return { found: false, ambiguous: false, reason: `exec state trajectory corrupt (unparseable event): ${e.message}` };
|
|
600
|
+
}
|
|
601
|
+
// S5.1 §五: every event line must declare the openclaw-trajectory schema —
|
|
602
|
+
// a database that merely CONTAINS a similar line is not host evidence.
|
|
603
|
+
const badSchema = lines.findIndex((l) => !l || l.traceSchema !== OPENCLAW_TRAJECTORY_SCHEMA);
|
|
604
|
+
if (badSchema >= 0) {
|
|
605
|
+
return { found: false, ambiguous: false, reason: `exec state trajectory event ${badSchema + 1} does not declare traceSchema '${OPENCLAW_TRAJECTORY_SCHEMA}' — not host evidence (S5.1)` };
|
|
606
|
+
}
|
|
607
|
+
const starteds = lines.filter((l) => l && l.type === 'session.started' && l.sessionId === sessionId);
|
|
608
|
+
if (starteds.length === 0) {
|
|
609
|
+
return { found: false, ambiguous: false, reason: `exec state trajectory carries no session.started for ${sessionId}` };
|
|
610
|
+
}
|
|
611
|
+
if (starteds.length > 1) {
|
|
612
|
+
return { found: false, ambiguous: true, reason: `exec state trajectory carries ${starteds.length} session.started records for ${sessionId} — cannot uniquely locate (S5 P1-4)` };
|
|
613
|
+
}
|
|
614
|
+
const started = starteds[0];
|
|
615
|
+
const model = typeof started.modelId === 'string' && started.modelId.length > 0 ? started.modelId : (typeof started.model === 'string' && started.model.length > 0 ? started.model : null);
|
|
616
|
+
const provider = typeof started.provider === 'string' && started.provider.length > 0 ? started.provider : null;
|
|
617
|
+
if (!model || !provider) {
|
|
618
|
+
return { found: false, ambiguous: false, reason: 'exec state trajectory session.started carries no model or provider identity — cannot corroborate (S5 P1-2: both are required)' };
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
found: true,
|
|
622
|
+
ambiguous: false,
|
|
623
|
+
sessionId,
|
|
624
|
+
model,
|
|
625
|
+
provider,
|
|
626
|
+
modelApi: typeof started.modelApi === 'string' ? started.modelApi : null,
|
|
627
|
+
stateAgentDb: dbReal,
|
|
628
|
+
source: 'openclaw-exec-state-trajectory',
|
|
629
|
+
};
|
|
630
|
+
}
|