@sema-agent/server 7.97.0 → 7.99.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 (70) hide show
  1. package/MIGRATION.md +11 -0
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +3 -3
  4. package/USAGE.md +18 -9
  5. package/deploy/sema-up/cluster-up.sh +8 -4
  6. package/deploy/sema-up/kube-up.sh +40 -15
  7. package/deploy/sema-up/lib/envf.sh +241 -0
  8. package/deploy/sema-up/sema-up.sh +162 -101
  9. package/deploy/sema-up/smoke.sh +8 -1
  10. package/dist/boot/config-center.js +169 -24
  11. package/dist/boot/org-memory.d.ts +2 -1
  12. package/dist/boot/org-memory.js +9 -5
  13. package/dist/boot/resolve-spec.js +9 -4
  14. package/dist/boot/runtime-caps.js +4 -4
  15. package/dist/brain.js +28 -18
  16. package/dist/capabilities/center-prompts.d.ts +3 -0
  17. package/dist/capabilities/center-prompts.js +1 -1
  18. package/dist/center-credential.d.ts +112 -0
  19. package/dist/center-credential.js +131 -0
  20. package/dist/config-catalog.js +7 -6
  21. package/dist/config-center/apply-effective.js +7 -1
  22. package/dist/config-center/center-request.d.ts +60 -0
  23. package/dist/config-center/center-request.js +71 -0
  24. package/dist/config-center/facade.d.ts +2 -1
  25. package/dist/config-center/http-client.d.ts +17 -9
  26. package/dist/config-center/http-client.js +27 -36
  27. package/dist/config-center/skills-mcp.d.ts +4 -1
  28. package/dist/config-center/skills-mcp.js +2 -2
  29. package/dist/config-invariants.d.ts +2 -2
  30. package/dist/config-invariants.js +9 -9
  31. package/dist/config-lkg.d.ts +47 -8
  32. package/dist/config-lkg.js +107 -20
  33. package/dist/config-provider.d.ts +8 -7
  34. package/dist/config-provider.js +5 -5
  35. package/dist/config-types.d.ts +20 -6
  36. package/dist/config.d.ts +9 -0
  37. package/dist/config.js +53 -18
  38. package/dist/fleet-client.d.ts +8 -8
  39. package/dist/fleet-client.js +7 -7
  40. package/dist/fleet-lease.d.ts +11 -11
  41. package/dist/fleet-lease.js +17 -13
  42. package/dist/hooks/hook-llm.d.ts +1 -1
  43. package/dist/hooks/hook-llm.js +3 -1
  44. package/dist/hosted-posture.d.ts +49 -93
  45. package/dist/hosted-posture.js +1 -25
  46. package/dist/memory-scope.d.ts +62 -11
  47. package/dist/memory-scope.js +32 -3
  48. package/dist/model-provider.d.ts +13 -0
  49. package/dist/model-provider.js +3 -0
  50. package/dist/model-route-endpoint.d.ts +82 -0
  51. package/dist/model-route-endpoint.js +55 -0
  52. package/dist/observability/secret-env-scrub.d.ts +2 -26
  53. package/dist/observability/secret-env-scrub.js +2 -2
  54. package/dist/plugins/remote-env-host.d.ts +3 -1
  55. package/dist/plugins/remote-env-host.js +3 -2
  56. package/dist/project-identity.d.ts +29 -0
  57. package/dist/project-identity.js +120 -0
  58. package/dist/project-memory.d.ts +28 -1
  59. package/dist/project-memory.js +22 -22
  60. package/dist/run-local.d.ts +14 -4
  61. package/dist/run-local.js +8 -7
  62. package/dist/runtime-caps-resolver.d.ts +8 -8
  63. package/dist/runtime-caps-resolver.js +16 -14
  64. package/dist/sealed-key.js +3 -7
  65. package/dist/server-secret-env.d.ts +41 -0
  66. package/dist/server-secret-env.js +18 -0
  67. package/dist/tool-approval.d.ts +48 -1
  68. package/dist/tool-approval.js +10 -3
  69. package/dist/trace/redact.d.ts +1 -1
  70. package/package.json +4 -3
@@ -1,3 +1,4 @@
1
+ import { type PlatformPath } from "node:path";
1
2
  import type { ProjectMemoryLoad } from "@sema-agent/core";
2
3
  interface ProjectMemoryCtx {
3
4
  cwd: string;
@@ -7,9 +8,35 @@ interface ProjectMemoryCtx {
7
8
  sessionId?: string;
8
9
  phase?: "fresh" | "resume" | "post-compact" | "post-clear";
9
10
  }
11
+ /** The instruction-file name chain: the CLAUDE family first, the AGENTS family only when no CLAUDE file is
12
+ * present. The FIRST hit is the single declared instruction source.
13
+ *
14
+ * Order follows CC 2.1.280 (clay ruling [ref], 2026-09-23, board [ref] RG-2). It replaces the core 1.302 S-1
15
+ * order, where AGENTS.md won over CLAUDE.md; [ref] supersedes that. In CC 2.1.280:
16
+ * - cli280.js:185162 — the default mode `claude-md-or-agents-md`: "a project with no CLAUDE.md of its own gets its
17
+ * AGENTS.md files instead, loaded exactly where and how CLAUDE.md would be";
18
+ * - cli280.js:245920 — the two name families: AGENTS `["AGENTS.md", ".claude/AGENTS.md"]`,
19
+ * CLAUDE `["CLAUDE.md", ".claude/CLAUDE.md", "CLAUDE.local.md"]`;
20
+ * - cli280.js:226831 — the 2.1.277 changelog entry "in a project with no CLAUDE.md, Claude Code reads AGENTS.md
21
+ * instead".
22
+ * Not followed here (CC divergences, registration drafted in the che-MN closing note): `CLAUDE.local.md` is not read;
23
+ * CC's other `instructionFiles` modes (`claude-md-and-agents-md` loads both, `managed-only`) are not offered; CC loads
24
+ * every CLAUDE file it finds, per directory level up the ancestor chain, whereas this loader reads one root and
25
+ * takes the single first hit. */
26
+ declare const INSTRUCTION_FILE_CHAIN: readonly ["CLAUDE.md", ".claude/CLAUDE.md", "AGENTS.md", ".claude/AGENTS.md"];
27
+ type ChainMember = (typeof INSTRUCTION_FILE_CHAIN)[number];
28
+ /** Recover (root, rel) for a path the LOADER declared — every declared path is `root + one chain member`.
29
+ * The probe needs this to re-run the loader's parent-directory symlink gate each round (self-review: the probe
30
+ * checked only the final component, so swapping `.claude` for a symlink out of the repo MID-RUN made the probe
31
+ * fingerprint an outside file). Exported only for its path-matrix pin (posix / win32 through `p`); the probe
32
+ * calls it with the host `node:path`. */
33
+ export declare function instructionChainCoordinates(absPath: string, p?: PlatformPath): {
34
+ root: string;
35
+ rel: ChainMember;
36
+ } | null;
10
37
  /**
11
38
  * Build a `loadProjectMemory` for the host lane. Returns null (skip) unless hands are enabled AND cwd is a local
12
- * directory. Reads the instruction-file chain (AGENTS.md → CLAUDE.md → .claude/CLAUDE.md, S-1) + (non-subagent)
39
+ * directory. Reads the instruction-file chain (CLAUDE.md → .claude/CLAUDE.md → AGENTS.md → .claude/AGENTS.md, [ref]) + (non-subagent)
13
40
  * `git log -5` / `git status`, redacts secrets, and caps the total. A subagent gets a trimmed instruction file and
14
41
  * no git narrative (cost control, H7). When an instruction file is hit the return is a `ProjectMemoryLoad`
15
42
  * declaring it in `instructionSources` (path + sha256 over the raw bounded-read window) so core's
@@ -1,11 +1,11 @@
1
1
  import { promises as fs, constants as fsConstants } from "node:fs";
2
2
  import { execFile } from "node:child_process";
3
3
  import { createHmac, randomBytes } from "node:crypto";
4
- import { isAbsolute, join, sep } from "node:path";
5
- import { scrubSecretEnv } from "@sema-agent/core";
4
+ import hostPath, { isAbsolute, join } from "node:path";
6
5
  import { recordSecretEnvScrub } from "./observability/secret-env-scrub.js";
6
+ import { scrubServerSecrets } from "./server-secret-env.js";
7
7
  import { redactSecrets } from "./trace/redact.js";
8
- const INSTRUCTION_FILE_CHAIN = ["AGENTS.md", "CLAUDE.md", ".claude/CLAUDE.md"];
8
+ const INSTRUCTION_FILE_CHAIN = ["CLAUDE.md", ".claude/CLAUDE.md", "AGENTS.md", ".claude/AGENTS.md"];
9
9
  const INSTRUCTION_READ_CAP = 24 * 1024;
10
10
  function capUtf8(s, maxBytes) {
11
11
  const buf = Buffer.from(s, "utf8");
@@ -41,15 +41,16 @@ function instructionHash(raw) {
41
41
  return `hmac-sha256:${createHmac("sha256", INSTRUCTION_HASH_KEY).update(raw).digest("hex")}`;
42
42
  }
43
43
  const ABSENT_HASH = null;
44
- function chainCoordinates(absPath) {
45
- let best = null;
46
- for (const rel of INSTRUCTION_FILE_CHAIN) {
47
- const suffix = sep + rel.split("/").join(sep);
48
- if (absPath.endsWith(suffix) && (best === null || suffix.length > best.len)) {
49
- best = { root: absPath.slice(0, -suffix.length), rel, len: suffix.length };
50
- }
44
+ export function instructionChainCoordinates(absPath, p = hostPath) {
45
+ const candidates = INSTRUCTION_FILE_CHAIN.filter((rel) => absPath.endsWith(p.sep + rel.split("/").join(p.sep))).sort((a, b) => b.length - a.length);
46
+ for (const rel of candidates) {
47
+ let root = absPath;
48
+ for (let i = 0; i < rel.split("/").length; i++)
49
+ root = p.dirname(root);
50
+ if (p.join(root, rel) === absPath)
51
+ return { root, rel };
51
52
  }
52
- return best && { root: best.root, rel: best.rel };
53
+ return null;
53
54
  }
54
55
  async function hasSymlinkedParent(root, rel) {
55
56
  const parts = rel.split("/").slice(0, -1);
@@ -62,10 +63,15 @@ async function hasSymlinkedParent(root, rel) {
62
63
  }
63
64
  return false;
64
65
  }
66
+ async function readChainMember(root, rel, maxBytes, truncatedFlag) {
67
+ if (rel.includes("/") && (await hasSymlinkedParent(root, rel)))
68
+ return null;
69
+ return readRawCapped(join(root, rel), maxBytes, truncatedFlag);
70
+ }
65
71
  function git(root, args, maxBytes) {
66
72
  const safeArgs = ["-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", ...args];
67
73
  const scrubFindings = [];
68
- const scrubbedEnv = scrubSecretEnv(process.env, scrubFindings);
74
+ const scrubbedEnv = scrubServerSecrets(process.env, scrubFindings);
69
75
  recordSecretEnvScrub("server.project-memory.git-subprocess", scrubFindings);
70
76
  return new Promise((res) => {
71
77
  execFile("git", safeArgs, { cwd: root, timeout: 5000, maxBuffer: maxBytes, env: { ...scrubbedEnv, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" } }, (err, stdout, stderr) => {
@@ -156,9 +162,7 @@ export function makeLoadProjectMemory(opts) {
156
162
  hitIndex = -1;
157
163
  for (let i = 0; i < INSTRUCTION_FILE_CHAIN.length; i++) {
158
164
  const rel = INSTRUCTION_FILE_CHAIN[i];
159
- if (rel.includes("/") && (await hasSymlinkedParent(root, rel)))
160
- continue;
161
- const raw = await readRawCapped(join(root, rel), claudeCap, claudeTrunc);
165
+ const raw = await readChainMember(root, rel, claudeCap, claudeTrunc);
162
166
  if (raw !== null) {
163
167
  claudeRaw = capUtf8(raw.toString("utf8"), claudeCap);
164
168
  hitFile = rel;
@@ -171,8 +175,7 @@ export function makeLoadProjectMemory(opts) {
171
175
  break;
172
176
  let appeared = false;
173
177
  for (const rel of INSTRUCTION_FILE_CHAIN.slice(0, hitIndex)) {
174
- const higher = await fs.lstat(join(root, rel)).catch(() => null);
175
- if (higher && !higher.isSymbolicLink()) {
178
+ if ((await readChainMember(root, rel, claudeCap)) !== null) {
176
179
  appeared = true;
177
180
  break;
178
181
  }
@@ -229,11 +232,8 @@ export function makeProbeInstructionSources() {
229
232
  return await Promise.all(sources.map(async ({ path }) => {
230
233
  if (!isAbsolute(path))
231
234
  return { path, contentHash: null };
232
- const coords = chainCoordinates(path);
233
- if (coords && coords.rel.includes("/") && (await hasSymlinkedParent(coords.root, coords.rel))) {
234
- return { path, contentHash: null };
235
- }
236
- const raw = await readRawCapped(path, INSTRUCTION_READ_CAP);
235
+ const coords = instructionChainCoordinates(path);
236
+ const raw = coords ? await readChainMember(coords.root, coords.rel, INSTRUCTION_READ_CAP) : await readRawCapped(path, INSTRUCTION_READ_CAP);
237
237
  return { path, contentHash: raw === null ? null : instructionHash(raw) };
238
238
  }));
239
239
  }
@@ -47,10 +47,20 @@ export declare function parseArgs(argv: string[]): RunLocalArgs;
47
47
  export declare class UsageError extends Error {
48
48
  }
49
49
  /**
50
- * The model GATEWAY pre-flight (mirrors the doctor message): the engine cannot run a real
51
- * task without (a) a gateway base URL that ends in `/v1` and (b) the gateway/model API key resolvable from
52
- * env. We surface a "you still need to set: …" list rather than letting the first brain call crash with a
53
- * cryptic auth/connect error. Returns a list of missing items (empty = OK).
50
+ * The model GATEWAY pre-flight (mirrors the doctor message): the engine cannot run a real task through a
51
+ * declared gateway without the gateway/model API key resolvable from env. We surface a "you still need to
52
+ * set: …" list rather than letting the first brain call crash with a cryptic auth error. Returns a list of
53
+ * missing items (empty = OK).
54
+ *
55
+ * 🔴 S-623:这里**不再**判网关 base URL。「配置能派发到的每一条 openai 路由都解析到显式端点」是 `loadConfig()`
56
+ * 那一层的拒启判据(`model-route-endpoint.ts`,本入口在预检之前就调 `loadConfig()` —— 没设网关的配置根本走不到
57
+ * 这里),原来那条臂(a)是它的一份更弱的手抄:注释要求「本机用户必须指向自己的网关」,判据却只查 `/v1` 后缀,
58
+ * 旧的出厂缺省值恰好以 `/v1` 结尾 ⇒ 缺省值照样过(本仓自己也被骗)。`/v1` 后缀本身也一并删,理由三条:
59
+ * ① 同一个问题两个判官 —— `serve` 从不要求 `/v1`,同一份 .env 在 `serve` 起得来、在 `run-local` 被拒;
60
+ * ② 它会误拒真实存在的 OpenAI 兼容端点(DeepSeek 根路径直接服务 `/chat/completions`,本仓
61
+ * `src/bench/s1/live-deps.ts` 用的正是 `https://api.deepseek.com`;Gemini 的兼容面 `…/v1beta/openai`、Azure 的
62
+ * `…/openai/deployments/<name>` 也都不以 `/v1` 结尾);
63
+ * ③ 规则集只许变小:端点判据只有 loadConfig 那一只。
54
64
  *
55
65
  * NOTE: with an injected mock `brain` the gateway is irrelevant (no real call is made), so the caller skips
56
66
  * this check in that case — the check is about a REAL run, not the assembly.
package/dist/run-local.js CHANGED
@@ -18,7 +18,6 @@ import { taskWallClockSec } from "./task-workflow.js";
18
18
  import { applyCatalogToSource } from "./capabilities/center-prompts.js";
19
19
  import { validatePromptsDomain, CORE_ENGINE_VERSION } from "./prompts-domain-validate.js";
20
20
  import { buildUnpricedCeilingNotice, loadConfig, logConfigDiagnostics, parseNumOrFailNonNegative } from "./config.js";
21
- import { clearHostedEvidenceEnv } from "./hosted-posture.js";
22
21
  import { buildStoreOpenRefusalTail } from "./plugins/store-open-refusal.js";
23
22
  import { acquireLocalBackendWithWait } from "./plugins/store-lock-wait.js";
24
23
  import { createConfigProvider } from "./config-provider.js";
@@ -34,7 +33,8 @@ import { createTaskListLane } from "./boot/task-list-lane.js";
34
33
  import { pickHandsRunner, withoutExecutionEnv } from "./capabilities/hands-lane.js";
35
34
  import { HttpError } from "./security.js";
36
35
  import { createMemoryEngineIncidentSink } from "./memory-incident-sink.js";
37
- import { memoryEngineBackendFor, memorySpecForRequest } from "./memory-scope.js";
36
+ import { memoryEngineBackendFor, memoryProjectKeyFor, memorySpecForRequest } from "./memory-scope.js";
37
+ import { modelRouteEndpointRefusal } from "./model-route-endpoint.js";
38
38
  import { createMemorySyncRunner, createMemorySyncTransport } from "./memory-sync-client.js";
39
39
  import { buildPricing, cappedCeiling, createTracer } from "./budget.js";
40
40
  import { createSharedRunnerDeps } from "./boot/runner-deps.js";
@@ -99,9 +99,8 @@ export class UsageError extends Error {
99
99
  }
100
100
  export function missingGatewayRequirements(config) {
101
101
  const missing = [];
102
- if (!config.gatewayBaseUrl || !/\/v1\/?$/.test(config.gatewayBaseUrl)) {
103
- missing.push("MODEL_GATEWAY_BASEURL (the OpenAI-compatible gateway URL, must end in /v1)");
104
- }
102
+ if (config.gatewayBaseUrl === undefined)
103
+ return missing;
105
104
  const perModelEnvs = Object.values(config.modelApiKeyEnv ?? {});
106
105
  const haveGatewayKey = !!config.gatewayApiKey;
107
106
  const catalogModelNames = new Set(Object.values(config.models ?? {}).map((m) => m.name));
@@ -176,7 +175,6 @@ export async function runLocal(argv, deps = {}) {
176
175
  process.env.CONFIG_PROVIDER = "local";
177
176
  process.env.SESSION_BACKEND = "memory";
178
177
  process.env.REQUIRE_PRINCIPAL = "false";
179
- clearHostedEvidenceEnv();
180
178
  const root = args.root ?? process.env.CONFIG_LOCAL_DIR ?? process.env.AGENT_DATA_DIR ?? join(homedir(), ".ai-agent");
181
179
  if (args.root)
182
180
  process.env.CONFIG_LOCAL_DIR = args.root;
@@ -209,6 +207,9 @@ export async function runLocal(argv, deps = {}) {
209
207
  catch (err) {
210
208
  logger.warn("config_source_unreachable_using_env", { source: configProvider.kind, err: String(err) });
211
209
  }
210
+ const endpointRefusal = modelRouteEndpointRefusal(config);
211
+ if (endpointRefusal !== undefined)
212
+ throw new Error(endpointRefusal);
212
213
  const unpricedCeiling = buildUnpricedCeilingNotice(config.models, config);
213
214
  if (unpricedCeiling)
214
215
  logger.warn(unpricedCeiling.event, unpricedCeiling.fields);
@@ -477,7 +478,7 @@ export async function runLocal(argv, deps = {}) {
477
478
  backgroundScope: "session",
478
479
  oneShot: true,
479
480
  principal: scope,
480
- ...(memoryEngine ? { memory: memorySpecForRequest(scope, undefined) } : {}),
481
+ ...(memoryEngine ? { memory: memorySpecForRequest(scope, memoryProjectKeyFor(config, scope, workspaceDir), undefined) } : {}),
481
482
  model: mention.model ?? "default",
482
483
  getApiKeyAndHeaders: keyResolver,
483
484
  tools: cap.tools,
@@ -54,6 +54,11 @@
54
54
  * `allowObservers` 是 `=== true` opt-in,缺席已等于关;`allowMemoryOptOut` 是隐私轴的 FAULT-TRANSIT 契约
55
55
  * (core types.d.ts 逐字禁止在 fault 上自造一个显式值)—— 铸 false 就是把故障臂默默换成判决臂。
56
56
  *
57
+ * 🔐 [ref] E-15(7.99.0 合并复审 F2):缓存、在飞去重与 ETag 都在**凭证身份的箱**里(`center-credential.ts`
58
+ * `createIdentityBins`,org 记忆目录同一只)——此前只按 principal 键,同机换账号后 TTL 内同一 principal 吃上一个
59
+ * 人的授予、搭上一个人的在飞请求、带上一个人的 etag 去换 304。在飞期间换了人,答复由传输层丢弃
60
+ * (`http-client.ts` `requestPrincipalView`),按这一次取数失败走下面的 fail-closed 臂(降级项落在发起那一箱)。
61
+ *
57
62
  * The resolver maps center's `EntitlementRuntimeCaps` → the SUBSET core enforces ({@link RuntimeCaps}): center
58
63
  * also carries `allowUltracode` (a shell/UX reasoning-tier cap the engine has no primitive for), which we DROP —
59
64
  * this seam feeds the ENGINE, and `allowUltracode` is enforced at the service settings-resolution layer, not here.
@@ -61,14 +66,15 @@
61
66
  import type { RuntimeCaps } from "@sema-agent/core";
62
67
  import type { EntitlementRuntimeCaps } from "@sema-agent/settings-schema";
63
68
  import { fetchPrincipalCaps, type ExecutionRuling } from "./config-center/facade.js";
69
+ import type { CenterEndpoint } from "./config-center/center-request.js";
64
70
  import type { ScenarioRuling } from "./capabilities/scenarios.js";
65
71
  import type { ServiceConfig } from "./config-types.js";
66
72
  import { type Logger } from "./observability/logger.js";
67
73
  /** test-only: reset the once-per-process trace so each test observes a fresh "first hit". */
68
74
  export declare function resetExecutionLaneDefaultTraceForTest(): void;
69
75
  export interface EntitlementsResolverOpts {
70
- baseUrl: string;
71
- token: string;
76
+ /** S-651:中心端点(基址 + 凭证**来源**;每次抓取取一次值,不持有启动快照)。 */
77
+ center: CenterEndpoint;
72
78
  /** This worker's name (`SEMA_REGISTRY_WORKER`), passed as `?worker=` so a worker-scoped `wpt_<self>` token can
73
79
  * resolve its OWN worker's principal caps (caps-only); harmless on a full token (the caps value
74
80
  * is worker-independent). Omitted ⇒ no `?worker=` (a full-token, non-fleet deployment). */
@@ -136,12 +142,6 @@ export declare function toCoreRuntimeCaps(ent: EntitlementRuntimeCaps | null | u
136
142
  * 所以本谓词为 false **不再**意味着 auto 不武装;「会不会武装」由 `auto-mode-face.ts` 的三项合取答。
137
143
  */
138
144
  export declare function centerEntitlementSourceWired(config: Pick<ServiceConfig, "configCenter">): boolean;
139
- /** A MISCONFIG diagnostic (review MEDIUM): a worker-scoped `wpt_<self>` pull token REQUIRES `?worker=<self>` to
140
- * resolve per-principal caps — so a `wpt_` token with NO `SEMA_REGISTRY_WORKER` set will 403
141
- * on every caps fetch → fail-closed DENY ALL workflows for ALL principals (correct degrade, but otherwise silent
142
- * bar per-task warns). True ⇒ the caller should log a one-shot BOOT warning so the misconfig is diagnosable.
143
- * (The orchestrator normally injects SEMA_REGISTRY_WORKER alongside a wpt_ token, so this is a hand-misconfig.) */
144
- export declare function scopedTokenNeedsWorker(token: string, worker: string | undefined): boolean;
145
145
  /** The two per-principal faces served off ONE caps fetch + cache. */
146
146
  export interface PrincipalEntitlementsClient {
147
147
  /** core's `RunnerDeps.runtimeCapsResolver` seam — FAIL-CLOSED on error (deny workflows/fork/autoMode;
@@ -1,4 +1,5 @@
1
1
  import { fetchPrincipalCaps } from "./config-center/facade.js";
2
+ import { createIdentityBins } from "./center-credential.js";
2
3
  import { HttpError } from "./security.js";
3
4
  import { createLogger } from "./observability/logger.js";
4
5
  import { recordFailOpen } from "./observability/fail-open.js";
@@ -54,9 +55,6 @@ export function toCoreRuntimeCaps(ent) {
54
55
  export function centerEntitlementSourceWired(config) {
55
56
  return config.configCenter !== undefined && !config.configCenter.dryRun;
56
57
  }
57
- export function scopedTokenNeedsWorker(token, worker) {
58
- return token.startsWith("wpt_") && !worker;
59
- }
60
58
  export function gateExecutionLane(ruling, ownLane, logger = defaultLogger) {
61
59
  if (!ruling) {
62
60
  traceExecutionLaneDefault("no_ruling", ownLane, logger);
@@ -80,11 +78,14 @@ export function createPrincipalEntitlementsClient(opts) {
80
78
  const fetchCaps = opts.fetchCaps ?? fetchPrincipalCaps;
81
79
  const now = opts.now ?? (() => Date.now());
82
80
  const onError = opts.onError ?? ((err, principal) => defaultLogger.warn("runtime_caps_resolve_failed", { principal, err: String(err) }));
83
- const cache = new Map();
84
- const inflight = new Map();
81
+ const binNow = createIdentityBins(opts.center.credential, (credential) => ({
82
+ center: { baseUrl: opts.center.baseUrl, credential },
83
+ cache: new Map(),
84
+ inflight: new Map(),
85
+ }));
85
86
  const readMintedDeny = new WeakSet();
86
87
  const MAX_CACHE = 10_000;
87
- const putCache = (key, entry) => {
88
+ const putCache = (cache, key, entry) => {
88
89
  if (cache.size >= MAX_CACHE) {
89
90
  const t = now();
90
91
  for (const [k, v] of cache)
@@ -100,15 +101,16 @@ export function createPrincipalEntitlementsClient(opts) {
100
101
  return entry;
101
102
  }
102
103
  async function loadRaw(principal, recordDeny) {
103
- const cached = cache.get(principal);
104
+ const bin = binNow();
105
+ const cached = bin.cache.get(principal);
104
106
  if (cached && cached.expiresAt > now() && !(recordDeny && readMintedDeny.has(cached)))
105
107
  return cached;
106
- const dup = inflight.get(principal);
108
+ const dup = bin.inflight.get(principal);
107
109
  if (dup)
108
110
  return dup;
109
111
  const p = (async () => {
110
112
  try {
111
- const r = await fetchCaps(opts.baseUrl, opts.token, principal, cached?.etag, fetchImpl, opts.worker);
113
+ const r = await fetchCaps(bin.center, principal, cached?.etag, fetchImpl, opts.worker);
112
114
  if (r === null) {
113
115
  const entry = {
114
116
  caps: cached?.caps,
@@ -117,7 +119,7 @@ export function createPrincipalEntitlementsClient(opts) {
117
119
  ...(cached?.etag ? { etag: cached.etag } : {}),
118
120
  expiresAt: now() + ttlMs,
119
121
  };
120
- putCache(principal, entry);
122
+ putCache(bin.cache, principal, entry);
121
123
  return entry;
122
124
  }
123
125
  if (r.executionDrift) {
@@ -133,7 +135,7 @@ export function createPrincipalEntitlementsClient(opts) {
133
135
  ...(r.etag ? { etag: r.etag } : {}),
134
136
  expiresAt: now() + ttlMs,
135
137
  };
136
- putCache(principal, entry);
138
+ putCache(bin.cache, principal, entry);
137
139
  return entry;
138
140
  }
139
141
  catch (err) {
@@ -147,7 +149,7 @@ export function createPrincipalEntitlementsClient(opts) {
147
149
  const entry = { caps: { allowWorkflows: false, allowFork: false, autoMode: false, autoModeDenySource: "org" }, expiresAt: now() + denyTtlMs };
148
150
  if (!recordDeny)
149
151
  readMintedDeny.add(entry);
150
- putCache(principal, entry);
152
+ putCache(bin.cache, principal, entry);
151
153
  try {
152
154
  onError(e, principal);
153
155
  }
@@ -155,10 +157,10 @@ export function createPrincipalEntitlementsClient(opts) {
155
157
  return entry;
156
158
  }
157
159
  finally {
158
- inflight.delete(principal);
160
+ bin.inflight.delete(principal);
159
161
  }
160
162
  })();
161
- inflight.set(principal, p);
163
+ bin.inflight.set(principal, p);
162
164
  return p;
163
165
  }
164
166
  return {
@@ -3,6 +3,8 @@ import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import _sodium from "libsodium-wrappers";
5
5
  import { buildDisplaySafeUrl } from "./trace/redact.js";
6
+ import { createCenterCredentialSource } from "./center-credential.js";
7
+ import { centerFetch } from "./config-center/center-request.js";
6
8
  export const SEALED_KEY_ALG = "libsodium-sealedbox-x25519";
7
9
  const SEAL_OVERHEAD_BYTES = 48;
8
10
  const BASE64_STRICT = /^[A-Za-z0-9+/]+={0,2}$/;
@@ -114,13 +116,7 @@ export async function reportExecutionPublicKey(opts) {
114
116
  return;
115
117
  }
116
118
  try {
117
- const doFetch = opts.fetchImpl ?? fetch;
118
- const res = await doFetch(`${baseUrl.replace(/\/$/, "")}/api/config/execution-keys`, {
119
- method: "POST",
120
- headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/json" },
121
- body: JSON.stringify({ publicKey: key.publicKey, publicKeyId: key.publicKeyId, ...(notes ? { notes } : {}) }),
122
- signal: AbortSignal.timeout(10_000),
123
- });
119
+ const { res } = await centerFetch({ baseUrl, credential: createCenterCredentialSource({ from: "env", token: adminToken }, undefined) }, { kind: "execution-keys" }, { method: "POST", json: { publicKey: key.publicKey, publicKeyId: key.publicKeyId, ...(notes ? { notes } : {}) }, timeoutMs: 10_000, ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });
124
120
  if (res.ok) {
125
121
  let idempotent = false;
126
122
  try {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 7.99.0 合并复审 F7(web-admin [ref] 实测;安全轴;DEBTS S-684)—— 子进程 env 交出去之前的**本仓**密钥剥除。
3
+ *
4
+ * ## 病
5
+ * 本仓两条子进程腿 —— 模型可驱动的 host shell(`inheritEnv:"scrub"` 缺省腿,`plugins/remote-env-host.ts`)与
6
+ * project-memory 的 git 子进程(`project-memory.ts`)—— 此前只调 core 的 `scrubSecretEnv`。它按键名**形状**判
7
+ * (`*_KEY` / `*_TOKEN` / `*_SECRET` …),而本仓自己的凭据键里有形状规则认不出的:`SERVICE_AUTH_TOKENS`(复数,
8
+ * per-system 凭据表)、`APPROVAL_HMAC_KEYS`(直连门 HMAC 密钥集)、`OTEL_EXPORTER_OTLP_HEADERS`(常载鉴权头)⇒
9
+ * 原样进子进程,模型选的一句 `printenv` 就读得到。
10
+ *
11
+ * ## 规则(一句)
12
+ * 本仓**知道**哪些 env 键是凭据 —— 配置目录(`config-catalog.ts`)里 `type:"secret"` 的每一行,从目录机器派生,
13
+ * 不手抄第二份名单 —— 这些键按名剥;其余照旧交 core 按形剥。{@link scrubServerSecrets} = 目录派生剥除 ∘ core
14
+ * `scrubSecretEnv`,两条腿都只调它。core 形状规则的缺口由 core 源头修(另案);本层不改 core 的判据,也不自造
15
+ * 第二份形状规则。目录里加一个 secret 型键,两条腿当场就剥它。
16
+ *
17
+ * ## 不变的面
18
+ * `inheritEnv:"all"`(显式全继承)与显式 allowlist 两形不经本函数,行为逐字不变;调用方显式传入的 env
19
+ * (`cfg.env` / 单次命令的 `env`)照旧原样覆盖在剥除结果之上。
20
+ */
21
+ import { type SecretEnvFinding } from "@sema-agent/core";
22
+ /** 目录派生臂的一条剥除记录(形同 core 的 `SecretEnvFinding`:键名 / 规则 / 置信度 / 来源;值永不携带)。
23
+ * `source` 用本仓自己的词 `server-config-catalog`,与 core 的 `secret-env` 分开 —— 读观测面的人要分得清
24
+ * 「形状规则剥的」与「本仓目录点名剥的」(后者多出来的那部分正是 core 形状规则的缺口)。 */
25
+ export interface CatalogSecretEnvFinding {
26
+ readonly key: string;
27
+ readonly kind: "config-catalog";
28
+ readonly confidence: "high";
29
+ readonly source: "server-config-catalog";
30
+ }
31
+ /** 本仓两条子进程腿的剥除记录:core 的形状臂 ∪ 本仓的目录臂。 */
32
+ export type ServerSecretEnvFinding = SecretEnvFinding | CatalogSecretEnvFinding;
33
+ /** 配置目录里 `type:"secret"` 的 env 名(大写 —— Windows 的 env 名不分大小写,按大写比才不会漏)。 */
34
+ export declare const CATALOG_SECRET_ENV_NAMES: ReadonlySet<string>;
35
+ /**
36
+ * 先交 core 按形剥,再按目录名剥它漏下的。`findings` 是调用方持有的收集器(交给 `recordSecretEnvScrub` 留痕):
37
+ * 形状臂剥掉的记 core 的 finding;目录臂只记**形状臂漏下、被目录点名剥掉**的键 —— 于是既有的形状臂计数不变,
38
+ * 目录臂的计数恰好就是 core 形状规则的缺口读数。
39
+ */
40
+ export declare function scrubServerSecrets(env: NodeJS.ProcessEnv, findings: ServerSecretEnvFinding[]): NodeJS.ProcessEnv;
41
+ //# sourceMappingURL=server-secret-env.d.ts.map
@@ -0,0 +1,18 @@
1
+ import { scrubSecretEnv } from "@sema-agent/core";
2
+ import { CONFIG_CATALOG } from "./config-catalog.js";
3
+ export const CATALOG_SECRET_ENV_NAMES = new Set(CONFIG_CATALOG.filter((spec) => spec.type === "secret").map((spec) => spec.name.toUpperCase()));
4
+ export function scrubServerSecrets(env, findings) {
5
+ const shapeFindings = [];
6
+ const shaped = scrubSecretEnv(env, shapeFindings);
7
+ findings.push(...shapeFindings);
8
+ const out = {};
9
+ for (const key of Object.keys(shaped)) {
10
+ if (CATALOG_SECRET_ENV_NAMES.has(key.toUpperCase())) {
11
+ findings.push({ key, kind: "config-catalog", confidence: "high", source: "server-config-catalog" });
12
+ continue;
13
+ }
14
+ out[key] = shaped[key];
15
+ }
16
+ return out;
17
+ }
18
+ //# sourceMappingURL=server-secret-env.js.map
@@ -76,6 +76,50 @@ export type ParkedAskRedeem = (req: ParkedAskRedeemRequest) => Promise<{
76
76
  status: number;
77
77
  body: Record<string, unknown>;
78
78
  }>;
79
+ /**
80
+ * 一问身上与「站着的答案」有关的三个事实位 —— {@link standingAnswerMayRetire} 的全部入参。
81
+ *
82
+ * 🔴 **三格全必填布尔**,刻意不做 `?: true` 的「只记真」形:判据哪天多一位,每个读者都要当场编译红,
83
+ * 而不是漏传一格被静默读成 `false`(那是安全轴上的 fail-open)。读者各自从自己手里的形折成这三格:
84
+ * live 读侧取 `req` + 本地治理 peek,live 写侧取 pending 条目。本型**不绑** live 条目 —— 耐久 `/decide`
85
+ * 腿候 core 给 `PendingAction` 补 `mandated` 孪生位后按同一形接入(见 S-663;今天行上只有
86
+ * `requiresRealApproval` 孪生与依赖车道的 `ruleOffersAbsence`,缺一位不先接两位)。
87
+ */
88
+ export interface StandingAnswerFacts {
89
+ /** 本仓治理层(`AUTONOMY` / `commandPolicy` / shellGate / `SENSITIVE_WRITE_PATTERNS`)产的门。 */
90
+ readonly governanceForced: boolean;
91
+ /** core 闸站盖的「必须真人逐次批」位(连一揽子 `onAsk:"allow"` 席也不得答)。 */
92
+ readonly requiresRealApproval: boolean;
93
+ /** core 闸站盖的强制位([ref]):规则清不掉、站着的答案退不掉,只有对**这一次调用**的应答能答。 */
94
+ readonly mandated: boolean;
95
+ }
96
+ /**
97
+ * 🔴 **S-660 —— 会话级放行的唯一判据:站着的答案能不能退掉这一问。** 纯谓词,两个(将来三个)读者:
98
+ * · **读侧**(`askBroadcast` 的会话授权短路):`true` 才可能由既有 grant 代答,否则往下走真出卡;
99
+ * · **写侧**(`finishRespond` 的 `grantable`):`true` 才可能把这张卡上的 `allow_session` 铸成 grant,
100
+ * 否则只放行这一次、`rememberApplied:false`。
101
+ * 两臂读同一份事实、说同一句话:一问若不许被记住的放行答掉,它的应答也不许变成一条记住的放行 —— 否则
102
+ * 同一只卡「重跑一遍」就被自己上一次的回答代答(test [ref] U1s-off 的真形)。
103
+ *
104
+ * **为什么三位是同一类**(「站着的答案退不掉的一问」):会话授权 = **上一次**应答记下的一揽子放行,不是对
105
+ * **这一次**调用的应答 —— 与持久 allow 规则同族,只是更短命(`askBroadcast` 短路处的注早已这么定性)。
106
+ * · `mandated`:core 7.29.0 `PermissionResult` ask 臂 `mandated` 成员的文档「no persisted allow rule and no standing
107
+ * posture may retire it, only an answer to THIS call」;`ruleClearanceOf` 的文档「Allow rules silence the
108
+ * classifier's questions, never a mandated one」。core 09-23 澄清(将在 7.30.0 落进 d.ts):「从早先某次
109
+ * 回答记住的放行,**任何 scope、会话级也算**」都是站着的答案。它**不**绑这一次调用的应答者(人、hook、
110
+ * 部署的一揽子 `onAsk:"allow"` 席、mandate 表允许的活判定者)—— 那一半本仓不动(见 `mandated` 帧键顶注)。
111
+ * · `requiresRealApproval`([ref]):更强的那条,连一揽子席也不得答,当然也不许记住的放行答。
112
+ * · `governanceForced`([ref]):运维治理层的门,客户端表态掀不掉;持久规则车道早已同判(`buildRuleLaneMaterial`)。
113
+ * 本谓词**不重推** core 的 `ruleClearanceOf` / `ruleMayClear`(core 刻意不导出:闸外重算拿不到同一份标记快照,
114
+ * 会分叉),只读闸站**盖好**的两位 + 本仓自己的治理标。
115
+ *
116
+ * **为什么读侧的行复核(`sessionGrantUncontestedByRow`)不必为 `mandated` 加一次行读**:`mandated` 是 `req`
117
+ * **自己**的位 —— core 每一次过闸(含 failover / 重启后的重入)都由 `runGateLanes` 里的 `stampAskOrigin`
118
+ * 重新盖、经同一处的 `carryOf` 带上 `AskRequest`,读侧手里的 `req` 恒是这一次过闸的真值。`governanceForced`
119
+ * 需要行读,是因为它来自本仓的**进程内**治理标表:failover / 重启后幂等命中既有行时本地表恒空,行上的卡才
120
+ * 存着落库那一刻的判定。何况行上本来就没有 `mandated`(耐久面不造孪生座)—— 读了也读不到。
121
+ */
122
+ export declare function standingAnswerMayRetire(facts: StandingAnswerFacts): boolean;
79
123
  /** A live approval frame delivered to whoever tails this run's stream. `type` IS the SSE event name (named-event
80
124
  * convention, same as question). The shell renders `tool_approval` as the CC three-choice card and dismisses on
81
125
  * `tool_approval_complete`. */
@@ -150,7 +194,10 @@ export interface ToolApprovalFrame {
150
194
  * 「不是强制」「老引擎」「这是一张从 park 行上重铸的耐久卡」三形 —— **耐久面上本仓不造孪生座**:core 的
151
195
  * 寄存行(`PendingAction`)没有这一位(KL-997a 亲读登记),行上强制表现为 `ruleOffersAbsence:"mandated"`
152
196
  * 且无 `ruleOffers`;在 `card_json` 侧自铸一个就是本仓成了这个事实的第二个源(而 park 那侧根本没有输入)。
153
- * 🔴 echo-only:server 不据它做任何裁决(强制的判定属主在 core 的闸站)。
197
+ * 🔴 **server 不判「是不是强制」**(判定属主在 core 的闸站),只**读**闸站盖好的这一位 —— 读者恰一个:
198
+ * S-660 起的会话级放行判据 {@link standingAnswerMayRetire}(会话授权是**上一次**应答记下的站着的答案,
199
+ * 不是对这一次调用的应答 ⇒ 强制卡上既不被既有 grant 代答、也不铸 grant)。上一段「一揽子 `onAsk:"allow"`
200
+ * 席照常答得了」说的是**这一次调用的应答者**,本仓不改它。
154
201
  */
155
202
  mandated?: true;
156
203
  /**
@@ -79,6 +79,9 @@ function sessionAllowCategory(toolName) {
79
79
  function sessionAllowKey(owner, sessionId, category) {
80
80
  return JSON.stringify([owner, sessionId, category]);
81
81
  }
82
+ export function standingAnswerMayRetire(facts) {
83
+ return !facts.governanceForced && !facts.requiresRealApproval && !facts.mandated;
84
+ }
82
85
  export const PERSIST_RULE_TEXT_ERROR = `persistRule.rule must be a non-empty string of at most ${MAX_RULE_TEXT_CHARS} characters`;
83
86
  export const PERSIST_RULE_EDITED_FLAG_ERROR = "persistRule.edited must be a boolean when present";
84
87
  export const PERSIST_RULE_BATCH_INDEX_ERROR = "persistRule.batchOfferIndex must be a non-negative integer when present";
@@ -829,8 +832,7 @@ export class ToolApprovalCoordinator {
829
832
  }
830
833
  const primary = aliveCtxs[0];
831
834
  const governanceForced = (this.governanceAskMarks ?? governanceAskMarksFor(this.streamKey(origin.owner, origin.sessionId, origin.taskId))).isMarked(req.toolCallId);
832
- if (!governanceForced &&
833
- req.requiresRealApproval !== true &&
835
+ if (standingAnswerMayRetire({ governanceForced, requiresRealApproval: req.requiresRealApproval === true, mandated: req.mandated === true }) &&
834
836
  origin.sessionId &&
835
837
  this.allowAllSessions.has(sessionAllowKey(origin.owner, origin.sessionId, sessionAllowCategory(req.toolName))) &&
836
838
  (await this.sessionGrantUncontestedByRow(origin, req))) {
@@ -1385,6 +1387,7 @@ export class ToolApprovalCoordinator {
1385
1387
  ...(ruleLaneMaterial !== undefined ? { ruleLane: ruleLaneMaterial } : {}),
1386
1388
  ...(effectiveGovernanceForced ? { governanceForced: true } : {}),
1387
1389
  ...(req.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
1390
+ ...(req.mandated === true ? { mandated: true } : {}),
1388
1391
  ...(askId !== undefined && batchId !== undefined ? { askId, batchId } : {}),
1389
1392
  };
1390
1393
  selfEntry = pendingEntry;
@@ -1962,7 +1965,11 @@ export class ToolApprovalCoordinator {
1962
1965
  }
1963
1966
  finishRespond(id, entry, parsed, opts) {
1964
1967
  let rememberApplied;
1965
- const grantable = entry.requiresRealApproval !== true && entry.governanceForced !== true;
1968
+ const grantable = standingAnswerMayRetire({
1969
+ governanceForced: entry.governanceForced === true,
1970
+ requiresRealApproval: entry.requiresRealApproval === true,
1971
+ mandated: entry.mandated === true,
1972
+ });
1966
1973
  if (parsed.value === "allow_session")
1967
1974
  rememberApplied = Boolean(entry.sessionId) && grantable;
1968
1975
  if (parsed.value === "allow_session" && entry.sessionId && grantable) {
@@ -190,7 +190,7 @@ export declare function isHierarchicalUrl(raw: string): boolean;
190
190
  * ① **有效性闸**:配置里的那一位应当是一个**绝对 URL**。`new URL(raw)` 解析不动 ⇒ 这一位根本不是 URL
191
191
  * (裸 `host:port`、相对路径、手滑的散文),自由文本的 URL 律对它没有定界依据 ⇒ 返回**占位**
192
192
  * {@link DISPLAY_SAFE_URL_TOKEN},**不回落原值**([ref]:安全轴上的兜底必须 fail-closed 或响亮)。
193
- * 成文代价:`MODEL_GATEWAY_BASEURL=127.0.0.1:8000` 这种缺 scheme 的配置在日志上变成 `«redacted:url»`
193
+ * 成文代价:`MODEL_GATEWAY_BASEURL=gw.internal:8000` 这种缺 scheme 的配置在日志上变成 `«redacted:url»`
194
194
  * —— 而它在 `fetch` 那一侧本来就是必然失败的形(WHATWG 要求绝对 URL),日志上响亮一点方向一致。
195
195
  * ② **空串 = 未设**:里面没有凭据,原样回空串(不铸占位 —— 占位会让「没配」读成「配了但看不见」)。
196
196
  * ⚠️ **保形只到 host / port / path 这一层**(S-483 起如实改口;`test/config-url-display-safe.test.ts`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.97.0",
3
+ "version": "7.99.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",
@@ -38,10 +38,11 @@
38
38
  "sema-up": "deploy/sema-up/sema-up.sh"
39
39
  },
40
40
  "engines": {
41
- "node": ">=20"
41
+ "node": "^20.19.0 || >=22.12.0"
42
42
  },
43
43
  "scripts": {
44
44
  "typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
45
+ "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
45
46
  "build": "rm -rf dist && tsc -p tsconfig.build-js.json && tsc -p tsconfig.build-dts.json && node scripts/scrub-dist-refs.mjs dist",
46
47
  "prepublishOnly": "npm run build && vitest run test/pack-hygiene.test.ts",
47
48
  "start": "node dist/main.js",
@@ -61,7 +62,7 @@
61
62
  },
62
63
  "dependencies": {
63
64
  "@sema-agent/core": "7.29.0",
64
- "@sema-agent/settings-schema": "3.0.0",
65
+ "@sema-agent/settings-schema": "5.0.0",
65
66
  "e2b": "^2.28.0",
66
67
  "libsodium-wrappers": "^0.8.4",
67
68
  "mysql2": "^3.22.4",