@sema-agent/server 6.8.0 → 7.1.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.
@@ -58,9 +58,18 @@ export declare function memoryEngineBackendFor(config: ServiceConfig, fallbackRo
58
58
  * (memory feature off for this run). Pure (testable in isolation) — main.ts's resolveSpec composes it with the
59
59
  * engine-backend presence guard.
60
60
  */
61
- export declare function memorySpecForRequest(scope: string | undefined, memoryWrite: boolean | undefined, defaultScopes?: string[]): {
61
+ export declare function memorySpecForRequest(scope: string | undefined, memoryWrite: boolean | undefined, defaultScopes?: string[],
62
+ /** design/170 件A(#148 件3④):origin 盖章的部署形态维(N2)。`multiTenant=true`(requirePrincipal)
63
+ * ⇒ 登记簿 defaultScopes 的 org 键按 **request** 盖章(条目由调用方 projectId 选定=caller 可影响的
64
+ * 选择器,core 准入门据此过目录判决);单用户 ⇒ 一律 deployment(operator 登记簿条目归 deployment,
65
+ * 否则单用户部署被自家规则整拒——v4 §0 伤害①的成立前提)。缺参=旧调用形,不盖章(整键缺席=
66
+ * core legacy 语义,零迁移)。 */
67
+ originPolicy?: {
68
+ multiTenant: boolean;
69
+ }): {
62
70
  scopes: string[];
63
71
  writeScope?: string | null;
64
72
  scopeContract?: "v2";
73
+ scopeOrigins?: Record<string, "deployment" | "request">;
65
74
  } | undefined;
66
75
  //# sourceMappingURL=memory-scope.d.ts.map
@@ -98,7 +98,13 @@ export function memoryEngineBackendFor(config, fallbackRoot) {
98
98
  * (memory feature off for this run). Pure (testable in isolation) — main.ts's resolveSpec composes it with the
99
99
  * engine-backend presence guard.
100
100
  */
101
- export function memorySpecForRequest(scope, memoryWrite, defaultScopes) {
101
+ export function memorySpecForRequest(scope, memoryWrite, defaultScopes,
102
+ /** design/170 件A(#148 件3④):origin 盖章的部署形态维(N2)。`multiTenant=true`(requirePrincipal)
103
+ * ⇒ 登记簿 defaultScopes 的 org 键按 **request** 盖章(条目由调用方 projectId 选定=caller 可影响的
104
+ * 选择器,core 准入门据此过目录判决);单用户 ⇒ 一律 deployment(operator 登记簿条目归 deployment,
105
+ * 否则单用户部署被自家规则整拒——v4 §0 伤害①的成立前提)。缺参=旧调用形,不盖章(整键缺席=
106
+ * core legacy 语义,零迁移)。 */
107
+ originPolicy) {
102
108
  if (!scope)
103
109
  return undefined;
104
110
  // 142-S1.5: a scope carrying a v2 typed prefix rides WITH the explicit contract marker — core validates
@@ -119,7 +125,21 @@ export function memorySpecForRequest(scope, memoryWrite, defaultScopes) {
119
125
  // 最后一层就是派生 scope=正确,不钉保持最小形)。
120
126
  const layered = extras.length > 0 ? { scopes: [scope, ...extras], writeScope: scope } : { scopes: [scope] };
121
127
  const v2 = isV2(scope) || extras.some(isV2) ? { scopeContract: "v2" } : {};
128
+ // 件A origin 盖章(core 5.13.0 `memory.scopeOrigins` 两格语义):只盖 org 键(非 org 不入准入判决,
129
+ // v4 §1);派生/env 位的 `scope` org 形只可能来自 operator 显式 MEMORY_SCOPE(memoryScopeFor 多租户
130
+ // 恒铸 user: 形)⇒ deployment;登记簿种子按部署形态维分格(见参数注)。零 org 键 ⇒ 整键缺席
131
+ // (core legacy 零迁移;非 org 部署形状逐字节不变=additive 锁)。
132
+ const stamped = {};
133
+ if (originPolicy !== undefined) {
134
+ if (scope.startsWith("org:"))
135
+ stamped[scope] = "deployment";
136
+ for (const extra of extras) {
137
+ if (extra.startsWith("org:"))
138
+ stamped[extra] = originPolicy.multiTenant ? "request" : "deployment";
139
+ }
140
+ }
141
+ const origins = Object.keys(stamped).length > 0 ? { scopeOrigins: stamped } : {};
122
142
  // memoryWrite === false(MF-30 pause)wins over the layered writeScope pin — 只读语义在有种子时同样成立。
123
- return memoryWrite === false ? { ...layered, writeScope: null, ...v2 } : { ...layered, ...v2 };
143
+ return memoryWrite === false ? { ...layered, writeScope: null, ...v2, ...origins } : { ...layered, ...v2, ...origins };
124
144
  }
125
145
  //# sourceMappingURL=memory-scope.js.map
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Org 记忆准入 resolver — server 侧对接 @sema-agent/core@5.13.0 `RunnerDeps.memoryScopeAdmission` seam。
3
+ * 授权事实来自「目录」抽象:远程腿 = config-center per-principal caps 响应里的 `orgMemory` 段(形状
4
+ * 同源锚 = registry-core `PrincipalOrgMemoryWire`,zod schema 直接 safeParse);单机腿 = env JSON 静态表
5
+ * (`MEMORY_ORG_DIRECTORY_JSON`,{@link parseOrgDirectoryStatic} 装载,启动期 fail-loud)。
6
+ *
7
+ * 判别联合 {@link OrgDirectoryLookup} 是本模块的核心裁定(设计 N3/C5):取数成功(granted,含零授权空表
8
+ * = 负结果)与瞬时不可用(unavailable)绝不塌成一格——granted 空表是 resolver 可以终局判决的「事实」
9
+ * (「这个 principal 没有这个 scope」),unavailable 必须让 core 铸瞬时码 memory.admission_required 重试,
10
+ * 而不是被误判成「没这个 scope」而永久拒绝。
11
+ *
12
+ * core 侧契约(RunnerDeps.memoryScopeAdmission,core types.d.ts):resolver 返回 {ok:false} ⇒ core 铸终局码
13
+ * memory.admission_denied;resolver throw 一个带 `retryAfterMs`(ms)属性的 Error ⇒ core 铸瞬时码
14
+ * memory.admission_required 并透传 retryAfterMs;{ok:true}.scopes 只能 ⊆ requested(多给 = resolver fault
15
+ * 会被 core 整拒)。core 只在「存在 request-origin org scope 且 principal 缺席」时自己先拒——本模块仍防御
16
+ * `principal === undefined` 这一支(不能信任「core 一定先拒过」这件事)。
17
+ */
18
+ import type { MemoryScopeAdmission } from "@sema-agent/core";
19
+ /**
20
+ * 单机腿:装载 `MEMORY_ORG_DIRECTORY_JSON`(`Record<principal, Record<orgScope, {write?}>>`)。启动期调用,
21
+ * **fail-loud**——任何非法直接 throw 且消息点名坏在哪个键(响亮拒是正确方向:一张半坏的授权表比拒绝
22
+ * 启动更危险)。principal 键复用 {@link assertPrincipalShape}(拒保留哨兵/超长租户名,与所有其它
23
+ * principal 入口同一道闸);scope 键必须是 `org:` 前缀、值必须是仅含可选 `write: boolean` 的 plain object。
24
+ */
25
+ export declare function parseOrgDirectoryStatic(json: string): Map<string, Record<string, {
26
+ write?: boolean;
27
+ }>>;
28
+ /**
29
+ * 目录条目的判别联合(设计裁定 N3/C5):`granted` 是取数成功——包括零授权空表(负结果,由 resolver
30
+ * 判终局拒);`unavailable` 是瞬时臂(取数本身失败/形状不可信/LB 陈旧副本),只在这一支才该让 core 重试。
31
+ */
32
+ export type OrgDirectoryLookup = {
33
+ kind: "granted";
34
+ scopes: Readonly<Record<string, {
35
+ write?: boolean;
36
+ }>>;
37
+ } | {
38
+ kind: "unavailable";
39
+ reason: OrgDirectoryUnavailableReason;
40
+ retryAfterMs: number;
41
+ };
42
+ /** {@link createOrgMemoryDirectory} 的产物 —— 单一 per-principal 查表口。 */
43
+ export interface OrgMemoryDirectory {
44
+ lookup(principal: string): Promise<OrgDirectoryLookup>;
45
+ }
46
+ export interface OrgMemoryDirectoryOptions {
47
+ /** 远程腿:返回 caps 响应的 `orgMemory` 段原值(unknown,本模块用 `PrincipalOrgMemoryWire.safeParse` 校验)。
48
+ * 返回 undefined = 响应里键整体缺席(旧 center 能力握手)⇒ unavailable("section_absent")。
49
+ * throw = fetch 失败 ⇒ unavailable("fetch_failed")。
50
+ * 与 `staticTable` 互斥:二者恰好给一个(装配点保证;都给或都缺 = 构造期 throw,fail-loud)。 */
51
+ fetchSection?: (principal: string) => Promise<unknown>;
52
+ /** 单机腿:{@link parseOrgDirectoryStatic} 的产物。查表恒「取数成功」:缺 principal ⇒ granted 空表。 */
53
+ staticTable?: Map<string, Record<string, {
54
+ write?: boolean;
55
+ }>>;
56
+ /** granted(含负结果)缓存 TTL——ms。负结果与正授权同 TTL:未授权 principal 不该比授权 principal 打出
57
+ * 更多流量。默认值不在本模块(接线点决定,设计定 60_000)。 */
58
+ grantTtlMs: number;
59
+ /** 一次 unavailable 之后,同 principal 在这个窗口内的 lookup 直接返回 unavailable(不重新 fetch)——
60
+ * 防止对一个持续故障/未授权目标反复打 center。默认值不在本模块(接线点决定,设计定 10_000)。 */
61
+ unavailableBackoffMs: number;
62
+ /** 时间注入(测试用);默认 `Date.now`。 */
63
+ now?: () => number;
64
+ }
65
+ type OrgDirectoryUnavailableReason = "fetch_failed" | "section_absent" | "malformed" | "stale_generation";
66
+ /**
67
+ * 建目录:远程腿(fetchSection,per-principal TTL 缓存 + in-flight 去重 + 退避窗 + gen 高水位)或单机腿
68
+ * (staticTable,恒同步成功、无 TTL/退避语义——operator 自证配置面)。两腿互斥,装配点必须恰好给一个。
69
+ */
70
+ export declare function createOrgMemoryDirectory(opts: OrgMemoryDirectoryOptions): OrgMemoryDirectory;
71
+ export interface MemoryScopeAdmissionOptions {
72
+ /** "audit": 判决照算但从不真拒——会拒的结果(终局 ok:false 或瞬时 throw)一律换成全额放行 + 观测记录
73
+ * would-deny;真通过的判决(含 write 收窄为 null)照常返回。"enforce": 判决即结果。 */
74
+ mode: "audit" | "enforce";
75
+ /** 观测 hook。outcome ∈ "ok" | "denied" | "principal_missing" | "directory_absent" | "directory_stale" |
76
+ * "directory_malformed"。audit 模式下的 would-deny 也走这里,`details.audited === true`。hook 本身绝不
77
+ * 允许打断准入判决——一律吞掉它可能抛出的异常。 */
78
+ onOutcome?: (outcome: string, details?: Record<string, unknown>) => void;
79
+ }
80
+ /** core 侧「瞬时不可用,重试」契约的载体:一个带 `retryAfterMs`(ms)属性的真 Error 子类,core 据此铸
81
+ * memory.admission_required 终局码并透传该值。子类而非事后挂属性——避免任何宽松断言就能拿到正确类型。 */
82
+ export declare class OrgMemoryAdmissionRetryError extends Error {
83
+ readonly retryAfterMs: number;
84
+ constructor(message: string, retryAfterMs: number);
85
+ }
86
+ /**
87
+ * `RunnerDeps.memoryScopeAdmission` 的 server 实装。deployment-origin 的 requested 项一律放行(operator
88
+ * 自证——v1 不做中央收窄部署声明);request-origin 项查 `directory.lookup(principal)`:granted 时逐项核
89
+ * 对是否在授权集里(任何一项缺席 = 整体终局拒绝,不做部分放行——reason 只点名调用方自己请求过的 scope
90
+ * 串,不逐 scope 展开「为什么」,避免变成成员探针);unavailable 时 throw 一个带 `retryAfterMs` 的
91
+ * {@link OrgMemoryAdmissionRetryError}。write 面独立收窄:deployment-origin 恒授,request-origin 仅当目录
92
+ * 条目 `write === true` 才授,否则收窄为 null(即使读侧整体放行)。
93
+ */
94
+ export declare function createMemoryScopeAdmission(directory: OrgMemoryDirectory, opts: MemoryScopeAdmissionOptions): MemoryScopeAdmission;
95
+ export {};
96
+ //# sourceMappingURL=org-memory-admission.d.ts.map
@@ -0,0 +1,224 @@
1
+ import { PrincipalOrgMemoryWire } from "@sema-agent/registry-core";
2
+ import { assertPrincipalShape } from "./security.js";
3
+ const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
4
+ const ORG_SCOPE_RE = /^org:\S+$/;
5
+ /**
6
+ * 单机腿:装载 `MEMORY_ORG_DIRECTORY_JSON`(`Record<principal, Record<orgScope, {write?}>>`)。启动期调用,
7
+ * **fail-loud**——任何非法直接 throw 且消息点名坏在哪个键(响亮拒是正确方向:一张半坏的授权表比拒绝
8
+ * 启动更危险)。principal 键复用 {@link assertPrincipalShape}(拒保留哨兵/超长租户名,与所有其它
9
+ * principal 入口同一道闸);scope 键必须是 `org:` 前缀、值必须是仅含可选 `write: boolean` 的 plain object。
10
+ */
11
+ export function parseOrgDirectoryStatic(json) {
12
+ let parsed;
13
+ try {
14
+ parsed = JSON.parse(json);
15
+ }
16
+ catch (e) {
17
+ throw new Error(`MEMORY_ORG_DIRECTORY_JSON is not valid JSON: ${e.message}`);
18
+ }
19
+ if (!isPlainObject(parsed)) {
20
+ throw new Error("MEMORY_ORG_DIRECTORY_JSON must be a JSON object: Record<principal, Record<orgScope, {write?: boolean}>>");
21
+ }
22
+ const table = new Map();
23
+ for (const [principal, scopesRaw] of Object.entries(parsed)) {
24
+ assertPrincipalShape(principal); // reserved sentinel / oversized tenant name → throws (same gate as every other principal entry point)
25
+ if (!isPlainObject(scopesRaw)) {
26
+ throw new Error(`MEMORY_ORG_DIRECTORY_JSON[${JSON.stringify(principal)}] must be an object of org scopes`);
27
+ }
28
+ const scopes = {};
29
+ for (const [scope, entryRaw] of Object.entries(scopesRaw)) {
30
+ if (!ORG_SCOPE_RE.test(scope)) {
31
+ throw new Error(`MEMORY_ORG_DIRECTORY_JSON[${JSON.stringify(principal)}] has an invalid org scope key ${JSON.stringify(scope)} (must match /^org:\\S+$/)`);
32
+ }
33
+ if (!isPlainObject(entryRaw)) {
34
+ throw new Error(`MEMORY_ORG_DIRECTORY_JSON[${JSON.stringify(principal)}][${JSON.stringify(scope)}] must be an object`);
35
+ }
36
+ for (const key of Object.keys(entryRaw)) {
37
+ if (key !== "write") {
38
+ throw new Error(`MEMORY_ORG_DIRECTORY_JSON[${JSON.stringify(principal)}][${JSON.stringify(scope)}] has an unexpected key ${JSON.stringify(key)} (only "write" is allowed)`);
39
+ }
40
+ }
41
+ if (entryRaw.write !== undefined && typeof entryRaw.write !== "boolean") {
42
+ throw new Error(`MEMORY_ORG_DIRECTORY_JSON[${JSON.stringify(principal)}][${JSON.stringify(scope)}].write must be a boolean`);
43
+ }
44
+ scopes[scope] = entryRaw.write !== undefined ? { write: entryRaw.write } : {};
45
+ }
46
+ table.set(principal, scopes);
47
+ }
48
+ return table;
49
+ }
50
+ /**
51
+ * 建目录:远程腿(fetchSection,per-principal TTL 缓存 + in-flight 去重 + 退避窗 + gen 高水位)或单机腿
52
+ * (staticTable,恒同步成功、无 TTL/退避语义——operator 自证配置面)。两腿互斥,装配点必须恰好给一个。
53
+ */
54
+ export function createOrgMemoryDirectory(opts) {
55
+ if ((opts.fetchSection !== undefined) === (opts.staticTable !== undefined)) {
56
+ throw new Error("createOrgMemoryDirectory requires exactly one of fetchSection or staticTable (both given or both missing)");
57
+ }
58
+ if (opts.staticTable !== undefined) {
59
+ const table = opts.staticTable;
60
+ return {
61
+ async lookup(principal) {
62
+ return { kind: "granted", scopes: table.get(principal) ?? {} };
63
+ },
64
+ };
65
+ }
66
+ if (opts.fetchSection === undefined) {
67
+ // Unreachable given the xor guard above (staticTable was undefined, so fetchSection must not be) — this
68
+ // check exists ONLY so the type checker narrows fetchSection to a function without a cast.
69
+ throw new Error("createOrgMemoryDirectory: fetchSection missing (invariant violated)");
70
+ }
71
+ const fetchSection = opts.fetchSection;
72
+ const grantTtlMs = opts.grantTtlMs;
73
+ const backoffMs = opts.unavailableBackoffMs;
74
+ const now = opts.now ?? (() => Date.now());
75
+ const states = new Map();
76
+ const inflight = new Map();
77
+ function markUnavailable(state, reason) {
78
+ state.unavailable = { until: now() + backoffMs, reason };
79
+ // C6: retryAfterMs never 0/negative — the window just opened, so its remaining time IS backoffMs.
80
+ return { kind: "unavailable", reason, retryAfterMs: Math.max(backoffMs, 1000) };
81
+ }
82
+ async function doFetch(principal, state) {
83
+ let raw;
84
+ try {
85
+ raw = await fetchSection(principal);
86
+ }
87
+ catch {
88
+ return markUnavailable(state, "fetch_failed");
89
+ }
90
+ if (raw === undefined)
91
+ return markUnavailable(state, "section_absent");
92
+ const parsed = PrincipalOrgMemoryWire.safeParse(raw);
93
+ if (!parsed.success) {
94
+ // malformed: does NOT touch state.grant — a bad shape must not refresh (or clear) an existing granted entry's
95
+ // timestamp; a still-fresh old grant keeps serving from cache on the NEXT lookup (checked before we ever get here).
96
+ return markUnavailable(state, "malformed");
97
+ }
98
+ if (state.highWaterGen !== undefined && parsed.data.gen < state.highWaterGen) {
99
+ // LB stale-replica defense: an older generation than one we've already seen is NEVER adopted.
100
+ return markUnavailable(state, "stale_generation");
101
+ }
102
+ state.highWaterGen = parsed.data.gen;
103
+ state.grant = { scopes: parsed.data.scopes, at: now() };
104
+ delete state.unavailable; // a real success clears any prior backoff window
105
+ return { kind: "granted", scopes: parsed.data.scopes };
106
+ }
107
+ return {
108
+ async lookup(principal) {
109
+ let state = states.get(principal);
110
+ if (!state) {
111
+ state = {};
112
+ states.set(principal, state);
113
+ }
114
+ const t = now();
115
+ // 1. Fresh grant (positive OR negative/empty result) always wins — no fetch, no backoff check.
116
+ if (state.grant && state.grant.at + grantTtlMs > t) {
117
+ return { kind: "granted", scopes: state.grant.scopes };
118
+ }
119
+ // 2. Inside an open backoff window → answer from the window WITHOUT re-fetching.
120
+ if (state.unavailable && state.unavailable.until > t) {
121
+ return { kind: "unavailable", reason: state.unavailable.reason, retryAfterMs: Math.max(state.unavailable.until - t, 1000) };
122
+ }
123
+ // 3. Grant expired (or never existed) and no active backoff → fetch, deduping concurrent callers.
124
+ const dup = inflight.get(principal);
125
+ if (dup)
126
+ return dup;
127
+ const p = doFetch(principal, state).finally(() => inflight.delete(principal));
128
+ inflight.set(principal, p);
129
+ return p;
130
+ },
131
+ };
132
+ }
133
+ /** core 侧「瞬时不可用,重试」契约的载体:一个带 `retryAfterMs`(ms)属性的真 Error 子类,core 据此铸
134
+ * memory.admission_required 终局码并透传该值。子类而非事后挂属性——避免任何宽松断言就能拿到正确类型。 */
135
+ export class OrgMemoryAdmissionRetryError extends Error {
136
+ retryAfterMs;
137
+ constructor(message, retryAfterMs) {
138
+ super(message);
139
+ this.retryAfterMs = retryAfterMs;
140
+ this.name = "OrgMemoryAdmissionRetryError";
141
+ }
142
+ }
143
+ /**
144
+ * `RunnerDeps.memoryScopeAdmission` 的 server 实装。deployment-origin 的 requested 项一律放行(operator
145
+ * 自证——v1 不做中央收窄部署声明);request-origin 项查 `directory.lookup(principal)`:granted 时逐项核
146
+ * 对是否在授权集里(任何一项缺席 = 整体终局拒绝,不做部分放行——reason 只点名调用方自己请求过的 scope
147
+ * 串,不逐 scope 展开「为什么」,避免变成成员探针);unavailable 时 throw 一个带 `retryAfterMs` 的
148
+ * {@link OrgMemoryAdmissionRetryError}。write 面独立收窄:deployment-origin 恒授,request-origin 仅当目录
149
+ * 条目 `write === true` 才授,否则收窄为 null(即使读侧整体放行)。
150
+ */
151
+ export function createMemoryScopeAdmission(directory, opts) {
152
+ const emit = (outcome, details) => {
153
+ try {
154
+ opts.onOutcome?.(outcome, details);
155
+ }
156
+ catch {
157
+ /* an observability hook must never break admission */
158
+ }
159
+ };
160
+ return async ({ principal, requested, requestedWriteScope }) => {
161
+ const requestOriginRequested = requested.filter((r) => r.origin === "request");
162
+ const writeIsRequestOrigin = requestedWriteScope?.origin === "request";
163
+ const hasRequestOrigin = requestOriginRequested.length > 0 || writeIsRequestOrigin;
164
+ const fullScopes = requested.map((r) => r.scope);
165
+ const fullWriteScope = requestedWriteScope?.scope ?? null;
166
+ const admitFull = () => ({ ok: true, scopes: fullScopes, writeScope: fullWriteScope });
167
+ async function decide() {
168
+ // deployment-only requests (no request-origin item anywhere, including the write slot): nothing to
169
+ // look up — v1 does not centrally narrow operator-declared deployment scopes.
170
+ if (!hasRequestOrigin)
171
+ return { kind: "admit", verdict: admitFull() };
172
+ if (principal === undefined) {
173
+ // Defensive arm: core already refuses this shape itself when a request-origin org scope is present
174
+ // with no principal, but the resolver must not assume that guard always ran first.
175
+ return { kind: "deny", reason: "principal absent", outcome: "principal_missing" };
176
+ }
177
+ const lookup = await directory.lookup(principal);
178
+ if (lookup.kind === "unavailable") {
179
+ const outcome = lookup.reason === "stale_generation" ? "directory_stale" : lookup.reason === "malformed" ? "directory_malformed" : "directory_absent"; // fetch_failed | section_absent
180
+ return { kind: "unavailable", retryAfterMs: lookup.retryAfterMs, outcome, lookupReason: lookup.reason, details: { reason: lookup.reason } };
181
+ }
182
+ const deniedScopes = requestOriginRequested.filter((r) => !(r.scope in lookup.scopes)).map((r) => r.scope);
183
+ if (deniedScopes.length > 0) {
184
+ return {
185
+ kind: "deny",
186
+ reason: `org scope(s) not admitted for this principal: ${deniedScopes.join(", ")}`,
187
+ outcome: "denied",
188
+ details: { deniedScopes },
189
+ };
190
+ }
191
+ const writeScope = requestedWriteScope === null
192
+ ? null
193
+ : requestedWriteScope.origin === "deployment"
194
+ ? requestedWriteScope.scope
195
+ : lookup.scopes[requestedWriteScope.scope]?.write === true
196
+ ? requestedWriteScope.scope
197
+ : null;
198
+ return { kind: "admit", verdict: { ok: true, scopes: fullScopes, writeScope } };
199
+ }
200
+ const decision = await decide();
201
+ if (decision.kind === "admit") {
202
+ // C10(audit=纯观察**零**行为变化):write 收窄也是行为变化(enforce 下 harvest 静默零提交)——
203
+ // audit 模式对「读面通过但 write 未显式授」的判决只记 would-narrow,返回未收窄的全额;
204
+ // enforce 照常收窄。ok:true 分支的 writeScope 恒为 fullWriteScope 或 null(decide 只做这两值)。
205
+ if (opts.mode === "audit" && decision.verdict.ok && decision.verdict.writeScope !== fullWriteScope) {
206
+ emit("ok", { audited: true, wouldNarrowWriteScope: true });
207
+ return admitFull();
208
+ }
209
+ emit("ok");
210
+ return decision.verdict;
211
+ }
212
+ if (opts.mode === "audit") {
213
+ // Zero behavior change: a would-deny (terminal or transient) is overridden to a full, UNNARROWED admit —
214
+ // the real decision is only ever recorded via onOutcome, never enforced.
215
+ emit(decision.outcome, { ...decision.details, audited: true, wouldDeny: true, ...(decision.kind === "unavailable" ? { retryAfterMs: decision.retryAfterMs } : { reason: decision.reason }) });
216
+ return admitFull();
217
+ }
218
+ emit(decision.outcome, decision.details);
219
+ if (decision.kind === "deny")
220
+ return { ok: false, reason: decision.reason };
221
+ throw new OrgMemoryAdmissionRetryError(`org memory directory unavailable (${decision.lookupReason})`, decision.retryAfterMs);
222
+ };
223
+ }
224
+ //# sourceMappingURL=org-memory-admission.js.map
@@ -67,6 +67,11 @@ export declare class QuestionCoordinator {
67
67
  };
68
68
  /** Test/observability hook: number of currently-parked questions. */
69
69
  pendingCount(): number;
70
+ /** #152 ([2703] 案二):durable 部署上的 AskUserQuestion 判决探针——本调用点是否处在某条活流腿的
71
+ * per-run 上下文里(runWithContext 包裹的 bg/SSE 腿=true;sync /v1/tasks、verify/cascade=false)。
72
+ * resolve-spec 的 durable question policy 用它在**判决时**分腿:有活流 ⇒ allow(问活人),无 ⇒
73
+ * ask(durable park)。ALS 让这个判断天然 per-leg,policy 组装期不必预知腿别。 */
74
+ hasLiveContext(): boolean;
70
75
  private countersFor;
71
76
  }
72
77
  //# sourceMappingURL=question.d.ts.map
package/dist/question.js CHANGED
@@ -192,6 +192,13 @@ export class QuestionCoordinator {
192
192
  pendingCount() {
193
193
  return this.pending.size;
194
194
  }
195
+ /** #152 ([2703] 案二):durable 部署上的 AskUserQuestion 判决探针——本调用点是否处在某条活流腿的
196
+ * per-run 上下文里(runWithContext 包裹的 bg/SSE 腿=true;sync /v1/tasks、verify/cascade=false)。
197
+ * resolve-spec 的 durable question policy 用它在**判决时**分腿:有活流 ⇒ allow(问活人),无 ⇒
198
+ * ask(durable park)。ALS 让这个判断天然 per-leg,policy 组装期不必预知腿别。 */
199
+ hasLiveContext() {
200
+ return this.als.getStore() !== undefined;
201
+ }
195
202
  countersFor(taskId) {
196
203
  let rc = this.counters.get(taskId);
197
204
  if (!rc) {
package/dist/runs.d.ts CHANGED
@@ -106,6 +106,13 @@ export declare function isSessionConflictResult(result: {
106
106
  * UNCHANGED for the shell). `rewind_snapshot.unresolvable` (rewindFiles + "before": no snapshot at/above the branch
107
107
  * point) is core's third rejection with a DIFFERENT prefix — same caller-mistake shape (prepare-throw, nothing
108
108
  * billed), map it to 422 explicitly so it doesn't fall through to a 200-with-failed body.
109
+ *
110
+ * design/170 件A(#148 件4): core 5.13.0's org-memory admission rides the SAME prepare-throw pipe
111
+ * (GOVERNANCE_CODES). `memory.admission_denied` is TERMINAL — the principal has no grant on that tenant
112
+ * plane → 403 (a retry cannot change the verdict). `memory.admission_required` is TRANSIENT fail-closed —
113
+ * the directory is unreachable / no resolver is wired → 503 (retry later; the sync leg forwards the
114
+ * result's `retryAfterMs` as body `retryAfterSec`, same family shape as usage.window_exhausted). Exact
115
+ * codes only, no memory.* family grab — an unknown sibling stays a 200-with-failed-body until cataloged.
109
116
  */
110
117
  export declare function resumeAtHttpStatus(result: {
111
118
  status: string;
package/dist/runs.js CHANGED
@@ -3,7 +3,7 @@ import { withPrincipal } from "./observability/principal-context.js";
3
3
  import { redactSecrets } from "./trace/redact.js";
4
4
  import { taskNotificationEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "./trace/project.js";
5
5
  import { createLedgerSink } from "./trace/ledger-sink.js";
6
- import { fleetRunResiduals } from "./fleet/fleet-bus.js";
6
+ import { fleetRunResiduals, isFleetAgentTerminalNotification } from "./fleet/fleet-bus.js";
7
7
  import { defaultSubagentTailBus, projectTailFrame } from "./fleet/subagent-tail-bus.js";
8
8
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "./orchestration/workflow-completion-inbox.js";
9
9
  /**
@@ -148,6 +148,13 @@ export function isSessionConflictResult(result) {
148
148
  * UNCHANGED for the shell). `rewind_snapshot.unresolvable` (rewindFiles + "before": no snapshot at/above the branch
149
149
  * point) is core's third rejection with a DIFFERENT prefix — same caller-mistake shape (prepare-throw, nothing
150
150
  * billed), map it to 422 explicitly so it doesn't fall through to a 200-with-failed body.
151
+ *
152
+ * design/170 件A(#148 件4): core 5.13.0's org-memory admission rides the SAME prepare-throw pipe
153
+ * (GOVERNANCE_CODES). `memory.admission_denied` is TERMINAL — the principal has no grant on that tenant
154
+ * plane → 403 (a retry cannot change the verdict). `memory.admission_required` is TRANSIENT fail-closed —
155
+ * the directory is unreachable / no resolver is wired → 503 (retry later; the sync leg forwards the
156
+ * result's `retryAfterMs` as body `retryAfterSec`, same family shape as usage.window_exhausted). Exact
157
+ * codes only, no memory.* family grab — an unknown sibling stays a 200-with-failed-body until cataloged.
151
158
  */
152
159
  export function resumeAtHttpStatus(result) {
153
160
  if (result.status !== "failed")
@@ -156,6 +163,10 @@ export function resumeAtHttpStatus(result) {
156
163
  return 404;
157
164
  if (result.errorCode === "rewind_snapshot.unresolvable")
158
165
  return 422;
166
+ if (result.errorCode === "memory.admission_denied")
167
+ return 403;
168
+ if (result.errorCode === "memory.admission_required")
169
+ return 503;
159
170
  if (!(result.errorCode?.startsWith("resume_at.") ?? false))
160
171
  return undefined;
161
172
  return result.errorCode === "resume_at.not_found" ? 404 : 422;
@@ -575,8 +586,11 @@ promptManifests) {
575
586
  }
576
587
  // id-domain alias (core): fleet child rows key by the TICK's taskId = the child's sessionId
577
588
  // (uuid domain); the notification's task_id is the a* domain — payload.sessionId IS the tick-domain
578
- // alias, so flip by it (fallback task_id for pre-1.238 payloads / bash lane).
579
- const hadRow = fleetPublisher?.onChildTerminal(n.sessionId ?? n.task_id, n.status, n.task_id, n.toolUseId) ?? false;
589
+ // alias, so flip by it (fallback task_id for pre-1.238 payloads).
590
+ // [2687-cli] 幽灵行案:onChildTerminal 只对 **agent 族终态**调用(isFleetAgentTerminalNotification
591
+ // 单源判别,理由与病灶链见其 doc 注)。通知帧/park 面照走,只掐 fleet 铸行。三消费点孪生同形
592
+ // (http/server.ts resume 腿、routes/tasks.ts sync 腿)。
593
+ const hadRow = isFleetAgentTerminalNotification(n) ? (fleetPublisher?.onChildTerminal(n.sessionId ?? n.task_id, n.status, n.task_id, n.toolUseId) ?? false) : false;
580
594
  const parked = !legLive && Boolean(workflowCompletionInbox && spec.sessionId);
581
595
  // diagnosability: one line per observed bg completion. hadFleetRow=false = the terminal frame was
582
596
  // FLIP-THROUGH synthesized (the row was gone — parent settle removed it, or a bg BASH never ticked).
@@ -86,15 +86,23 @@ export declare function compileCommandPolicy(rules: CommandRule[] | undefined):
86
86
  */
87
87
  export declare function autonomyOverrides(autonomy: Autonomy | undefined): Partial<TaskSpec>;
88
88
  /**
89
- * Apply the operator's runtime governance (autonomy + commandPolicy) onto a base `TaskSpec`, TIGHTEN-ONLY, in a
90
- * SINGLE {@link tightenTaskSpec} call: commandPolicy compiles to a `toolPolicy` override (combined onto the
91
- * baseline by `tightenTaskSpec` — TRAP #1), and autonomy expands to `handsReadOnly` / `shellGate` overrides
92
- * (TRAP #2). `tightenTaskSpec` THROWS `TaskSpecTightenError` if any override would LOOSEN a stricter base safety
93
- * field — surfacing a misconfiguration loudly instead of silently weakening the gate. Returns `base` untouched
94
- * when there is nothing to apply (no autonomy override and no commandPolicy).
89
+ * Apply the operator's runtime governance (autonomy + commandPolicy + manualModeShellGate) onto a base
90
+ * `TaskSpec`, TIGHTEN-ONLY, in a SINGLE {@link tightenTaskSpec} call: commandPolicy compiles to a `toolPolicy`
91
+ * override (combined onto the baseline by `tightenTaskSpec` — TRAP #1), and autonomy expands to
92
+ * `handsReadOnly` / `shellGate` overrides (TRAP #2). `tightenTaskSpec` THROWS `TaskSpecTightenError` if any
93
+ * override would LOOSEN a stricter base safety field — surfacing a misconfiguration loudly instead of silently
94
+ * weakening the gate. Returns `base` untouched when there is nothing to apply.
95
+ *
96
+ * #153([2713] server 分单②):`MANUAL_MODE_SHELL_GATE` 从 settings 折叠搬到这里**无条件施加**。旧家
97
+ * (task-settings deriveSettingsPolicy 的 default/auto/acceptEdits 三臂)让一个**部署级 tighten-only 旋钮
98
+ * 的生死由客户端表态在场性决定**:body 无 settings/permissionMode(cli print/headless 腿按设计不 stamp)
99
+ * ⇒ 旋钮静默失效(clay 实机 Monitor 案「门根本没铸」);bypassPermissions 一句话掀掉部署闸;非 host lane
100
+ * fsWriteGate wiring 不建 ⇒ 沙箱 lane 全失效。governance 层与 autonomy 同拍施加后三者全闭。rank 合成:
101
+ * autonomy 派生值与旋钮取大(ask→"always" 压过 "classify"),再对 base 只升不降(防 tightenTaskSpec throw)。
95
102
  */
96
103
  export declare function applyRuntimeGovernance(base: TaskSpec, governance: {
97
104
  autonomy?: Autonomy;
98
105
  commandPolicy?: CommandRule[];
106
+ manualModeShellGate?: "always" | "classify";
99
107
  }): TaskSpec;
100
108
  //# sourceMappingURL=runtime-governance.d.ts.map
@@ -204,19 +204,40 @@ export function autonomyOverrides(autonomy) {
204
204
  return {};
205
205
  }
206
206
  }
207
+ /** core `tighten-task-spec` 的 SHELL_GATE_RANK 镜像(单一属主:#153 搬家后本文件是 server 侧唯一副本;
208
+ * task-settings 的旧副本随 settings 折叠臂一并拆除)。tightenTaskSpec 的契约是「override 自身必须收紧」
209
+ * 而非「取两者较严」——低于 base 的 override 直接 throw,所以施加前要按 rank 只升不降。 */
210
+ const SHELL_GATE_RANK = { off: 0, classify: 1, always: 2 };
207
211
  /**
208
- * Apply the operator's runtime governance (autonomy + commandPolicy) onto a base `TaskSpec`, TIGHTEN-ONLY, in a
209
- * SINGLE {@link tightenTaskSpec} call: commandPolicy compiles to a `toolPolicy` override (combined onto the
210
- * baseline by `tightenTaskSpec` — TRAP #1), and autonomy expands to `handsReadOnly` / `shellGate` overrides
211
- * (TRAP #2). `tightenTaskSpec` THROWS `TaskSpecTightenError` if any override would LOOSEN a stricter base safety
212
- * field — surfacing a misconfiguration loudly instead of silently weakening the gate. Returns `base` untouched
213
- * when there is nothing to apply (no autonomy override and no commandPolicy).
212
+ * Apply the operator's runtime governance (autonomy + commandPolicy + manualModeShellGate) onto a base
213
+ * `TaskSpec`, TIGHTEN-ONLY, in a SINGLE {@link tightenTaskSpec} call: commandPolicy compiles to a `toolPolicy`
214
+ * override (combined onto the baseline by `tightenTaskSpec` — TRAP #1), and autonomy expands to
215
+ * `handsReadOnly` / `shellGate` overrides (TRAP #2). `tightenTaskSpec` THROWS `TaskSpecTightenError` if any
216
+ * override would LOOSEN a stricter base safety field — surfacing a misconfiguration loudly instead of silently
217
+ * weakening the gate. Returns `base` untouched when there is nothing to apply.
218
+ *
219
+ * #153([2713] server 分单②):`MANUAL_MODE_SHELL_GATE` 从 settings 折叠搬到这里**无条件施加**。旧家
220
+ * (task-settings deriveSettingsPolicy 的 default/auto/acceptEdits 三臂)让一个**部署级 tighten-only 旋钮
221
+ * 的生死由客户端表态在场性决定**:body 无 settings/permissionMode(cli print/headless 腿按设计不 stamp)
222
+ * ⇒ 旋钮静默失效(clay 实机 Monitor 案「门根本没铸」);bypassPermissions 一句话掀掉部署闸;非 host lane
223
+ * fsWriteGate wiring 不建 ⇒ 沙箱 lane 全失效。governance 层与 autonomy 同拍施加后三者全闭。rank 合成:
224
+ * autonomy 派生值与旋钮取大(ask→"always" 压过 "classify"),再对 base 只升不降(防 tightenTaskSpec throw)。
214
225
  */
215
226
  export function applyRuntimeGovernance(base, governance) {
216
227
  const overrides = { ...autonomyOverrides(governance.autonomy) };
217
228
  const coarse = compileCommandPolicy(governance.commandPolicy);
218
229
  if (coarse)
219
230
  overrides.toolPolicy = coarse;
231
+ if (governance.manualModeShellGate !== undefined) {
232
+ const candidate = overrides.shellGate !== undefined && SHELL_GATE_RANK[overrides.shellGate] >= SHELL_GATE_RANK[governance.manualModeShellGate]
233
+ ? overrides.shellGate
234
+ : governance.manualModeShellGate;
235
+ // base 已更严(如 resume 重放时 base 带 always)⇒ 省略,让 base 原样保留(省略=行为等价,不 throw)。
236
+ if (SHELL_GATE_RANK[candidate] >= SHELL_GATE_RANK[base.shellGate ?? "off"])
237
+ overrides.shellGate = candidate;
238
+ else
239
+ delete overrides.shellGate;
240
+ }
220
241
  if (overrides.toolPolicy === undefined && overrides.handsReadOnly === undefined && overrides.shellGate === undefined) {
221
242
  return base; // nothing to govern → avoid a needless tightenTaskSpec pass
222
243
  }
@@ -181,19 +181,21 @@ export interface FsWriteGateWiring {
181
181
  * now closed). Source = config `SENSITIVE_WRITE_PATTERNS` (unset ⇒ core RECOMMENDED_SENSITIVE_PATTERNS; the
182
182
  * set curation is core's — the server passes through). Absent/empty ⇒ no sensitive policy (pre-1.295 shape). */
183
183
  sensitivePatterns?: string[];
184
- /** [1557]§四 opt-in(cli[1555]②「echo -n … > file 绕写门」缺口的部署侧补丁,core[1556] 建议):
185
- * CC manual 系模式(default/auto/acceptEdits — 见 {@link deriveSettingsPolicy})額外把
186
- * `TaskSpec.shellGate` 收紧到这个值,让 Bash 走同一 fs-write 审批链(core `bashReversibilityProbe`
187
- * 在 `"classify"` 下只拦构造性不可逆命令——含 shell 重定向 `>`/`>>` 等,纯读命令白名单自动放行)。
188
- * **Absent(缺省)= 零行为变化**(config `MANUAL_MODE_SHELL_GATE` 未设置时不传本字段,pre-此批行为
189
- * byte-for-byte;这是个 tighten-only opt-in,不是默认开启——运营方需要显式选择开启)。 */
190
- shellGate?: "always" | "classify";
191
184
  }
192
185
  export declare function deriveSettingsPolicy(settings: ParsedTaskSettings, gate?: FsWriteGateWiring, workflowGate?: WorkflowGateWiring): {
193
186
  toolPolicy?: ToolPolicy;
194
187
  handsReadOnly?: boolean;
195
188
  enablePlanMode?: boolean;
196
- shellGate?: "always" | "classify";
197
189
  };
190
+ /**
191
+ * Apply a client's parsed per-request settings onto a base `TaskSpec`, TIGHTEN-ONLY, in a SINGLE {@link
192
+ * tightenTaskSpec} pass (so the permission override composes deny-wins onto the baseline — TRAP #1 — and
193
+ * `handsReadOnly`/`onAsk` can only narrow). `tightenTaskSpec` THROWS `TaskSpecTightenError` if any override would
194
+ * LOOSEN a stricter base — surfaced by the caller as a 4xx (a client settings stamp that tries to widen is a
195
+ * client error, never a silent weakening). `model`/`outputStyle` are applied directly (model is the caller's
196
+ * choice WITHIN the catalog — the caller gates it before calling; outputStyle appends to the system prompt).
197
+ *
198
+ * Returns `base` untouched when the parsed settings project nothing onto the spec.
199
+ */
198
200
  export declare function applyTaskSettings(base: TaskSpec, settings: ParsedTaskSettings, gate?: FsWriteGateWiring, workflowGate?: WorkflowGateWiring): TaskSpec;
199
201
  //# sourceMappingURL=task-settings.d.ts.map