@sema-agent/server 4.1.1 → 4.1.2

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 (54) hide show
  1. package/dist/approval.d.ts +3 -2
  2. package/dist/approval.js +0 -6
  3. package/dist/bake-runner/main.js +1 -5
  4. package/dist/boot/config-center.js +2 -1
  5. package/dist/boot/execution-env.d.ts +2 -2
  6. package/dist/boot/execution-env.js +17 -12
  7. package/dist/boot/resolve-spec.js +3 -3
  8. package/dist/bounded-session-map.d.ts +25 -0
  9. package/dist/bounded-session-map.js +37 -0
  10. package/dist/capabilities/center-plugins.d.ts +6 -1
  11. package/dist/capabilities/center-plugins.js +32 -9
  12. package/dist/capabilities/center-prompts.js +2 -1
  13. package/dist/capabilities/sandbox-file-send.d.ts +2 -2
  14. package/dist/capabilities/sandbox-file-send.js +6 -4
  15. package/dist/capabilities/select-environment-tool.d.ts +1 -2
  16. package/dist/capabilities/select-environment-tool.js +7 -11
  17. package/dist/capabilities/skills.d.ts +4 -0
  18. package/dist/capabilities/skills.js +5 -4
  19. package/dist/config-center/skills-mcp.js +2 -1
  20. package/dist/digest-form.d.ts +15 -0
  21. package/dist/digest-form.js +15 -0
  22. package/dist/http/route-ctx.d.ts +3 -8
  23. package/dist/http/routes/approvals-assistant.js +3 -2
  24. package/dist/http/routes/runs.js +2 -2
  25. package/dist/http/routes/session-sync.js +11 -11
  26. package/dist/http/server.d.ts +2 -2
  27. package/dist/http/server.js +18 -40
  28. package/dist/http/verify-rounds.d.ts +24 -0
  29. package/dist/http/verify-rounds.js +18 -0
  30. package/dist/images/bake-validate.d.ts +5 -2
  31. package/dist/images/bake-validate.js +5 -2
  32. package/dist/index.d.ts +1 -0
  33. package/dist/leader/grader-env-factory.js +1 -2
  34. package/dist/leader/merge.js +1 -1
  35. package/dist/leader/repair-oracle.js +1 -2
  36. package/dist/per-task-image.d.ts +1 -16
  37. package/dist/per-task-image.js +21 -11
  38. package/dist/plugins/approval-store-sql.js +2 -3
  39. package/dist/plugins/checkpoint-store-sql.js +2 -3
  40. package/dist/plugins/image-bake-store-sql.js +3 -3
  41. package/dist/plugins/outcome-ledger-sql.js +2 -3
  42. package/dist/plugins/remote-env-e2b.js +1 -5
  43. package/dist/plugins/remote-shell.d.ts +2 -1
  44. package/dist/plugins/remote-shell.js +2 -1
  45. package/dist/plugins/run-store-sql.js +3 -3
  46. package/dist/plugins/sql-driver.d.ts +17 -1
  47. package/dist/plugins/sql-driver.js +12 -0
  48. package/dist/prompts-domain-validate.js +3 -3
  49. package/dist/runs.d.ts +2 -4
  50. package/dist/security.d.ts +11 -0
  51. package/dist/security.js +11 -0
  52. package/dist/session-sync.d.ts +12 -1
  53. package/dist/session-sync.js +15 -3
  54. package/package.json +1 -1
@@ -2,7 +2,7 @@ import { once } from "node:events";
2
2
  import { randomBytes, createHash } from "node:crypto";
3
3
  import { sessionLogDigest, sessionLogDigestsComparable, StreamingImportValidator, SessionError, SessionPolicyError } from "@sema-agent/core";
4
4
  import { StringDecoder } from "node:string_decoder";
5
- import { exportSessionManifest, classifySyncRelationshipByIds, SyncConflictError } from "../../session-sync.js";
5
+ import { exportSessionManifest, classifySyncRelationshipByIds, SyncConflictError, fileSnapshotImportFace } from "../../session-sync.js";
6
6
  import { isUuidShape } from "../../security.js";
7
7
  import { sendJson, sendError, msg } from "../send.js";
8
8
  import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
@@ -19,8 +19,8 @@ const SYNC_NDJSON_LINE_MAX_BYTES = 8 * 1024 * 1024;
19
19
  /** 2c session-sync P1d-β: the Phase-B entry batch size — entries are fed to `appendBatch` in chunks of this many, so
20
20
  * in-flight memory is O(batch), not O(session). 500 mirrors the PULL stream's keyset page size (one bounded txn). */
21
21
  const SYNC_IMPORT_BATCH = 500;
22
- /** 64 lowercase-hex chars = a sha256 content-address (the blob hash + the GET :hash param shape). */
23
- const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
22
+ // SHA256_HEX_RE = 64 lowercase-hex chars, a sha256 content-address (the blob hash + the GET :hash param shape).
23
+ import { SHA256_HEX_RE } from "../../digest-form.js";
24
24
  export function createSessionSyncLocal() {
25
25
  const pendingStagings = new Map();
26
26
  const importLeases = new Map();
@@ -379,11 +379,11 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
379
379
  // 焊死成永久分裂(Phase B 的重放永远不再运行)。identical 时把附随物幂等重放(snapshot=
380
380
  // content-addressed importManifest,已存在=no-op;policy/anchor put=幂等),失败=同款 422,
381
381
  // 客户端 re-PUT blob 再重试——自愈闭环。
382
- const fss = deps.fileSnapshotStore;
383
- if (fss?.importManifest && fss.getBlob && snapshots.length > 0) {
384
- const getBlobA = (h) => fss.getBlob(h);
382
+ const importFaceIdentical = fileSnapshotImportFace(deps.fileSnapshotStore);
383
+ if (importFaceIdentical && deps.fileSnapshotStore?.getBlob && snapshots.length > 0) {
384
+ const getBlobA = (h) => deps.fileSnapshotStore.getBlob(h);
385
385
  for (const snap of snapshots) {
386
- const r = await fss.importManifest(sessionId, snap.key, new Map(snap.manifest), getBlobA);
386
+ const r = await importFaceIdentical.importManifest(sessionId, snap.key, new Map(snap.manifest), getBlobA);
387
387
  if (!r.ok) {
388
388
  sendError(res, 422, r.error.code === "restore_failed" ? "restore_failed" : "missing_blob", `snapshot import failed (${r.error.code})`);
389
389
  return;
@@ -451,7 +451,7 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
451
451
  // a full getBlob GET-per-hash (under MinIO that would download + re-hash every ≤64 MiB object just to test
452
452
  // presence). Feature-detect hasBlobs; if a store lacks it, fall back to getBlob (parity for older stores).
453
453
  const fss = deps.fileSnapshotStore;
454
- const canImportSnapshots = typeof fss?.importManifest === "function";
454
+ const canImportSnapshots = fileSnapshotImportFace(fss) !== undefined;
455
455
  if (canImportSnapshots) {
456
456
  const distinct = new Set();
457
457
  for (const snap of snapshots)
@@ -518,7 +518,7 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
518
518
  return;
519
519
  }
520
520
  const getBlob = (h) => deps.fileSnapshotStore.getBlob(h); // cloud's OWN store
521
- const canImportSnapshots = typeof deps.fileSnapshotStore?.importManifest === "function";
521
+ const importFaceB = fileSnapshotImportFace(deps.fileSnapshotStore);
522
522
  // ── Stream the NDJSON request body → core's StreamingImportValidator per line → batched appendBatch (bounded
523
523
  // memory: line buffer + batch). The streaming validator IS the single invariant gate (unique ids /
524
524
  // parent-before-child / structural refs / one-root / leaf-resolvable) — core 1.143.0 — so the service
@@ -650,8 +650,8 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
650
650
  // fail-closed 422 (a blob the Phase-A presence check couldn't catch — corrupted in transit). Note: the
651
651
  // ENTRIES are already committed (the swap), so a snapshot failure here means the conversation synced but a
652
652
  // file tree did not — we surface 422 so the client re-PUTs the blob and re-POSTs (idempotent).
653
- if (canImportSnapshots) {
654
- const importManifest = deps.fileSnapshotStore.importManifest.bind(deps.fileSnapshotStore);
653
+ if (importFaceB) {
654
+ const importManifest = importFaceB.importManifest;
655
655
  for (const snap of pending.snapshots) {
656
656
  const r = await importManifest(sessionId, snap.key, new Map(snap.manifest), getBlob);
657
657
  if (!r.ok) {
@@ -25,6 +25,8 @@ import type { QuotaTracker } from "../observability/cost-quota.js";
25
25
  import type { ModelUsageTracker, PromptManifestTracker } from "../budget.js";
26
26
  import { scopedIdempotencyKey } from "./idempotency.js";
27
27
  export { scopedIdempotencyKey };
28
+ import { clampVerifyRounds } from "./verify-rounds.js";
29
+ export { clampVerifyRounds };
28
30
  import { streamApprovals, isQuestionAnswer } from "./routes/approvals-assistant.js";
29
31
  export { streamApprovals, isQuestionAnswer };
30
32
  import { cascadeConfig } from "./run-meta.js";
@@ -451,8 +453,6 @@ export declare const MAX_SYSTEM_PROMPT_CHARS = 16384;
451
453
  export declare const MAX_OUTPUT_SCHEMA_CHARS = 32768;
452
454
  /** Validate `body.skills` (caps + shape). Returns an error string (→ 400) or null when acceptable. */
453
455
  export declare function validateUserSkills(skills: unknown): string | null;
454
- /** Verification rounds requested by a caller, clamped to a safe [1,5] (default 2). */
455
- export declare function clampVerifyRounds(v: unknown): number;
456
456
  /**
457
457
  * Managed-Agents-style HTTP/SSE service surface.
458
458
  * GET /health
@@ -5,7 +5,7 @@ import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, Checkpoin
5
5
  import { decideParkedAgent, findParkedAgentForCheckpoint } from "../parked-decide.js";
6
6
  import { matchCatalogModel } from "../model-select.js";
7
7
  import {} from "../config-center/facade.js";
8
- import { HttpError, principalFrom, verifiedPrincipal, setSsoPrincipal, ssoVerifiedPrincipal, setSsoScope, isUuidV7, verifyDirectDoorProof, isDestructiveSessionWrite } from "../security.js";
8
+ import { HttpError, principalFrom, verifiedPrincipal, setSsoPrincipal, ssoVerifiedPrincipal, setSsoScope, isUuidV7, verifyDirectDoorProof, isDestructiveSessionWrite, decodeCheckpointScope, CHECKPOINT_PUBLIC_SCOPE } from "../security.js";
9
9
  import {} from "../memory-sync.js";
10
10
  import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS } from "../task-settings.js";
11
11
  import { parseHooksConfig } from "../hooks/hook-runner.js";
@@ -24,6 +24,8 @@ import { redactSecrets } from "../trace/redact.js";
24
24
  import { IdempotencyCache, scopedIdempotencyKey } from "./idempotency.js";
25
25
  export { scopedIdempotencyKey };
26
26
  import { streamSseLog } from "./sse-log.js";
27
+ import { clampVerifyRounds, verifyRoundsFromBody } from "./verify-rounds.js"; // design/97 R9: the shape's neutral leaf (route-ctx.ts type-imports VerifyRoundsSpec from here, not from this file)
28
+ export { clampVerifyRounds }; // deep-path test imports of http/server.js's clampVerifyRounds keep working unchanged
27
29
  import { handleCapabilities } from "./routes/capabilities.js";
28
30
  import { handleObservability } from "./routes/observability.js";
29
31
  import { handleMemoryPolicy } from "./routes/memory-policy.js";
@@ -88,11 +90,6 @@ export function validateUserSkills(skills) {
88
90
  }
89
91
  return null;
90
92
  }
91
- /** Verification rounds requested by a caller, clamped to a safe [1,5] (default 2). */
92
- export function clampVerifyRounds(v) {
93
- const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : 2;
94
- return Math.min(5, Math.max(1, n));
95
- }
96
93
  const MAX_BODY = 8 * 1024 * 1024;
97
94
  /** Caps on `body.images`: inline base64 `data` is otherwise passed RAW to the
98
95
  * spec (URL images are SSRF+size-gated by core, inline aren't). The whole JSON body is already ≤ MAX_BODY, but with NO
@@ -1067,12 +1064,7 @@ export function createHttpServer(rawDeps) {
1067
1064
  }
1068
1065
  // 历史复审轴A #3(1.254):verify 环补 cost 顶——cascade 腿一直映 costCeilingMicroUsd,verify 腿漏了
1069
1066
  // (每轮独立 runTask,总花费此前仅受 maxRounds 约束);同源 body.maxCostUsd,同换算。
1070
- const verify = body.verify === true
1071
- ? {
1072
- maxRounds: clampVerifyRounds(body.verifyRounds),
1073
- ...(typeof body.maxCostUsd === "number" && body.maxCostUsd > 0 ? { costCeilingMicroUsd: Math.round(body.maxCostUsd * 1e6) } : {}),
1074
- }
1075
- : undefined;
1067
+ const verify = verifyRoundsFromBody(body);
1076
1068
  const cascade = body.cascade === true;
1077
1069
  if (cascade && verify) {
1078
1070
  sendError(res, 400, "request.field_conflict", "verify and cascade are mutually exclusive (verify = adversarial gate; cascade = cheap→strong ladder)");
@@ -1251,7 +1243,7 @@ export function createHttpServer(rawDeps) {
1251
1243
  if (!ctx)
1252
1244
  return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
1253
1245
  // Rebuild taskConfig via resolveSpec with auth reconstructed from the checkpoint (sessionId + scope).
1254
- const auth = { sessionId: cp.sessionId, principal: cp.scope === "_" ? undefined : cp.scope, memoryScope: ctx.memoryScope };
1246
+ const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
1255
1247
  // E18: STRIP resumeAt before re-resolving on a durable resume — the leaf is already set by the checkpoint, and
1256
1248
  // resumeAt + a durable resume is rejected by core (resume_at.conflicts_resume); also avoids re-resolving a stale
1257
1249
  // anchor (which could 404 if the session was purged). A resume-at run that suspended stored its body with resumeAt.
@@ -1354,13 +1346,7 @@ export function createHttpServer(rawDeps) {
1354
1346
  // completion — a plain resumeStream would silently drop the gate. The flag rides in the persisted ctx.body
1355
1347
  // (same source the spec was rebuilt from). Like first-run verify it's multi-attempt (not a live stream), so
1356
1348
  // we await the result + write a single terminal event rather than streaming post-resume deltas.
1357
- const verifyRounds = ctx.body.verify === true
1358
- ? {
1359
- maxRounds: clampVerifyRounds(ctx.body.verifyRounds),
1360
- // 快审 F1:resume 腿同样带 cost 顶(persisted body 的 maxCostUsd 同源;cascade 腿同换算)
1361
- ...(typeof ctx.body.maxCostUsd === "number" && ctx.body.maxCostUsd > 0 ? { costCeilingMicroUsd: Math.round(ctx.body.maxCostUsd * 1e6) } : {}),
1362
- }
1363
- : undefined;
1349
+ const verifyRounds = verifyRoundsFromBody(ctx.body);
1364
1350
  // taskId up-front: the resumed leg streams its post-resume events into the SAME durable run log, CONTINUING
1365
1351
  // the seq past the suspend (core 1.70 `resumeStream`) — so GET /v1/runs/:id/events covers post-resume too,
1366
1352
  // observability parity with runInBackground (closed by core 1.70).
@@ -2029,7 +2015,7 @@ export function createHttpServer(rawDeps) {
2029
2015
  if (!ctx)
2030
2016
  return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
2031
2017
  // Rebuild taskConfig via resolveSpec with auth reconstructed from the checkpoint scope (mirrors resumeCheckpoint).
2032
- const auth = { sessionId: cp.sessionId, principal: cp.scope === "_" ? undefined : cp.scope, memoryScope: ctx.memoryScope };
2018
+ const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
2033
2019
  // E18: STRIP resumeAt before re-resolving on a durable resume — the leaf is already set by the checkpoint, and
2034
2020
  // resumeAt + a durable resume is rejected by core (resume_at.conflicts_resume); also avoids re-resolving a stale
2035
2021
  // anchor (which could 404 if the session was purged). A resume-at run that suspended stored its body with resumeAt.
@@ -2037,13 +2023,7 @@ export function createHttpServer(rawDeps) {
2037
2023
  const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
2038
2024
  // A resource-suspended task that was submitted with verify:true is re-gated on completion (parity with the
2039
2025
  // HITL path — the flag rides the persisted ctx.body).
2040
- const verifyRounds = ctx.body.verify === true
2041
- ? {
2042
- maxRounds: clampVerifyRounds(ctx.body.verifyRounds),
2043
- // 快审 F1:resume 腿同样带 cost 顶(persisted body 的 maxCostUsd 同源;cascade 腿同换算)
2044
- ...(typeof ctx.body.maxCostUsd === "number" && ctx.body.maxCostUsd > 0 ? { costCeilingMicroUsd: Math.round(ctx.body.maxCostUsd * 1e6) } : {}),
2045
- }
2046
- : undefined;
2026
+ const verifyRounds = verifyRoundsFromBody(ctx.body);
2047
2027
  return driveResumeIntoRunLog({ token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome: { gate: "resource_limit", decision: "continue" }, verifyRounds });
2048
2028
  }
2049
2029
  /** design/144 §3:wake 一个 task_done 纯 park——非门决策,解除 park 消费消息续跑。owner 门:非 operator
@@ -2058,7 +2038,8 @@ export function createHttpServer(rawDeps) {
2058
2038
  const operator = explicitOperatorOk(caller, deps.config.operatorPrincipals);
2059
2039
  if (!operator) {
2060
2040
  const scope = await cs.peekPendingScope(sessionId).catch(() => undefined);
2061
- if (scope !== null && scope !== undefined && scope !== "_" && scope !== caller) {
2041
+ const decodedScope = scope != null ? decodeCheckpointScope(scope) : undefined;
2042
+ if (decodedScope !== undefined && decodedScope !== caller) {
2062
2043
  return { status: 404, body: { error: "no parked checkpoint for this session (nothing to wake)", errorCode: "not_found.parked_checkpoint" } }; // 无 oracle
2063
2044
  }
2064
2045
  }
@@ -2071,8 +2052,11 @@ export function createHttpServer(rawDeps) {
2071
2052
  }
2072
2053
  if (!cp)
2073
2054
  return { status: 404, body: { error: "checkpoint not found", errorCode: "not_found.checkpoint" } };
2074
- if (!operator && cp.scope !== "_" && cp.scope !== caller) {
2075
- return { status: 404, body: { error: "no parked checkpoint for this session (nothing to wake)", errorCode: "not_found.parked_checkpoint" } }; // 纵深(peek 缺席面兜底)
2055
+ // 纵深(peek 缺席面兜底)。比较留在 scope 空间(常量比较,不 decode):行体可能来自不做形状校验的
2056
+ // JSON 读取,scope 越契约为 nullish decode 形会把「畸形行」折成「公开」= 放行;旧码对该支是拒绝
2057
+ // 方向,常量形逐字节保持它(codex 码审 finding,#97 α 车)。
2058
+ if (!operator && cp.scope !== CHECKPOINT_PUBLIC_SCOPE && cp.scope !== caller) {
2059
+ return { status: 404, body: { error: "no parked checkpoint for this session (nothing to wake)", errorCode: "not_found.parked_checkpoint" } };
2076
2060
  }
2077
2061
  // 预检(core 兜底同码):非纯 park = 有未决门,wake 永不绕审批。
2078
2062
  const gateKind = cp.gate?.kind;
@@ -2094,7 +2078,7 @@ export function createHttpServer(rawDeps) {
2094
2078
  const ctx = await cs.getCtx(sessionId).catch(() => null);
2095
2079
  if (!ctx)
2096
2080
  return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
2097
- const auth = { sessionId: cp.sessionId, principal: cp.scope === "_" ? undefined : cp.scope, memoryScope: ctx.memoryScope };
2081
+ const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
2098
2082
  const spec = await deps.resolveSpec({ ...ctx.body, resumeAt: undefined }, req, auth);
2099
2083
  const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
2100
2084
  return driveResumeIntoRunLog({
@@ -2148,7 +2132,7 @@ export function createHttpServer(rawDeps) {
2148
2132
  if (!ctx)
2149
2133
  return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
2150
2134
  // Rebuild taskConfig via resolveSpec with auth reconstructed from the checkpoint scope (mirrors resumeCheckpoint).
2151
- const auth = { sessionId: cp.sessionId, principal: cp.scope === "_" ? undefined : cp.scope, memoryScope: ctx.memoryScope };
2135
+ const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
2152
2136
  // E18: STRIP resumeAt before re-resolving on a durable resume — the leaf is already set by the checkpoint, and
2153
2137
  // resumeAt + a durable resume is rejected by core (resume_at.conflicts_resume); also avoids re-resolving a stale
2154
2138
  // anchor (which could 404 if the session was purged). A resume-at run that suspended stored its body with resumeAt.
@@ -2156,13 +2140,7 @@ export function createHttpServer(rawDeps) {
2156
2140
  const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
2157
2141
  // A plan_review-suspended task submitted with verify:true is re-gated on completion (parity with the other
2158
2142
  // resume paths — the flag rides the persisted ctx.body).
2159
- const verifyRounds = ctx.body.verify === true
2160
- ? {
2161
- maxRounds: clampVerifyRounds(ctx.body.verifyRounds),
2162
- // 快审 F1:resume 腿同样带 cost 顶(persisted body 的 maxCostUsd 同源;cascade 腿同换算)
2163
- ...(typeof ctx.body.maxCostUsd === "number" && ctx.body.maxCostUsd > 0 ? { costCeilingMicroUsd: Math.round(ctx.body.maxCostUsd * 1e6) } : {}),
2164
- }
2165
- : undefined;
2143
+ const verifyRounds = verifyRoundsFromBody(ctx.body);
2166
2144
  const outcome = {
2167
2145
  gate: "plan_review",
2168
2146
  decision,
@@ -0,0 +1,24 @@
1
+ /**
2
+ * design/97 R9 — the verify-rounds shape (used by the developer-mode adversarial-verification gate, 1.44) pulled
3
+ * out to a NEUTRAL LEAF: it was three copies of the same anonymous `{ maxRounds: number; costCeilingMicroUsd?:
4
+ * number }` object type (route-ctx.ts ×2, runs.ts `runInBackground`'s public signature) plus four call sites in
5
+ * http/server.ts that each re-derived the same "verify:true → clamp rounds + fold cost ceiling" expression from a
6
+ * request body. A leaf (not server.ts itself) so route-ctx.ts — which must never value-import server.ts (that
7
+ * closes the runtime module-load cycle test/module-cycle-gate.test.ts guards) — can `import type` the shape from
8
+ * one place. server.ts imports {@link clampVerifyRounds} from here and RE-EXPORTS it (deep-path test imports of
9
+ * `http/server.js`'s `clampVerifyRounds` keep working unchanged).
10
+ */
11
+ import type { TaskRequestBody } from "./wire-types.js";
12
+ /** Verification-rounds config threaded onto a task's `verify`/resume paths (see {@link verifyRoundsFromBody}). */
13
+ export interface VerifyRoundsSpec {
14
+ maxRounds: number;
15
+ costCeilingMicroUsd?: number;
16
+ }
17
+ /** Verification rounds requested by a caller, clamped to a safe [1,5] (default 2). */
18
+ export declare function clampVerifyRounds(v: unknown): number;
19
+ /** `undefined` when `body.verify` is not `true` (the caller didn't opt into the gate); otherwise the clamped
20
+ * rounds + (if `maxCostUsd` is a positive number) the same USD→micro-USD cost ceiling the cascade lane uses.
21
+ * Same expression that lived inline at all four verify/resume call sites in http/server.ts (fresh-submit,
22
+ * resumeCheckpoint, resumePreempted, resumePlanReview) — collected here VERBATIM (design/97 R9). */
23
+ export declare function verifyRoundsFromBody(body: Pick<TaskRequestBody, "verify" | "verifyRounds" | "maxCostUsd">): VerifyRoundsSpec | undefined;
24
+ //# sourceMappingURL=verify-rounds.d.ts.map
@@ -0,0 +1,18 @@
1
+ /** Verification rounds requested by a caller, clamped to a safe [1,5] (default 2). */
2
+ export function clampVerifyRounds(v) {
3
+ const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : 2;
4
+ return Math.min(5, Math.max(1, n));
5
+ }
6
+ /** `undefined` when `body.verify` is not `true` (the caller didn't opt into the gate); otherwise the clamped
7
+ * rounds + (if `maxCostUsd` is a positive number) the same USD→micro-USD cost ceiling the cascade lane uses.
8
+ * Same expression that lived inline at all four verify/resume call sites in http/server.ts (fresh-submit,
9
+ * resumeCheckpoint, resumePreempted, resumePlanReview) — collected here VERBATIM (design/97 R9). */
10
+ export function verifyRoundsFromBody(body) {
11
+ return body.verify === true
12
+ ? {
13
+ maxRounds: clampVerifyRounds(body.verifyRounds),
14
+ ...(typeof body.maxCostUsd === "number" && body.maxCostUsd > 0 ? { costCeilingMicroUsd: Math.round(body.maxCostUsd * 1e6) } : {}),
15
+ }
16
+ : undefined;
17
+ }
18
+ //# sourceMappingURL=verify-rounds.js.map
@@ -15,6 +15,7 @@
15
15
  * `bands/` dir). A DRIFT test (test/image-bake-validate.test.ts) asserts they still match — a silently-drifted
16
16
  * list would either reject a valid profile or admit a removed band. Keep them in lockstep with the recipes.
17
17
  */
18
+ import { SHA256_DIGEST_RE as DIGEST_RE } from "../digest-form.js";
18
19
  /**
19
20
  * FROZEN profile catalog — mirrored from `e2b-template/dev-sandbox/gen-dockerfile.sh` `profile_bands()`.
20
21
  * (db / browser-nodriver are opt-in overlays, NOT in `code-full` — `code-full-db` adds db; per design §4.)
@@ -32,8 +33,10 @@ export declare const FROZEN_BUMP_TOOLS: readonly string[];
32
33
  export declare const VER_RE: RegExp;
33
34
  /** Belt-only band shape (set-membership is the real gate); a defense-in-depth reject of structurally weird ids. */
34
35
  export declare const BAND_SHAPE_RE: RegExp;
35
- /** A digest must be an immutable sha256 ref (the allow-list check against the index is the real gate, P2.4c). */
36
- export declare const DIGEST_RE: RegExp;
36
+ /** A digest must be an immutable sha256 ref (the allow-list check against the index is the real gate, P2.4c).
37
+ * design/158 R12: the shape itself is collected in `digest-form.ts`'s `SHA256_DIGEST_RE`; `DIGEST_RE` stays a
38
+ * re-exported alias (compat — export surface unchanged). */
39
+ export { DIGEST_RE };
37
40
  /** The raw, untrusted submit body fields this module validates (operator free-text). */
38
41
  export interface BakeSubmit {
39
42
  profile?: unknown;
@@ -15,6 +15,7 @@
15
15
  * `bands/` dir). A DRIFT test (test/image-bake-validate.test.ts) asserts they still match — a silently-drifted
16
16
  * list would either reject a valid profile or admit a removed band. Keep them in lockstep with the recipes.
17
17
  */
18
+ import { SHA256_DIGEST_RE as DIGEST_RE } from "../digest-form.js";
18
19
  /**
19
20
  * FROZEN profile catalog — mirrored from `e2b-template/dev-sandbox/gen-dockerfile.sh` `profile_bands()`.
20
21
  * (db / browser-nodriver are opt-in overlays, NOT in `code-full` — `code-full-db` adds db; per design §4.)
@@ -89,8 +90,10 @@ const BUMP_TOOL_SET = new Set(FROZEN_BUMP_TOOLS);
89
90
  export const VER_RE = /^[A-Za-z0-9._-]{1,40}$/;
90
91
  /** Belt-only band shape (set-membership is the real gate); a defense-in-depth reject of structurally weird ids. */
91
92
  export const BAND_SHAPE_RE = /^[a-z0-9-]+$/;
92
- /** A digest must be an immutable sha256 ref (the allow-list check against the index is the real gate, P2.4c). */
93
- export const DIGEST_RE = /^sha256:[a-f0-9]{64}$/;
93
+ /** A digest must be an immutable sha256 ref (the allow-list check against the index is the real gate, P2.4c).
94
+ * design/158 R12: the shape itself is collected in `digest-form.ts`'s `SHA256_DIGEST_RE`; `DIGEST_RE` stays a
95
+ * re-exported alias (compat — export surface unchanged). */
96
+ export { DIGEST_RE };
94
97
  function reject(status, error, conflictBands) {
95
98
  return conflictBands ? { ok: false, status, error, conflictBands } : { ok: false, status, error };
96
99
  }
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export { createStoreBackend, type StoreBackend } from "./plugins/store-backend.j
5
5
  export { CachingSessionStore, type CachingOptions } from "./plugins/caching-session-store.js";
6
6
  export type { RunRecord, RunEvent } from "./plugins/store-contracts.js";
7
7
  export { runInBackground } from "./runs.js";
8
+ export type { VerifyRoundsSpec } from "./http/verify-rounds.js";
8
9
  export { type ApprovalRow, type ApprovalStatus } from "./plugins/approval-store-sql.js";
9
10
  export { createOaApprovalPolicy, type OaApprovalOptions } from "./approval.js";
10
11
  export { createHardenedVmRunner, runInHardenedVm, type HardenedVmLimits, type HardenedMembrane } from "./orchestration/hardened-vm-runner.js";
@@ -24,8 +24,7 @@
24
24
  * `LEADER_REPAIR_LOOP` / `LEADER_MEASURE_GATES` / `LEADER_GRADER_FACTORY`.
25
25
  */
26
26
  import { runExecGate } from "@sema-agent/core";
27
- /** Single-quote a string for safe interpolation into a shell command (mirrors `repair-oracle.ts` / `merge.ts`). */
28
- const sq = (s) => `'${s.replace(/'/g, "'\\''")}'`;
27
+ import { shellQuote as sq } from "../plugins/remote-shell.js";
29
28
  /**
30
29
  * §10.1/§10.4 — HERMETIC apply-and-grade. Reset the grader to its clean `integBase` (`git reset --hard` +
31
30
  * `git clean -fdx`), apply the FULL `base→latest` `patch` (never cumulative onto an already-applied tree), then
@@ -1,6 +1,6 @@
1
1
  import { delimitUntrusted } from "@sema-agent/core";
2
2
  import { applyAndGrade, changedFilesOutOfAllowlist } from "./grader-env-factory.js";
3
- const Q = (s) => `'${s.replace(/'/g, "'\\''")}'`; // single-quote for shell safety
3
+ import { shellQuote as Q } from "../plugins/remote-shell.js";
4
4
  /** Integrate completed workers' branches. Never throws — failures are typed MergeResult. */
5
5
  export async function mergeBranches(reports, baseSha, deps) {
6
6
  const repo = deps.repoDir ?? "/repo";
@@ -28,8 +28,7 @@
28
28
  */
29
29
  import { runExecGate, } from "@sema-agent/core";
30
30
  import { createHash } from "node:crypto";
31
- /** Single-quote a string for safe interpolation into a shell command. */
32
- const sq = (s) => `'${s.replace(/'/g, "'\\''")}'`;
31
+ import { shellQuote as sq } from "../plugins/remote-shell.js";
33
32
  /** A short, stable content hash for the stale-diff guard (fork2.1) + the graded-bytes binding (fork2.2). */
34
33
  const sha = (s) => createHash("sha256").update(s).digest("hex").slice(0, 16);
35
34
  /** Parse the changed file paths from a unified diff — the `diff --git a/X b/X` headers (both old + new path). */
@@ -1,17 +1,3 @@
1
- /**
2
- * §7 P0.5 — per-task sandbox image binding (門B "用户按需选像"). IMAGE-API-DESIGN §7.
3
- *
4
- * The caller sends a PROFILE (intent) on the run body — NEVER a digest. The trusted control plane (this service,
5
- * with the caller's principal) resolves the profile → a pullable `repo@digest`, FAIL-CLOSED, re-admitting by the
6
- * SAME per-principal visibility the `/v1/images/select` admission uses. Why profile-not-digest: a digest is a
7
- * stable, enumerable sha256 with no caller-binding — trusting a caller-supplied digest is fail-OPEN (a user could
8
- * pass another tenant's digest to pull an image they can't see). Resolving inside the trust boundary closes that.
9
- *
10
- * The resolved ref reaches the worker-global {@link import("@sema-agent/core").ExecutionEnvFactory} (which
11
- * only receives core's deliberately-minimal `{sessionId}` context) via {@link PerTaskImageRegistry} — core's own
12
- * doctrine: "richer routing is captured in the factory closure by the trusted control plane". This module is that
13
- * capture, kept narrow + unit-testable.
14
- */
15
1
  import type { ImageViewer, ImageIndexEntry } from "./plugins/store-contracts.js";
16
2
  /** Minimal slice of the image index the per-task resolver needs (a testable seam — the real `imageIndex`'s
17
3
  * `latestPublished` returns the full {@link ImageIndexEntry}, which is assignable to this Pick). */
@@ -67,8 +53,7 @@ export declare function resolveSandboxImageRef(opts: {
67
53
  * tracked as a follow-up. cascade/verify + a profile are rejected up front (they strip/replace the session).
68
54
  */
69
55
  export declare class PerTaskImageRegistry {
70
- private readonly maxEntries;
71
- private readonly map;
56
+ private readonly bounded;
72
57
  constructor(maxEntries?: number);
73
58
  /** Register the resolved `repo@digest` for a session. A re-set (resume / a later same-session task) overwrites;
74
59
  * on overflow the oldest insertion is evicted so an orphaned entry (a resolveSpec not followed by a factory
@@ -1,3 +1,18 @@
1
+ /**
2
+ * §7 P0.5 — per-task sandbox image binding (門B "用户按需选像"). IMAGE-API-DESIGN §7.
3
+ *
4
+ * The caller sends a PROFILE (intent) on the run body — NEVER a digest. The trusted control plane (this service,
5
+ * with the caller's principal) resolves the profile → a pullable `repo@digest`, FAIL-CLOSED, re-admitting by the
6
+ * SAME per-principal visibility the `/v1/images/select` admission uses. Why profile-not-digest: a digest is a
7
+ * stable, enumerable sha256 with no caller-binding — trusting a caller-supplied digest is fail-OPEN (a user could
8
+ * pass another tenant's digest to pull an image they can't see). Resolving inside the trust boundary closes that.
9
+ *
10
+ * The resolved ref reaches the worker-global {@link import("@sema-agent/core").ExecutionEnvFactory} (which
11
+ * only receives core's deliberately-minimal `{sessionId}` context) via {@link PerTaskImageRegistry} — core's own
12
+ * doctrine: "richer routing is captured in the factory closure by the trusted control plane". This module is that
13
+ * capture, kept narrow + unit-testable.
14
+ */
15
+ import { BoundedSessionMap } from "./bounded-session-map.js";
1
16
  /** Boolean capability keys a caller may require. `nestedBuildMode` is a posture string, not a boolean gate, so it
2
17
  * is intentionally NOT requestable here (capability admission is allow-on-true only). */
3
18
  const REQUESTABLE_CAPS = new Set(["browser", "db", "nestedBuild"]);
@@ -52,27 +67,22 @@ export async function resolveSandboxImageRef(opts) {
52
67
  * tracked as a follow-up. cascade/verify + a profile are rejected up front (they strip/replace the session).
53
68
  */
54
69
  export class PerTaskImageRegistry {
55
- maxEntries;
56
- map = new Map();
70
+ // #97 R11: the sessionId-keyed bounded-eviction map is the shared BoundedSessionMap basis (this class was
71
+ // its original verbatim form); the composition below keeps THIS class's own ctor/method surface unchanged.
72
+ bounded;
57
73
  constructor(maxEntries = 4096) {
58
- this.maxEntries = maxEntries;
74
+ this.bounded = new BoundedSessionMap(maxEntries);
59
75
  }
60
76
  /** Register the resolved `repo@digest` for a session. A re-set (resume / a later same-session task) overwrites;
61
77
  * on overflow the oldest insertion is evicted so an orphaned entry (a resolveSpec not followed by a factory
62
78
  * call, e.g. a rejected/pre-exec-failed request) stays bounded. */
63
79
  set(sessionId, ref) {
64
- this.map.delete(sessionId);
65
- this.map.set(sessionId, ref);
66
- if (this.map.size > this.maxEntries) {
67
- const oldest = this.map.keys().next().value;
68
- if (oldest !== undefined)
69
- this.map.delete(oldest);
70
- }
80
+ this.bounded.set(sessionId, ref);
71
81
  }
72
82
  /** Read the ref for a session (NON-removing — the factory may be invoked more than once per logical task).
73
83
  * `undefined` ⇒ no per-task image for this session ⇒ the factory uses its worker-global default image. */
74
84
  get(sessionId) {
75
- return sessionId === undefined ? undefined : this.map.get(sessionId);
85
+ return this.bounded.get(sessionId);
76
86
  }
77
87
  }
78
88
  //# sourceMappingURL=per-task-image.js.map
@@ -1,6 +1,5 @@
1
1
  import { parseJsonStrict as parseJson, toIsoOrNull as iso } from "./sql-row-helpers.js";
2
- import { pgProtocolJsonStringify } from "./pg-safe-json.js";
3
- import { mysqlDriver, pgDriver } from "./sql-driver.js";
2
+ import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
4
3
  /** PG translation of the `approval` DDL in tidb-pool.ts SCHEMA_STATEMENTS (JSON→JSONB, DATETIME(3)→TIMESTAMPTZ(3),
5
4
  * inline KEY→separate CREATE INDEX).
6
5
  *
@@ -48,7 +47,7 @@ export class SqlApprovalStore {
48
47
  * (codex R4-H2 — the approval `args` is the operator's review surface and must agree with the
49
48
  * policy-executed args at the NUL position). */
50
49
  json(value, label) {
51
- return this.db.dialect === "tidb" ? JSON.stringify(value) : pgProtocolJsonStringify(value, label);
50
+ return dialectProtocolJsonEncoder(this.db.dialect)(value, label);
52
51
  }
53
52
  async createPending(id, scope, ctx) {
54
53
  await this.db.query(this.q("INSERT INTO approval (id, task_id, session_id, owner, scope, tool_name, args, status, created_at) " +
@@ -29,9 +29,8 @@
29
29
  import { createHash } from "node:crypto";
30
30
  import { CheckpointError, validatePendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, } from "@sema-agent/core";
31
31
  import { redactDeep } from "../trace/redact.js";
32
- import { pgProtocolJsonStringify } from "./pg-safe-json.js";
33
32
  import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
34
- import { mysqlDriver, pgDriver } from "./sql-driver.js";
33
+ import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
35
34
  /** Cap the persisted/served pending-approval args so a pathological tool payload can't bloat the checkpoint row
36
35
  * or the operator-queue response (the BFF renders this in an approval card; a few KB is plenty). */
37
36
  const MAX_TOOL_INPUT_CHARS = 8192;
@@ -94,7 +93,7 @@ export class SqlCheckpointStore {
94
93
  /** JSON column binding: TiDB stores the string verbatim; PG goes through the LOSSLESS protocol envelope
95
94
  * (R4-H1 — the operator's review surface must agree with the executed args at the NUL position). */
96
95
  json(value, label) {
97
- return this.db.dialect === "tidb" ? JSON.stringify(value) : pgProtocolJsonStringify(value, label);
96
+ return dialectProtocolJsonEncoder(this.db.dialect)(value, label);
98
97
  }
99
98
  isDupKey(e) {
100
99
  return this.db.dialect === "tidb"
@@ -37,9 +37,9 @@
37
37
  */
38
38
  import { uuidv7 } from "@sema-agent/core";
39
39
  import { randomBytes } from "node:crypto";
40
- import { pgSanitizeText, pgSafeJsonStringify, pgHasUnstorable } from "./pg-safe-json.js";
40
+ import { pgSanitizeText, pgHasUnstorable } from "./pg-safe-json.js";
41
41
  import { parseJsonOr as parseJson, toIso as iso } from "./sql-row-helpers.js";
42
- import { mysqlDriver, pgDriver } from "./sql-driver.js";
42
+ import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
43
43
  import { mapBakeRow as mapRow, BAKE_SELECT_COLS as SELECT_COLS, } from "./store-contracts.js";
44
44
  /** PG translation of the image_bake / image_bake_event / image_bake_lease DDL in tidb-pool.ts SCHEMA_STATEMENTS
45
45
  * (JSON→JSONB, TINYINT→SMALLINT, DATETIME(3)→TIMESTAMPTZ(3), inline UNIQUE/KEY→named CONSTRAINT + CREATE INDEX).
@@ -440,7 +440,7 @@ export class SqlImageBake {
440
440
  return null; // fast dedupe pre-check
441
441
  // PG-only: codex R14 — a `done` frame carrying a NUL in its error text would raise 22P05 and be LOST FOREVER
442
442
  // (the terminal flip already cleared the ingest secret, so it cannot be retried). TiDB stores JSON verbatim.
443
- const payload = data == null ? null : this.db.dialect === "tidb" ? JSON.stringify(data) : pgSafeJsonStringify(data);
443
+ const payload = data == null ? null : dialectJsonEncoder(this.db.dialect)(data);
444
444
  // The PK race is a thundering-herd: K concurrent writers all read the same maxSeq, ONE wins the (bake_id,seq)
445
445
  // INSERT, the rest re-read and retry — so the unluckiest writer can lose up to ~K races in a row. A small fixed
446
446
  // cap (6) under-serves a realistic ingest burst and would THROW a frame that is merely contended, not lost.
@@ -30,8 +30,7 @@
30
30
  * (test/pg-outcome-ledger-integration.test.ts, both engines run the SAME scenarios in parallel).
31
31
  */
32
32
  import { randomUUID, createHash } from "node:crypto";
33
- import { pgSafeJsonStringify } from "./pg-safe-json.js";
34
- import { mysqlDriver, pgDriver } from "./sql-driver.js";
33
+ import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
35
34
  /**
36
35
  * v1 task-signature (council Q11, recommended candidate (a)): a deterministic structural hash — zero
37
36
  * embedder, coarse on purpose (routing is a coarse decision). An explicit `tag` short-circuits to the
@@ -163,7 +162,7 @@ export class SqlOutcomeLedger {
163
162
  * store's blobs are observability/dashboard data, so the lossy (U+FFFD) sanitize is the right tier
164
163
  * (not the lossless envelope checkpoint-store-sql.ts uses for the approval trust boundary). */
165
164
  json(value) {
166
- return this.db.dialect === "tidb" ? JSON.stringify(value) : pgSafeJsonStringify(value);
165
+ return dialectJsonEncoder(this.db.dialect)(value);
167
166
  }
168
167
  /** Record one graded task run. Idempotent only by row id; callers pass distinct runs. */
169
168
  async record(o) {
@@ -23,7 +23,7 @@ import path from "node:path";
23
23
  import { Sandbox, CommandExitError, FileType } from "e2b";
24
24
  import { FileError, ExecutionError, RemoteExecutionError, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
25
25
  import { fileErrorFromExec } from "./remote-env-file-error.js";
26
- import { numEnvOr } from "./remote-shell.js";
26
+ import { numEnvOr, shellQuote } from "./remote-shell.js";
27
27
  import { BackgroundShellManager } from "./background-shell-support.js";
28
28
  const PROVIDER = "e2b";
29
29
  /** E2B default sandbox user home — the workspace root unless overridden. */
@@ -1264,10 +1264,6 @@ function anySignal(signals) {
1264
1264
  }
1265
1265
  return { signal: ctrl.signal, dispose: () => cleanups.forEach((c) => c()) };
1266
1266
  }
1267
- /** POSIX single-quote a string for safe shell interpolation. */
1268
- function shellQuote(s) {
1269
- return `'${s.replace(/'/g, `'\\''`)}'`;
1270
- }
1271
1267
  /** Copy a Uint8Array's exact bytes into a standalone ArrayBuffer (E2B write accepts ArrayBuffer). */
1272
1268
  function toArrayBuffer(u) {
1273
1269
  return u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength);
@@ -22,7 +22,8 @@ export declare const EXEC_FORCE_SETTLE_GRACE_MS = 3000;
22
22
  * any promise (the buffered exec already settled synchronously on abort/timeout — that path is unchanged).
23
23
  */
24
24
  export declare function armPipeDestroyGrace(child: ChildProcess): ReturnType<typeof setTimeout>;
25
- /** POSIX single-quote a string for safe interpolation into a remote shell command. */
25
+ /** POSIX single-quote a string for safe interpolation into a remote shell command (the only metacharacter
26
+ * inside '' is ' itself, closed/escaped/reopened). */
26
27
  export declare function shellQuote(s: string): string;
27
28
  /** Derive FileInfo kind from POSIX st_mode bits (raw stat/readdir attrs carry no isDirectory() helper). */
28
29
  export declare function kindFromMode(mode?: number): FileInfo["kind"];
@@ -28,7 +28,8 @@ export function armPipeDestroyGrace(child) {
28
28
  t.unref?.();
29
29
  return t;
30
30
  }
31
- /** POSIX single-quote a string for safe interpolation into a remote shell command. */
31
+ /** POSIX single-quote a string for safe interpolation into a remote shell command (the only metacharacter
32
+ * inside '' is ' itself, closed/escaped/reopened). */
32
33
  export function shellQuote(s) {
33
34
  return `'${s.replace(/'/g, `'\\''`)}'`;
34
35
  }
@@ -1,8 +1,8 @@
1
1
  import { projectUsageStats, USAGE_SCAN_LIMIT } from "../usage-analytics.js";
2
2
  import { escapeLike } from "./sql-escape.js";
3
- import { pgSafeJsonStringify, pgSanitizeText } from "./pg-safe-json.js";
3
+ import { pgSanitizeText } from "./pg-safe-json.js";
4
4
  import { parseJsonLenient as parseJson, toIso as iso } from "./sql-row-helpers.js";
5
- import { mysqlDriver, pgDriver } from "./sql-driver.js";
5
+ import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
6
6
  /** Dual-dialect async run registry + event log. See the file header for the dialect-delta ledger. */
7
7
  export class SqlRunStore {
8
8
  db;
@@ -21,7 +21,7 @@ export class SqlRunStore {
21
21
  /** JSON column binding: TiDB stores the string verbatim; PG goes through the protocol-safe envelope
22
22
  * (a raw NUL byte in an event payload would otherwise throw 22P05 mid-write). */
23
23
  json(value) {
24
- return this.db.dialect === "tidb" ? JSON.stringify(value) : pgSafeJsonStringify(value);
24
+ return dialectJsonEncoder(this.db.dialect)(value);
25
25
  }
26
26
  /** Run statements atomically; rolls back on failure. */
27
27
  async tx(fn) {