@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.
Files changed (119) hide show
  1. package/gui/dist/assets/index-DF_UFrGS.css +1 -0
  2. package/gui/dist/assets/index-DSK3S5HY.js +76 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/mimo-free.ts +17 -0
  6. package/src/adapters/openai-chat.ts +367 -32
  7. package/src/adapters/registry.ts +144 -0
  8. package/src/chat/inbound.ts +13 -7
  9. package/src/cli/claude.ts +2 -1
  10. package/src/cli/combo.ts +3 -0
  11. package/src/cli/dispatch.ts +8 -0
  12. package/src/cli/export-command.ts +2 -2
  13. package/src/cli/help.ts +2 -0
  14. package/src/cli/index.ts +3 -2
  15. package/src/cli/lab.ts +135 -1
  16. package/src/cli/minimax.ts +491 -0
  17. package/src/cli/models-runtime.ts +22 -1
  18. package/src/cli/models.ts +67 -2
  19. package/src/cli/opencode.ts +2 -1
  20. package/src/cli/registry.ts +22 -2
  21. package/src/clients/config-export.ts +125 -7
  22. package/src/codex/app-server-processes.ts +57 -2
  23. package/src/codex/app-server-restart-service.ts +232 -0
  24. package/src/codex/catalog/aggregation.ts +10 -1
  25. package/src/codex/catalog/effort.ts +15 -3
  26. package/src/codex/catalog/parsing.ts +3 -1
  27. package/src/codex/catalog/provider-fetch.ts +45 -5
  28. package/src/codex/catalog/sync.ts +74 -4
  29. package/src/codex/convergence.ts +2 -0
  30. package/src/combos/index.ts +1 -0
  31. package/src/combos/request.ts +30 -0
  32. package/src/combos/types.ts +6 -0
  33. package/src/config.ts +52 -0
  34. package/src/generated/compatibility-version.json +224 -76
  35. package/src/images/loop.ts +11 -1
  36. package/src/integrations/registry.ts +7 -0
  37. package/src/lab/conformance/jcs.ts +42 -2
  38. package/src/lab/conformance/negative-controls.ts +6 -2
  39. package/src/lab/conformance/runner.ts +16 -5
  40. package/src/lab/fabric/observe.ts +49 -14
  41. package/src/lab/index.ts +16 -0
  42. package/src/lab/ledger/purge.ts +152 -83
  43. package/src/lab/ledger/store.ts +168 -54
  44. package/src/lab/observe/from-conformance.ts +8 -6
  45. package/src/lab/observe/from-live.ts +8 -2
  46. package/src/lab/paths.ts +23 -0
  47. package/src/lab/public/bundle.ts +217 -0
  48. package/src/lab/public/community-authority.ts +175 -0
  49. package/src/lab/public/community-files.ts +29 -0
  50. package/src/lab/public/community.ts +479 -0
  51. package/src/lab/public/file-safety.ts +155 -0
  52. package/src/lab/public/ids.ts +26 -0
  53. package/src/lab/public/index.ts +16 -0
  54. package/src/lab/public/mutation-lock.ts +424 -0
  55. package/src/lab/public/operator.ts +353 -0
  56. package/src/lab/public/origin-purge.ts +79 -0
  57. package/src/lab/public/origin.ts +203 -0
  58. package/src/lab/public/privacy.ts +143 -0
  59. package/src/lab/public/private-file.ts +261 -0
  60. package/src/lab/public/project.ts +124 -0
  61. package/src/lab/public/purge-test-fault.ts +21 -0
  62. package/src/lab/public/purge.ts +223 -0
  63. package/src/lab/public/registry.ts +44 -0
  64. package/src/lab/public/revocation.ts +252 -0
  65. package/src/lab/public/signature.ts +219 -0
  66. package/src/lab/public/storage.ts +105 -0
  67. package/src/lab/public/strict-json.ts +206 -0
  68. package/src/lab/public/time.ts +26 -0
  69. package/src/lab/public/types.ts +172 -0
  70. package/src/lab/public/validate.ts +391 -0
  71. package/src/lib/codex-restart-contract.ts +120 -0
  72. package/src/lib/lab-activation.ts +109 -47
  73. package/src/lib/lab-live-pinned-sender.ts +16 -5
  74. package/src/lib/pinned-http.ts +70 -16
  75. package/src/lib/self-launch-argv.ts +15 -0
  76. package/src/lib/state-store-registrations.ts +2 -0
  77. package/src/lib/upstream-reachability.ts +4 -0
  78. package/src/lib/windows-elevation.ts +10 -1
  79. package/src/providers/derive.ts +24 -4
  80. package/src/providers/registry.ts +7 -3
  81. package/src/providers/request-pacing.ts +310 -0
  82. package/src/providers/service-tier.ts +143 -0
  83. package/src/providers/static-model-discovery.ts +86 -0
  84. package/src/reasoning-effort.ts +27 -1
  85. package/src/router.ts +23 -6
  86. package/src/routing/capability.ts +4 -2
  87. package/src/routing/compatibility/behavior.ts +5 -1
  88. package/src/server/adapter-resolve.ts +2 -32
  89. package/src/server/auth-cors.ts +8 -0
  90. package/src/server/chat-completions.ts +74 -36
  91. package/src/server/chat-native-sse.ts +331 -0
  92. package/src/server/chat-native.ts +371 -0
  93. package/src/server/management/combo-routes.ts +16 -2
  94. package/src/server/management/config-routes.ts +6 -4
  95. package/src/server/management/context.ts +17 -0
  96. package/src/server/management/lab-routes.ts +181 -19
  97. package/src/server/management/model-routes.ts +76 -2
  98. package/src/server/management/model-rows.ts +8 -0
  99. package/src/server/management/provider-capability-config.ts +48 -0
  100. package/src/server/management/provider-routes.ts +76 -4
  101. package/src/server/management/system-restart.ts +4 -2
  102. package/src/server/management/system-routes.ts +38 -0
  103. package/src/server/relay.ts +17 -3
  104. package/src/server/responses/compact.ts +4 -1
  105. package/src/server/responses/core.ts +257 -46
  106. package/src/server/responses/empty-completion-guard.ts +276 -0
  107. package/src/server/responses/fetch-helpers.ts +35 -4
  108. package/src/server/responses/pacing-overload.ts +13 -0
  109. package/src/server/responses/policy-fallback.ts +16 -2
  110. package/src/server/responses/terminal-guard.ts +1 -1
  111. package/src/server/responses/upstream-error.ts +5 -0
  112. package/src/server/responses.ts +17 -2
  113. package/src/types.ts +66 -4
  114. package/src/update/index.ts +6 -5
  115. package/src/update/job.ts +5 -6
  116. package/src/update/notify.ts +5 -3
  117. package/src/usage/log.ts +11 -1
  118. package/gui/dist/assets/index-CQ7bIKee.css +0 -1
  119. package/gui/dist/assets/index-D_JUZLEC.js +0 -76
@@ -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
- const end = Date.now() + ms;
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
- if (isLockHolderAlive(meta.pid)) return false;
87
- return Date.now() - meta.createdAt > LEDGER_LOCK_STALE_MS;
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
- unlinkSync(lockPath);
101
- } catch (unlinkError) {
102
- if (Date.now() >= deadline) throw unlinkError;
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
- const metadataBytes = Buffer.from(JSON.stringify({ pid: process.pid, createdAt: Date.now(), token }), "utf8");
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
- closeSync(fd);
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
- export function appendLabEvent(ledgerPath: string, event: LabEvent): void {
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(validated)}\n`;
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
- * Append only when eventId is absent. Uses an exclusive lock file plus a
183
- * process-local event-id index refreshed under the lock.
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 appendLabEventIfAbsent(ledgerPath: string, event: LabEvent): boolean {
186
- const validated = validateLabEvent(event);
255
+ export function withLedgerMutation<T>(
256
+ ledgerPath: string,
257
+ fn: (mutation: LedgerMutationContext) => T,
258
+ ): T {
187
259
  return withLedgerLock(ledgerPath, () => {
188
- // Refresh from disk under the lock so concurrent writers are visible.
189
- const fresh = new Set<string>();
190
- if (existsSync(ledgerPath)) {
191
- for (const row of replayLabLedger(ledgerPath).events) {
192
- fresh.add(row.eventId);
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 { appendLabEvent } from "../ledger/store";
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
- const { event } = observationFromConformanceResult(result, caseRecord, authority, {
289
- ...opts,
290
- artifactStore: store,
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 { appendLabEvent } from "../ledger/store";
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 { const { event } = observationFromLiveResult(result, caseRecord, authority, { ...opts, artifactStore: store }); appendLabEvent(paths.ledgerPath, event); return { event, ledgerPath: paths.ledgerPath }; }
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
+ }