@bitkyc08/opencodex 2.12.0 → 2.13.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 (173) hide show
  1. package/README.md +1 -1
  2. package/gui/dist/assets/index-BHldBl6_.js +76 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/anthropic-output-schema.ts +137 -0
  6. package/src/adapters/anthropic.ts +15 -0
  7. package/src/adapters/google-antigravity-replay.ts +269 -5
  8. package/src/adapters/openai-chat.ts +25 -18
  9. package/src/adapters/openai-responses.ts +63 -17
  10. package/src/bridge.ts +11 -5
  11. package/src/claude/inbound.ts +24 -3
  12. package/src/cli/account-api.ts +2 -0
  13. package/src/cli/account-extended.ts +262 -1
  14. package/src/cli/account.ts +3 -1
  15. package/src/cli/dispatch.ts +532 -0
  16. package/src/cli/doctor.ts +26 -21
  17. package/src/cli/help.ts +9 -278
  18. package/src/cli/index.ts +45 -506
  19. package/src/cli/lab.ts +171 -65
  20. package/src/cli/registry.ts +384 -0
  21. package/src/cli/root.ts +86 -0
  22. package/src/cli/status.ts +9 -2
  23. package/src/cli/v2.ts +37 -2
  24. package/src/codex/account-label.ts +14 -1
  25. package/src/codex/account-store.ts +12 -1
  26. package/src/codex/admission.ts +2 -1
  27. package/src/codex/auth-api.ts +3 -2
  28. package/src/codex/catalog/metadata.ts +6 -4
  29. package/src/codex/catalog/parsing.ts +31 -7
  30. package/src/codex/catalog/provider-fetch.ts +2 -1
  31. package/src/codex/catalog/sync.ts +65 -11
  32. package/src/codex/features.ts +437 -31
  33. package/src/codex/history-job.ts +6 -11
  34. package/src/codex/history-provider.ts +10 -9
  35. package/src/codex/inject.ts +14 -1
  36. package/src/codex/native-profile-startup.ts +49 -3
  37. package/src/codex/native-residue.ts +14 -2
  38. package/src/codex/paths.ts +75 -1
  39. package/src/codex/reset-credit-recovery.ts +1044 -0
  40. package/src/codex/shim.ts +876 -16
  41. package/src/codex/sync.ts +27 -2
  42. package/src/config.ts +148 -10
  43. package/src/generated/compatibility-version.json +2572 -0
  44. package/src/images/loop.ts +1 -1
  45. package/src/lab/artifacts/sanitize.ts +3 -3
  46. package/src/lab/automation/budgets.ts +78 -0
  47. package/src/lab/automation/config-persistence.ts +256 -0
  48. package/src/lab/automation/constants.ts +39 -0
  49. package/src/lab/automation/cooldown.ts +103 -0
  50. package/src/lab/automation/dispatch.ts +211 -0
  51. package/src/lab/automation/index.ts +13 -0
  52. package/src/lab/automation/orchestrator.ts +480 -0
  53. package/src/lab/automation/persistence.ts +512 -0
  54. package/src/lab/automation/planner.ts +371 -0
  55. package/src/lab/automation/policy.ts +136 -0
  56. package/src/lab/automation/queue.ts +191 -0
  57. package/src/lab/automation/recovery.ts +24 -0
  58. package/src/lab/automation/route-context.ts +21 -0
  59. package/src/lab/automation/run-key.ts +44 -0
  60. package/src/lab/automation/runs-query.ts +34 -0
  61. package/src/lab/automation/types.ts +160 -0
  62. package/src/lab/conformance/types.ts +23 -0
  63. package/src/lab/events/types.ts +2 -1
  64. package/src/lab/events/validate.ts +2 -1
  65. package/src/lab/fabric/constants.ts +40 -0
  66. package/src/lab/fabric/executor.ts +492 -0
  67. package/src/lab/fabric/index.ts +80 -0
  68. package/src/lab/fabric/manifest.ts +222 -0
  69. package/src/lab/fabric/observe.ts +454 -0
  70. package/src/lab/fabric/patch.ts +79 -0
  71. package/src/lab/fabric/producer-child.ts +139 -0
  72. package/src/lab/fabric/producer-isolate.ts +276 -0
  73. package/src/lab/fabric/producer-protocol.ts +61 -0
  74. package/src/lab/fabric/scratch.ts +439 -0
  75. package/src/lab/fabric/subject.ts +106 -0
  76. package/src/lab/fabric/types.ts +134 -0
  77. package/src/lab/fabric/verifier.ts +98 -0
  78. package/src/lab/index.ts +2 -0
  79. package/src/lab/ledger/store.ts +173 -0
  80. package/src/lab/live/executor.ts +29 -4
  81. package/src/lab/live/transport.ts +3 -1
  82. package/src/lab/observe/from-conformance.ts +12 -57
  83. package/src/lab/observe/from-live.ts +9 -1
  84. package/src/lab/paths.ts +74 -9
  85. package/src/lab/projection/schema.ts +14 -1
  86. package/src/lab/projection/verdicts.ts +89 -53
  87. package/src/lab/projection/verification.ts +83 -3
  88. package/src/lab/query/catalog.ts +34 -5
  89. package/src/lab/query/freshness.ts +53 -0
  90. package/src/lab/query/index.ts +11 -0
  91. package/src/lab/query/latest-observation.ts +59 -0
  92. package/src/lab/query/passive-production.ts +159 -0
  93. package/src/lab/subject/installation-salt.ts +24 -4
  94. package/src/lab/subject/protocol-subject.ts +80 -0
  95. package/src/lab/subject/route-subject.ts +2 -1
  96. package/src/lib/fabric-task-execution-authority.ts +7 -0
  97. package/src/lib/fabric-task-host.ts +29 -0
  98. package/src/lib/lab-live-route-production.ts +130 -0
  99. package/src/lib/local-management-capability.ts +100 -0
  100. package/src/lib/server-resource-ownership.ts +71 -0
  101. package/src/lib/windows-elevation.ts +8 -1
  102. package/src/lib/windows-user-principal.ts +62 -2
  103. package/src/lib/winsw.ts +8 -2
  104. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  105. package/src/oauth/account-import/index.ts +15 -0
  106. package/src/oauth/account-import/parser.ts +83 -0
  107. package/src/oauth/account-import/registry.ts +18 -0
  108. package/src/oauth/account-import/service.ts +75 -0
  109. package/src/oauth/account-import/types.ts +91 -0
  110. package/src/oauth/anthropic.ts +12 -1
  111. package/src/oauth/callback-server.ts +8 -2
  112. package/src/oauth/chatgpt.ts +12 -1
  113. package/src/oauth/google-antigravity.ts +27 -0
  114. package/src/oauth/health.ts +12 -48
  115. package/src/oauth/index.ts +122 -0
  116. package/src/oauth/kimi.ts +16 -2
  117. package/src/oauth/nous.ts +749 -0
  118. package/src/oauth/store.ts +56 -3
  119. package/src/providers/codex-capacity.ts +1 -0
  120. package/src/providers/registry.ts +161 -16
  121. package/src/responses/custom-tool-compat.ts +182 -0
  122. package/src/responses/parser.ts +22 -0
  123. package/src/responses/reasoning-replay-cache.ts +168 -11
  124. package/src/responses/schema.ts +1 -1
  125. package/src/router.ts +5 -20
  126. package/src/routing/capability.ts +9 -3
  127. package/src/routing/compatibility/assemble.ts +159 -0
  128. package/src/routing/compatibility/behavior.ts +224 -0
  129. package/src/routing/compatibility/catalog.ts +99 -0
  130. package/src/routing/compatibility/endpoint.ts +52 -0
  131. package/src/routing/compatibility/policy.ts +181 -0
  132. package/src/routing/compatibility/reader.ts +110 -0
  133. package/src/routing/compatibility/subject.ts +191 -0
  134. package/src/routing/compatibility/types.ts +64 -0
  135. package/src/routing/compatibility/version.ts +104 -0
  136. package/src/routing/evaluator.ts +27 -32
  137. package/src/routing/profile.ts +118 -4
  138. package/src/routing/trace.ts +82 -28
  139. package/src/server/auth-cors.ts +7 -0
  140. package/src/server/background-lifecycle.ts +20 -7
  141. package/src/server/chat-completions.ts +2 -2
  142. package/src/server/claude-messages.ts +6 -3
  143. package/src/server/direct-local-http.ts +343 -0
  144. package/src/server/index.ts +80 -18
  145. package/src/server/lifecycle.ts +12 -4
  146. package/src/server/local-management-read-client.ts +90 -0
  147. package/src/server/management/agent-settings-routes.ts +27 -3
  148. package/src/server/management/lab-automation-routes.ts +206 -0
  149. package/src/server/management/lab-routes.ts +13 -0
  150. package/src/server/management/logs-usage-routes.ts +1 -0
  151. package/src/server/management/oauth-account-routes.ts +57 -1
  152. package/src/server/management/provider-routes.ts +16 -0
  153. package/src/server/management/routing-profile-routes.ts +12 -24
  154. package/src/server/management-api.ts +2 -0
  155. package/src/server/management-auth.ts +68 -3
  156. package/src/server/proxy-liveness.ts +3 -2
  157. package/src/server/request-decompress.ts +9 -3
  158. package/src/server/request-log.ts +53 -4
  159. package/src/server/responses/collaboration.ts +93 -17
  160. package/src/server/responses/compact.ts +3 -0
  161. package/src/server/responses/core.ts +202 -15
  162. package/src/server/responses/policy-fallback.ts +1 -0
  163. package/src/server/responses-custom-tool-repair.ts +280 -0
  164. package/src/server/responses-terminal-repair.ts +342 -0
  165. package/src/server/startup-action-control.ts +8 -1
  166. package/src/service.ts +278 -12
  167. package/src/types.ts +49 -3
  168. package/src/usage/expected-prices.ts +30 -0
  169. package/src/usage/log.ts +30 -4
  170. package/src/usage/summary.ts +153 -2
  171. package/src/web-search/loop.ts +1 -1
  172. package/gui/dist/assets/index-Cw1_mxO8.js +0 -70
  173. package/src/cli/internal-dispatch.ts +0 -20
@@ -0,0 +1,98 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ FABRIC_LIMITS,
4
+ FABRIC_VERIFIER_ID,
5
+ SYNTHETIC_AFTER_UTF8,
6
+ SYNTHETIC_BEFORE_UTF8,
7
+ SYNTHETIC_VALUE_PATH,
8
+ } from "./constants";
9
+ import { readScratchFileUtf8, walkScratchFiles } from "./scratch";
10
+ import { verifierManifestDigest } from "./subject";
11
+ import type { ExactTreeDiffResultV1 } from "./types";
12
+ import { FabricTaskError } from "./types";
13
+
14
+ /** SHA-256 hex digest of a UTF-8 string for verifier tree comparisons. */
15
+ function sha256Utf8(value: string): string {
16
+ return createHash("sha256").update(value, "utf8").digest("hex");
17
+ }
18
+
19
+ /**
20
+ * Deterministic exact-tree-diff-v1 verifier. No LLM. Walks only the scratch tree.
21
+ */
22
+ export function verifyExactTreeDiffV1(scratchRoot: string): ExactTreeDiffResultV1 {
23
+ const manifestDigest = verifierManifestDigest();
24
+ const files = walkScratchFiles(scratchRoot);
25
+ let aggregate = 0;
26
+ for (const file of files) {
27
+ aggregate += file.byteLength;
28
+ if (aggregate > FABRIC_LIMITS.maxAggregateIoBytes) {
29
+ throw new FabricTaskError("tree exceeds io budget", "budget_exhausted", "environment");
30
+ }
31
+ }
32
+ if (files.length !== 1 || files[0]!.relativePosix !== SYNTHETIC_VALUE_PATH) {
33
+ const unexpected = files
34
+ .filter((row) => row.relativePosix !== SYNTHETIC_VALUE_PATH)
35
+ .map((row) => row.relativePosix);
36
+ return {
37
+ verifierId: FABRIC_VERIFIER_ID,
38
+ manifestDigest,
39
+ passed: false,
40
+ pathSummaries: [
41
+ ...unexpected.map((path) => ({
42
+ path,
43
+ kind: "added" as const,
44
+ reason: "unexpected_file",
45
+ })),
46
+ ...(files.some((row) => row.relativePosix === SYNTHETIC_VALUE_PATH)
47
+ ? []
48
+ : [{ path: SYNTHETIC_VALUE_PATH, kind: "deleted" as const, reason: "missing_required_file" }]),
49
+ ],
50
+ reason: files.length === 0 ? "deleted_required_file" : "unexpected_tree_shape",
51
+ };
52
+ }
53
+ const after = readScratchFileUtf8(scratchRoot, SYNTHETIC_VALUE_PATH, FABRIC_LIMITS.maxAggregateIoBytes);
54
+ const beforeDigest = sha256Utf8(SYNTHETIC_BEFORE_UTF8);
55
+ const afterDigest = sha256Utf8(after);
56
+ const expectedDigest = sha256Utf8(SYNTHETIC_AFTER_UTF8);
57
+ if (after === SYNTHETIC_BEFORE_UTF8) {
58
+ return {
59
+ verifierId: FABRIC_VERIFIER_ID,
60
+ manifestDigest,
61
+ passed: false,
62
+ pathSummaries: [{
63
+ path: SYNTHETIC_VALUE_PATH,
64
+ kind: "unchanged",
65
+ beforeDigest,
66
+ afterDigest,
67
+ reason: "unchanged",
68
+ }],
69
+ reason: "unchanged_file",
70
+ };
71
+ }
72
+ if (after !== SYNTHETIC_AFTER_UTF8) {
73
+ return {
74
+ verifierId: FABRIC_VERIFIER_ID,
75
+ manifestDigest,
76
+ passed: false,
77
+ pathSummaries: [{
78
+ path: SYNTHETIC_VALUE_PATH,
79
+ kind: "modified",
80
+ beforeDigest,
81
+ afterDigest,
82
+ reason: "wrong_final_bytes",
83
+ }],
84
+ reason: `expected_digest_${expectedDigest}`,
85
+ };
86
+ }
87
+ return {
88
+ verifierId: FABRIC_VERIFIER_ID,
89
+ manifestDigest,
90
+ passed: true,
91
+ pathSummaries: [{
92
+ path: SYNTHETIC_VALUE_PATH,
93
+ kind: "modified",
94
+ beforeDigest,
95
+ afterDigest,
96
+ }],
97
+ };
98
+ }
package/src/lab/index.ts CHANGED
@@ -18,6 +18,7 @@ export * from "./ledger/artifact-refs";
18
18
  export * from "./projection/verification";
19
19
  export * from "./observe/from-conformance";
20
20
  export * from "./observe/from-live";
21
+ export * from "./fabric";
21
22
  export * from "./live/manifest";
22
23
  export * from "./live/runner";
23
24
  export * from "./live/executor";
@@ -34,3 +35,4 @@ export * from "./subject/behavior-fingerprint";
34
35
  export * from "./subject/installation-salt";
35
36
  export { CL03_LIVE_SUITES } from "./conformance/types";
36
37
  export * from "./query";
38
+ export * from "./automation";
@@ -1,13 +1,17 @@
1
1
  import {
2
2
  closeSync,
3
+ constants as fsConstants,
3
4
  existsSync,
4
5
  fsyncSync,
5
6
  mkdirSync,
6
7
  openSync,
8
+ readFileSync,
7
9
  readSync,
8
10
  statSync,
11
+ unlinkSync,
9
12
  writeSync,
10
13
  } from "node:fs";
14
+ import { randomBytes } from "node:crypto";
11
15
  import { dirname } from "node:path";
12
16
  import { jcsStringify } from "../digest";
13
17
  import { MAX_SERIALIZED_EVENT_BYTES } from "../constants";
@@ -21,6 +25,153 @@ export interface LedgerStore {
21
25
  replay(): ReplayResult;
22
26
  }
23
27
 
28
+ const eventIdIndexByLedger = new Map<string, Set<string>>();
29
+ const LEDGER_LOCK_STALE_MS = 60_000;
30
+ const LEDGER_LOCK_WAIT_MS = 5_000;
31
+
32
+ interface LedgerLockMeta {
33
+ pid: number;
34
+ createdAt: number;
35
+ token: string;
36
+ }
37
+
38
+ /** Block synchronously for the given duration (ledger lock retry only). */
39
+ function sleepSyncMs(ms: number): void {
40
+ const end = Date.now() + ms;
41
+ while (Date.now() < end) {
42
+ /* spin */
43
+ }
44
+ }
45
+
46
+ /** Read pid, createdAt, and token metadata from a ledger lock file, if well-formed. */
47
+ function readLedgerLockMeta(lockPath: string): LedgerLockMeta | null {
48
+ try {
49
+ const parsed = JSON.parse(readFileSync(lockPath, "utf8")) as LedgerLockMeta;
50
+ if (
51
+ typeof parsed.pid === "number"
52
+ && typeof parsed.createdAt === "number"
53
+ && typeof parsed.token === "string"
54
+ && parsed.token.length > 0
55
+ ) {
56
+ return parsed;
57
+ }
58
+ } catch {
59
+ /* ignore */
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /** Return true when the lock holder process is still running. */
65
+ function isLockHolderAlive(pid: number): boolean {
66
+ try {
67
+ process.kill(pid, 0);
68
+ return true;
69
+ } catch (error) {
70
+ const code = (error as NodeJS.ErrnoException).code;
71
+ if (code === "ESRCH") return false;
72
+ if (code === "EPERM") return true;
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /** Return true when a ledger lock file has dead metadata and can be recovered. */
78
+ function isLedgerLockStale(lockPath: string): boolean {
79
+ const meta = readLedgerLockMeta(lockPath);
80
+ if (!meta) {
81
+ try {
82
+ return Date.now() - statSync(lockPath).mtimeMs > LEDGER_LOCK_STALE_MS;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+ if (isLockHolderAlive(meta.pid)) return false;
88
+ return Date.now() - meta.createdAt > LEDGER_LOCK_STALE_MS;
89
+ }
90
+
91
+ /** Create a ledger lock file exclusively, recovering stale locks when needed. */
92
+ function tryAcquireLedgerLock(lockPath: string, deadline: number): { fd: number; token: string } {
93
+ while (Date.now() < deadline) {
94
+ const token = randomBytes(16).toString("hex");
95
+ let fd: number;
96
+ try {
97
+ fd = openSync(lockPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
98
+ } catch (error) {
99
+ if (existsSync(lockPath) && isLedgerLockStale(lockPath)) {
100
+ try {
101
+ unlinkSync(lockPath);
102
+ } catch (unlinkError) {
103
+ if (Date.now() >= deadline) throw unlinkError;
104
+ sleepSyncMs(10);
105
+ }
106
+ continue;
107
+ }
108
+ if (Date.now() >= deadline) throw error;
109
+ sleepSyncMs(10);
110
+ continue;
111
+ }
112
+ try {
113
+ const metadataBytes = Buffer.from(JSON.stringify({ pid: process.pid, createdAt: Date.now(), token }), "utf8");
114
+ const written = writeSync(fd, metadataBytes);
115
+ if (written !== metadataBytes.byteLength) {
116
+ throw new LabValidationError("short_write", "ledger lock metadata write incomplete");
117
+ }
118
+ } catch (error) {
119
+ closeSync(fd);
120
+ try {
121
+ unlinkSync(lockPath);
122
+ } catch {
123
+ /* best-effort */
124
+ }
125
+ throw error;
126
+ }
127
+ return { fd, token };
128
+ }
129
+ throw new Error("ledger lock acquisition timed out");
130
+ }
131
+
132
+ /** Release a ledger lock only when the token still matches the lock file. */
133
+ function releaseLedgerLock(lockPath: string, lockFd: number, token: string): void {
134
+ try {
135
+ closeSync(lockFd);
136
+ } catch {
137
+ /* ignore */
138
+ }
139
+ try {
140
+ const meta = readLedgerLockMeta(lockPath);
141
+ if (meta?.token === token) unlinkSync(lockPath);
142
+ } catch {
143
+ /* best-effort */
144
+ }
145
+ }
146
+
147
+ /** Run a ledger mutation while holding the compatibility ledger lock file. */
148
+ function withLedgerLock<T>(ledgerPath: string, fn: () => T): T {
149
+ const lockPath = `${ledgerPath}.lock`;
150
+ mkdirSync(dirname(ledgerPath), { recursive: true, mode: 0o700 });
151
+ const deadline = Date.now() + LEDGER_LOCK_WAIT_MS;
152
+ const { fd, token } = tryAcquireLedgerLock(lockPath, deadline);
153
+ try {
154
+ return fn();
155
+ } finally {
156
+ releaseLedgerLock(lockPath, fd, token);
157
+ }
158
+ }
159
+
160
+ /** Load or build the in-memory event-id index for a ledger path. */
161
+ function loadEventIdIndex(ledgerPath: string): Set<string> {
162
+ let index = eventIdIndexByLedger.get(ledgerPath);
163
+ if (!index) {
164
+ index = new Set();
165
+ if (existsSync(ledgerPath)) {
166
+ for (const event of replayLabLedger(ledgerPath).events) {
167
+ index.add(event.eventId);
168
+ }
169
+ }
170
+ eventIdIndexByLedger.set(ledgerPath, index);
171
+ }
172
+ return index;
173
+ }
174
+
24
175
  /** Durable append of one validated event as a single JSONL line + fsync. */
25
176
  export function appendLabEvent(ledgerPath: string, event: LabEvent): void {
26
177
  const validated = validateLabEvent(event);
@@ -41,6 +192,28 @@ export function appendLabEvent(ledgerPath: string, event: LabEvent): void {
41
192
  } finally {
42
193
  closeSync(fd);
43
194
  }
195
+ loadEventIdIndex(ledgerPath).add(validated.eventId);
196
+ }
197
+
198
+ /**
199
+ * Append only when eventId is absent. Uses an exclusive lock file plus a
200
+ * process-local event-id index refreshed under the lock.
201
+ */
202
+ export function appendLabEventIfAbsent(ledgerPath: string, event: LabEvent): boolean {
203
+ const validated = validateLabEvent(event);
204
+ return withLedgerLock(ledgerPath, () => {
205
+ // Refresh from disk under the lock so concurrent writers are visible.
206
+ const fresh = new Set<string>();
207
+ if (existsSync(ledgerPath)) {
208
+ for (const row of replayLabLedger(ledgerPath).events) {
209
+ fresh.add(row.eventId);
210
+ }
211
+ }
212
+ eventIdIndexByLedger.set(ledgerPath, fresh);
213
+ if (fresh.has(validated.eventId)) return false;
214
+ appendLabEvent(ledgerPath, validated);
215
+ return true;
216
+ });
44
217
  }
45
218
 
46
219
  function processLine(
@@ -21,6 +21,8 @@ export interface LiveExecutorOptions {
21
21
  resolve?: DnsResolver;
22
22
  configDir?: string;
23
23
  env?: NodeJS.ProcessEnv;
24
+ /** Orchestration cancellation — must not produce route incompatibility evidence. */
25
+ cancelSignal?: AbortSignal;
24
26
  }
25
27
 
26
28
  interface TrustedLiveResultReceipt {
@@ -143,6 +145,11 @@ function pathForProtocol(protocol: string): string {
143
145
  return "/responses";
144
146
  }
145
147
 
148
+ /** Upstream HTTP path for a trusted live-route request (CL-03 / CL-08 production dispatch). */
149
+ export function liveUpstreamRequestPath(protocol: string): string {
150
+ return pathForProtocol(protocol);
151
+ }
152
+
146
153
  function chatObservation(body: string, status: number): NormalizedObservation {
147
154
  const observation = emptyObservation();
148
155
  const output: unknown[] = [];
@@ -215,15 +222,33 @@ function normalizeTransportObservation(route: LabRouteContext, response: { statu
215
222
  return observation;
216
223
  }
217
224
 
218
- async function withTotalTimeout<T>(timeoutMs: number, run: (signal: AbortSignal) => Promise<T>): Promise<T> {
225
+ /** Normalize a pinned provider response into a Lab observation (trusted-route production path). */
226
+ export function normalizeLabLiveTransportObservation(
227
+ route: LabRouteContext,
228
+ response: { status: number; body: string },
229
+ ): NormalizedObservation {
230
+ return normalizeTransportObservation(route, response);
231
+ }
232
+
233
+ async function withTotalTimeout<T>(
234
+ timeoutMs: number,
235
+ run: (signal: AbortSignal) => Promise<T>,
236
+ cancelSignal?: AbortSignal,
237
+ ): Promise<T> {
238
+ if (cancelSignal?.aborted) throw cancelSignal.reason ?? new TransportError("harness_failure", "cancelled");
219
239
  const controller = new AbortController();
240
+ const abortFromCancel = () => controller.abort(cancelSignal?.reason ?? new TransportError("harness_failure", "cancelled"));
241
+ if (cancelSignal) cancelSignal.addEventListener("abort", abortFromCancel, { once: true });
220
242
  const timer = setTimeout(() => controller.abort(new TransportError("total_timeout", "live scenario total timeout")), timeoutMs);
221
243
  try {
222
244
  return await Promise.race([
223
245
  run(controller.signal),
224
246
  new Promise<T>((_, reject) => controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true })),
225
247
  ]);
226
- } finally { clearTimeout(timer); }
248
+ } finally {
249
+ clearTimeout(timer);
250
+ if (cancelSignal) cancelSignal.removeEventListener("abort", abortFromCancel);
251
+ }
227
252
  }
228
253
 
229
254
  export async function runLiveScenario(caseRecord: CaseRecord, routeContext: LabRouteContext, opts: LiveExecutorOptions = {}): Promise<LiveScenarioRunResult> {
@@ -266,13 +291,13 @@ export async function runLiveScenario(caseRecord: CaseRecord, routeContext: LabR
266
291
  if (!isTrustedLabRouteExecutor(opts.routeExecutor)) throw new TransportError("untrusted_route_executor", "untrusted route executor capability");
267
292
  executionAuthority = "trusted_route";
268
293
  trustedExecutionStarted = true;
269
- observation = await withTotalTimeout(limits.totalTimeoutMs, (signal) => opts.routeExecutor!.execute({ routeContext, destination, routeSubject: routeSubject!, scenarioId: activeCase.id, initiatingRequest: activeCase.initiatingRequest?.bytesUtf8, limits, signal, environment }));
294
+ observation = await withTotalTimeout(limits.totalTimeoutMs, (signal) => opts.routeExecutor!.execute({ routeContext, destination, routeSubject: routeSubject!, scenarioId: activeCase.id, initiatingRequest: activeCase.initiatingRequest?.bytesUtf8, limits, signal, environment }), opts.cancelSignal);
270
295
  } else if (opts.transport && activeCase.initiatingRequest) {
271
296
  executionAuthority = "test_transport";
272
297
  const body = activeCase.initiatingRequest.bytesUtf8;
273
298
  const inputBytes = new TextEncoder().encode(body).byteLength;
274
299
  enforceSandboxLimits(state, limits, { requests: 1, inputBytes });
275
- const response = await withTotalTimeout(limits.totalTimeoutMs, (signal) => opts.transport!.request({ method: "POST", path: pathForProtocol(routeContext.upstreamProtocol), body, signal }));
300
+ const response = await withTotalTimeout(limits.totalTimeoutMs, (signal) => opts.transport!.request({ method: "POST", path: pathForProtocol(routeContext.upstreamProtocol), body, signal }), opts.cancelSignal);
276
301
  if (response.status === 401 || response.status === 403) throw new TransportError("auth_blocked", `HTTP ${response.status}`);
277
302
  if (response.status === 429) throw new TransportError("quota_blocked", "HTTP 429");
278
303
  if (response.status === 451) throw new TransportError("region_blocked", "HTTP 451");
@@ -104,8 +104,10 @@ export function classifyTransportError(error: unknown): { classification: Failur
104
104
  case "network_blocked": return { classification: "network_failure", secondaryCode: code };
105
105
  case "region_blocked": return { classification: "region_blocked", secondaryCode: code };
106
106
  case "provider_transient": return { classification: "provider_transient", secondaryCode: code };
107
- case "connect_timeout": case "first_byte_timeout": case "inactivity_timeout": case "total_timeout":
107
+ case "connect_timeout": case "first_byte_timeout": case "total_timeout":
108
108
  return { classification: "timeout", secondaryCode: code };
109
+ case "inactivity_timeout":
110
+ return { classification: "inactivity_timeout", secondaryCode: code };
109
111
  case "request_limit": case "input_byte_limit": case "output_byte_limit": case "output_token_limit":
110
112
  case "tool_call_limit": case "artifact_byte_limit": case "memory_limit": case "child_process_limit":
111
113
  return { classification: "budget_exhausted", secondaryCode: code };
@@ -2,7 +2,6 @@
2
2
  * Persistence seam: transform CL-01 conformance results into valid observation events.
3
3
  * Deterministic harness execution stays separate from ledger persistence.
4
4
  */
5
- import { createHash } from "node:crypto";
6
5
  import { createArtifactStore, type ArtifactStore } from "../artifacts/store";
7
6
  import { sanitizeDiagnostic, truncateUtf8 } from "../artifacts/sanitize";
8
7
  import {
@@ -12,12 +11,11 @@ import {
12
11
  type ObservationOutcome,
13
12
  } from "../constants";
14
13
  import {
15
- jcsStringify,
16
14
  scenarioManifestDigest,
17
15
  subjectIdForSubject,
18
16
  suiteManifestDigest,
19
17
  } from "../digest";
20
- import type { ObservationEvent, ProtocolSubjectV1 } from "../events/types";
18
+ import type { ObservationEvent } from "../events/types";
21
19
  import { assignEventId } from "../events/validate";
22
20
  import { appendLabEvent } from "../ledger/store";
23
21
  import { ensureLabDirs } from "../paths";
@@ -31,8 +29,10 @@ import { expandScenario } from "../conformance/manifest";
31
29
  import { suiteManifestObjectForCase } from "../conformance/suite-manifest";
32
30
  import { fixtureDigest } from "../conformance/digest";
33
31
  import { resolveProtocolExecutionContext } from "../conformance/executor";
34
-
35
- const COMPAT_VERSION = "protocol-v1";
32
+ import {
33
+ buildProtocolSubjectV1,
34
+ protocolBehaviorFingerprintV1,
35
+ } from "../subject/protocol-subject";
36
36
 
37
37
  export interface PersistConformanceOptions {
38
38
  configDir?: string;
@@ -75,71 +75,24 @@ function behaviorFingerprintForCase(
75
75
  caseRecord,
76
76
  executionContext ?? resolveProtocolExecutionContext(caseRecord),
77
77
  );
78
- const adapter = upstreamAdapter(ctx.upstreamProtocol);
79
- const values = {
80
- schemaVersion: 1,
81
- resolverVersion: 1,
82
- values: {
83
- "wire.adapter": {
84
- source: "lab_forced",
85
- value: adapter,
86
- },
87
- "wire.upstreamProtocol": {
88
- source: "lab_forced",
89
- value: ctx.upstreamProtocol,
90
- },
91
- "runtime.arch": {
92
- source: "lab_forced",
93
- value: process.arch,
94
- },
95
- "runtime.bunVersion": {
96
- source: "lab_forced",
97
- value: process.versions.bun ?? Bun.version,
98
- },
99
- "runtime.platform": {
100
- source: "lab_forced",
101
- value: process.platform,
102
- },
103
- },
104
- };
105
- return createHash("sha256").update(jcsStringify(values)).digest("hex");
78
+ return protocolBehaviorFingerprintV1(ctx);
106
79
  }
107
80
 
108
- function protocolSubject(caseRecord: CaseRecord, result: ScenarioRunResult): ProtocolSubjectV1 {
81
+ function protocolSubject(caseRecord: CaseRecord, result: ScenarioRunResult) {
109
82
  const ctx = validateExecutionContext(
110
83
  caseRecord,
111
84
  result.executionContext ?? resolveProtocolExecutionContext(caseRecord),
112
85
  );
113
- return {
114
- subjectSchemaVersion: 1,
115
- subjectKind: "protocol",
116
- opencodexCompatibilityVersion: COMPAT_VERSION,
117
- effectiveAdapter: upstreamAdapter(ctx.upstreamProtocol),
118
- inboundProtocol: ctx.inboundProtocol,
119
- upstreamProtocol: ctx.upstreamProtocol,
120
- surface: ctx.surface,
121
- behaviorFingerprint: behaviorFingerprintForCase(caseRecord, ctx),
122
- };
123
- }
124
-
125
- function upstreamAdapter(protocol: string): string {
126
- switch (protocol) {
127
- case "openai-responses":
128
- return "openai-responses";
129
- case "anthropic-messages":
130
- return "anthropic";
131
- case "openai-chat":
132
- return "openai-chat";
133
- default:
134
- throw new Error(`unsupported protocol identity: ${protocol}`);
135
- }
86
+ return buildProtocolSubjectV1(ctx);
136
87
  }
137
88
 
138
89
  function outcomeFromResult(result: ScenarioRunResult): ObservationOutcome {
139
90
  if (result.passed) return "pass";
140
91
  switch (result.classification) {
141
92
  case "timeout":
93
+ case "inactivity_timeout":
142
94
  case "budget_exhausted":
95
+ case "sandbox_violation":
143
96
  case "authentication_blocked":
144
97
  case "quota_blocked":
145
98
  case "region_blocked":
@@ -148,6 +101,8 @@ function outcomeFromResult(result: ScenarioRunResult): ObservationOutcome {
148
101
  return "blocked";
149
102
  case "inconclusive":
150
103
  case "harness_failure":
104
+ case "malformed_producer_outcome":
105
+ case "layer_subject_mismatch":
151
106
  return "inconclusive";
152
107
  case "protocol_failure":
153
108
  case "capability_failure":
@@ -20,7 +20,9 @@ function outcomeFromLiveResult(result: LiveScenarioRunResult): ObservationOutcom
20
20
  if (result.passed) return "pass";
21
21
  switch (result.classification) {
22
22
  case "timeout":
23
+ case "inactivity_timeout":
23
24
  case "budget_exhausted":
25
+ case "sandbox_violation":
24
26
  case "authentication_blocked":
25
27
  case "quota_blocked":
26
28
  case "region_blocked":
@@ -33,7 +35,13 @@ function outcomeFromLiveResult(result: LiveScenarioRunResult): ObservationOutcom
33
35
  return "fail";
34
36
  case "inconclusive":
35
37
  case "harness_failure":
38
+ case "malformed_producer_outcome":
39
+ case "layer_subject_mismatch":
36
40
  return "inconclusive";
41
+ default: {
42
+ const _never: never = result.classification;
43
+ throw new Error(`unmapped failure classification: ${String(_never)}`);
44
+ }
37
45
  }
38
46
  }
39
47
 
@@ -41,7 +49,7 @@ function failureFromLiveResult(result: LiveScenarioRunResult, retryable: boolean
41
49
  if (result.passed || result.classification === "inconclusive") return undefined;
42
50
  const attribution: FailureRecordV1["attribution"] = result.classification === "harness_failure"
43
51
  ? "harness"
44
- : ["authentication_blocked", "quota_blocked", "region_blocked", "network_failure", "provider_transient", "timeout", "budget_exhausted"].includes(result.classification)
52
+ : ["authentication_blocked", "quota_blocked", "region_blocked", "network_failure", "provider_transient", "timeout", "inactivity_timeout", "budget_exhausted"].includes(result.classification)
45
53
  ? "environment"
46
54
  : "route";
47
55
  return {
package/src/lab/paths.ts CHANGED
@@ -1,12 +1,63 @@
1
- import { chmodSync, mkdirSync, statSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { chmodSync, existsSync, mkdirSync, lstatSync, realpathSync } from "node:fs";
2
+ import { basename, dirname, join, resolve, sep } from "node:path";
3
3
  import { getConfigDir } from "../config";
4
4
 
5
- function ensureRestrictedDir(dir: string): void {
6
- mkdirSync(dir, { recursive: true, mode: 0o700 });
7
- if (process.platform === "win32") return;
8
- const mode = statSync(dir).mode & 0o777;
9
- if (mode !== 0o700) chmodSync(dir, 0o700);
5
+ /** Return true when path is the Lab root or a descendant path component. */
6
+ function isAtOrBelowLabBoundary(path: string, labRootBoundary: string): boolean {
7
+ const normalizedPath = resolve(path);
8
+ const normalizedBoundary = resolve(labRootBoundary);
9
+ if (normalizedPath === normalizedBoundary) return true;
10
+ const prefix = normalizedBoundary + sep;
11
+ return normalizedPath.startsWith(prefix) && normalizedPath.length > normalizedBoundary.length;
12
+ }
13
+
14
+ /** Reject symlink, non-directory, and reparse-point substitutions for a Lab-owned component. */
15
+ function assertRestrictedDirectoryComponent(path: string): void {
16
+ const componentStats = lstatSync(path);
17
+ if (componentStats.isSymbolicLink()) {
18
+ throw new Error(`restricted directory component is a symbolic link: ${path}`);
19
+ }
20
+ if (!componentStats.isDirectory()) {
21
+ throw new Error(`restricted path component is not a directory: ${path}`);
22
+ }
23
+ const canonical = resolve(realpathSync.native(path));
24
+ const canonicalParent = resolve(realpathSync.native(dirname(path)));
25
+ const expectedCanonical = resolve(join(canonicalParent, basename(path)));
26
+ if (canonical !== expectedCanonical) {
27
+ throw new Error(`restricted directory component contains a link or reparse-point substitution: ${path}`);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Create (or harden) a directory to mode 0o700 without following symlinks on Lab-owned
33
+ * components. Ancestors above labRootBoundary are treated as infrastructure and are not
34
+ * inspected for symlinks (for example macOS /var → /private/var).
35
+ */
36
+ export function ensureRestrictedDir(dir: string, labRootBoundary: string): void {
37
+ const abs = resolve(dir);
38
+ const boundary = resolve(labRootBoundary);
39
+ const parts = abs.split(sep);
40
+ let current = parts[0] === "" ? sep : parts[0]!;
41
+ for (const part of parts.slice(1)) {
42
+ if (part === "") continue;
43
+ current = join(current, part);
44
+ try {
45
+ mkdirSync(current, { mode: 0o700 });
46
+ } catch (error) {
47
+ const code = (error as NodeJS.ErrnoException).code;
48
+ if (code !== "EEXIST") throw error;
49
+ }
50
+ if (isAtOrBelowLabBoundary(current, boundary)) {
51
+ assertRestrictedDirectoryComponent(current);
52
+ }
53
+ }
54
+ if (!existsSync(abs)) return;
55
+ if (!isAtOrBelowLabBoundary(abs, boundary)) return;
56
+ assertRestrictedDirectoryComponent(abs);
57
+ if (process.platform !== "win32") {
58
+ const mode = lstatSync(abs).mode & 0o777;
59
+ if (mode !== 0o700) chmodSync(abs, 0o700);
60
+ }
10
61
  }
11
62
 
12
63
  /** Canonical Compatibility Lab state root under the OpenCodex config dir. */
@@ -39,6 +90,18 @@ export function labInstallationSaltPath(configDir = getConfigDir()): string {
39
90
  return join(labRoot(configDir), "installation-salt.bin");
40
91
  }
41
92
 
93
+ export function labAutomationPolicyPath(configDir = getConfigDir()): string {
94
+ return join(labRoot(configDir), "automation-policy.json");
95
+ }
96
+
97
+ export function labAutomationStatePath(configDir = getConfigDir()): string {
98
+ return join(labRoot(configDir), "automation-state.json");
99
+ }
100
+
101
+ export function labAutomationRoutesPath(configDir = getConfigDir()): string {
102
+ return join(labRoot(configDir), "automation-routes.json");
103
+ }
104
+
42
105
  /** Ensure lab directories exist with restrictive permissions where the platform allows. */
43
106
  export function ensureLabDirs(configDir = getConfigDir()): {
44
107
  root: string;
@@ -52,8 +115,10 @@ export function ensureLabDirs(configDir = getConfigDir()): {
52
115
  const artifactsDir = labArtifactsDir(configDir);
53
116
  const scratchDir = labScratchDir(configDir);
54
117
  const exportDir = labExportDir(configDir);
55
- ensureRestrictedDir(root);
56
- ensureRestrictedDir(artifactsDir);
118
+ ensureRestrictedDir(root, root);
119
+ ensureRestrictedDir(artifactsDir, root);
120
+ ensureRestrictedDir(scratchDir, root);
121
+ ensureRestrictedDir(exportDir, root);
57
122
  return {
58
123
  root,
59
124
  ledgerPath: labLedgerPath(configDir),
@@ -2,7 +2,7 @@
2
2
  * Disposable SQLite projection schema for Compatibility Lab (CL-02).
3
3
  * Not canonical storage — rebuildable from JSONL + content-addressed artifacts.
4
4
  */
5
- export const LAB_SQLITE_SCHEMA_VERSION = 2;
5
+ export const LAB_SQLITE_SCHEMA_VERSION = 3;
6
6
 
7
7
  export const LAB_SQLITE_DDL = `
8
8
  CREATE TABLE IF NOT EXISTS schema_meta (
@@ -50,6 +50,19 @@ CREATE INDEX IF NOT EXISTS idx_observations_proj ON observations(
50
50
  subject_id, evidence_layer, suite_id, suite_version, suite_manifest_digest, completed_at
51
51
  );
52
52
 
53
+ CREATE INDEX IF NOT EXISTS idx_observations_exact_identity ON observations(
54
+ evidence_layer,
55
+ subject_id,
56
+ suite_id,
57
+ suite_version,
58
+ suite_manifest_digest,
59
+ scenario_id,
60
+ scenario_version,
61
+ scenario_manifest_digest,
62
+ completed_at DESC,
63
+ event_id DESC
64
+ );
65
+
53
66
  CREATE TABLE IF NOT EXISTS claims (
54
67
  event_id TEXT PRIMARY KEY,
55
68
  subject_id TEXT NOT NULL,