@sema-agent/server 3.14.0 → 3.15.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/README.md CHANGED
@@ -130,7 +130,7 @@ The server is configured entirely through environment variables. The most import
130
130
  | Variable | Default | What it does |
131
131
  |----------|---------|--------------|
132
132
  | `PORT` | `8090` | HTTP listen port |
133
- | `BIND_HOST` (alias `HOST`) | see note | Listen address. An explicit value **always wins**. Default: `127.0.0.1` when the write face is unauthenticated (`ALLOW_UNAUTHED_WRITES=true` **and** no service token configured), otherwise all interfaces — deployments with a token are unaffected. |
133
+ | `BIND_HOST` (alias `HOST`) | see note | Listen address. An explicit `BIND_HOST` **always wins**. Default: `127.0.0.1` when the write face is unauthenticated (`ALLOW_UNAUTHED_WRITES=true` **and** no service token configured), otherwise all interfaces — deployments with a token are unaffected. Since 3.15.0 the `HOST` alias is **not** fully equivalent: shells commonly set `HOST` to the machine name without the operator knowing, so when the narrowing condition above holds it wins over an inherited `HOST` (logged as `bind_host_from_HOST_env_overridden`). Set `BIND_HOST` explicitly, or configure a service token, to expose the write face. |
134
134
  | `MODEL_GATEWAY_BASEURL` | `http://127.0.0.1:8000/v1` | OpenAI-compatible gateway base URL (without `/chat/completions`) |
135
135
  | `MODEL_ID` | **required** | Default model id — **no factory default since 3.0.0**. Unset ⇒ the server refuses to boot with a message naming the knob (the old baked-in default was an internal-only model name, so every external deployment failed later and further from the cause: a gateway `400` plus a cascade of title-hook warnings). Set it to whatever model name your gateway serves, or supply the catalog via the config-center control plane |
136
136
  | `MODEL_API_KEY` | — | Gateway API key (optional) |
package/USAGE.md CHANGED
@@ -169,8 +169,11 @@ MODEL_CASCADE_LADDER=deepseek-flash,deepseek-pro # 目录里的模型名,cheap
169
169
  数据根/HOME(1.309 起并发 boot 不再拒启,但账本内容级共享仍不受支持)。
170
170
 
171
171
  **监听绑址(1.306+,[1934])—— 桌面/单机形请注意**
172
- - `BIND_HOST`(兼容 `HOST`)= 监听地址;显式设置**恒生效**(要在无鉴权下对外暴露,显式写
173
- `BIND_HOST=0.0.0.0` 即可)
172
+ - `BIND_HOST` = 监听地址;显式设置**恒生效**(要在无鉴权下对外暴露,显式写 `BIND_HOST=0.0.0.0` 即可)。
173
+ - `HOST` 是兼容别名,但**不完全等价**(3.15.0 起):它常被 shell 继承(zsh 默认把 `HOST` 设成机器名),
174
+ 所以当下面那条自动收窄的条件成立时,**收窄压过 `HOST`** —— 否则一个用户从没设过的 shell 变量就能
175
+ 把无鉴权写面静默扩到 LAN。被压过时 boot 日志打 `bind_host_from_HOST_env_overridden`,点名被忽略的
176
+ 值与两条逃生口(显式 `BIND_HOST`,或给写面配 token)。收窄条件不成立时 `HOST` 与从前完全一样。
174
177
  - **缺省自动收窄**:当写面无鉴权时(`ALLOW_UNAUTHED_WRITES=true` 且**完全没有**
175
178
  `SERVICE_AUTH_TOKEN`/TOKENS 名录)⇒ 自动绑 `127.0.0.1`,boot 日志点名原因。理由:该形常配
176
179
  `REMOTE_EXEC=host`(用户真机、非沙箱),绑全接口=同网段任何人可无鉴权提交任务并执行(1.305 及以前
package/dist/budget.d.ts CHANGED
@@ -13,6 +13,18 @@ import type { FleetLeaseManager } from "./fleet-lease.js";
13
13
  import { type PromptManifestTracker } from "./observability/prompt-manifest.js";
14
14
  /** ③ Per-model pricing keyed by model id (core falls back to each Model.cost when a model is absent). */
15
15
  export declare function buildPricing(models: Record<string, Model>): Record<string, ModelPricing>;
16
+ /**
17
+ * 缝合审 M3:`capabilities.pricingConfigured` 的判据 —— 提成函数放在 pricing 自己的文件里,因为
18
+ * **这个表是热变的**(`boot/config-center.ts` 的 `mutateInPlace(pricing, buildPricing(config.models))`,
19
+ * 那行注释自己写着 `hot: cost/model changes`)。旧码在 `main.ts` 的 capabilities 字面量里把它算成一个
20
+ * **boot 时的布尔快照**,于是运维在 config center 里加上/撤掉价目表之后,`/v1/capabilities` 还报旧值 ——
21
+ * 而这个键的**全部用途**就是给消费端消歧「costMicroUsd=0 是免费跑,还是根本没配价目表」,报错了就是
22
+ * 把消歧信号变成误导信号。main.ts 侧改用 getter(读时求值)。
23
+ *
24
+ * 同一条论证在同一个字面量里**已经执行过一次**:紧邻的 `restartState: () => configCenter.restartState()`
25
+ * 带着注释「A live getter (not a snapshot) so /health always reads current」—— 兄弟键漏了。本仓第 7 例同形。
26
+ */
27
+ export declare function isPricingConfigured(pricing: Record<string, ModelPricing>): boolean;
16
28
  /**
17
29
  * Bound the cardinality of a free-form metric label value (BL-11/BL-21). A label fed by untrusted/unbounded
18
30
  * input — MCP tool names (a misconfigured/malicious server can emit thousands of dynamic names) or center
package/dist/budget.js CHANGED
@@ -16,6 +16,20 @@ export function buildPricing(models) {
16
16
  pricing[m.id] = modelCostToPricing(m.cost);
17
17
  return pricing;
18
18
  }
19
+ /**
20
+ * 缝合审 M3:`capabilities.pricingConfigured` 的判据 —— 提成函数放在 pricing 自己的文件里,因为
21
+ * **这个表是热变的**(`boot/config-center.ts` 的 `mutateInPlace(pricing, buildPricing(config.models))`,
22
+ * 那行注释自己写着 `hot: cost/model changes`)。旧码在 `main.ts` 的 capabilities 字面量里把它算成一个
23
+ * **boot 时的布尔快照**,于是运维在 config center 里加上/撤掉价目表之后,`/v1/capabilities` 还报旧值 ——
24
+ * 而这个键的**全部用途**就是给消费端消歧「costMicroUsd=0 是免费跑,还是根本没配价目表」,报错了就是
25
+ * 把消歧信号变成误导信号。main.ts 侧改用 getter(读时求值)。
26
+ *
27
+ * 同一条论证在同一个字面量里**已经执行过一次**:紧邻的 `restartState: () => configCenter.restartState()`
28
+ * 带着注释「A live getter (not a snapshot) so /health always reads current」—— 兄弟键漏了。本仓第 7 例同形。
29
+ */
30
+ export function isPricingConfigured(pricing) {
31
+ return Object.values(pricing).some((p) => Object.values(p).some((v) => typeof v === "number" && v > 0));
32
+ }
19
33
  /**
20
34
  * Bound the cardinality of a free-form metric label value (BL-11/BL-21). A label fed by untrusted/unbounded
21
35
  * input — MCP tool names (a misconfigured/malicious server can emit thousands of dynamic names) or center
@@ -507,7 +507,8 @@ export interface ServiceConfigFlat {
507
507
  approvalNeverAuto: string[];
508
508
  /** design/80 D-G (HMAC = integrity, not identity; ✅ WIRED — the "SCAFFOLD, not yet wired" note here was STALE,
509
509
  * corrected 2026-07-25: consumed by `verifyDirectDoorProof` → `verifyApprovalHmac`, reached from the live decide
510
- * routes `http/server.ts:4796`/`:4907`): the sema-registry-managed, ROTATING key-set used to verify an
510
+ * routes — the two `verifyDirectDoorProof(` call sites in `http/routes/approvals-assistant.ts`): the
511
+ * sema-registry-managed, ROTATING key-set used to verify an
511
512
  * approval-decision envelope's integrity on a direct-connect door. Empty (unset `APPROVAL_HMAC_KEYS`) ⇒ D-G
512
513
  * inactive (BFF-minted-principal is the only /decide door — back-compat), which is why "wired but off" is the
513
514
  * accurate description of every stock deployment. sema-registry mints + rotates the set; the shape is
package/dist/config.d.ts CHANGED
@@ -111,8 +111,54 @@ export declare function loadConfig(): ServiceConfig;
111
111
  * 是「沉默陷阱」。零凭证 + 全接口 + host lane(用户真机非沙箱)= 同网段任意执行,缺省必须安全。
112
112
  * "无鉴权写面" 的判据与 http 层 503 fail-closed 门同源:allowUnauthedWrites 且**完全没有**凭证
113
113
  * (半配=凭证必须使用,不算无鉴权)。 */
114
+ /**
115
+ * 缝合审 M4:per-request 配速上限旋钮(TASK_TIMEOUT_MAX_SEC / TASK_MAX_OUTPUT_TOKENS_MAX /
116
+ * TASK_MAX_TURNS_MAX)的解析 —— 判据提成函数放到 env 解析的家里。
117
+ *
118
+ * 病:旧码是 `Math.floor(Number(process.env[name] ?? "0"))`,非有限或 ≤0 ⇒ 回 undefined ⇒ **该上限
119
+ * 不设**。而这三个旋钮的全部用途就是给多租部署封顶 caller 自报的配速,于是打错一个字符(`50s`、
120
+ * `5_000`、全角数字)⇒ 封顶静默消失,caller 从此可自报任意 timeout/tokens/turns。fail-open 且零痕迹。
121
+ *
122
+ * 本仓**已经有**为这种情况建的渠道(`CONFIG_WARNINGS` / `drainConfigWarnings`,注释逐字写着
123
+ * 「soft-knob parse rejects … main.ts drains this once after createLogger and warns per entry」),
124
+ * 这三个旋钮没走它。这里只加**可观测性**,不改解析语义:非法值仍不设限(改成拒启会把一个打错字的
125
+ * 运营方整个挡在门外,代价与收益不成比例),但它必须出声。
126
+ *
127
+ * @returns `{}` = 缺席(正常的「不设限」);`{ value }` = 生效;`{ invalid }` = 存在但非法(不设限 + 该告警)。
128
+ */
129
+ export declare function parseCapEnv(name: string, raw: string | undefined): {
130
+ value?: number;
131
+ invalid?: string;
132
+ };
133
+ /**
134
+ * 缝合审 M6:数据根与配置根是否分家 —— 判据提成函数,放在 `localDataRoot` 自己的文件里。
135
+ *
136
+ * 两条解析各写各的:数据根吃 `LOCAL_DATA_ROOT`,配置根(`main.ts` 的 `localRoot` / `run-local.ts` 的
137
+ * `root`)不吃。只设 `LOCAL_DATA_ROOT=/data/sema` 的部署因此把 store 数据挪到了 /data/sema,而
138
+ * config.d / remote-exec.json 仍从 `~/.ai-agent` 读 —— 静默分家,而设它的人多半以为改的是「本地数据放哪」。
139
+ *
140
+ * 讽刺处:`main.ts` 那段的标题逐字写着「ONE local root for BOTH … avoids a split-brain」——那条论证
141
+ * 合并了 config provider 与 remoteExec 两个根,**漏了第三个**。
142
+ *
143
+ * 这里**不改解析**(让配置根跟着 `LOCAL_DATA_ROOT` 走,会让现有部署的 config.d 当场失踪 —— 代价比
144
+ * 分家本身大得多)。只回一个「分家了,两个根分别是什么」的事实,由 boot 期打告警,合一旋钮 = `CONFIG_LOCAL_DIR`。
145
+ *
146
+ * @returns 分家时回两个根;一致(或已被 CONFIG_LOCAL_DIR/AGENT_DATA_DIR 统一)时回 undefined。
147
+ */
148
+ export declare function splitLocalRoots(a: {
149
+ /** optional:类型上可缺席(config-types 里是 `localDataRoot?`)——缺席时无从比较,判为不分家。 */
150
+ localDataRoot: string | undefined;
151
+ configLocalDir: string | undefined;
152
+ agentDataDir: string | undefined;
153
+ home: string;
154
+ }): {
155
+ dataRoot: string;
156
+ configRoot: string;
157
+ } | undefined;
114
158
  export declare function resolveBindHost(config: {
115
159
  bindHost?: string;
160
+ /** 缝合审 M5:`bindHost` 的**来源**决定它算不算 operator 明示,见下。 */
161
+ bindHostSource?: "BIND_HOST" | "HOST";
116
162
  allowUnauthedWrites?: boolean;
117
163
  authToken?: string;
118
164
  authTokens?: Record<string, string>;
package/dist/config.js CHANGED
@@ -378,11 +378,14 @@ function parseStoreDomain(ctx) {
378
378
  const sqlEngineExplicit = !!process.env.DB_BACKEND && dbBackend !== "local" && dbBackend !== "memory";
379
379
  const sessionBackendRaw = enumEnv("SESSION_BACKEND", sqlEngineExplicit ? "mysql" : "memory", ["memory", "mysql", "tidb", "auto"]);
380
380
  const sessionBackend = sessionBackendRaw === "mysql" ? "tidb" : sessionBackendRaw;
381
- // P0.5 variant-2: the file-backed `local` backend's data root. SAME resolution main.ts:102 (localRoot =
382
- // CONFIG_LOCAL_DIR ?? AGENT_DATA_DIR ?? ~/.ai-agent) + run-local.ts:198, so the HTTP service + a run-local on one
383
- // box open ONE boot-locked data dir. `LOCAL_DATA_ROOT` is an additional highest-priority override (the explicit
384
- // "the local backend's data lives HERE" knob). Only the `local` backend consumes it (createStoreBackend), but it's
385
- // always resolved (cheap, env-deterministic).
381
+ // P0.5 variant-2: the file-backed `local` backend's data root. Same tail as main.ts's `localRoot` and
382
+ // run-local.ts's `root` (CONFIG_LOCAL_DIR AGENT_DATA_DIR ~/.ai-agent), so the HTTP service + a run-local
383
+ // on one box open ONE boot-locked data dir. `LOCAL_DATA_ROOT` is an additional highest-priority override
384
+ // (the explicit "the local backend's data lives HERE" knob). Only the `local` backend consumes it
385
+ // (createStoreBackend), but it's always resolved (cheap, env-deterministic).
386
+ // ⚠️ 缝合审 M6:上面那句「same resolution」**只在没设 LOCAL_DATA_ROOT 时成立** —— 旧注释断言了无条件
387
+ // 相同,那是假的。设了它就只有数据根跟着走,config.d/remote-exec.json 仍从配置根读 ⇒ 两根分家。
388
+ // 不改解析(改了会让现有部署的 config.d 当场失踪),改成分家时 boot 期出声:见 `splitLocalRoots`。
386
389
  const localDataRoot = process.env.LOCAL_DATA_ROOT || process.env.CONFIG_LOCAL_DIR || process.env.AGENT_DATA_DIR || join(homedir(), ".ai-agent");
387
390
  // `auto` builds the DB coords only when a host is actually configured — otherwise it stays memory with no
388
391
  // probe. (Explicit mysql always builds them so a missing host fails loudly.) `needsDb` is engine-agnostic;
@@ -1408,10 +1411,63 @@ export function loadConfig() {
1408
1411
  * 是「沉默陷阱」。零凭证 + 全接口 + host lane(用户真机非沙箱)= 同网段任意执行,缺省必须安全。
1409
1412
  * "无鉴权写面" 的判据与 http 层 503 fail-closed 门同源:allowUnauthedWrites 且**完全没有**凭证
1410
1413
  * (半配=凭证必须使用,不算无鉴权)。 */
1414
+ /**
1415
+ * 缝合审 M4:per-request 配速上限旋钮(TASK_TIMEOUT_MAX_SEC / TASK_MAX_OUTPUT_TOKENS_MAX /
1416
+ * TASK_MAX_TURNS_MAX)的解析 —— 判据提成函数放到 env 解析的家里。
1417
+ *
1418
+ * 病:旧码是 `Math.floor(Number(process.env[name] ?? "0"))`,非有限或 ≤0 ⇒ 回 undefined ⇒ **该上限
1419
+ * 不设**。而这三个旋钮的全部用途就是给多租部署封顶 caller 自报的配速,于是打错一个字符(`50s`、
1420
+ * `5_000`、全角数字)⇒ 封顶静默消失,caller 从此可自报任意 timeout/tokens/turns。fail-open 且零痕迹。
1421
+ *
1422
+ * 本仓**已经有**为这种情况建的渠道(`CONFIG_WARNINGS` / `drainConfigWarnings`,注释逐字写着
1423
+ * 「soft-knob parse rejects … main.ts drains this once after createLogger and warns per entry」),
1424
+ * 这三个旋钮没走它。这里只加**可观测性**,不改解析语义:非法值仍不设限(改成拒启会把一个打错字的
1425
+ * 运营方整个挡在门外,代价与收益不成比例),但它必须出声。
1426
+ *
1427
+ * @returns `{}` = 缺席(正常的「不设限」);`{ value }` = 生效;`{ invalid }` = 存在但非法(不设限 + 该告警)。
1428
+ */
1429
+ export function parseCapEnv(name, raw) {
1430
+ void name; // 名字只为调用方的日志留位;判据本身与旋钮名无关(三个旋钮同一条语义)
1431
+ if (raw === undefined)
1432
+ return {};
1433
+ const n = Math.floor(Number(raw));
1434
+ if (!Number.isFinite(n) || n <= 0)
1435
+ return { invalid: raw };
1436
+ return { value: n };
1437
+ }
1438
+ /**
1439
+ * 缝合审 M6:数据根与配置根是否分家 —— 判据提成函数,放在 `localDataRoot` 自己的文件里。
1440
+ *
1441
+ * 两条解析各写各的:数据根吃 `LOCAL_DATA_ROOT`,配置根(`main.ts` 的 `localRoot` / `run-local.ts` 的
1442
+ * `root`)不吃。只设 `LOCAL_DATA_ROOT=/data/sema` 的部署因此把 store 数据挪到了 /data/sema,而
1443
+ * config.d / remote-exec.json 仍从 `~/.ai-agent` 读 —— 静默分家,而设它的人多半以为改的是「本地数据放哪」。
1444
+ *
1445
+ * 讽刺处:`main.ts` 那段的标题逐字写着「ONE local root for BOTH … avoids a split-brain」——那条论证
1446
+ * 合并了 config provider 与 remoteExec 两个根,**漏了第三个**。
1447
+ *
1448
+ * 这里**不改解析**(让配置根跟着 `LOCAL_DATA_ROOT` 走,会让现有部署的 config.d 当场失踪 —— 代价比
1449
+ * 分家本身大得多)。只回一个「分家了,两个根分别是什么」的事实,由 boot 期打告警,合一旋钮 = `CONFIG_LOCAL_DIR`。
1450
+ *
1451
+ * @returns 分家时回两个根;一致(或已被 CONFIG_LOCAL_DIR/AGENT_DATA_DIR 统一)时回 undefined。
1452
+ */
1453
+ export function splitLocalRoots(a) {
1454
+ const configRoot = a.configLocalDir ?? a.agentDataDir ?? join(a.home, ".ai-agent");
1455
+ if (!a.localDataRoot || a.localDataRoot === configRoot)
1456
+ return undefined;
1457
+ return { dataRoot: a.localDataRoot, configRoot };
1458
+ }
1411
1459
  export function resolveBindHost(config) {
1412
- if (config.bindHost)
1413
- return config.bindHost;
1414
1460
  const anyServiceAuth = Boolean(config.authToken) || Object.keys(config.authTokens ?? {}).length > 0;
1415
- return config.allowUnauthedWrites && !anyServiceAuth ? "127.0.0.1" : undefined;
1461
+ const mustNarrow = Boolean(config.allowUnauthedWrites) && !anyServiceAuth;
1462
+ // 缝合审 M5:`bindHost` = `BIND_HOST || HOST`,而 zsh 常设 `HOST=<机器名>` —— 用户从未设过它。
1463
+ // 旧码第一行就是 `if (config.bindHost) return config.bindHost`,于是一个**继承来的** shell 变量
1464
+ // 静默击穿了下面那条无鉴权自收窄:写面无鉴权 + 绑到机器名解析出的 LAN 地址 = 同网段任何人可在
1465
+ // 用户真机上提交任务并执行。触发条件是「什么都没做」,这是它比一般配置错误危险的地方。
1466
+ // [2062]③ 已经识别出「HOST 是暗通道」并加了启动 warn,但那个识别没接到这条判据上 —— 现在接上:
1467
+ // **只在收窄条件成立时**让收窄压过继承的 HOST(`HOST` 在 Node 生态确有绑址惯例,不能当它不存在);
1468
+ // 显式 `BIND_HOST` 恒生效,operator 明示暴露的权利一点不动。main.ts 会为被压过的那次打专门告警。
1469
+ if (config.bindHost && !(mustNarrow && config.bindHostSource === "HOST"))
1470
+ return config.bindHost;
1471
+ return mustNarrow ? "127.0.0.1" : undefined;
1416
1472
  }
1417
1473
  //# sourceMappingURL=config.js.map
@@ -47,7 +47,7 @@ export async function handleSessionSync(req, res, url, ctx) {
47
47
  /** 路由体 = 从 `server.ts` 的 `handle()` 里**整段剪切**过来的原文(唯一改动:统一去缩进两格)。 */
48
48
  async function handleSessionSyncBody(req, res, url, ctx, miss) {
49
49
  const { deps } = ctx;
50
- const { readJson, readRawBody, safeDecode, sessionOwnerScopeForWrite } = ctx.helpers;
50
+ const { readJson, readRawBody, safeDecode, sessionOwnerScopeForWrite, rateLimited } = ctx.helpers;
51
51
  const { pendingStagings, importLeases, touchImportLease, cleanupStaging } = ctx.local.sessionSync;
52
52
  // ── 2c session-sync (P1d) — the cloud as a SYNC PEER (sema-internal server/docs/DESIGN-session-sync.md §15 + §5–§10) ──────────────
53
53
  // The real 2c topology is TWO PROCESSES (the local shell's local backend + this cloud service); these routes let
@@ -218,6 +218,13 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
218
218
  // own-or-FRESH gate. Verify sha256(body)===:hash (the content-address is the integrity guarantee; a transfer
219
219
  // crosses the net) BEFORE storing. The body is read with a PER-BLOB cap (reject-not-OOM).
220
220
  if (sm[3] !== undefined && req.method === "PUT") {
221
+ // 缝合审 S7:限流对**字节写入**要对称。同族的 `POST /v1/attachments`(也是「客户端把任意字节
222
+ // 写进持久存储」)走 rateLimited;这条腿此前只有 own-or-fresh 门 —— 单个 blob 有 cap,但**次数
223
+ // 不封**,持合法 principal 的 caller 可全速灌存储/带宽。attachments 那段注释自己写着
224
+ // 「上限=server 权威…sync-blob 同姿」:尺寸口径同姿了,限流口径没跟上。
225
+ // 只加 rateLimited,**不加** quotaExceeded —— blob 上传不跑模型,与 runs.ts 的 detach/stop 同姿。
226
+ if (rateLimited(req, res))
227
+ return;
221
228
  if (!pushGateOk())
222
229
  return;
223
230
  const hash = safeDecode(sm[3]);
@@ -3,7 +3,8 @@
3
3
  * modules under `http/routes/` can call them without a value edge back into `server.ts` (that edge would close
4
4
  * a runtime import cycle — see `test/module-cycle-gate.test.ts`).
5
5
  *
6
- * Pure move: bodies are byte-identical to the ones that lived at `server.ts:9720/9822/9834/9843`. `server.ts`
6
+ * Pure move: bodies are byte-identical to the ones that lived in the pre-A9 `server.ts` (that ~9.7k-line file
7
+ * no longer exists, so quoting its line numbers here would point at nothing). `server.ts`
7
8
  * now imports them from here, so all ~500 existing `sendJson(...)` call sites are unchanged text.
8
9
  */
9
10
  import type { ServerResponse } from "node:http";
@@ -543,6 +543,24 @@ export function createHttpServer(rawDeps) {
543
543
  sendError(res, 503, "auth.service_token_required", "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting session-policy writes");
544
544
  return;
545
545
  }
546
+ // 🔴 E6 的**兄弟路径**(2026-08-01 缝合审):上面那道门只挂在 policy 的 PUT 腿上,而 session-sync 的
547
+ // import 提交腿(`routes/session-sync.ts` 的 ② policy 段)对**同一个 sessionPolicyStore** 调 `putRules`。
548
+ // 该腿的授权门是 `sessionOwnerScopeForWrite`,在「无 service token + 未开 ALLOW_UNAUTHED_WRITES +
549
+ // 未开 REQUIRE_PRINCIPAL」这一形下是 fleet-wide ⇒ 集群内**任意**调用方可对**任意** session 走两阶段
550
+ // import,顺带重写它的 policy 规则。`operatorOk` 走 explicitOperatorOk(无 operator 名单时为 false)
551
+ // ⇒ 只能 tighten 不能放宽 —— 但上面那道门自己写明的威胁就是「forged principal 可以 **tighten/DoS**
552
+ // 一个会话的工具」,严丝合缝落在同一句话上。
553
+ //
554
+ // 拦在 **Phase A**(建 staging 之前),不是 Phase B:否则会先落一半状态再拒。
555
+ // 辖域取整条 import 而非只跳过 policy 段 —— 同一个理由:这一形下不信任调用方写 policy,就没有道理
556
+ // 信任它整段替换会话的 entries(import 的破坏面比 policy 更大)。
557
+ if (req.method === "POST" &&
558
+ !anyServiceAuth &&
559
+ !deps.config.allowUnauthedWrites &&
560
+ /^\/v1\/sessions\/[^/]+\/sync\/import$/.test(url)) {
561
+ sendError(res, 503, "auth.service_token_required", "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting a session-sync import");
562
+ return;
563
+ }
546
564
  // design/158 A9:能力/场景/模型目录域(routes/capabilities.ts)。位置=全局 service-credential 门之后、
547
565
  // 任务提交面之前,与拆分前逐行同序。
548
566
  if (await handleCapabilities(req, res, url, ctx))
package/dist/main.js CHANGED
@@ -6,9 +6,9 @@ import { createSessionTitler } from "./session-titler.js";
6
6
  import { posIntEnv } from "./session-watch.js";
7
7
  import { selectEnvironmentTool } from "./capabilities/select-environment-tool.js";
8
8
  import { subagentSendUserFileExtraTools } from "./capabilities/send-user-file-tool.js";
9
- import { resolve } from "node:path";
10
9
  import { brainSummary } from "./brain.js";
11
- import { loadConfig, logConfigDiagnostics, resolveBindHost } from "./config.js";
10
+ import { loadConfig, logConfigDiagnostics, resolveBindHost, splitLocalRoots, parseCapEnv } from "./config.js";
11
+ import { isPricingConfigured } from "./budget.js"; // 缝合审 M3:capabilities.pricingConfigured 的单一真源判据
12
12
  import { drainNumEnvWarnings } from "./plugins/remote-shell.js";
13
13
  import { ensureChildSessionDurableWithPromotion } from "./plugins/session-store.js";
14
14
  import { ForkRoutingSessionStore } from "./plugins/fork-routing-session-store.js";
@@ -130,6 +130,22 @@ async function main() {
130
130
  // AGENT_DATA_DIR, so config.d and remote-exec.json could resolve to DIFFERENT directories). run-local already
131
131
  // computes this once; main.ts now does too. Shared with the config-center runtime and every boot leaf below.
132
132
  const localRoot = config.configLocalDir ?? process.env.AGENT_DATA_DIR ?? join(homedir(), ".ai-agent");
133
+ // 缝合审 M6:上面这条论证合并了 config provider 与 remoteExec 两个根,**漏了第三个** —— store 的
134
+ // 数据根另有一条解析(config.ts 的 `localDataRoot`,它吃 `LOCAL_DATA_ROOT` 而这里不吃)。只设
135
+ // LOCAL_DATA_ROOT 的部署因此数据在一处、config.d/remote-exec.json 在另一处,而且**一声不响**。
136
+ // 不改解析(改了现有部署的 config.d 会当场失踪),改成说出来 + 给合一旋钮。
137
+ const rootsSplit = splitLocalRoots({
138
+ localDataRoot: config.localDataRoot,
139
+ configLocalDir: config.configLocalDir,
140
+ agentDataDir: process.env.AGENT_DATA_DIR,
141
+ home: homedir(),
142
+ });
143
+ if (rootsSplit) {
144
+ logger.warn("local_roots_split", {
145
+ ...rootsSplit,
146
+ note: "LOCAL_DATA_ROOT moved the local backend's DATA root, but config.d / remote-exec.json are still read from the config root (LOCAL_DATA_ROOT is not consulted there). If you meant to move both, set CONFIG_LOCAL_DIR to the same directory.",
147
+ });
148
+ }
133
149
  // design/158 A10 尾刀:config-center 的 boot 半场(sealed-key 托管 → provider → prompt-epoch 店 →
134
150
  // effective 拉取 / LKG 兜底 / tolerant apply)搬到 src/boot/config-center.ts。该段与 refresh 循环共享的
135
151
  // 12 个跨段可变 `let` 现在是那只工厂的闭包私有字段(每一处读写的文本与次序逐字保留,见该文件头注);
@@ -351,9 +367,18 @@ async function main() {
351
367
  // Math.min 封顶(normalizeLimits)。多租部署想约束 caller 自报配速时才设;单用户 turnkey 通常留空。
352
368
  // ⚠️ 语义=只钳「显式请求值」,不是全队默认限额:body 缺席(或重放体里的非法值被 defensive DROP)⇒ 该键
353
369
  // 不设限,与升级前行为同形——要给所有任务强加限额是另一个旋钮(未建),别指望这三个(交叉评审 F1 判读)。
370
+ // 缝合审 M4:非法值仍 fail-open(不设限),但**必须出声** —— 这三个旋钮的全部用途就是封顶,
371
+ // 打错一个字符让封顶静默消失是它最坏的失败形。判据单一真源在 config.ts 的 parseCapEnv。
354
372
  const capEnv = (name) => {
355
- const n = Math.floor(Number(process.env[name] ?? "0"));
356
- return Number.isFinite(n) && n > 0 ? n : undefined;
373
+ const r = parseCapEnv(name, process.env[name]);
374
+ if (r.invalid !== undefined) {
375
+ logger.warn("task_limit_cap_invalid", {
376
+ env: name,
377
+ raw: r.invalid,
378
+ note: "this per-request rate cap is NOT in effect (a non-numeric or non-positive value parses to 'no cap'). Callers can self-report any value for this limit until the env is fixed.",
379
+ });
380
+ }
381
+ return r.value;
357
382
  };
358
383
  const taskLimitCaps = {
359
384
  timeoutSec: capEnv("TASK_TIMEOUT_MAX_SEC"),
@@ -769,7 +794,14 @@ async function main() {
769
794
  capabilities: {
770
795
  version: serviceVersion(),
771
796
  scenarios: Object.keys(scenarios),
772
- pricingConfigured: Object.values(pricing).some((p) => Object.values(p).some((v) => typeof v === "number" && v > 0)),
797
+ // 缝合审 M3:**getter,不是 boot 快照** —— `pricing` 是被 config-center 就地热改的同一个引用
798
+ // (`boot/config-center.ts` 的 `mutateInPlace(pricing, buildPricing(config.models))`)。算成快照的话,
799
+ // 运维加上/撤掉价目表之后这个键还报旧值,而它的全部用途就是给消费端消歧「0 = 免费跑 vs 没配价目表」——
800
+ // 报错了就把消歧信号变成误导信号。判据单一真源在 `budget.ts` 的 `isPricingConfigured`。
801
+ // (同一字面量里紧邻的 `restartState` 已经是 live getter 并写着为什么;这个兄弟键当时漏了。)
802
+ get pricingConfigured() {
803
+ return isPricingConfigured(pricing);
804
+ },
773
805
  },
774
806
  // center: orchestrator polls GET /health for this — set by the sema-registry refresh loop when a
775
807
  // pull's restart-to-apply slices differ from boot. A live getter (not a snapshot) so /health always reads current.
@@ -815,15 +847,30 @@ async function main() {
815
847
  // 🔴 [2067]③ 次序承重:必须在 listen **之前**打——HOST=不可解析名时 listen 直接 ENOTFOUND 崩溃,
816
848
  // 恰是「继承 HOST 把 boot 弄崩」这个最需要解释的场景;warn 在 listen 后=诊断在崩溃里缺席,用户只见
817
849
  // 裸 getaddrinfo 栈(cli 真机取证)。
818
- if (config.bindHostSource === "HOST") {
850
+ // 缝合审 M5:收窄现在会**压过**继承的 HOST(见 resolveBindHost)。分两臂 —— 否则这条 warn 打的
851
+ // `bindHost` 已是收窄后的 127.0.0.1,而 note 还说「came from the HOST env var」,自己变成一句谎。
852
+ if (config.bindHostSource === "HOST" && bindHost !== config.bindHost) {
853
+ logger.warn("bind_host_from_HOST_env_overridden", {
854
+ bindHost,
855
+ ignoredHostEnv: config.bindHost,
856
+ note: "HOST was inherited from the shell (zsh commonly sets it to the machine name) and would have bound the write face to a non-loopback address — but this deployment has ALLOW_UNAUTHED_WRITES with no service token, so the bind address was narrowed to loopback instead. Two ways out if you meant to expose it: set BIND_HOST explicitly, or configure a service token.",
857
+ });
858
+ }
859
+ else if (config.bindHostSource === "HOST") {
819
860
  logger.warn("bind_host_from_HOST_env", {
820
861
  bindHost,
821
862
  note: "the bind address came from the HOST env var (not BIND_HOST). Shells like zsh commonly set HOST to the machine name — if you did not set it yourself, this may be an inherited shell variable silently configuring the bind face. Set BIND_HOST explicitly to silence this warning.",
822
863
  });
823
864
  }
865
+ // 缝合审 M8:listen 期的 rejecter 必须**具名**,因为它必须在 listen 结束后被摘掉。留着不摘的话,
866
+ // 运行期第一个 server-level error 会喂给一个已 settle 的 Promise(reject = no-op)⇒ 零日志零抛;
867
+ // 而 `once` 自摘之后,第二个 error 才 uncaught 崩进程 —— 这条缺口吃掉的恰好是**第一个**证据,
868
+ // 而第一个证据往往是唯一能解释后面那次崩溃的东西。
869
+ let onListenError;
824
870
  try {
825
871
  await new Promise((resolve, reject) => {
826
- server.once("error", reject); // listen 失败(ENOTFOUND/EADDRINUSE…)不吊死这个 Promise
872
+ onListenError = reject; // listen 失败(ENOTFOUND/EADDRINUSE…)不吊死这个 Promise
873
+ server.once("error", onListenError);
827
874
  bindHost ? server.listen(config.port, bindHost, resolve) : server.listen(config.port, resolve);
828
875
  });
829
876
  }
@@ -838,12 +885,17 @@ async function main() {
838
885
  });
839
886
  throw e;
840
887
  }
841
- logger.info("listening", {
842
- port: config.port,
843
- bindHost: bindHost ?? "0.0.0.0/::(all interfaces)",
844
- ...(bindHost === "127.0.0.1" && !config.bindHost
845
- ? { note: "auto-narrowed to loopback: write face is unauthenticated (ALLOW_UNAUTHED_WRITES with no service token). Set BIND_HOST explicitly to override." }
846
- : {}),
888
+ finally {
889
+ // listen 结束(成功或失败)即摘。换上持久 handler:server-level error 之后进程通常仍能服务,
890
+ // 崩掉整个副本太重 —— 但绝不能无声。
891
+ if (onListenError)
892
+ server.removeListener("error", onListenError);
893
+ }
894
+ server.on("error", (e) => {
895
+ logger.error("server_error", {
896
+ error: e.message,
897
+ ...("code" in e ? { code: String(e.code) } : {}),
898
+ });
847
899
  });
848
900
  // fleet worker 接线(announce/heartbeat + usage 批报;lease 消费后一拍):boot 完成后
849
901
  // 注册(=首次心跳)。门 = center lane 配置 ∧ FLEET_ADVERTISE_ADDRESS 显式声明(不自猜可达地址);任一缺 → undefined
@@ -857,8 +909,15 @@ async function main() {
857
909
  });
858
910
  if (fleetClient)
859
911
  logger.info("fleet_client_started", { worker: config.configCenter?.worker, address: process.env.FLEET_ADVERTISE_ADDRESS });
912
+ // 缝合审 M7:这是**唯一**一处 `listening`。此前 listen 成功处还有第二条同名事件(只带
913
+ // port/bindHost/note),日志消费方 grep "listening" 会撞见两条键集不同的同名事件,按前一条解析
914
+ // 部署快照的人拿到残缺形。bindHost + loopback 收窄 note 并进这里,别再另起一行。
860
915
  logger.info("listening", {
861
916
  port: config.port,
917
+ bindHost: bindHost ?? "0.0.0.0/::(all interfaces)",
918
+ ...(bindHost === "127.0.0.1" && !config.bindHost
919
+ ? { bindHostNote: "auto-narrowed to loopback: write face is unauthenticated (ALLOW_UNAUTHED_WRITES with no service token). Set BIND_HOST explicitly to override." }
920
+ : {}),
862
921
  model: config.model.id,
863
922
  brain: brainSummary(config),
864
923
  // shared = cross-replica TiDB breaker state; in-process = core's per-replica Map; off = breaker disabled
@@ -165,7 +165,7 @@ export declare class FileRunStore {
165
165
  * session forever" incident). Semantics verbatim from the SQL twin: suspended ∧ updatedAt < cutoff ∧ NOT
166
166
  * probe.hasPending(session) → failed 'approval expired before decision' / errorCode 'approval.expired' +
167
167
  * release the task_active claim. The NOT-pending clause is the design/80 D-D adversarial-blocker guard
168
- * (tidb-run-store.ts:577-583): a row whose checkpoint is STILL pending belongs to the checkpoint-state sweeps —
168
+ * (SQL 孪生现在的真身:`run-store-sql.ts` 的 `reapSuspended`;`tidb-run-store.ts` 已是 re-export 壳): a row whose checkpoint is STILL pending belongs to the checkpoint-state sweeps —
169
169
  * time-reaping it would orphan a model leg on a now-unlocked session. Probe absent ⇒ honest NO-OP.
170
170
  * MemoryRunStore is the parity oracle (byte-identical predicate; only the persist differs).
171
171
  */
@@ -529,7 +529,7 @@ export class FileRunStore {
529
529
  * session forever" incident). Semantics verbatim from the SQL twin: suspended ∧ updatedAt < cutoff ∧ NOT
530
530
  * probe.hasPending(session) → failed 'approval expired before decision' / errorCode 'approval.expired' +
531
531
  * release the task_active claim. The NOT-pending clause is the design/80 D-D adversarial-blocker guard
532
- * (tidb-run-store.ts:577-583): a row whose checkpoint is STILL pending belongs to the checkpoint-state sweeps —
532
+ * (SQL 孪生现在的真身:`run-store-sql.ts` 的 `reapSuspended`;`tidb-run-store.ts` 已是 re-export 壳): a row whose checkpoint is STILL pending belongs to the checkpoint-state sweeps —
533
533
  * time-reaping it would orphan a model leg on a now-unlocked session. Probe absent ⇒ honest NO-OP.
534
534
  * MemoryRunStore is the parity oracle (byte-identical predicate; only the persist differs).
535
535
  */
@@ -129,7 +129,7 @@ export declare class MemoryRunStore {
129
129
  * locks the session forever" incident). Semantics verbatim from the SQL twin: suspended ∧ updatedAt < cutoff
130
130
  * ∧ NOT probe.hasPending(session) → failed 'approval expired before decision' / error_code 'approval.expired'
131
131
  * + release the task_active claim. The NOT-pending clause is the design/80 D-D adversarial-blocker guard
132
- * (see tidb-run-store.ts:577-583): a row whose checkpoint is STILL pending belongs to the checkpoint-state
132
+ * (SQL 孪生现在的真身:`run-store-sql.ts` 的 `reapSuspended`;`tidb-run-store.ts` 已是 re-export 壳): a row whose checkpoint is STILL pending belongs to the checkpoint-state
133
133
  * sweeps (SLA deny-sweep / failSuspendedWithExpiredCheckpoint) — time-reaping it here would orphan a model
134
134
  * leg on a now-unlocked session. Probe absent ⇒ honest NO-OP (never kill a park without the guard).
135
135
  */
@@ -322,7 +322,7 @@ export class MemoryRunStore {
322
322
  * locks the session forever" incident). Semantics verbatim from the SQL twin: suspended ∧ updatedAt < cutoff
323
323
  * ∧ NOT probe.hasPending(session) → failed 'approval expired before decision' / error_code 'approval.expired'
324
324
  * + release the task_active claim. The NOT-pending clause is the design/80 D-D adversarial-blocker guard
325
- * (see tidb-run-store.ts:577-583): a row whose checkpoint is STILL pending belongs to the checkpoint-state
325
+ * (SQL 孪生现在的真身:`run-store-sql.ts` 的 `reapSuspended`;`tidb-run-store.ts` 已是 re-export 壳): a row whose checkpoint is STILL pending belongs to the checkpoint-state
326
326
  * sweeps (SLA deny-sweep / failSuspendedWithExpiredCheckpoint) — time-reaping it here would orphan a model
327
327
  * leg on a now-unlocked session. Probe absent ⇒ honest NO-OP (never kill a park without the guard).
328
328
  */
@@ -132,9 +132,10 @@ export interface StoreBackend {
132
132
  * ✅ WIRED (was "NOT YET WIRABLE, as of core 1.144.0" — that claim went STALE and sat here misleading readers
133
133
  * until a 2026-07-25 doc-rot sweep caught it). core 1.145.0 added the seam this comment was waiting for:
134
134
  * `RunnerDeps.workflowJournalStore` (now `@sema-agent/core` `dist/core/types.d.ts:708`) + `journalStore` on the
135
- * run_workflow tool deps (`dist/orchestration/run-workflow-tool.d.ts:65`), and main.ts consumes it end-to-end:
136
- * built at §run_workflow mount (`main.ts:1349`), threaded into RunnerDeps (`main.ts:1636`), reaped on the
137
- * maintenance tick (`main.ts:2399`), and read by `GET /v1/workflows/:id/journal` (`main.ts:3077`, [1402]). */
135
+ * run_workflow tool deps (`dist/orchestration/run-workflow-tool.d.ts:65`), and the boot chain consumes it
136
+ * end-to-end: built in `boot/workflow-orchestration.ts` (`const workflowJournalStore = …backend.workflowJournal()`),
137
+ * threaded into RunnerDeps (`boot/runner-deps.ts` 的 `workflowJournalStore`), reaped on the maintenance tick
138
+ * (`boot/reapers.ts` 同名字段), and read by `GET /v1/workflows/:id/journal` (`http/routes/workflows.ts`, [1402]). */
138
139
  workflowJournal(): ServiceWorkflowJournalStore;
139
140
  /** design/73 §1 consumption sink for `RunnerDeps.onTaskOutcome` facts. REQUIRED on all backends:
140
141
  * tidb/pg = the outcome-ledger twins (SQL rows, mapped via coreOutcomeToLedgerRow + verbatim core_outcome
package/dist/security.js CHANGED
@@ -110,7 +110,13 @@ export function createAuthorizer(config, sessionStore) {
110
110
  const requested = typeof body.sessionId === "string" && body.sessionId ? body.sessionId : undefined;
111
111
  let sessionId;
112
112
  if (requested) {
113
- if (ownerOf && register) {
113
+ // 🔴 [2195]:两个能力**解耦判**(旧码是 `if (ownerOf && register)` 一把抓)。它们回答的是两个
114
+ // 不同的问题:`ownerOf` = 「这个 session 归谁」(判归属),`register` = 「把新 id 记在谁名下」
115
+ // (能记账)。旧码把两者绑成一个开关,于是 `ownerOf` 在、`register` 缺时,**明明判得了归属却
116
+ // 整条不判** —— 仓里有一条特征化测试逐字钉着这个结果(principal 直接叫 `user:intruder`,
117
+ // 注释写「would be a mismatch IF enforced」),它 describe 名却是 "in-memory dev backend",
118
+ // 而配的是 `requirePrincipal: true`(多租)。描述与执行面脱钩,读标题的人以为那是 dev-only 的事。
119
+ if (ownerOf) {
114
120
  let owner = await ownerOf(requested);
115
121
  if (owner === undefined) {
116
122
  if (requireExisting) {
@@ -123,11 +129,19 @@ export function createAuthorizer(config, sessionStore) {
123
129
  // still holds on the paths that never reach this authorizer.
124
130
  throw new HttpError(404, "requireExistingSession: session does not exist (resume.session_not_found)");
125
131
  }
126
- await register(requested, principal ?? null); // unknown id → claim for this principal
127
- // Re-read the authoritative stored owner: register is an idempotent upsert that keeps the
128
- // FIRST owner, so a concurrent first-claim of the same id by another principal could have won
129
- // the race. Without this re-check the loser would silently attach to the winner's session.
130
- owner = await ownerOf(requested);
132
+ if (register) {
133
+ await register(requested, principal ?? null); // unknown id claim for this principal
134
+ // Re-read the authoritative stored owner: register is an idempotent upsert that keeps the
135
+ // FIRST owner, so a concurrent first-claim of the same id by another principal could have won
136
+ // the race. Without this re-check the loser would silently attach to the winner's session.
137
+ owner = await ownerOf(requested);
138
+ }
139
+ else if (config.requirePrincipal) {
140
+ // 🔴 [2195] 的第二半:能判归属但**记不了账**。放行的话这个 id 的归属永远不会被写下,
141
+ // 于是下一个租户带同一个 id 来,同样看到「不存在」、同样被放行 —— 两个租户共用一条会话,
142
+ // 第二个人读得到第一个人的对话。多租下这与「归属不匹配」是同一类越权,同样拒。
143
+ throw new HttpError(403, "cannot claim a caller-supplied sessionId on a multi-tenant deployment: this worker's session store cannot record session ownership, so the id would stay unowned and the next caller could attach to it. Configure a durable session backend (SESSION_BACKEND), or omit sessionId to start a new session.");
144
+ }
131
145
  }
132
146
  // Enforce ownership whenever the session HAS an owner — including against callers that present
133
147
  // no principal (audit B, security.ts:77): with the old `principal &&` guard, a caller that
@@ -140,6 +154,23 @@ export function createAuthorizer(config, sessionStore) {
140
154
  : new HttpError(401, `session is principal-owned; missing principal header '${config.principalHeader}'`);
141
155
  }
142
156
  }
157
+ else if (config.requirePrincipal) {
158
+ // 🔴 [2195](cli 七版实测:bob 续聊 alice 的 session,期望 403 实测 200)的根因修。
159
+ //
160
+ // 上面整段 owner 门挂在 `ownerOf && register` 上,而 core 的**默认内存店 `TtlSessionStore`
161
+ // 这两个方法都没有**。能力缺席时,旧码不是「拒绝」而是让门**整条消失** —— caller 自报的
162
+ // sessionId 被原样受理,于是多租部署里 bob 直接续聊 alice 的会话。
163
+ //
164
+ // 这也解释了 cli 观察到的 GET/POST 不对称:GET 走 `routes/sessions.ts` 的
165
+ // `deps.sessionStorage.ownerOf`(durable session 面,**另一个对象**)⇒ 判别在场 ⇒ 404;
166
+ // POST 走这里的 runner sessionStore ⇒ 门蒸发。也解释了为什么按路由 diff 定位不到:
167
+ // **路由代码没变**,变的是装配里塞进来的 store 实现有没有这两个方法。
168
+ //
169
+ // 判据改成 fail-closed:多租形下拿不到归属判别能力,就不能允许复用一个 caller 自报的
170
+ // sessionId(拒绝的是「复用」,不是「提交」—— 不带 sessionId 的新会话照常受理)。
171
+ // 单租/dev 形(requirePrincipal=false)零行为变化:那里没有租户边界可越。
172
+ throw new HttpError(403, "session reuse needs an owner-aware session store on a multi-tenant deployment: this worker's session store cannot attest who owns a session, so a caller-supplied sessionId cannot be authorized. Configure a durable session backend (SESSION_BACKEND), or omit sessionId to start a new session.");
173
+ }
143
174
  sessionId = requested;
144
175
  }
145
176
  else {
@@ -418,7 +418,7 @@ export class ToolApprovalCoordinator {
418
418
  // PAIR-REVIEW [1392]②:ack 带 rememberApplied 诚实回显——allow_session 且**真记了**(sessionId 在,
419
419
  // grant 落桥内店)= true;allow_session 但无 sessionId(adhoc 无会话,记不了)= false(壳不得渲染
420
420
  // 「本 session 全放行」);普通 allow/deny 不涉 remember = 字段省略。durable decide 腿的同名回显
421
- // (server.ts:4326)语义对齐。
421
+ // (`routes/approvals-assistant.ts` 的 `rememberApplied`)语义对齐。
422
422
  let rememberApplied;
423
423
  if (parsed.value === "allow_session")
424
424
  rememberApplied = Boolean(entry.sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "3.14.0",
3
+ "version": "3.15.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",