orbit-agent-runtime 0.8.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/CHANGELOG.md +750 -0
- package/LICENSE +201 -0
- package/README.md +492 -0
- package/README.zh-CN.md +389 -0
- package/benchmarks/README.md +24 -0
- package/benchmarks/gateway.mjs +68 -0
- package/benchmarks/pae.mjs +90 -0
- package/benchmarks/replay.mjs +51 -0
- package/benchmarks/run-all.mjs +24 -0
- package/benchmarks/wal.mjs +50 -0
- package/bin/orbit.mjs +523 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/demo-deepseek.d.ts +2 -0
- package/dist/demo-deepseek.d.ts.map +1 -0
- package/dist/demo-deepseek.js +66 -0
- package/dist/demo-deepseek.js.map +1 -0
- package/dist/demo-host.d.ts +2 -0
- package/dist/demo-host.d.ts.map +1 -0
- package/dist/demo-host.js +98 -0
- package/dist/demo-host.js.map +1 -0
- package/dist/demo-replay.d.ts +2 -0
- package/dist/demo-replay.d.ts.map +1 -0
- package/dist/demo-replay.js +68 -0
- package/dist/demo-replay.js.map +1 -0
- package/dist/src/core/orbitRuntimeHost.d.ts +349 -0
- package/dist/src/core/orbitRuntimeHost.d.ts.map +1 -0
- package/dist/src/core/orbitRuntimeHost.js +706 -0
- package/dist/src/core/orbitRuntimeHost.js.map +1 -0
- package/dist/src/index.d.ts +92 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +226 -0
- package/dist/src/index.js.map +1 -0
- package/dist/test/audit_chain.test.d.ts +2 -0
- package/dist/test/audit_chain.test.d.ts.map +1 -0
- package/dist/test/audit_chain.test.js +283 -0
- package/dist/test/audit_chain.test.js.map +1 -0
- package/dist/test/channel_hub.test.d.ts +2 -0
- package/dist/test/channel_hub.test.d.ts.map +1 -0
- package/dist/test/channel_hub.test.js +66 -0
- package/dist/test/channel_hub.test.js.map +1 -0
- package/dist/test/cordis_adapter.test.d.ts +2 -0
- package/dist/test/cordis_adapter.test.d.ts.map +1 -0
- package/dist/test/cordis_adapter.test.js +280 -0
- package/dist/test/cordis_adapter.test.js.map +1 -0
- package/dist/test/cost_routing.test.d.ts +2 -0
- package/dist/test/cost_routing.test.d.ts.map +1 -0
- package/dist/test/cost_routing.test.js +31 -0
- package/dist/test/cost_routing.test.js.map +1 -0
- package/dist/test/domain_transaction.test.d.ts +2 -0
- package/dist/test/domain_transaction.test.d.ts.map +1 -0
- package/dist/test/domain_transaction.test.js +351 -0
- package/dist/test/domain_transaction.test.js.map +1 -0
- package/dist/test/file_channel.test.d.ts +2 -0
- package/dist/test/file_channel.test.d.ts.map +1 -0
- package/dist/test/file_channel.test.js +179 -0
- package/dist/test/file_channel.test.js.map +1 -0
- package/dist/test/gateway.test.d.ts +2 -0
- package/dist/test/gateway.test.d.ts.map +1 -0
- package/dist/test/gateway.test.js +548 -0
- package/dist/test/gateway.test.js.map +1 -0
- package/dist/test/governance_profile.test.d.ts +2 -0
- package/dist/test/governance_profile.test.d.ts.map +1 -0
- package/dist/test/governance_profile.test.js +214 -0
- package/dist/test/governance_profile.test.js.map +1 -0
- package/dist/test/governance_schema.test.d.ts +2 -0
- package/dist/test/governance_schema.test.d.ts.map +1 -0
- package/dist/test/governance_schema.test.js +200 -0
- package/dist/test/governance_schema.test.js.map +1 -0
- package/dist/test/host_integration.test.d.ts +2 -0
- package/dist/test/host_integration.test.d.ts.map +1 -0
- package/dist/test/host_integration.test.js +104 -0
- package/dist/test/host_integration.test.js.map +1 -0
- package/dist/test/host_journal_persistence.test.d.ts +2 -0
- package/dist/test/host_journal_persistence.test.d.ts.map +1 -0
- package/dist/test/host_journal_persistence.test.js +367 -0
- package/dist/test/host_journal_persistence.test.js.map +1 -0
- package/dist/test/impact_domain.test.d.ts +2 -0
- package/dist/test/impact_domain.test.d.ts.map +1 -0
- package/dist/test/impact_domain.test.js +48 -0
- package/dist/test/impact_domain.test.js.map +1 -0
- package/dist/test/injected_clock.test.d.ts +13 -0
- package/dist/test/injected_clock.test.d.ts.map +1 -0
- package/dist/test/injected_clock.test.js +200 -0
- package/dist/test/injected_clock.test.js.map +1 -0
- package/dist/test/isolation_domain.test.d.ts +2 -0
- package/dist/test/isolation_domain.test.d.ts.map +1 -0
- package/dist/test/isolation_domain.test.js +302 -0
- package/dist/test/isolation_domain.test.js.map +1 -0
- package/dist/test/journal_wal.test.d.ts +2 -0
- package/dist/test/journal_wal.test.d.ts.map +1 -0
- package/dist/test/journal_wal.test.js +622 -0
- package/dist/test/journal_wal.test.js.map +1 -0
- package/dist/test/mcp_adapter.test.d.ts +2 -0
- package/dist/test/mcp_adapter.test.d.ts.map +1 -0
- package/dist/test/mcp_adapter.test.js +440 -0
- package/dist/test/mcp_adapter.test.js.map +1 -0
- package/dist/test/openai_compat_channel.test.d.ts +2 -0
- package/dist/test/openai_compat_channel.test.d.ts.map +1 -0
- package/dist/test/openai_compat_channel.test.js +325 -0
- package/dist/test/openai_compat_channel.test.js.map +1 -0
- package/dist/test/openapi_adapter.test.d.ts +2 -0
- package/dist/test/openapi_adapter.test.d.ts.map +1 -0
- package/dist/test/openapi_adapter.test.js +270 -0
- package/dist/test/openapi_adapter.test.js.map +1 -0
- package/dist/test/orbit_cli.test.d.ts +2 -0
- package/dist/test/orbit_cli.test.d.ts.map +1 -0
- package/dist/test/orbit_cli.test.js +137 -0
- package/dist/test/orbit_cli.test.js.map +1 -0
- package/dist/test/pact_verifier.test.d.ts +2 -0
- package/dist/test/pact_verifier.test.d.ts.map +1 -0
- package/dist/test/pact_verifier.test.js +41 -0
- package/dist/test/pact_verifier.test.js.map +1 -0
- package/dist/test/pae_adapter.test.d.ts +2 -0
- package/dist/test/pae_adapter.test.d.ts.map +1 -0
- package/dist/test/pae_adapter.test.js +469 -0
- package/dist/test/pae_adapter.test.js.map +1 -0
- package/dist/test/plugin_sandbox_guard.test.d.ts +2 -0
- package/dist/test/plugin_sandbox_guard.test.d.ts.map +1 -0
- package/dist/test/plugin_sandbox_guard.test.js +57 -0
- package/dist/test/plugin_sandbox_guard.test.js.map +1 -0
- package/dist/test/rate_limit_behavior.test.d.ts +2 -0
- package/dist/test/rate_limit_behavior.test.d.ts.map +1 -0
- package/dist/test/rate_limit_behavior.test.js +85 -0
- package/dist/test/rate_limit_behavior.test.js.map +1 -0
- package/dist/test/replay_compat.test.d.ts +2 -0
- package/dist/test/replay_compat.test.d.ts.map +1 -0
- package/dist/test/replay_compat.test.js +1122 -0
- package/dist/test/replay_compat.test.js.map +1 -0
- package/dist/test/replay_engine.test.d.ts +2 -0
- package/dist/test/replay_engine.test.d.ts.map +1 -0
- package/dist/test/replay_engine.test.js +85 -0
- package/dist/test/replay_engine.test.js.map +1 -0
- package/dist/test/shell_channel.test.d.ts +2 -0
- package/dist/test/shell_channel.test.d.ts.map +1 -0
- package/dist/test/shell_channel.test.js +173 -0
- package/dist/test/shell_channel.test.js.map +1 -0
- package/dist/test/teardown_isolation.test.d.ts +10 -0
- package/dist/test/teardown_isolation.test.d.ts.map +1 -0
- package/dist/test/teardown_isolation.test.js +279 -0
- package/dist/test/teardown_isolation.test.js.map +1 -0
- package/dist/test/token_budget.test.d.ts +2 -0
- package/dist/test/token_budget.test.d.ts.map +1 -0
- package/dist/test/token_budget.test.js +113 -0
- package/dist/test/token_budget.test.js.map +1 -0
- package/dist/test/trace_journal.test.d.ts +2 -0
- package/dist/test/trace_journal.test.d.ts.map +1 -0
- package/dist/test/trace_journal.test.js +43 -0
- package/dist/test/trace_journal.test.js.map +1 -0
- package/dist/test/trace_journal_persistence.test.d.ts +2 -0
- package/dist/test/trace_journal_persistence.test.d.ts.map +1 -0
- package/dist/test/trace_journal_persistence.test.js +175 -0
- package/dist/test/trace_journal_persistence.test.js.map +1 -0
- package/dist/test/trace_persistence.test.d.ts +2 -0
- package/dist/test/trace_persistence.test.d.ts.map +1 -0
- package/dist/test/trace_persistence.test.js +216 -0
- package/dist/test/trace_persistence.test.js.map +1 -0
- package/dist/test/trip_protector.test.d.ts +2 -0
- package/dist/test/trip_protector.test.d.ts.map +1 -0
- package/dist/test/trip_protector.test.js +42 -0
- package/dist/test/trip_protector.test.js.map +1 -0
- package/examples/README.md +34 -0
- package/examples/cli-record-replay.mjs +74 -0
- package/examples/custom-channel.mjs +102 -0
- package/examples/js-pae-plugin.mjs +128 -0
- package/examples/mcp-adapter.mjs +102 -0
- package/package.json +60 -0
- package/src/core/orbitRuntimeHost.ts +853 -0
- package/src/index.ts +256 -0
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
import { ChannelHub } from "@orbit/core-hub";
|
|
2
|
+
import { MemoryKvChannel } from "@orbit/core-hub";
|
|
3
|
+
import { LlmMockChannel } from "@orbit/core-hub";
|
|
4
|
+
import { TraceJournal, PersistedTraceJournal } from "@orbit/core-hub";
|
|
5
|
+
import { PluginSandboxGuard } from "@orbit/core-hub";
|
|
6
|
+
import { PluginPactVerifier } from "@orbit/core-hub";
|
|
7
|
+
import { SandboxPool } from "@orbit/sandbox-runtime";
|
|
8
|
+
import { ImpactDomainGraph } from "@orbit/sandbox-runtime";
|
|
9
|
+
import { CostRouter } from "@orbit/core-hub";
|
|
10
|
+
import { RecordJournal, PersistedRecordJournal } from "@orbit/core-hub";
|
|
11
|
+
import { ReplayEngine } from "@orbit/core-hub";
|
|
12
|
+
import { makeUniqueMark } from "@orbit/infra-common";
|
|
13
|
+
import { KERNEL_VERSION } from "@orbit/infra-common";
|
|
14
|
+
import { CapabilityGateway } from "@orbit/core-hub";
|
|
15
|
+
import { TokenBudgetEngine, DEFAULT_TOKEN_BUDGET_CONFIG } from "@orbit/core-hub";
|
|
16
|
+
import { RateLimiter, DEFAULT_RATE_LIMIT_CONFIG } from "@orbit/core-hub";
|
|
17
|
+
import { BehaviorCollector } from "@orbit/core-hub";
|
|
18
|
+
import { PaeAdapterRegistry } from "@orbit/pae-engine";
|
|
19
|
+
import { PaeChannel } from "@orbit/pae-engine";
|
|
20
|
+
import type { IPaeAdapter, PaeFidelity, PaeToolDescriptor } from "@orbit/pae-engine";
|
|
21
|
+
import type { GatewayInvokeParams } from "@orbit/core-hub";
|
|
22
|
+
import type { GatewayCheckers } from "@orbit/core-hub";
|
|
23
|
+
import { tripThresholdForProfile, tokenBudgetConfigForProfile } from "@orbit/core-hub";
|
|
24
|
+
import { verifyAuditChain } from "@orbit/core-hub";
|
|
25
|
+
import { validateArgsAgainstSchema } from "@orbit/infra-common";
|
|
26
|
+
import type { AuditChainReport } from "@orbit/core-hub";
|
|
27
|
+
import type { AgentSandbox } from "@orbit/sandbox-runtime";
|
|
28
|
+
import {
|
|
29
|
+
ChannelKind, ChannelCallCtx, PluginUnitPact, AgentBoxConfig, CapabilityKey, ReplayMode
|
|
30
|
+
} from "@orbit/infra-common";
|
|
31
|
+
import {
|
|
32
|
+
resolveGovernanceProfile,
|
|
33
|
+
governanceProfileHash,
|
|
34
|
+
type GovernanceProfile,
|
|
35
|
+
type GovernanceProfileName
|
|
36
|
+
} from "@orbit/infra-common";
|
|
37
|
+
import type { RunVersionFingerprint } from "@orbit/infra-common";
|
|
38
|
+
import type { ClockSource } from "@orbit/infra-common";
|
|
39
|
+
// W20: the physical layer becomes host state.
|
|
40
|
+
import { IsolationDomainManager } from "@orbit/sandbox-runtime";
|
|
41
|
+
import { DomainChannel } from "@orbit/sandbox-runtime";
|
|
42
|
+
import type { DomainTransportFactory } from "@orbit/sandbox-runtime";
|
|
43
|
+
import type { IsolationDomainPlan } from "@orbit/sandbox-runtime";
|
|
44
|
+
import type { DomainInvokeCtx } from "@orbit/sandbox-runtime";
|
|
45
|
+
import type { DomainReconciliation, DomainTransaction } from "@orbit/sandbox-runtime";
|
|
46
|
+
|
|
47
|
+
const HOST_DEFAULT_TIMEOUT_MS = 10_000;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Durability options. When a path is supplied the corresponding journal is
|
|
51
|
+
* mirrored to an append-only write-ahead log on disk and recovered on the next
|
|
52
|
+
* boot, so a restart does not lose the audit trail or the recorded run. Omitting
|
|
53
|
+
* a path (the default) keeps the journal purely in-memory — the original
|
|
54
|
+
* behavior, and what every existing test relies on.
|
|
55
|
+
*/
|
|
56
|
+
/** W31: PAE isolation rank for the trust-assumption cap (L0 < L1 < L2). */
|
|
57
|
+
function rankIsolation(level: string): number {
|
|
58
|
+
return level === "L2" ? 2 : level === "L1" ? 1 : 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface OrbitRuntimeHostOptions {
|
|
62
|
+
/** Durable audit/behavior journal (trace) WAL path. */
|
|
63
|
+
traceJournalPath?: string;
|
|
64
|
+
/** Durable recording-window journal WAL path (recovered on boot). */
|
|
65
|
+
recordJournalPath?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Wall-clock source for every time-dependent decision that reaches a recorded
|
|
68
|
+
* value (trip cooldown, channel TTLs). Injecting a frozen clock makes a
|
|
69
|
+
* recording window reproducible; omitting it keeps the real clock, which is
|
|
70
|
+
* the previous behaviour.
|
|
71
|
+
*/
|
|
72
|
+
clock?: ClockSource;
|
|
73
|
+
/**
|
|
74
|
+
* Retention bound for the durable audit journal: keep at most this many
|
|
75
|
+
* newest entries. An append-only audit log that grows without limit
|
|
76
|
+
* eventually fills the disk, and a full disk is an outage — so the bound is
|
|
77
|
+
* explicit and operator-chosen rather than an implicit default. Applied at
|
|
78
|
+
* boot (after recovery) and at shutdown. Omit for unbounded retention.
|
|
79
|
+
*/
|
|
80
|
+
auditRetention?: number;
|
|
81
|
+
/**
|
|
82
|
+
* W29: governance tier (VISION §3.1). `standard` is the default and resolves
|
|
83
|
+
* to the kernel's pre-W29 numbers verbatim. `sandbox` disables token
|
|
84
|
+
* compression, widens rate limits, admits every PAE adapter kind and keeps
|
|
85
|
+
* the trace in memory. `strict` narrows rate limits, trips earlier, compresses
|
|
86
|
+
* aggressively, admits NO foreign adapters and REQUIRES a durable trace path
|
|
87
|
+
* (construction fails without one). The profile name is hashed into the run
|
|
88
|
+
* fingerprint, so a trace recorded under one tier refuses to replay under
|
|
89
|
+
* another (`RunFingerprintDriftError`).
|
|
90
|
+
*/
|
|
91
|
+
governanceProfile?: GovernanceProfileName;
|
|
92
|
+
/**
|
|
93
|
+
* W30: HMAC-SHA256 signing key for the audit hash chain (VISION §3.1's
|
|
94
|
+
* "落盘 + 签名"). When provided, every audit entry is linked with
|
|
95
|
+
* `prevHash`/`chainHash` and `host.verifyAuditChain()` can prove the trail
|
|
96
|
+
* has not been tampered with. Without a key the journal records no chain
|
|
97
|
+
* fields at all (pre-W30 behaviour). The `strict` governance tier REQUIRES
|
|
98
|
+
* a signing key — a compliance tier must be able to prove its audit trail.
|
|
99
|
+
*/
|
|
100
|
+
auditSigningKey?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Top-level assembly: wires every component bottom-up and owns the host
|
|
105
|
+
* lifecycle. Components stay public for advanced scenarios (custom channels,
|
|
106
|
+
* audit), but day-to-day use should go through the facade methods below.
|
|
107
|
+
*/
|
|
108
|
+
export class OrbitRuntimeHost {
|
|
109
|
+
public readonly channelHub: ChannelHub;
|
|
110
|
+
public readonly traceJournal: TraceJournal;
|
|
111
|
+
public readonly pluginSandboxGuard: PluginSandboxGuard;
|
|
112
|
+
public readonly pluginPactVerifier: PluginPactVerifier;
|
|
113
|
+
public readonly sandboxPool: SandboxPool;
|
|
114
|
+
/** M3: dependency graph feeding isolation decisions. */
|
|
115
|
+
public readonly impactGraph: ImpactDomainGraph;
|
|
116
|
+
/** M4: cost profiles and budget routing. */
|
|
117
|
+
public readonly costRouter: CostRouter;
|
|
118
|
+
/** W8: pure-function token budget + context compressor (single source of truth). */
|
|
119
|
+
public readonly tokenBudget: TokenBudgetEngine;
|
|
120
|
+
/** W11: pure-function rate limiter (call-count budget, replay-safe). */
|
|
121
|
+
public readonly rateLimiter: RateLimiter;
|
|
122
|
+
/** W11: behavior collector (record / live-proposal / replay-bypass). */
|
|
123
|
+
public readonly behaviorCollector: BehaviorCollector;
|
|
124
|
+
/** W7: unified gateway entry — the determinism boundary (capabilityInvoke). */
|
|
125
|
+
public readonly gateway: CapabilityGateway;
|
|
126
|
+
/** W15: registry of foreign-runtime adapters (the adaptation surface). */
|
|
127
|
+
public readonly paeRegistry: PaeAdapterRegistry;
|
|
128
|
+
/** W15: the channel that publishes the adaptation surface to the hub. */
|
|
129
|
+
public readonly paeChannel: PaeChannel;
|
|
130
|
+
/** W8: kinds served by a PAE adapter; routing flips to "pae" when non-empty. */
|
|
131
|
+
private readonly paeAdapterKinds = new Set<ChannelKind>();
|
|
132
|
+
/**
|
|
133
|
+
* W20: the physical layer — isolation domains allocated from the impact
|
|
134
|
+
* graph. Created lazily: a host that never allocates domains keeps its
|
|
135
|
+
* previous hub surface and fingerprint byte for byte.
|
|
136
|
+
*/
|
|
137
|
+
private domainManager: IsolationDomainManager | null = null;
|
|
138
|
+
/** W20: the gateway surface over the domain manager's units. */
|
|
139
|
+
private domainChannel: DomainChannel | null = null;
|
|
140
|
+
/** W20: the graph changed since the last allocation (plan needs a re-sync). */
|
|
141
|
+
private domainsStaleFlag = false;
|
|
142
|
+
/** W27: durable recording-window journal WAL path, if configured. */
|
|
143
|
+
private readonly recordJournalPath?: string;
|
|
144
|
+
/** Injected clock, threaded to every time-dependent decision that is recorded. */
|
|
145
|
+
private readonly clock?: ClockSource;
|
|
146
|
+
/** W27: the recording window currently attached to the gateway (for flush). */
|
|
147
|
+
private activeRecordJournal: RecordJournal | null = null;
|
|
148
|
+
/**
|
|
149
|
+
* W27: the same instance as {@link traceJournal}, held at its durable type.
|
|
150
|
+
* `traceJournal` stays declared as the base class on purpose — call sites must
|
|
151
|
+
* not depend on durability — so retention/compaction needs its own handle.
|
|
152
|
+
*/
|
|
153
|
+
private readonly durableTraceJournal: PersistedTraceJournal;
|
|
154
|
+
/** W27: audit retention bound; undefined means unbounded. */
|
|
155
|
+
private readonly auditRetention?: number;
|
|
156
|
+
/** W29: resolved governance tier; drives limiter / trip / compression / PAE / durability. */
|
|
157
|
+
private readonly governanceProfile: GovernanceProfile;
|
|
158
|
+
/** W30: HMAC key for the audit hash chain; undefined = unsigned journal. */
|
|
159
|
+
private readonly auditSigningKey?: string;
|
|
160
|
+
|
|
161
|
+
public constructor(opts?: OrbitRuntimeHostOptions) {
|
|
162
|
+
this.recordJournalPath = opts?.recordJournalPath;
|
|
163
|
+
this.clock = opts?.clock;
|
|
164
|
+
this.governanceProfile = resolveGovernanceProfile(opts?.governanceProfile);
|
|
165
|
+
// A compliance tier with an ephemeral audit trail is a contradiction in
|
|
166
|
+
// terms: fail at construction, not at the first reboot.
|
|
167
|
+
if (
|
|
168
|
+
this.governanceProfile.traceDurability === "required" &&
|
|
169
|
+
!opts?.traceJournalPath
|
|
170
|
+
) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
"governance profile 'strict' requires traceJournalPath — a compliance tier must have a durable audit trail"
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
// W30: strict also requires the ability to PROVE the trail. The signing
|
|
176
|
+
// key is what turns an append-only file into a tamper-evident one.
|
|
177
|
+
if (this.governanceProfile.name === "strict" && !opts?.auditSigningKey) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
"governance profile 'strict' requires auditSigningKey — a compliance tier must sign its audit chain"
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
this.auditSigningKey = opts?.auditSigningKey;
|
|
183
|
+
if (opts?.auditRetention !== undefined) {
|
|
184
|
+
if (!Number.isInteger(opts.auditRetention) || opts.auditRetention < 0) {
|
|
185
|
+
throw new RangeError(
|
|
186
|
+
`auditRetention expects a non-negative integer, received ${String(opts.auditRetention)}`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
this.auditRetention = opts.auditRetention;
|
|
190
|
+
}
|
|
191
|
+
this.costRouter = new CostRouter();
|
|
192
|
+
this.tokenBudget = new TokenBudgetEngine(tokenBudgetConfigForProfile(this.governanceProfile));
|
|
193
|
+
this.rateLimiter = new RateLimiter({
|
|
194
|
+
maxCallsPerWindow: this.governanceProfile.limiter.maxCallsPerWindow,
|
|
195
|
+
windowSizeCalls: this.governanceProfile.limiter.windowSizeCalls
|
|
196
|
+
});
|
|
197
|
+
this.behaviorCollector = new BehaviorCollector();
|
|
198
|
+
this.paeRegistry = new PaeAdapterRegistry();
|
|
199
|
+
this.paeChannel = new PaeChannel(this.paeRegistry);
|
|
200
|
+
this.channelHub = new ChannelHub();
|
|
201
|
+
this.durableTraceJournal = new PersistedTraceJournal(opts?.traceJournalPath, this.auditSigningKey);
|
|
202
|
+
this.traceJournal = this.durableTraceJournal;
|
|
203
|
+
this.impactGraph = new ImpactDomainGraph();
|
|
204
|
+
this.pluginPactVerifier = new PluginPactVerifier();
|
|
205
|
+
this.pluginSandboxGuard = new PluginSandboxGuard(
|
|
206
|
+
this.traceJournal,
|
|
207
|
+
// W29: the trip threshold comes from the profile, softened by the
|
|
208
|
+
// plugin's dependency out-degree (see tripThresholdForProfile).
|
|
209
|
+
(pluginId) => tripThresholdForProfile(this.governanceProfile, this.impactGraph.outDegree(pluginId)),
|
|
210
|
+
opts?.clock
|
|
211
|
+
);
|
|
212
|
+
this.sandboxPool = new SandboxPool(this.channelHub, this.traceJournal, this.costRouter);
|
|
213
|
+
|
|
214
|
+
// Close the capability loop: plugin-originated channel calls must pass the
|
|
215
|
+
// declared-capability check. Injected as a function so the channel layer
|
|
216
|
+
// never depends on the pact layer above it.
|
|
217
|
+
this.channelHub.attachCapabilityGate((pluginUnitId, kind, funcName) =>
|
|
218
|
+
this.pluginPactVerifier.hasCapability(pluginUnitId, this.requiredCapabilityFor(kind, funcName))
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// W7: wire the gateway. Checkers are injected (not concrete components) so
|
|
222
|
+
// the gateway stays above the pact/safeguard layers. tripAllowed routes
|
|
223
|
+
// through the gateway's own per-plugin trip map. W8: budget / compression /
|
|
224
|
+
// route / tokenConfigHash are now derived from the real TokenBudgetEngine
|
|
225
|
+
// and channel registry instead of literal stubs.
|
|
226
|
+
const checkers: GatewayCheckers = {
|
|
227
|
+
tripAllowed: (pluginId) => this.tripPreCheckFor(pluginId),
|
|
228
|
+
pactPass: (pluginId, kind, funcName) =>
|
|
229
|
+
this.pluginPactVerifier.hasCapability(pluginId, this.requiredCapabilityFor(kind, funcName)),
|
|
230
|
+
budgetDecision: (pluginId) => this.tokenBudget.budgetPolicy(pluginId),
|
|
231
|
+
rateLimited: (pluginId) => this.rateLimiter.isLimited(pluginId),
|
|
232
|
+
// W15: a call on the adaptation channel is by definition PAE-routed; the
|
|
233
|
+
// legacy "any adapter kind declared" rule is kept for callers that only
|
|
234
|
+
// announce a routing intent without registering a real adapter.
|
|
235
|
+
route: (_pluginId, kind) =>
|
|
236
|
+
kind === ChannelKind.PAE_TOOL || this.paeAdapterKinds.size > 0 ? "pae" : "native",
|
|
237
|
+
compression: (output) => this.tokenBudget.decideCompression(output),
|
|
238
|
+
fingerprint: () => this.runFingerprint(),
|
|
239
|
+
accountTokens: (pluginId, output) => {
|
|
240
|
+
if (typeof output === "string") {
|
|
241
|
+
this.tokenBudget.account(pluginId, this.tokenBudget.estimateTokens(output));
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
estimateTokens: (output) => (typeof output === "string" ? this.tokenBudget.estimateTokens(output) : 0),
|
|
245
|
+
consumeRateLimit: (pluginId) => {
|
|
246
|
+
this.rateLimiter.acquire(pluginId);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
this.gateway = new CapabilityGateway(this.channelHub, checkers, opts?.clock);
|
|
250
|
+
// W11: collectors/hooks live inside the gateway; attach the host-owned one.
|
|
251
|
+
this.gateway.attachCollector(this.behaviorCollector);
|
|
252
|
+
|
|
253
|
+
this.channelHub.registerBuiltInChannel(ChannelKind.MEM_KV_STORE, new MemoryKvChannel(this.clock));
|
|
254
|
+
this.channelHub.registerBuiltInChannel(ChannelKind.LLM_ACCESS, new LlmMockChannel());
|
|
255
|
+
// W15: the adaptation surface is a built-in channel with no tools until an
|
|
256
|
+
// adapter is registered — foreign runtimes get no private path in.
|
|
257
|
+
this.channelHub.registerBuiltInChannel(ChannelKind.PAE_TOOL, this.paeChannel);
|
|
258
|
+
this.costRouter.register(ChannelKind.MEM_KV_STORE, { costPerCall: 0, latencyMs: 1, quality: 1 });
|
|
259
|
+
this.costRouter.register(ChannelKind.LLM_ACCESS, { costPerCall: 1, latencyMs: 320, quality: 1 });
|
|
260
|
+
// Adaptation costs more than a native call (an extra hop, possibly a
|
|
261
|
+
// process boundary) and is priced accordingly for budget routing.
|
|
262
|
+
this.costRouter.register(ChannelKind.PAE_TOOL, { costPerCall: 2, latencyMs: 40, quality: 1 });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Capability required by a channel method. Native channels map statically;
|
|
267
|
+
* PAE tools are resolved through the registry so a foreign tool is governed
|
|
268
|
+
* at the same granularity as a native method (read vs write), never wholesale.
|
|
269
|
+
* An unknown tool is treated as a write — the conservative default — so a
|
|
270
|
+
* mis-registered surface fails closed.
|
|
271
|
+
*/
|
|
272
|
+
private requiredCapabilityFor(kind: ChannelKind, funcName: string): CapabilityKey {
|
|
273
|
+
if (kind === ChannelKind.PAE_TOOL) {
|
|
274
|
+
return this.paeRegistry.capabilityOf(funcName) ?? "channel:write";
|
|
275
|
+
}
|
|
276
|
+
if (kind === ChannelKind.DOMAIN_TOOL) {
|
|
277
|
+
// W20: a domain hop is a cross-process call into another unit's process.
|
|
278
|
+
// The capability is not yet declared per unit tool, so the conservative
|
|
279
|
+
// default governs — the same fail-closed rule the adapter surface uses.
|
|
280
|
+
return "channel:write";
|
|
281
|
+
}
|
|
282
|
+
return requiredCapability(kind, funcName);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Host-private trip pre-check delegating to the gateway's per-plugin map. */
|
|
286
|
+
private tripPreCheckFor(pluginId: string): boolean {
|
|
287
|
+
return this.gateway.tripPreCheck(pluginId);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The run-version fingerprint a recorded trace carries (W7, extended W15/W20).
|
|
292
|
+
*
|
|
293
|
+
* Both optional fields are **omitted rather than empty**: a host that never
|
|
294
|
+
* registers an adapter or allocates a domain produces exactly the fingerprint
|
|
295
|
+
* it produced before those layers existed, so a trace recorded on an older
|
|
296
|
+
* kernel is reported as configuration drift — never as a mystery digest
|
|
297
|
+
* mismatch.
|
|
298
|
+
*/
|
|
299
|
+
public runFingerprint(): RunVersionFingerprint {
|
|
300
|
+
return {
|
|
301
|
+
kernelVersion: KERNEL_VERSION,
|
|
302
|
+
pactVersions: {},
|
|
303
|
+
tokenConfigHash: this.tokenBudget.configHash(),
|
|
304
|
+
paeEnabled: this.paeAdapterKinds.size > 0,
|
|
305
|
+
...(this.paeRegistry.isEmpty() ? {} : { paeAdaptersHash: this.paeRegistry.configHash() }),
|
|
306
|
+
...(this.domainManager?.planOf() ? { domainPlanHash: this.domainManager.planHash() } : {}),
|
|
307
|
+
// W29: only a NON-default tier is a fingerprint surface. `standard` is
|
|
308
|
+
// omitted so a default host keeps its pre-W29 fingerprint byte for byte
|
|
309
|
+
// (backward-compat rule: new hash fields are omitted, never empty).
|
|
310
|
+
...(this.governanceProfile.name === "standard"
|
|
311
|
+
? {}
|
|
312
|
+
: { governanceProfileHash: governanceProfileHash(this.governanceProfile) })
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* W29: the resolved governance tier and its concrete numbers. Read-only —
|
|
318
|
+
* the tier is a construction-time decision; switching it means constructing
|
|
319
|
+
* a host with `governanceProfile`.
|
|
320
|
+
*/
|
|
321
|
+
public get currentGovernanceProfile(): GovernanceProfile {
|
|
322
|
+
return this.governanceProfile;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
public async bootHost(): Promise<void> {
|
|
326
|
+
// W27: recover durable journals *before* anything can append. A missing file
|
|
327
|
+
// is normal (first boot) and leaves the journal empty; an existing file
|
|
328
|
+
// replays its entries so the audit trail survives the restart. Recovery
|
|
329
|
+
// rebuilds the whole chain, so it must precede channel setup — otherwise
|
|
330
|
+
// setup-time audit entries would be overwritten by the recovered snapshot.
|
|
331
|
+
await this.traceJournal.load();
|
|
332
|
+
// W30: a compliance tier starts only against a PROVABLE audit trail. A
|
|
333
|
+
// chain that fails verification after recovery means the log was edited
|
|
334
|
+
// while the host was down — a strict tier refuses to run on it.
|
|
335
|
+
if (this.governanceProfile.name === "strict" && this.auditSigningKey) {
|
|
336
|
+
const report = this.verifyAuditChain();
|
|
337
|
+
if (!report.consistent) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`governance profile 'strict' refuses to boot: audit chain broken at entry #${report.brokenAt} ` +
|
|
340
|
+
`(${report.brokenReason})`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// Apply the retention bound to whatever the previous run left behind, before
|
|
345
|
+
// this run starts appending. Compaction also physically removes a tail that
|
|
346
|
+
// a crash truncated (recovery merely ignores it), so the log is well-formed
|
|
347
|
+
// again from the first append of the new run.
|
|
348
|
+
await this.pruneAuditLog();
|
|
349
|
+
await this.channelHub.setupAllBuiltInChannels(this.newHostCtx());
|
|
350
|
+
if (this.recordJournalPath) {
|
|
351
|
+
await this.resumeRecording();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* W30: prove the audit trail has not been tampered with. Recomputes the
|
|
357
|
+
* hash chain from the genesis seed and reports the first broken entry.
|
|
358
|
+
* An unsigned journal (no key configured) reports `signed: false` and is
|
|
359
|
+
* vacuously consistent.
|
|
360
|
+
*/
|
|
361
|
+
public verifyAuditChain(): AuditChainReport {
|
|
362
|
+
if (!this.auditSigningKey) {
|
|
363
|
+
return { consistent: true, total: this.traceJournal.entries().length, signed: false };
|
|
364
|
+
}
|
|
365
|
+
return verifyAuditChain(this.traceJournal.entries(), this.auditSigningKey);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* W27: enforce the configured audit-retention bound and compact the WAL.
|
|
370
|
+
*
|
|
371
|
+
* Called automatically at boot and shutdown; exposed so an operator can prune
|
|
372
|
+
* a long-running host on demand without a restart.
|
|
373
|
+
*
|
|
374
|
+
* @returns the number of audit entries retained.
|
|
375
|
+
*/
|
|
376
|
+
public async pruneAuditLog(): Promise<number> {
|
|
377
|
+
if (this.auditRetention === undefined) {
|
|
378
|
+
return this.traceJournal.entries().length;
|
|
379
|
+
}
|
|
380
|
+
return this.durableTraceJournal.retainLast(this.auditRetention);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Reverse-order teardown: pool -> pact -> guards -> channels -> journal -> graph. */
|
|
384
|
+
public async shutdownHost(): Promise<void> {
|
|
385
|
+
// W27: drain any pending WAL writes before tearing components down, so the
|
|
386
|
+
// last recorded calls/audit entries are not lost on a clean shutdown.
|
|
387
|
+
//
|
|
388
|
+
// A drain that loses a write now reports it (see PersistedRecordJournal),
|
|
389
|
+
// but the teardown below must still run to completion: aborting here would
|
|
390
|
+
// leak every child process and foreign adapter the host owns. So the flush
|
|
391
|
+
// failure is carried and re-raised only after the release is done — the
|
|
392
|
+
// caller gets the signal *and* a clean process.
|
|
393
|
+
let drainError: unknown = null;
|
|
394
|
+
try {
|
|
395
|
+
await this.activeRecordJournal?.flush();
|
|
396
|
+
} catch (err) {
|
|
397
|
+
drainError = err;
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
await this.traceJournal.flush();
|
|
401
|
+
} catch (err) {
|
|
402
|
+
drainError ??= err;
|
|
403
|
+
}
|
|
404
|
+
// Bound the log *at rest*: flush first so nothing pending is lost, then
|
|
405
|
+
// apply retention. Order matters — pruning before the flush would let the
|
|
406
|
+
// drained writes push the file back over the bound.
|
|
407
|
+
await this.pruneAuditLog();
|
|
408
|
+
this.sandboxPool.clear();
|
|
409
|
+
this.pluginPactVerifier.clear();
|
|
410
|
+
this.pluginSandboxGuard.releaseAllGuard();
|
|
411
|
+
// W20: release the physical layer before the hubs — every domain host is a
|
|
412
|
+
// real child process and must not outlive the kernel that spawned it.
|
|
413
|
+
await this.releaseIsolationDomains();
|
|
414
|
+
await this.channelHub.teardown();
|
|
415
|
+
// Channel teardown already released every adapter (PaeChannel.teardown);
|
|
416
|
+
// dropping the registry afterwards leaves no dangling foreign surface.
|
|
417
|
+
this.paeRegistry.clear();
|
|
418
|
+
this.traceJournal.clear();
|
|
419
|
+
this.impactGraph.clear();
|
|
420
|
+
if (drainError !== null) throw drainError;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Facade -----------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
/** Register a plugin; its declared channel deps feed the impact graph (M3). */
|
|
426
|
+
public registerPlugin(pact: PluginUnitPact): void {
|
|
427
|
+
// W31: the strict tier demands a declared parameter contract for every
|
|
428
|
+
// plugin — a compliance tier must state what it accepts.
|
|
429
|
+
if (this.governanceProfile.schemaMode === "required" && !pact.schema) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
`governance profile 'strict' requires a schema on plugin '${pact.id}' — a compliance tier must declare its parameter contract`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
this.pluginPactVerifier.registerPluginUnit(pact, makeUniqueMark());
|
|
435
|
+
this.impactGraph.addNode(pact.id);
|
|
436
|
+
for (const dep of pact.declareChannelDeps ?? []) {
|
|
437
|
+
this.impactGraph.addEdge(pact.id, dep);
|
|
438
|
+
}
|
|
439
|
+
// W20: the graph feeds domain allocation — a change invalidates the plan.
|
|
440
|
+
this.domainsStaleFlag = true;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Spawn an agent sandbox; its channel deps feed the impact graph (M3). */
|
|
444
|
+
public spawnAgentBox(cfg: AgentBoxConfig): AgentSandbox {
|
|
445
|
+
const box = this.sandboxPool.spawnSandbox(cfg);
|
|
446
|
+
this.impactGraph.addNode(cfg.agentBoxId);
|
|
447
|
+
for (const dep of cfg.channelDeps ?? []) {
|
|
448
|
+
this.impactGraph.addEdge(cfg.agentBoxId, dep);
|
|
449
|
+
}
|
|
450
|
+
// W20: same invalidation rule as `registerPlugin`.
|
|
451
|
+
this.domainsStaleFlag = true;
|
|
452
|
+
return box;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* W8: declare a PAE adapter for a channel kind. Once any adapter is
|
|
457
|
+
* registered the gateway's `route` decision becomes "pae" for governed
|
|
458
|
+
* calls, and the run fingerprint's `paeEnabled` flips — so a trace recorded
|
|
459
|
+
* before PAE existed is detected as config drift, never as a digest mismatch.
|
|
460
|
+
*/
|
|
461
|
+
public registerPaeAdapter(kind: ChannelKind): void {
|
|
462
|
+
this.paeAdapterKinds.add(kind);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* W15: attach a real foreign-runtime adapter.
|
|
467
|
+
*
|
|
468
|
+
* The adapter is validated, indexed and published as tools on the PAE
|
|
469
|
+
* channel, and a **dynamic pact** is derived from its declared surface and
|
|
470
|
+
* registered like any hand-written plugin manifest. That is what keeps the
|
|
471
|
+
* promise of "MCP-grade ecosystem reach without a governance downgrade": the
|
|
472
|
+
* foreign tool now passes the same capability check, trip protection, budget
|
|
473
|
+
* accounting and recording as a native channel method, because it *is* one.
|
|
474
|
+
*
|
|
475
|
+
* @returns the derived pact (already registered unless `registerPact: false`).
|
|
476
|
+
*/
|
|
477
|
+
public registerPaeToolAdapter(
|
|
478
|
+
adapter: IPaeAdapter,
|
|
479
|
+
opts: { registerPact?: boolean; requireHostMinEdition?: string } = {}
|
|
480
|
+
): PluginUnitPact {
|
|
481
|
+
// W29: the governance tier admits adapter kinds explicitly. `strict`
|
|
482
|
+
// admits none — a compliance tier has no foreign-runtime surface at all.
|
|
483
|
+
this.assertPaeAdmitted(adapter.meta.kind, adapter.meta.isolation);
|
|
484
|
+
this.paeRegistry.register(adapter, makeUniqueMark());
|
|
485
|
+
this.paeChannel.syncTools();
|
|
486
|
+
this.paeAdapterKinds.add(ChannelKind.PAE_TOOL);
|
|
487
|
+
const pact = this.paeRegistry.derivePact(adapter.meta.adapterId, {
|
|
488
|
+
requireHostMinEdition: opts.requireHostMinEdition
|
|
489
|
+
});
|
|
490
|
+
if (opts.registerPact !== false) {
|
|
491
|
+
this.registerPlugin(pact);
|
|
492
|
+
}
|
|
493
|
+
return pact;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* W29+W31: the governance gate for foreign adapters — kind admission
|
|
498
|
+
* (`paeAdmission`) and isolation cap (`maxIsolationLevel`, the trust
|
|
499
|
+
* assumption). `strict` admits no kind at all and caps isolation at L1; a
|
|
500
|
+
* denied adapter is rejected outright rather than half-connected.
|
|
501
|
+
*/
|
|
502
|
+
private assertPaeAdmitted(kind: string, isolation: string): void {
|
|
503
|
+
const admission = this.governanceProfile.paeAdmission;
|
|
504
|
+
if (admission !== "all" && !admission.includes(kind)) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`governance profile '${this.governanceProfile.name}' does not admit PAE adapter kind '${kind}' ` +
|
|
507
|
+
`(admitted: ${admission.length === 0 ? "none" : admission.join(", ")})`
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
const cap = this.governanceProfile.maxIsolationLevel;
|
|
511
|
+
if (rankIsolation(isolation) > rankIsolation(cap)) {
|
|
512
|
+
throw new Error(
|
|
513
|
+
`governance profile '${this.governanceProfile.name}' caps PAE isolation at ${cap}; ` +
|
|
514
|
+
`adapter '${kind}' claims ${isolation} (trust assumption)`
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Register an adapter whose surface is only knowable *after* a handshake.
|
|
521
|
+
*
|
|
522
|
+
* `registerPaeToolAdapter` resolves the capability surface at registration
|
|
523
|
+
* time, which presupposes that the surface already exists. An MCP server
|
|
524
|
+
* announces its tools over the wire, so there is nothing to resolve until the
|
|
525
|
+
* connection is up. This variant connects first, lets the adapter discover
|
|
526
|
+
* what the peer actually exposes, and then registers exactly that.
|
|
527
|
+
*
|
|
528
|
+
* The handshake is not a governance bypass: registration still runs the same
|
|
529
|
+
* static validation and still derives a dynamic pact from the *discovered*
|
|
530
|
+
* surface, so a server that later announces a new tool is configuration drift
|
|
531
|
+
* on the next replay rather than a silently expanded capability.
|
|
532
|
+
*/
|
|
533
|
+
public async connectPaeToolAdapter(
|
|
534
|
+
adapter: IPaeAdapter,
|
|
535
|
+
opts: { registerPact?: boolean; requireHostMinEdition?: string; maxWaitMs?: number } = {}
|
|
536
|
+
): Promise<PluginUnitPact> {
|
|
537
|
+
// W29+W31: gate BEFORE the handshake — a denied kind or isolation must
|
|
538
|
+
// not spawn a child process just to be rejected (register re-checks).
|
|
539
|
+
this.assertPaeAdmitted(adapter.meta.kind, adapter.meta.isolation);
|
|
540
|
+
if (adapter.setup) {
|
|
541
|
+
await adapter.setup({
|
|
542
|
+
traceMarkId: makeUniqueMark(),
|
|
543
|
+
maxWaitMs: opts.maxWaitMs ?? HOST_DEFAULT_TIMEOUT_MS
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
return this.registerPaeToolAdapter(adapter, {
|
|
547
|
+
registerPact: opts.registerPact,
|
|
548
|
+
requireHostMinEdition: opts.requireHostMinEdition
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Detach an adapter: its tools stop being dispatchable, its derived pact is
|
|
554
|
+
* revoked, and the routing flag clears once the surface is empty. A trace
|
|
555
|
+
* recorded while it was present then replays as configuration drift, not as
|
|
556
|
+
* a mysterious digest mismatch.
|
|
557
|
+
*
|
|
558
|
+
* Resources held by the adapter are released in the background; use
|
|
559
|
+
* `releasePaeToolAdapter` when you need to wait for that to complete (for
|
|
560
|
+
* example before the process exits, or in a test that asserts a subprocess
|
|
561
|
+
* is gone).
|
|
562
|
+
*/
|
|
563
|
+
public unregisterPaeToolAdapter(adapterId: string): void {
|
|
564
|
+
this.paeRegistry.unregister(adapterId);
|
|
565
|
+
this.paeChannel.syncTools();
|
|
566
|
+
this.pluginPactVerifier.unregisterPluginUnit(adapterId);
|
|
567
|
+
this.impactGraph.removeNode(adapterId);
|
|
568
|
+
// W20: removing a node can shrink closures, so the plan must be recomputed.
|
|
569
|
+
this.domainsStaleFlag = true;
|
|
570
|
+
if (this.paeRegistry.isEmpty()) {
|
|
571
|
+
this.paeAdapterKinds.delete(ChannelKind.PAE_TOOL);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Detach an adapter and await its release.
|
|
577
|
+
*
|
|
578
|
+
* Same as `unregisterPaeToolAdapter`, plus a wait for `teardown` to finish —
|
|
579
|
+
* the difference matters for adapters that own an OS process (MCP), where
|
|
580
|
+
* "unregistered" and "peer actually shut down" are separated by real time.
|
|
581
|
+
*/
|
|
582
|
+
public async releasePaeToolAdapter(adapterId: string): Promise<void> {
|
|
583
|
+
this.unregisterPaeToolAdapter(adapterId);
|
|
584
|
+
await this.paeRegistry.drainReleases();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Connect adapters registered after `bootHost`. Idempotent — adapters that
|
|
589
|
+
* are already connected are skipped, so it is safe to call after every batch
|
|
590
|
+
* of registrations.
|
|
591
|
+
*/
|
|
592
|
+
public async bootPaeAdapters(): Promise<void> {
|
|
593
|
+
await this.paeRegistry.setupAll({
|
|
594
|
+
traceMarkId: makeUniqueMark(),
|
|
595
|
+
maxWaitMs: HOST_DEFAULT_TIMEOUT_MS
|
|
596
|
+
});
|
|
597
|
+
this.paeChannel.syncTools();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Capability negotiation for a foreign tool: returns the descriptor when the
|
|
602
|
+
* mapping is at least as faithful as required, otherwise throws. Callers make
|
|
603
|
+
* an informed choice instead of silently receiving a degraded result.
|
|
604
|
+
*/
|
|
605
|
+
public negotiatePaeTool(toolName: string, minFidelity: PaeFidelity = "full"): PaeToolDescriptor {
|
|
606
|
+
return this.paeRegistry.negotiate(toolName, minFidelity, makeUniqueMark());
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* M2: open a recording window; sandboxes running in "record" mode fill it.
|
|
611
|
+
*
|
|
612
|
+
* W27: when the host was constructed with `recordJournalPath`, the window is
|
|
613
|
+
* durable — every recorded call is mirrored to the WAL. Opening a *new* window
|
|
614
|
+
* truncates that WAL first (a fresh window must not inherit the previous
|
|
615
|
+
* run's calls); use {@link resumeRecording} to continue a prior window instead.
|
|
616
|
+
*/
|
|
617
|
+
public beginRecording(): RecordJournal {
|
|
618
|
+
const journal = this.recordJournalPath
|
|
619
|
+
? new PersistedRecordJournal(this.recordJournalPath, { truncate: true })
|
|
620
|
+
: new RecordJournal();
|
|
621
|
+
this.activeRecordJournal = journal;
|
|
622
|
+
this.channelHub.attachRecordJournal(journal);
|
|
623
|
+
this.gateway.attachJournal(journal);
|
|
624
|
+
return journal;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* W27: reopen the durable recording window persisted by a previous process.
|
|
629
|
+
*
|
|
630
|
+
* Recovers the WAL (crash-safe: a truncated trailing line is dropped) and
|
|
631
|
+
* re-attaches it, so `orderIndex` continues from the recovered length and the
|
|
632
|
+
* combined journal replays as one uninterrupted run. Without a configured
|
|
633
|
+
* `recordJournalPath` this degrades to a plain in-memory window.
|
|
634
|
+
*/
|
|
635
|
+
public async resumeRecording(): Promise<RecordJournal> {
|
|
636
|
+
if (!this.recordJournalPath) return this.beginRecording();
|
|
637
|
+
const journal = await PersistedRecordJournal.recover(this.recordJournalPath);
|
|
638
|
+
// Heal the file before the resumed window appends to it: a crash-truncated
|
|
639
|
+
// tail would otherwise sit in the *interior* once the next line lands, and an
|
|
640
|
+
// invalid interior line is a hard fault. No-op when the log is healthy.
|
|
641
|
+
await journal.healIfNeeded();
|
|
642
|
+
this.activeRecordJournal = journal;
|
|
643
|
+
this.channelHub.attachRecordJournal(journal);
|
|
644
|
+
this.gateway.attachJournal(journal);
|
|
645
|
+
return journal;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* W27: the recording window currently attached to the gateway, or `null` if
|
|
650
|
+
* none was ever opened. After a boot with `recordJournalPath` configured this
|
|
651
|
+
* is the journal recovered from the WAL, so callers can inspect or replay a
|
|
652
|
+
* window persisted by a previous process without reopening it.
|
|
653
|
+
*/
|
|
654
|
+
public currentRecordJournal(): RecordJournal | null {
|
|
655
|
+
return this.activeRecordJournal;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** M2: attach a replay engine over a previously recorded journal. */
|
|
659
|
+
public attachReplayEngine(journal: RecordJournal): ReplayEngine {
|
|
660
|
+
const engine = new ReplayEngine(journal);
|
|
661
|
+
this.channelHub.attachReplayEngine(engine);
|
|
662
|
+
this.gateway.attachReplayEngine(engine);
|
|
663
|
+
return engine;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** W7: the unified gateway entry — deterministic boundary for governed calls. */
|
|
667
|
+
public async capabilityInvoke<T>(params: {
|
|
668
|
+
kind: ChannelKind;
|
|
669
|
+
pluginId?: string;
|
|
670
|
+
funcName: string;
|
|
671
|
+
args: unknown[];
|
|
672
|
+
mode: ReplayMode;
|
|
673
|
+
ctx?: Partial<ChannelCallCtx>;
|
|
674
|
+
}): Promise<T> {
|
|
675
|
+
// W31: progressive contractification — when the tier checks schemas and
|
|
676
|
+
// the target tool declares one, reject non-conforming arguments BEFORE
|
|
677
|
+
// the call executes. Replay bypasses the check: arguments were already
|
|
678
|
+
// validated at record time, and injection must stay a pure replay. The
|
|
679
|
+
// function is async so the rejection is a rejected promise, never a
|
|
680
|
+
// synchronous throw (callers use await / assert.rejects uniformly).
|
|
681
|
+
if (params.mode !== "replay" && this.governanceProfile.schemaMode !== "optional") {
|
|
682
|
+
this.assertArgsAgainstSchema(params.funcName, params.args);
|
|
683
|
+
}
|
|
684
|
+
return this.gateway.capabilityInvoke<T>(params as GatewayInvokeParams);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* W31: gateway-side parameter validation for PAE tools that declare a
|
|
689
|
+
* schema. The error is raised as a plain Error (user-classified 400 by the
|
|
690
|
+
* console bridge); the recorded call never happens, so nothing to replay.
|
|
691
|
+
*/
|
|
692
|
+
private assertArgsAgainstSchema(funcName: string, args: unknown[]): void {
|
|
693
|
+
const binding = this.paeRegistry.lookup(funcName);
|
|
694
|
+
const schema = binding?.tool.schema;
|
|
695
|
+
if (!schema) return; // no contract declared -> nothing to check
|
|
696
|
+
const result = validateArgsAgainstSchema(schema, args);
|
|
697
|
+
if (!result.ok) {
|
|
698
|
+
throw new Error(
|
|
699
|
+
`parameter contract violated for tool '${funcName}' at ${result.path}: ${result.error}`
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** M3: nodes that would be affected if the given node fails (reachability closure). */
|
|
705
|
+
public isolationDomain(nodeId: string): string[] {
|
|
706
|
+
return [...this.impactGraph.closure(nodeId)];
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** M3: isolation theorem query — two nodes are provably independent. */
|
|
710
|
+
public areIsolated(a: string, b: string): boolean {
|
|
711
|
+
return this.impactGraph.areIndependent(a, b);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** M4: choose the cheapest channel that fits the budget and latency target. */
|
|
715
|
+
public routeChannel(kinds: ChannelKind[], budget: number, maxLatencyMs: number): ChannelKind | undefined {
|
|
716
|
+
return this.costRouter.choose(kinds, budget, maxLatencyMs);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/* ------------------------------------------------------------------ */
|
|
720
|
+
/* W20 · 图驱动域分配与域间事务化调用 */
|
|
721
|
+
/* ------------------------------------------------------------------ */
|
|
722
|
+
|
|
723
|
+
/** Whether the impact graph changed since the last domain allocation. */
|
|
724
|
+
public domainsStale(): boolean {
|
|
725
|
+
return this.domainsStaleFlag;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Allocate isolation domains from the current impact graph and publish their
|
|
730
|
+
* unit surface on the gateway (W19 channel, W20 host state).
|
|
731
|
+
*
|
|
732
|
+
* Idempotent for a given graph: the sync is a diff, so re-running after a
|
|
733
|
+
* no-op graph change neither restarts child processes nor perturbs the plan
|
|
734
|
+
* hash. Registering the channel happens here and only here — a host that
|
|
735
|
+
* never allocates domains keeps its previous hub surface and fingerprint.
|
|
736
|
+
*/
|
|
737
|
+
public async allocateIsolationDomains(
|
|
738
|
+
opts: {
|
|
739
|
+
transportFactory?: DomainTransportFactory;
|
|
740
|
+
clock?: ClockSource;
|
|
741
|
+
maxImpactClosure?: number;
|
|
742
|
+
maxDomainSize?: number;
|
|
743
|
+
defaultTimeoutMs?: number;
|
|
744
|
+
} = {}
|
|
745
|
+
): Promise<IsolationDomainPlan> {
|
|
746
|
+
let manager = this.domainManager;
|
|
747
|
+
if (!manager) {
|
|
748
|
+
manager = new IsolationDomainManager({
|
|
749
|
+
transportFactory: opts.transportFactory,
|
|
750
|
+
clock: opts.clock,
|
|
751
|
+
defaultTimeoutMs: opts.defaultTimeoutMs,
|
|
752
|
+
maxImpactClosure: opts.maxImpactClosure,
|
|
753
|
+
maxDomainSize: opts.maxDomainSize
|
|
754
|
+
});
|
|
755
|
+
const channel = new DomainChannel(manager);
|
|
756
|
+
this.domainManager = manager;
|
|
757
|
+
this.domainChannel = channel;
|
|
758
|
+
this.channelHub.registerBuiltInChannel(ChannelKind.DOMAIN_TOOL, channel);
|
|
759
|
+
// A domain hop is a cross-process call: priced and timed like the
|
|
760
|
+
// adaptation surface, so budget routing sees the real cost.
|
|
761
|
+
this.costRouter.register(ChannelKind.DOMAIN_TOOL, { costPerCall: 2, latencyMs: 40, quality: 1 });
|
|
762
|
+
}
|
|
763
|
+
const plan = await manager.syncDomains(this.impactGraph, this.domainCtx(), opts);
|
|
764
|
+
this.domainChannel?.syncTools();
|
|
765
|
+
this.domainsStaleFlag = false;
|
|
766
|
+
return plan;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** The current domain plan, or `null` before the first allocation. */
|
|
770
|
+
public domainPlan(): IsolationDomainPlan | null {
|
|
771
|
+
return this.domainManager?.planOf() ?? null;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Running domains: id, units and isolation level, sorted. */
|
|
775
|
+
public domains(): Array<{ domainId: string; units: string[]; isolation: string }> {
|
|
776
|
+
return this.domainManager?.domainsOf() ?? [];
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Invoke a unit in its domain through the gateway — the cross-domain hop.
|
|
781
|
+
* Recorded and replayed like any other governed call; settled in the domain
|
|
782
|
+
* transaction ledger either way.
|
|
783
|
+
*/
|
|
784
|
+
public invokeDomainUnit<T>(
|
|
785
|
+
unitId: string,
|
|
786
|
+
tool: string,
|
|
787
|
+
args: unknown[] = [],
|
|
788
|
+
opts: { pluginUnitId?: string; mode?: ReplayMode; ctx?: Partial<ChannelCallCtx> } = {}
|
|
789
|
+
): Promise<T> {
|
|
790
|
+
return this.gateway.capabilityInvoke<T>({
|
|
791
|
+
kind: ChannelKind.DOMAIN_TOOL,
|
|
792
|
+
pluginId: opts.pluginUnitId,
|
|
793
|
+
funcName: `${unitId}:${tool}`,
|
|
794
|
+
args,
|
|
795
|
+
mode: opts.mode ?? "live",
|
|
796
|
+
ctx: opts.ctx
|
|
797
|
+
} as GatewayInvokeParams);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** The cross-domain transaction ledger (decision → execution → result). */
|
|
801
|
+
public domainLedger(): readonly DomainTransaction[] {
|
|
802
|
+
return this.domainManager?.txnLedger() ?? [];
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Reconcile the ledger — cross-domain events must balance (VISION 2.2). */
|
|
806
|
+
public reconcileDomainTransactions(): DomainReconciliation {
|
|
807
|
+
return this.domainManager
|
|
808
|
+
? this.domainManager.reconcile()
|
|
809
|
+
: {
|
|
810
|
+
balanced: true,
|
|
811
|
+
pairs: [],
|
|
812
|
+
orphans: [],
|
|
813
|
+
rejected: [],
|
|
814
|
+
totals: { transactions: 0, settled: 0, failed: 0, rejected: 0 }
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Tear down every domain and release its host process. */
|
|
819
|
+
public async releaseIsolationDomains(): Promise<void> {
|
|
820
|
+
if (!this.domainManager) return;
|
|
821
|
+
const manager = this.domainManager;
|
|
822
|
+
this.domainManager = null;
|
|
823
|
+
this.domainChannel = null;
|
|
824
|
+
this.domainsStaleFlag = false;
|
|
825
|
+
await manager.teardownAll();
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Context for domain synchronization calls. */
|
|
829
|
+
private domainCtx(): DomainInvokeCtx {
|
|
830
|
+
const ctx = this.newHostCtx();
|
|
831
|
+
return { traceMarkId: ctx.traceMarkId, maxWaitMs: ctx.maxWaitMs };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
private newHostCtx(): ChannelCallCtx {
|
|
835
|
+
return { traceMarkId: makeUniqueMark(), maxWaitMs: HOST_DEFAULT_TIMEOUT_MS };
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** Minimal mapping from a channel method to its required capability. */
|
|
840
|
+
function requiredCapability(kind: ChannelKind, funcName: string): CapabilityKey {
|
|
841
|
+
if (kind === ChannelKind.MEM_KV_STORE) {
|
|
842
|
+
return funcName === "writeEntry" || funcName === "removeEntry" ? "channel:write" : "channel:read";
|
|
843
|
+
}
|
|
844
|
+
if (kind === ChannelKind.FILE_SYSTEM) {
|
|
845
|
+
const writes = new Set(["writeTextFile", "appendTextFile", "removePath", "makeDir"]);
|
|
846
|
+
return writes.has(funcName) ? "channel:write" : "channel:read";
|
|
847
|
+
}
|
|
848
|
+
if (kind === ChannelKind.SHELL_EXEC) {
|
|
849
|
+
// Executing commands is treated as a mutating capability by default.
|
|
850
|
+
return "channel:write";
|
|
851
|
+
}
|
|
852
|
+
return "channel:read";
|
|
853
|
+
}
|