@sema-agent/server 7.124.0 → 7.125.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.
Files changed (152) hide show
  1. package/MIGRATION.md +138 -0
  2. package/USAGE.md +46 -8
  3. package/dist/audit.d.ts +27 -5
  4. package/dist/audit.js +42 -8
  5. package/dist/boot/execution-env.d.ts +33 -1
  6. package/dist/boot/execution-env.js +31 -2
  7. package/dist/boot/resolve-spec.js +4 -3
  8. package/dist/boot/session-faces.d.ts +14 -1
  9. package/dist/boot/session-faces.js +6 -5
  10. package/dist/boot/stage-02-web-admin-pull.js +5 -1
  11. package/dist/boot/stage-05-execution-env.js +6 -2
  12. package/dist/boot/stage-09-leader.d.ts +1 -1
  13. package/dist/boot/stage-10-http-server.d.ts +1 -1
  14. package/dist/boot/web-admin.js +130 -140
  15. package/dist/capabilities/center-wiring.d.ts +2 -1
  16. package/dist/capabilities/composite-members.d.ts +1 -0
  17. package/dist/capabilities/composite-members.js +1 -0
  18. package/dist/capability-facts.d.ts +28 -3
  19. package/dist/capability-facts.js +20 -0
  20. package/dist/config-catalog.js +4 -1
  21. package/dist/config-d-migration.js +2 -1
  22. package/dist/config-lkg.d.ts +4 -1
  23. package/dist/config-lkg.js +16 -12
  24. package/dist/config-provider.d.ts +4 -1
  25. package/dist/config-provider.js +1 -0
  26. package/dist/config-types.d.ts +10 -3
  27. package/dist/config.js +44 -14
  28. package/dist/env-facts.d.ts +2 -2
  29. package/dist/env-facts.js +3 -2
  30. package/dist/fleet/fleet-bus.d.ts +24 -12
  31. package/dist/fleet/fleet-bus.js +4 -1
  32. package/dist/fleet/workspace-snapshot-residual.d.ts +10 -0
  33. package/dist/fleet/workspace-snapshot-residual.js +35 -0
  34. package/dist/hooks/hook-runner.d.ts +15 -27
  35. package/dist/hooks/hook-runner.js +19 -59
  36. package/dist/http/active-run-conflict.d.ts +3 -0
  37. package/dist/http/active-run-conflict.js +3 -0
  38. package/dist/http/admission.js +7 -7
  39. package/dist/http/approval-sweeps.js +2 -4
  40. package/dist/http/device-placement.js +1 -0
  41. package/dist/http/dispatch.js +1 -1
  42. package/dist/http/durable-run-leg.d.ts +20 -6
  43. package/dist/http/durable-run-leg.js +4 -4
  44. package/dist/http/idempotency.d.ts +83 -38
  45. package/dist/http/idempotency.js +38 -13
  46. package/dist/http/request-guards.d.ts +18 -0
  47. package/dist/http/request-guards.js +12 -3
  48. package/dist/http/resume-legs.js +7 -1
  49. package/dist/http/route-ctx.d.ts +3 -3
  50. package/dist/http/route-ctx.js +1 -1
  51. package/dist/http/routes/a2a-serve.js +27 -11
  52. package/dist/http/routes/capabilities.js +1 -0
  53. package/dist/http/routes/fleet.js +2 -0
  54. package/dist/http/routes/images.js +2 -2
  55. package/dist/http/routes/leader.d.ts +4 -0
  56. package/dist/http/routes/leader.js +13 -3
  57. package/dist/http/routes/observability.js +2 -0
  58. package/dist/http/routes/runs.d.ts +6 -2
  59. package/dist/http/routes/runs.js +71 -101
  60. package/dist/http/routes/session-sync.d.ts +3 -0
  61. package/dist/http/routes/session-sync.js +30 -1
  62. package/dist/http/routes/sessions-list.d.ts +11 -0
  63. package/dist/http/routes/sessions-list.js +6 -6
  64. package/dist/http/routes/sessions.d.ts +6 -1
  65. package/dist/http/routes/sessions.js +41 -10
  66. package/dist/http/routes/tasks.js +63 -21
  67. package/dist/http/run-read-face.d.ts +26 -0
  68. package/dist/http/run-read-face.js +67 -0
  69. package/dist/http/server.d.ts +4 -4
  70. package/dist/http/server.js +1 -2
  71. package/dist/http/submit-replay.d.ts +40 -0
  72. package/dist/http/submit-replay.js +22 -0
  73. package/dist/leader/endpoint.d.ts +10 -5
  74. package/dist/leader/endpoint.js +27 -5
  75. package/dist/observability/cost-quota.d.ts +51 -7
  76. package/dist/observability/cost-quota.js +35 -17
  77. package/dist/observability/fail-open.d.ts +1 -1
  78. package/dist/observability/fail-open.js +1 -1
  79. package/dist/observability/run-terminal.d.ts +5 -9
  80. package/dist/observability/run-terminal.js +2 -7
  81. package/dist/plugins/approval-ask-store-sql.js +1 -1
  82. package/dist/plugins/caching-session-store.d.ts +7 -2
  83. package/dist/plugins/caching-session-store.js +6 -0
  84. package/dist/plugins/device-store-sql.js +1 -1
  85. package/dist/plugins/ensure-index.js +22 -0
  86. package/dist/plugins/file-run-store.d.ts +24 -14
  87. package/dist/plugins/file-run-store.js +52 -12
  88. package/dist/plugins/leader-run-store-sql.d.ts +1 -11
  89. package/dist/plugins/leader-run-store-sql.js +1 -5
  90. package/dist/plugins/local-session-store.d.ts +35 -6
  91. package/dist/plugins/local-session-store.js +87 -18
  92. package/dist/plugins/memory-run-store.d.ts +13 -12
  93. package/dist/plugins/memory-run-store.js +25 -0
  94. package/dist/plugins/one-time-migrations.js +1 -1
  95. package/dist/plugins/pg-cost-quota.d.ts +1 -1
  96. package/dist/plugins/pg-cost-quota.js +2 -5
  97. package/dist/plugins/pg-pool.js +3 -0
  98. package/dist/plugins/pg-session-storage.d.ts +11 -1
  99. package/dist/plugins/pg-session-storage.js +44 -8
  100. package/dist/plugins/remote-env-k8s.d.ts +127 -12
  101. package/dist/plugins/remote-env-k8s.js +270 -27
  102. package/dist/plugins/run-store-sql.d.ts +26 -12
  103. package/dist/plugins/run-store-sql.js +50 -25
  104. package/dist/plugins/schema-consistency.d.ts +1 -13
  105. package/dist/plugins/schema-consistency.js +1 -41
  106. package/dist/plugins/schema-indexes.js +1 -0
  107. package/dist/plugins/schema-live-columns.d.ts +20 -0
  108. package/dist/plugins/schema-live-columns.js +42 -0
  109. package/dist/plugins/store-contracts.d.ts +40 -2
  110. package/dist/plugins/tidb-cost-quota.d.ts +1 -1
  111. package/dist/plugins/tidb-cost-quota.js +2 -5
  112. package/dist/plugins/tidb-pool.js +14 -3
  113. package/dist/plugins/tidb-session-store.d.ts +22 -2
  114. package/dist/plugins/tidb-session-store.js +33 -6
  115. package/dist/plugins/write-behind-counter.d.ts +12 -5
  116. package/dist/plugins/write-behind-counter.js +14 -7
  117. package/dist/prompts-domain-validate.js +3 -2
  118. package/dist/rules-consent.d.ts +2 -2
  119. package/dist/rules-consent.js +2 -2
  120. package/dist/run-local.js +31 -23
  121. package/dist/runs.d.ts +9 -0
  122. package/dist/runs.js +8 -0
  123. package/dist/runtime-governance.d.ts +3 -3
  124. package/dist/runtime-governance.js +2 -4
  125. package/dist/security.d.ts +19 -2
  126. package/dist/session-row.d.ts +53 -0
  127. package/dist/session-row.js +47 -0
  128. package/dist/session-sync.d.ts +6 -0
  129. package/dist/session-sync.js +5 -1
  130. package/dist/session-titler.d.ts +9 -5
  131. package/dist/session-titler.js +3 -1
  132. package/dist/spec-fields.js +2 -2
  133. package/dist/task-cwd.d.ts +0 -20
  134. package/dist/task-cwd.js +2 -10
  135. package/dist/tool-approval.d.ts +5 -7
  136. package/dist/trace/redact.d.ts +23 -50
  137. package/dist/trace/redact.js +58 -18
  138. package/dist/trace/self-minted-wire.d.ts +25 -13
  139. package/dist/trace/self-minted-wire.js +9 -4
  140. package/dist/web-admin-client/apply-effective.d.ts +10 -12
  141. package/dist/web-admin-client/apply-effective.js +2 -3
  142. package/dist/web-admin-client/candidate-gate.d.ts +69 -0
  143. package/dist/web-admin-client/candidate-gate.js +27 -0
  144. package/dist/web-admin-client/http-client.d.ts +2 -1
  145. package/dist/web-admin-client/http-client.js +2 -1
  146. package/dist/web-admin-client/pull-state.d.ts +6 -4
  147. package/dist/web-admin-client/read-warnings.d.ts +29 -5
  148. package/dist/web-admin-client/read-warnings.js +24 -4
  149. package/dist/web-admin-client/stage-limits.js +4 -1
  150. package/dist/workspace-space.d.ts +41 -0
  151. package/dist/workspace-space.js +5 -0
  152. package/package.json +3 -3
@@ -1,3 +1,4 @@
1
+ import { alignedWindow, retryAfterSecUntil } from "../observability/cost-quota.js";
1
2
  const SEP = "\u0000";
2
3
  export const DEFAULT_COUNTER_QUERY_TIMEOUT_MS = 30_000;
3
4
  export class SqlWriteBehindCounter {
@@ -48,24 +49,30 @@ export class SqlWriteBehindCounter {
48
49
  catch {
49
50
  }
50
51
  }
52
+ window() {
53
+ return alignedWindow(this.now(), this.windowMs);
54
+ }
51
55
  bucket() {
52
- return Math.floor(this.now() / this.windowMs);
56
+ return this.window().bucket;
53
57
  }
54
- rk(key) {
55
- return `${key}${SEP}${this.bucket()}`;
58
+ rk(key, bucket) {
59
+ return `${key}${SEP}${bucket}`;
56
60
  }
57
- used(key) {
58
- const rk = this.rk(key);
61
+ usedIn(key, bucket) {
62
+ const rk = this.rk(key, bucket);
59
63
  return (this.fleetTotal.get(rk) ?? 0) + (this.pendingDelta.get(rk) ?? 0);
60
64
  }
65
+ used(key) {
66
+ return this.usedIn(key, this.bucket());
67
+ }
61
68
  addLocal(key, n) {
62
69
  if (n <= 0)
63
70
  return;
64
- const rk = this.rk(key);
71
+ const rk = this.rk(key, this.bucket());
65
72
  this.pendingDelta.set(rk, (this.pendingDelta.get(rk) ?? 0) + n);
66
73
  }
67
74
  retryAfterSec() {
68
- return Math.ceil(((this.bucket() + 1) * this.windowMs - this.now()) / 1000);
75
+ return retryAfterSecUntil(this.window());
69
76
  }
70
77
  async flush() {
71
78
  if (this.flushing) {
@@ -3,9 +3,10 @@ import { createRequire } from "node:module";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { verifyPromptArtifact } from "@sema-agent/core";
6
+ import { PROMPT_SLOTS } from "@sema-agent/settings-schema";
6
7
  import { SHA256_DIGEST_RE } from "./digest-form.js";
7
8
  const ID_RE = /^[a-z0-9][a-z0-9/._-]*$/;
8
- const SLOTS = new Set(["identity", "scenario", "behavior"]);
9
+ const SLOTS = PROMPT_SLOTS;
9
10
  function declarationIssue(d, where) {
10
11
  if (d === null || typeof d !== "object")
11
12
  return `${where}: declaration is not an object`;
@@ -14,7 +15,7 @@ function declarationIssue(d, where) {
14
15
  return `${where}: bad id`;
15
16
  if (o.id.startsWith("core/"))
16
17
  return `${where}: id in the reserved core/ namespace`;
17
- if (typeof o.slot !== "string" || !SLOTS.has(o.slot))
18
+ if (typeof o.slot !== "string" || !SLOTS.includes(o.slot))
18
19
  return `${where}: bad slot`;
19
20
  if (typeof o.text !== "string" || o.text.length === 0)
20
21
  return `${where}: text must be a non-empty string`;
@@ -479,7 +479,7 @@ export declare function serializeRuleScope(scope: RuleScope): string;
479
479
  * 🔴 判别式串的**上限,由铸侧的尺派生**([ref],2026-08-19 合并重扫 confirmed —— 修的是「两个数字
480
480
  * 各挑各的」这件事本身,不是把某个数字调大一点)。
481
481
  *
482
- * 病:铸侧 `cardRuleScopeRoot` 对 root 长度零把关(入口尺 = `isValidCwd` 的 {@link MAX_CWD_CHARS}),
482
+ * 病:铸侧 `cardRuleScopeRoot` 对 root 长度零把关(入口尺 = 契约 `isAbsoluteHostPath` 的上界 `ABSOLUTE_HOST_PATH_MAX`),
483
483
  * core 与两个店都不设 root 长度上限 ⇒ 写面全程无阻;而 `DELETE /v1/rules` 的体 schema 此前独立写死
484
484
  * `scope: max(1088)`,且 safeParse 排在 `parseRuleScope`/权限判据**之前** ⇒ 超限 root 的规则
485
485
  * `GET /v1/rules` **列得出**、按同一串 DELETE **恒 400**。全仓只有一个 `lane.removeRule` 调用点(直删
@@ -491,7 +491,7 @@ export declare function serializeRuleScope(scope: RuleScope): string;
491
491
  * 不碰盘 ⇒ **任何宿主上** 1081..4096 字符的注册 cwd 都能铸出这样一条规则。
492
492
  *
493
493
  * 取值 = `"project:"` 前缀 + 铸侧 root 的入口尺。`path.resolve` 对「已绝对、无 `..` 段」的路径不加长
494
- * (`isValidCwd` 两条都要求),故 {@link MAX_CWD_CHARS} 是 root 的真上界。**两侧从此同源**:谁改铸侧
494
+ * (`isAbsoluteHostPath` 两条都要求),故 `ABSOLUTE_HOST_PATH_MAX` 是 root 的真上界。**两侧从此同源**:谁改铸侧
495
495
  * 的尺,撤侧自动跟。
496
496
  */
497
497
  export declare const MAX_RULE_SCOPE_CHARS: number;
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { recordFailOpen } from "./observability/fail-open.js";
4
4
  import { createLogger } from "./observability/logger.js";
5
- import { MAX_CWD_CHARS } from "./task-cwd.js";
5
+ import { ABSOLUTE_HOST_PATH_MAX } from "@sema-agent/settings-schema";
6
6
  import { isDirectWritableRuleBehavior } from "./permission-rule-vocab.js";
7
7
  import { CONTROL_AND_BIDI_CHARS, hasBidiControls } from "./text-bidi.js";
8
8
  import { confirmRuleApproval, RULE_BEHAVIORS, parseRuleText, ruleReachesProgramRun, ruleToolGrammarOf, precheckEditedRuleText, prepareCardApproval, prepareCcImport, redeemRuleBatch, redeemRuleTicket, addPersistedRule, removePersistedRule, isValidDurableScope, effectiveOrThrow, readRowBehavior, } from "@sema-agent/core";
@@ -51,7 +51,7 @@ export function serializeRuleScope(scope) {
51
51
  throw new Error("serializeRuleScope got a session scope — session consent never lands on the durable rules wire (design/382 §4.3 two-member durable face); a session row reaching this serializer is a defect upstream, not a spelling to invent");
52
52
  }
53
53
  }
54
- export const MAX_RULE_SCOPE_CHARS = "project:".length + MAX_CWD_CHARS;
54
+ export const MAX_RULE_SCOPE_CHARS = "project:".length + ABSOLUTE_HOST_PATH_MAX;
55
55
  export function parseRuleScope(text) {
56
56
  if (text === "global")
57
57
  return { kind: "global" };
package/dist/run-local.js CHANGED
@@ -18,11 +18,13 @@ import { applyRuntimeGovernance, autonomyModeFloor, entryPermissionMode } from "
18
18
  import { ForkRoutingSessionStore } from "./plugins/fork-routing-session-store.js";
19
19
  import { taskWallClockSec } from "./task-workflow.js";
20
20
  import { applyCatalogToSource } from "./capabilities/center-prompts.js";
21
- import { validatePromptsDomain, CORE_ENGINE_VERSION } from "./prompts-domain-validate.js";
21
+ import { CORE_ENGINE_VERSION } from "./prompts-domain-validate.js";
22
22
  import { buildUnpricedCeilingNotice, loadConfig, logConfigDiagnostics, parseNumOrFailNonNegative } from "./config.js";
23
23
  import { buildStoreOpenRefusalTail } from "./plugins/store-open-refusal.js";
24
24
  import { acquireLocalBackendWithWait } from "./plugins/store-lock-wait.js";
25
25
  import { configLocalRoot, configProviderKind, createConfigProvider } from "./config-provider.js";
26
+ import { CONFIG_DOMAIN_INVALID_NOTE, buildCandidateRejectedFields, judgeConfigCandidate } from "./web-admin-client/candidate-gate.js";
27
+ import { buildUnreadConfigFileFields } from "./web-admin-client/read-warnings.js";
26
28
  import { composeExecutionLane } from "./boot/execution-lane.js";
27
29
  import { judgeConsolidationValve } from "./boot/memory-consolidation.js";
28
30
  import { logConfigDirMigration, migrateLocalConfigDirectory } from "./config-d-migration.js";
@@ -330,32 +332,43 @@ export async function runLocal(argv, deps = {}) {
330
332
  provisionSemaConfigDir(config, logger);
331
333
  installOutboundDispatcher(logger);
332
334
  const providerInput = { provider: config.configProvider ?? "local", localDir: root };
335
+ const configSource = configProviderKind(providerInput);
336
+ if (configSource === "local")
337
+ logConfigDirMigration(logger, await migrateLocalConfigDirectory(root));
333
338
  try {
334
- await composeExecutionLane(config, { configSource: configProviderKind(providerInput), localRoot: root, logger, drivesFromFile: ["host"] });
339
+ await composeExecutionLane(config, { configSource, localRoot: root, logger, drivesFromFile: ["host"] });
335
340
  }
336
341
  catch (err) {
337
342
  printErr(err instanceof Error ? err.message : String(err));
338
343
  return 2;
339
344
  }
340
345
  const configProvider = createConfigProvider(providerInput);
341
- if (configProvider.kind === "local")
342
- logConfigDirMigration(logger, await migrateLocalConfigDirectory(root));
343
346
  let effective;
347
+ let candidatePrompts;
344
348
  try {
345
349
  const r = await configProvider.fetchEffective(undefined);
346
350
  if (r) {
347
- for (const de of r.domainErrors ?? [])
348
- logger.warn("config_domain_invalid", { domain: de.domain, error: de.error, note: "domain fell to schema defaults; other domains applied normally" });
349
- for (const d of r.droppedEntries ?? [])
350
- logger.warn("config_entry_dropped", { domain: d.domain, entry: d.entry, reasonCode: d.reasonCode, error: d.error, version: r.effective.version, phase: "boot", note: "this one entry failed its domain schema and was dropped on its own — the rest of the candidate applies (per-entry tolerance); fix the named entry to bring it back" });
351
- effective = r.effective;
352
- applyEffective(config, r.effective, logger, { leg: "boot" });
353
- if (effective?.mcp)
354
- config.mcpServers = resolveMcpServers(effective.mcp, logger);
355
- if (effective?.a2a)
356
- config.a2aPeers = resolveA2aPeers(effective.a2a, logger);
357
- applyCenterReadFace(config, r.effective, logger, { phase: "boot" });
358
- logger.info("config_loaded", { source: configProvider.kind, version: r.effective.version });
351
+ const verdict = judgeConfigCandidate(r);
352
+ if (verdict.verdict === "rejected") {
353
+ for (const de of r.domainErrors ?? [])
354
+ logger.warn("config_domain_invalid", { domain: de.domain, error: de.error, note: CONFIG_DOMAIN_INVALID_NOTE });
355
+ logger.warn("config_candidate_rejected", buildCandidateRejectedFields(r, verdict, "run-local", { lkgVersion: "(env)", ...(configProvider.kind === "local" ? { dir: root } : {}) }));
356
+ }
357
+ else {
358
+ for (const f of r.unreadFiles ?? [])
359
+ logger.warn("config_unread_file", buildUnreadConfigFileFields(f, configProvider.kind === "local" ? root : undefined));
360
+ for (const d of r.droppedEntries ?? [])
361
+ logger.warn("config_entry_dropped", { domain: d.domain, entry: d.entry, reasonCode: d.reasonCode, error: d.error, version: r.effective.version, phase: "boot", note: "this one entry failed its domain schema and was dropped on its own — the rest of the candidate applies (per-entry tolerance); fix the named entry to bring it back" });
362
+ effective = r.effective;
363
+ candidatePrompts = verdict.prompts;
364
+ applyEffective(config, r.effective, logger, { leg: "boot" });
365
+ if (effective?.mcp)
366
+ config.mcpServers = resolveMcpServers(effective.mcp, logger);
367
+ if (effective?.a2a)
368
+ config.a2aPeers = resolveA2aPeers(effective.a2a, logger);
369
+ applyCenterReadFace(config, r.effective, logger, { phase: "boot" });
370
+ logger.info("config_loaded", { source: configProvider.kind, version: r.effective.version });
371
+ }
359
372
  }
360
373
  }
361
374
  catch (err) {
@@ -470,13 +483,8 @@ export async function runLocal(argv, deps = {}) {
470
483
  })();
471
484
  try {
472
485
  await promptSource.restore();
473
- const promptsRaw = effective?.prompts;
474
- if (promptsRaw !== undefined) {
475
- const pv = validatePromptsDomain(promptsRaw);
476
- if (pv.ok)
477
- await applyCatalogToSource(promptSource, pv.value, undefined, logger, "run-local");
478
- else
479
- logger.warn("center_prompts_invalid", { phase: "run-local", error: pv.error, note: "malformed prompts face — built-ins serve" });
486
+ if (candidatePrompts !== undefined) {
487
+ await applyCatalogToSource(promptSource, candidatePrompts, undefined, logger, "run-local");
480
488
  }
481
489
  else {
482
490
  await applyCatalogToSource(promptSource, undefined, undefined, logger, "run-local");
package/dist/runs.d.ts CHANGED
@@ -116,6 +116,15 @@ export declare function resumeAtHttpStatus(result: {
116
116
  * `resumeAtHttpStatus` 自己(单元测试直接喂码)。
117
117
  */
118
118
  export declare function syncRefusalHttpStatus(result: Pick<TaskResult, "terminal" | "stats">): number | undefined;
119
+ /**
120
+ * 一条提交腿把引擎终局落行之后的**唯一**收尾:**零工作的拒不占提交幂等键**。判据就是上面这只 {@link syncRefusalHttpStatus}
121
+ * (零 turn、零 token 且是 prepare 期的拒码)—— 三条提交腿(同步 / 流式 / durable)同调这一只,不另开判据。
122
+ * 行留作那次拒绝的记录,只放开键:拒绝邀请的正是重发(同步腿的 4xx / 带 `Retry-After` 的 503;流式 / durable 腿结果里的
123
+ * `retryAfterMs`),同键重发必须按新请求受理,而不是回放这次拒绝。做过工作的结局(完成 / 失败 / 取消 / park)一律占键。
124
+ */
125
+ export declare function releaseKeyIfZeroWorkRefusal(runStore: {
126
+ releaseIdemKey(taskId: string): Promise<void>;
127
+ }, taskId: string, result: Pick<TaskResult, "terminal" | "stats">): Promise<void>;
119
128
  /**
120
129
  * E18 resume-at — per-turn anchor capture, shared by runInBackground AND the resume-leg driveResume so the (single,
121
130
  * tricky) capture rule can't drift between the two stream-drain loops.
package/dist/runs.js CHANGED
@@ -111,6 +111,10 @@ export function syncRefusalHttpStatus(result) {
111
111
  return undefined;
112
112
  return resumeAtHttpStatus(result);
113
113
  }
114
+ export async function releaseKeyIfZeroWorkRefusal(runStore, taskId, result) {
115
+ if (syncRefusalHttpStatus(result) !== undefined)
116
+ await runStore.releaseIdemKey(taskId);
117
+ }
114
118
  export class TurnAnchorCapture {
115
119
  capture;
116
120
  onCaptureFail;
@@ -340,6 +344,7 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
340
344
  reached = { kind: "done", result: safe };
341
345
  const plane = outwardPlane(safe.terminal);
342
346
  await runStore.setTerminal(taskId, plane.status, safe, plane.errorMessage ?? null);
347
+ await releaseKeyIfZeroWorkRefusal(runStore, taskId, safe);
343
348
  recordDone(safe, plane.status);
344
349
  return;
345
350
  }
@@ -353,6 +358,7 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
353
358
  reached = { kind: "done", result: safe };
354
359
  const plane = outwardPlane(safe.terminal);
355
360
  await runStore.setTerminal(taskId, plane.status, safe, plane.errorMessage ?? null);
361
+ await releaseKeyIfZeroWorkRefusal(runStore, taskId, safe);
356
362
  recordDone(safe, plane.status);
357
363
  return;
358
364
  }
@@ -408,6 +414,7 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
408
414
  reached = { kind: "done", result: safe };
409
415
  const plane = outwardPlane(safe.terminal);
410
416
  await runStore.setTerminal(taskId, plane.status, safe, plane.errorMessage ?? null);
417
+ await releaseKeyIfZeroWorkRefusal(runStore, taskId, safe);
411
418
  recordDone(safe, failureCodeOf(safe.terminal) === CANCELLED_CODE ? CANCELLED_CODE : plane.status);
412
419
  }
413
420
  continue;
@@ -482,6 +489,7 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
482
489
  const plane = outwardPlane(reached.result.terminal);
483
490
  settledAsCancel = failureCodeOf(reached.result.terminal) === CANCELLED_CODE;
484
491
  await runStore.setTerminal(taskId, plane.status, reached.result, plane.errorMessage ?? null);
492
+ await releaseKeyIfZeroWorkRefusal(runStore, taskId, reached.result);
485
493
  }
486
494
  else if (reached?.kind === "suspended") {
487
495
  await runStore.setSuspended(taskId, reached.result);
@@ -24,11 +24,11 @@
24
24
  * name). Real isolation is the `executionEnv` sandbox. Same caveat core documents on `createCoarseCommandNamePolicy`.
25
25
  */
26
26
  import { type PermissionMode, type ShellGateDoctrine, type TaskSpec, type ToolPolicy } from "@sema-agent/core";
27
- import { GovernanceConfig } from "@sema-agent/settings-schema";
27
+ import { AUTONOMY_MODES, type GovernanceConfig } from "@sema-agent/settings-schema";
28
28
  import { type GovernanceAskMarks } from "./governance-ask-marks.js";
29
29
  import { type GovernanceOverrides } from "./governance-overrides.js";
30
- /** 运维 autonomy 四档的**闭集**(同上)。`config.ts` 的 `parseAutonomy` 也从这里读 —— 四档词表全仓一份。 */
31
- export declare const AUTONOMY_MODES: ("plan" | "auto" | "ask" | "read-only")[];
30
+ /** 运维 autonomy 四档的**闭集**(契约成员表本尊,照旧从本模块转出)。`config.ts` 的 `parseAutonomy` 也从这里读 —— 四档词表全仓一份。 */
31
+ export { AUTONOMY_MODES };
32
32
  /** A per-command governance rule (registry `runtime.commandPolicy[]`) —— **型也从契约派生**:
33
33
  * 契约给 `command` 加一条 refine / 给 `decision` 加一个判词,本仓的型当场跟上(或在下面的
34
34
  * `RANK` / 穷尽 switch 上编译红),而不是靠人记得把手写联合改一遍。 */
@@ -1,12 +1,10 @@
1
1
  import { clampPermissionMode, combinePolicies, COARSE_SHELL_TOOLS as CORE_COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, modePolicyOf, parseLeadingCommandName, wrapToolPolicy, } from "@sema-agent/core";
2
- import { GovernanceConfig } from "@sema-agent/settings-schema";
2
+ import { AUTONOMY_MODES, COMMAND_DECISIONS } from "@sema-agent/settings-schema";
3
3
  import { currentGovernanceAskMarks } from "./governance-ask-marks.js";
4
4
  import { tightenGoverned } from "./governance-overrides.js";
5
5
  import { createDeploymentAuthoredPolicy, operatorAsk } from "./operator-ask.js";
6
6
  import { heldRuleName } from "./tool-rule-names.js";
7
- const COMMAND_RULE_SHAPE = GovernanceConfig.shape.commandPolicy.unwrap().element.shape;
8
- const COMMAND_DECISIONS = COMMAND_RULE_SHAPE.decision.options;
9
- export const AUTONOMY_MODES = GovernanceConfig.shape.autonomy.unwrap().options;
7
+ export { AUTONOMY_MODES };
10
8
  const COARSE_SHELL_TOOLS = CORE_COARSE_SHELL_TOOLS;
11
9
  export const COARSE_SHELL_TOOLS_MIRROR = COARSE_SHELL_TOOLS;
12
10
  function createCommandAskListPolicy(askCommands, tools = COARSE_SHELL_TOOLS) {
@@ -3,7 +3,8 @@ import { type SessionStore, type SessionTreeEntry } from "@sema-agent/core";
3
3
  import type { ServiceConfig } from "./config-types.js";
4
4
  import type { TaskRequestBody } from "./http/wire-types.js";
5
5
  import type { StagingHandle } from "./session-sync-kernel.js";
6
- import type { SessionFaceReader } from "./session-incarnation.js";
6
+ import type { SessionFace, SessionFaceReader } from "./session-incarnation.js";
7
+ import type { SessionRowPatch } from "./session-row.js";
7
8
  /** One row of the session-list projection (CC /resume picker) — the durable session abstraction's enumeration
8
9
  * output (§0.5). Mirrors {@link RunStore}'s shipped `SessionSummary` shape so the wire response is unchanged. */
9
10
  export interface SessionListItem {
@@ -23,8 +24,12 @@ export interface SessionListItem {
23
24
  * 是两条身份轴,命名对齐 core `SessionStoreSummary.lastTaskRunId`。wire:additive 键零投影透传
24
25
  * (sessions-list.ts);sema-sdk spec `SessionSummary` 闭集候 SDK 半场开键([ref]/.82 班车)。 */
25
26
  lastTaskRunId?: string;
26
- /** Auto-generated session title (null = untitled / lister face has no session_meta). */
27
+ /** The session's title — the user's (`PATCH /v1/sessions/:id`) or, until the user sets one, the auto-titler's
28
+ * (null = untitled / lister face has no session_meta). One column, two writers, one order: `session-row.ts`. */
27
29
  title: string | null;
30
+ /** Pinned by the user (`PATCH /v1/sessions/:id`). ALWAYS present — `false` by default (a session's state, not a capability);
31
+ * a lister face with no session_meta answers `false` (nothing there can pin it). */
32
+ pinned: boolean;
28
33
  }
29
34
  /** A SessionStore that may additionally expose tenant-ownership ops (the TiDB store does) AND the
30
35
  * `SessionRepo` enumeration/lifecycle seam (§0.5: list/fork/delete homed on the session abstraction's own
@@ -115,6 +120,18 @@ export type OwnerAwareSessionStore = SessionStore & {
115
120
  limit: number;
116
121
  q?: string;
117
122
  }) => Promise<SessionListItem[]>;
123
+ /** ONE session's list row — the SAME projection {@link listSessions} emits for it (same columns, same mapper). `undefined` = not a
124
+ * row of the list face (no such session, or a placed / delegation-transcript session, which never lists). Reader: the 2c
125
+ * manifest (`title` / `pinned`). */
126
+ sessionSummaryOf?: (sessionId: string) => Promise<SessionListItem | undefined>;
127
+ /** The session row's writable bits (`title` / `pinned`) — the ONLY writer of a user-set title or pin (`title: null` clears it back
128
+ * to the automatic title; the auto-titler's `setTitleIfNull` fills a NULL title only; the law of the two writers is written in
129
+ * `session-row.ts`). **Written on the occupant the caller verified, and only on it**: `face` is the {@link sessionFaceOf} read
130
+ * the caller's gate judged; the write and the read-back are both conditioned on that occupant's incarnation (the same law as
131
+ * the parked decide leg's two reads, `session-incarnation.ts`). Answers that occupant's list row after the write
132
+ * ({@link sessionSummaryOf}'s projection); `undefined` = no list row for that occupant (deleted, re-registered by anyone in the
133
+ * meantime, or a placed session) — nothing was written. A failed write throws. */
134
+ patchSessionMeta?: (sessionId: string, patch: SessionRowPatch, face: SessionFace) => Promise<SessionListItem | undefined>;
118
135
  /** E17 — fork a session's whole history to a NEW session owned by `owner`; null if the source is unknown. */
119
136
  fork?: (sourceId: string, owner: string | null) => Promise<string | null>;
120
137
  /** E18 — the session's current leaf `SessionTreeEntry.id` (a cache-bypassing single read), so the resume-at
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 会话行的**可写位**(`title` / `pinned`)—— `PATCH /v1/sessions/:id` 的体闭集、两键的判据,与 2c 导入同读的那一套(唯一属主)。
3
+ *
4
+ * ## 一列一事实,写者次序成文
5
+ * · `title` 是一列(SQL `session_meta.title` / local 标题边档),两个写者,一条律:**用户**经会话店唯一写口 `patchSessionMeta`
6
+ * **无条件**写 —— 写一个串 = 改名,写 `null` = **清回自动标题**(列回到 NULL);**自动标题器**经 `setTitleIfNull` **只填 NULL**
7
+ * (每次提交按列读一次:列是 NULL 才起名,见 `session-titler.ts`)。所以清与填是同一列同一律:用户清掉之后,下一次提交由自动标题器
8
+ * 照常填;用户写过串的会话,自动标题器永不覆盖(无条件写盖得掉自动标题,NULL 守卫盖不掉用户标题)。没有第二列(不另设 `userTitle` /
9
+ * `label`)。清的拼法只有 `null` 一种 —— 空串 `""` 是坏体(`request.body_shape`),不是「清」。
10
+ * · `pinned` 是一列布尔,缺省 `false`;列表行**恒在场**(置顶是会话的状态,不是能力)。
11
+ *
12
+ * ## 上界 = 列宽
13
+ * {@link SESSION_TITLE_MAX_CHARS} 是 `title` 列的宽度,两方言 DDL 内插的就是这一个数(`tidb-pool.ts` / `pg-session-storage.ts`),
14
+ * wire 上界与存储上界是同一个事实。按**字符**计(Unicode 码点 —— 与 MySQL 协议 utf8mb4 / PostgreSQL 的 `VARCHAR(n)` 同一单位),
15
+ * 不按 UTF-16 位计。自动标题器自己的显示预算(`session-titler.ts` 的 `TITLE_MAX_CHARS`)更短,落在这个上界之内。
16
+ */
17
+ /** `title` 列的宽度(字符)= 用户标题的上界。两方言 DDL 内插它。 */
18
+ export declare const SESSION_TITLE_MAX_CHARS = 120;
19
+ /** 会话行的可写位(体闭集;两键至少一键)。会话店唯一写口 `patchSessionMeta` 的形参。`title: null` = 清回自动标题(列写 NULL)。 */
20
+ export interface SessionRowPatch {
21
+ readonly title?: string | null;
22
+ readonly pinned?: boolean;
23
+ }
24
+ /** 一条已分类的拒:码 + 不回显输入的句子。 */
25
+ export interface SessionRowProblem {
26
+ readonly code: "request.body_shape" | "request.field_invalid";
27
+ readonly message: string;
28
+ }
29
+ /**
30
+ * `title` 一格的**唯一**读者(PATCH 体与 2c 导入同读)。`null` ⇒ 清回自动标题;串 ⇒ 1..{@link SESSION_TITLE_MAX_CHARS} 个字符、首尾无空白、
31
+ * 不含 C0 控制字符({@link C0_CONTROL}),存原文(不脱敏、不改写);空串 ⇒ `request.body_shape`(清的拼法只有 `null`);其余 ⇒ `request.field_invalid`。
32
+ */
33
+ export declare function readSessionTitle(v: unknown): {
34
+ readonly title: string | null;
35
+ } | {
36
+ readonly problem: SessionRowProblem;
37
+ };
38
+ /** `pinned` 一格的**唯一**读者(PATCH 体与 2c 导入同读)。 */
39
+ export declare function readSessionPinned(v: unknown): {
40
+ readonly pinned: boolean;
41
+ } | {
42
+ readonly problem: SessionRowProblem;
43
+ };
44
+ /**
45
+ * `PATCH /v1/sessions/:id` 体的**唯一**读者。形不对(非对象 / 空 / 表外键)⇒ `request.body_shape`;两键各走自己那一格的读者。
46
+ * 拒句不回显调用方的键名或值。
47
+ */
48
+ export declare function readSessionRowPatch(body: unknown): {
49
+ readonly patch: SessionRowPatch;
50
+ } | {
51
+ readonly problem: SessionRowProblem;
52
+ };
53
+ //# sourceMappingURL=session-row.d.ts.map
@@ -0,0 +1,47 @@
1
+ export const SESSION_TITLE_MAX_CHARS = 120;
2
+ const SESSION_ROW_PATCH_KEYS = { title: true, pinned: true };
3
+ const BODY_SHAPE_MESSAGE = "the body must be a JSON object carrying at least one of `title` and `pinned`, and nothing else";
4
+ const EMPTY_TITLE_MESSAGE = "title must not be the empty string — send `null` to clear it back to the automatic title";
5
+ const C0_CONTROL = /[\u0000-\u001f]/u;
6
+ export function readSessionTitle(v) {
7
+ if (v === null)
8
+ return { title: null };
9
+ if (v === "")
10
+ return { problem: { code: "request.body_shape", message: EMPTY_TITLE_MESSAGE } };
11
+ if (typeof v !== "string")
12
+ return { problem: { code: "request.field_invalid", message: "title must be a string, or null to clear it" } };
13
+ if (Array.from(v).length > SESSION_TITLE_MAX_CHARS)
14
+ return { problem: { code: "request.field_invalid", message: `title must be 1 to ${SESSION_TITLE_MAX_CHARS} characters` } };
15
+ if (v !== v.trim())
16
+ return { problem: { code: "request.field_invalid", message: "title must not start or end with whitespace" } };
17
+ if (C0_CONTROL.test(v))
18
+ return { problem: { code: "request.field_invalid", message: "title must not contain control characters (U+0000 to U+001F)" } };
19
+ return { title: v };
20
+ }
21
+ export function readSessionPinned(v) {
22
+ return typeof v === "boolean" ? { pinned: v } : { problem: { code: "request.field_invalid", message: "pinned must be a boolean" } };
23
+ }
24
+ export function readSessionRowPatch(body) {
25
+ if (body === null || typeof body !== "object" || Array.isArray(body))
26
+ return { problem: { code: "request.body_shape", message: BODY_SHAPE_MESSAGE } };
27
+ const keys = Object.keys(body);
28
+ if (keys.length === 0 || keys.some((k) => !Object.hasOwn(SESSION_ROW_PATCH_KEYS, k))) {
29
+ return { problem: { code: "request.body_shape", message: BODY_SHAPE_MESSAGE } };
30
+ }
31
+ const b = body;
32
+ let patch = {};
33
+ if ("title" in b) {
34
+ const read = readSessionTitle(b.title);
35
+ if ("problem" in read)
36
+ return read;
37
+ patch = { ...patch, title: read.title };
38
+ }
39
+ if ("pinned" in b) {
40
+ const read = readSessionPinned(b.pinned);
41
+ if ("problem" in read)
42
+ return read;
43
+ patch = { ...patch, pinned: read.pinned };
44
+ }
45
+ return { patch };
46
+ }
47
+ //# sourceMappingURL=session-row.js.map
@@ -127,6 +127,12 @@ export interface SessionManifest {
127
127
  entryId: string;
128
128
  owner: string | null;
129
129
  }>;
130
+ /** The session row's writable bits (`src/session-row.ts`), read through the list projection (`sessionSummaryOf`): the title
131
+ * (`null` = no title — the PUSH that replays it clears the destination back to automatic, the same law as `PATCH`) and the pin.
132
+ * Present iff the session is a row of the list face — every ordinary session; a placed (delegation-transcript) session has no
133
+ * writable row bits, so both keys are absent there. */
134
+ title?: string | null;
135
+ pinned?: boolean;
130
136
  }
131
137
  /**
132
138
  * P1d-α (PULL streaming, §15) — EXPORT a session's portable state as a {@link SessionManifest} (entries LIFTED OFF
@@ -40,10 +40,14 @@ export async function exportSessionManifest(sessionId, srcBackend, historyStore)
40
40
  : [];
41
41
  const anchorStore = srcBackend.resumeAnchor();
42
42
  const anchors = await anchorStore.listBySession(sessionId);
43
+ const summaryOf = session.sessionSummaryOf?.bind(session);
44
+ if (summaryOf === undefined)
45
+ throw new Error("exportSessionManifest: the session store has no row read face (sessionSummaryOf) — the sessionSync fact should have refused this deployment");
46
+ const row = await summaryOf(sessionId);
43
47
  const getBlobFn = fs.getBlob?.bind(fs);
44
48
  const getBlob = getBlobFn ? (hash) => getBlobFn(hash) : async () => undefined;
45
49
  return {
46
- manifest: { sessionId, entryIds, entryCount: entryIds.length, leafId, fileHistory, policy, anchors },
50
+ manifest: { sessionId, entryIds, entryCount: entryIds.length, leafId, fileHistory, policy, anchors, ...(row !== undefined ? { title: row.title, pinned: row.pinned } : {}) },
47
51
  getBlob,
48
52
  };
49
53
  }
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Auto-generated session titles.
3
3
  *
4
- * SHAPE: fire-and-forget at FIRST task submit for a session (the objective is known then — no need to wait
5
- * for the run to finish, and the web Recents gets the title sooner). Write-once (`setTitleIfNull`, store-side
6
- * `WHERE title IS NULL`) — the USER-facing rename lives BFF-side as a label overlay (user label wins
7
- * over engine title, two layers never fight). Generation = one cheap-model one-liner via the deployment's
8
- * hookLlm face (summarize role → MODEL_CHEAP_ID when configured, else the main model; bounded + timed out).
4
+ * SHAPE: fire-and-forget at a task submit whose session has NO title (the objective is known then — no need to wait
5
+ * for the run to finish, and the web Recents gets the title sooner). Fills a NULL title only (`setTitleIfNull`,
6
+ * store-side `WHERE title IS NULL`) — the user writes the SAME column unconditionally (`PATCH /v1/sessions/:id` →
7
+ * `patchSessionMeta`): a user-set title is never overwritten (the probe below reads "titled" and skips the model
8
+ * call), and a title the user cleared (`title: null` ⇒ NULL) is filled again at the next submit (law: `session-row.ts`).
9
+ * "Has it a title?" is read from the column on every submit (one point read) — the process keeps NO "already titled"
10
+ * memo, because such a memo would be a second answer to that question that a clear turns stale. Generation = one
11
+ * cheap-model one-liner via the deployment's hookLlm face (summarize role → MODEL_CHEAP_ID when configured, else the
12
+ * main model; bounded + timed out).
9
13
  *
10
14
  * FAILURE POSTURE: everything is best-effort — a generation/store failure logs and leaves title null (the
11
15
  * list falls back to objectivePreview). An in-process pending set dedupes concurrent submits on the same
@@ -58,6 +58,7 @@ export function createSessionTitler(deps) {
58
58
  }
59
59
  }
60
60
  if (state === "titled") {
61
+ seen.delete(sessionId);
61
62
  deps.metrics?.inc("session_title_total", { outcome: "already_titled" });
62
63
  return;
63
64
  }
@@ -75,11 +76,12 @@ export function createSessionTitler(deps) {
75
76
  const landed = await deps.setTitleIfNull(sessionId, title);
76
77
  if (!landed) {
77
78
  const after = await probeOrContinue(sessionId, "none");
78
- if (after.state === "none")
79
+ if (after.state !== "untitled")
79
80
  seen.delete(sessionId);
80
81
  deps.metrics?.inc("session_title_total", { outcome: after.unreadable ? "probe_failed" : after.state === "none" ? "row_not_yet" : "already_titled" });
81
82
  return;
82
83
  }
84
+ seen.delete(sessionId);
83
85
  deps.metrics?.inc("session_title_total", { outcome: "set" });
84
86
  deps.logger?.info?.("session_titled", { sessionId, titleChars: title.length });
85
87
  })().catch((e) => {
@@ -1,6 +1,6 @@
1
1
  import { isThinkingLevel } from "@sema-agent/core";
2
2
  import { taskWallClockSec } from "./task-workflow.js";
3
- import { isValidCwd } from "./task-cwd.js";
3
+ import { isAbsoluteHostPath } from "@sema-agent/settings-schema";
4
4
  import { allowLayerEntryCountIssue, ruleEntryListShapeOk } from "./rule-entry-list.js";
5
5
  export function normalizeSuggestNextPrompts(v) {
6
6
  if (v === true)
@@ -88,7 +88,7 @@ const SKILL_FIELD_SHAPES = {
88
88
  allowedTools: { required: false, ok: skillToolListOk },
89
89
  disallowedTools: { required: false, ok: skillToolListOk },
90
90
  files: { required: false, ok: skillFilesOk },
91
- baseDir: { required: false, ok: isValidCwd },
91
+ baseDir: { required: false, ok: isAbsoluteHostPath },
92
92
  };
93
93
  const skillOk = (s) => {
94
94
  if (!plainObject(s))
@@ -1,23 +1,3 @@
1
- /** Cap on a cwd path length (defensive — a row/path label bound; a real path is far shorter). */
2
- export declare const MAX_CWD_CHARS = 4096;
3
- /** Is this a structurally-valid absolute host path? Rejects: non-strings, relative paths (TOC review #12 — a
4
- * relative cwd would resolve against the SERVICE process cwd, not the user's project = silent wrong-dir), empty,
5
- * over-long, and NUL-byte injection. NOT an existence check (the env surfaces a missing dir at first tool use).
6
- * Defense-in-depth (double-review LOW): `path.isAbsolute` is SYNTACTIC — `/a/../../etc` is "absolute" yet contains
7
- * `..` traversal that the host env would `path.resolve` away. Single-user host lane = the caller's own machine (no
8
- * cross-tenant escape), but we reject any path with a `..` segment so the honored cwd is the literal path sent.
9
- *
10
- * 🔴 **接缝登记(7.74.0 / core 7.17.0 [ref],今日无可达分歧 —— 登记的是「以后会分歧」)**:这条判断回答的是
11
- * 「引擎眼里这条 cwd 算不算绝对」,而引擎自本版起按**树的家族**判(`isAbsoluteForFamily(pathFamilyOf({root,cwd}), p)`,
12
- * 家族从 root/cwd 的**拼法**读,**从不**读 `process.platform`)。本函数用的是 Node 的宿主口径,两者:
13
- * · **posix 家族逐字同判**(2026-09-12 实测六形 `/etc/passwd` / `a/b` / `\\host\share\x` / `C:\x` /
14
- * `//host/share/x` / `/c/x` 六格全同)⇒ 今天(Linux 宿主 + posix 树,本位又只在单用户 `host` 车道上场)
15
- * **零分歧**,不改;
16
- * · **win 家族分歧在 `/`-开头但没有盘符的那一类**(`/etc/passwd`、`/c/x`):Node 的 win32 口径说「绝对」,
17
- * 引擎说「rooted 但未完全限定」(要拼上基准的盘/共享根才成一条真路径)。⇒ **哪天 `host` 车道要接 win 树,
18
- * 先改这里**:把判据换成 core 的 `isAbsoluteForFamily`,并把家族从这条 cwd 自己的拼法取。
19
- * 钉见 `test/task-cwd.test.ts` 的「[ref] 路径家族接缝」格(实测读数,不是推断)。 */
20
- export declare function isValidCwd(cwd: unknown): cwd is string;
21
1
  /**
22
2
  * 词法归一:纯字符串运算,折 `.` / `..` / 重复分隔符,**不碰 fs**。
23
3
  * `posix.normalize` 把 `..` 在根部截断(`/../x` → `/x`),与「根之上没有东西」的语义一致;尾部分隔符
package/dist/task-cwd.js CHANGED
@@ -1,14 +1,6 @@
1
1
  import path from "node:path";
2
+ import { isAbsoluteHostPath } from "@sema-agent/settings-schema";
2
3
  import { executionLaneOf, handsOnWorkerFilePlane } from "./execution-lane-caps.js";
3
- export const MAX_CWD_CHARS = 4_096;
4
- export function isValidCwd(cwd) {
5
- return (typeof cwd === "string" &&
6
- cwd.length > 0 &&
7
- cwd.length <= MAX_CWD_CHARS &&
8
- !cwd.includes("\0") &&
9
- path.isAbsolute(cwd) &&
10
- !cwd.split(/[/\\]/).includes(".."));
11
- }
12
4
  export function normalizeAbsolutePathLexically(p) {
13
5
  const collapsed = path.posix.normalize(p.replace(/^\/+/, "/"));
14
6
  return collapsed.length > 1 ? collapsed.replace(/\/+$/, "") : collapsed;
@@ -55,7 +47,7 @@ export function parseAdditionalDirectories(raw) {
55
47
  for (const entry of raw) {
56
48
  if (out.length >= MAX_ADDITIONAL_DIRS)
57
49
  break;
58
- if (!isValidCwd(entry) || seen.has(entry))
50
+ if (!isAbsoluteHostPath(entry) || seen.has(entry))
59
51
  continue;
60
52
  seen.add(entry);
61
53
  out.push(entry);
@@ -446,13 +446,11 @@ export interface ToolApprovalFrame {
446
446
  * 更需要知道「你看不到的那份里有隐形字符」,所以帽不影响本键;
447
447
  * · 序列化失败(循环引用 / 抛错的 `toJSON` / BigInt)⇒ 两件事一起交白卷:args 不上帧,本键**不铸**
448
448
  * (两次 `JSON.stringify` 共用同一个 try/catch —— 顶注反对的是第二个**静默降级点**,不是第二次序列化);
449
- * · 🔴 **7.70.0(S-103)改素材**:此前用的是脱敏后那份(理由=与卡面同源),而本版 `redactSecrets` 入口
450
- * 先剥全部 `\p{Cf}` —— bidi 控制符**整族都是** `\p{Cf}` ⇒ 在脱敏后的文本上扫,本键恒缺席、E-14 整条
451
- * 失效(六格红先复现)。所以判据素材改回原字节;同源那条理由让位于「信号必须真」。
452
- * · ⚠️ **上面「字节零改」那一条自 7.70.0 起对 `\p{Cf}` 族不再成立**:帧/卡上的 args 是**剥后**字节
453
- * (与 core 显示面 `renderUntrustedCommandText` 同律,显示的是真正执行的逻辑顺序),壳因此**拿不到
454
- * 「在哪一位」**、只能按整只 args 打标。这是有意的取舍(夹层密钥的根治要求单一剥点),不是遗漏;
455
- * 要恢复定位面需要给脱敏器一条 span/偏移表回填路(S-103 定案明确不做),不是在这条腿上加特判。
449
+ * · 素材是**原字节**,不是脱敏后那份:7.70.0–7.124.x 的脱敏器吐出剥掉全部 `\p{Cf}` 的文本(bidi 控制符整族都是
450
+ * `\p{Cf}`),在它上面扫本键恒缺席;7.125.0 起脱敏产物回到原文(认出的凭据跨度映回原文坐标替换,跨度外的
451
+ * 格式字符逐字保留),原字节与卡面在 bidi 控制符上已无差别,素材仍取原字节(脱敏前那一次序列化,上一条)。
452
+ * · **「字节零改」对卡面 args 成立**:帧/卡上的 args 与预览是脱敏产物,方向控制符留在原位 —— 壳拿得到「在哪一位」,
453
+ * 显形 / 中和归壳的渲染面;只有被认出的凭据跨度(连同夹在其中的格式字符)整段换成记号。
456
454
  *
457
455
  * 判据**不看 `message`**(引擎/策略写的说明文本,不是待执行输入):本键被壳读作「**待执行的输入**可能
458
456
  * 在骗你的眼睛」,把两个来源折进一个布尔会让壳无法判断该给哪一段加显形标记。