create-principles-disciple 1.145.43 → 1.146.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/_release/product-identity.json +2 -2
- package/console/dist/server/routes/examiner-executor.d.ts +8 -2
- package/console/dist/server/routes/examiner-executor.js +10 -4
- package/console/dist/server/routes/examiner.d.ts +23 -17
- package/console/dist/server/routes/examiner.js +30 -27
- package/console/dist/ui/App.js +1 -1
- package/console/dist/ui/components/layout/app-sidebar.d.ts +3 -4
- package/console/dist/ui/components/layout/app-sidebar.js +5 -7
- package/console/dist/ui/i18n/en.json +1 -1
- package/console/dist/ui/i18n/zh-CN.json +1 -1
- package/console/dist/ui/utils/runtime-mode.d.ts +2 -1
- package/console/dist/ui/utils/runtime-mode.js +2 -1
- package/console/dist/web/assets/app.js +23 -23
- 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/core/dist/runtime-v2/pain-recall-capability.d.ts +140 -0
- package/core/dist/runtime-v2/pain-recall-capability.d.ts.map +1 -0
- package/core/dist/runtime-v2/pain-recall-capability.js +334 -0
- package/core/dist/runtime-v2/pain-recall-capability.js.map +1 -0
- package/core/dist/runtime-v2/pain-recall-read-model.d.ts +113 -0
- package/core/dist/runtime-v2/pain-recall-read-model.d.ts.map +1 -0
- package/{pd-cli/dist/services/pain-recall.js → core/dist/runtime-v2/pain-recall-read-model.js} +7 -10
- package/core/dist/runtime-v2/pain-recall-read-model.js.map +1 -0
- package/core/dist/runtime-v2/pain-recall-snapshot-hooks.d.ts +39 -0
- package/core/dist/runtime-v2/pain-recall-snapshot-hooks.d.ts.map +1 -0
- package/core/dist/runtime-v2/pain-recall-snapshot-hooks.js +15 -0
- package/core/dist/runtime-v2/pain-recall-snapshot-hooks.js.map +1 -0
- package/core/package.json +9 -1
- package/package.json +2 -2
- package/pd-cli/dist/commands/pain-recall.js +1 -1
- package/pd-cli/dist/commands/pain-recall.js.map +1 -1
- package/plugin/dist/openclaw.plugin.json +1 -1
- package/plugin/openclaw.plugin.json +1 -1
- package/plugin/package.json +1 -1
- package/release-manager/package.json +2 -2
- package/pd-cli/dist/services/pain-recall.d.ts +0 -192
- package/pd-cli/dist/services/pain-recall.d.ts.map +0 -1
- package/pd-cli/dist/services/pain-recall.js.map +0 -1
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// SPEC-PD-EXAMINER-001 v0.4 §7.2 / §6.4 / §12 — Stage R (Actor answering)
|
|
2
|
+
// authorization gate + real-host baseline discipline (OFFLINE ENFORCEMENT
|
|
3
|
+
// ONLY: no transport, no network, no DB, no ambient clock).
|
|
4
|
+
//
|
|
5
|
+
// Stage G already enforces its side fail-closed (verifyStageGAuthorization in
|
|
6
|
+
// challenge-examiner.mjs runs before reserve/prepare/transport). The challenge
|
|
7
|
+
// runner's actorFactory seam is the ONE place a real model/host actor can
|
|
8
|
+
// enter a challenge study, and until now nothing enforced the Stage R side:
|
|
9
|
+
// the NOT_AUTHORIZED placeholder challenge-studies/CEB-001/run-authorization.json
|
|
10
|
+
// promises "A/B/C 任何真实模型执行在本占位被替换为经 Owner 批准的授权记录前必须
|
|
11
|
+
// 被拒绝" — as prose only, with zero code reading that file. This module is
|
|
12
|
+
// the machine enforcement of exactly that sentence:
|
|
13
|
+
//
|
|
14
|
+
// - every actor that does NOT declare itself an offline stub must pass
|
|
15
|
+
// verifyStageRAuthorization BEFORE the runner reserves budget, marks an
|
|
16
|
+
// attempt PREPARED or invokes a single actor step;
|
|
17
|
+
// - the authorization must bind the freshly recomputed frozen study
|
|
18
|
+
// (studyId/suiteHash/mappingHash/graderHash/studyPlanHash — SPEC §7.2),
|
|
19
|
+
// the plan (arms/cases/repetitions subsets), the attempt cap, and a
|
|
20
|
+
// validity window checked against an INJECTED trusted clock (request
|
|
21
|
+
// inputs can never supply `now`; a null now refuses as clock-unavailable,
|
|
22
|
+
// mirroring the Stage G gate);
|
|
23
|
+
// - F2-real-host additionally requires the PRI-1037 real-host isolation
|
|
24
|
+
// probe to be PASSED and bound to the same suiteHash. The probe itself
|
|
25
|
+
// must execute in a real host and is NOT implemented here; PRI-1037 stays
|
|
26
|
+
// NOT_RUN until that execution happens under its own authorization.
|
|
27
|
+
//
|
|
28
|
+
// realHostBaseline discipline (SPEC §6.4 "不能证明隔离时停止 F2…不能把它标为真实
|
|
29
|
+
// 宿主实验"; §12 No-Go "用 Mock 冒充真实双 LLM/宿主实验"):
|
|
30
|
+
// deriveRealHostBaseline() is the single machine-checkable rule — PASS is
|
|
31
|
+
// reachable ONLY from F2-real-host attempts that carry authorization
|
|
32
|
+
// linkage (probe PASSED) AND observed host evidence (session id + observed
|
|
33
|
+
// model identity from the host's own event stream). Offline-stub attempts,
|
|
34
|
+
// F1 attempts and wiring-only test doubles can never produce PASS.
|
|
35
|
+
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
import { hashOfCanonical } from './challenge-freeze.mjs';
|
|
40
|
+
|
|
41
|
+
export const STAGE_R_AUTH_SCHEMA = 'challenge-stage-authorization.v1';
|
|
42
|
+
export const STAGE_R_STAGE = 'R';
|
|
43
|
+
export const STAGE_R_STATUS_APPROVED = 'APPROVED';
|
|
44
|
+
export const STAGE_R_EXECUTION_MODES = ['F1-direct-model', 'F2-real-host'];
|
|
45
|
+
|
|
46
|
+
// Closed key set, mirroring the Stage G gate discipline: an Owner-issued
|
|
47
|
+
// APPROVED record that carries a field this gate does not know is REFUSED —
|
|
48
|
+
// the gate is updated deliberately when the real record schema is issued,
|
|
49
|
+
// never silently widened. The placeholder keys (purpose / note /
|
|
50
|
+
// requiredBindingsWhenAuthorized) stay legal so the NOT_AUTHORIZED placeholder
|
|
51
|
+
// itself parses; the binding keys are the machine-checkable form of its
|
|
52
|
+
// requiredBindingsWhenAuthorized list.
|
|
53
|
+
export const STAGE_R_AUTH_KEYS = [
|
|
54
|
+
'schema', 'stage', 'status', 'purpose', 'note', 'requiredBindingsWhenAuthorized',
|
|
55
|
+
'studyId', 'suiteHash', 'mappingHash', 'graderHash', 'studyPlanHash',
|
|
56
|
+
'requestedActorIdentity', 'executionMode', 'arms', 'cases', 'repetitions',
|
|
57
|
+
'attemptCap', 'requestCap', 'requestTimeoutMs', 'maxRequestsPerAttempt', 'isolationAcceptance', 'issuedBy', 'validFrom', 'validUntil',
|
|
58
|
+
'revoked', 'halted',
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** Hard ceiling for a per-attempt model-request cap binding: the actor loop's
|
|
62
|
+
* step ceiling (DEFAULT_CHALLENGE_ACTOR_CAPS.maxSteps = 40 in
|
|
63
|
+
* challenge-actor.mjs). A bound value above this is meaningless (the loop
|
|
64
|
+
* terminates earlier) and therefore ambiguous input — refused fail-closed. */
|
|
65
|
+
export const MAX_REQUESTS_PER_ATTEMPT_CEILING = 40;
|
|
66
|
+
|
|
67
|
+
/** Default model-request timeout when the authorization does not bind one.
|
|
68
|
+
* Semantics: node:https req.setTimeout is a SOCKET INACTIVITY timeout (it
|
|
69
|
+
* arms only while no data flows) — NOT a strict wall-clock total deadline. */
|
|
70
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 240_000;
|
|
71
|
+
|
|
72
|
+
/** Read the AUTHORITATIVE Stage R authorization record for a study: the
|
|
73
|
+
* Owner-controlled run-authorization.json inside the study directory (the
|
|
74
|
+
* same file whose NOT_AUTHORIZED placeholder this gate enforces). Real
|
|
75
|
+
* execution must go through THIS reader — the runner never trusts an
|
|
76
|
+
* authorization object passed in memory by a caller (test fixtures are
|
|
77
|
+
* injected by writing this file inside a COPIED study directory, so the
|
|
78
|
+
* production entry and the test wiring share one fail-closed path).
|
|
79
|
+
* Returns null on any read/parse failure — a missing or corrupt authority
|
|
80
|
+
* file refuses, never silently degrades to empty. */
|
|
81
|
+
export function readStageRAuthorization(studyDir) {
|
|
82
|
+
try {
|
|
83
|
+
const raw = fs.readFileSync(path.join(studyDir, 'run-authorization.json'), 'utf8');
|
|
84
|
+
const parsed = JSON.parse(raw);
|
|
85
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Recompute the study-plan binding hash from preregistration.json (never
|
|
92
|
+
* trusts the stored hashes.json value — same discipline as verifyFrozenStudy). */
|
|
93
|
+
export function readStudyPlanHash(studyDir) {
|
|
94
|
+
const prereg = JSON.parse(fs.readFileSync(path.join(studyDir, 'preregistration.json'), 'utf8'));
|
|
95
|
+
return hashOfCanonical(prereg);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Authorization retirement registry (Owner P1-1 isolation scheme, 2026-10-28) ──
|
|
99
|
+
//
|
|
100
|
+
// Archived evidence studies preserve their HISTORICAL execution authorizations
|
|
101
|
+
// byte-exact (run-authorization.json stays APPROVED with its original window).
|
|
102
|
+
// Study-directory budget exhaustion does NOT revoke such a record: the Stage R
|
|
103
|
+
// gate and the per-directory ledger are different checks, so a fresh-ledger
|
|
104
|
+
// study copy could pass the gate while the window is still open. The registry
|
|
105
|
+
// retires those records at the GATE level: a matching record is refused at ANY
|
|
106
|
+
// clock time, against ANY study copy — the registry lives with the lab CODE
|
|
107
|
+
// (module-relative), so copying/truncating study DATA cannot shed it. Deleting
|
|
108
|
+
// registry entries requires editing the repository itself (git-protected),
|
|
109
|
+
// the same trust boundary as the gate code. This does not modify any archived
|
|
110
|
+
// record; retirement is enforced here and only ADDS refusals.
|
|
111
|
+
export const AUTHORIZATION_REVOCATIONS_SCHEMA = 'challenge-authorization-revocations.v1';
|
|
112
|
+
const RETIRED_ENTRY_KEYS = ['studyId', 'suiteHash', 'validFrom', 'validUntil', 'retiredAt', 'reason'];
|
|
113
|
+
|
|
114
|
+
/** Load the retirement registry from the lab's challenge-studies area.
|
|
115
|
+
* A missing file means no retirements (fresh labs); a MALFORMED file throws —
|
|
116
|
+
* a corrupt registry must refuse loudly, never silently ignore retirements. */
|
|
117
|
+
export function readAuthorizationRevocations({ registryPath } = {}) {
|
|
118
|
+
const file = registryPath || path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'challenge-studies', 'authorization-revocations.json');
|
|
119
|
+
let raw;
|
|
120
|
+
try {
|
|
121
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
122
|
+
} catch {
|
|
123
|
+
return []; // no registry = nothing retired
|
|
124
|
+
}
|
|
125
|
+
const parsed = JSON.parse(raw);
|
|
126
|
+
if (!parsed || typeof parsed !== 'object' || parsed.schema !== AUTHORIZATION_REVOCATIONS_SCHEMA || !Array.isArray(parsed.entries)) {
|
|
127
|
+
throw new Error(`readAuthorizationRevocations: malformed registry (${file}) — expected ${AUTHORIZATION_REVOCATIONS_SCHEMA} with entries[]; refusing loudly`);
|
|
128
|
+
}
|
|
129
|
+
for (const e of parsed.entries) {
|
|
130
|
+
if (!e || typeof e !== 'object') throw new Error('readAuthorizationRevocations: malformed entry (not an object)');
|
|
131
|
+
for (const k of RETIRED_ENTRY_KEYS) {
|
|
132
|
+
if (typeof e[k] !== 'string' || e[k].length === 0) throw new Error(`readAuthorizationRevocations: entry missing non-empty '${k}'`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return parsed.entries;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A registry entry matches an authorization record iff the full retirement
|
|
139
|
+
* tuple is identical (study + frozen suite + exact window). A near-miss does
|
|
140
|
+
* NOT retire — the record is then judged on its own merits (window, caps…). */
|
|
141
|
+
export function isRetiredAuthorization(auth, retiredAuthorizations) {
|
|
142
|
+
if (!Array.isArray(retiredAuthorizations) || !auth || typeof auth !== 'object') return false;
|
|
143
|
+
return retiredAuthorizations.some((e) => e.studyId === auth.studyId && e.suiteHash === auth.suiteHash && e.validFrom === auth.validFrom && e.validUntil === auth.validUntil);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* An actor is an offline stub iff it declares itself so: an explicit
|
|
148
|
+
* offlineStub:true flag or a kind string rooted in 'stub'. Everything else —
|
|
149
|
+
* including test doubles that forget to declare — is treated as a REAL actor
|
|
150
|
+
* and must pass the Stage R gate before the runner touches any budget. This
|
|
151
|
+
* is a cooperative boundary between well-meaning callers (like the workspace
|
|
152
|
+
* lease): it makes undeclared real execution fail closed without pretending
|
|
153
|
+
* to detect deliberate mislabeling.
|
|
154
|
+
*/
|
|
155
|
+
export function isOfflineStubActor(actor) {
|
|
156
|
+
if (!actor || typeof actor !== 'object' || typeof actor.nextAction !== 'function') return false;
|
|
157
|
+
if (actor.offlineStub === true) return true;
|
|
158
|
+
return typeof actor.kind === 'string' && actor.kind.startsWith('stub');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Strict ISO-8601 with a MANDATORY timezone (Z or ±HH:MM): a timezone-less
|
|
162
|
+
// timestamp would be parsed as LOCAL time (the validity window silently
|
|
163
|
+
// shifts with the host TZ), and Date.parse also accepts legacy formats
|
|
164
|
+
// ("October 26 2026") and normalizes impossible dates/clocks (2026-02-30 →
|
|
165
|
+
// Mar 2, 24:00 → next day). For an authorization validity window every one
|
|
166
|
+
// of those is an ambiguous input and refuses fail-closed.
|
|
167
|
+
const ISO_8601_TZ_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/;
|
|
168
|
+
|
|
169
|
+
/** Strict ISO-8601-with-timezone parse (shared authority: the gate's
|
|
170
|
+
* validity windows and the isolation-probe evidence timestamps judge time
|
|
171
|
+
* with the SAME strictness — exported for the probe module). */
|
|
172
|
+
export function parseIso(value) {
|
|
173
|
+
if (typeof value !== 'string' || value.length === 0) return null;
|
|
174
|
+
const m = ISO_8601_TZ_RE.exec(value);
|
|
175
|
+
if (!m) return null;
|
|
176
|
+
const [y, mo, d, h, mi, s] = m.slice(1, 7).map(Number);
|
|
177
|
+
if (mo < 1 || mo > 12 || h > 23 || mi > 59 || s > 59) return null;
|
|
178
|
+
if (m[7] !== 'Z' && (Number(m[8]) > 23 || Number(m[9]) > 59)) return null;
|
|
179
|
+
const cal = new Date(0);
|
|
180
|
+
cal.setUTCFullYear(y, mo - 1, d);
|
|
181
|
+
if (cal.getUTCFullYear() !== y || cal.getUTCMonth() !== mo - 1 || cal.getUTCDate() !== d) return null; // impossible calendar dates (Feb 30…) must refuse, not normalize
|
|
182
|
+
const t = Date.parse(value);
|
|
183
|
+
return Number.isFinite(t) ? t : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isNonEmptyStringArray(value) {
|
|
187
|
+
return Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === 'string' && v.length > 0);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Stage R authorization gate — fail-closed check (bindings + plan + budget +
|
|
192
|
+
* validity window + revocation + F2 isolation probe). Runs BEFORE any budget
|
|
193
|
+
* reservation, actor construction or actor step. Boundary semantics:
|
|
194
|
+
* validFrom inclusive, validUntil inclusive (Stage G parity). `now` is
|
|
195
|
+
* INJECTED by the trusted runner; a null now refuses as clock-unavailable.
|
|
196
|
+
* No ambient clock is read anywhere in this module.
|
|
197
|
+
*
|
|
198
|
+
* `declaredExecutionMode` is the RUNNER-CONTROLLED mode (P1-1/P1-5: the
|
|
199
|
+
* execution mode comes from the runner's execution config, never from actor
|
|
200
|
+
* self-report). The gate refuses when the authorization's executionMode does
|
|
201
|
+
* not match it — an F1 authorization can never bless an F2 run and vice
|
|
202
|
+
* versa. `requestCap` is the study-wide REAL MODEL REQUEST budget (an
|
|
203
|
+
* attempt may span many model requests; the attempt cap is not a request
|
|
204
|
+
* budget) — required, positive safe integer, fail-closed when absent.
|
|
205
|
+
*/
|
|
206
|
+
export function verifyStageRAuthorization({ authorizationRecord, bindings, plan, maxAttemptsTotal, now = null, declaredExecutionMode = null, declaredRequestTimeoutMs = undefined, declaredMaxRequestsPerAttempt = undefined, retiredAuthorizations = null }) {
|
|
207
|
+
const reasons = [];
|
|
208
|
+
const auth = authorizationRecord;
|
|
209
|
+
if (!auth || typeof auth !== 'object') return { ok: false, reasons: ['authorization record missing or unreadable'] };
|
|
210
|
+
if (auth.schema !== STAGE_R_AUTH_SCHEMA) reasons.push(`authorization schema must be ${STAGE_R_AUTH_SCHEMA}`);
|
|
211
|
+
if (auth.stage !== STAGE_R_STAGE) reasons.push(`authorization stage '${auth.stage}' must be '${STAGE_R_STAGE}' — Stage G/R records are not interchangeable`);
|
|
212
|
+
if (auth.status !== STAGE_R_STATUS_APPROVED) reasons.push(`authorization status '${auth.status}' is not ${STAGE_R_STATUS_APPROVED} — fail-closed`);
|
|
213
|
+
if (reasons.length > 0) return { ok: false, reasons }; // schema/stage/status gates first — never leak further checks on a malformed record
|
|
214
|
+
|
|
215
|
+
for (const k of Object.keys(auth)) if (!STAGE_R_AUTH_KEYS.includes(k)) reasons.push(`unknown authorization field: ${k}`);
|
|
216
|
+
if (auth.halted === true) reasons.push('authorization halted by owner — stop condition triggered');
|
|
217
|
+
if (auth.revoked === true) reasons.push('authorization revoked by owner');
|
|
218
|
+
|
|
219
|
+
// Retirement registry (Owner P1-1 isolation scheme): an archived historical
|
|
220
|
+
// record that matches a registry entry is refused at ANY clock time and
|
|
221
|
+
// against ANY study copy — fresh-ledger copies included, because the registry
|
|
222
|
+
// travels with the lab code rather than the study data. This never modifies
|
|
223
|
+
// the archived record; it only adds a refusal ground.
|
|
224
|
+
if (isRetiredAuthorization(auth, retiredAuthorizations)) {
|
|
225
|
+
const entry = retiredAuthorizations.find((e) => e.studyId === auth.studyId && e.suiteHash === auth.suiteHash && e.validFrom === auth.validFrom && e.validUntil === auth.validUntil);
|
|
226
|
+
reasons.push(`authorization retired: this historical record (study ${auth.studyId}, window ${auth.validFrom} → ${auth.validUntil}) is registered as retired evidence and must never authorize new execution — see challenge-studies/authorization-revocations.json (${entry.reason})`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!STAGE_R_EXECUTION_MODES.includes(auth.executionMode)) {
|
|
230
|
+
reasons.push(`executionMode must be one of ${STAGE_R_EXECUTION_MODES.join(' | ')} (got '${auth.executionMode}')`);
|
|
231
|
+
}
|
|
232
|
+
if (declaredExecutionMode !== null && auth.executionMode !== declaredExecutionMode) {
|
|
233
|
+
reasons.push(`authorization executionMode '${auth.executionMode}' does not match the runner-declared execution mode '${declaredExecutionMode}' — an F1 authorization cannot bless an F2 run and vice versa`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Request-timeout binding (optional field — schema evolution alongside
|
|
237
|
+
// requestCap): when the authorization binds a requestTimeoutMs, the
|
|
238
|
+
// runner-declared value MUST equal it; when it does not, the default
|
|
239
|
+
// 240000ms is the only approved value and any non-default declaration
|
|
240
|
+
// refuses. This keeps an approved timeout machine-verifiable instead of
|
|
241
|
+
// a free CLI knob.
|
|
242
|
+
const declaredTimeoutProvided = declaredRequestTimeoutMs !== undefined && declaredRequestTimeoutMs !== null;
|
|
243
|
+
if (auth.requestTimeoutMs !== undefined) {
|
|
244
|
+
if (!Number.isSafeInteger(auth.requestTimeoutMs) || auth.requestTimeoutMs < 1) {
|
|
245
|
+
reasons.push('authorization requestTimeoutMs must be a positive safe integer when present');
|
|
246
|
+
} else if (!declaredTimeoutProvided) {
|
|
247
|
+
reasons.push(`authorization binds requestTimeoutMs '${auth.requestTimeoutMs}' but the runner declared no timeout — the approved value is binding and must be declared explicitly`);
|
|
248
|
+
} else if (auth.requestTimeoutMs !== declaredRequestTimeoutMs) {
|
|
249
|
+
reasons.push(`authorization requestTimeoutMs '${auth.requestTimeoutMs}' does not match the runner-declared '${declaredRequestTimeoutMs}' — the approved timeout is binding; widen it via a new authorization, not a CLI flag`);
|
|
250
|
+
}
|
|
251
|
+
if (declaredTimeoutProvided && (declaredRequestTimeoutMs < 60_000 || declaredRequestTimeoutMs > 600_000)) {
|
|
252
|
+
reasons.push(`runner-declared requestTimeoutMs '${declaredRequestTimeoutMs}' is outside the supported range [60000, 600000]`);
|
|
253
|
+
}
|
|
254
|
+
} else if (declaredTimeoutProvided && declaredRequestTimeoutMs !== DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
255
|
+
reasons.push(`authorization does not bind a requestTimeoutMs — the default ${DEFAULT_REQUEST_TIMEOUT_MS}ms is the only approved value for this authorization; widen it via a new authorization, not a CLI flag`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Per-attempt model-request cap binding (optional field — schema evolution
|
|
259
|
+
// alongside requestCap, CEB-003-P): when the authorization binds
|
|
260
|
+
// maxRequestsPerAttempt, the runner-declared value MUST equal it and the
|
|
261
|
+
// runner enforces it per attempt (a refused reservation terminates the
|
|
262
|
+
// loop BEFORE the request is sent). When the authorization does not bind
|
|
263
|
+
// one, NO per-attempt cap is approved — declaring any value refuses —
|
|
264
|
+
// because a silent CLI-side cap change would alter study conditions
|
|
265
|
+
// (tighten or loosen) without an Owner-approved record.
|
|
266
|
+
const declaredPerAttemptCapProvided = declaredMaxRequestsPerAttempt !== undefined && declaredMaxRequestsPerAttempt !== null;
|
|
267
|
+
if (auth.maxRequestsPerAttempt !== undefined) {
|
|
268
|
+
if (!Number.isSafeInteger(auth.maxRequestsPerAttempt) || auth.maxRequestsPerAttempt < 1 || auth.maxRequestsPerAttempt > MAX_REQUESTS_PER_ATTEMPT_CEILING) {
|
|
269
|
+
reasons.push(`authorization maxRequestsPerAttempt must be a safe integer in [1, ${MAX_REQUESTS_PER_ATTEMPT_CEILING}] when present (the actor-loop step ceiling)`);
|
|
270
|
+
} else if (!declaredPerAttemptCapProvided) {
|
|
271
|
+
reasons.push(`authorization binds maxRequestsPerAttempt '${auth.maxRequestsPerAttempt}' but the runner declared none — the approved per-attempt request cap is binding and must be declared explicitly`);
|
|
272
|
+
} else if (auth.maxRequestsPerAttempt !== declaredMaxRequestsPerAttempt) {
|
|
273
|
+
reasons.push(`authorization maxRequestsPerAttempt '${auth.maxRequestsPerAttempt}' does not match the runner-declared '${declaredMaxRequestsPerAttempt}' — the approved per-attempt request cap is binding; widen it via a new authorization, not a CLI flag`);
|
|
274
|
+
}
|
|
275
|
+
} else if (declaredPerAttemptCapProvided) {
|
|
276
|
+
reasons.push('authorization does not bind a maxRequestsPerAttempt — no per-attempt request cap is approved for this authorization; bind one via a new authorization, not a CLI flag');
|
|
277
|
+
}
|
|
278
|
+
if (declaredPerAttemptCapProvided && (!Number.isSafeInteger(declaredMaxRequestsPerAttempt) || declaredMaxRequestsPerAttempt < 1 || declaredMaxRequestsPerAttempt > MAX_REQUESTS_PER_ATTEMPT_CEILING)) {
|
|
279
|
+
reasons.push(`runner-declared maxRequestsPerAttempt '${String(declaredMaxRequestsPerAttempt)}' is outside the supported range [1, ${MAX_REQUESTS_PER_ATTEMPT_CEILING}]`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const validFrom = parseIso(auth.validFrom);
|
|
283
|
+
const validUntil = parseIso(auth.validUntil);
|
|
284
|
+
if (validFrom === null || validUntil === null) reasons.push('authorization validity window missing or malformed (validFrom/validUntil must be ISO-8601 WITH timezone, e.g. 2026-10-26T12:00:00Z — timezone-less values are ambiguous and refuse)');
|
|
285
|
+
if (validFrom !== null && validUntil !== null && validUntil < validFrom) reasons.push('authorization validity window inverted (validUntil < validFrom)');
|
|
286
|
+
if (now === null || now === undefined || !Number.isFinite(now)) {
|
|
287
|
+
reasons.push('trusted clock unavailable (now not injected by the runner) — fail-closed');
|
|
288
|
+
} else if (validFrom !== null && validUntil !== null) {
|
|
289
|
+
if (now < validFrom) reasons.push(`authorization not yet valid (now ${new Date(now).toISOString()} < validFrom ${auth.validFrom})`);
|
|
290
|
+
if (now > validUntil) reasons.push(`authorization expired (now ${new Date(now).toISOString()} > validUntil ${auth.validUntil})`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (typeof auth.requestedActorIdentity !== 'string' || auth.requestedActorIdentity.length === 0) {
|
|
294
|
+
reasons.push('requestedActorIdentity missing (SPEC §7.2 Examiner/Actor model requirement binding)');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const bind = (field, expected, actual) => { if (String(expected) !== String(actual)) reasons.push(`authorization binding mismatch on ${field}: auth='${expected}' vs frozen study='${actual}'`); };
|
|
298
|
+
if (!bindings || typeof bindings !== 'object') {
|
|
299
|
+
reasons.push('frozen study bindings not provided by the runner — fail-closed');
|
|
300
|
+
} else {
|
|
301
|
+
bind('studyId', auth.studyId, bindings.studyId);
|
|
302
|
+
bind('suiteHash', auth.suiteHash, bindings.suiteHash);
|
|
303
|
+
bind('mappingHash', auth.mappingHash, bindings.mappingHash);
|
|
304
|
+
bind('graderHash', auth.graderHash, bindings.graderHash);
|
|
305
|
+
bind('studyPlanHash', auth.studyPlanHash, bindings.studyPlanHash);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!isNonEmptyStringArray(auth.arms)) reasons.push('authorization arms must be a non-empty string array');
|
|
309
|
+
if (!isNonEmptyStringArray(auth.cases)) reasons.push('authorization cases must be a non-empty string array');
|
|
310
|
+
if (!isNonEmptyStringArray(auth.repetitions)) reasons.push('authorization repetitions must be a non-empty string array');
|
|
311
|
+
if (plan && typeof plan === 'object') {
|
|
312
|
+
// Normalize before the containment checks: a malformed record already has
|
|
313
|
+
// its reason above — dereferencing the raw field here would turn the
|
|
314
|
+
// structured { ok:false, reasons } refusal into a TypeError.
|
|
315
|
+
const authArms = Array.isArray(auth.arms) ? auth.arms : [];
|
|
316
|
+
const authCases = Array.isArray(auth.cases) ? auth.cases : [];
|
|
317
|
+
const authRepetitions = Array.isArray(auth.repetitions) ? auth.repetitions.map(String) : [];
|
|
318
|
+
for (const armId of plan.arms || []) if (!authArms.includes(armId)) reasons.push(`plan arm '${armId}' is outside the authorized arms ${JSON.stringify(auth.arms)}`);
|
|
319
|
+
for (const caseId of plan.cases || []) if (!authCases.includes(caseId)) reasons.push(`plan case '${caseId}' is outside the authorized case list`);
|
|
320
|
+
for (const repetitionId of (plan.repetitions || []).map(String)) if (!authRepetitions.includes(repetitionId)) reasons.push(`plan repetition '${repetitionId}' is outside the authorized repetitions`);
|
|
321
|
+
} else {
|
|
322
|
+
reasons.push('run plan not provided to the gate — fail-closed');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!Number.isSafeInteger(auth.attemptCap) || auth.attemptCap < 1) {
|
|
326
|
+
reasons.push('authorization attemptCap must be a positive safe integer');
|
|
327
|
+
} else if (!Number.isSafeInteger(maxAttemptsTotal) || maxAttemptsTotal < 1) {
|
|
328
|
+
reasons.push('runner maxAttemptsTotal must be a positive safe integer — omitted/Infinity/NaN would silently disable the budget cap (fail-closed)');
|
|
329
|
+
} else if (maxAttemptsTotal > auth.attemptCap) {
|
|
330
|
+
reasons.push(`runner attempt cap ${maxAttemptsTotal} exceeds the authorized attemptCap ${auth.attemptCap} — widen via a new authorization, not the runner flag`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!Number.isSafeInteger(auth.requestCap) || auth.requestCap < 1) {
|
|
334
|
+
reasons.push('authorization requestCap must be a positive safe integer — real execution needs a study-wide model-REQUEST budget distinct from the attempt cap (fail-closed when absent)');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (auth.executionMode === 'F2-real-host') {
|
|
338
|
+
const probe = auth.isolationAcceptance && auth.isolationAcceptance.realHostProbe;
|
|
339
|
+
if (!probe || typeof probe !== 'object') {
|
|
340
|
+
reasons.push('F2-real-host requires isolationAcceptance.realHostProbe (PRI-1037 real-host isolation probe evidence)');
|
|
341
|
+
} else {
|
|
342
|
+
if (probe.status !== 'PASSED') reasons.push(`real-host isolation probe status '${probe.status}' is not PASSED — PRI-1037 blocks F2 until the probe passes in a real host`);
|
|
343
|
+
if (typeof probe.ticketId !== 'string' || probe.ticketId.length === 0) reasons.push('realHostProbe.ticketId missing');
|
|
344
|
+
if (typeof probe.evidenceRef !== 'string' || probe.evidenceRef.length === 0) reasons.push('realHostProbe.evidenceRef missing (the probe execution record)');
|
|
345
|
+
if (typeof probe.suiteHash !== 'string' || probe.suiteHash.length === 0) {
|
|
346
|
+
reasons.push('realHostProbe.suiteHash missing — the probe must be bound to the frozen suite it validated');
|
|
347
|
+
} else if (probe.suiteHash !== auth.suiteHash) {
|
|
348
|
+
reasons.push('realHostProbe.suiteHash is bound to a different frozen suite than the authorization');
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return { ok: reasons.length === 0, reasons, executionMode: auth.executionMode || null, requestCap: Number.isSafeInteger(auth.requestCap) ? auth.requestCap : null };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Extract observed host evidence from a real-mode actor. Only the real host
|
|
357
|
+
* actor implementation can provide it (session id + observed model identity
|
|
358
|
+
* come from the host's own event stream — host-actor.mjs discipline); an
|
|
359
|
+
* offline test double yields null and therefore never counts as real-host
|
|
360
|
+
* acceptance evidence. When the transport records WHERE the host persisted
|
|
361
|
+
* the session's trajectory (OpenClaw exec-state), that location travels with
|
|
362
|
+
* the evidence as a closed-shape `hostEvidence` object — the formal baseline
|
|
363
|
+
* corroborates against it instead of the record strings. */
|
|
364
|
+
export function readObservedHostEvidence(actor) {
|
|
365
|
+
const e = actor && actor.observedHostEvidence;
|
|
366
|
+
if (!e || typeof e !== 'object') return null;
|
|
367
|
+
if (typeof e.hostSessionId !== 'string' || e.hostSessionId.length === 0) return null;
|
|
368
|
+
if (typeof e.observedModelIdentity !== 'string' || e.observedModelIdentity.length === 0) return null;
|
|
369
|
+
if (e.hostEvidence !== undefined) {
|
|
370
|
+
const h = e.hostEvidence;
|
|
371
|
+
if (!h || typeof h !== 'object' || h.kind !== 'openclaw-exec-state'
|
|
372
|
+
|| typeof h.roundDir !== 'string' || h.roundDir.length === 0
|
|
373
|
+
|| typeof h.stateDir !== 'string' || h.stateDir.length === 0
|
|
374
|
+
|| typeof h.stateAgentDb !== 'string' || h.stateAgentDb.length === 0) {
|
|
375
|
+
return { hostSessionId: e.hostSessionId, observedModelIdentity: e.observedModelIdentity, hostEvidence: null }; // malformed location is dropped, never trusted
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
hostSessionId: e.hostSessionId,
|
|
379
|
+
observedModelIdentity: e.observedModelIdentity,
|
|
380
|
+
hostEvidence: { kind: h.kind, roundDir: h.roundDir, stateDir: h.stateDir, stateAgentDb: h.stateAgentDb },
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
return { hostSessionId: e.hostSessionId, observedModelIdentity: e.observedModelIdentity };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Machine-checkable realHostBaseline rule (SPEC §6.4 / §12 No-Go). Never
|
|
388
|
+
* throws; never returns PASS from anything but complete F2-real-host
|
|
389
|
+
* evidence. Statuses:
|
|
390
|
+
* PASS — every attempt is F2-real-host with authorization linkage
|
|
391
|
+
* (probe PASSED), observed host evidence, and ≥1 SUCCEEDED.
|
|
392
|
+
* NOT_ESTIMABLE — real-host attempts exist but authorization/isolation
|
|
393
|
+
* evidence is missing, lineage conflicts with `expected`,
|
|
394
|
+
* or the input mixes non-F2 attempts: mock/offline
|
|
395
|
+
* evidence can never establish the baseline.
|
|
396
|
+
* UNKNOWN — no real-host evidence at all (wiring-only doubles, no
|
|
397
|
+
* successful attempt, or no attempts): baseline unmeasured.
|
|
398
|
+
*
|
|
399
|
+
* `expected` ({ suiteHash, requestedActorIdentity }) tightens the classifier
|
|
400
|
+
* from "well-shaped strings" to lineage-consistent evidence (rc-6): an
|
|
401
|
+
* attempt whose frozenRefs.suiteHash does not match THIS frozen study, or
|
|
402
|
+
* whose observed host identity does not match the authorization's bound
|
|
403
|
+
* actor identity, is NOT_ESTIMABLE — record fields alone never establish
|
|
404
|
+
* real-host acceptance. The FORMAL study-level entry
|
|
405
|
+
* (challenge-baseline.mjs deriveRealHostBaselineFromStudy) additionally
|
|
406
|
+
* corroborates every hostSessionId against the host's own rollout files.
|
|
407
|
+
*/
|
|
408
|
+
export function deriveRealHostBaseline({ attempts, expected = null } = {}) {
|
|
409
|
+
if (!Array.isArray(attempts) || attempts.length === 0) {
|
|
410
|
+
return { status: 'UNKNOWN', reasons: ['no attempts recorded — realHostBaseline stays UNKNOWN'] };
|
|
411
|
+
}
|
|
412
|
+
const nonReal = attempts.filter((a) => !a || a.mode !== 'F2-real-host');
|
|
413
|
+
if (nonReal.length > 0) {
|
|
414
|
+
return {
|
|
415
|
+
status: 'NOT_ESTIMABLE',
|
|
416
|
+
reasons: [
|
|
417
|
+
`${nonReal.length}/${attempts.length} attempt(s) are not F2-real-host — offline-stub / F1 / mock evidence can never establish a real-host baseline (SPEC §12 No-Go: 用 Mock 冒充真实宿主实验)`,
|
|
418
|
+
],
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
const missingAuthLink = attempts.filter((a) => {
|
|
422
|
+
const probe = a.authorization && a.authorization.isolationAcceptance && a.authorization.isolationAcceptance.realHostProbe;
|
|
423
|
+
return !a.authorization || !probe || probe.status !== 'PASSED';
|
|
424
|
+
});
|
|
425
|
+
if (missingAuthLink.length > 0) {
|
|
426
|
+
return {
|
|
427
|
+
status: 'NOT_ESTIMABLE',
|
|
428
|
+
reasons: [`${missingAuthLink.length}/${attempts.length} F2 attempt(s) lack authorization linkage with a PASSED real-host isolation probe — not acceptance evidence`],
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const missingEvidence = attempts.filter((a) => !a.observedHostEvidence || typeof a.observedHostEvidence.hostSessionId !== 'string' || typeof a.observedHostEvidence.observedModelIdentity !== 'string');
|
|
432
|
+
if (missingEvidence.length > 0) {
|
|
433
|
+
return {
|
|
434
|
+
status: 'UNKNOWN',
|
|
435
|
+
reasons: [`${missingEvidence.length}/${attempts.length} F2 attempt(s) carry no observed host session/identity — wiring-only evidence (an offline test double cannot provide it), never real-host acceptance`],
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
if (expected && typeof expected === 'object') {
|
|
439
|
+
const lineageMismatches = attempts.filter((a) => {
|
|
440
|
+
const refs = a.frozenRefs || {};
|
|
441
|
+
const suiteOk = !expected.suiteHash || refs.suiteHash === expected.suiteHash;
|
|
442
|
+
const identityOk = !expected.requestedActorIdentity
|
|
443
|
+
|| a.observedHostEvidence.observedModelIdentity === expected.requestedActorIdentity
|
|
444
|
+
|| a.observedHostEvidence.observedModelIdentity.endsWith(`/${expected.requestedActorIdentity}`);
|
|
445
|
+
return !suiteOk || !identityOk;
|
|
446
|
+
});
|
|
447
|
+
if (lineageMismatches.length > 0) {
|
|
448
|
+
return {
|
|
449
|
+
status: 'NOT_ESTIMABLE',
|
|
450
|
+
reasons: [`${lineageMismatches.length}/${attempts.length} F2 attempt(s) carry evidence whose lineage does not match this frozen study / authorized actor identity — conflicting evidence is not acceptance evidence`],
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (!attempts.some((a) => a.result === 'SUCCEEDED')) {
|
|
455
|
+
return { status: 'UNKNOWN', reasons: ['no successful F2-real-host attempt — baseline unmeasured'] };
|
|
456
|
+
}
|
|
457
|
+
return { status: 'PASS', reasons: [`${attempts.length} F2-real-host attempt(s) with authorization linkage, PASSED isolation probe and observed host evidence (≥1 SUCCEEDED)`] };
|
|
458
|
+
}
|