@sema-agent/server 1.298.0 → 1.299.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
@@ -164,6 +164,7 @@ export interface ServiceConfig {
164
164
  };
165
165
  snapshotBlobSqlMaxBytes?: number;
166
166
  snapshotBlobAllowSql: boolean;
167
+ workspaceFileMaxBytes: number;
167
168
  sendUserFile?: {
168
169
  endpoint: string;
169
170
  publicEndpoint?: string;
package/dist/config.js CHANGED
@@ -538,6 +538,7 @@ export function loadConfig() {
538
538
  : undefined,
539
539
  snapshotBlobSqlMaxBytes: optFinitePositiveEnv("SNAPSHOT_BLOB_SQL_MAX_BYTES"),
540
540
  snapshotBlobAllowSql: process.env.SNAPSHOT_BLOB_ALLOW_SQL_BYTES === "true",
541
+ workspaceFileMaxBytes: optFinitePositiveEnv("WORKSPACE_FILE_MAX_BYTES") ?? 8 * 1024 * 1024,
541
542
  sendUserFile: (process.env.MINIO_ENDPOINT || process.env.S3_ENDPOINT) && process.env.MINIO_ACCESS_KEY && process.env.MINIO_SECRET_KEY
542
543
  ? {
543
544
  endpoint: (process.env.MINIO_ENDPOINT || process.env.S3_ENDPOINT),
@@ -3,6 +3,7 @@ import { once } from "node:events";
3
3
  import { timingSafeEqual, createHash, randomBytes } from "node:crypto";
4
4
  import { sessionLogDigest, sessionLogDigestsComparable, uuidv7, sanitizePathComponent, callKeyOrdinal, isThinkingLevel, DEFAULT_EFFORT_LEVELS, expandTiers, materializeMcpTools, runWithVerification, resumeWithVerification, runCascade, CheckpointError, mintCheckpointToken, SessionPolicyError, SessionError, HAND_TOOL_EFFECTS, canonicalToolName, formatUserScope, defaultTaskRegistry, validatePendingSteer, StreamingImportValidator, getWorkflowRun, subscribeWorkflow, deriveAgentDisplayStatus } from "@sema-agent/core";
5
5
  import { decideParkedAgent, findParkedAgentForCheckpoint } from "../parked-decide.js";
6
+ import { tarHeader, tarPadding, splitTarPath, TAR_END } from "./tar.js";
6
7
  import { matchCatalogModel } from "../model-select.js";
7
8
  import { mcpForScenario } from "../sema-registry.js";
8
9
  import { HttpError, principalFrom, verifiedPrincipal, setSsoPrincipal, ssoVerifiedPrincipal, setSsoScope, isUuidV7, isUuidShape, verifyDirectDoorProof, MAX_APPROVAL_REASON_CHARS } from "../security.js";
@@ -671,6 +672,12 @@ export function createHttpServer(deps) {
671
672
  fleet: deps.fleetBus
672
673
  ? { stream: true, sessionScope: true, observe: true, resume: "snapshot", bgNotifyFailClosed: true, steerPriority: "advisory" }
673
674
  : false,
675
+ workspace: (() => {
676
+ const wfs = deps.fileSnapshotStore;
677
+ return wfs && typeof wfs.listKeys === "function" && typeof wfs.exportManifest === "function" && typeof wfs.getBlob === "function" && Boolean(deps.sessionStorage?.ownerOf)
678
+ ? { browse: true, archive: true, maxFileBytes: deps.config.workspaceFileMaxBytes ?? 8 * 1024 * 1024 }
679
+ : false;
680
+ })(),
674
681
  leader: Boolean(deps.leaderEndpoint),
675
682
  memory: false,
676
683
  memoryWrite: false,
@@ -3932,6 +3939,180 @@ export function createHttpServer(deps) {
3932
3939
  return;
3933
3940
  }
3934
3941
  }
3942
+ {
3943
+ const wm = req.method === "GET" ? /^\/v1\/sessions\/([^/]+)\/workspace(?:\/([^/]+)(?:\/(tree|file|archive))?)?$/.exec(url) : null;
3944
+ if (wm) {
3945
+ const fs = deps.fileSnapshotStore;
3946
+ const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
3947
+ if (!fs || typeof fs.listKeys !== "function" || typeof fs.exportManifest !== "function" || typeof fs.getBlob !== "function" || !ownerOf) {
3948
+ sendJson(res, 501, { error: "workspace browse requires a durable snapshot store (rewindFiles wiring) and a durable session store" });
3949
+ return;
3950
+ }
3951
+ const scope = sessionOwnerScope(req);
3952
+ if (!scope.fleetWide && scope.gateOwner === null) {
3953
+ sendJson(res, 401, { error: "unauthorized" });
3954
+ return;
3955
+ }
3956
+ const wsSession = safeDecode(wm[1]);
3957
+ if (wsSession === null || !isUuidShape(wsSession)) {
3958
+ sendJson(res, 400, { error: "invalid session id (must be a canonical uuid)" });
3959
+ return;
3960
+ }
3961
+ const wsOwner = await ownerOf(wsSession);
3962
+ if (wsOwner === undefined || (!scope.fleetWide && wsOwner !== scope.gateOwner)) {
3963
+ sendJson(res, 404, { error: "session not found" });
3964
+ return;
3965
+ }
3966
+ const q = new URL(req.url ?? "", "http://x").searchParams;
3967
+ const keys = (await fs.listKeys(wsSession)).sort().reverse();
3968
+ const sub = wm[3];
3969
+ if (wm[2] === undefined) {
3970
+ const limRaw = q.get("limit");
3971
+ if (limRaw !== null && !(/^[0-9]+$/.test(limRaw) && Number(limRaw) >= 1 && Number(limRaw) <= 100)) {
3972
+ sendJson(res, 400, { error: "limit must be a decimal integer in 1..100" });
3973
+ return;
3974
+ }
3975
+ const lim = limRaw === null ? 20 : Number(limRaw);
3976
+ const page = keys.slice(0, lim);
3977
+ const snapshots = [];
3978
+ for (const k of page) {
3979
+ const m = await fs.exportManifest(wsSession, k);
3980
+ if (m === null)
3981
+ continue;
3982
+ let bytes;
3983
+ if (typeof fs.blobSizes === "function") {
3984
+ const sizes = await fs.blobSizes([...new Set(m.values())]);
3985
+ bytes = 0;
3986
+ for (const h of m.values())
3987
+ bytes += sizes.get(h) ?? 0;
3988
+ }
3989
+ snapshots.push({ key: k, files: m.size, ...(bytes !== undefined ? { bytes } : {}) });
3990
+ }
3991
+ sendJson(res, 200, { sessionId: wsSession, snapshots, ...(keys.length > 0 ? { latest: keys[0] } : {}), total: keys.length });
3992
+ return;
3993
+ }
3994
+ const keyRaw = safeDecode(wm[2]);
3995
+ if (keyRaw === null) {
3996
+ sendJson(res, 400, { error: "malformed snapshot key" });
3997
+ return;
3998
+ }
3999
+ const key = keyRaw === "latest" ? keys[0] : keyRaw;
4000
+ const manifest = key !== undefined ? await fs.exportManifest(wsSession, key) : null;
4001
+ if (key === undefined || manifest === null) {
4002
+ sendJson(res, 404, { error: "snapshot not found" });
4003
+ return;
4004
+ }
4005
+ if (sub === "tree") {
4006
+ const limRaw = q.get("limit");
4007
+ if (limRaw !== null && !(/^[0-9]+$/.test(limRaw) && Number(limRaw) >= 1 && Number(limRaw) <= 2000)) {
4008
+ sendJson(res, 400, { error: "limit must be a decimal integer in 1..2000" });
4009
+ return;
4010
+ }
4011
+ const offRaw = q.get("offset");
4012
+ if (offRaw !== null && !/^[0-9]+$/.test(offRaw)) {
4013
+ sendJson(res, 400, { error: "offset must be a non-negative integer" });
4014
+ return;
4015
+ }
4016
+ const lim = limRaw === null ? 500 : Number(limRaw);
4017
+ const off = offRaw === null ? 0 : Number(offRaw);
4018
+ const all = [...manifest.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
4019
+ const page = all.slice(off, off + lim);
4020
+ const sizes = typeof fs.blobSizes === "function" ? await fs.blobSizes([...new Set(page.map(([, h]) => h))]) : undefined;
4021
+ sendJson(res, 200, {
4022
+ sessionId: wsSession,
4023
+ key,
4024
+ entries: page.map(([path, hash]) => ({ path, hash, ...(sizes?.get(hash) !== undefined ? { size: sizes.get(hash) } : {}) })),
4025
+ total: all.length,
4026
+ ...(off + lim < all.length ? { nextOffset: off + lim } : {}),
4027
+ });
4028
+ return;
4029
+ }
4030
+ if (sub === "file") {
4031
+ const pathParam = q.get("path");
4032
+ if (!pathParam) {
4033
+ sendJson(res, 400, { error: "missing ?path=<relPath from the tree>" });
4034
+ return;
4035
+ }
4036
+ const hash = manifest.get(pathParam);
4037
+ if (hash === undefined) {
4038
+ sendJson(res, 404, { error: "no such file in this snapshot (paths are the tree's relPaths, verbatim)" });
4039
+ return;
4040
+ }
4041
+ const cap = deps.config.workspaceFileMaxBytes ?? 8 * 1024 * 1024;
4042
+ if (typeof fs.blobSizes === "function") {
4043
+ const known = (await fs.blobSizes([hash])).get(hash);
4044
+ if (known !== undefined && known > cap) {
4045
+ sendJson(res, 413, { error: "file exceeds the single-file read limit — download it via the archive endpoint", code: "workspace_file_too_large", sizeBytes: known, limit: cap });
4046
+ return;
4047
+ }
4048
+ }
4049
+ const bytes = await fs.getBlob(hash);
4050
+ if (bytes === undefined) {
4051
+ sendJson(res, 404, { error: "file content no longer available (snapshot raced a reap)" });
4052
+ return;
4053
+ }
4054
+ if (bytes.byteLength > cap) {
4055
+ sendJson(res, 413, { error: "file exceeds the single-file read limit — download it via the archive endpoint", code: "workspace_file_too_large", sizeBytes: bytes.byteLength, limit: cap });
4056
+ return;
4057
+ }
4058
+ const ct = workspaceContentType(pathParam);
4059
+ const bin = looksBinary(bytes);
4060
+ res.writeHead(200, {
4061
+ "content-type": ct,
4062
+ "content-length": bytes.byteLength,
4063
+ "x-content-type-options": "nosniff",
4064
+ "x-sema-content-binary": bin ? "true" : "false",
4065
+ });
4066
+ res.end(Buffer.from(bytes));
4067
+ return;
4068
+ }
4069
+ if (sub === "archive") {
4070
+ const entries = [...manifest.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
4071
+ const bad = entries.filter(([p]) => splitTarPath(p) === null).map(([p]) => p);
4072
+ if (bad.length > 0) {
4073
+ sendJson(res, 422, { error: `snapshot contains ${bad.length} path(s) not representable in ustar (>255 bytes or unsplittable)`, paths: bad.slice(0, 5) });
4074
+ return;
4075
+ }
4076
+ if (typeof fs.hasBlobs === "function") {
4077
+ const present = await fs.hasBlobs([...new Set(entries.map(([, h]) => h))]);
4078
+ const missing = entries.filter(([, h]) => !present.has(h)).length;
4079
+ if (missing > 0) {
4080
+ sendJson(res, 502, { error: `snapshot is missing ${missing} blob(s) (raced a reap or a partial import) — retry, or pick another snapshot` });
4081
+ return;
4082
+ }
4083
+ }
4084
+ res.writeHead(200, {
4085
+ "content-type": "application/x-tar",
4086
+ "content-disposition": `attachment; filename="workspace-${key}.tar"`,
4087
+ "x-content-type-options": "nosniff",
4088
+ });
4089
+ const write = async (buf) => {
4090
+ if (res.writableEnded || res.destroyed)
4091
+ throw new Error("client gone");
4092
+ if (!res.write(buf))
4093
+ await new Promise((r) => res.once("drain", r));
4094
+ };
4095
+ try {
4096
+ for (const [p, h] of entries) {
4097
+ const bytes = await fs.getBlob(h);
4098
+ if (bytes === undefined)
4099
+ throw new Error(`blob vanished mid-stream: ${h}`);
4100
+ await write(tarHeader(p, bytes.byteLength, 0));
4101
+ await write(Buffer.from(bytes));
4102
+ await write(tarPadding(bytes.byteLength));
4103
+ }
4104
+ await write(TAR_END);
4105
+ res.end();
4106
+ }
4107
+ catch {
4108
+ res.destroy();
4109
+ }
4110
+ return;
4111
+ }
4112
+ sendJson(res, 404, { error: "unknown workspace verb — use /tree, /file?path=, or /archive" });
4113
+ return;
4114
+ }
4115
+ }
3935
4116
  {
3936
4117
  const sm = /^\/v1\/sessions\/([^/]+)\/sync\/(manifest|entries|import|plan|blobs(?:\/([^/]+))?|snapshots\/([^/]+)\/blobs\/([^/]+)|import\/([^/]+)\/entries)$/.exec(url);
3937
4118
  if (sm) {
@@ -6625,6 +6806,23 @@ function routeLabel(_method, url) {
6625
6806
  return url;
6626
6807
  return "other";
6627
6808
  }
6809
+ function workspaceContentType(relPath) {
6810
+ const ext = relPath.slice(relPath.lastIndexOf(".") + 1).toLowerCase();
6811
+ const TABLE = {
6812
+ txt: "text/plain; charset=utf-8", md: "text/plain; charset=utf-8", log: "text/plain; charset=utf-8",
6813
+ json: "application/json; charset=utf-8", csv: "text/csv; charset=utf-8",
6814
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
6815
+ pdf: "application/pdf",
6816
+ };
6817
+ return TABLE[ext] ?? "application/octet-stream";
6818
+ }
6819
+ function looksBinary(bytes) {
6820
+ const n = Math.min(bytes.byteLength, 8192);
6821
+ for (let i = 0; i < n; i++)
6822
+ if (bytes[i] === 0)
6823
+ return true;
6824
+ return false;
6825
+ }
6628
6826
  function authorized(req, token) {
6629
6827
  const h = req.headers.authorization ?? "";
6630
6828
  return safeEqual(h, `Bearer ${token}`) || safeEqual(h, `token ${token}`);
@@ -0,0 +1,8 @@
1
+ export declare function splitTarPath(relPath: string): {
2
+ prefix: string;
3
+ name: string;
4
+ } | null;
5
+ export declare function tarHeader(relPath: string, sizeBytes: number, mtimeSec: number): Buffer;
6
+ export declare function tarPadding(sizeBytes: number): Buffer;
7
+ export declare const TAR_END: Buffer<ArrayBuffer>;
8
+ //# sourceMappingURL=tar.d.ts.map
@@ -0,0 +1,49 @@
1
+ const BLOCK = 512;
2
+ function octal(n, width) {
3
+ const s = n.toString(8).padStart(width - 1, "0");
4
+ return Buffer.from(s + "\0", "ascii");
5
+ }
6
+ export function splitTarPath(relPath) {
7
+ const bytes = Buffer.byteLength(relPath, "utf8");
8
+ if (bytes <= 100)
9
+ return { prefix: "", name: relPath };
10
+ if (bytes > 255)
11
+ return null;
12
+ for (let i = relPath.length - 1; i > 0; i--) {
13
+ if (relPath[i] !== "/")
14
+ continue;
15
+ const prefix = relPath.slice(0, i);
16
+ const name = relPath.slice(i + 1);
17
+ if (Buffer.byteLength(name, "utf8") <= 100 && Buffer.byteLength(prefix, "utf8") <= 155 && name.length > 0)
18
+ return { prefix, name };
19
+ }
20
+ return null;
21
+ }
22
+ export function tarHeader(relPath, sizeBytes, mtimeSec) {
23
+ const split = splitTarPath(relPath);
24
+ if (split === null)
25
+ throw new Error(`tar: path not representable in ustar (must be ≤255 bytes and splittable at "/"): ${relPath}`);
26
+ const h = Buffer.alloc(BLOCK, 0);
27
+ h.write(split.name, 0, 100, "utf8");
28
+ octal(0o644, 8).copy(h, 100);
29
+ octal(0, 8).copy(h, 108);
30
+ octal(0, 8).copy(h, 116);
31
+ octal(sizeBytes, 12).copy(h, 124);
32
+ octal(Math.max(0, Math.floor(mtimeSec)), 12).copy(h, 136);
33
+ h.fill(0x20, 148, 156);
34
+ h.write("0", 156, 1, "ascii");
35
+ h.write("ustar\0", 257, 6, "ascii");
36
+ h.write("00", 263, 2, "ascii");
37
+ h.write(split.prefix, 345, 155, "utf8");
38
+ let sum = 0;
39
+ for (const b of h)
40
+ sum += b;
41
+ Buffer.from(sum.toString(8).padStart(6, "0") + "\0 ", "ascii").copy(h, 148);
42
+ return h;
43
+ }
44
+ export function tarPadding(sizeBytes) {
45
+ const rem = sizeBytes % BLOCK;
46
+ return Buffer.alloc(rem === 0 ? 0 : BLOCK - rem, 0);
47
+ }
48
+ export const TAR_END = Buffer.alloc(BLOCK * 2, 0);
49
+ //# sourceMappingURL=tar.js.map
@@ -21,6 +21,7 @@ export declare class SqlBlobBackend implements BlobBackend {
21
21
  hasBlobs(hashes: string[]): Promise<Set<string>>;
22
22
  deleteBlobs(hashes: string[]): Promise<number>;
23
23
  }
24
+ export declare function sqlBlobSizes(dialect: "tidb" | "pg", pool: MysqlPool | PgPool, hashes: string[]): Promise<Map<string, number>>;
24
25
  export interface MinioBlobBackendConfig {
25
26
  endpoint: string;
26
27
  bucket: string;
@@ -71,6 +71,19 @@ async function sqlHasBlobs(dialect, pool, hashes) {
71
71
  const r = await pool.query(`SELECT blob_hash FROM snapshot_blob WHERE blob_hash IN (${ph})`, distinct);
72
72
  return new Set(r.rows.map((row) => row.blob_hash));
73
73
  }
74
+ export async function sqlBlobSizes(dialect, pool, hashes) {
75
+ if (hashes.length === 0)
76
+ return new Map();
77
+ const distinct = [...new Set(hashes)];
78
+ if (dialect === "tidb") {
79
+ const ph = distinct.map(() => "?").join(",");
80
+ const [rows] = await pool.query(`SELECT blob_hash, byte_len FROM snapshot_blob WHERE blob_hash IN (${ph})`, distinct);
81
+ return new Map(rows.map((r) => [String(r.blob_hash), Number(r.byte_len)]));
82
+ }
83
+ const ph = distinct.map((_, i) => `$${i + 1}`).join(",");
84
+ const r = await pool.query(`SELECT blob_hash, byte_len FROM snapshot_blob WHERE blob_hash IN (${ph})`, distinct);
85
+ return new Map(r.rows.map((row) => [row.blob_hash, Number(row.byte_len)]));
86
+ }
74
87
  async function sqlDeleteIndexRowsPastGrace(dialect, pool, hashes) {
75
88
  if (hashes.length === 0)
76
89
  return [];
@@ -18,6 +18,7 @@ export declare class PgFileSnapshotStore implements FileSnapshotStore {
18
18
  exportManifest(scope: string, key: string): Promise<Map<string, string> | null>;
19
19
  getBlob(hash: string): Promise<Uint8Array | undefined>;
20
20
  hasBlobs(hashes: string[]): Promise<Set<string>>;
21
+ blobSizes(hashes: string[]): Promise<Map<string, number>>;
21
22
  reap(scope: string, keepKeys: string[]): Promise<number>;
22
23
  deleteBySession(scope: string): Promise<number>;
23
24
  private gcOrphanBlobs;
@@ -1,6 +1,6 @@
1
1
  import { captureManifest, applyManifest, DEFAULT_SNAPSHOT_BOUNDS } from "@sema-agent/core";
2
2
  import { createHash } from "node:crypto";
3
- import { SqlBlobBackend } from "./blob-backend.js";
3
+ import { SqlBlobBackend, sqlBlobSizes } from "./blob-backend.js";
4
4
  export class PgFileSnapshotStore {
5
5
  pool;
6
6
  bounds;
@@ -141,6 +141,9 @@ export class PgFileSnapshotStore {
141
141
  async hasBlobs(hashes) {
142
142
  return this.blobs.hasBlobs(hashes);
143
143
  }
144
+ async blobSizes(hashes) {
145
+ return sqlBlobSizes("pg", this.pool, hashes);
146
+ }
144
147
  async reap(scope, keepKeys) {
145
148
  return this.tx(async (c) => {
146
149
  const keys = await c.query("SELECT DISTINCT snap_key FROM snapshot_manifest WHERE scope = $1", [scope]);
@@ -18,6 +18,7 @@ export declare class TiDBFileSnapshotStore implements FileSnapshotStore {
18
18
  exportManifest(scope: string, key: string): Promise<Map<string, string> | null>;
19
19
  getBlob(hash: string): Promise<Uint8Array | undefined>;
20
20
  hasBlobs(hashes: string[]): Promise<Set<string>>;
21
+ blobSizes(hashes: string[]): Promise<Map<string, number>>;
21
22
  reap(scope: string, keepKeys: string[]): Promise<number>;
22
23
  deleteBySession(scope: string): Promise<number>;
23
24
  private gcOrphanBlobs;
@@ -1,6 +1,6 @@
1
1
  import { captureManifest, applyManifest, DEFAULT_SNAPSHOT_BOUNDS } from "@sema-agent/core";
2
2
  import { createHash } from "node:crypto";
3
- import { SqlBlobBackend } from "./blob-backend.js";
3
+ import { SqlBlobBackend, sqlBlobSizes } from "./blob-backend.js";
4
4
  export class TiDBFileSnapshotStore {
5
5
  pool;
6
6
  bounds;
@@ -141,6 +141,9 @@ export class TiDBFileSnapshotStore {
141
141
  async hasBlobs(hashes) {
142
142
  return this.blobs.hasBlobs(hashes);
143
143
  }
144
+ async blobSizes(hashes) {
145
+ return sqlBlobSizes("tidb", this.pool, hashes);
146
+ }
144
147
  async reap(scope, keepKeys) {
145
148
  return this.tx(async (conn) => {
146
149
  const [keys] = await conn.query("SELECT DISTINCT snap_key FROM snapshot_manifest WHERE scope = ?", [scope]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "1.298.0",
3
+ "version": "1.299.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",