@bitkyc08/opencodex 2.19.0 → 2.20.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/gui/dist/assets/index-DF_UFrGS.css +1 -0
- package/gui/dist/assets/index-DSK3S5HY.js +76 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/mimo-free.ts +17 -0
- package/src/adapters/openai-chat.ts +367 -32
- package/src/adapters/registry.ts +144 -0
- package/src/chat/inbound.ts +13 -7
- package/src/cli/claude.ts +2 -1
- package/src/cli/combo.ts +3 -0
- package/src/cli/dispatch.ts +8 -0
- package/src/cli/export-command.ts +2 -2
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +3 -2
- package/src/cli/lab.ts +135 -1
- package/src/cli/minimax.ts +491 -0
- package/src/cli/models-runtime.ts +22 -1
- package/src/cli/models.ts +67 -2
- package/src/cli/opencode.ts +2 -1
- package/src/cli/registry.ts +22 -2
- package/src/clients/config-export.ts +125 -7
- package/src/codex/app-server-processes.ts +57 -2
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/catalog/aggregation.ts +10 -1
- package/src/codex/catalog/effort.ts +15 -3
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +45 -5
- package/src/codex/catalog/sync.ts +74 -4
- package/src/codex/convergence.ts +2 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/request.ts +30 -0
- package/src/combos/types.ts +6 -0
- package/src/config.ts +52 -0
- package/src/generated/compatibility-version.json +224 -76
- package/src/images/loop.ts +11 -1
- package/src/integrations/registry.ts +7 -0
- package/src/lab/conformance/jcs.ts +42 -2
- package/src/lab/conformance/negative-controls.ts +6 -2
- package/src/lab/conformance/runner.ts +16 -5
- package/src/lab/fabric/observe.ts +49 -14
- package/src/lab/index.ts +16 -0
- package/src/lab/ledger/purge.ts +152 -83
- package/src/lab/ledger/store.ts +168 -54
- package/src/lab/observe/from-conformance.ts +8 -6
- package/src/lab/observe/from-live.ts +8 -2
- package/src/lab/paths.ts +23 -0
- package/src/lab/public/bundle.ts +217 -0
- package/src/lab/public/community-authority.ts +175 -0
- package/src/lab/public/community-files.ts +29 -0
- package/src/lab/public/community.ts +479 -0
- package/src/lab/public/file-safety.ts +155 -0
- package/src/lab/public/ids.ts +26 -0
- package/src/lab/public/index.ts +16 -0
- package/src/lab/public/mutation-lock.ts +424 -0
- package/src/lab/public/operator.ts +353 -0
- package/src/lab/public/origin-purge.ts +79 -0
- package/src/lab/public/origin.ts +203 -0
- package/src/lab/public/privacy.ts +143 -0
- package/src/lab/public/private-file.ts +261 -0
- package/src/lab/public/project.ts +124 -0
- package/src/lab/public/purge-test-fault.ts +21 -0
- package/src/lab/public/purge.ts +223 -0
- package/src/lab/public/registry.ts +44 -0
- package/src/lab/public/revocation.ts +252 -0
- package/src/lab/public/signature.ts +219 -0
- package/src/lab/public/storage.ts +105 -0
- package/src/lab/public/strict-json.ts +206 -0
- package/src/lab/public/time.ts +26 -0
- package/src/lab/public/types.ts +172 -0
- package/src/lab/public/validate.ts +391 -0
- package/src/lib/codex-restart-contract.ts +120 -0
- package/src/lib/lab-activation.ts +109 -47
- package/src/lib/lab-live-pinned-sender.ts +16 -5
- package/src/lib/pinned-http.ts +70 -16
- package/src/lib/self-launch-argv.ts +15 -0
- package/src/lib/state-store-registrations.ts +2 -0
- package/src/lib/upstream-reachability.ts +4 -0
- package/src/lib/windows-elevation.ts +10 -1
- package/src/providers/derive.ts +24 -4
- package/src/providers/registry.ts +7 -3
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +143 -0
- package/src/providers/static-model-discovery.ts +86 -0
- package/src/reasoning-effort.ts +27 -1
- package/src/router.ts +23 -6
- package/src/routing/capability.ts +4 -2
- package/src/routing/compatibility/behavior.ts +5 -1
- package/src/server/adapter-resolve.ts +2 -32
- package/src/server/auth-cors.ts +8 -0
- package/src/server/chat-completions.ts +74 -36
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +371 -0
- package/src/server/management/combo-routes.ts +16 -2
- package/src/server/management/config-routes.ts +6 -4
- package/src/server/management/context.ts +17 -0
- package/src/server/management/lab-routes.ts +181 -19
- package/src/server/management/model-routes.ts +76 -2
- package/src/server/management/model-rows.ts +8 -0
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +76 -4
- package/src/server/management/system-restart.ts +4 -2
- package/src/server/management/system-routes.ts +38 -0
- package/src/server/relay.ts +17 -3
- package/src/server/responses/compact.ts +4 -1
- package/src/server/responses/core.ts +257 -46
- package/src/server/responses/empty-completion-guard.ts +276 -0
- package/src/server/responses/fetch-helpers.ts +35 -4
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/policy-fallback.ts +16 -2
- package/src/server/responses/terminal-guard.ts +1 -1
- package/src/server/responses/upstream-error.ts +5 -0
- package/src/server/responses.ts +17 -2
- package/src/types.ts +66 -4
- package/src/update/index.ts +6 -5
- package/src/update/job.ts +5 -6
- package/src/update/notify.ts +5 -3
- package/src/usage/log.ts +11 -1
- package/gui/dist/assets/index-CQ7bIKee.css +0 -1
- package/gui/dist/assets/index-D_JUZLEC.js +0 -76
package/src/lab/ledger/store.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface LedgerStore {
|
|
|
25
25
|
replay(): ReplayResult;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export interface LedgerMutationContext {
|
|
29
|
+
replay(): ReplayResult;
|
|
30
|
+
append(event: LabEvent): void;
|
|
31
|
+
appendIfAbsent(event: LabEvent): boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
const LEDGER_LOCK_STALE_MS = 60_000;
|
|
29
35
|
const LEDGER_LOCK_WAIT_MS = 5_000;
|
|
30
36
|
|
|
@@ -36,10 +42,7 @@ interface LedgerLockMeta {
|
|
|
36
42
|
|
|
37
43
|
/** Block synchronously for the given duration (ledger lock retry only). */
|
|
38
44
|
function sleepSyncMs(ms: number): void {
|
|
39
|
-
|
|
40
|
-
while (Date.now() < end) {
|
|
41
|
-
/* spin */
|
|
42
|
-
}
|
|
45
|
+
Bun.sleepSync(ms);
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/** Read pid, createdAt, and token metadata from a ledger lock file, if well-formed. */
|
|
@@ -83,8 +86,94 @@ function isLedgerLockStale(lockPath: string): boolean {
|
|
|
83
86
|
return false;
|
|
84
87
|
}
|
|
85
88
|
}
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
return !isLockHolderAlive(meta.pid);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Write lock ownership metadata to a newly created exclusive lock file. */
|
|
93
|
+
function writeLedgerLockMeta(fd: number, token: string): void {
|
|
94
|
+
const metadataBytes = Buffer.from(JSON.stringify({
|
|
95
|
+
pid: process.pid,
|
|
96
|
+
createdAt: Date.now(),
|
|
97
|
+
token,
|
|
98
|
+
}), "utf8");
|
|
99
|
+
let written = 0;
|
|
100
|
+
while (written < metadataBytes.byteLength) {
|
|
101
|
+
const n = writeSync(fd, metadataBytes, written, metadataBytes.byteLength - written);
|
|
102
|
+
if (n <= 0) {
|
|
103
|
+
throw new LabValidationError("short_write", "ledger lock metadata write incomplete");
|
|
104
|
+
}
|
|
105
|
+
written += n;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Discard a lock whose exclusive creator failed before publishing ownership metadata. */
|
|
110
|
+
function discardUninitialisedLedgerLock(lockPath: string, lockFd: number): void {
|
|
111
|
+
try {
|
|
112
|
+
closeSync(lockFd);
|
|
113
|
+
} catch {
|
|
114
|
+
/* ignore */
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
unlinkSync(lockPath);
|
|
118
|
+
} catch {
|
|
119
|
+
/* best-effort */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Release a lock file only when the token still matches the path owner. */
|
|
124
|
+
function releaseLedgerLock(lockPath: string, lockFd: number, token: string): void {
|
|
125
|
+
try {
|
|
126
|
+
closeSync(lockFd);
|
|
127
|
+
} catch {
|
|
128
|
+
/* ignore */
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const meta = readLedgerLockMeta(lockPath);
|
|
132
|
+
if (meta?.token === token) unlinkSync(lockPath);
|
|
133
|
+
} catch {
|
|
134
|
+
/* best-effort */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Recover one stale lock while holding a separate recovery mutex.
|
|
140
|
+
*
|
|
141
|
+
* The recovery mutex prevents two waiters from both observing the same stale
|
|
142
|
+
* owner and then unlinking each other's replacement lock. If a process dies
|
|
143
|
+
* while holding the recovery mutex, acquisition fails closed instead of
|
|
144
|
+
* guessing ownership of that mutex.
|
|
145
|
+
*/
|
|
146
|
+
function recoverStaleLedgerLock(lockPath: string): boolean {
|
|
147
|
+
const recoveryPath = `${lockPath}.recovery`;
|
|
148
|
+
const token = randomBytes(16).toString("hex");
|
|
149
|
+
let recoveryFd: number;
|
|
150
|
+
try {
|
|
151
|
+
recoveryFd = openSync(
|
|
152
|
+
recoveryPath,
|
|
153
|
+
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
|
|
154
|
+
0o600,
|
|
155
|
+
);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
writeLedgerLockMeta(recoveryFd, token);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
discardUninitialisedLedgerLock(recoveryPath, recoveryFd);
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
// Re-check after taking the recovery mutex. Another waiter may already
|
|
170
|
+
// have recovered the old lock and installed a live replacement.
|
|
171
|
+
if (!existsSync(lockPath) || !isLedgerLockStale(lockPath)) return false;
|
|
172
|
+
unlinkSync(lockPath);
|
|
173
|
+
return true;
|
|
174
|
+
} finally {
|
|
175
|
+
releaseLedgerLock(recoveryPath, recoveryFd, token);
|
|
176
|
+
}
|
|
88
177
|
}
|
|
89
178
|
|
|
90
179
|
/** Create a ledger lock file exclusively, recovering stale locks when needed. */
|
|
@@ -97,30 +186,19 @@ function tryAcquireLedgerLock(lockPath: string, deadline: number): { fd: number;
|
|
|
97
186
|
} catch (error) {
|
|
98
187
|
if (existsSync(lockPath) && isLedgerLockStale(lockPath)) {
|
|
99
188
|
try {
|
|
100
|
-
|
|
101
|
-
} catch (
|
|
102
|
-
if (Date.now() >= deadline) throw
|
|
103
|
-
sleepSyncMs(10);
|
|
189
|
+
if (recoverStaleLedgerLock(lockPath)) continue;
|
|
190
|
+
} catch (recoveryError) {
|
|
191
|
+
if (Date.now() >= deadline) throw recoveryError;
|
|
104
192
|
}
|
|
105
|
-
continue;
|
|
106
193
|
}
|
|
107
194
|
if (Date.now() >= deadline) throw error;
|
|
108
195
|
sleepSyncMs(10);
|
|
109
196
|
continue;
|
|
110
197
|
}
|
|
111
198
|
try {
|
|
112
|
-
|
|
113
|
-
const written = writeSync(fd, metadataBytes);
|
|
114
|
-
if (written !== metadataBytes.byteLength) {
|
|
115
|
-
throw new LabValidationError("short_write", "ledger lock metadata write incomplete");
|
|
116
|
-
}
|
|
199
|
+
writeLedgerLockMeta(fd, token);
|
|
117
200
|
} catch (error) {
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
unlinkSync(lockPath);
|
|
121
|
-
} catch {
|
|
122
|
-
/* best-effort */
|
|
123
|
-
}
|
|
201
|
+
discardUninitialisedLedgerLock(lockPath, fd);
|
|
124
202
|
throw error;
|
|
125
203
|
}
|
|
126
204
|
return { fd, token };
|
|
@@ -128,21 +206,6 @@ function tryAcquireLedgerLock(lockPath: string, deadline: number): { fd: number;
|
|
|
128
206
|
throw new Error("ledger lock acquisition timed out");
|
|
129
207
|
}
|
|
130
208
|
|
|
131
|
-
/** Release a ledger lock only when the token still matches the lock file. */
|
|
132
|
-
function releaseLedgerLock(lockPath: string, lockFd: number, token: string): void {
|
|
133
|
-
try {
|
|
134
|
-
closeSync(lockFd);
|
|
135
|
-
} catch {
|
|
136
|
-
/* ignore */
|
|
137
|
-
}
|
|
138
|
-
try {
|
|
139
|
-
const meta = readLedgerLockMeta(lockPath);
|
|
140
|
-
if (meta?.token === token) unlinkSync(lockPath);
|
|
141
|
-
} catch {
|
|
142
|
-
/* best-effort */
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
209
|
/** Run a ledger mutation while holding the compatibility ledger lock file. */
|
|
147
210
|
function withLedgerLock<T>(ledgerPath: string, fn: () => T): T {
|
|
148
211
|
const lockPath = `${ledgerPath}.lock`;
|
|
@@ -156,11 +219,10 @@ function withLedgerLock<T>(ledgerPath: string, fn: () => T): T {
|
|
|
156
219
|
}
|
|
157
220
|
}
|
|
158
221
|
|
|
159
|
-
/** Durable append of one validated event as a single JSONL line + fsync. */
|
|
160
|
-
|
|
161
|
-
const validated = validateLabEvent(event);
|
|
222
|
+
/** Durable append of one already-validated event as a single JSONL line + fsync. */
|
|
223
|
+
function appendValidatedLabEvent(ledgerPath: string, event: LabEvent): void {
|
|
162
224
|
mkdirSync(dirname(ledgerPath), { recursive: true, mode: 0o700 });
|
|
163
|
-
const line = `${jcsStringify(
|
|
225
|
+
const line = `${jcsStringify(event)}\n`;
|
|
164
226
|
const bytes = new TextEncoder().encode(line);
|
|
165
227
|
const fd = openSync(ledgerPath, "a", 0o600);
|
|
166
228
|
try {
|
|
@@ -178,26 +240,78 @@ export function appendLabEvent(ledgerPath: string, event: LabEvent): void {
|
|
|
178
240
|
}
|
|
179
241
|
}
|
|
180
242
|
|
|
243
|
+
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
244
|
+
return (
|
|
245
|
+
(typeof value === "object" && value !== null) || typeof value === "function"
|
|
246
|
+
) && typeof (value as { then?: unknown }).then === "function";
|
|
247
|
+
}
|
|
248
|
+
|
|
181
249
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
250
|
+
* Serialize a ledger read-modify-write transaction with all ordinary appends.
|
|
251
|
+
* The callback is intentionally synchronous. Mutation methods become invalid
|
|
252
|
+
* as soon as the callback returns, so an accidental async continuation cannot
|
|
253
|
+
* write after the lock has been released.
|
|
184
254
|
*/
|
|
185
|
-
export function
|
|
186
|
-
|
|
255
|
+
export function withLedgerMutation<T>(
|
|
256
|
+
ledgerPath: string,
|
|
257
|
+
fn: (mutation: LedgerMutationContext) => T,
|
|
258
|
+
): T {
|
|
187
259
|
return withLedgerLock(ledgerPath, () => {
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
260
|
+
let active = true;
|
|
261
|
+
const requireActive = () => {
|
|
262
|
+
if (!active) {
|
|
263
|
+
throw new LabValidationError(
|
|
264
|
+
"inactive_ledger_mutation",
|
|
265
|
+
"ledger mutation context used after its lock was released",
|
|
266
|
+
);
|
|
193
267
|
}
|
|
268
|
+
};
|
|
269
|
+
const replay = () => {
|
|
270
|
+
requireActive();
|
|
271
|
+
return replayLabLedger(ledgerPath);
|
|
272
|
+
};
|
|
273
|
+
const append = (event: LabEvent) => {
|
|
274
|
+
requireActive();
|
|
275
|
+
appendValidatedLabEvent(ledgerPath, validateLabEvent(event));
|
|
276
|
+
};
|
|
277
|
+
const appendIfAbsent = (event: LabEvent): boolean => {
|
|
278
|
+
requireActive();
|
|
279
|
+
const validated = validateLabEvent(event);
|
|
280
|
+
if (replay().events.some((row) => row.eventId === validated.eventId)) return false;
|
|
281
|
+
appendValidatedLabEvent(ledgerPath, validated);
|
|
282
|
+
return true;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const result = fn({ replay, append, appendIfAbsent });
|
|
287
|
+
if (isThenable(result)) {
|
|
288
|
+
throw new LabValidationError(
|
|
289
|
+
"async_ledger_mutation",
|
|
290
|
+
"ledger mutation callback must be synchronous",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return result;
|
|
294
|
+
} finally {
|
|
295
|
+
active = false;
|
|
194
296
|
}
|
|
195
|
-
if (fresh.has(validated.eventId)) return false;
|
|
196
|
-
appendLabEvent(ledgerPath, validated);
|
|
197
|
-
return true;
|
|
198
297
|
});
|
|
199
298
|
}
|
|
200
299
|
|
|
300
|
+
/** Durable append of one validated event as a single JSONL line + fsync. */
|
|
301
|
+
export function appendLabEvent(ledgerPath: string, event: LabEvent): void {
|
|
302
|
+
withLedgerMutation(ledgerPath, (mutation) => {
|
|
303
|
+
mutation.append(event);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Append only when eventId is absent. Uses the same mutation lock as every
|
|
309
|
+
* other ledger writer so the presence check and append are one transaction.
|
|
310
|
+
*/
|
|
311
|
+
export function appendLabEventIfAbsent(ledgerPath: string, event: LabEvent): boolean {
|
|
312
|
+
return withLedgerMutation(ledgerPath, (mutation) => mutation.appendIfAbsent(event));
|
|
313
|
+
}
|
|
314
|
+
|
|
201
315
|
function processLine(
|
|
202
316
|
line: string,
|
|
203
317
|
lineNumber: number,
|
|
@@ -415,4 +529,4 @@ export function openLedgerStore(configDir?: string): LedgerStore {
|
|
|
415
529
|
|
|
416
530
|
export function defaultLedgerPath(configDir?: string): string {
|
|
417
531
|
return labLedgerPath(configDir);
|
|
418
|
-
}
|
|
532
|
+
}
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "../digest";
|
|
18
18
|
import type { ObservationEvent } from "../events/types";
|
|
19
19
|
import { assignEventId } from "../events/validate";
|
|
20
|
-
import {
|
|
20
|
+
import { withLedgerMutation } from "../ledger/store";
|
|
21
21
|
import { ensureLabDirs } from "../paths";
|
|
22
22
|
import type {
|
|
23
23
|
CaseAuthority,
|
|
@@ -285,12 +285,14 @@ export function persistConformanceResult(
|
|
|
285
285
|
const ownsStore = !opts.artifactStore;
|
|
286
286
|
const store = opts.artifactStore ?? createArtifactStore(paths.artifactsDir);
|
|
287
287
|
try {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
288
|
+
return withLedgerMutation(paths.ledgerPath, (ledger) => {
|
|
289
|
+
const { event } = observationFromConformanceResult(result, caseRecord, authority, {
|
|
290
|
+
...opts,
|
|
291
|
+
artifactStore: store,
|
|
292
|
+
});
|
|
293
|
+
ledger.append(event);
|
|
294
|
+
return { event, ledgerPath: paths.ledgerPath };
|
|
291
295
|
});
|
|
292
|
-
appendLabEvent(paths.ledgerPath, event);
|
|
293
|
-
return { event, ledgerPath: paths.ledgerPath };
|
|
294
296
|
} finally {
|
|
295
297
|
if (ownsStore) store.close();
|
|
296
298
|
}
|
|
@@ -5,7 +5,7 @@ import { LAB_EVENT_SCHEMA_VERSION, LAB_PRODUCER, LAB_PRODUCER_VERSION, OBSERVATI
|
|
|
5
5
|
import { fixtureDigest, scenarioManifestDigest, subjectIdForSubject, suiteManifestDigest } from "../digest";
|
|
6
6
|
import type { FailureRecordV1, ObservationEvent } from "../events/types";
|
|
7
7
|
import { assignEventId } from "../events/validate";
|
|
8
|
-
import {
|
|
8
|
+
import { withLedgerMutation } from "../ledger/store";
|
|
9
9
|
import { ensureLabDirs } from "../paths";
|
|
10
10
|
import type { CaseAuthority, CaseRecord } from "../conformance/types";
|
|
11
11
|
import { trustedLiveResultRetryable } from "../live/executor";
|
|
@@ -106,6 +106,12 @@ export function observationFromLiveResult(result: LiveScenarioRunResult, caseRec
|
|
|
106
106
|
|
|
107
107
|
export function persistLiveResult(result: LiveScenarioRunResult, caseRecord: CaseRecord, authority: CaseAuthority, opts: PersistLiveOptions = {}): PersistedLiveObservation {
|
|
108
108
|
const paths = ensureLabDirs(opts.configDir); const ownsStore = !opts.artifactStore; const store = opts.artifactStore ?? createArtifactStore(paths.artifactsDir);
|
|
109
|
-
try {
|
|
109
|
+
try {
|
|
110
|
+
return withLedgerMutation(paths.ledgerPath, (ledger) => {
|
|
111
|
+
const { event } = observationFromLiveResult(result, caseRecord, authority, { ...opts, artifactStore: store });
|
|
112
|
+
ledger.append(event);
|
|
113
|
+
return { event, ledgerPath: paths.ledgerPath };
|
|
114
|
+
});
|
|
115
|
+
}
|
|
110
116
|
finally { if (ownsStore) store.close(); }
|
|
111
117
|
}
|
package/src/lab/paths.ts
CHANGED
|
@@ -81,10 +81,25 @@ export function labScratchDir(configDir = getConfigDir()): string {
|
|
|
81
81
|
return join(labRoot(configDir), "scratch");
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** Shared Lab export directory. Public evidence bundles intentionally live here too. */
|
|
84
85
|
export function labExportDir(configDir = getConfigDir()): string {
|
|
85
86
|
return join(labRoot(configDir), "export");
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
export function labCommunityDir(configDir = getConfigDir()): string {
|
|
90
|
+
return join(labRoot(configDir), "community");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function labPublicOriginDir(configDir = getConfigDir()): string {
|
|
94
|
+
return join(labRoot(configDir), "public-origin-v1");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const LAB_PUBLIC_PUBLISHER_KEY_FILE = "publisher-ed25519.pem";
|
|
98
|
+
|
|
99
|
+
export function labPublicPublisherKeyPath(configDir = getConfigDir()): string {
|
|
100
|
+
return join(labRoot(configDir), LAB_PUBLIC_PUBLISHER_KEY_FILE);
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
/** Opaque per-installation salt for local fingerprinting (never exported as evidence). */
|
|
89
104
|
export function labInstallationSaltPath(configDir = getConfigDir()): string {
|
|
90
105
|
return join(labRoot(configDir), "installation-salt.bin");
|
|
@@ -110,15 +125,21 @@ export function ensureLabDirs(configDir = getConfigDir()): {
|
|
|
110
125
|
artifactsDir: string;
|
|
111
126
|
scratchDir: string;
|
|
112
127
|
exportDir: string;
|
|
128
|
+
communityDir: string;
|
|
129
|
+
publicOriginDir: string;
|
|
113
130
|
} {
|
|
114
131
|
const root = labRoot(configDir);
|
|
115
132
|
const artifactsDir = labArtifactsDir(configDir);
|
|
116
133
|
const scratchDir = labScratchDir(configDir);
|
|
117
134
|
const exportDir = labExportDir(configDir);
|
|
135
|
+
const communityDir = labCommunityDir(configDir);
|
|
136
|
+
const publicOriginDir = labPublicOriginDir(configDir);
|
|
118
137
|
ensureRestrictedDir(root, root);
|
|
119
138
|
ensureRestrictedDir(artifactsDir, root);
|
|
120
139
|
ensureRestrictedDir(scratchDir, root);
|
|
121
140
|
ensureRestrictedDir(exportDir, root);
|
|
141
|
+
ensureRestrictedDir(communityDir, root);
|
|
142
|
+
ensureRestrictedDir(publicOriginDir, root);
|
|
122
143
|
return {
|
|
123
144
|
root,
|
|
124
145
|
ledgerPath: labLedgerPath(configDir),
|
|
@@ -126,5 +147,7 @@ export function ensureLabDirs(configDir = getConfigDir()): {
|
|
|
126
147
|
artifactsDir,
|
|
127
148
|
scratchDir,
|
|
128
149
|
exportDir,
|
|
150
|
+
communityDir,
|
|
151
|
+
publicOriginDir,
|
|
129
152
|
};
|
|
130
153
|
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { jcsStringify } from "../digest";
|
|
2
|
+
import { publicEvidenceId } from "./ids";
|
|
3
|
+
import {
|
|
4
|
+
PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION,
|
|
5
|
+
PUBLIC_EXPORT_POLICY_VERSION,
|
|
6
|
+
type PublicArtifactV1,
|
|
7
|
+
type PublicEvidenceBundleUnsignedV1,
|
|
8
|
+
type PublicEvidenceRecordV1,
|
|
9
|
+
type PublicPublisherV1,
|
|
10
|
+
} from "./types";
|
|
11
|
+
import { PublicEvidenceValidationError, validatePublicEvidenceRecord } from "./validate";
|
|
12
|
+
|
|
13
|
+
export const MAX_PUBLIC_BUNDLE_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
export const MAX_PUBLIC_BUNDLE_RECORDS = 256;
|
|
15
|
+
export const MAX_PUBLIC_BUNDLE_ARTIFACTS = 16;
|
|
16
|
+
export const MAX_PUBLIC_ARTIFACT_BYTES = 256 * 1024;
|
|
17
|
+
export const MAX_PUBLIC_ARTIFACT_BYTES_TOTAL = 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
export interface PublicEvidenceContentInput {
|
|
20
|
+
records: PublicEvidenceRecordV1[];
|
|
21
|
+
artifacts: PublicArtifactV1[];
|
|
22
|
+
createdDayUtc: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BuildPublicEvidenceBundleInput extends PublicEvidenceContentInput {
|
|
26
|
+
publisher: PublicPublisherV1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Deterministic, locale-independent code-unit ordering for canonical identity. */
|
|
30
|
+
function compareCanonicalId(a: string, b: string): number {
|
|
31
|
+
if (a < b) return -1;
|
|
32
|
+
if (a > b) return 1;
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function utcDay(value: string): string {
|
|
37
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
38
|
+
throw new PublicEvidenceValidationError("invalid_day", "createdDayUtc must be YYYY-MM-DD");
|
|
39
|
+
}
|
|
40
|
+
const parsed = new Date(`${value}T00:00:00.000Z`);
|
|
41
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
|
|
42
|
+
throw new PublicEvidenceValidationError("invalid_day", "createdDayUtc must be a real UTC day");
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validatePublisher(publisher: PublicPublisherV1): PublicPublisherV1 {
|
|
48
|
+
const raw = publisher as unknown as Record<string, unknown>;
|
|
49
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
50
|
+
throw new PublicEvidenceValidationError("invalid_publisher", "publisher must be an object");
|
|
51
|
+
}
|
|
52
|
+
const keys = Object.keys(raw);
|
|
53
|
+
if (keys.some((key) => !["algorithm", "keyId", "publicKey"].includes(key))) {
|
|
54
|
+
throw new PublicEvidenceValidationError("unknown_field", "publisher contains unknown fields");
|
|
55
|
+
}
|
|
56
|
+
if (publisher.algorithm !== "ed25519") {
|
|
57
|
+
throw new PublicEvidenceValidationError("unsupported_algorithm", "publisher must use ed25519");
|
|
58
|
+
}
|
|
59
|
+
if (!/^[0-9a-f]{64}$/.test(publisher.keyId)) {
|
|
60
|
+
throw new PublicEvidenceValidationError("invalid_publisher", "publisher.keyId must be sha256 hex");
|
|
61
|
+
}
|
|
62
|
+
if (typeof publisher.publicKey !== "string" || publisher.publicKey.length === 0 || publisher.publicKey.length > 1024) {
|
|
63
|
+
throw new PublicEvidenceValidationError("invalid_publisher", "publisher.publicKey is invalid");
|
|
64
|
+
}
|
|
65
|
+
const publicKeyBytes = Buffer.from(publisher.publicKey, "base64");
|
|
66
|
+
if (publicKeyBytes.byteLength === 0 || publicKeyBytes.toString("base64") !== publisher.publicKey) {
|
|
67
|
+
throw new PublicEvidenceValidationError("invalid_publisher", "publisher.publicKey must use canonical base64");
|
|
68
|
+
}
|
|
69
|
+
const expectedKeyId = publicEvidenceId("publisher_key", {
|
|
70
|
+
algorithm: publisher.algorithm,
|
|
71
|
+
publicKey: publisher.publicKey,
|
|
72
|
+
});
|
|
73
|
+
if (publisher.keyId !== expectedKeyId) {
|
|
74
|
+
throw new PublicEvidenceValidationError("publisher_key_id_mismatch", "publisher.keyId does not match public key");
|
|
75
|
+
}
|
|
76
|
+
return { algorithm: "ed25519", keyId: publisher.keyId, publicKey: publisher.publicKey };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateArtifacts(artifacts: PublicArtifactV1[]): PublicArtifactV1[] {
|
|
80
|
+
if (!Array.isArray(artifacts) || artifacts.length > MAX_PUBLIC_BUNDLE_ARTIFACTS) {
|
|
81
|
+
throw new PublicEvidenceValidationError("array_too_large", `artifacts exceeds ${MAX_PUBLIC_BUNDLE_ARTIFACTS}`);
|
|
82
|
+
}
|
|
83
|
+
let aggregate = 0;
|
|
84
|
+
const ids = new Set<string>();
|
|
85
|
+
return artifacts.map((artifact, index) => {
|
|
86
|
+
const raw = artifact as unknown as Record<string, unknown>;
|
|
87
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
88
|
+
throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}] must be an object`);
|
|
89
|
+
}
|
|
90
|
+
if (Object.keys(raw).some((key) => !["artifactId", "artifactClass", "mediaType", "byteCount", "contentBase64"].includes(key))) {
|
|
91
|
+
throw new PublicEvidenceValidationError("unknown_field", `artifacts[${index}] contains unknown fields`);
|
|
92
|
+
}
|
|
93
|
+
if (!/^[0-9a-f]{64}$/.test(artifact.artifactId)) {
|
|
94
|
+
throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].artifactId is invalid`);
|
|
95
|
+
}
|
|
96
|
+
if (typeof artifact.artifactClass !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:+-]{0,255}$/.test(artifact.artifactClass)) {
|
|
97
|
+
throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].artifactClass is invalid`);
|
|
98
|
+
}
|
|
99
|
+
if (typeof artifact.mediaType !== "string" || artifact.mediaType.length === 0 || artifact.mediaType.length > 256) {
|
|
100
|
+
throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].mediaType is invalid`);
|
|
101
|
+
}
|
|
102
|
+
if (!Number.isInteger(artifact.byteCount) || artifact.byteCount < 0 || artifact.byteCount > MAX_PUBLIC_ARTIFACT_BYTES) {
|
|
103
|
+
throw new PublicEvidenceValidationError("artifact_too_large", `artifacts[${index}].byteCount is invalid`);
|
|
104
|
+
}
|
|
105
|
+
let bytes: Buffer;
|
|
106
|
+
try {
|
|
107
|
+
bytes = Buffer.from(artifact.contentBase64, "base64");
|
|
108
|
+
} catch {
|
|
109
|
+
throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].contentBase64 is invalid`);
|
|
110
|
+
}
|
|
111
|
+
if (bytes.byteLength !== artifact.byteCount || bytes.toString("base64") !== artifact.contentBase64) {
|
|
112
|
+
throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}] byte count or base64 is non-canonical`);
|
|
113
|
+
}
|
|
114
|
+
const expectedArtifactId = publicEvidenceId("artifact", {
|
|
115
|
+
artifactClass: artifact.artifactClass,
|
|
116
|
+
mediaType: artifact.mediaType,
|
|
117
|
+
byteCount: artifact.byteCount,
|
|
118
|
+
contentBase64: artifact.contentBase64,
|
|
119
|
+
});
|
|
120
|
+
if (artifact.artifactId !== expectedArtifactId) {
|
|
121
|
+
throw new PublicEvidenceValidationError("artifact_id_mismatch", `artifacts[${index}].artifactId mismatch`);
|
|
122
|
+
}
|
|
123
|
+
aggregate += artifact.byteCount;
|
|
124
|
+
if (aggregate > MAX_PUBLIC_ARTIFACT_BYTES_TOTAL) {
|
|
125
|
+
throw new PublicEvidenceValidationError("artifact_aggregate_too_large", "artifact aggregate exceeds 1 MiB");
|
|
126
|
+
}
|
|
127
|
+
if (ids.has(artifact.artifactId)) {
|
|
128
|
+
throw new PublicEvidenceValidationError("duplicate_id", "artifacts contains duplicate ids");
|
|
129
|
+
}
|
|
130
|
+
ids.add(artifact.artifactId);
|
|
131
|
+
return { ...artifact };
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Validate all publisher-independent bundle content before any signing-key state is touched. */
|
|
136
|
+
export function normalizePublicEvidenceContent(input: PublicEvidenceContentInput): PublicEvidenceContentInput {
|
|
137
|
+
if (!Array.isArray(input.records) || input.records.length > MAX_PUBLIC_BUNDLE_RECORDS) {
|
|
138
|
+
throw new PublicEvidenceValidationError("array_too_large", `records exceeds ${MAX_PUBLIC_BUNDLE_RECORDS}`);
|
|
139
|
+
}
|
|
140
|
+
const records = input.records
|
|
141
|
+
.map(validatePublicEvidenceRecord)
|
|
142
|
+
.sort((a, b) => compareCanonicalId(a.recordId, b.recordId));
|
|
143
|
+
if (new Set(records.map((record) => record.recordId)).size !== records.length) {
|
|
144
|
+
throw new PublicEvidenceValidationError("duplicate_id", "records contains duplicate ids");
|
|
145
|
+
}
|
|
146
|
+
const artifacts = validateArtifacts(input.artifacts)
|
|
147
|
+
.sort((a, b) => compareCanonicalId(a.artifactId, b.artifactId));
|
|
148
|
+
const artifactIds = new Set(artifacts.map((artifact) => artifact.artifactId));
|
|
149
|
+
for (const record of records) {
|
|
150
|
+
for (const artifactId of record.artifactRefs ?? []) {
|
|
151
|
+
if (!artifactIds.has(artifactId)) {
|
|
152
|
+
throw new PublicEvidenceValidationError("artifact_ref_missing", `record ${record.recordId} references a missing public artifact`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { records, artifacts, createdDayUtc: utcDay(input.createdDayUtc) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function canonicalPublicEvidenceContent(
|
|
160
|
+
input: PublicEvidenceContentInput,
|
|
161
|
+
): { canonical: boolean; normalized: PublicEvidenceContentInput } {
|
|
162
|
+
const normalized = normalizePublicEvidenceContent(input);
|
|
163
|
+
const canonical = input.records.length === normalized.records.length
|
|
164
|
+
&& input.artifacts.length === normalized.artifacts.length
|
|
165
|
+
&& input.records.every((record, index) => record.recordId === normalized.records[index]!.recordId)
|
|
166
|
+
&& input.artifacts.every((artifact, index) => artifact.artifactId === normalized.artifacts[index]!.artifactId);
|
|
167
|
+
return { canonical, normalized };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function hasCanonicalPublicEvidenceOrder(input: PublicEvidenceContentInput): boolean {
|
|
171
|
+
return canonicalPublicEvidenceContent(input).canonical;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildFromNormalizedContent(
|
|
175
|
+
normalized: PublicEvidenceContentInput,
|
|
176
|
+
publisherInput: PublicPublisherV1,
|
|
177
|
+
): PublicEvidenceBundleUnsignedV1 {
|
|
178
|
+
const publisher = validatePublisher(publisherInput);
|
|
179
|
+
const content = {
|
|
180
|
+
schemaVersion: PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION,
|
|
181
|
+
exportPolicyVersion: PUBLIC_EXPORT_POLICY_VERSION,
|
|
182
|
+
createdDayUtc: normalized.createdDayUtc,
|
|
183
|
+
publisher,
|
|
184
|
+
records: normalized.records,
|
|
185
|
+
artifacts: normalized.artifacts,
|
|
186
|
+
};
|
|
187
|
+
const bundleId = publicEvidenceId("bundle", content);
|
|
188
|
+
const bundleDigest = publicEvidenceId("bundle_digest", { ...content, bundleId });
|
|
189
|
+
const bundle: PublicEvidenceBundleUnsignedV1 = { ...content, bundleId, bundleDigest };
|
|
190
|
+
if (new TextEncoder().encode(jcsStringify(bundle)).byteLength > MAX_PUBLIC_BUNDLE_BYTES) {
|
|
191
|
+
throw new PublicEvidenceValidationError("bundle_too_large", "public bundle exceeds 2 MiB");
|
|
192
|
+
}
|
|
193
|
+
return bundle;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function buildPublicEvidenceBundle(input: BuildPublicEvidenceBundleInput): PublicEvidenceBundleUnsignedV1 {
|
|
197
|
+
return buildFromNormalizedContent(normalizePublicEvidenceContent(input), input.publisher);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function expectedPublicBundleIdentityFromNormalized(
|
|
201
|
+
normalized: PublicEvidenceContentInput,
|
|
202
|
+
publisher: PublicPublisherV1,
|
|
203
|
+
): { bundleId: string; bundleDigest: string } {
|
|
204
|
+
const rebuilt = buildFromNormalizedContent(normalized, publisher);
|
|
205
|
+
return { bundleId: rebuilt.bundleId, bundleDigest: rebuilt.bundleDigest };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function expectedPublicBundleIdentity(bundle: PublicEvidenceBundleUnsignedV1): { bundleId: string; bundleDigest: string } {
|
|
209
|
+
return expectedPublicBundleIdentityFromNormalized(
|
|
210
|
+
normalizePublicEvidenceContent({
|
|
211
|
+
records: bundle.records,
|
|
212
|
+
artifacts: bundle.artifacts,
|
|
213
|
+
createdDayUtc: bundle.createdDayUtc,
|
|
214
|
+
}),
|
|
215
|
+
bundle.publisher,
|
|
216
|
+
);
|
|
217
|
+
}
|