@sema-agent/server 7.101.0 → 7.102.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 (86) hide show
  1. package/MIGRATION.md +23 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/USAGE.md +58 -4
  5. package/dist/approval-card.d.ts +35 -2
  6. package/dist/approval-card.js +19 -1
  7. package/dist/background-session-reap.d.ts +55 -0
  8. package/dist/background-session-reap.js +90 -0
  9. package/dist/boot/config-center.d.ts +16 -4
  10. package/dist/boot/config-center.js +44 -15
  11. package/dist/boot/exit-boundary.js +17 -0
  12. package/dist/boot/runner-deps.js +1 -0
  13. package/dist/boot/runtime-caps.js +3 -2
  14. package/dist/boot/session-faces.js +3 -4
  15. package/dist/boot/shutdown.d.ts +2 -1
  16. package/dist/boot/shutdown.js +33 -12
  17. package/dist/boot/stage-07-capability-layer.d.ts +1 -1
  18. package/dist/boot/stage-07-capability-layer.js +6 -4
  19. package/dist/boot/stage-08-reapers.d.ts +1 -1
  20. package/dist/boot/stage-09-leader.d.ts +1 -1
  21. package/dist/boot/stage-10-http-server.d.ts +1 -1
  22. package/dist/boot/stage-10-http-server.js +9 -3
  23. package/dist/capabilities/builtin-tools.d.ts +14 -1
  24. package/dist/capabilities/builtin-tools.js +3 -0
  25. package/dist/capabilities/scenarios.d.ts +1 -1
  26. package/dist/capabilities/scenarios.js +3 -3
  27. package/dist/center-credential-renewer.d.ts +76 -0
  28. package/dist/center-credential-renewer.js +359 -0
  29. package/dist/center-credential.d.ts +76 -22
  30. package/dist/center-credential.js +119 -43
  31. package/dist/config-catalog.js +9 -4
  32. package/dist/config-center/apply-effective.js +5 -12
  33. package/dist/config-center/center-request.d.ts +13 -0
  34. package/dist/config-center/center-request.js +23 -3
  35. package/dist/config-center/facade.d.ts +1 -1
  36. package/dist/config-center/http-client.js +2 -5
  37. package/dist/config-center/read-warnings.d.ts +19 -48
  38. package/dist/config-center/read-warnings.js +7 -49
  39. package/dist/config-center/restart-signal.d.ts +8 -11
  40. package/dist/config-center/types.d.ts +4 -2
  41. package/dist/config-provider.js +1 -48
  42. package/dist/config-types.d.ts +77 -5
  43. package/dist/config-types.js +5 -0
  44. package/dist/config.js +49 -13
  45. package/dist/device-store.d.ts +1 -1
  46. package/dist/fleet/fleet-bus.d.ts +30 -4
  47. package/dist/fleet/fleet-bus.js +20 -4
  48. package/dist/fleet/fleet-reconciler.d.ts +29 -6
  49. package/dist/fleet/fleet-reconciler.js +109 -79
  50. package/dist/fleet-client.d.ts +21 -7
  51. package/dist/fleet-client.js +41 -12
  52. package/dist/fleet-lease.d.ts +1 -1
  53. package/dist/http/approval-sweeps.js +5 -20
  54. package/dist/http/dispatch.js +20 -14
  55. package/dist/http/readiness.d.ts +87 -0
  56. package/dist/http/readiness.js +54 -0
  57. package/dist/http/resume-legs.js +5 -4
  58. package/dist/http/route-table.d.ts +7 -0
  59. package/dist/http/route-table.js +6 -0
  60. package/dist/http/routes/a2a-serve.js +6 -14
  61. package/dist/http/routes/approvals-assistant.js +1 -0
  62. package/dist/http/routes/background.d.ts +80 -0
  63. package/dist/http/routes/background.js +94 -0
  64. package/dist/http/routes/capabilities.js +2 -0
  65. package/dist/http/server.d.ts +21 -8
  66. package/dist/http/server.js +2 -2
  67. package/dist/model-entry-refusal.d.ts +11 -10
  68. package/dist/model-entry-refusal.js +13 -10
  69. package/dist/observability/fail-open.d.ts +6 -2
  70. package/dist/observability/fail-open.js +6 -2
  71. package/dist/parked-decide.d.ts +11 -5
  72. package/dist/parked-decide.js +4 -1
  73. package/dist/parked-revive-surface.d.ts +47 -0
  74. package/dist/parked-revive-surface.js +20 -0
  75. package/dist/plugins/background-shell-support.d.ts +20 -1
  76. package/dist/plugins/background-shell-support.js +26 -0
  77. package/dist/plugins/host-platform.d.ts +11 -1
  78. package/dist/plugins/host-platform.js +11 -0
  79. package/dist/plugins/remote-env-host.d.ts +14 -1
  80. package/dist/plugins/remote-env-host.js +18 -5
  81. package/dist/tool-approval.d.ts +39 -1
  82. package/dist/tool-approval.js +37 -8
  83. package/dist/trace/core-keyset-guard.d.ts +3 -3
  84. package/dist/trace/engine-notice-wire.d.ts +1 -1
  85. package/dist/trace/engine-notice-wire.js +2 -0
  86. package/package.json +3 -3
@@ -0,0 +1,359 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { closeSync, fchmodSync, fsyncSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { CENTER_CREDENTIAL_ENV, CenterCredentialEnvelope, parseCenterCredentialFile } from "@sema-agent/settings-schema";
5
+ import { ACCESS_TOKEN_TTL_SECONDS, OAuthErrorResponse, TokenGrantResponse } from "@sema-agent/settings-schema/api/auth";
6
+ import { CENTER_PULL_TIMEOUT_MS, centerTokenRefresh } from "./config-center/center-request.js";
7
+ export const CENTER_CREDENTIAL_RENEW_LEAD_KEY = "CENTER_CREDENTIAL_RENEW_LEAD_MS";
8
+ export const CENTER_CREDENTIAL_RENEW_LEAD_DEFAULT_MS = 5 * 60_000;
9
+ export const CENTER_CREDENTIAL_RENEW_LEAD_MIN_MS = CENTER_PULL_TIMEOUT_MS;
10
+ export const CENTER_CREDENTIAL_RENEW_LEAD_MAX_MS = (ACCESS_TOKEN_TTL_SECONDS * 1000) / 2;
11
+ export function parseCenterCredentialRenewLeadMs(raw) {
12
+ if (raw === undefined || raw.trim() === "")
13
+ return CENTER_CREDENTIAL_RENEW_LEAD_DEFAULT_MS;
14
+ const n = Number(raw);
15
+ if (n === 0)
16
+ return 0;
17
+ if (!Number.isSafeInteger(n) || n < CENTER_CREDENTIAL_RENEW_LEAD_MIN_MS || n > CENTER_CREDENTIAL_RENEW_LEAD_MAX_MS) {
18
+ throw new Error(`env ${CENTER_CREDENTIAL_RENEW_LEAD_KEY}="${raw}" must be 0 (renewal off: the sign-in envelope is only read) or an INTEGER number of MILLISECONDS in ` +
19
+ `[${CENTER_CREDENTIAL_RENEW_LEAD_MIN_MS}, ${CENTER_CREDENTIAL_RENEW_LEAD_MAX_MS}] — how long before the access token expires the engine renews it. ` +
20
+ `Below the floor not even one refresh request (timeout ${CENTER_PULL_TIMEOUT_MS} ms) fits before expiry; above the ceiling (half the ${ACCESS_TOKEN_TTL_SECONDS}s access-token lifetime) ` +
21
+ `every freshly minted token is already due, so renewal would spin. Unset = ${CENTER_CREDENTIAL_RENEW_LEAD_DEFAULT_MS}.`);
22
+ }
23
+ return n;
24
+ }
25
+ export function buildCenterCredentialHealthView(health, renewer) {
26
+ const renewal = renewer?.renewal();
27
+ const expiresAt = renewer?.sessionExpiresAt();
28
+ const renewalCell = renewal === undefined || renewal.state === "idle"
29
+ ? undefined
30
+ : { state: renewal.state, reason: renewal.reason, since: renewal.since, ...(expiresAt !== undefined ? { expiresAt: new Date(expiresAt).toISOString() } : {}) };
31
+ if (health.state === "ok")
32
+ return renewalCell === undefined ? undefined : { state: "ok", renewal: renewalCell };
33
+ return { state: "unreadable", failure: health.failure, since: health.since, failures: health.failures, ...(renewalCell !== undefined ? { renewal: renewalCell } : {}) };
34
+ }
35
+ const digest16 = (s) => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
36
+ function readFileNow(path) {
37
+ let text;
38
+ try {
39
+ text = readFileSync(path, "utf8");
40
+ }
41
+ catch (err) {
42
+ const code = err instanceof Error && "code" in err && typeof err.code === "string" ? err.code : "read error";
43
+ return code === "ENOENT" ? { kind: "no-session" } : { kind: "unreadable", code };
44
+ }
45
+ const file = parseCenterCredentialFile(text);
46
+ return file.kind === "envelope" ? { kind: "session", envelope: file.envelope, refreshDigest: digest16(file.envelope.refreshToken) } : { kind: "no-session" };
47
+ }
48
+ const TMP_RANDOM_BYTES = 6;
49
+ const tmpNameFor = (path) => `.${basename(path)}.${process.pid}.${randomBytes(TMP_RANDOM_BYTES).toString("hex")}.tmp`;
50
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
+ const tmpResiduePattern = (path) => new RegExp(`^\\.${escapeRegExp(basename(path))}\\.\\d+\\.[0-9a-f]{${TMP_RANDOM_BYTES * 2}}\\.tmp$`);
52
+ function prepareTmp(path, envelope, discard, onCleanupFailure) {
53
+ const tmp = join(dirname(path), tmpNameFor(path));
54
+ const fd = openSync(tmp, "wx", 0o600);
55
+ let written = false;
56
+ try {
57
+ fchmodSync(fd, 0o600);
58
+ writeFileSync(fd, `${JSON.stringify(envelope)}\n`, "utf8");
59
+ fsyncSync(fd);
60
+ written = true;
61
+ }
62
+ finally {
63
+ try {
64
+ closeSync(fd);
65
+ }
66
+ catch (err) {
67
+ onCleanupFailure("close", err);
68
+ }
69
+ if (!written)
70
+ discard(tmp);
71
+ }
72
+ return tmp;
73
+ }
74
+ function sameOrigin(a, b) {
75
+ return new URL(a).origin === new URL(b).origin;
76
+ }
77
+ const BACKOFF_BASE_MS = 1000;
78
+ function sessionExpiryOf(read) {
79
+ if (read.ok)
80
+ return read.kind === "user-session" ? read.expiresAt : undefined;
81
+ return read.failure === "expired" ? read.expiresAt : undefined;
82
+ }
83
+ export function createCenterCredentialRenewer(deps) {
84
+ const { source, centerUrl, leadMs, logger } = deps;
85
+ if (source.origin.from !== "file")
86
+ throw new Error(`center credential renewer needs the ${CENTER_CREDENTIAL_ENV.tokenFile} form (got ${source.origin.from})`);
87
+ const path = source.origin.path;
88
+ const now = deps.now ?? Date.now;
89
+ const tickMs = Math.min(Math.max(leadMs / 2, 1000), 60_000);
90
+ let renewal = { state: "idle" };
91
+ let subject;
92
+ let held;
93
+ let inFlight = false;
94
+ let stopped = false;
95
+ const stranded = new Set();
96
+ let cleanupWarned = false;
97
+ let quietUntil = 0;
98
+ let lastExpiresAt;
99
+ let unavailableStreak = (() => {
100
+ const h = source.health();
101
+ return h.state === "unreadable" ? h.failure : undefined;
102
+ })();
103
+ const expiryField = () => (lastExpiresAt !== undefined ? { expiresAt: new Date(lastExpiresAt).toISOString() } : {});
104
+ const errCode = (err, fallback) => (err instanceof Error && "code" in err && typeof err.code === "string" ? err.code : err instanceof Error ? err.name : fallback);
105
+ const reportCleanup = (op, err) => {
106
+ if (cleanupWarned)
107
+ return;
108
+ cleanupWarned = true;
109
+ logger.warn("center_credential_tmp_cleanup_failed", {
110
+ op,
111
+ code: errCode(err, `${op} error`),
112
+ note: "a temporary copy of the credential (0600, next to the credential file) may be left behind; the renewal outcome is unaffected. It is removed after the next successful rewrite or at the next start; warned once per process",
113
+ });
114
+ };
115
+ const discardTmp = (tmp) => {
116
+ try {
117
+ rmSync(tmp, { force: true });
118
+ stranded.delete(tmp);
119
+ return true;
120
+ }
121
+ catch (err) {
122
+ stranded.add(tmp);
123
+ reportCleanup("remove", err);
124
+ return false;
125
+ }
126
+ };
127
+ {
128
+ const dir = dirname(path);
129
+ const pattern = tmpResiduePattern(path);
130
+ let names = [];
131
+ try {
132
+ names = readdirSync(dir).filter((n) => pattern.test(n));
133
+ }
134
+ catch (err) {
135
+ reportCleanup("scan", err);
136
+ }
137
+ const removed = names.filter((name) => discardTmp(join(dir, name))).length;
138
+ if (removed > 0)
139
+ logger.info("center_credential_tmp_residue_removed", { count: removed });
140
+ }
141
+ const loudness = (read) => {
142
+ if (read.ok) {
143
+ unavailableStreak = undefined;
144
+ return;
145
+ }
146
+ if (unavailableStreak === read.failure)
147
+ return;
148
+ unavailableStreak = read.failure;
149
+ logger.warn("center_credential_unreadable", {
150
+ key: CENTER_CREDENTIAL_ENV.tokenFile,
151
+ failure: read.failure,
152
+ reason: read.reason,
153
+ note: "no center request carries this credential until the file holds a usable one again (each consumer takes its own failure arm); warned once per unavailability streak",
154
+ });
155
+ };
156
+ const reset = () => {
157
+ renewal = { state: "idle" };
158
+ subject = undefined;
159
+ held = undefined;
160
+ };
161
+ const reconcile = () => {
162
+ if (renewal.state === "idle" || inFlight)
163
+ return;
164
+ const file = readFileNow(path);
165
+ if (file.kind === "unreadable")
166
+ return;
167
+ if (file.kind === "no-session" || file.refreshDigest !== subject)
168
+ reset();
169
+ };
170
+ const settleTerminal = (reason, refreshDigest, fields) => {
171
+ renewal = { state: "failed", reason, since: now() };
172
+ subject = refreshDigest;
173
+ held = undefined;
174
+ if (reason === "issuer_mismatch") {
175
+ logger.warn("center_credential_issuer_mismatch", { ...fields, note: "the sign-in envelope was issued by a different center than SEMA_REGISTRY_URL — its refresh token is never sent there; the access token is used until it expires, then sign in again against this center" });
176
+ return;
177
+ }
178
+ logger.warn("center_credential_renew_failed", {
179
+ reason,
180
+ retrying: false,
181
+ ...fields,
182
+ ...expiryField(),
183
+ note: "this refresh token is not retried until the credential file changes (sign in again with the shell); center requests keep the current access token until it expires, then take their existing failure arms",
184
+ });
185
+ };
186
+ const settleRetrying = (reason, refreshDigest, fields) => {
187
+ const t = now();
188
+ const streak = renewal.state === "pending" && renewal.reason === reason ? renewal : undefined;
189
+ const attempts = streak === undefined ? 1 : streak.attempts + 1;
190
+ renewal = { state: "pending", reason, since: streak === undefined ? t : streak.since, attempts, retryAt: t + Math.min(BACKOFF_BASE_MS * 2 ** (attempts - 1), tickMs) };
191
+ subject = refreshDigest;
192
+ if (attempts === 1) {
193
+ logger.warn("center_credential_renew_failed", {
194
+ reason,
195
+ retrying: true,
196
+ ...fields,
197
+ ...expiryField(),
198
+ note: reason === "write_failed"
199
+ ? "the center already rotated the refresh token; the new credential is kept in memory and the file rewrite is retried with backoff (the consumed token is never sent again); warned once per failure streak"
200
+ : "retried with backoff on the next read / timer tick; warned once per failure streak",
201
+ });
202
+ }
203
+ };
204
+ const judgeRefusal = (usedDigest, reason, fields) => {
205
+ const file = readFileNow(path);
206
+ if (file.kind === "unreadable") {
207
+ settleRetrying("center_unavailable", usedDigest, { ...fields, readError: file.code });
208
+ return;
209
+ }
210
+ if (file.kind === "no-session" || file.refreshDigest !== usedDigest) {
211
+ reset();
212
+ return;
213
+ }
214
+ settleTerminal(reason, usedDigest, fields);
215
+ };
216
+ const persistHeld = (next, consumedDigest, expiresInSec) => {
217
+ held = next;
218
+ subject = consumedDigest;
219
+ let tmp;
220
+ try {
221
+ tmp = prepareTmp(path, next, discardTmp, reportCleanup);
222
+ }
223
+ catch (err) {
224
+ settleRetrying("write_failed", consumedDigest, { error: errCode(err, "write error") });
225
+ return;
226
+ }
227
+ const file = readFileNow(path);
228
+ if (file.kind !== "session" || file.refreshDigest !== consumedDigest) {
229
+ discardTmp(tmp);
230
+ if (file.kind === "unreadable") {
231
+ settleRetrying("write_failed", consumedDigest, { error: file.code });
232
+ return;
233
+ }
234
+ reset();
235
+ return;
236
+ }
237
+ try {
238
+ renameSync(tmp, path);
239
+ }
240
+ catch (err) {
241
+ discardTmp(tmp);
242
+ settleRetrying("write_failed", consumedDigest, { error: errCode(err, "rename error") });
243
+ return;
244
+ }
245
+ for (const leftover of [...stranded])
246
+ discardTmp(leftover);
247
+ reset();
248
+ lastExpiresAt = Date.parse(next.expiresAt);
249
+ quietUntil = now() + tickMs;
250
+ logger.info("center_credential_renewed", { expiresAt: next.expiresAt, ...(expiresInSec !== undefined ? { expiresInSec } : {}) });
251
+ };
252
+ const renewOnce = async () => {
253
+ if (held !== undefined && subject !== undefined) {
254
+ persistHeld(held, subject, undefined);
255
+ return;
256
+ }
257
+ const file = readFileNow(path);
258
+ if (file.kind !== "session")
259
+ return;
260
+ const { envelope, refreshDigest: usedDigest } = file;
261
+ if (subject !== undefined && subject !== usedDigest)
262
+ reset();
263
+ if (renewal.state === "failed")
264
+ return;
265
+ const expiresAt = Date.parse(envelope.expiresAt);
266
+ lastExpiresAt = expiresAt;
267
+ if (expiresAt - now() > leadMs) {
268
+ reset();
269
+ return;
270
+ }
271
+ if (!sameOrigin(envelope.issuer, centerUrl)) {
272
+ settleTerminal("issuer_mismatch", usedDigest, { issuerOrigin: new URL(envelope.issuer).origin, centerOrigin: new URL(centerUrl).origin });
273
+ return;
274
+ }
275
+ let res;
276
+ try {
277
+ res = await centerTokenRefresh(envelope.issuer, envelope.refreshToken, { timeoutMs: CENTER_PULL_TIMEOUT_MS, ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}) });
278
+ }
279
+ catch (err) {
280
+ settleRetrying("center_unavailable", usedDigest, { error: err instanceof Error ? err.name : "fetch error" });
281
+ return;
282
+ }
283
+ if (res.status >= 500 || res.status === 408 || res.status === 429) {
284
+ await res.body?.cancel();
285
+ settleRetrying("center_unavailable", usedDigest, { status: res.status });
286
+ return;
287
+ }
288
+ if (res.status !== 200) {
289
+ const oauth = OAuthErrorResponse.safeParse(await res.json().catch(() => undefined));
290
+ const oauthError = oauth.success ? oauth.data.error : undefined;
291
+ judgeRefusal(usedDigest, res.status === 400 && oauthError === "invalid_grant" ? "invalid_grant" : "rejected", { status: res.status, ...(oauthError !== undefined ? { oauthError } : {}) });
292
+ return;
293
+ }
294
+ const issuedAt = now();
295
+ const grant = TokenGrantResponse.safeParse(await res.json().catch(() => undefined));
296
+ if (!grant.success || !Number.isFinite(grant.data.expires_in) || grant.data.expires_in <= 0) {
297
+ judgeRefusal(usedDigest, "malformed_grant", { status: res.status });
298
+ return;
299
+ }
300
+ const next = CenterCredentialEnvelope.safeParse({
301
+ kind: envelope.kind,
302
+ accessToken: grant.data.access_token,
303
+ refreshToken: grant.data.refresh_token,
304
+ expiresAt: new Date(issuedAt + grant.data.expires_in * 1000).toISOString(),
305
+ issuer: envelope.issuer,
306
+ });
307
+ if (!next.success) {
308
+ judgeRefusal(usedDigest, "malformed_grant", { status: res.status });
309
+ return;
310
+ }
311
+ persistHeld(next.data, usedDigest, grant.data.expires_in);
312
+ };
313
+ const maybeRenew = (expiresAt) => {
314
+ if (leadMs === 0 || stopped || inFlight)
315
+ return;
316
+ const t = now();
317
+ if (renewal.state === "pending" && t < renewal.retryAt)
318
+ return;
319
+ if (held === undefined && (expiresAt - t > leadMs || t < quietUntil))
320
+ return;
321
+ inFlight = true;
322
+ void renewOnce()
323
+ .catch((err) => {
324
+ const error = { error: err instanceof Error ? err.message : String(err) };
325
+ if (held !== undefined && subject !== undefined) {
326
+ settleRetrying("write_failed", subject, error);
327
+ return;
328
+ }
329
+ const file = readFileNow(path);
330
+ settleRetrying("center_unavailable", file.kind === "session" ? file.refreshDigest : (subject ?? ""), error);
331
+ })
332
+ .finally(() => {
333
+ inFlight = false;
334
+ });
335
+ };
336
+ const unsubscribe = source.onRead((read) => {
337
+ loudness(read);
338
+ const expiresAt = sessionExpiryOf(read);
339
+ lastExpiresAt = expiresAt;
340
+ reconcile();
341
+ if (expiresAt !== undefined || held !== undefined)
342
+ maybeRenew(expiresAt ?? now());
343
+ });
344
+ const timer = leadMs > 0 ? setInterval(() => void source.read(), tickMs) : undefined;
345
+ timer?.unref();
346
+ if (leadMs > 0)
347
+ source.read();
348
+ return {
349
+ renewal: () => renewal,
350
+ sessionExpiresAt: () => lastExpiresAt,
351
+ stop() {
352
+ stopped = true;
353
+ if (timer !== undefined)
354
+ clearInterval(timer);
355
+ unsubscribe();
356
+ },
357
+ };
358
+ }
359
+ //# sourceMappingURL=center-credential-renewer.js.map
@@ -1,15 +1,8 @@
1
+ import { type CenterCredentialDeclaration } from "@sema-agent/settings-schema";
1
2
  /** 凭证种类闭集。新增一个种类 = 本文件与 `config-center/center-request.ts` 的穷举 switch 当场编译红。 */
2
- export type CenterCredentialKind = "pull-token" | "user-jwt" | "none";
3
- /** 凭证在哪里声明(env 形带值 —— 只在本模块内流转)。 */
4
- export type CenterCredentialDeclaration = {
5
- readonly from: "env";
6
- readonly token: string;
7
- } | {
8
- readonly from: "file";
9
- readonly path: string;
10
- } | {
11
- readonly from: "absent";
12
- };
3
+ export type CenterCredentialKind = "pull-token" | "user-jwt" | "user-session" | "none";
4
+ /** 一次取值失败的成因闭集(见模块头注「四个成因」;boot 判决 {@link judgeCenterCredentialAtBoot} 对它穷举)。 */
5
+ export type CenterCredentialFailure = "missing" | "malformed" | "multi_tenant_session" | "expired";
13
6
  /** 声明形的**非秘密**投影(进日志 / 拒因 / 未来的 caps 读面):env 形不带值。 */
14
7
  export type CenterCredentialOrigin = {
15
8
  readonly from: "env";
@@ -19,7 +12,11 @@ export type CenterCredentialOrigin = {
19
12
  } | {
20
13
  readonly from: "absent";
21
14
  };
22
- /** 一次取值的结果。`ok:false` 只有文件形会出现(env / absent 形的取值恒成功)。 */
15
+ /**
16
+ * 一次取值的结果。`ok:false` 只有文件形会出现(env / absent 形的取值恒成功)。
17
+ * `identity` 是「此刻的凭证是谁」—— 成功恒在;失败时只有 `expired` 知道(信封还读得出 `sub`,只是令牌不许再出站),
18
+ * 其余失败 = 身份未知(见 {@link credentialIdentityNow})。
19
+ */
23
20
  export type CenterCredentialRead = {
24
21
  readonly ok: true;
25
22
  readonly kind: "pull-token";
@@ -31,19 +28,34 @@ export type CenterCredentialRead = {
31
28
  readonly kind: "user-jwt";
32
29
  readonly bearer: string;
33
30
  readonly identity: string;
31
+ } | {
32
+ readonly ok: true;
33
+ readonly kind: "user-session";
34
+ readonly bearer: string;
35
+ readonly identity: string;
36
+ readonly expiresAt: number;
34
37
  } | {
35
38
  readonly ok: true;
36
39
  readonly kind: "none";
37
40
  readonly identity: string;
38
41
  } | {
39
42
  readonly ok: false;
43
+ readonly failure: Exclude<CenterCredentialFailure, "expired">;
40
44
  readonly reason: string;
45
+ readonly identity?: undefined;
46
+ } | {
47
+ readonly ok: false;
48
+ readonly failure: "expired";
49
+ readonly reason: string;
50
+ readonly identity: string;
51
+ readonly expiresAt: number;
41
52
  };
42
- /** 健康态:最近一次取值的结果(caps `leaseHealth` 之类的读面读它,不自己再判)。 */
53
+ /** 健康态:最近一次取值的结果(`/health.centerCredential` 经续签器模块的视图读它,不自己再判)。 */
43
54
  export type CenterCredentialHealth = {
44
55
  readonly state: "ok";
45
56
  } | {
46
57
  readonly state: "unreadable";
58
+ readonly failure: CenterCredentialFailure;
47
59
  readonly reason: string;
48
60
  readonly since: number;
49
61
  readonly failures: number;
@@ -56,31 +68,72 @@ export interface CenterCredentialSource {
56
68
  /** 每次出站请求调用一次;文件形每次都读文件。 */
57
69
  read(): CenterCredentialRead;
58
70
  health(): CenterCredentialHealth;
71
+ /**
72
+ * S-698:每次取值之后**同步**回调一次(入参 = 这次的结果,已记进健康态)。续签器的唯一触发口(设计 §2「每次取值后,
73
+ * 读到 user-session 信封且在提前量内 ⇒ 排队一次续签」)。返回退订。回调不许抛(抛 = 这次取值跟着抛,响亮)。
74
+ */
75
+ onRead(listener: (read: CenterCredentialRead) => void): () => void;
59
76
  }
60
- /** 凭证文件此刻不可读 / 为空。中心请求装配点(`center-request.ts`)取值失败时抛它,各消费方按自己的
77
+ /** 凭证此刻取不到(文件不可读 / 读不成 / 过期 / 多租户拒)。中心请求装配点(`center-request.ts`)取值失败时抛它,各消费方按自己的
61
78
  * 失败臂处置(拉配置保 LKG、caps fail-closed、租约走「鉴权失败」响亮臂 …),**不发一个空 bearer 出去**。 */
62
79
  export declare class CenterCredentialUnavailableError extends Error {
63
80
  readonly reason: string;
64
81
  constructor(reason: string);
65
82
  }
66
- export declare const CENTER_TOKEN_ENV = "SEMA_REGISTRY_TOKEN";
67
- export declare const CENTER_TOKEN_FILE_ENV = "SEMA_REGISTRY_TOKEN_FILE";
68
83
  /**
69
- * 读两个声明键。两个都非空 ⇒ throw(boot 拒启,拒因点名二选一)。空串 = 未设(与 7.98.0 之前
70
- * `SEMA_REGISTRY_TOKEN || ""` 的读法一致)。文件路径在这里解析成绝对路径(之后 cwd 变了也读同一个文件)。
84
+ * 凭证来源的构造入参:settings-schema 的声明形,文件形**必须**带上本部署的租户姿态(多租户拒人的会话信封,见模块头注)——
85
+ * 姿态只在文件形有意义(env 值形是部署注入的拉取令牌,永远不是信封),所以只在那一臂上必填:漏传 = 编译红,不是静默单租户。
86
+ */
87
+ export type CenterCredentialSourceSpec = Exclude<CenterCredentialDeclaration, {
88
+ from: "file";
89
+ }> | {
90
+ readonly from: "file";
91
+ readonly path: string;
92
+ readonly multiTenant: boolean;
93
+ };
94
+ /**
95
+ * 读两个声明键 —— 判定全交 settings-schema 的判官 `judgeCenterCredentialDeclaration`(S-742:二选一规则的**唯一**实现;空串 = 未设、
96
+ * 不 trim;两个都设 ⇒ throw,boot 拒启,拒句点名两个键、不回显值)。本仓只多读方自己的那一步:文件路径按本进程 cwd 解析成绝对
97
+ * 路径(之后 cwd 变了也读同一个文件 —— 纯契约不碰 `node:path`,这一步归读方)。
71
98
  */
72
- export declare function readCenterCredentialDeclaration(env?: NodeJS.ProcessEnv): CenterCredentialDeclaration;
99
+ export declare function readCenterCredentialDeclaration(env?: Readonly<Record<string, string | undefined>>): CenterCredentialDeclaration;
73
100
  /** 拉取令牌形的身份 = worker(拉取令牌代表 worker,不是人)。种类判别与 formatVersion 1 LKG 的一次性迁移
74
101
  * (`config-lkg.ts` `migrateV1Lkg`:v1 信封只记 worker,只有这一形的身份推得出来)共用这一只。 */
75
102
  export declare function pullTokenIdentity(worker: string | undefined): string;
76
103
  /**
77
104
  * 凭证来源工厂(活对象:文件形每次取值读盘并记健康态)。`worker` = `SEMA_REGISTRY_WORKER`(拉取令牌形的身份)。
78
- * `readFile` 只为测试注入。
105
+ * `readFile` / `now` 只为测试注入。
79
106
  */
80
- export declare function createCenterCredentialSource(declaration: CenterCredentialDeclaration, worker: string | undefined, deps?: {
107
+ export declare function createCenterCredentialSource(spec: CenterCredentialSourceSpec, worker: string | undefined, deps?: {
81
108
  readFile?: (path: string) => string;
82
109
  now?: () => number;
83
110
  }): CenterCredentialSource;
111
+ /** boot 期一次取值的判决(闭集,对 {@link CenterCredentialFailure} 穷举 —— 加一个成因不补臂 = 编译红)。 */
112
+ export type CenterCredentialBootVerdict = {
113
+ readonly verdict: "serve";
114
+ } | {
115
+ readonly verdict: "loud";
116
+ readonly failure: "missing" | "expired";
117
+ readonly reason: string;
118
+ readonly effect: string;
119
+ } | {
120
+ readonly verdict: "refuse";
121
+ readonly code: CenterCredentialRefusalCode;
122
+ readonly sentence: string;
123
+ };
124
+ /** 拒启码(拼法随 `config.*` 家族,按**规则**命名):出现在拒启句的方括号里。 */
125
+ export declare const CENTER_CREDENTIAL_REFUSAL_CODES: {
126
+ readonly malformed: "config.center_credential_malformed";
127
+ readonly multi_tenant_session: "config.center_credential_user_session_multi_tenant";
128
+ };
129
+ export type CenterCredentialRefusalCode = (typeof CENTER_CREDENTIAL_REFUSAL_CODES)[keyof typeof CENTER_CREDENTIAL_REFUSAL_CODES];
130
+ /**
131
+ * S-698 —— boot 读到的凭证怎么处置(`boot/config-center.ts` 执行,规则只在这里):
132
+ * · `missing` / `expired` ⇒ 响亮(error 级一行),照常起服 —— 前者是「此刻没有凭证」(登出形,文件出现即恢复),
133
+ * 后者续签器立刻续签;
134
+ * · `malformed` / `multi_tenant_session` ⇒ **拒启**,拒句带码、点名文件键,不回显内容(`reason` 由本模块铸,本来就不含内容)。
135
+ */
136
+ export declare function judgeCenterCredentialAtBoot(read: CenterCredentialRead): CenterCredentialBootVerdict;
84
137
  /** 需要作用域 worker 的拉取令牌(`wpt_<self>`)却没配 `SEMA_REGISTRY_WORKER` ⇒ 中心对 per-principal caps
85
138
  * 逐次 403(caps fail-closed 拒全部 workflow)。boot 期一次性诊断用;判据只读本模块的种类判别。 */
86
139
  export declare function scopedTokenNeedsWorker(read: CenterCredentialRead, worker: string | undefined): boolean;
@@ -90,7 +143,8 @@ export declare function scopedTokenNeedsWorker(read: CenterCredentialRead, worke
90
143
  * 目录定的姿势,本批推广到 LKG)。
91
144
  */
92
145
  export declare function centerStateBin(worker: string | undefined, identity: string): string;
93
- /** 此刻凭证的身份(每次现读);文件形此刻不可读 ⇒ `undefined`(身份未知)。「我现在是谁」只有这一只读法。 */
146
+ /** 此刻凭证的身份(每次现读);取不到凭证 ⇒ `undefined`(身份未知)—— 唯一例外是 `expired`:令牌不许出站,但信封还读得出
147
+ * 是谁(S-698:过期不等于换了人,盘上 LKG / prompt 分箱照常对得上)。「我现在是谁」只有这一只读法。 */
94
148
  export declare function credentialIdentityNow(source: CenterCredentialSource): string | undefined;
95
149
  /**
96
150
  * [ref] E-15 ② —— 一次中心往返的答复**仍属于此刻的凭证**:发出它所用的身份 = 此刻的身份。