@sema-agent/server 1.293.0 → 1.295.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.d.ts CHANGED
@@ -162,6 +162,8 @@ export interface ServiceConfig {
162
162
  keyPrefix?: string;
163
163
  presignTtlSec?: number;
164
164
  };
165
+ snapshotBlobSqlMaxBytes?: number;
166
+ snapshotBlobAllowSql: boolean;
165
167
  sendUserFile?: {
166
168
  endpoint: string;
167
169
  publicEndpoint?: string;
package/dist/config.js CHANGED
@@ -536,6 +536,8 @@ export function loadConfig() {
536
536
  ...((ttl) => (ttl !== undefined ? { presignTtlSec: ttl } : {}))(optFinitePositiveEnv("SESSION_SNAPSHOT_TTL_SEC")),
537
537
  }
538
538
  : undefined,
539
+ snapshotBlobSqlMaxBytes: optFinitePositiveEnv("SNAPSHOT_BLOB_SQL_MAX_BYTES"),
540
+ snapshotBlobAllowSql: process.env.SNAPSHOT_BLOB_ALLOW_SQL_BYTES === "true",
539
541
  sendUserFile: (process.env.MINIO_ENDPOINT || process.env.S3_ENDPOINT) && process.env.MINIO_ACCESS_KEY && process.env.MINIO_SECRET_KEY
540
542
  ? {
541
543
  endpoint: (process.env.MINIO_ENDPOINT || process.env.S3_ENDPOINT),
@@ -1,8 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { hostShell, resolveHostShell } from "../plugins/host-platform.js";
2
3
  import { HooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_EVENT_OWNER, } from "@sema-agent/registry-core/hooks";
3
4
  import { redactSecrets } from "../trace/redact.js";
4
5
  import { ccPromptSystemFor, wrapCondition, parseCcVerdict, CC_EVALUATOR_MAX_OUTPUT_TOKENS } from "./cc-stop-prompt.js";
5
6
  import { renderBranchTranscript } from "./branch-transcript.js";
7
+ void resolveHostShell().catch(() => undefined);
6
8
  export const MAX_HOOK_ENTRIES_PER_EVENT = 32;
7
9
  export const MAX_HOOK_COMMAND_CHARS = 8_192;
8
10
  export const MAX_HOOK_TIMEOUT_SECONDS = 600;
@@ -144,7 +146,8 @@ function runCommandHook(entry, payload, ctx) {
144
146
  const timeoutMs = Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) * 1000;
145
147
  let child;
146
148
  try {
147
- child = spawn("/bin/sh", ["-c", entry.command], {
149
+ const sh = hostShell();
150
+ child = spawn(sh.shell, [...sh.args, entry.command], {
148
151
  cwd: ctx.cwd,
149
152
  env: hookEnv(ctx),
150
153
  stdio: ["pipe", "pipe", "pipe"],
@@ -203,7 +206,8 @@ function fireAsyncCommandHook(entry, payload, ctx, event) {
203
206
  try {
204
207
  const rewake = entry.asyncRewake === true;
205
208
  const timeoutMs = Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) * 1000;
206
- const child = spawn("/bin/sh", ["-c", entry.command], {
209
+ const sh = hostShell();
210
+ const child = spawn(sh.shell, [...sh.args, entry.command], {
207
211
  cwd: ctx.cwd,
208
212
  env: hookEnv(ctx),
209
213
  stdio: ["pipe", "ignore", rewake ? "pipe" : "ignore"],
@@ -63,6 +63,7 @@ export interface ServiceDeps {
63
63
  approvalExemptionStore?: ApprovalExemptionStore;
64
64
  sessionPolicyStore?: ServiceSessionPolicyStore;
65
65
  fileSnapshotStore?: ServiceFileSnapshotStore;
66
+ snapshotBlobSqlCapBytes?: number;
66
67
  backend?: StoreBackend;
67
68
  sessionMirrorRuling?: (principal: string | undefined) => Promise<SessionMirrorRuling | undefined>;
68
69
  approvalStore?: ApprovalStore;
@@ -4062,6 +4062,13 @@ export function createHttpServer(deps) {
4062
4062
  return;
4063
4063
  }
4064
4064
  const body = await readRawBody(req, SYNC_BLOB_MAX_BYTES);
4065
+ if (deps.snapshotBlobSqlCapBytes !== undefined && body.byteLength > deps.snapshotBlobSqlCapBytes) {
4066
+ sendJson(res, 413, {
4067
+ code: "blob_too_large_for_sql",
4068
+ error: `blob of ${body.byteLength} bytes exceeds this deployment's SQL snapshot-blob cap (${deps.snapshotBlobSqlCapBytes} bytes; mysql-protocol packet limit) — configure MinIO object storage (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY) for large snapshot blobs`,
4069
+ });
4070
+ return;
4071
+ }
4065
4072
  if (createHash("sha256").update(body).digest("hex") !== hash) {
4066
4073
  sendJson(res, 400, { error: "blob hash mismatch" });
4067
4074
  return;
package/dist/main.js CHANGED
@@ -28,7 +28,7 @@ import { loadConfig, logConfigDiagnostics } from "./config.js";
28
28
  import { resourceSuspendOptIn } from "./resource-suspend.js";
29
29
  import { createSessionStore, ensureChildSessionDurableWithPromotion } from "./plugins/session-store.js";
30
30
  import { ForkRoutingSessionStore } from "./plugins/fork-routing-session-store.js";
31
- import { createStoreBackend } from "./plugins/store-backend.js";
31
+ import { createStoreBackend, assertCloudSnapshotBlobPosture } from "./plugins/store-backend.js";
32
32
  import { e2bExecutionEnvFactory } from "./plugins/remote-env-e2b.js";
33
33
  import { k8sExecutionEnvFactory } from "./plugins/remote-env-k8s.js";
34
34
  import { sshExecutionEnvFactory } from "./plugins/remote-env-ssh.js";
@@ -80,7 +80,7 @@ import { defaultLkgPath, defaultSkillCacheDir, saveLkg, loadLkg } from "./config
80
80
  import { TiDBRosterStore, PgRosterStore, ensureTiDBRosterSchema, ensurePgRosterSchema } from "./plugins/roster-store-sql.js";
81
81
  import { TiDBTaskAttachmentStore, PgTaskAttachmentStore, ensureTiDBTaskAttachmentSchema, ensurePgTaskAttachmentSchema, bindAttachmentsForTask, materializeAttachmentsInto } from "./plugins/task-attachment-store.js";
82
82
  import { LocalTaskAttachmentStore } from "./plugins/local-task-attachment-store.js";
83
- import { MinioBlobBackend } from "./plugins/blob-backend.js";
83
+ import { MinioBlobBackend, SQL_BLOB_DEFAULT_MAX_BYTES } from "./plugins/blob-backend.js";
84
84
  import { TiDBBackgroundAgentStore, PgBackgroundAgentStore, ensureTiDBBackgroundAgentSchema, ensurePgBackgroundAgentSchema } from "./plugins/background-agent-store-sql.js";
85
85
  import { TiDBMailboxStore, PgMailboxStore, ensureTiDBMailboxSchema, ensurePgMailboxSchema } from "./plugins/mailbox-store-sql.js";
86
86
  import { createPrincipalCapsClient, gateExecutionLane, scopedTokenNeedsWorker, applyObserverEnvOptIn } from "./runtime-caps-resolver.js";
@@ -634,10 +634,14 @@ async function main() {
634
634
  "the in-memory store cannot enforce session ownership.");
635
635
  }
636
636
  if (config.requirePrincipal && backend?.kind === "local") {
637
- throw new Error("REQUIRE_PRINCIPAL=true is not supported on the local in-memory backend (DB_BACKEND=local): its owner map is " +
638
- "process-local and lost on restart, so it cannot durably enforce multi-tenant session ownership. Use a DB " +
639
- "backend (DB_BACKEND=tidb|pg) for multi-tenant, or run local single-user with REQUIRE_PRINCIPAL=false.");
637
+ throw new Error("REQUIRE_PRINCIPAL=true is not supported on the local file backend (DB_BACKEND=local): session/run CONTENT is " +
638
+ "durable there, but OWNER attribution is process-local and lost on restart (store-backend.ts §0.5 durable " +
639
+ "owners = P1), so it cannot durably enforce multi-tenant session ownership. Use a SQL backend " +
640
+ "(DB_BACKEND=mysql|pg) for multi-tenant, or run local single-user with REQUIRE_PRINCIPAL=false (a BFF may " +
641
+ "still inject x-agent-principal per request for memory scoping and audit attribution).");
640
642
  }
643
+ if (backend)
644
+ assertCloudSnapshotBlobPosture(backend.kind, config);
641
645
  if (!config.requirePrincipal && sessionStore.ownerOf) {
642
646
  logger.warn("principal_optional", {
643
647
  note: "owner-aware session store with REQUIRE_PRINCIPAL=false — owned sessions are protected, but headerless callers can create/share anonymous sessions; set REQUIRE_PRINCIPAL=true for multi-tenant",
@@ -1046,6 +1050,9 @@ async function main() {
1046
1050
  ...(sessionPolicyStore ? { sessionPolicyStore } : {}),
1047
1051
  ...(runtimeCapsResolver ? { runtimeCapsResolver } : {}),
1048
1052
  ...(fileSnapshotStore ? { fileSnapshotStore } : {}),
1053
+ ...(backend && backend.kind !== "local" && !config.snapshotBlobStore
1054
+ ? ((cap) => (cap !== undefined ? { snapshotBlobSqlCapBytes: cap } : {}))(config.snapshotBlobSqlMaxBytes ?? (backend.kind === "mysql" ? SQL_BLOB_DEFAULT_MAX_BYTES : undefined))
1055
+ : {}),
1049
1056
  ...(executionEnvFactory ? { executionEnvFactory } : {}),
1050
1057
  ...(lspManager ? { lspManager } : {}),
1051
1058
  onBackgroundChildEvent: fleetBackgroundChildPublisher(fleetBus, (msg, fields) => logger.info(msg, fields)),
@@ -1,5 +1,10 @@
1
1
  import type { Pool as MysqlPool } from "mysql2/promise";
2
2
  import type { Pool as PgPool } from "pg";
3
+ export declare const SQL_BLOB_DEFAULT_MAX_BYTES = 6291456;
4
+ export declare class BlobTooLargeError extends Error {
5
+ readonly code: "blob_too_large_for_sql";
6
+ constructor(byteLen: number, capBytes: number);
7
+ }
3
8
  export interface BlobBackend {
4
9
  putBlob(hash: string, bytes: Uint8Array): Promise<void>;
5
10
  getBlob(hash: string): Promise<Uint8Array | undefined>;
@@ -9,7 +14,8 @@ export interface BlobBackend {
9
14
  export declare class SqlBlobBackend implements BlobBackend {
10
15
  private readonly dialect;
11
16
  private readonly pool;
12
- constructor(dialect: "tidb" | "pg", pool: MysqlPool | PgPool);
17
+ private readonly capBytes;
18
+ constructor(dialect: "tidb" | "pg", pool: MysqlPool | PgPool, maxBytes?: number);
13
19
  putBlob(hash: string, bytes: Uint8Array): Promise<void>;
14
20
  getBlob(hash: string): Promise<Uint8Array | undefined>;
15
21
  hasBlobs(hashes: string[]): Promise<Set<string>>;
@@ -1,6 +1,17 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { presignS3Url } from "./s3-presign.js";
3
3
  const BLOB_GC_GRACE_MS = 3_600_000;
4
+ export const SQL_BLOB_DEFAULT_MAX_BYTES = 6_291_456;
5
+ export class BlobTooLargeError extends Error {
6
+ code = "blob_too_large_for_sql";
7
+ constructor(byteLen, capBytes) {
8
+ super(`blob of ${byteLen} bytes exceeds the SQL snapshot-blob cap (${capBytes} bytes): a single-row INSERT this large ` +
9
+ `hits TiDB's txn-entry-size-limit (default 6MiB) or the mysql-protocol max_allowed_packet (~2x text-protocol ` +
10
+ `inflation). Configure object storage (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY) for large snapshot ` +
11
+ `blobs, or override the cap with SNAPSHOT_BLOB_SQL_MAX_BYTES if your deployment raised those limits.`);
12
+ this.name = "BlobTooLargeError";
13
+ }
14
+ }
4
15
  const MINIO_OP_CONCURRENCY = 16;
5
16
  async function mapBounded(items, limit, fn) {
6
17
  let i = 0;
@@ -15,11 +26,15 @@ async function mapBounded(items, limit, fn) {
15
26
  export class SqlBlobBackend {
16
27
  dialect;
17
28
  pool;
18
- constructor(dialect, pool) {
29
+ capBytes;
30
+ constructor(dialect, pool, maxBytes) {
19
31
  this.dialect = dialect;
20
32
  this.pool = pool;
33
+ this.capBytes = maxBytes ?? (dialect === "tidb" ? SQL_BLOB_DEFAULT_MAX_BYTES : Number.POSITIVE_INFINITY);
21
34
  }
22
35
  async putBlob(hash, bytes) {
36
+ if (bytes.byteLength > this.capBytes)
37
+ throw new BlobTooLargeError(bytes.byteLength, this.capBytes);
23
38
  if (this.dialect === "tidb") {
24
39
  await this.pool.query("INSERT INTO snapshot_blob (blob_hash, byte_len, bytes, created_at) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE created_at = VALUES(created_at)", [hash, bytes.byteLength, Buffer.from(bytes), new Date()]);
25
40
  }
@@ -1,10 +1,16 @@
1
1
  import { getShellConfig, killProcessTree, signalProcessTree } from "@sema-agent/core";
2
2
  export const IS_WIN32 = process.platform === "win32";
3
- const POSIX_SHELL = { shell: "/bin/sh", args: ["-c"] };
3
+ const POSIX_SH_FALLBACK = { shell: "/bin/sh", args: ["-c"] };
4
4
  let cachedWinShell;
5
+ let cachedPosixShell;
5
6
  export async function resolveHostShell() {
6
- if (!IS_WIN32)
7
- return POSIX_SHELL;
7
+ if (!IS_WIN32) {
8
+ if (cachedPosixShell)
9
+ return cachedPosixShell;
10
+ const posixCfg = await getShellConfig(process.env.HOST_SHELL_PATH || undefined);
11
+ cachedPosixShell = posixCfg.ok ? { shell: posixCfg.value.shell, args: posixCfg.value.args } : POSIX_SH_FALLBACK;
12
+ return cachedPosixShell;
13
+ }
8
14
  if (cachedWinShell)
9
15
  return cachedWinShell;
10
16
  const custom = process.env.HOST_SHELL_PATH;
@@ -18,14 +24,16 @@ export async function resolveHostShell() {
18
24
  return cachedWinShell;
19
25
  }
20
26
  export function hostShell() {
21
- if (!IS_WIN32)
22
- return POSIX_SHELL;
27
+ if (!IS_WIN32) {
28
+ return cachedPosixShell ?? POSIX_SH_FALLBACK;
29
+ }
23
30
  if (!cachedWinShell)
24
31
  throw new Error("host shell not resolved yet — resolveHostShell() must settle before any spawn (shellReady ordering bug)");
25
32
  return cachedWinShell;
26
33
  }
27
34
  export function resetHostShellCacheForTest() {
28
35
  cachedWinShell = undefined;
36
+ cachedPosixShell = undefined;
29
37
  }
30
38
  export function spawnGroupOptions() {
31
39
  return IS_WIN32 ? { detached: false, windowsHide: true } : { detached: true };
@@ -114,6 +114,9 @@ export declare class RemoteLocalDockerExecutionEnv implements RemoteExecutionEnv
114
114
  private execArgv;
115
115
  private execRaw;
116
116
  private docker;
117
+ private containerShell;
118
+ private containerShellProbed;
119
+ private probeContainerShell;
117
120
  private ensureConnectedExec;
118
121
  private fsReady;
119
122
  private tempResult;
@@ -480,7 +480,7 @@ export class RemoteLocalDockerExecutionEnv {
480
480
  const mergedEnv = { ...(this.cfg.env ?? {}), ...(env ?? {}) };
481
481
  for (const [k, v] of Object.entries(mergedEnv))
482
482
  args.push("-e", `${k}=${v}`);
483
- args.push(this.containerName, "/bin/sh", "-c", command);
483
+ args.push(this.containerName, this.containerShell, "-c", command);
484
484
  return args;
485
485
  }
486
486
  execRaw(command) {
@@ -547,14 +547,31 @@ export class RemoteLocalDockerExecutionEnv {
547
547
  });
548
548
  });
549
549
  }
550
+ containerShell = "/bin/sh";
551
+ containerShellProbed = false;
552
+ async probeContainerShell() {
553
+ if (this.containerShellProbed)
554
+ return;
555
+ this.containerShellProbed = true;
556
+ try {
557
+ const r = await this.docker(this.withHostFlag(["exec", this.containerName, "/bin/sh", "-c", "command -v bash"]), { timeoutMs: this.cfg.controlTimeoutMs });
558
+ if (r.ok && r.value.exitCode === 0 && r.value.stdout.trim().length > 0)
559
+ this.containerShell = "bash";
560
+ }
561
+ catch {
562
+ }
563
+ }
550
564
  async ensureConnectedExec() {
551
565
  if (this.destroyed)
552
566
  return { ok: false, error: new ExecutionError("shell_unavailable", "execution env already destroyed") };
553
- if (this.containerId)
567
+ if (this.containerId) {
568
+ await this.probeContainerShell();
554
569
  return ok(undefined);
570
+ }
555
571
  const c = await this.connect();
556
572
  if (!c.ok)
557
573
  return { ok: false, error: new ExecutionError("shell_unavailable", `local-docker connect failed: ${c.error.message}`, c.error) };
574
+ await this.probeContainerShell();
558
575
  return ok(undefined);
559
576
  }
560
577
  async fsReady(p) {
@@ -93,6 +93,7 @@ export interface StoreBackend {
93
93
  pgPool(): PgPool | undefined;
94
94
  dbNowMs?(): Promise<number>;
95
95
  }
96
+ export declare function assertCloudSnapshotBlobPosture(kind: "mysql" | "pg" | "local", config: Pick<ServiceConfig, "snapshotBlobStore" | "snapshotBlobAllowSql">): void;
96
97
  export declare function snapshotBoundsFromConfig(config: ServiceConfig): FileSnapshotBounds;
97
98
  export declare function createStoreBackend(config: ServiceConfig): StoreBackend | undefined;
98
99
  //# sourceMappingURL=store-backend.d.ts.map
@@ -21,7 +21,7 @@ import { TiDBWorkflowJournalStore } from "./tidb-workflow-journal-store.js";
21
21
  import { PgWorkflowJournalStore } from "./pg-workflow-journal-store.js";
22
22
  import { TiDBFileSnapshotStore } from "./tidb-file-snapshot-store.js";
23
23
  import { PgFileSnapshotStore } from "./pg-file-snapshot-store.js";
24
- import { MinioBlobBackend } from "./blob-backend.js";
24
+ import { MinioBlobBackend, SqlBlobBackend } from "./blob-backend.js";
25
25
  import { createTidbPool, ensureSchema as ensureTidbSchema } from "./tidb-pool.js";
26
26
  import { createPgPool, ensurePgSchema, pgPoolOptions } from "./pg-pool.js";
27
27
  import { TiDBRunStore } from "./tidb-run-store.js";
@@ -47,7 +47,7 @@ import { PgSessionStore } from "./pg-session-storage.js";
47
47
  function snapshotBlobBackend(config, dialect, pool) {
48
48
  const s = config.snapshotBlobStore;
49
49
  if (!s)
50
- return undefined;
50
+ return new SqlBlobBackend(dialect, pool, config.snapshotBlobSqlMaxBytes);
51
51
  return new MinioBlobBackend({
52
52
  endpoint: s.endpoint,
53
53
  bucket: s.bucket,
@@ -58,6 +58,15 @@ function snapshotBlobBackend(config, dialect, pool) {
58
58
  ...(s.presignTtlSec ? { presignTtlSec: s.presignTtlSec } : {}),
59
59
  }, { dialect, pool });
60
60
  }
61
+ export function assertCloudSnapshotBlobPosture(kind, config) {
62
+ if (kind === "local" || config.snapshotBlobStore || config.snapshotBlobAllowSql)
63
+ return;
64
+ throw new Error("cloud deployments (DB_BACKEND=mysql|pg) must configure object storage for snapshot blobs " +
65
+ "(MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY): single-row SQL blob writes hit TiDB's " +
66
+ "txn-entry-size-limit (default 6MiB) / the mysql-protocol packet limit (bytes belong in object storage — " +
67
+ "same ruling as the D-1 attachment store). Single-box/test rigs may explicitly opt out with " +
68
+ "SNAPSHOT_BLOB_ALLOW_SQL_BYTES=true (a per-blob cap then applies; oversized blobs are rejected 413).");
69
+ }
61
70
  export function snapshotBoundsFromConfig(config) {
62
71
  const mb = config.rewindSnapshotMaxMb;
63
72
  return mb !== undefined ? { ...DEFAULT_SNAPSHOT_BOUNDS, maxBytes: Math.round(mb * 1024 * 1024) } : DEFAULT_SNAPSHOT_BOUNDS;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "1.293.0",
3
+ "version": "1.295.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",