@sema-agent/server 7.50.0 → 7.52.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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/USAGE.md +83 -20
- package/dist/adoption/plan.d.ts +4 -4
- package/dist/adoption/plan.js +11 -0
- package/dist/approval-card.d.ts +26 -2
- package/dist/approval-card.js +9 -0
- package/dist/approval-hmac.d.ts +1 -1
- package/dist/approval-reconciler.d.ts +2 -2
- package/dist/approval-reconciler.js +1 -1
- package/dist/bench/l8/artifact.d.ts +1 -1
- package/dist/bench/s1/arms.d.ts +1 -1
- package/dist/bench/s1/live-deps.d.ts +1 -1
- package/dist/bench/s1/oracle.d.ts +1 -1
- package/dist/bench/s1/row.d.ts +1 -1
- package/dist/boot/budget-tracing.d.ts +1 -1
- package/dist/boot/budget-tracing.js +2 -5
- package/dist/boot/coordinators.js +3 -1
- package/dist/boot/deferred-sandbox-path-env.d.ts +1 -1
- package/dist/boot/device-lane.d.ts +79 -0
- package/dist/boot/device-lane.js +63 -0
- package/dist/boot/engine-lease.d.ts +1 -1
- package/dist/boot/execution-env.d.ts +48 -2
- package/dist/boot/execution-env.js +57 -5
- package/dist/boot/governance-seams.d.ts +2 -2
- package/dist/boot/leader.d.ts +1 -1
- package/dist/boot/memory-boundary.d.ts +2 -2
- package/dist/boot/memory-consolidation.d.ts +6 -6
- package/dist/boot/org-memory.d.ts +1 -1
- package/dist/boot/parked-revive-gate.d.ts +3 -3
- package/dist/boot/reapers.d.ts +2 -2
- package/dist/boot/reapers.js +17 -4
- package/dist/boot/resolve-spec.d.ts +15 -0
- package/dist/boot/resolve-spec.js +40 -15
- package/dist/boot/retention-lane.d.ts +2 -2
- package/dist/boot/runner-deps.d.ts +17 -7
- package/dist/boot/runner-deps.js +4 -2
- package/dist/boot/runtime-caps.d.ts +23 -0
- package/dist/boot/runtime-caps.js +24 -2
- package/dist/boot/session-faces.d.ts +6 -1
- package/dist/boot/session-faces.js +17 -3
- package/dist/boot/shutdown.d.ts +8 -0
- package/dist/boot/shutdown.js +3 -1
- package/dist/boot/stores.js +40 -1
- package/dist/boot/task-list-lane.d.ts +2 -2
- package/dist/brain.js +51 -31
- package/dist/budget.d.ts +2 -2
- package/dist/capabilities/center-plugins.d.ts +1 -1
- package/dist/capabilities/collab-workflows.d.ts +1 -1
- package/dist/capabilities/hands-lane.d.ts +3 -3
- package/dist/capabilities/scenarios.d.ts +3 -3
- package/dist/capabilities/skills.d.ts +1 -1
- package/dist/config-center/hot-keys-registry.js +5 -0
- package/dist/config-center/http-client.d.ts +2 -2
- package/dist/config-center/read-face.d.ts +1 -1
- package/dist/config-center/restart-signal.d.ts +1 -1
- package/dist/config-center/types.d.ts +10 -3
- package/dist/config-invariants.d.ts +2 -2
- package/dist/config-invariants.js +18 -0
- package/dist/config-types.d.ts +90 -18
- package/dist/config.js +91 -19
- package/dist/deployment-governance.d.ts +1 -1
- package/dist/device-enrollment.d.ts +125 -0
- package/dist/device-enrollment.js +156 -0
- package/dist/device-store.d.ts +385 -0
- package/dist/device-store.js +407 -0
- package/dist/device-ws-hub.d.ts +182 -0
- package/dist/device-ws-hub.js +1012 -0
- package/dist/device-ws-protocol.d.ts +429 -0
- package/dist/device-ws-protocol.js +464 -0
- package/dist/env-facts.d.ts +5 -2
- package/dist/env-facts.js +1 -0
- package/dist/execution-lane-caps.d.ts +152 -0
- package/dist/execution-lane-caps.js +166 -0
- package/dist/fleet/fleet-bus.d.ts +1 -1
- package/dist/fleet/fleet-reconciler.d.ts +1 -1
- package/dist/fleet/fleet-terminal-window.d.ts +1 -1
- package/dist/http/active-run-conflict.d.ts +2 -2
- package/dist/http/principal-gate.d.ts +1 -1
- package/dist/http/route-ctx.d.ts +24 -4
- package/dist/http/routes/admin-memory-optout.d.ts +48 -0
- package/dist/http/routes/admin-memory-optout.js +75 -0
- package/dist/http/routes/agents-roster.d.ts +1 -1
- package/dist/http/routes/approvals-assistant.js +21 -21
- package/dist/http/routes/capabilities.js +27 -11
- package/dist/http/routes/diagnostics.d.ts +1 -1
- package/dist/http/routes/leader.d.ts +1 -1
- package/dist/http/routes/memory-bundle.d.ts +1 -1
- package/dist/http/routes/memory-bundle.js +3 -1
- package/dist/http/routes/memory-compliance.d.ts +4 -4
- package/dist/http/routes/memory-origin.d.ts +4 -4
- package/dist/http/routes/memory-origin.js +3 -1
- package/dist/http/routes/memory-policy.js +6 -2
- package/dist/http/routes/retention-ops.d.ts +1 -1
- package/dist/http/routes/rules.d.ts +5 -2
- package/dist/http/routes/rules.js +84 -5
- package/dist/http/routes/run-memory-capture.d.ts +34 -0
- package/dist/http/routes/run-memory-capture.js +97 -0
- package/dist/http/routes/runs.d.ts +1 -0
- package/dist/http/routes/runs.js +212 -6
- package/dist/http/routes/session-sync.d.ts +4 -6
- package/dist/http/routes/session-sync.js +60 -47
- package/dist/http/routes/sessions.js +61 -188
- package/dist/http/routes/shared-memory.d.ts +1 -1
- package/dist/http/routes/tasks.js +1 -1
- package/dist/http/routes/workflows.js +19 -0
- package/dist/http/server.d.ts +49 -13
- package/dist/http/server.js +109 -13
- package/dist/http/verify-rounds.d.ts +1 -1
- package/dist/http/wire-types.d.ts +41 -8
- package/dist/leader/endpoint.d.ts +1 -1
- package/dist/leader/leader.d.ts +1 -1
- package/dist/leader/repair-oracle.d.ts +2 -2
- package/dist/leader/repair-oracle.js +1 -1
- package/dist/leader/wire.js +4 -2
- package/dist/main.js +23 -9
- package/dist/memory-bundle-engine.d.ts +1 -1
- package/dist/memory-operator-faces.d.ts +5 -5
- package/dist/memory-origin-fence.d.ts +50 -0
- package/dist/memory-origin-fence.js +15 -0
- package/dist/memory-posture.d.ts +13 -1
- package/dist/memory-posture.js +1 -0
- package/dist/memory-scope.d.ts +6 -1
- package/dist/memory-scope.js +3 -2
- package/dist/model-select.d.ts +2 -2
- package/dist/observability/fail-open.d.ts +17 -5
- package/dist/observability/fail-open.js +17 -5
- package/dist/orchestration/hardened-vm-runner.d.ts +9 -2
- package/dist/orchestration/hardened-vm-runner.js +11 -1
- package/dist/orchestration/hardened-vm-worker-runner.js +2 -2
- package/dist/orchestration/workflow-completion-inbox.d.ts +2 -2
- package/dist/plugins/background-shell-support.d.ts +2 -2
- package/dist/plugins/blob-backend.d.ts +7 -2
- package/dist/plugins/blob-backend.js +22 -18
- package/dist/plugins/caching-session-store.d.ts +7 -3
- package/dist/plugins/checkpoint-store-sql.d.ts +2 -2
- package/dist/plugins/device-store-sql.d.ts +130 -0
- package/dist/plugins/device-store-sql.js +574 -0
- package/dist/plugins/file-history-store-sql.d.ts +170 -0
- package/dist/plugins/file-history-store-sql.js +827 -0
- package/dist/plugins/file-run-store.d.ts +44 -5
- package/dist/plugins/file-run-store.js +85 -13
- package/dist/plugins/fork-routing-session-store.d.ts +1 -1
- package/dist/plugins/fork-routing-session-store.js +2 -2
- package/dist/plugins/leader-run-store-sql.d.ts +1 -1
- package/dist/plugins/local-session-store.d.ts +9 -1
- package/dist/plugins/memory-embedder-fingerprint.d.ts +1 -1
- package/dist/plugins/memory-optout-grant-store-sql.d.ts +139 -0
- package/dist/plugins/memory-optout-grant-store-sql.js +157 -0
- package/dist/plugins/memory-origin-law.d.ts +2 -2
- package/dist/plugins/memory-run-store.d.ts +4 -3
- package/dist/plugins/memory-run-store.js +2 -1
- package/dist/plugins/permission-rule-store-file.d.ts +16 -6
- package/dist/plugins/permission-rule-store-file.js +77 -13
- package/dist/plugins/permission-rule-store-sql.d.ts +156 -8
- package/dist/plugins/permission-rule-store-sql.js +123 -21
- package/dist/plugins/pg-pool.js +59 -0
- package/dist/plugins/pg-safe-json.d.ts +1 -1
- package/dist/plugins/remote-env-device.d.ts +271 -0
- package/dist/plugins/remote-env-device.js +727 -0
- package/dist/plugins/remote-env-k8s.d.ts +1 -1
- package/dist/plugins/remote-scratchpad.js +1 -1
- package/dist/plugins/retention-lane-store-sql.d.ts +3 -3
- package/dist/plugins/retention-store-sql.d.ts +7 -7
- package/dist/plugins/retention-store-sql.js +6 -0
- package/dist/plugins/run-store-sql.d.ts +9 -3
- package/dist/plugins/run-store-sql.js +2 -2
- package/dist/plugins/send-user-file.d.ts +1 -1
- package/dist/plugins/session-placement.d.ts +1 -1
- package/dist/plugins/sql-errors.d.ts +1 -1
- package/dist/plugins/store-backend.d.ts +68 -29
- package/dist/plugins/store-backend.js +78 -15
- package/dist/plugins/store-contracts.d.ts +5 -3
- package/dist/plugins/task-list-store-sql.d.ts +1 -1
- package/dist/plugins/tidb-pool.js +59 -0
- package/dist/plugins/web-search.d.ts +1 -1
- package/dist/prompts-domain-validate.js +1 -1
- package/dist/resource-suspend.d.ts +1 -1
- package/dist/resource-window.d.ts +3 -3
- package/dist/rules-consent.d.ts +231 -9
- package/dist/rules-consent.js +411 -13
- package/dist/run-local.js +1 -0
- package/dist/runs.d.ts +15 -1
- package/dist/runs.js +19 -0
- package/dist/runtime-caps-resolver.d.ts +36 -1
- package/dist/runtime-caps-resolver.js +25 -0
- package/dist/runtime-governance.d.ts +1 -1
- package/dist/security.d.ts +5 -5
- package/dist/security.js +1 -1
- package/dist/session-sync.d.ts +52 -37
- package/dist/session-sync.js +19 -16
- package/dist/shared-memory-scope-authorizer.d.ts +1 -1
- package/dist/sighup-idle.d.ts +1 -1
- package/dist/spec-fields.d.ts +22 -1
- package/dist/spec-fields.js +11 -0
- package/dist/store-live-probe.d.ts +2 -2
- package/dist/task-a2a.d.ts +1 -1
- package/dist/task-cwd.d.ts +25 -0
- package/dist/task-cwd.js +3 -0
- package/dist/task-mcp.d.ts +3 -3
- package/dist/task-settings.d.ts +3 -3
- package/dist/task-settings.js +3 -1
- package/dist/task-workflow.d.ts +1 -1
- package/dist/text-bidi.d.ts +2 -2
- package/dist/tool-approval.d.ts +191 -9
- package/dist/tool-approval.js +248 -29
- package/dist/trace/core-keyset-guard.d.ts +1 -1
- package/dist/trace/engine-notice-wire.d.ts +6 -4
- package/dist/trace/engine-notice-wire.js +2 -0
- package/dist/trace/ledger-events.d.ts +1 -1
- package/dist/trace/ledger-sink.d.ts +2 -2
- package/dist/trace/project.d.ts +3 -2
- package/dist/trace/project.js +1 -0
- package/dist/turn-activity.d.ts +1 -1
- package/dist/wall-clock-jump-guard.d.ts +3 -3
- package/package.json +2 -2
- package/dist/http/tar.d.ts +0 -17
- package/dist/http/tar.js +0 -53
- package/dist/http/workspace-content.d.ts +0 -11
- package/dist/http/workspace-content.js +0 -20
- package/dist/plugins/file-snapshot-store-sql.d.ts +0 -170
- package/dist/plugins/file-snapshot-store-sql.js +0 -213
package/dist/config.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { BUILTIN_COMPLIANCE_DENIES, COMPLIANCE_CAPABILITIES, compileReadDeny, resolveReadDenyBuiltins, CODE_AGENT_PROMPT, formatUserScope, isThinkingLevel, PROTOCOL_TABLE, protocolOf, RECOMMENDED_SENSITIVE_PATTERNS, RETIRED_TOOL_NAMES, resolveLockedKeys, validatePermissionRules } from "@sema-agent/core";
|
|
5
|
+
import { BUILTIN_COMPLIANCE_DENIES, COMPLIANCE_CAPABILITIES, compileReadDeny, resolveReadDenyBuiltins, CODE_AGENT_PROMPT, formatUserScope, isThinkingLevel, PROTOCOL_TABLE, protocolOf, RECOMMENDED_SENSITIVE_PATTERNS, RETIRED_TOOL_NAMES, resolveFileHistoryRetention, resolveLockedKeys, validatePermissionRules } from "@sema-agent/core";
|
|
6
6
|
import { ROSTER_PRIMARY_ROLES, ROSTER_CHEAP_ROLES } from "@sema-agent/settings-schema";
|
|
7
7
|
import { parseApprovalHmacKeys, parsePrincipalJwks } from "./auth-keys.js";
|
|
8
8
|
import { DEFAULT_ELICITATION_THROTTLE } from "./elicitation.js";
|
|
@@ -11,6 +11,7 @@ import { DEFAULT_QUESTION_THROTTLE } from "./question.js";
|
|
|
11
11
|
import { GIT_API_KINDS, isGitApiKind } from "./git-api-kind.js";
|
|
12
12
|
import { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC } from "./config-types.js";
|
|
13
13
|
import { normalizeSshFingerprint } from "./ssh-host-key.js";
|
|
14
|
+
import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
|
|
14
15
|
export { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC } from "./config-types.js";
|
|
15
16
|
function csv(name) {
|
|
16
17
|
return (process.env[name] ?? "")
|
|
@@ -251,6 +252,15 @@ function optNumEnvStrict(name) {
|
|
|
251
252
|
return undefined;
|
|
252
253
|
return parseNumOrFail(name, raw);
|
|
253
254
|
}
|
|
255
|
+
function parseFileHistoryRetention() {
|
|
256
|
+
const raw = process.env.FILE_HISTORY_RETENTION_KEEP;
|
|
257
|
+
if (raw === undefined || raw === "")
|
|
258
|
+
return undefined;
|
|
259
|
+
const keep = raw === "unbounded" ? "unbounded" : /^(?:0|[1-9]\d*)$/.test(raw) ? Number(raw) : raw;
|
|
260
|
+
const policy = { keep };
|
|
261
|
+
resolveFileHistoryRetention("FILE_HISTORY_RETENTION_KEEP", policy);
|
|
262
|
+
return { retention: policy };
|
|
263
|
+
}
|
|
254
264
|
function optFinitePositiveEnv(name) {
|
|
255
265
|
const raw = process.env[name];
|
|
256
266
|
if (raw === undefined || raw === "")
|
|
@@ -502,10 +512,10 @@ function parseStoreDomain(ctx) {
|
|
|
502
512
|
sessionBackend,
|
|
503
513
|
sessionCacheTtlSec: numEnv("SESSION_CACHE_TTL_SEC", "300"),
|
|
504
514
|
storeProbeIntervalMs: numEnvBounded("STORE_PROBE_INTERVAL_MS", "15000", 0, 3_600_000),
|
|
505
|
-
rewindSnapshotMaxMb: optFinitePositiveEnv("REWIND_SNAPSHOT_MAX_MB"),
|
|
506
515
|
dbBackend,
|
|
507
516
|
dbBackendExplicit: dbBackendSet,
|
|
508
517
|
localDataRoot,
|
|
518
|
+
runStoreStrictHydrate: parseRunStoreStrictHydrate(process.env.RUN_STORE_STRICT_HYDRATE),
|
|
509
519
|
tidb: needsDb && dbBackend === "mysql"
|
|
510
520
|
? {
|
|
511
521
|
host: env("MYSQL_HOST"),
|
|
@@ -540,6 +550,7 @@ function parseStoreDomain(ctx) {
|
|
|
540
550
|
: undefined,
|
|
541
551
|
snapshotBlobSqlMaxBytes: optFinitePositiveEnv("SNAPSHOT_BLOB_SQL_MAX_BYTES"),
|
|
542
552
|
snapshotBlobAllowSql: boolEnv("SNAPSHOT_BLOB_ALLOW_SQL_BYTES", false),
|
|
553
|
+
fileHistory: parseFileHistoryRetention(),
|
|
543
554
|
sendUserFile: (process.env.MINIO_ENDPOINT || process.env.S3_ENDPOINT) && process.env.MINIO_ACCESS_KEY && process.env.MINIO_SECRET_KEY
|
|
544
555
|
? {
|
|
545
556
|
endpoint: (process.env.MINIO_ENDPOINT || process.env.S3_ENDPOINT),
|
|
@@ -736,11 +747,17 @@ function parseDegradeOn(words) {
|
|
|
736
747
|
}
|
|
737
748
|
return words;
|
|
738
749
|
}
|
|
750
|
+
const PRESENTED_NAME_MAX = 160;
|
|
751
|
+
function presentToolName(name) {
|
|
752
|
+
const clean = name.replace(CONTROL_AND_BIDI_STRIP_RE, "");
|
|
753
|
+
return clean.length <= PRESENTED_NAME_MAX ? clean : `${clean.slice(0, PRESENTED_NAME_MAX)}…(${name.length} chars)`;
|
|
754
|
+
}
|
|
739
755
|
function ccRuleFormGuidance(name) {
|
|
740
756
|
if (!name.includes("(") && !name.includes(")"))
|
|
741
757
|
return undefined;
|
|
742
|
-
|
|
743
|
-
|
|
758
|
+
const shown = presentToolName(name);
|
|
759
|
+
return (`CC-form rule specifier — this list matches the WHOLE tool name verbatim, so it can only ever match a tool literally named "${shown}". ` +
|
|
760
|
+
`Drop the parentheses to gate the tool itself ("${presentToolName(name.split("(")[0] ?? name)}"); ` +
|
|
744
761
|
`to decide per COMMAND use \`runtime.commandPolicy\` ({command, decision} — bare argv[0] names); ` +
|
|
745
762
|
`to put every shell call behind an approval use MANUAL_MODE_SHELL_GATE=always`);
|
|
746
763
|
}
|
|
@@ -783,7 +800,7 @@ export function findUnmatchableToolNames(names) {
|
|
|
783
800
|
const ns = protocolOf(name);
|
|
784
801
|
if (ns === undefined) {
|
|
785
802
|
if (name.includes("__"))
|
|
786
|
-
out.push({ name, guidance: `pre-prefix short form — the live name carries a protocol prefix: ${PROTOCOL_TABLE.map((e) => `${e.prefix}${name}`).join(" or ")}` });
|
|
803
|
+
out.push({ name, guidance: `pre-prefix short form — the live name carries a protocol prefix: ${PROTOCOL_TABLE.map((e) => `${e.prefix}${presentToolName(name)}`).join(" or ")}` });
|
|
787
804
|
continue;
|
|
788
805
|
}
|
|
789
806
|
if (ns.parse(name) === undefined) {
|
|
@@ -794,9 +811,9 @@ export function findUnmatchableToolNames(names) {
|
|
|
794
811
|
}
|
|
795
812
|
export function formatUnmatchableToolNames(source, bad) {
|
|
796
813
|
return (`${source} names tool(s) that can never match a live tool (these lists are RAW whole-name comparisons — core 5.0.0 RB-476 removed the alias/auto-prefix folds): ` +
|
|
797
|
-
bad.map((b) => `${b.name} → ${b.guidance}`).join("; "));
|
|
814
|
+
bad.map((b) => `${presentToolName(b.name)} → ${b.guidance}`).join("; ")).replace(CONTROL_AND_BIDI_STRIP_RE, "");
|
|
798
815
|
}
|
|
799
|
-
function streamApprovalConfig() {
|
|
816
|
+
function streamApprovalConfig(gates) {
|
|
800
817
|
const cfg = {
|
|
801
818
|
enabled: boolEnv("STREAM_APPROVAL_ENABLED", true),
|
|
802
819
|
windowMs: numEnv("STREAM_ASK_WINDOW_MS", "300000"),
|
|
@@ -807,10 +824,15 @@ function streamApprovalConfig() {
|
|
|
807
824
|
pendingGraceMs: numEnvBounded("STREAM_APPROVAL_PENDING_GRACE_MS", "30000", 0, 3_600_000),
|
|
808
825
|
adhocGraceMs: numEnvBounded("STREAM_APPROVAL_ADHOC_GRACE_MS", "60000", 0, 86_400_000),
|
|
809
826
|
orphanTtlMs: numEnvBounded("STREAM_APPROVAL_ORPHAN_TTL_MS", String(7 * 24 * 60 * 60 * 1000), 60_000, 90 * 24 * 60 * 60 * 1000),
|
|
827
|
+
unreachedTtlMs: numEnvBounded("UNREACHED_ASK_TTL_MS", String(60 * 60 * 1000), 60_000, 24 * 60 * 60 * 1000),
|
|
810
828
|
};
|
|
811
829
|
if (cfg.windowMs + cfg.adhocGraceMs >= cfg.orphanTtlMs) {
|
|
812
830
|
throw new Error(`env STREAM_ASK_WINDOW_MS=${cfg.windowMs} + STREAM_APPROVAL_ADHOC_GRACE_MS=${cfg.adhocGraceMs} must be STRICTLY LESS than STREAM_APPROVAL_ORPHAN_TTL_MS=${cfg.orphanTtlMs} — the adhoc arm fires at (createdAt + window + grace) while the orphan backstop fires at (createdAt + ttl); if the backstop wins, every adhoc-leg row is attributed "orphan_ttl_exceeded" instead of "adhoc_leg_no_durable_domain" (attribution honesty, design/172 §6.1)`);
|
|
813
831
|
}
|
|
832
|
+
const suspendedRowsPossible = cfg.enabled && gates.toolApprovalEnabled && gates.unattendedPolicy !== "deny";
|
|
833
|
+
if (suspendedRowsPossible && cfg.unreachedTtlMs + cfg.adhocGraceMs >= cfg.orphanTtlMs) {
|
|
834
|
+
throw new Error(`env UNREACHED_ASK_TTL_MS=${cfg.unreachedTtlMs} + STREAM_APPROVAL_ADHOC_GRACE_MS=${cfg.adhocGraceMs} must be STRICTLY LESS than STREAM_APPROVAL_ORPHAN_TTL_MS=${cfg.orphanTtlMs} — an ask minted with NO live stream (case A: a workflow child ask) waits UNREACHED_ASK_TTL_MS, so its row expires at (createdAt + that window); the orphan backstop fires at (createdAt + ttl). If the backstop wins, every suspended row is attributed "orphan_ttl_exceeded" instead of its real cause (attribution honesty, same rule as the STREAM_ASK_WINDOW_MS line above)`);
|
|
835
|
+
}
|
|
814
836
|
if (!Number.isInteger(cfg.reconcileBatch)) {
|
|
815
837
|
throw new Error(`env STREAM_APPROVAL_RECONCILE_BATCH=${cfg.reconcileBatch} must be an INTEGER row count`);
|
|
816
838
|
}
|
|
@@ -829,6 +851,8 @@ function unattendedApprovalPolicyEnv() {
|
|
|
829
851
|
}
|
|
830
852
|
function parseApprovalDomain(ctx) {
|
|
831
853
|
const { postureOn } = ctx;
|
|
854
|
+
const unattendedPolicy = unattendedApprovalPolicyEnv();
|
|
855
|
+
const toolApprovalEnabled = postureOn("TOOL_APPROVAL_ENABLED");
|
|
832
856
|
const directApprovalDoor = boolEnv("DIRECT_APPROVAL_DOOR", false);
|
|
833
857
|
const dgHmacKeys = parseApprovalHmacKeys(process.env.APPROVAL_HMAC_KEYS, announceKeySetRejection("APPROVAL_HMAC_KEYS"));
|
|
834
858
|
const durableApproval = boolEnv("DURABLE_APPROVAL", false);
|
|
@@ -896,12 +920,12 @@ function parseApprovalDomain(ctx) {
|
|
|
896
920
|
maxTotalPerRun: numEnvBounded("QUESTION_MAX_TOTAL_PER_RUN", String(DEFAULT_QUESTION_THROTTLE.maxTotalPerRun), 1, 10_000),
|
|
897
921
|
ttlMs: numEnvBounded("QUESTION_TTL_MS", String(DEFAULT_QUESTION_THROTTLE.ttlMs), 1_000, 3_600_000),
|
|
898
922
|
},
|
|
899
|
-
toolApprovalEnabled
|
|
923
|
+
toolApprovalEnabled,
|
|
900
924
|
permissionRulesEnabled: boolEnv("PERMISSION_RULES_ENABLED", true),
|
|
901
925
|
permissionRulesEnabledExplicit: parseBoolWord("PERMISSION_RULES_ENABLED", process.env["PERMISSION_RULES_ENABLED"]) !== undefined,
|
|
902
926
|
streamAskWindowMarginMs: numEnv("STREAM_ASK_WINDOW_MARGIN_MS", "10000"),
|
|
903
|
-
streamApproval: streamApprovalConfig(),
|
|
904
|
-
unattendedApprovalPolicy:
|
|
927
|
+
streamApproval: streamApprovalConfig({ unattendedPolicy, toolApprovalEnabled }),
|
|
928
|
+
unattendedApprovalPolicy: unattendedPolicy,
|
|
905
929
|
approvalTimeoutSec: numEnv("APPROVAL_TIMEOUT_SEC", "0"),
|
|
906
930
|
};
|
|
907
931
|
}
|
|
@@ -932,6 +956,20 @@ function parseMemoryPersistenceCapable(raw) {
|
|
|
932
956
|
`\`# Memory\` write instruction on a remote execution lane) or ${MEMORY_ENGINE_OFF_WORDS.join(" | ")} (it CANNOT — ` +
|
|
933
957
|
`forces the read-only disclosure). Leave it UNSET to let the engine infer from the mounted roster.`);
|
|
934
958
|
}
|
|
959
|
+
function parseRunStoreStrictHydrate(raw) {
|
|
960
|
+
const w = (raw ?? "").trim().toLowerCase();
|
|
961
|
+
if (w === "")
|
|
962
|
+
return false;
|
|
963
|
+
if (MEMORY_ENGINE_ON_WORDS.includes(w))
|
|
964
|
+
return true;
|
|
965
|
+
if (MEMORY_ENGINE_OFF_WORDS.includes(w))
|
|
966
|
+
return false;
|
|
967
|
+
throw new Error(`env RUN_STORE_STRICT_HYDRATE="${raw}" is not a known on/off word — use one of: ${MEMORY_ENGINE_ON_WORDS.join(" | ")} ` +
|
|
968
|
+
"(the file run-store REFUSES TO BOOT when it cannot list its runs/ directory) or " +
|
|
969
|
+
`${MEMORY_ENGINE_OFF_WORDS.join(" | ")} (the default: boot anyway with an empty ledger, disclosed by the ` +
|
|
970
|
+
"file_run_store_hydrate_unreadable error line + the server.run-store.file-hydrate-unreadable counter). " +
|
|
971
|
+
"An unrecognized value must not fold to the default: that would silently swallow an operator's explicit fail-closed request.");
|
|
972
|
+
}
|
|
935
973
|
const SANDBOX_PKG_SOURCES = ["global", "cn", "custom", "none"];
|
|
936
974
|
function pkgSourceEnv() {
|
|
937
975
|
const v = env("SANDBOX_PKG_SOURCE", "global");
|
|
@@ -1137,6 +1175,9 @@ function parseMemoryDomain(ctx) {
|
|
|
1137
1175
|
...(process.env.MEMORY_PROVENANCE !== undefined
|
|
1138
1176
|
? { memoryProvenance: enumEnv("MEMORY_PROVENANCE", "carry", ["off", "carry"]) }
|
|
1139
1177
|
: {}),
|
|
1178
|
+
...(process.env.MEMORY_CAPTURE_POLICY !== undefined
|
|
1179
|
+
? { memoryCapturePolicy: enumEnv("MEMORY_CAPTURE_POLICY", "open", ["open", "governed", "capture-required"]) }
|
|
1180
|
+
: {}),
|
|
1140
1181
|
memoryConsolidationDriver: parseMemoryConsolidationDriver(),
|
|
1141
1182
|
...(process.env.DELEGATION_MAX_CONCURRENT !== undefined || process.env.DELEGATION_MAX_PER_SESSION !== undefined
|
|
1142
1183
|
? {
|
|
@@ -1277,10 +1318,11 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1277
1318
|
adb: ["ADB_SERIAL"],
|
|
1278
1319
|
host: [],
|
|
1279
1320
|
"local-docker": ["DOCKER_IMAGE"],
|
|
1321
|
+
device: [],
|
|
1280
1322
|
};
|
|
1281
|
-
const required = Object.
|
|
1323
|
+
const required = Object.entries(REQUIRED_ENVS_BY_LANE).find(([word]) => word === lane)?.[1];
|
|
1282
1324
|
if (required === undefined) {
|
|
1283
|
-
throw new Error(`REMOTE_EXEC="${lane}" is not an execution lane — valid:
|
|
1325
|
+
throw new Error(`REMOTE_EXEC="${lane}" is not an execution lane — valid: ${Object.keys(REQUIRED_ENVS_BY_LANE).join(" | ")}. ` +
|
|
1284
1326
|
`An unrecognized value used to fall through to the host/in-process lane silently; refusing to start instead (#157).`);
|
|
1285
1327
|
}
|
|
1286
1328
|
const missing = required.filter((name) => !process.env[name]);
|
|
@@ -1385,9 +1427,19 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1385
1427
|
...(process.env.DOCKER_SANDBOX_ENV ? { env: resolveEnvForward(process.env.DOCKER_SANDBOX_ENV) } : {}),
|
|
1386
1428
|
}
|
|
1387
1429
|
:
|
|
1388
|
-
process.env.
|
|
1389
|
-
? {
|
|
1390
|
-
|
|
1430
|
+
process.env.REMOTE_EXEC === "device"
|
|
1431
|
+
? {
|
|
1432
|
+
provider: "device",
|
|
1433
|
+
...((n) => (n !== undefined ? { deliveryTimeoutMs: n } : {}))(optNumEnvStrict("DEVICE_DELIVERY_TIMEOUT_MS")),
|
|
1434
|
+
...((n) => (n !== undefined ? { reconnectGraceMs: n } : {}))(optNumEnvStrict("DEVICE_RECONNECT_GRACE_MS")),
|
|
1435
|
+
...((n) => (n !== undefined ? { execTimeoutMs: n } : {}))(optNumEnvStrict("DEVICE_EXEC_TIMEOUT_MS")),
|
|
1436
|
+
...((n) => (n !== undefined ? { maxInflightPerDevice: n } : {}))(optNumEnvStrict("DEVICE_MAX_INFLIGHT_PER_DEVICE")),
|
|
1437
|
+
...(process.env.DEVICE_SUPERSEDE ? { supersede: enumEnv("DEVICE_SUPERSEDE", "stale_only", ["stale_only", "deny"]) } : {}),
|
|
1438
|
+
}
|
|
1439
|
+
:
|
|
1440
|
+
process.env.CONFIG_PROVIDER === "local"
|
|
1441
|
+
? { provider: "host" }
|
|
1442
|
+
: undefined,
|
|
1391
1443
|
worktreeIsolation: boolEnv("WORKTREE_ISOLATION_ENABLED", false) && process.env.WORKTREE_REPO_ROOT
|
|
1392
1444
|
? {
|
|
1393
1445
|
repoRoot: process.env.WORKTREE_REPO_ROOT,
|
|
@@ -1651,8 +1703,9 @@ function parseA2aServeSkills(raw) {
|
|
|
1651
1703
|
});
|
|
1652
1704
|
}
|
|
1653
1705
|
const STORE_GROUP_KEYS = [
|
|
1654
|
-
"sessionBackend", "sessionCacheTtlSec", "storeProbeIntervalMs", "
|
|
1655
|
-
"
|
|
1706
|
+
"sessionBackend", "sessionCacheTtlSec", "storeProbeIntervalMs", "dbBackend", "dbBackendExplicit", "localDataRoot",
|
|
1707
|
+
"runStoreStrictHydrate",
|
|
1708
|
+
"tidb", "pg", "dbQueryTimeoutMs", "snapshotBlobStore", "snapshotBlobSqlMaxBytes", "snapshotBlobAllowSql", "fileHistory", "sendUserFile",
|
|
1656
1709
|
];
|
|
1657
1710
|
const MODEL_PLANE_GROUP_KEYS = [
|
|
1658
1711
|
"gatewayBaseUrl", "gatewayApiKey", "gatewayFallbackUrls", "gatewayMaxRetries", "anthropic", "resilience", "model", "models",
|
|
@@ -1666,7 +1719,7 @@ const APPROVAL_GROUP_KEYS = [
|
|
|
1666
1719
|
];
|
|
1667
1720
|
const MEMORY_GROUP_KEYS = [
|
|
1668
1721
|
"memoryEngineEnabled", "memoryEngineDir", "memoryEngineRemoteLaneAllowed", "memoryEngineBackend", "memoryScope",
|
|
1669
|
-
"memoryPersistenceCapable", "memorySync", "memoryEmbedder", "memoryOrgAdmissionMode", "memoryDelegationEvidence", "memoryProvenance", "memoryConsolidationDriver", "memoryOrgDirectoryJson", "memoryOrgGrantTtlMs", "memoryOrgUnavailableBackoffMs",
|
|
1722
|
+
"memoryPersistenceCapable", "memorySync", "memoryEmbedder", "memoryOrgAdmissionMode", "memoryDelegationEvidence", "memoryProvenance", "memoryCapturePolicy", "memoryConsolidationDriver", "memoryOrgDirectoryJson", "memoryOrgGrantTtlMs", "memoryOrgUnavailableBackoffMs",
|
|
1670
1723
|
"projectMemoryEnabled", "syncImportLeaseStaleSec",
|
|
1671
1724
|
];
|
|
1672
1725
|
const AUTH_GROUP_KEYS = [
|
|
@@ -1774,7 +1827,7 @@ export function loadConfig() {
|
|
|
1774
1827
|
if (dgOperators.length === 0)
|
|
1775
1828
|
missing.push("OPERATOR_PRINCIPALS (else isOperator is true-for-all)");
|
|
1776
1829
|
if (!requirePrincipal)
|
|
1777
|
-
missing.push("REQUIRE_PRINCIPAL=true (a direct door is multi-tenant; without it an absent principal is anonymous and the
|
|
1830
|
+
missing.push("REQUIRE_PRINCIPAL=true (a direct door is multi-tenant; without it an absent principal is anonymous and the self-asserted header governs owner/quota checks)");
|
|
1778
1831
|
if (missing.length) {
|
|
1779
1832
|
throw new Error(`DIRECT_APPROVAL_DOOR=true but D-G anchors are missing: ${missing.join(", ")} — refusing to start a half-open direct door (design/80 D-G boot invariant)`);
|
|
1780
1833
|
}
|
|
@@ -1803,6 +1856,25 @@ export function loadConfig() {
|
|
|
1803
1856
|
"push. Point the service at a durable store (DB_BACKEND=mysql|pg), or turn the leader off (unset LEADER_ENABLED) " +
|
|
1804
1857
|
"(#193 车4 件2 / staleness ledger P1-9+P1-10 boot invariant)");
|
|
1805
1858
|
}
|
|
1859
|
+
{
|
|
1860
|
+
const deviceLane = orchestration.remoteExec?.provider === "device";
|
|
1861
|
+
const deviceSqlStore = (store.dbBackend === "mysql" && store.tidb !== undefined) || (store.dbBackend === "pg" && store.pg !== undefined);
|
|
1862
|
+
if (deviceLane && !deviceSqlStore) {
|
|
1863
|
+
throw new Error(`REMOTE_EXEC=device with DB_BACKEND=${store.dbBackend} and no SQL connection coordinates built is refused ` +
|
|
1864
|
+
"[device-durable-store]: the device registry, the session↔device binding and the device audit trail are all " +
|
|
1865
|
+
"SQL-backed — without a real SQL store the binding lives in this process only, so a restart forgets WHICH device " +
|
|
1866
|
+
"a parked run must resume onto and a second replica cannot see it at all. Note DB_BACKEND alone is not enough: " +
|
|
1867
|
+
"SESSION_BACKEND=memory (or auto with no MYSQL_HOST/PG_HOST) builds no coordinates at all. Configure a durable " +
|
|
1868
|
+
"store (DB_BACKEND=mysql|pg + its *_HOST/*_USER/*_DATABASE), or unset REMOTE_EXEC " +
|
|
1869
|
+
"(device-executor-lane-v2 §4.7 boot invariant)");
|
|
1870
|
+
}
|
|
1871
|
+
if (deviceLane && !dgDurable) {
|
|
1872
|
+
throw new Error("REMOTE_EXEC=device without DURABLE_APPROVAL=true is refused [device-durable-approval]: a personal device goes " +
|
|
1873
|
+
"offline (lid closed, network drop) at arbitrary points, so an in-flight approval that outlives the window MUST " +
|
|
1874
|
+
"have a park exit — without the durable approval store there is none and the run is stuck with nowhere to persist. " +
|
|
1875
|
+
"Set DURABLE_APPROVAL=true, or unset REMOTE_EXEC (device-executor-lane-v2 §4.7 boot invariant)");
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1806
1878
|
EXEC_LANE_NOTICE = buildExecLaneNotice(orchestration.remoteExec);
|
|
1807
1879
|
return attachConfigGroups({
|
|
1808
1880
|
...store,
|
|
@@ -57,7 +57,7 @@ export interface DurableApprovalSeat {
|
|
|
57
57
|
*/
|
|
58
58
|
export declare function createDurableQuestionGate(live: LiveQuestionFace | undefined): ToolPolicy;
|
|
59
59
|
/**
|
|
60
|
-
* 沙箱 lane 上**相对形**写目标的守卫补层用 env(codex
|
|
60
|
+
* 沙箱 lane 上**相对形**写目标的守卫补层用 env(codex 交叉复审 round1 finding 1,红先复现)。
|
|
61
61
|
*
|
|
62
62
|
* 缺口:沙箱 lane 的守卫策略拿不到 `rootPath`(沙箱 cwd 不是 server 能猜的,#165 裁定 1),而
|
|
63
63
|
* `DeferredSandboxPathEnv.absolutePath` 对相对形一律报错。core 的 `canonicalizeTarget` 在
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* device lane 准入链(design/device-executor-lane-v2 §4.3.2 提交期准入 + §7-T5 enrollment 五件 +
|
|
3
|
+
* §7-T6 反枚举)—— **纯函数层**:没有 HTTP、没有 WS、没有 core 装配,只有「店 + 判据 → typed 判决」。
|
|
4
|
+
* 端点(`/v1/devices/*`、`GET /v1/device/ws`)由车A-3/车C 接;它们**必须**经由这里的判决,不得自建判据。
|
|
5
|
+
*
|
|
6
|
+
* ── 五件(§7-T5,逐条落在哪儿)────────────────────────────────────────────────────────────────────
|
|
7
|
+
* ① TTL —— `DeviceStore.consumeEnrollToken` 的 CAS 谓词 `expires_at > NOW(3)`(**DB 时钟**,§4.6-F18)。
|
|
8
|
+
* ② 绑定 principal —— 同一条 CAS 谓词里 `target_tenant/target_subject` 逐字节等值;不匹配 = 拒。
|
|
9
|
+
* ③ 消费一次性 CAS —— `consumed_at IS NULL` 谓词 + `rev=rev+1`;**affected=0 时只有同 pubkey 放行**。
|
|
10
|
+
* ④ 重用审计 —— 异 pubkey ⇒ `device_enroll_token_reuse` 审计行(告警级);principal 不符 ⇒
|
|
11
|
+
* `device_identity_mismatch` 审计行。两条都在**拒绝之前**写,失败即整条请求失败(fail-closed:
|
|
12
|
+
* 审计写不进去 ≠ 放行)。
|
|
13
|
+
* ⑤ 限速 —— {@link DeviceEnrollRateLimiter},注入式。缺席 = **不限速**,所以端点车必须传一个
|
|
14
|
+
* (§7-T5 ⑤ 双限速);本层不替它兜底,也不假装限了。
|
|
15
|
+
*
|
|
16
|
+
* ── 反枚举(§4.8 + memory `anti-enum-404-ambiguity`)──────────────────────────────────────────────
|
|
17
|
+
* enrollment 的**全部**拒绝理由(token 不存在 / 过期 / principal 不符 / 被别的 pubkey 用过)在 wire
|
|
18
|
+
* 上是**同一个** `device.enrollment_invalid`;设备读面的「不存在」与「不是你的」是同一个
|
|
19
|
+
* `not_found.device`。细分只进审计。
|
|
20
|
+
*
|
|
21
|
+
* ── 吊销在途语义(§8-R4)──────────────────────────────────────────────────────────────────────────
|
|
22
|
+
* `revoked` 是终态:①绑定的 session 后续提交 = `device.revoked`(admitSession 步 2,**先于** presence
|
|
23
|
+
* 判定);②吊销同事务把连接租约作废并把 `conn_generation` 推进一格 ⇒ 在途连接的每一次带世代谓词的写
|
|
24
|
+
* 都会影响 0 行,设备侧因此立刻失权(§4.6 步 2);③重新 enrollment = 新 deviceId,不复活旧行。
|
|
25
|
+
*/
|
|
26
|
+
import { DEVICE_RATE_LIMIT_CODE, type DeviceErrorCode, type DeviceOwner, type DeviceRow, type DeviceSessionRow, type DeviceStore, type NewDeviceInput } from "./device-store.js";
|
|
27
|
+
/** typed 拒绝 —— 端点层只负责把它翻成 HTTP(status 已在体内,不许调用方另算)。 */
|
|
28
|
+
export interface DeviceRejection {
|
|
29
|
+
code: DeviceErrorCode | typeof DEVICE_RATE_LIMIT_CODE;
|
|
30
|
+
status: number;
|
|
31
|
+
message: string;
|
|
32
|
+
/** 限流专用:`Retry-After` 的秒值(附录 A 的 429 族纪律)。 */
|
|
33
|
+
retryAfterSec?: number;
|
|
34
|
+
}
|
|
35
|
+
export type DeviceOutcome<T> = {
|
|
36
|
+
ok: true;
|
|
37
|
+
value: T;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
reject: DeviceRejection;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* 限速座(§7-T5 ⑤)。**fail-closed**:`check` 抛错 = 拒绝(不是放行)—— 限速器不可用时放行,正是
|
|
44
|
+
* token 撞库最想要的那一刻。
|
|
45
|
+
*/
|
|
46
|
+
export interface DeviceEnrollRateLimiter {
|
|
47
|
+
check(key: string): Promise<{
|
|
48
|
+
allowed: boolean;
|
|
49
|
+
retryAfterSec?: number;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
52
|
+
export interface EnrollDeviceInput {
|
|
53
|
+
/** 明文 token(**只**在这里出现,落库前立刻摘要)。 */
|
|
54
|
+
token: string;
|
|
55
|
+
/** 请求方**验证过**的复合身份(SSO/凭据链产出,恒不取自可自造头)。 */
|
|
56
|
+
requester: DeviceOwner;
|
|
57
|
+
/** 🔴 `deviceId` 与 `owner` **不在**这里:前者恒由 server 铸(调用方选 id = 让请求方挑自己的设备身份,
|
|
58
|
+
* 与 §6「server 铸 dev_<ulid>」相悖),后者恒取自**验证过的** requester —— 两个字段都不接受请求体供给。
|
|
59
|
+
* 测试要确定性 id ⇒ 注入 {@link DeviceEnrollmentDeps.mintId},不是从入参开一个口子。 */
|
|
60
|
+
device: Omit<NewDeviceInput, "deviceId" | "owner">;
|
|
61
|
+
}
|
|
62
|
+
export interface EnrollDeviceValue {
|
|
63
|
+
device: DeviceRow;
|
|
64
|
+
/** `true` = 幂等重放(响应丢失重试),`false` = 本次真注册。wire 上两者同为 200 + 同 deviceId。 */
|
|
65
|
+
replayed: boolean;
|
|
66
|
+
}
|
|
67
|
+
export interface AdmitSessionInput {
|
|
68
|
+
rootSessionId: string;
|
|
69
|
+
requester: DeviceOwner;
|
|
70
|
+
/** 提交体携带的 deviceId(§4.3.2:仅根任务携;缺席 = 沿用绑定)。 */
|
|
71
|
+
requestedDeviceId?: string;
|
|
72
|
+
}
|
|
73
|
+
export interface AdmitSessionValue {
|
|
74
|
+
deviceId: string;
|
|
75
|
+
device: DeviceRow;
|
|
76
|
+
/**
|
|
77
|
+
* 判决**当刻**的连接世代(§4.6 fencing token)。
|
|
78
|
+
*
|
|
79
|
+
* 🔴 **准入判决是一个快照,不是一把锁**(codex 交叉轮 F4,判真但归属下一车):`admitSession` 返回后、
|
|
80
|
+
* 调用方真正下发指令前,一次 `revokeDevice` 可以提交 —— 它会作废租约并把 `conn_generation` 推进一格,
|
|
81
|
+
* 但它**追不回**已经基于旧判决发出去的指令。这不是本层能关掉的窗(§6 注③ 刻意不落指令表,所以持久层
|
|
82
|
+
* 没有「派发」这个写点可以带谓词);设计给的答案是**在下发边界重新断言世代**(§4.6 步 2/3:每一次带
|
|
83
|
+
* `conn_generation = ?` 谓词的写影响 0 行 = 本连接已失权 ⇒ 停发并断连)。
|
|
84
|
+
* 本字段就是那个断言要用的值 —— 车A-3 的 WS 汇聚端**必须**拿它去 fence,拿不到就不要下发。
|
|
85
|
+
* 残余诚实:窗口上界 = 心跳周期(吊销后设备侧最迟一个心跳失败即失权),§8-R4 对已发指令的账面处置是
|
|
86
|
+
* 「在途结果帧丢弃 + 审计,对应调用按 outcome-unknown 收尾」。
|
|
87
|
+
*/
|
|
88
|
+
connGeneration: number;
|
|
89
|
+
}
|
|
90
|
+
export interface DeviceEnrollment {
|
|
91
|
+
enroll(input: EnrollDeviceInput): Promise<DeviceOutcome<EnrollDeviceValue>>;
|
|
92
|
+
bindPlacement(input: {
|
|
93
|
+
rootSessionId: string;
|
|
94
|
+
deviceId: string;
|
|
95
|
+
requester: DeviceOwner;
|
|
96
|
+
}): Promise<DeviceOutcome<DeviceSessionRow>>;
|
|
97
|
+
admitSession(input: AdmitSessionInput): Promise<DeviceOutcome<AdmitSessionValue>>;
|
|
98
|
+
revoke(input: {
|
|
99
|
+
deviceId: string;
|
|
100
|
+
requester: DeviceOwner;
|
|
101
|
+
revokedBy: string;
|
|
102
|
+
reason?: string | null;
|
|
103
|
+
}): Promise<DeviceOutcome<DeviceRow>>;
|
|
104
|
+
}
|
|
105
|
+
export interface DeviceEnrollmentDeps {
|
|
106
|
+
store: DeviceStore;
|
|
107
|
+
/** 缺席 = 不限速(端点车必须传;见文件头注 ⑤)。 */
|
|
108
|
+
rateLimiter?: DeviceEnrollRateLimiter;
|
|
109
|
+
/** 测试可注入确定性铸造;默认 `mintDeviceId()`。 */
|
|
110
|
+
mintId?: () => string;
|
|
111
|
+
}
|
|
112
|
+
export declare function createDeviceEnrollment(deps: DeviceEnrollmentDeps): DeviceEnrollment;
|
|
113
|
+
/**
|
|
114
|
+
* 进程内固定窗限速(§7-T5 ⑤ 的**默认**实现)。
|
|
115
|
+
*
|
|
116
|
+
* 🔴 诚实边界:这是**每副本**的窗。跨副本的真限速属主是既有的 `observability/rate-limit.ts` RateGate
|
|
117
|
+
* (SQL 后端),端点车把它适配成 {@link DeviceEnrollRateLimiter} 传进来即可 —— 本函数不假装自己是
|
|
118
|
+
* 那个东西,也不在多副本部署下声称有全局上界。
|
|
119
|
+
*/
|
|
120
|
+
export declare function createFixedWindowRateLimiter(opts: {
|
|
121
|
+
limit: number;
|
|
122
|
+
windowMs: number;
|
|
123
|
+
now?: () => number;
|
|
124
|
+
}): DeviceEnrollRateLimiter;
|
|
125
|
+
//# sourceMappingURL=device-enrollment.d.ts.map
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { DEVICE_ERROR_CODES, DEVICE_ERROR_STATUS, DEVICE_RATE_LIMIT_CODE, assertDeviceIdShape, assertDeviceOwnerShape, assertDevicePubkeyShape, assertEnrollTokenHashShape, assertRootSessionIdShape, hashEnrollToken, mintDeviceId, } from "./device-store.js";
|
|
2
|
+
function reject(code, message, extra) {
|
|
3
|
+
return { ok: false, reject: { code, status: DEVICE_ERROR_STATUS[code], message, ...(extra?.retryAfterSec === undefined ? {} : { retryAfterSec: extra.retryAfterSec }) } };
|
|
4
|
+
}
|
|
5
|
+
function rejectEnrollment() {
|
|
6
|
+
return reject(DEVICE_ERROR_CODES.ENROLLMENT_INVALID, "enrollment token is not valid for this request");
|
|
7
|
+
}
|
|
8
|
+
function rejectNotFound() {
|
|
9
|
+
return reject(DEVICE_ERROR_CODES.NOT_FOUND, "device not found");
|
|
10
|
+
}
|
|
11
|
+
export function createDeviceEnrollment(deps) {
|
|
12
|
+
const { store } = deps;
|
|
13
|
+
const mintId = deps.mintId ?? mintDeviceId;
|
|
14
|
+
async function rateGate(key) {
|
|
15
|
+
if (!deps.rateLimiter)
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
const verdict = await deps.rateLimiter.check(key);
|
|
19
|
+
if (verdict.allowed)
|
|
20
|
+
return null;
|
|
21
|
+
return reject(DEVICE_RATE_LIMIT_CODE, "too many device enrollment attempts", { retryAfterSec: verdict.retryAfterSec ?? 1 });
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return reject(DEVICE_RATE_LIMIT_CODE, `device enrollment rate check unavailable (fail-closed): ${err instanceof Error ? err.message : String(err)}`, { retryAfterSec: 1 });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function enroll(input) {
|
|
28
|
+
assertDeviceOwnerShape(input.requester);
|
|
29
|
+
assertDevicePubkeyShape(input.device.pubkey);
|
|
30
|
+
const limited = await rateGate(`enroll:${input.requester.tenant}\u0000${input.requester.subject}`);
|
|
31
|
+
if (limited)
|
|
32
|
+
return limited;
|
|
33
|
+
const tokenHash = hashEnrollToken(input.token);
|
|
34
|
+
assertEnrollTokenHashShape(tokenHash);
|
|
35
|
+
const deviceId = mintId();
|
|
36
|
+
assertDeviceIdShape(deviceId);
|
|
37
|
+
const result = await store.consumeEnrollToken({
|
|
38
|
+
tokenHash,
|
|
39
|
+
requester: input.requester,
|
|
40
|
+
device: { ...input.device, deviceId, owner: input.requester },
|
|
41
|
+
});
|
|
42
|
+
switch (result.outcome) {
|
|
43
|
+
case "enrolled":
|
|
44
|
+
return { ok: true, value: { device: result.device, replayed: false } };
|
|
45
|
+
case "replayed":
|
|
46
|
+
if (!result.device)
|
|
47
|
+
return rejectEnrollment();
|
|
48
|
+
if (result.device.status === "revoked")
|
|
49
|
+
return reject(DEVICE_ERROR_CODES.REVOKED, "device has been revoked");
|
|
50
|
+
return { ok: true, value: { device: result.device, replayed: true } };
|
|
51
|
+
case "token_reuse": {
|
|
52
|
+
const row = await store.getEnrollToken(tokenHash);
|
|
53
|
+
await store.appendAudit({
|
|
54
|
+
event: "device_enroll_token_reuse",
|
|
55
|
+
deviceId: row?.consumedDeviceId ?? null,
|
|
56
|
+
owner: input.requester,
|
|
57
|
+
detail: { consumedAtMs: row?.consumedAtMs ?? null, presentedPubkeyDiffers: true },
|
|
58
|
+
});
|
|
59
|
+
return rejectEnrollment();
|
|
60
|
+
}
|
|
61
|
+
case "principal_mismatch":
|
|
62
|
+
await store.appendAudit({ event: "device_identity_mismatch", deviceId: null, owner: input.requester, detail: { at: "enrollment", reason: "token target principal mismatch" } });
|
|
63
|
+
return rejectEnrollment();
|
|
64
|
+
case "expired":
|
|
65
|
+
case "unknown_token":
|
|
66
|
+
return rejectEnrollment();
|
|
67
|
+
case "pubkey_taken":
|
|
68
|
+
await store.appendAudit({ event: "device_identity_mismatch", deviceId: null, owner: input.requester, detail: { at: "enrollment", reason: "pubkey already bound to another device in this tenant" } });
|
|
69
|
+
return rejectEnrollment();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function bindPlacement(input) {
|
|
73
|
+
assertRootSessionIdShape(input.rootSessionId);
|
|
74
|
+
assertDeviceIdShape(input.deviceId);
|
|
75
|
+
assertDeviceOwnerShape(input.requester);
|
|
76
|
+
const result = await store.bindSession({ rootSessionId: input.rootSessionId, deviceId: input.deviceId, owner: input.requester });
|
|
77
|
+
switch (result.outcome) {
|
|
78
|
+
case "bound":
|
|
79
|
+
case "idempotent":
|
|
80
|
+
return { ok: true, value: result.row };
|
|
81
|
+
case "conflict":
|
|
82
|
+
return reject(DEVICE_ERROR_CODES.CLAIM_CONFLICT, "this session is already bound to a different device");
|
|
83
|
+
case "device_not_found":
|
|
84
|
+
return rejectNotFound();
|
|
85
|
+
case "device_revoked":
|
|
86
|
+
return reject(DEVICE_ERROR_CODES.REVOKED, "device has been revoked");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function admitSession(input) {
|
|
90
|
+
assertRootSessionIdShape(input.rootSessionId);
|
|
91
|
+
assertDeviceOwnerShape(input.requester);
|
|
92
|
+
if (input.requestedDeviceId !== undefined)
|
|
93
|
+
assertDeviceIdShape(input.requestedDeviceId);
|
|
94
|
+
const binding = await store.getSessionBinding(input.rootSessionId);
|
|
95
|
+
if (!binding)
|
|
96
|
+
return reject(DEVICE_ERROR_CODES.NOT_BOUND, "this session has no device binding");
|
|
97
|
+
if (binding.owner.tenant !== input.requester.tenant || binding.owner.subject !== input.requester.subject)
|
|
98
|
+
return rejectNotFound();
|
|
99
|
+
if (input.requestedDeviceId !== undefined && input.requestedDeviceId !== binding.deviceId) {
|
|
100
|
+
return reject(DEVICE_ERROR_CODES.IDENTITY_MISMATCH, "the requested device is not the one bound to this session");
|
|
101
|
+
}
|
|
102
|
+
const view = await store.readDeviceForAdmission(binding.deviceId, input.requester);
|
|
103
|
+
if (!view)
|
|
104
|
+
return rejectNotFound();
|
|
105
|
+
if (view.row.status === "revoked")
|
|
106
|
+
return reject(DEVICE_ERROR_CODES.REVOKED, "device has been revoked");
|
|
107
|
+
if (!view.online)
|
|
108
|
+
return reject(DEVICE_ERROR_CODES.OFFLINE, "device is not connected");
|
|
109
|
+
return { ok: true, value: { deviceId: binding.deviceId, device: view.row, connGeneration: view.row.connGeneration } };
|
|
110
|
+
}
|
|
111
|
+
async function revoke(input) {
|
|
112
|
+
assertDeviceIdShape(input.deviceId);
|
|
113
|
+
assertDeviceOwnerShape(input.requester);
|
|
114
|
+
const result = await store.revokeDevice({ deviceId: input.deviceId, owner: input.requester, revokedBy: input.revokedBy, reason: input.reason ?? null });
|
|
115
|
+
switch (result.outcome) {
|
|
116
|
+
case "revoked":
|
|
117
|
+
case "already_revoked":
|
|
118
|
+
return { ok: true, value: result.row };
|
|
119
|
+
case "not_found":
|
|
120
|
+
return rejectNotFound();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { enroll, bindPlacement, admitSession, revoke };
|
|
124
|
+
}
|
|
125
|
+
export function createFixedWindowRateLimiter(opts) {
|
|
126
|
+
const now = opts.now ?? (() => Date.now());
|
|
127
|
+
const windows = new Map();
|
|
128
|
+
const MAX_WINDOWS = 10_000;
|
|
129
|
+
const prune = (t) => {
|
|
130
|
+
for (const [k, w] of windows)
|
|
131
|
+
if (t - w.startedAtMs >= opts.windowMs)
|
|
132
|
+
windows.delete(k);
|
|
133
|
+
if (windows.size <= MAX_WINDOWS)
|
|
134
|
+
return;
|
|
135
|
+
const victims = [...windows.entries()].sort((a, b) => a[1].startedAtMs - b[1].startedAtMs).slice(0, Math.floor(windows.size / 2));
|
|
136
|
+
for (const [k] of victims)
|
|
137
|
+
windows.delete(k);
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
check: async (key) => {
|
|
141
|
+
const t = now();
|
|
142
|
+
if (windows.size >= MAX_WINDOWS)
|
|
143
|
+
prune(t);
|
|
144
|
+
const cur = windows.get(key);
|
|
145
|
+
if (!cur || t - cur.startedAtMs >= opts.windowMs) {
|
|
146
|
+
windows.set(key, { startedAtMs: t, count: 1 });
|
|
147
|
+
return { allowed: true };
|
|
148
|
+
}
|
|
149
|
+
cur.count += 1;
|
|
150
|
+
if (cur.count > opts.limit)
|
|
151
|
+
return { allowed: false, retryAfterSec: Math.max(1, Math.ceil((cur.startedAtMs + opts.windowMs - t) / 1000)) };
|
|
152
|
+
return { allowed: true };
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=device-enrollment.js.map
|