@sema-agent/server 7.34.0 → 7.35.0-rc.1

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 (93) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +1 -1
  3. package/dist/boot/coordinators.d.ts +3 -0
  4. package/dist/boot/coordinators.js +3 -1
  5. package/dist/boot/resolve-spec.js +1 -1
  6. package/dist/boot/stores.js +1 -1
  7. package/dist/config.js +2 -2
  8. package/dist/http/routes/admin-drain.d.ts +22 -0
  9. package/dist/http/routes/admin-drain.js +44 -0
  10. package/dist/http/routes/runs.js +2 -2
  11. package/dist/http/routes/sessions-list.js +2 -2
  12. package/dist/http/routes/sessions.js +4 -4
  13. package/dist/http/routes/tasks.js +2 -2
  14. package/dist/http/routes/trace-usage.js +2 -2
  15. package/dist/http/server.d.ts +1 -0
  16. package/dist/http/server.js +12 -2
  17. package/dist/main.js +8 -2
  18. package/dist/observability/fail-open.d.ts +4 -0
  19. package/dist/observability/fail-open.js +4 -0
  20. package/dist/plugins/session-store.js +1 -1
  21. package/dist/task-cwd.d.ts +18 -0
  22. package/dist/task-cwd.js +27 -0
  23. package/dist/tool-approval.d.ts +8 -0
  24. package/dist/tool-approval.js +16 -2
  25. package/package.json +1 -1
  26. package/dist/boot/lexical-path-env.d.ts +0 -10
  27. package/dist/boot/lexical-path-env.js +0 -88
  28. package/dist/capabilities/oa-tools.d.ts +0 -15
  29. package/dist/capabilities/oa-tools.js +0 -54
  30. package/dist/finance/cost-taxonomy.d.ts +0 -34
  31. package/dist/finance/cost-taxonomy.js +0 -26
  32. package/dist/plugins/approval-store-sql.d.ts +0 -116
  33. package/dist/plugins/approval-store-sql.js +0 -151
  34. package/dist/plugins/file-workflow-journal-store.d.ts +0 -12
  35. package/dist/plugins/file-workflow-journal-store.js +0 -12
  36. package/dist/plugins/pg-approval-store.d.ts +0 -9
  37. package/dist/plugins/pg-approval-store.js +0 -9
  38. package/dist/plugins/pg-breaker-state.d.ts +0 -8
  39. package/dist/plugins/pg-breaker-state.js +0 -8
  40. package/dist/plugins/pg-checkpoint-store.d.ts +0 -10
  41. package/dist/plugins/pg-checkpoint-store.js +0 -10
  42. package/dist/plugins/pg-file-snapshot-store.d.ts +0 -8
  43. package/dist/plugins/pg-file-snapshot-store.js +0 -8
  44. package/dist/plugins/pg-image-bake.d.ts +0 -12
  45. package/dist/plugins/pg-image-bake.js +0 -11
  46. package/dist/plugins/pg-image-index.d.ts +0 -12
  47. package/dist/plugins/pg-image-index.js +0 -11
  48. package/dist/plugins/pg-outcome-ledger.d.ts +0 -12
  49. package/dist/plugins/pg-outcome-ledger.js +0 -11
  50. package/dist/plugins/pg-resume-anchor-store.d.ts +0 -7
  51. package/dist/plugins/pg-resume-anchor-store.js +0 -7
  52. package/dist/plugins/pg-run-store.d.ts +0 -9
  53. package/dist/plugins/pg-run-store.js +0 -9
  54. package/dist/plugins/pg-session-policy-store.d.ts +0 -7
  55. package/dist/plugins/pg-session-policy-store.js +0 -7
  56. package/dist/plugins/pg-session-store.d.ts +0 -12
  57. package/dist/plugins/pg-session-store.js +0 -12
  58. package/dist/plugins/pg-tool-result-store.d.ts +0 -9
  59. package/dist/plugins/pg-tool-result-store.js +0 -9
  60. package/dist/plugins/pg-workflow-journal-store.d.ts +0 -9
  61. package/dist/plugins/pg-workflow-journal-store.js +0 -9
  62. package/dist/plugins/pg-workflow-run-store.d.ts +0 -9
  63. package/dist/plugins/pg-workflow-run-store.js +0 -9
  64. package/dist/plugins/tidb-approval-store.d.ts +0 -8
  65. package/dist/plugins/tidb-approval-store.js +0 -8
  66. package/dist/plugins/tidb-breaker-state.d.ts +0 -7
  67. package/dist/plugins/tidb-breaker-state.js +0 -7
  68. package/dist/plugins/tidb-checkpoint-store.d.ts +0 -9
  69. package/dist/plugins/tidb-checkpoint-store.js +0 -9
  70. package/dist/plugins/tidb-file-snapshot-store.d.ts +0 -8
  71. package/dist/plugins/tidb-file-snapshot-store.js +0 -8
  72. package/dist/plugins/tidb-image-bake.d.ts +0 -12
  73. package/dist/plugins/tidb-image-bake.js +0 -11
  74. package/dist/plugins/tidb-image-index.d.ts +0 -12
  75. package/dist/plugins/tidb-image-index.js +0 -11
  76. package/dist/plugins/tidb-outcome-ledger.d.ts +0 -12
  77. package/dist/plugins/tidb-outcome-ledger.js +0 -12
  78. package/dist/plugins/tidb-resume-anchor-store.d.ts +0 -7
  79. package/dist/plugins/tidb-resume-anchor-store.js +0 -7
  80. package/dist/plugins/tidb-run-store.d.ts +0 -10
  81. package/dist/plugins/tidb-run-store.js +0 -9
  82. package/dist/plugins/tidb-session-policy-store.d.ts +0 -7
  83. package/dist/plugins/tidb-session-policy-store.js +0 -7
  84. package/dist/plugins/tidb-tool-result-store.d.ts +0 -8
  85. package/dist/plugins/tidb-tool-result-store.js +0 -10
  86. package/dist/plugins/tidb-workflow-journal-store.d.ts +0 -9
  87. package/dist/plugins/tidb-workflow-journal-store.js +0 -9
  88. package/dist/plugins/tidb-workflow-run-store.d.ts +0 -10
  89. package/dist/plugins/tidb-workflow-run-store.js +0 -10
  90. package/dist/plugins/workflow-journal-limits.d.ts +0 -12
  91. package/dist/plugins/workflow-journal-limits.js +0 -12
  92. package/dist/sema-registry.d.ts +0 -41
  93. package/dist/sema-registry.js +0 -40
package/README.md CHANGED
@@ -150,8 +150,8 @@ The server is configured entirely through environment variables. The most import
150
150
  | `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 |
151
151
  | `MODEL_API_KEY` | — | Gateway API key (optional) |
152
152
  | `SERVICE_AUTH_TOKEN` | — | Callers must send `Authorization: Bearer <token>` |
153
- | `DB_BACKEND` | `local`* | `mysql` (any MySQL-protocol DB: MySQL/TiDB/MariaDB; `tidb` alias) / `pg` (PostgreSQL) / `local` (file-backed, no DB) / `memory` (explicit in-memory: nothing survives a restart, durable-runs faces 501). *Bare boot (no DB env at all) defaults to `local` so a single-user machine keeps its runs across restarts; any SQL signal (`SESSION_BACKEND` or `TIDB_/MYSQL_/PG_HOST`) keeps the `mysql` engine default, and `REQUIRE_PRINCIPAL=true` bare boots stay `memory` (the local file store has no tenant isolation — a warning says so). A DEFAULT-derived `local` that cannot create its data root degrades to memory with a warning + the `store_backend_degraded` gauge; an EXPLICIT `DB_BACKEND=local` fails loud instead. Setting `mysql`/`pg` explicitly also switches sessions to durable |
154
- | `SESSION_BACKEND` | `memory`* | `memory` / `mysql` (durable session center; `tidb` alias) / `auto`. *Defaults to durable when `DB_BACKEND` is explicitly `mysql`/`pg` |
153
+ | `DB_BACKEND` | `local`* | `mysql` (any MySQL-protocol DB: MySQL/TiDB/MariaDB) / `pg` (PostgreSQL) / `local` (file-backed, no DB) / `memory` (explicit in-memory: nothing survives a restart, durable-runs faces 501). *Bare boot (no DB env at all) defaults to `local` so a single-user machine keeps its runs across restarts; any SQL signal (`SESSION_BACKEND` or `TIDB_/MYSQL_/PG_HOST`) keeps the `mysql` engine default, and `REQUIRE_PRINCIPAL=true` bare boots stay `memory` (the local file store has no tenant isolation — a warning says so). A DEFAULT-derived `local` that cannot create its data root degrades to memory with a warning + the `store_backend_degraded` gauge; an EXPLICIT `DB_BACKEND=local` fails loud instead. Setting `mysql`/`pg` explicitly also switches sessions to durable |
154
+ | `SESSION_BACKEND` | `memory`* | `memory` / `mysql` (durable session center) / `auto`. *Defaults to durable when `DB_BACKEND` is explicitly `mysql`/`pg` |
155
155
  | `REMOTE_EXEC` | unset | Sandbox execution lane: `host` / `local-docker` / `e2b` / `k8s` / `ssh` / `adb`; unset = in-process stub (with `CONFIG_PROVIDER=local` the default becomes `host`). Naming a lane without its required env (e.g. `e2b` without `E2B_API_KEY`) or an unrecognized value **refuses to start** — no silent downgrade to the host/in-process lane (#157 fail-closed) |
156
156
  | `CONFIG_PROVIDER` | unset | Config source: `local` (file-backed `config.d/`, single machine) / `remote` (registry control plane) |
157
157
  | `DEFAULT_SCENARIO` | `code` | Default scenario when the request body names none |
package/README.zh-CN.md CHANGED
@@ -140,7 +140,7 @@ curl -s localhost:8090/v1/tasks -H "Authorization: Bearer <SERVICE_AUTH_TOKEN>"
140
140
  | `MODEL_API_KEY` | — | 网关 key(可选) |
141
141
  | `SERVICE_AUTH_TOKEN` | — | 调用方需带 `Authorization: Bearer <token>` |
142
142
  | `DB_BACKEND` | `mysql` | SQL 引擎:`mysql`(任何 MySQL 协议库:MySQL/TiDB/MariaDB;`tidb` 为兼容别名)/ `pg`(PostgreSQL)/ `local`(免 DB 文件持久化)。显式设置 `mysql`/`pg` 时 session 自动转 durable |
143
- | `SESSION_BACKEND` | `memory`* | `memory` / `mysql`(durable 会话中心;`tidb` 别名)/ `auto`。*显式 `DB_BACKEND=mysql/pg` 时默认转 durable |
143
+ | `SESSION_BACKEND` | `memory`* | `memory` / `mysql`(durable 会话中心)/ `auto`。*显式 `DB_BACKEND=mysql/pg` 时默认转 durable |
144
144
  | `REMOTE_EXEC` | 未设 | 沙箱执行通道:`host` / `local-docker` / `e2b` / `k8s` / `ssh` / `adb`;未设 = 进程内 stub(`CONFIG_PROVIDER=local` 时缺省转 `host`)。点名了通道但必需 env 不全(如 `e2b` 缺 `E2B_API_KEY`)或值不在闭集内 ⇒ **启动即拒**——不再静默降级到 host/进程内通道(#157 fail-closed) |
145
145
  | `CONFIG_PROVIDER` | 未设 | 配置来源:`local`(单机文件 `config.d/`)/ `remote`(registry 控制面) |
146
146
  | `DEFAULT_SCENARIO` | `code` | 请求体未指定场景时的缺省场景 |
@@ -24,6 +24,9 @@ export interface LiveCoordinatorsCtx {
24
24
  /** #154 车二:持久化权限规则的同意车道(main.ts 与 `RunnerDeps.permissionRuleStore` **同源**于
25
25
  * `backend.permissionRule()` 的同一个返回值)。在场 ⇒ ask 帧投 `ruleSuggestions` + 回决可兑付。 */
26
26
  ruleConsent: RuleConsentLane | undefined;
27
+ /** #295(F-1):卡批规则的 project root 解析器(语义单点=task-cwd.ts `cardRuleScopeRoot`;main.ts
28
+ * 用 per-session cwd 登记簿 + config 铸)。缺席 ⇒ 不铸 scope,卡批规则落 core 的 global 缺省。 */
29
+ ruleScopeRootFor: ((sessionId: string | undefined) => string | undefined) | undefined;
27
30
  }
28
31
  export declare function createLiveCoordinators(ctx: LiveCoordinatorsCtx): {
29
32
  elicitation: ElicitationCoordinator | undefined;
@@ -8,7 +8,7 @@ import { withLedgerRecording } from "../plugins/send-file-ledger.js";
8
8
  import { basename, resolve } from "node:path";
9
9
  import { stat as fsStat, readFile as fsReadFile } from "node:fs/promises";
10
10
  export function createLiveCoordinators(ctx) {
11
- const { config, logger, backend, sendUserFileTaskEnvs, ruleConsent } = ctx;
11
+ const { config, logger, backend, sendUserFileTaskEnvs, ruleConsent, ruleScopeRootFor } = ctx;
12
12
  // E23 (shell-host contract): inbound MCP elicitation coordinator (live-only HITL). Present ONLY when MCP_ELICITATION_ENABLED
13
13
  // — absent ⇒ onElicit is not wired ⇒ core advertises no elicitation capability to any server (fail-closed). Shared
14
14
  // by the runner (the onElicit seam) and the HTTP layer (the respond route + the per-run ALS context wraps).
@@ -74,6 +74,8 @@ export function createLiveCoordinators(ctx) {
74
74
  admitMaxPerOwner: config.streamApproval.admitMaxPerOwner,
75
75
  // #154 车二:同意车道(缺席 = 规则店没装配 ⇒ 帧上零候选、回决的 persistRule 如实拒)。
76
76
  ...(ruleConsent !== undefined ? { ruleConsent } : {}),
77
+ // #295(F-1):卡批规则 project root 解析器(缺席 ⇒ 落 global,修前行为)。
78
+ ...(ruleScopeRootFor !== undefined ? { ruleScopeRootFor } : {}),
77
79
  // #280 R-13 C:无人值守政策(闭集 park|deny)。**恒传**(不挂任何在场性推断):它是纯部署级的一位,
78
80
  // 语义/三问/射程边界一处成文见 `config-types.ts` 的 `unattendedApprovalPolicy` 域注。
79
81
  // ⚠️ 恒传是**刻意**的,不是顺手(codex R2-F2 点过这一行):`streamApprovalGate.active` 为假时只
@@ -198,7 +198,7 @@ export function createResolveSpec(ctx) {
198
198
  throw new HttpError(400, "`attachmentIds` exceeds the per-task limit (16)");
199
199
  if (reqAtt.length > 0) {
200
200
  if (!taskAttachmentStore)
201
- throw new HttpError(501, "attachmentIds require a store backend (DB_BACKEND=tidb|pg|local)");
201
+ throw new HttpError(501, "attachmentIds require a store backend (DB_BACKEND=mysql|pg|local)");
202
202
  if (!auth?.sessionId)
203
203
  throw new HttpError(400, "`attachmentIds` requires a session-resolving deployment (no authorizer session)");
204
204
  const sid = auth.sessionId;
@@ -193,7 +193,7 @@ export async function openStores(ctx) {
193
193
  else {
194
194
  const pool = backend?.mysqlPool();
195
195
  if (!pool)
196
- throw new Error("MEMORY_ENGINE_BACKEND=tidb requires DB_BACKEND=mysql/tidb — refusing to start half-configured");
196
+ throw new Error("MEMORY_ENGINE_BACKEND=tidb requires DB_BACKEND=mysql — refusing to start half-configured");
197
197
  await ensureTiDBMemoryEngineSchema(pool);
198
198
  await ensureTiDBMemoryHistorySchema(pool);
199
199
  await ensureTiDBMemorySyncSchema(pool); // 142-S2.5: sync_cursors(+push_queue)同池 ensure,幂等
package/dist/config.js CHANGED
@@ -480,7 +480,7 @@ export const configLkgEnabled = () => boolEnvWithLegacyNegated("CONFIG_LKG_ENABL
480
480
  export const hostBackgroundShellEnabled = () => boolEnvWithLegacyNegated("HOST_BG_ENABLED", "HOST_BG_DISABLED", true);
481
481
  /** The `host` exec lane's spool-file stdio form; false ⇒ the pre-1.226 pipe fallback (read per exec). */
482
482
  export const hostExecSpoolEnabled = () => boolEnvWithLegacyNegated("HOST_EXEC_SPOOL_ENABLED", "HOST_EXEC_SPOOL_DISABLED", true);
483
- /** Enum env with validation: a typo (e.g. SESSION_BACKEND=Tidb) FAILS at startup instead of being silently
483
+ /** Enum env with validation: a typo (e.g. SESSION_BACKEND misspelled as `Tidb`) FAILS at startup instead of being silently
484
484
  * cast to the union type and degrading to the default backend (dropping the persistence the operator asked for). */
485
485
  /** #246:READ_DENY_PATTERNS 的 env 形=JSON 数组,元素为 `"pattern"` 裸字符串简写或 {pattern, caseSensitive?}
486
486
  * (与 core `ReadDenyEntry = string | {…}` 词表同宽——合并码扫 C6:USAGE 承诺了简写而 parser 只收对象形,
@@ -672,7 +672,7 @@ function parseStoreDomain(ctx) {
672
672
  },
673
673
  });
674
674
  }
675
- // Asymmetry收编 (clay 2026-07-06, overseas-pilot pit #1): an EXPLICIT `DB_BACKEND=mysql/tidb/pg` is the operator
675
+ // Asymmetry收编 (clay 2026-07-06, overseas-pilot pit #1): an EXPLICIT `DB_BACKEND=mysql/pg` is the operator
676
676
  // asking for a durable SQL deployment — default sessions onto it (fail-fast, same as an explicit
677
677
  // SESSION_BACKEND=mysql) instead of silently keeping the in-memory default ("DB_BACKEND=pg engaged nothing,
678
678
  // session:memory, PG 0 tables"). DB_BACKEND=local keeps its own coercion in main.ts (it needs the built
@@ -0,0 +1,22 @@
1
+ /**
2
+ * #291(C17,[4470]/[4472] 认领,黑板 [4472] 原文即本口的契约):`POST /v1/admin/drain {reason}` ——
3
+ * 编排壳在发停机信号(SIGTERM)**之前**先调本口显式声明停机因由;server 记入 `drainState.reason`,
4
+ * draining 期间由 `/health`(`drainReason`)与 503 draining 体(`reason`)透出。未声明 = 两面键缺席
5
+ * (additive,不编数)——升级/缩容/驱逐在消费端因此可判别,而不是一律读成「实例没了」。
6
+ *
7
+ * 族属 = adoption / retention-ops 的 operator lane(同一条 `explicitOperatorOk` 门、billable=false、
8
+ * **刻意不吃 drain 503**——它正是 drain 开始前要调的口,吃了就自锁)。门序照 retention-ops 逐字:
9
+ * 身份 → 授权 → 验型。刻意**不进** `isCredentialGatedRewrite` 名单:那族的判据是「授权唯一输入是
10
+ * service token ∧ 爆炸半径跨租户持久改写」,本口只写一个进程内观测字段(重启即逝、零持久面),
11
+ * operator 门(空表恒拒,fail-closed)已是正确强度。
12
+ *
13
+ * reason 校验:非空 trim 后 string ≤ {@link MAX_DRAIN_REASON_CHARS} —— 它会进 /health 与 503 体
14
+ * (消费面是监控/壳),超长或怪型响亮拒([3425] 坏值立律),拒绝不留痕(不写半个值)。
15
+ */
16
+ import type { IncomingMessage, ServerResponse } from "node:http";
17
+ import type { RouteCtx } from "../route-ctx.js";
18
+ export declare const ADMIN_DRAIN_PATH = "/v1/admin/drain";
19
+ /** 上限与 approvals 的 reason 族同量级(消费面同为人读短句;非同一常量——两面语义无关,独立演化)。 */
20
+ export declare const MAX_DRAIN_REASON_CHARS = 256;
21
+ export declare function handleAdminDrain(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
22
+ //# sourceMappingURL=admin-drain.d.ts.map
@@ -0,0 +1,44 @@
1
+ import { sendJson, sendError } from "../send.js";
2
+ import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
3
+ export const ADMIN_DRAIN_PATH = "/v1/admin/drain";
4
+ /** 上限与 approvals 的 reason 族同量级(消费面同为人读短句;非同一常量——两面语义无关,独立演化)。 */
5
+ export const MAX_DRAIN_REASON_CHARS = 256;
6
+ const OPERATOR_ONLY_MESSAGE = "drain declaration is operator-only: the caller principal is not in OPERATOR_PRINCIPALS (an empty list means NO one is an operator — configure it to enable this endpoint)";
7
+ export async function handleAdminDrain(req, res, url, ctx) {
8
+ const miss = { fell: false };
9
+ await handleAdminDrainBody(req, res, url, ctx, miss);
10
+ return !miss.fell;
11
+ }
12
+ async function handleAdminDrainBody(req, res, url, ctx, miss) {
13
+ const { deps } = ctx;
14
+ const path = url.split("?")[0] ?? url;
15
+ if (req.method !== "POST" || path !== ADMIN_DRAIN_PATH) {
16
+ miss.fell = true;
17
+ return;
18
+ }
19
+ // 门序 = operator 面的既有口径(routes/retention-ops.ts 逐字):身份 → 授权 → 验型。
20
+ const principal = gatedPrincipal(req, deps.config);
21
+ if (deps.config.requirePrincipal && !principal) {
22
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
23
+ return;
24
+ }
25
+ if (!explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
26
+ sendError(res, 403, "auth.operator_only", OPERATOR_ONLY_MESSAGE);
27
+ return;
28
+ }
29
+ const body = (await ctx.helpers.readJson(req));
30
+ const reason = body?.reason;
31
+ if (typeof reason !== "string" || reason.trim().length === 0 || reason.length > MAX_DRAIN_REASON_CHARS) {
32
+ sendError(res, 400, "request.field_invalid", `body must be { reason: string } — a non-empty reason of at most ${MAX_DRAIN_REASON_CHARS} characters (it is surfaced verbatim on /health and the 503 draining body)`);
33
+ return;
34
+ }
35
+ if (!deps.drainState) {
36
+ // 无 drain 面的装配(嵌入/测试宿主)上声明无处可落——诚实 501,不吞成 200(声明了却不透出
37
+ // 比不能声明更糟:壳以为消费端能看到因由,实际两面永远缺席)。
38
+ sendError(res, 501, "capability.drain_state_required", "this deployment has no drain state wired (embedded host?) — the declared reason would have nowhere to surface");
39
+ return;
40
+ }
41
+ deps.drainState.reason = reason.trim();
42
+ sendJson(res, 200, { ok: true });
43
+ }
44
+ //# sourceMappingURL=admin-drain.js.map
@@ -273,7 +273,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
273
273
  // Async run: create + return ids immediately, execute in the background.
274
274
  if (req.method === "POST" && url === "/v1/runs") {
275
275
  if (!deps.runStore) {
276
- sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg) (SESSION_BACKEND=tidb)");
276
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg|local)");
277
277
  return;
278
278
  }
279
279
  // Idempotency-Key dedup (center blocker): a retried create returns the SAME taskId instead of starting a
@@ -1999,7 +1999,7 @@ idemKey) {
1999
1999
  const source = ctx.req.source;
2000
2000
  if (!deps.runStore) {
2001
2001
  // 防御性复检:两个调用方都在自己的入口先判过(各自面上的正确错误形不同),走到这里说明有人漏判。
2002
- sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg) (SESSION_BACKEND=tidb)");
2002
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg|local)");
2003
2003
  return null;
2004
2004
  }
2005
2005
  const runStore = deps.runStore;
@@ -27,7 +27,7 @@ async function handleSessionsListBody(req, res, url, ctx, miss) {
27
27
  }
28
28
  const lister = deps.sessionStorage?.listSessions?.bind(deps.sessionStorage) ?? deps.runStore?.listSessions?.bind(deps.runStore);
29
29
  if (!lister) {
30
- sendError(res, 501, "capability.session_store_required", "session list requires a durable session store (SESSION_BACKEND=tidb)");
30
+ sendError(res, 501, "capability.session_store_required", "session list requires a durable session store (DB_BACKEND=mysql|pg)");
31
31
  return;
32
32
  }
33
33
  // [4198] 窄互认第二臂(列表面)—— 条件①③ 在此判,条件②(`owner === null`)由 store 的
@@ -57,7 +57,7 @@ async function handleSessionsListBody(req, res, url, ctx, miss) {
57
57
  return;
58
58
  }
59
59
  if (!deps.sendFileLedger) {
60
- sendError(res, 501, "capability.store_required", "sendfile ledger requires a store backend (DB_BACKEND=tidb|pg|local) and the SendUserFile issuer configured");
60
+ sendError(res, 501, "capability.store_required", "sendfile ledger requires a store backend (DB_BACKEND=mysql|pg|local) and the SendUserFile issuer configured");
61
61
  return;
62
62
  }
63
63
  // F6 (codex audit): STRICT query validation — a malformed limit used to silently degrade to the default
@@ -257,7 +257,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
257
257
  const getLeafId = deps.sessionStorage?.getLeafId?.bind(deps.sessionStorage);
258
258
  const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
259
259
  if (!getLeafId || !ownerOf) {
260
- sendError(res, 501, "capability.session_store_required", "session head probe requires a durable session store (SESSION_BACKEND=tidb|pg|local)");
260
+ sendError(res, 501, "capability.session_store_required", "session head probe requires a durable session store (DB_BACKEND=mysql|pg)");
261
261
  return;
262
262
  }
263
263
  const headSession = decodeURIComponent(hm[1]);
@@ -310,7 +310,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
310
310
  const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
311
311
  const watch = deps.sessionWatch;
312
312
  if (!deps.sessionStorage?.getLeafId || !ownerOf || !watch) {
313
- sendError(res, 501, "capability.session_store_required", "session events require a durable session store (SESSION_BACKEND=tidb|pg|local)");
313
+ sendError(res, 501, "capability.session_store_required", "session events require a durable session store (DB_BACKEND=mysql|pg)");
314
314
  return;
315
315
  }
316
316
  const evSession = decodeURIComponent(em[1]);
@@ -579,7 +579,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
579
579
  const fork = deps.sessionStorage?.fork?.bind(deps.sessionStorage);
580
580
  const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
581
581
  if (!fork || !ownerOf) {
582
- sendError(res, 501, "capability.session_store_required", "session fork requires a durable session store (SESSION_BACKEND=tidb)");
582
+ sendError(res, 501, "capability.session_store_required", "session fork requires a durable session store (DB_BACKEND=mysql|pg)");
583
583
  return;
584
584
  }
585
585
  const scope = sessionOwnerScopeForWrite(req);
@@ -638,7 +638,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
638
638
  const purge = deps.purgeSession;
639
639
  const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
640
640
  if (!purge || !ownerOf) {
641
- sendError(res, 501, "capability.session_store_required", "session delete requires a durable session store (SESSION_BACKEND=tidb)");
641
+ sendError(res, 501, "capability.session_store_required", "session delete requires a durable session store (DB_BACKEND=mysql|pg)");
642
642
  return;
643
643
  }
644
644
  const scope = sessionOwnerScopeForWrite(req);
@@ -354,7 +354,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
354
354
  // result.taskId re-stamp) is gated on `runStore` ALONE; the durable-suspend resume ctx (`putCtx`) on
355
355
  // `checkpointStore`. They were bundled under ONE checkpointStore gate, over-coupling REWIND to
356
356
  // DURABLE_APPROVAL — but E18 resume-at needs NO checkpoint (main.ts:815). Now matches the /v1/runs bg path:
357
- // rewind works with just runStore (DB_BACKEND=tidb/pg), no approval. Claim-first preserved (session locked
357
+ // rewind works with just runStore (DB_BACKEND=mysql|pg|local), no approval. Claim-first preserved (session locked
358
358
  // submit→resume; a concurrent submit → 409, never a silent 2nd leg, and a rejected submit never clobbers the ctx).
359
359
  if (deps.runStore && prepared.spec.sessionId) {
360
360
  const tid = earlyDurableTid ?? uuidv7(); // reuse the id already emitted as the X-Task-Id header (defensive mint if absent)
@@ -1301,7 +1301,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1301
1301
  // re-stamp) is gated on `runStore` ALONE; the durable-suspend resume ctx (`putCtx`) on `checkpointStore`. These
1302
1302
  // were bundled under ONE checkpointStore gate, which over-coupled REWIND to DURABLE_APPROVAL — but E18 resume-at
1303
1303
  // needs NO checkpoint (main.ts:815). Now matches the already-decoupled /v1/runs bg path: rewind works with just
1304
- // runStore (DB_BACKEND=tidb/pg), no approval. Claim-first preserved (a lost claim → 409, never a silent 2nd leg).
1304
+ // runStore (DB_BACKEND=mysql|pg|local), no approval. Claim-first preserved (a lost claim → 409, never a silent 2nd leg).
1305
1305
  if (deps.runStore && prepared.spec.sessionId) {
1306
1306
  const tid = uuidv7();
1307
1307
  const created = await deps.runStore.createRun(tid, prepared.spec.sessionId, principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
@@ -52,7 +52,7 @@ async function handleTraceUsageBody(req, res, url, ctx, miss) {
52
52
  return;
53
53
  }
54
54
  if (!deps.runStore) {
55
- sendError(res, 501, "capability.run_store_required", "trace API requires a durable run store (DB_BACKEND=mysql|pg) (SESSION_BACKEND=tidb)");
55
+ sendError(res, 501, "capability.run_store_required", "trace API requires a durable run store (DB_BACKEND=mysql|pg|local)");
56
56
  return;
57
57
  }
58
58
  const q = new URL(req.url ?? "", "http://x").searchParams;
@@ -72,7 +72,7 @@ async function handleTraceUsageBody(req, res, url, ctx, miss) {
72
72
  return;
73
73
  }
74
74
  if (!deps.runStore) {
75
- sendError(res, 501, "capability.run_store_required", "trace API requires a durable run store (DB_BACKEND=mysql|pg) (SESSION_BACKEND=tidb)");
75
+ sendError(res, 501, "capability.run_store_required", "trace API requires a durable run store (DB_BACKEND=mysql|pg|local)");
76
76
  return;
77
77
  }
78
78
  const query = new URL(req.url ?? "", "http://x").searchParams;
@@ -430,6 +430,7 @@ export interface ServiceDeploymentDeps {
430
430
  drainState?: {
431
431
  draining: boolean;
432
432
  since?: number;
433
+ reason?: string;
433
434
  inflight?: () => number;
434
435
  lastActivityAt?: () => number;
435
436
  };
@@ -40,6 +40,7 @@ import { handleObservability } from "./routes/observability.js";
40
40
  import { handleDiagnostics } from "./routes/diagnostics.js";
41
41
  import { handleAdoption } from "./routes/adoption.js";
42
42
  import { handleRetention } from "./routes/retention-ops.js";
43
+ import { handleAdminDrain } from "./routes/admin-drain.js";
43
44
  import { handleMemoryPolicy } from "./routes/memory-policy.js";
44
45
  import { handleMemoryBundle } from "./routes/memory-bundle.js";
45
46
  import { handleSharedMemory } from "./routes/shared-memory.js";
@@ -213,6 +214,7 @@ const ROUTE_DOMAINS = [
213
214
  handleDiagnostics,
214
215
  handleAdoption,
215
216
  handleRetention,
217
+ handleAdminDrain,
216
218
  handleMemoryPolicy,
217
219
  handleMemoryBundle,
218
220
  handleRules,
@@ -454,7 +456,8 @@ export function createHttpServer(rawDeps) {
454
456
  // shell/orchestrator (same source as GET /v1/capabilities.version; absent only if capabilities wiring is).
455
457
  ...(typeof deps.capabilities?.version === "string" ? { version: deps.capabilities.version } : {}),
456
458
  // additive draining flag — k8s readiness / shell Lifecycle Manager摘流 signal. Absent when not draining.
457
- ...(deps.drainState?.draining ? { draining: true, ...(deps.drainState.since ? { drainingSince: deps.drainState.since } : {}) } : {}),
459
+ // #291:drainReason = POST /v1/admin/drain 声明的停机因由(additive,未声明=键缺席)
460
+ ...(deps.drainState?.draining ? { draining: true, ...(deps.drainState.since ? { drainingSince: deps.drainState.since } : {}), ...(deps.drainState.reason ? { drainReason: deps.drainState.reason } : {}) } : {}),
458
461
  // S5: additive degradation flag — present ONLY when the boot auto-probe fell back to in-memory, so a
459
462
  // healthy worker's /health shape is unchanged. durable:false = "this replica is NOT persisting although
460
463
  // a DB was configured" (the orchestrator's restart/alarm signal; an in-memory-by-choice worker omits it).
@@ -629,7 +632,8 @@ export function createHttpServer(rawDeps) {
629
632
  // errorCode 补齐(2026-07-28 核查:cli 的 pre-stream drain 重试只能靠 `error` 文案字面判型——
630
633
  // SDK 的 code 臂恒 undefined 是死码;server 改文案=对端重试静默失效)。`error:"draining"` 字面
631
634
  // 自此视为**冻结的 wire 契约**(旧消费端判的就是它),errorCode 是机器码正门。
632
- sendError(res, 503, "draining", "draining", { message: "this instance is draining for shutdown/upgrade — retry against the replacement instance" });
635
+ // #291:`reason` = 声明的停机因由(additive 键;`message` 字面冻结不动——SDK/壳按 error:"draining" )
636
+ sendError(res, 503, "draining", "draining", { message: "this instance is draining for shutdown/upgrade — retry against the replacement instance", ...(deps.drainState.reason ? { reason: deps.drainState.reason } : {}) });
633
637
  return;
634
638
  }
635
639
  // boot ready 门(b)(E3 workers.model 废除的 fail-closed 半场):registry 部署且无显式 env 模型时,
@@ -703,6 +707,10 @@ export function createHttpServer(rawDeps) {
703
707
  // 同样 billable=false 且刻意不吃 drain / model-roster-pending 两道 503)。
704
708
  if (await handleRetention(req, res, url, ctx))
705
709
  return;
710
+ // #291(C17):停机因由声明口(routes/admin-drain.ts,operator lane;POST /v1/admin/drain)。
711
+ // 同族(operator 门、billable=false、不吃 drain 503——它正是 drain 前要调的口),位置跟着 retention。
712
+ if (await handleAdminDrain(req, res, url, ctx))
713
+ return;
706
714
  // design/158 A9:memory 导出/同步 + /v1/policy 只读面(routes/memory-policy.ts)。
707
715
  if (await handleMemoryPolicy(req, res, url, ctx))
708
716
  return;
@@ -3386,6 +3394,8 @@ const ROUTE_LABEL_LITERALS = new Set([
3386
3394
  // design/183:收编发起口(operator-only,一次性部署级动作)。低频、但它的 duration 与失败率是
3387
3395
  // 迁移现场唯一的机器信号,落进 `other` 桶就看不见了。
3388
3396
  "/v1/adoption",
3397
+ // #291:停机因由声明口(operator-only,drain 前一次性动作)。同 adoption 判:低频但只在停机现场被打。
3398
+ "/v1/admin/drain",
3389
3399
  // #154 车二:CC settings 导入两口。字面量(无 id 段)⇒ 进 LITERALS 表而不是模式表。
3390
3400
  // 🔴 写**字面量**而不是引 `RULES_CC_IMPORT_*_PATH` 常量:名册门与 billable 申明门都是**扫源码文本**
3391
3401
  // 的(它们正则抓本表里的双引号词),引常量会让两条新路由对两道门隐形 —— 那不是「过了门」,是绕过门。
package/dist/main.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, realpathSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { Runner, InMemoryToolResultStore, TtlSessionStore, uuidv7, defaultTaskRegistry, createAllowDenyPolicy, workflowsCapability, createWebFetchSummarizer, resolveTaskModel as coreResolveTaskModel, probeSearchBackend, describeStaticWiring } from "@sema-agent/core";
@@ -8,6 +8,7 @@ import { selectEnvironmentTool } from "./capabilities/select-environment-tool.js
8
8
  import { subagentSendUserFileExtraTools } from "./capabilities/send-user-file-tool.js";
9
9
  import { brainSummary } from "./brain.js";
10
10
  import { loadConfig, logConfigDiagnostics, resolveBindHost, splitLocalRoots, parseCapEnv, numEnv } from "./config.js";
11
+ import { cardRuleScopeRoot } from "./task-cwd.js";
11
12
  import { isPricingConfigured } from "./budget.js"; // 缝合审 M3:capabilities.pricingConfigured 的单一真源判据
12
13
  import { drainNumEnvWarnings } from "./plugins/remote-shell.js";
13
14
  import { ensureChildSessionDurableWithPromotion } from "./plugins/session-store.js";
@@ -241,7 +242,12 @@ async function main() {
241
242
  const { sqlWorkflowRunStore, workflowNotifyJournal, workflowCompletionInbox, deliverWorkflowCompletion, workflowNotifyGate, fleetBus, workflowRunStore, workflowJournalStore, outcomeSink, workflowRecoverOpts, workflowAgentRegistry, subagentSteerRegistry, } = createWorkflowOrchestration({ config, logger, metrics, localRoot, backend, getRunStore: () => runStore });
242
243
  // design/158 A10:活体协调器 + SendUserFile 工具面搬到 src/boot/coordinators.ts(逐字;durableEnabled 的
243
244
  // 「必须早于 runnerDeps 求值」次序契约见该文件头注)。
244
- const { elicitation, question, toolApproval, durableEnabled, streamApprovalGate, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec } = createLiveCoordinators({ config, logger, backend, sendUserFileTaskEnvs, ruleConsent });
245
+ const { elicitation, question, toolApproval, durableEnabled, streamApprovalGate, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec } = createLiveCoordinators({
246
+ config, logger, backend, sendUserFileTaskEnvs, ruleConsent,
247
+ // #295(F-1,[4512] 修向 (b')):卡批「不再询问」的 project root——语义单点见 task-cwd.ts
248
+ // `cardRuleScopeRoot` 顶注(session 注册 cwd ?? in-process 单用户形 process.cwd();其余 undefined)。
249
+ ruleScopeRootFor: (sessionId) => cardRuleScopeRoot(sessionId !== undefined ? perSessionCwd.get(sessionId) : undefined, config, (p) => realpathSync(p)),
250
+ });
245
251
  // design/158 A10:per-principal caps 段搬到 src/boot/runtime-caps.ts(逐字)。
246
252
  const { principalCaps, centerRuntimeCapsResolver, runtimeCapsResolver } = createRuntimeCaps({ config, logger });
247
253
  // design/170 件A(#148 件3③):org 记忆准入装配(目录源三态选择+C12 能力探测,坏配置在此拒启动)。
@@ -53,6 +53,10 @@ export declare const FAIL_OPEN_TAGS: {
53
53
  readonly cls: "F";
54
54
  readonly note: "CC 规则导入票的**认领回滚**失败(认领之后的某一步没成 ⇒ 本该把认领放回去,而这次放回本身也抛了)。放行的最坏后果 = 这张票留在已认领态、属主这一轮不能重试 —— 恰好等于加认领回滚**之前**的行为,不是新损失;票随 TTL 自然消失,属主重走一次 prepare 即可拿新票(导入按 core 的设计幂等:同一条规则再兑付只是同一个 dot 的重放)。方向上没有任何权限被放宽(规则**没有**落地才走到这条臂),故 F 类;必须留痕,否则「票为什么突然不能用了」在遥测里没有任何痕迹。";
55
55
  };
56
+ readonly "server.approvals.rule-scope-root-unresolvable": {
57
+ readonly cls: "F";
58
+ readonly note: "#295(F-1):卡批「不再询问」铸卡时解析授权发生地 root,realpath 失败(目录已删/权限/竞窗)。放行的最坏后果=**这一次**卡批落 global scope(= 7.34.0 及以前的全量行为,方向不比修前宽);不放行的代价=为一个几乎不可达的文件系统瞬态拒掉人已明说的「不再询问」,或铸一个未规范化、与规则判定时 kernel-resolved cwd 不同形的 root(静默恒不命中=把同意变哑)。故 F 类兜底+留痕:遥测里该 tag 计数>0 即说明有部署在这条臂上落了 global,可循 probe 定位。";
59
+ };
56
60
  readonly "server.approvals.rule-suggestions-cell-unreadable": {
57
61
  readonly cls: "F";
58
62
  readonly note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.rule_suggestions` 这一格**不是合法 JSON 文本**(手改过的行 / 未来列语义漂了 / 回滚残留)。放行的最坏后果 = **这一行**的「不再询问」候选缺席(展示/分诊材料,永不参与 resume、也不是任何判据),队列其余部分照常;不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空)。故 F 类,但必须留痕:静默吞掉之后「候选为什么消失了」在遥测里没有任何痕迹,而本键存在的全部理由就是「缺席 = 真的没有候选」。";
@@ -76,6 +76,10 @@ export const FAIL_OPEN_TAGS = {
76
76
  cls: "F",
77
77
  note: "CC 规则导入票的**认领回滚**失败(认领之后的某一步没成 ⇒ 本该把认领放回去,而这次放回本身也抛了)。放行的最坏后果 = 这张票留在已认领态、属主这一轮不能重试 —— 恰好等于加认领回滚**之前**的行为,不是新损失;票随 TTL 自然消失,属主重走一次 prepare 即可拿新票(导入按 core 的设计幂等:同一条规则再兑付只是同一个 dot 的重放)。方向上没有任何权限被放宽(规则**没有**落地才走到这条臂),故 F 类;必须留痕,否则「票为什么突然不能用了」在遥测里没有任何痕迹。",
78
78
  },
79
+ "server.approvals.rule-scope-root-unresolvable": {
80
+ cls: "F",
81
+ note: "#295(F-1):卡批「不再询问」铸卡时解析授权发生地 root,realpath 失败(目录已删/权限/竞窗)。放行的最坏后果=**这一次**卡批落 global scope(= 7.34.0 及以前的全量行为,方向不比修前宽);不放行的代价=为一个几乎不可达的文件系统瞬态拒掉人已明说的「不再询问」,或铸一个未规范化、与规则判定时 kernel-resolved cwd 不同形的 root(静默恒不命中=把同意变哑)。故 F 类兜底+留痕:遥测里该 tag 计数>0 即说明有部署在这条臂上落了 global,可循 probe 定位。",
82
+ },
79
83
  "server.approvals.rule-suggestions-cell-unreadable": {
80
84
  cls: "F",
81
85
  note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.rule_suggestions` 这一格**不是合法 JSON 文本**(手改过的行 / 未来列语义漂了 / 回滚残留)。放行的最坏后果 = **这一行**的「不再询问」候选缺席(展示/分诊材料,永不参与 resume、也不是任何判据),队列其余部分照常;不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空)。故 F 类,但必须留痕:静默吞掉之后「候选为什么消失了」在遥测里没有任何痕迹,而本键存在的全部理由就是「缺席 = 真的没有候选」。",
@@ -12,7 +12,7 @@ import { CachingSessionStore } from "./caching-session-store.js";
12
12
  export function createSessionStore(config, backend, metrics) {
13
13
  if (config.sessionBackend === "tidb") {
14
14
  if (!backend)
15
- throw new Error("a DB session backend requires a store backend (SESSION_BACKEND=tidb + a reachable DB).");
15
+ throw new Error("a DB session backend requires a store backend (DB_BACKEND=mysql|pg + a reachable DB).");
16
16
  const store = backend.session();
17
17
  return config.sessionCacheTtlSec > 0
18
18
  ? new CachingSessionStore(store, { ttlSec: config.sessionCacheTtlSec, onConflictEvict: metrics ? () => metrics.inc("stale_cache_conflict_total") : undefined })
@@ -32,6 +32,24 @@ export declare function inProcessSingleUserLane(config: {
32
32
  };
33
33
  requirePrincipal?: boolean;
34
34
  }): boolean;
35
+ /**
36
+ * #295(F-1,[4512] 修向 (b')):卡批「不再询问」落盘规则的 **project root** —— 「这次授权是在哪个
37
+ * 工作目录里点的」。取值与既有 cwd 语义同源,不新造坐标系:
38
+ * 1. session 显式注册的 cwd(cwd seam,host lane;与 {@link effectiveHostWorkspace} 第 1 优先级同源);
39
+ * 2. in-process 单用户 lane({@link inProcessSingleUserLane}):引擎自身 `process.cwd()` ——
40
+ * 壳把引擎 spawn 在用户目录的本地形([851]P3a 的 PROCESS 级消费者口径,hooks/C4 同判);
41
+ * 3. 其余(远程沙箱 / REMOTE_EXEC=host 未注册 / 多租户)⇒ `undefined`:root 在 server 坐标系里
42
+ * 不可知,调用方**不铸 scope**,落 core 的 global 缺省(= #295 修前行为,恒不更宽;也绝不铸一个
43
+ * 与规则判定时 cwd 不同坐标系的 root —— 那会静默把「不再询问」变成恒不命中)。
44
+ * 出口一律 realpath 规范化(`PersistedAllowRule.scope` 的 JSDoc 契约:root 由构造方 canonicalize;
45
+ * macOS `/tmp`→`/private/tmp` 族)——解析失败 ⇒ `undefined`(宁可落 global,不落一个歪 root)。
46
+ */
47
+ export declare function cardRuleScopeRoot(registeredCwd: string | undefined, config: {
48
+ remoteExec?: {
49
+ provider?: string;
50
+ };
51
+ requirePrincipal?: boolean;
52
+ }, realpath: (p: string) => string, cwd?: () => string): string | undefined;
35
53
  /** Is a requested cwd ALREADY the engine process's working dir? realpath BOTH sides (macOS `/tmp` →
36
54
  * `/private/tmp`-class symlinks); either side failing to resolve ⇒ treated as a mismatch (warn — never a
37
55
  * false downgrade). `realpath`/`cwd` are parameters for testability; production passes `fs.realpathSync`. */
package/dist/task-cwd.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * shell never pretends it took effect.
17
17
  */
18
18
  import path from "node:path";
19
+ import { recordFailOpen } from "./observability/fail-open.js";
19
20
  /** Cap on a cwd path length (defensive — a row/path label bound; a real path is far shorter). */
20
21
  export const MAX_CWD_CHARS = 4_096;
21
22
  /** Is this a structurally-valid absolute host path? Rejects: non-strings, relative paths (TOC review #12 — a
@@ -51,6 +52,32 @@ export function cwdHonored(config) {
51
52
  export function inProcessSingleUserLane(config) {
52
53
  return config.remoteExec === undefined && config.requirePrincipal !== true;
53
54
  }
55
+ /**
56
+ * #295(F-1,[4512] 修向 (b')):卡批「不再询问」落盘规则的 **project root** —— 「这次授权是在哪个
57
+ * 工作目录里点的」。取值与既有 cwd 语义同源,不新造坐标系:
58
+ * 1. session 显式注册的 cwd(cwd seam,host lane;与 {@link effectiveHostWorkspace} 第 1 优先级同源);
59
+ * 2. in-process 单用户 lane({@link inProcessSingleUserLane}):引擎自身 `process.cwd()` ——
60
+ * 壳把引擎 spawn 在用户目录的本地形([851]P3a 的 PROCESS 级消费者口径,hooks/C4 同判);
61
+ * 3. 其余(远程沙箱 / REMOTE_EXEC=host 未注册 / 多租户)⇒ `undefined`:root 在 server 坐标系里
62
+ * 不可知,调用方**不铸 scope**,落 core 的 global 缺省(= #295 修前行为,恒不更宽;也绝不铸一个
63
+ * 与规则判定时 cwd 不同坐标系的 root —— 那会静默把「不再询问」变成恒不命中)。
64
+ * 出口一律 realpath 规范化(`PersistedAllowRule.scope` 的 JSDoc 契约:root 由构造方 canonicalize;
65
+ * macOS `/tmp`→`/private/tmp` 族)——解析失败 ⇒ `undefined`(宁可落 global,不落一个歪 root)。
66
+ */
67
+ export function cardRuleScopeRoot(registeredCwd, config, realpath, cwd = process.cwd) {
68
+ const raw = registeredCwd ?? (inProcessSingleUserLane(config) ? cwd() : undefined);
69
+ if (raw === undefined)
70
+ return undefined;
71
+ try {
72
+ return realpath(raw);
73
+ }
74
+ catch (err) {
75
+ // F 类兜底(#157 纪律):这一臂的放行方向=落 global(修前行为),但必须留痕——遥测里读得出
76
+ // 「有部署正在这条臂上退回 global」。语义与代价的完整三问见 fail-open.ts 的 tag 登记行。
77
+ recordFailOpen("server.approvals.rule-scope-root-unresolvable", `raw=${raw} err=${String(err)}`);
78
+ return undefined;
79
+ }
80
+ }
54
81
  /** Is a requested cwd ALREADY the engine process's working dir? realpath BOTH sides (macOS `/tmp` →
55
82
  * `/private/tmp`-class symlinks); either side failing to resolve ⇒ treated as a mismatch (warn — never a
56
83
  * false downgrade). `realpath`/`cwd` are parameters for testability; production passes `fs.realpathSync`. */
@@ -471,6 +471,12 @@ export declare class ToolApprovalCoordinator {
471
471
  * (memory: operator-knob-must-be-unconditional —— #153 shellGate 让部署级旋钮的生死由客户端表态
472
472
  * 决定,同病两犯过一次)。安全轴:`deny` 是收紧方向(不放行任何东西),fail-closed 铁律不受损。 */
473
473
  private readonly unattendedPolicy;
474
+ /** #295(F-1,[4512] 修向 (b')):卡批「不再询问」落盘规则的 **project root 解析器**。铸卡素材时以
475
+ * ask 的 `sessionId` 咨询一次——「这次授权是在**哪里**点的」在授权发生时捕获,不在回决时再猜。
476
+ * 回 `undefined` = 该部署形上 run 的 workspace root 不可知(远程沙箱/多租户等)⇒ 不铸 scope,落
477
+ * core 的 global 缺省(= 现行为,恒不更宽;也绝不错铸一个坐标系不对的 root)。装配见 main.ts:
478
+ * registry cwd(host lane 显式注册的 launch dir)?? in-process 单用户形的 `process.cwd()`。 */
479
+ private readonly ruleScopeRootFor;
474
480
  constructor(opts?: {
475
481
  ttlMs?: number;
476
482
  askStore?: ApprovalAskStore;
@@ -483,6 +489,8 @@ export declare class ToolApprovalCoordinator {
483
489
  ruleConsent?: RuleConsentLane;
484
490
  /** #280 R-13 C:无人值守政策(见 {@link ToolApprovalCoordinator.unattendedPolicy});缺省 `park`。 */
485
491
  unattendedPolicy?: UnattendedApprovalPolicy;
492
+ /** #295:卡批规则的 project root 解析器(见 {@link ToolApprovalCoordinator.ruleScopeRootFor})。 */
493
+ ruleScopeRootFor?: (sessionId: string | undefined) => string | undefined;
486
494
  });
487
495
  /**
488
496
  * #280 R-13 C —— 「**无人可答**」这一类终局的**唯一**成形口(park 路由 vs deny 政策)。
@@ -396,8 +396,15 @@ export class ToolApprovalCoordinator {
396
396
  * (memory: operator-knob-must-be-unconditional —— #153 shellGate 让部署级旋钮的生死由客户端表态
397
397
  * 决定,同病两犯过一次)。安全轴:`deny` 是收紧方向(不放行任何东西),fail-closed 铁律不受损。 */
398
398
  unattendedPolicy;
399
+ /** #295(F-1,[4512] 修向 (b')):卡批「不再询问」落盘规则的 **project root 解析器**。铸卡素材时以
400
+ * ask 的 `sessionId` 咨询一次——「这次授权是在**哪里**点的」在授权发生时捕获,不在回决时再猜。
401
+ * 回 `undefined` = 该部署形上 run 的 workspace root 不可知(远程沙箱/多租户等)⇒ 不铸 scope,落
402
+ * core 的 global 缺省(= 现行为,恒不更宽;也绝不错铸一个坐标系不对的 root)。装配见 main.ts:
403
+ * registry cwd(host lane 显式注册的 launch dir)?? in-process 单用户形的 `process.cwd()`。 */
404
+ ruleScopeRootFor;
399
405
  constructor(opts) {
400
406
  this.ruleConsent = opts?.ruleConsent;
407
+ this.ruleScopeRootFor = opts?.ruleScopeRootFor;
401
408
  this.governanceAskMarks = opts?.governanceAskMarks;
402
409
  this.ttlMs = opts?.ttlMs ?? DEFAULT_APPROVAL_TTL_MS;
403
410
  this.askStore = opts?.askStore;
@@ -504,7 +511,7 @@ export class ToolApprovalCoordinator {
504
511
  return false;
505
512
  return a.every((x, i) => x.rule === b[i].rule && x.match === b[i].match && x.command === b[i].command);
506
513
  }
507
- buildRuleLaneMaterial(req, owner, governanceForced) {
514
+ buildRuleLaneMaterial(req, owner, governanceForced, sessionId) {
508
515
  if (this.ruleConsent === undefined)
509
516
  return undefined;
510
517
  // 🔴 **治理档的 ask 不进规则车道**(#204 件7)。`governanceForced` 的语义(wire 契约逐字)是「这只
@@ -545,11 +552,15 @@ export class ToolApprovalCoordinator {
545
552
  if (typeof command !== "string" || command === "")
546
553
  return undefined;
547
554
  const boundInputHash = readBoundInputHash(req);
555
+ // #295(F-1):root 在**铸卡时**咨询——素材是「这次授权」的快照,scope 属于快照的一部分(回决可能
556
+ // 发生在断连重连/另一副本上,那时再解析等于让落盘 scope 取决于回决路径,而不是授权发生地)。
557
+ const scopeRoot = this.ruleScopeRootFor?.(sessionId);
548
558
  return {
549
559
  command,
550
560
  suggestions,
551
561
  ...(typeof req.toolCallId === "string" && req.toolCallId !== "" ? { toolCallId: req.toolCallId } : {}),
552
562
  ...(boundInputHash !== null ? { boundInputHash } : {}),
563
+ ...(scopeRoot !== undefined ? { scopeRoot } : {}),
553
564
  };
554
565
  }
555
566
  /** #241([3731]/[3730] 双属主裁定):把某 wire run 的**流内未决 ask** 立即转 durable park——断连支专用。
@@ -961,7 +972,7 @@ export class ToolApprovalCoordinator {
961
972
  // 候选**基数**同批从 ≤1 变成 ≤2(reviewed 前缀候选,`exact` 恒 index 0)——本层逐字透传整只数组,
962
973
  // 不截长不重排;兑付口按**人报的文本**定位(`rules-consent.ts` 的 `findIndex`,不是 `[0]`)。
963
974
  // `let`:上面那条并集裁定可能在对账后把它撤回 undefined(行说这是治理门)。
964
- let ruleLaneMaterial = this.buildRuleLaneMaterial(req, primary.owner, governanceForced);
975
+ let ruleLaneMaterial = this.buildRuleLaneMaterial(req, primary.owner, governanceForced, primary.sessionId);
965
976
  const frame = {
966
977
  type: "tool_approval",
967
978
  approvalId: id,
@@ -1968,6 +1979,9 @@ export class ToolApprovalCoordinator {
1968
1979
  ruleText,
1969
1980
  ...(material.toolCallId !== undefined ? { toolCallId: material.toolCallId } : {}),
1970
1981
  ...(material.boundInputHash !== undefined ? { boundInputHash: material.boundInputHash } : {}),
1982
+ // #295(F-1,[4512] (b')):授权发生地已知 ⇒ 落 project scope;未知 ⇒ 键缺席,core 落 global
1983
+ // (= 修前行为;绝不在坐标系不明时编一个 root)。root 语义与撤销面判别式串 `project:<root>` 同源。
1984
+ ...(material.scopeRoot !== undefined ? { scope: { kind: "project", root: material.scopeRoot } } : {}),
1971
1985
  });
1972
1986
  if (!persisted.ok) {
1973
1987
  defaultLogger.warn("permission rule was not persisted after an approval", { reason: persisted.reason, detail: persisted.detail });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.34.0",
3
+ "version": "7.35.0-rc.1",
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",
@@ -1,10 +0,0 @@
1
- import { FileError, StubExecutionEnv, type Result } from "@sema-agent/core";
2
- /**
3
- * 无文件系统的路径裁决 env(见文件头)。只覆写 `absolutePath` 一面,其余全部继承 `StubExecutionEnv`
4
- * 的 `not_supported` ——**继承而非逐一手写**是刻意的:core 日后给 `ExecutionEnv` 加必填面时,新面会
5
- * 随 `StubExecutionEnv` 一起到位并保持同一个诚实答案,不会在这里留下一个悄悄编出来的假答案。
6
- */
7
- export declare class LexicalPathExecutionEnv extends StubExecutionEnv {
8
- absolutePath(path: string): Promise<Result<string, FileError>>;
9
- }
10
- //# sourceMappingURL=lexical-path-env.d.ts.map