@gethelio/proxy 0.12.0 → 0.13.1
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 +7 -4
- package/dist/cli.js +1508 -753
- package/dist/dashboard-assets/assets/index-BRvkMXWl.js +128 -0
- package/dist/dashboard-assets/assets/index-DtnT1Y9r.css +1 -0
- package/dist/dashboard-assets/index.html +2 -2
- package/dist/index.d.ts +490 -24
- package/dist/index.js +1221 -693
- package/package.json +2 -2
- package/dist/dashboard-assets/assets/index-BBYXsIig.css +0 -1
- package/dist/dashboard-assets/assets/index-uJng9NyO.js +0 -128
package/dist/index.js
CHANGED
|
@@ -35,6 +35,11 @@ var RESERVED_TRANSPORT_HEADERS = /* @__PURE__ */ new Set([
|
|
|
35
35
|
"content-type",
|
|
36
36
|
"content-length",
|
|
37
37
|
"host",
|
|
38
|
+
// The Accept is Helio-owned per HTTP upstream leg: where Helio
|
|
39
|
+
// advertises at all it advertises its own response parsing (the SSE
|
|
40
|
+
// message POSTs assert none), so an operator value could only
|
|
41
|
+
// misadvertise it, never extend it (issue #304).
|
|
42
|
+
"accept",
|
|
38
43
|
// Modern (2026-07-28) transport headers Helio owns on the wire for every
|
|
39
44
|
// Streamable HTTP POST it sends upstream — relayed client traffic and
|
|
40
45
|
// proxy-initiated requests (era probe, revalidation) alike — see
|
|
@@ -44,8 +49,8 @@ var RESERVED_TRANSPORT_HEADERS = /* @__PURE__ */ new Set([
|
|
|
44
49
|
]);
|
|
45
50
|
var transportSchema = z.enum(["streamable-http", "sse", "stdio"]);
|
|
46
51
|
var protocolVersionSchema = z.enum(["auto", "2025-06-18", "2026-07-28"]);
|
|
47
|
-
var
|
|
48
|
-
url: z.string(),
|
|
52
|
+
var upstreamObjectSchema = z.object({
|
|
53
|
+
url: z.string().optional(),
|
|
49
54
|
transport: transportSchema.default("streamable-http"),
|
|
50
55
|
protocol_version: protocolVersionSchema.default("auto"),
|
|
51
56
|
command: z.string().optional(),
|
|
@@ -54,10 +59,22 @@ var upstreamSchema = z.object({
|
|
|
54
59
|
request_timeout: durationSchema.default("30s"),
|
|
55
60
|
forward_headers: z.array(z.string().min(1)).default([]),
|
|
56
61
|
headers: z.record(z.string(), z.string()).default({})
|
|
57
|
-
}).strict()
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
}).strict();
|
|
63
|
+
function upstreamEntryChecks(data, ctx) {
|
|
64
|
+
if (data.transport === "stdio" && data.command === void 0) {
|
|
65
|
+
ctx.addIssue({
|
|
66
|
+
code: "custom",
|
|
67
|
+
path: ["command"],
|
|
68
|
+
message: '"command" is required when transport is "stdio"'
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (data.transport !== "stdio" && data.url === void 0) {
|
|
72
|
+
ctx.addIssue({
|
|
73
|
+
code: "custom",
|
|
74
|
+
path: ["url"],
|
|
75
|
+
message: `"url" is required when transport is "${data.transport}"`
|
|
76
|
+
});
|
|
77
|
+
}
|
|
61
78
|
if (data.protocol_version === "2026-07-28" && data.transport !== "streamable-http") {
|
|
62
79
|
ctx.addIssue({
|
|
63
80
|
code: "custom",
|
|
@@ -83,6 +100,26 @@ var upstreamSchema = z.object({
|
|
|
83
100
|
});
|
|
84
101
|
}
|
|
85
102
|
}
|
|
103
|
+
}
|
|
104
|
+
var upstreamSchema = upstreamObjectSchema.superRefine(upstreamEntryChecks);
|
|
105
|
+
var upstreamNameSchema = z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/, {
|
|
106
|
+
message: 'Upstream names may only contain letters, digits, "_" and "-"'
|
|
107
|
+
});
|
|
108
|
+
var namedUpstreamEntrySchema = z.object({ name: upstreamNameSchema, ...upstreamObjectSchema.shape }).strict().superRefine(upstreamEntryChecks);
|
|
109
|
+
var upstreamsListSchema = z.array(namedUpstreamEntrySchema).min(1, {
|
|
110
|
+
message: 'upstreams: must declare at least one upstream \u2014 an empty list would serve nothing. For a single upstream you can keep the "upstream:" form.'
|
|
111
|
+
}).superRefine((entries, ctx) => {
|
|
112
|
+
const seen = /* @__PURE__ */ new Set();
|
|
113
|
+
for (const [index, entry] of entries.entries()) {
|
|
114
|
+
if (seen.has(entry.name)) {
|
|
115
|
+
ctx.addIssue({
|
|
116
|
+
code: "custom",
|
|
117
|
+
path: [index, "name"],
|
|
118
|
+
message: `Duplicate upstream name "${entry.name}". Upstream names embed in mount paths, limiter keys, and audit records \u2014 each upstream needs its own.`
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
seen.add(entry.name);
|
|
122
|
+
}
|
|
86
123
|
});
|
|
87
124
|
var listenSchema = z.object({
|
|
88
125
|
port: z.number().int().min(1).max(65535).default(3e3),
|
|
@@ -241,7 +278,11 @@ var matchSchema = z.object({
|
|
|
241
278
|
annotations: annotationsMatchSchema.optional(),
|
|
242
279
|
input: z.record(z.string(), inputConditionSchema).optional(),
|
|
243
280
|
environment: z.string().optional(),
|
|
244
|
-
metadata: z.record(z.string(), metadataConditionSchema).optional()
|
|
281
|
+
metadata: z.record(z.string(), metadataConditionSchema).optional(),
|
|
282
|
+
/** Configured upstream names the rule is scoped to (issue #293). */
|
|
283
|
+
upstreams: z.array(z.string().min(1)).min(1, {
|
|
284
|
+
message: "match.upstreams must name at least one upstream \u2014 an empty list matches nothing."
|
|
285
|
+
}).optional()
|
|
245
286
|
}).strict();
|
|
246
287
|
var policyActionSchema = z.enum([
|
|
247
288
|
"allow",
|
|
@@ -364,7 +405,11 @@ var budgetContributorMatchSchema = z.object({
|
|
|
364
405
|
// Same operators and AND-combination as rule `match.input`. Other rule
|
|
365
406
|
// matchers (annotations, environment, metadata) stay strict-rejected
|
|
366
407
|
// until the budget charge context can actually evaluate them.
|
|
367
|
-
input: z.record(z.string(), inputConditionSchema).optional()
|
|
408
|
+
input: z.record(z.string(), inputConditionSchema).optional(),
|
|
409
|
+
/** Configured upstream names the contributor is scoped to (issue #293). */
|
|
410
|
+
upstreams: z.array(z.string().min(1)).min(1, {
|
|
411
|
+
message: "match.upstreams must name at least one upstream \u2014 an empty list matches nothing."
|
|
412
|
+
}).optional()
|
|
368
413
|
}).strict();
|
|
369
414
|
var modernBudgetContributorSchema = z.object({
|
|
370
415
|
match: budgetContributorMatchSchema,
|
|
@@ -476,9 +521,7 @@ var sdkSchema = z.object({
|
|
|
476
521
|
*/
|
|
477
522
|
evaluation_ttl: durationSchema.default("10m")
|
|
478
523
|
}).strict();
|
|
479
|
-
var
|
|
480
|
-
version: z.literal("1"),
|
|
481
|
-
upstream: upstreamSchema,
|
|
524
|
+
var rootSectionSchemas = {
|
|
482
525
|
listen: listenSchema.prefault({}),
|
|
483
526
|
environment: z.string().optional(),
|
|
484
527
|
// Session precedes policies deliberately: upstream/listen/environment say
|
|
@@ -495,6 +538,16 @@ var helioConfigBaseSchema = z.object({
|
|
|
495
538
|
// the request path (canonical section order, #89/#163).
|
|
496
539
|
dashboard: dashboardSchema.prefault({}),
|
|
497
540
|
sdk: sdkSchema.prefault({})
|
|
541
|
+
};
|
|
542
|
+
var singularConfigBase = z.object({
|
|
543
|
+
version: z.literal("1"),
|
|
544
|
+
upstream: upstreamSchema,
|
|
545
|
+
...rootSectionSchemas
|
|
546
|
+
}).strict();
|
|
547
|
+
var namedConfigBase = z.object({
|
|
548
|
+
version: z.literal("1"),
|
|
549
|
+
upstreams: upstreamsListSchema,
|
|
550
|
+
...rootSectionSchemas
|
|
498
551
|
}).strict();
|
|
499
552
|
function stripRootExtensionKeys(value) {
|
|
500
553
|
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
@@ -502,7 +555,7 @@ function stripRootExtensionKeys(value) {
|
|
|
502
555
|
Object.entries(value).filter(([key]) => !key.startsWith("x-"))
|
|
503
556
|
);
|
|
504
557
|
}
|
|
505
|
-
|
|
558
|
+
function rootConfigChecks(cfg, ctx) {
|
|
506
559
|
const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
|
|
507
560
|
const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.on_tool_drift === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval") || cfg.budgets.some((budget) => budget.on_exceed === "require_approval");
|
|
508
561
|
const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
|
|
@@ -511,7 +564,7 @@ var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
|
511
564
|
ctx.addIssue({
|
|
512
565
|
code: "custom",
|
|
513
566
|
path: ["dashboard", "api_secret"],
|
|
514
|
-
message: 'dashboard.api_secret is required when any rule uses require_approval, any budget uses on_exceed: require_approval, or policies.flag_destructive or policies.on_tool_drift is "require_approval".
|
|
567
|
+
message: 'dashboard.api_secret is required when any rule uses require_approval, any budget uses on_exceed: require_approval, or policies.flag_destructive or policies.on_tool_drift is "require_approval". Run `helio secret` and set the printed digest under `dashboard.api_secret` in your helio.yaml (a plaintext value is accepted but warned about at startup). (See docs/approvals.md.)'
|
|
515
568
|
});
|
|
516
569
|
}
|
|
517
570
|
}
|
|
@@ -519,7 +572,7 @@ var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
|
519
572
|
ctx.addIssue({
|
|
520
573
|
code: "custom",
|
|
521
574
|
path: ["dashboard", "api_secret"],
|
|
522
|
-
message: "dashboard.api_secret is required when dashboard.enabled is true unless dashboard.allow_open_mode is explicitly set to true.
|
|
575
|
+
message: "dashboard.api_secret is required when dashboard.enabled is true unless dashboard.allow_open_mode is explicitly set to true. Run `helio secret` and set the printed digest under dashboard.api_secret in helio.yaml."
|
|
523
576
|
});
|
|
524
577
|
}
|
|
525
578
|
if (!requiresSecret && cfg.dashboard.enabled && !hasSecret && cfg.dashboard.allow_open_mode && !isLoopbackHost(cfg.dashboard.host)) {
|
|
@@ -722,11 +775,170 @@ var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
|
722
775
|
}
|
|
723
776
|
}
|
|
724
777
|
}
|
|
778
|
+
}
|
|
779
|
+
function upstreamVocabularyChecks(cfg, ctx, configuredNames) {
|
|
780
|
+
for (const [ruleIndex, rule] of cfg.policies.rules.entries()) {
|
|
781
|
+
const upstreams = rule.match.upstreams;
|
|
782
|
+
if (upstreams === void 0) continue;
|
|
783
|
+
if (configuredNames === null) {
|
|
784
|
+
ctx.addIssue({
|
|
785
|
+
code: "custom",
|
|
786
|
+
path: ["policies", "rules", ruleIndex, "match", "upstreams"],
|
|
787
|
+
message: 'Rule sets match.upstreams but the config declares a single "upstream:", which has no name on purpose. Upstream-scoped rules require the named "upstreams:" list.'
|
|
788
|
+
});
|
|
789
|
+
} else {
|
|
790
|
+
for (const [entryIndex, name] of upstreams.entries()) {
|
|
791
|
+
if (!configuredNames.has(name)) {
|
|
792
|
+
ctx.addIssue({
|
|
793
|
+
code: "custom",
|
|
794
|
+
path: ["policies", "rules", ruleIndex, "match", "upstreams", entryIndex],
|
|
795
|
+
message: `Rule names upstream "${name}" in match.upstreams but no configured upstream has that name. Every entry must name an upstream from the upstreams: list.`
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (rule.match.metadata !== void 0) {
|
|
801
|
+
ctx.addIssue({
|
|
802
|
+
code: "custom",
|
|
803
|
+
path: ["policies", "rules", ruleIndex, "match", "upstreams"],
|
|
804
|
+
message: "match.upstreams cannot be combined with match.metadata \u2014 metadata rules only match on the sideband (host) path and upstream-scoped rules only on the MCP path, so the combination can never match. Split it into two rules."
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
if (rule.limits?.key === "sender_id") {
|
|
808
|
+
ctx.addIssue({
|
|
809
|
+
code: "custom",
|
|
810
|
+
path: ["policies", "rules", ruleIndex, "limits", "key"],
|
|
811
|
+
message: 'limits.key "sender_id" cannot be combined with match.upstreams \u2014 an upstream-scoped rule only matches on the MCP path, where sender_id is absent and the key would silently collapse to tool scope.'
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
if (rule.limits?.max_spend?.key === "sender_id") {
|
|
815
|
+
ctx.addIssue({
|
|
816
|
+
code: "custom",
|
|
817
|
+
path: ["policies", "rules", ruleIndex, "limits", "max_spend", "key"],
|
|
818
|
+
message: 'limits.max_spend.key "sender_id" cannot be combined with match.upstreams \u2014 an upstream-scoped rule only matches on the MCP path, where sender_id is absent and the key would silently collapse to tool scope.'
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
for (const [budgetIndex, budget] of cfg.budgets.entries()) {
|
|
823
|
+
let hasUnscopedContributor = false;
|
|
824
|
+
for (const [contributorIndex, contributor] of budget.contributors.entries()) {
|
|
825
|
+
const upstreams = contributor.match?.upstreams;
|
|
826
|
+
if (upstreams === void 0) {
|
|
827
|
+
hasUnscopedContributor = true;
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
if (configuredNames === null) {
|
|
831
|
+
ctx.addIssue({
|
|
832
|
+
code: "custom",
|
|
833
|
+
path: ["budgets", budgetIndex, "contributors", contributorIndex, "match", "upstreams"],
|
|
834
|
+
message: 'Contributor sets match.upstreams but the config declares a single "upstream:", which has no name on purpose. Upstream-scoped contributors require the named "upstreams:" list.'
|
|
835
|
+
});
|
|
836
|
+
} else {
|
|
837
|
+
for (const [entryIndex, name] of upstreams.entries()) {
|
|
838
|
+
if (!configuredNames.has(name)) {
|
|
839
|
+
ctx.addIssue({
|
|
840
|
+
code: "custom",
|
|
841
|
+
path: [
|
|
842
|
+
"budgets",
|
|
843
|
+
budgetIndex,
|
|
844
|
+
"contributors",
|
|
845
|
+
contributorIndex,
|
|
846
|
+
"match",
|
|
847
|
+
"upstreams",
|
|
848
|
+
entryIndex
|
|
849
|
+
],
|
|
850
|
+
message: `Contributor names upstream "${name}" in match.upstreams but no configured upstream has that name. Every entry must name an upstream from the upstreams: list.`
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (budget.key === "sender_id" && !hasUnscopedContributor) {
|
|
857
|
+
ctx.addIssue({
|
|
858
|
+
code: "custom",
|
|
859
|
+
path: ["budgets", budgetIndex, "key"],
|
|
860
|
+
message: 'budget key "sender_id" requires at least one contributor without an "upstreams" scope \u2014 upstream-scoped contributors only match MCP calls, which never carry a sender, so every charge would land in the shared "unknown" pot while sideband calls (the only ones with real senders) never feed this budget.'
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (configuredNames !== null) {
|
|
865
|
+
const hasEvidenceGatedRule = cfg.policies.rules.some(
|
|
866
|
+
(rule) => (rule.evidence?.requires.length ?? 0) > 0 || (rule.requires?.length ?? 0) > 0
|
|
867
|
+
);
|
|
868
|
+
if (hasEvidenceGatedRule) {
|
|
869
|
+
const legacyIndex = cfg.session.identity.findIndex(
|
|
870
|
+
(source) => source.source === "legacy_header"
|
|
871
|
+
);
|
|
872
|
+
if (legacyIndex !== -1) {
|
|
873
|
+
ctx.addIssue({
|
|
874
|
+
code: "custom",
|
|
875
|
+
path: ["session", "identity", legacyIndex],
|
|
876
|
+
message: `session.identity includes "legacy_header" while named upstreams and evidence-gated rules ("evidence"/"requires") are configured. On the legacy relay flow the Mcp-Session-Id a client echoes was minted by the upstream itself, so with multiple upstreams a hostile server could collide session identities across doors and pollute another door's evidence gates. Remove legacy_header from session.identity and use a caller-owned source such as the default "x-helio-session-id" header.`
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
var singularConfigSchema = singularConfigBase.superRefine((cfg, ctx) => {
|
|
883
|
+
rootConfigChecks(cfg, ctx);
|
|
884
|
+
upstreamVocabularyChecks(cfg, ctx, null);
|
|
885
|
+
});
|
|
886
|
+
var namedConfigSchema = namedConfigBase.superRefine((cfg, ctx) => {
|
|
887
|
+
rootConfigChecks(cfg, ctx);
|
|
888
|
+
upstreamVocabularyChecks(cfg, ctx, new Set(cfg.upstreams.map((entry) => entry.name)));
|
|
725
889
|
});
|
|
726
|
-
var
|
|
890
|
+
var objectRootSchema = z.object({});
|
|
891
|
+
function dispatchByMode(raw, ctx) {
|
|
892
|
+
const isObject2 = raw !== null && typeof raw === "object" && !Array.isArray(raw);
|
|
893
|
+
if (!isObject2) {
|
|
894
|
+
const typeResult = objectRootSchema.safeParse(raw);
|
|
895
|
+
if (!typeResult.success) {
|
|
896
|
+
for (const issue of typeResult.error.issues) {
|
|
897
|
+
ctx.addIssue(issue);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return z.NEVER;
|
|
901
|
+
}
|
|
902
|
+
const hasUpstream = "upstream" in raw;
|
|
903
|
+
const hasUpstreams = "upstreams" in raw;
|
|
904
|
+
if (hasUpstream && hasUpstreams) {
|
|
905
|
+
ctx.addIssue({
|
|
906
|
+
code: "custom",
|
|
907
|
+
path: ["upstreams"],
|
|
908
|
+
message: 'Set exactly one of "upstream:" (single upstream) or "upstreams:" (named multi-upstream list) \u2014 not both. To migrate, move the upstream: fields into an upstreams: entry and give it a name.'
|
|
909
|
+
});
|
|
910
|
+
return z.NEVER;
|
|
911
|
+
}
|
|
912
|
+
if (!hasUpstream && !hasUpstreams) {
|
|
913
|
+
ctx.addIssue({
|
|
914
|
+
code: "custom",
|
|
915
|
+
message: 'Missing upstream configuration: set exactly one of "upstream:" (single upstream) or "upstreams:" (named multi-upstream list).'
|
|
916
|
+
});
|
|
917
|
+
return z.NEVER;
|
|
918
|
+
}
|
|
919
|
+
const result = hasUpstreams ? namedConfigSchema.safeParse(raw) : singularConfigSchema.safeParse(raw);
|
|
920
|
+
if (!result.success) {
|
|
921
|
+
for (const issue of result.error.issues) {
|
|
922
|
+
ctx.addIssue(issue);
|
|
923
|
+
}
|
|
924
|
+
return z.NEVER;
|
|
925
|
+
}
|
|
926
|
+
return result.data;
|
|
927
|
+
}
|
|
928
|
+
var helioConfigSchema = z.preprocess(
|
|
929
|
+
stripRootExtensionKeys,
|
|
930
|
+
z.unknown().transform(dispatchByMode)
|
|
931
|
+
);
|
|
932
|
+
function isSingularConfig(config) {
|
|
933
|
+
return !("upstreams" in config);
|
|
934
|
+
}
|
|
935
|
+
function isNamedConfig(config) {
|
|
936
|
+
return "upstreams" in config;
|
|
937
|
+
}
|
|
727
938
|
|
|
728
939
|
// src/config/loader.ts
|
|
729
940
|
import { readFile } from "fs/promises";
|
|
941
|
+
import { createHash } from "crypto";
|
|
730
942
|
import yaml from "js-yaml";
|
|
731
943
|
|
|
732
944
|
// src/util/format-zod-errors.ts
|
|
@@ -746,36 +958,42 @@ var ConfigError = class extends Error {
|
|
|
746
958
|
}
|
|
747
959
|
};
|
|
748
960
|
var ENV_VAR_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
749
|
-
function
|
|
961
|
+
function interpolateTracked(value, env, path, out) {
|
|
750
962
|
if (typeof value === "string") {
|
|
751
|
-
|
|
963
|
+
let substitutions = 0;
|
|
964
|
+
const result = value.replace(ENV_VAR_PATTERN, (_match, varName) => {
|
|
752
965
|
const envValue = env[varName];
|
|
753
966
|
if (envValue === void 0) {
|
|
754
967
|
throw new ConfigError(`Environment variable "${varName}" is not set`);
|
|
755
968
|
}
|
|
969
|
+
substitutions += 1;
|
|
756
970
|
return envValue;
|
|
757
971
|
});
|
|
972
|
+
if (substitutions > 0 && out !== void 0) out.push(path.join("."));
|
|
973
|
+
return result;
|
|
758
974
|
}
|
|
759
975
|
if (Array.isArray(value)) {
|
|
760
|
-
return value.map((item) =>
|
|
976
|
+
return value.map((item, index) => interpolateTracked(item, env, [...path, String(index)], out));
|
|
761
977
|
}
|
|
762
978
|
if (value !== null && typeof value === "object") {
|
|
763
979
|
return Object.fromEntries(
|
|
764
980
|
Object.entries(value).map(([k, v]) => [
|
|
765
981
|
k,
|
|
766
|
-
|
|
982
|
+
interpolateTracked(v, env, [...path, k], out)
|
|
767
983
|
])
|
|
768
984
|
);
|
|
769
985
|
}
|
|
770
986
|
return value;
|
|
771
987
|
}
|
|
772
|
-
async function
|
|
773
|
-
let
|
|
988
|
+
async function loadConfigWithMeta(filePath, env) {
|
|
989
|
+
let bytes;
|
|
774
990
|
try {
|
|
775
|
-
|
|
991
|
+
bytes = await readFile(filePath);
|
|
776
992
|
} catch {
|
|
777
993
|
throw new ConfigError(`Cannot read config file: ${filePath}`);
|
|
778
994
|
}
|
|
995
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
996
|
+
const raw = bytes.toString("utf-8");
|
|
779
997
|
let parsed;
|
|
780
998
|
try {
|
|
781
999
|
parsed = yaml.load(raw);
|
|
@@ -783,7 +1001,8 @@ async function loadConfig(filePath, env) {
|
|
|
783
1001
|
const message = err instanceof Error ? err.message : String(err);
|
|
784
1002
|
throw new ConfigError(`YAML parse error in ${filePath}: ${message}`);
|
|
785
1003
|
}
|
|
786
|
-
const
|
|
1004
|
+
const interpolatedPaths = [];
|
|
1005
|
+
const interpolated = interpolateTracked(parsed, env ?? process.env, [], interpolatedPaths);
|
|
787
1006
|
const result = helioConfigSchema.safeParse(interpolated);
|
|
788
1007
|
if (!result.success) {
|
|
789
1008
|
const details = formatZodErrors(result.error).map(
|
|
@@ -795,7 +1014,10 @@ async function loadConfig(filePath, env) {
|
|
|
795
1014
|
details
|
|
796
1015
|
);
|
|
797
1016
|
}
|
|
798
|
-
return result.data;
|
|
1017
|
+
return { config: result.data, sha256, interpolatedPaths };
|
|
1018
|
+
}
|
|
1019
|
+
async function loadConfig(filePath, env) {
|
|
1020
|
+
return (await loadConfigWithMeta(filePath, env)).config;
|
|
799
1021
|
}
|
|
800
1022
|
|
|
801
1023
|
// src/config/watcher.ts
|
|
@@ -904,7 +1126,8 @@ function compileMatch(match, ruleIndex, ruleName) {
|
|
|
904
1126
|
...match.environment !== void 0 && { environment: match.environment },
|
|
905
1127
|
...match.metadata !== void 0 && {
|
|
906
1128
|
metadata: flattenMetadataConditions(match.metadata, ruleIndex, ruleName)
|
|
907
|
-
}
|
|
1129
|
+
},
|
|
1130
|
+
...match.upstreams !== void 0 && { upstreams: [...match.upstreams] }
|
|
908
1131
|
};
|
|
909
1132
|
}
|
|
910
1133
|
function compileToolMatcher(pattern, ruleIndex, ruleName) {
|
|
@@ -1107,6 +1330,9 @@ function compileContributor(contributor, budgetName, index) {
|
|
|
1107
1330
|
) : void 0;
|
|
1108
1331
|
return {
|
|
1109
1332
|
match: { tool, ...input !== void 0 && { input } },
|
|
1333
|
+
...contributor.match.upstreams !== void 0 && {
|
|
1334
|
+
upstreams: [...contributor.match.upstreams]
|
|
1335
|
+
},
|
|
1110
1336
|
field: contributor.field
|
|
1111
1337
|
};
|
|
1112
1338
|
}
|
|
@@ -1371,6 +1597,8 @@ function buildStandardRequestHeaders(method, params) {
|
|
|
1371
1597
|
}
|
|
1372
1598
|
|
|
1373
1599
|
// src/upstream/merge-headers.ts
|
|
1600
|
+
var UPSTREAM_POST_ACCEPT = "application/json, text/event-stream";
|
|
1601
|
+
var UPSTREAM_SSE_CONNECT_ACCEPT = "text/event-stream";
|
|
1374
1602
|
function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
|
|
1375
1603
|
const out = {};
|
|
1376
1604
|
const apply = (headers) => {
|
|
@@ -1384,6 +1612,11 @@ function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
|
|
|
1384
1612
|
return out;
|
|
1385
1613
|
}
|
|
1386
1614
|
|
|
1615
|
+
// src/util/log-label.ts
|
|
1616
|
+
function helioLogTag(upstreamName) {
|
|
1617
|
+
return upstreamName ? `[helio][${upstreamName}]` : "[helio]";
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1387
1620
|
// src/upstream/connection-error.ts
|
|
1388
1621
|
var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
|
|
1389
1622
|
var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
|
|
@@ -1419,7 +1652,7 @@ function describeUnreachableUpstream(error, url) {
|
|
|
1419
1652
|
}
|
|
1420
1653
|
const codeSuffix = code ? ` (${code})` : "";
|
|
1421
1654
|
return new Error(
|
|
1422
|
-
`Upstream MCP server at ${url} is unreachable${codeSuffix} \u2014 is it running? Helio proxies an existing MCP server: set upstream.url in helio.yaml to a reachable server, or start the server it points at. See ${UPSTREAM_DOCS_URL}`
|
|
1655
|
+
`Upstream MCP server at ${url} is unreachable${codeSuffix} \u2014 is it running? Helio proxies an existing MCP server: set upstream.url (or upstreams[].url) in helio.yaml to a reachable server, or start the server it points at. See ${UPSTREAM_DOCS_URL}`
|
|
1423
1656
|
);
|
|
1424
1657
|
}
|
|
1425
1658
|
|
|
@@ -1526,11 +1759,13 @@ var UpstreamSessionManager = class {
|
|
|
1526
1759
|
inflight;
|
|
1527
1760
|
inflightProbe;
|
|
1528
1761
|
probeBackoffUntil = 0;
|
|
1762
|
+
logTag;
|
|
1529
1763
|
constructor(options) {
|
|
1530
1764
|
this.url = options.url;
|
|
1531
1765
|
this.staticHeaders = options.staticHeaders;
|
|
1532
1766
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
|
|
1533
1767
|
this.pin = options.protocolVersion ?? "auto";
|
|
1768
|
+
this.logTag = helioLogTag(options.upstreamName);
|
|
1534
1769
|
}
|
|
1535
1770
|
/** Return the internal session, establishing it once if needed. */
|
|
1536
1771
|
ensureInternalSession() {
|
|
@@ -1662,7 +1897,7 @@ var UpstreamSessionManager = class {
|
|
|
1662
1897
|
this.capture = void 0;
|
|
1663
1898
|
this.probeBackoffUntil = Date.now() + ERA_PROBE_BACKOFF_MS;
|
|
1664
1899
|
console.error(
|
|
1665
|
-
|
|
1900
|
+
`${this.logTag} Upstream MCP era cleared: ${door}; relays presume legacy and re-probing is throttled for ${String(ERA_PROBE_BACKOFF_MS / 1e3)}s`
|
|
1666
1901
|
);
|
|
1667
1902
|
}
|
|
1668
1903
|
/** Convert a fetch failure into an actionable error for the given step. */
|
|
@@ -1706,7 +1941,7 @@ var UpstreamSessionManager = class {
|
|
|
1706
1941
|
if (this.era === era) return;
|
|
1707
1942
|
this.era = era;
|
|
1708
1943
|
console.error(
|
|
1709
|
-
era === "modern" ?
|
|
1944
|
+
era === "modern" ? `${this.logTag} Upstream MCP era detected: modern (${HELIO_MCP_MODERN_PROTOCOL_VERSION}, via server/discover)` : `${this.logTag} Upstream MCP era detected: legacy (initialize handshake)`
|
|
1710
1945
|
);
|
|
1711
1946
|
}
|
|
1712
1947
|
/** A modern upstream neither mints nor echoes session ids — nothing to hold. */
|
|
@@ -1730,7 +1965,7 @@ var UpstreamSessionManager = class {
|
|
|
1730
1965
|
const headers = mergeUpstreamHeaders(
|
|
1731
1966
|
{
|
|
1732
1967
|
"content-type": "application/json",
|
|
1733
|
-
accept:
|
|
1968
|
+
accept: UPSTREAM_POST_ACCEPT,
|
|
1734
1969
|
"mcp-protocol-version": HELIO_MCP_MODERN_PROTOCOL_VERSION,
|
|
1735
1970
|
"mcp-method": "server/discover"
|
|
1736
1971
|
},
|
|
@@ -1739,6 +1974,7 @@ var UpstreamSessionManager = class {
|
|
|
1739
1974
|
);
|
|
1740
1975
|
headers["mcp-method"] = "server/discover";
|
|
1741
1976
|
delete headers["mcp-name"];
|
|
1977
|
+
headers["accept"] = UPSTREAM_POST_ACCEPT;
|
|
1742
1978
|
const probeBody = {
|
|
1743
1979
|
jsonrpc: "2.0",
|
|
1744
1980
|
id: ERA_PROBE_REQUEST_ID,
|
|
@@ -1811,13 +2047,14 @@ var UpstreamSessionManager = class {
|
|
|
1811
2047
|
const headers = mergeUpstreamHeaders(
|
|
1812
2048
|
{
|
|
1813
2049
|
"content-type": "application/json",
|
|
1814
|
-
accept:
|
|
2050
|
+
accept: UPSTREAM_POST_ACCEPT
|
|
1815
2051
|
},
|
|
1816
2052
|
{},
|
|
1817
2053
|
this.staticHeaders
|
|
1818
2054
|
);
|
|
1819
2055
|
delete headers["mcp-method"];
|
|
1820
2056
|
delete headers["mcp-name"];
|
|
2057
|
+
headers["accept"] = UPSTREAM_POST_ACCEPT;
|
|
1821
2058
|
const initBody = {
|
|
1822
2059
|
jsonrpc: "2.0",
|
|
1823
2060
|
id: 0,
|
|
@@ -2431,6 +2668,7 @@ function createSseRoute(forwarder, options = {}) {
|
|
|
2431
2668
|
const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
|
|
2432
2669
|
const sessionIdentity = options.session ?? DEFAULT_SESSION_IDENTITY;
|
|
2433
2670
|
const maxConcurrentSessions = options.maxConcurrentSessions ?? MAX_CONCURRENT_SESSIONS;
|
|
2671
|
+
const routeLabel = options.routeLabel ?? "/sse";
|
|
2434
2672
|
let refusalCount = 0;
|
|
2435
2673
|
let lastRefusalLogAt = null;
|
|
2436
2674
|
const logRefusal = () => {
|
|
@@ -2439,7 +2677,7 @@ function createSseRoute(forwarder, options = {}) {
|
|
|
2439
2677
|
if (lastRefusalLogAt !== null && now - lastRefusalLogAt < REFUSAL_LOG_WINDOW_MS) return;
|
|
2440
2678
|
lastRefusalLogAt = now;
|
|
2441
2679
|
console.error(
|
|
2442
|
-
`[helio]
|
|
2680
|
+
`[helio] ${routeLabel} at session cap (${String(maxConcurrentSessions)}); refusing new streams (${String(refusalCount)} refusals so far).`
|
|
2443
2681
|
);
|
|
2444
2682
|
};
|
|
2445
2683
|
app.use("*", createOriginGuard(options.allowedOrigins ?? []));
|
|
@@ -2645,6 +2883,11 @@ function createServerHandle(server) {
|
|
|
2645
2883
|
};
|
|
2646
2884
|
}
|
|
2647
2885
|
function createApp(config, forwarder, options) {
|
|
2886
|
+
if (isNamedConfig(config)) {
|
|
2887
|
+
throw new Error(
|
|
2888
|
+
"createApp serves a single-upstream (upstream:) config only. Named multi-upstream configs are composed by createMultiApp."
|
|
2889
|
+
);
|
|
2890
|
+
}
|
|
2648
2891
|
const app = new Hono3();
|
|
2649
2892
|
const forwardHeadersAllowlist = config.upstream.forward_headers;
|
|
2650
2893
|
const allowedOrigins = config.listen.allowed_origins;
|
|
@@ -2665,6 +2908,77 @@ function createApp(config, forwarder, options) {
|
|
|
2665
2908
|
}
|
|
2666
2909
|
return app;
|
|
2667
2910
|
}
|
|
2911
|
+
function createMultiApp(config, forwarders, options) {
|
|
2912
|
+
if (!isNamedConfig(config)) {
|
|
2913
|
+
throw new Error(
|
|
2914
|
+
"createMultiApp composes a named multi-upstream (upstreams:) config only. Singular configs are served by createApp."
|
|
2915
|
+
);
|
|
2916
|
+
}
|
|
2917
|
+
const doors = [];
|
|
2918
|
+
const missing = [];
|
|
2919
|
+
for (const entry of config.upstreams) {
|
|
2920
|
+
const forwarder = forwarders[entry.name];
|
|
2921
|
+
if (forwarder === void 0) missing.push(entry.name);
|
|
2922
|
+
else doors.push({ entry, forwarder });
|
|
2923
|
+
}
|
|
2924
|
+
const configured = new Set(config.upstreams.map((entry) => entry.name));
|
|
2925
|
+
const unexpected = Object.keys(forwarders).filter((name) => !configured.has(name));
|
|
2926
|
+
if (missing.length > 0 || unexpected.length > 0) {
|
|
2927
|
+
throw new Error(
|
|
2928
|
+
`createMultiApp forwarders must match the configured upstream names exactly \u2014 missing: [${missing.join(", ")}], unexpected: [${unexpected.join(", ")}].`
|
|
2929
|
+
);
|
|
2930
|
+
}
|
|
2931
|
+
const app = new Hono3();
|
|
2932
|
+
const allowedOrigins = config.listen.allowed_origins;
|
|
2933
|
+
const session = compileSessionIdentity(config.session);
|
|
2934
|
+
app.get("/healthz", (c) => c.json({ status: "ok" }));
|
|
2935
|
+
for (const { entry, forwarder } of doors) {
|
|
2936
|
+
const name = entry.name;
|
|
2937
|
+
app.route(
|
|
2938
|
+
`/mcp/${name}`,
|
|
2939
|
+
createStreamableHttpRoute(forwarder, {
|
|
2940
|
+
forwardHeadersAllowlist: entry.forward_headers,
|
|
2941
|
+
allowedOrigins,
|
|
2942
|
+
session,
|
|
2943
|
+
onHeaderMismatch: options?.onHeaderMismatch ? (rejection) => options.onHeaderMismatch?.(rejection, name) : void 0
|
|
2944
|
+
})
|
|
2945
|
+
);
|
|
2946
|
+
app.route(
|
|
2947
|
+
`/sse/${name}`,
|
|
2948
|
+
createSseRoute(forwarder, {
|
|
2949
|
+
forwardHeadersAllowlist: entry.forward_headers,
|
|
2950
|
+
allowedOrigins,
|
|
2951
|
+
session,
|
|
2952
|
+
routeLabel: `/sse/${name}`,
|
|
2953
|
+
maxConcurrentSessions: options?.sse?.maxConcurrentSessions
|
|
2954
|
+
})
|
|
2955
|
+
);
|
|
2956
|
+
}
|
|
2957
|
+
if (options?.slackActionApp) {
|
|
2958
|
+
app.route("/slack/actions", options.slackActionApp);
|
|
2959
|
+
}
|
|
2960
|
+
app.all(
|
|
2961
|
+
"/mcp/*",
|
|
2962
|
+
(c) => c.json(
|
|
2963
|
+
makeJsonRpcErrorWithoutId(
|
|
2964
|
+
INVALID_REQUEST,
|
|
2965
|
+
"No MCP endpoint answers this request: this Helio serves named upstreams at /mcp/<name>."
|
|
2966
|
+
),
|
|
2967
|
+
404
|
|
2968
|
+
)
|
|
2969
|
+
);
|
|
2970
|
+
app.all(
|
|
2971
|
+
"/sse/*",
|
|
2972
|
+
(c) => c.json(
|
|
2973
|
+
makeJsonRpcErrorWithoutId(
|
|
2974
|
+
INVALID_REQUEST,
|
|
2975
|
+
"No MCP endpoint answers this request: this Helio serves named upstreams at /sse/<name>."
|
|
2976
|
+
),
|
|
2977
|
+
404
|
|
2978
|
+
)
|
|
2979
|
+
);
|
|
2980
|
+
return app;
|
|
2981
|
+
}
|
|
2668
2982
|
function startServer(app, config) {
|
|
2669
2983
|
const server = serve({
|
|
2670
2984
|
fetch: app.fetch,
|
|
@@ -2718,7 +3032,8 @@ var StreamableHttpForwarder = class {
|
|
|
2718
3032
|
url: this.url,
|
|
2719
3033
|
staticHeaders: this.staticHeaders,
|
|
2720
3034
|
requestTimeoutMs: this.requestTimeoutMs,
|
|
2721
|
-
protocolVersion: options.protocolVersion
|
|
3035
|
+
protocolVersion: options.protocolVersion,
|
|
3036
|
+
upstreamName: options.upstreamName
|
|
2722
3037
|
});
|
|
2723
3038
|
}
|
|
2724
3039
|
/** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
|
|
@@ -2891,11 +3206,15 @@ var StreamableHttpForwarder = class {
|
|
|
2891
3206
|
const headers = mergeUpstreamHeaders(
|
|
2892
3207
|
{
|
|
2893
3208
|
"content-type": "application/json",
|
|
2894
|
-
accept:
|
|
3209
|
+
accept: UPSTREAM_POST_ACCEPT
|
|
2895
3210
|
},
|
|
2896
3211
|
request.headers ?? {},
|
|
2897
3212
|
this.staticHeaders
|
|
2898
3213
|
);
|
|
3214
|
+
headers["content-type"] = "application/json";
|
|
3215
|
+
delete headers["content-length"];
|
|
3216
|
+
headers["accept"] = UPSTREAM_POST_ACCEPT;
|
|
3217
|
+
delete headers["mcp-session-id"];
|
|
2899
3218
|
if (session.sessionId) headers["mcp-session-id"] = session.sessionId;
|
|
2900
3219
|
if (modern) {
|
|
2901
3220
|
delete headers["mcp-session-id"];
|
|
@@ -3070,13 +3389,16 @@ var SseUpstreamForwarder = class {
|
|
|
3070
3389
|
connect() {
|
|
3071
3390
|
const controller = new AbortController();
|
|
3072
3391
|
this.abortController = controller;
|
|
3392
|
+
const headers = mergeUpstreamHeaders(
|
|
3393
|
+
{ accept: UPSTREAM_SSE_CONNECT_ACCEPT },
|
|
3394
|
+
{},
|
|
3395
|
+
this.staticHeaders
|
|
3396
|
+
);
|
|
3397
|
+
headers["accept"] = UPSTREAM_SSE_CONNECT_ACCEPT;
|
|
3073
3398
|
return new Promise((resolve, reject) => {
|
|
3074
3399
|
let resolved = false;
|
|
3075
3400
|
fetch(this.url, {
|
|
3076
|
-
headers
|
|
3077
|
-
accept: "text/event-stream",
|
|
3078
|
-
...this.staticHeaders
|
|
3079
|
-
},
|
|
3401
|
+
headers,
|
|
3080
3402
|
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(this.connectTimeoutMs)])
|
|
3081
3403
|
}).then((res) => {
|
|
3082
3404
|
if (!res.ok) {
|
|
@@ -3128,9 +3450,12 @@ var SseUpstreamForwarder = class {
|
|
|
3128
3450
|
request.headers ?? {},
|
|
3129
3451
|
this.staticHeaders
|
|
3130
3452
|
);
|
|
3453
|
+
headers["content-type"] = "application/json";
|
|
3454
|
+
delete headers["content-length"];
|
|
3131
3455
|
delete headers["mcp-method"];
|
|
3132
3456
|
delete headers["mcp-name"];
|
|
3133
3457
|
delete headers["mcp-session-id"];
|
|
3458
|
+
delete headers["accept"];
|
|
3134
3459
|
if (request.transportSessionId) {
|
|
3135
3460
|
headers["mcp-session-id"] = request.transportSessionId;
|
|
3136
3461
|
}
|
|
@@ -3297,6 +3622,7 @@ var StdioForwarder = class {
|
|
|
3297
3622
|
maxRetries;
|
|
3298
3623
|
retryDelayMs;
|
|
3299
3624
|
pending;
|
|
3625
|
+
logTag;
|
|
3300
3626
|
child = null;
|
|
3301
3627
|
buffer = "";
|
|
3302
3628
|
retryCount = 0;
|
|
@@ -3308,6 +3634,7 @@ var StdioForwarder = class {
|
|
|
3308
3634
|
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
3309
3635
|
this.retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
3310
3636
|
this.pending = new PendingRequests(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS2);
|
|
3637
|
+
this.logTag = helioLogTag(options.upstreamName);
|
|
3311
3638
|
}
|
|
3312
3639
|
/** Spawn the child process and set up event handlers. */
|
|
3313
3640
|
start() {
|
|
@@ -3436,7 +3763,9 @@ var StdioForwarder = class {
|
|
|
3436
3763
|
}, this.retryDelayMs);
|
|
3437
3764
|
} else {
|
|
3438
3765
|
this.dead = true;
|
|
3439
|
-
console.error(
|
|
3766
|
+
console.error(
|
|
3767
|
+
`${this.logTag} Stdio forwarder: max retries (${String(this.maxRetries)}) exceeded`
|
|
3768
|
+
);
|
|
3440
3769
|
this.pending.rejectAll(new Error("stdio forwarder is dead (max retries exceeded)"));
|
|
3441
3770
|
}
|
|
3442
3771
|
}
|
|
@@ -3511,6 +3840,10 @@ function matchEnvironment(required, ctx) {
|
|
|
3511
3840
|
if (ctx.environment === void 0) return false;
|
|
3512
3841
|
return ctx.environment === required;
|
|
3513
3842
|
}
|
|
3843
|
+
function matchUpstreams(required, ctx) {
|
|
3844
|
+
if (ctx.upstream === void 0) return false;
|
|
3845
|
+
return required.includes(ctx.upstream);
|
|
3846
|
+
}
|
|
3514
3847
|
function matchMetadata(conditions, ctx) {
|
|
3515
3848
|
if (conditions.length === 0) return true;
|
|
3516
3849
|
if (ctx.metadata === void 0) return false;
|
|
@@ -3536,6 +3869,7 @@ function matchRule(rule, ctx) {
|
|
|
3536
3869
|
if (match.input !== void 0 && !matchInput(match.input, ctx)) return false;
|
|
3537
3870
|
if (match.environment !== void 0 && !matchEnvironment(match.environment, ctx)) return false;
|
|
3538
3871
|
if (match.metadata !== void 0 && !matchMetadata(match.metadata, ctx)) return false;
|
|
3872
|
+
if (match.upstreams !== void 0 && !matchUpstreams(match.upstreams, ctx)) return false;
|
|
3539
3873
|
return true;
|
|
3540
3874
|
}
|
|
3541
3875
|
|
|
@@ -3690,7 +4024,8 @@ function decide(input) {
|
|
|
3690
4024
|
annotations,
|
|
3691
4025
|
toolArguments,
|
|
3692
4026
|
environment,
|
|
3693
|
-
metadata
|
|
4027
|
+
metadata,
|
|
4028
|
+
upstream: input.upstream
|
|
3694
4029
|
});
|
|
3695
4030
|
if (driftEvent && driftMode === "log") {
|
|
3696
4031
|
const currentDecision = evaluatePolicy(policy, {
|
|
@@ -3698,7 +4033,8 @@ function decide(input) {
|
|
|
3698
4033
|
annotations: input.currentAnnotations,
|
|
3699
4034
|
toolArguments,
|
|
3700
4035
|
environment,
|
|
3701
|
-
metadata
|
|
4036
|
+
metadata,
|
|
4037
|
+
upstream: input.upstream
|
|
3702
4038
|
});
|
|
3703
4039
|
decision = stricterDecision(decision, currentDecision);
|
|
3704
4040
|
}
|
|
@@ -4311,394 +4647,83 @@ function buildBudgetApprovalTimeoutFeedback(decision, breaches, timeoutMs) {
|
|
|
4311
4647
|
};
|
|
4312
4648
|
}
|
|
4313
4649
|
|
|
4314
|
-
// src/policy/
|
|
4315
|
-
function
|
|
4650
|
+
// src/policy/bucket-key.ts
|
|
4651
|
+
function ruleBucketKey(baseKey, ruleIndex) {
|
|
4316
4652
|
return `${baseKey}:rule:${String(ruleIndex)}`;
|
|
4317
4653
|
}
|
|
4318
4654
|
var RULE_SUFFIX_RE = /:rule:(\d+)$/;
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4655
|
+
function parseRuleIndex(key) {
|
|
4656
|
+
const match = RULE_SUFFIX_RE.exec(key);
|
|
4657
|
+
return match ? Number(match[1]) : void 0;
|
|
4658
|
+
}
|
|
4659
|
+
function toolLimitKey(toolName, upstreamName) {
|
|
4660
|
+
return upstreamName ? `upstream:${upstreamName}:tool:${toolName}` : `tool:${toolName}`;
|
|
4661
|
+
}
|
|
4662
|
+
|
|
4663
|
+
// src/policy/governed-forwarder.ts
|
|
4664
|
+
var POLICY_DENIED = -32001;
|
|
4665
|
+
function blocked(result) {
|
|
4666
|
+
return { proceed: false, result, approvalWaitMs: 0 };
|
|
4667
|
+
}
|
|
4668
|
+
function budgetChainBlock(entry, kind) {
|
|
4669
|
+
return {
|
|
4670
|
+
name: entry.budget.name,
|
|
4671
|
+
bucket_key: entry.bucketKey,
|
|
4672
|
+
allowed: entry.allowed,
|
|
4673
|
+
amount: entry.amount,
|
|
4674
|
+
spent: entry.spent,
|
|
4675
|
+
limit: entry.budget.limit,
|
|
4676
|
+
remaining: entry.remaining,
|
|
4677
|
+
currency: entry.budget.currency,
|
|
4678
|
+
...kind ? { kind } : {},
|
|
4679
|
+
...entry.stale ? { stale: true } : {}
|
|
4680
|
+
};
|
|
4681
|
+
}
|
|
4682
|
+
var GovernedForwarder = class {
|
|
4683
|
+
inner;
|
|
4684
|
+
policy;
|
|
4685
|
+
environment;
|
|
4686
|
+
session;
|
|
4687
|
+
auditWriter;
|
|
4688
|
+
evidenceStore;
|
|
4689
|
+
approvalRouter;
|
|
4690
|
+
rateLimiter;
|
|
4691
|
+
spendLimiter;
|
|
4692
|
+
budgetEngine;
|
|
4693
|
+
upstreamName;
|
|
4694
|
+
annotationCache = new ToolAnnotationCache();
|
|
4695
|
+
agentKeyWarned = false;
|
|
4696
|
+
senderKeyWarned = false;
|
|
4697
|
+
constructor(inner, policy, options) {
|
|
4698
|
+
this.inner = inner;
|
|
4699
|
+
this.policy = policy;
|
|
4700
|
+
this.environment = options?.environment;
|
|
4701
|
+
this.auditWriter = options?.auditWriter;
|
|
4702
|
+
this.evidenceStore = options?.evidenceStore;
|
|
4703
|
+
this.approvalRouter = options?.approvalRouter;
|
|
4704
|
+
this.rateLimiter = options?.rateLimiter;
|
|
4705
|
+
this.spendLimiter = options?.spendLimiter;
|
|
4706
|
+
this.budgetEngine = options?.budgetEngine;
|
|
4707
|
+
this.upstreamName = options?.upstreamName;
|
|
4708
|
+
this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
|
|
4709
|
+
if (this.evidenceStore) {
|
|
4710
|
+
this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
|
|
4336
4711
|
}
|
|
4337
4712
|
}
|
|
4338
|
-
// -------------------------------------------------------------------------
|
|
4339
|
-
// Core operations
|
|
4340
|
-
// -------------------------------------------------------------------------
|
|
4341
4713
|
/**
|
|
4342
|
-
*
|
|
4714
|
+
* Swap the compiled policy atomically and reconcile limit bucket state
|
|
4715
|
+
* against the new configuration.
|
|
4343
4716
|
*
|
|
4344
|
-
*
|
|
4345
|
-
*
|
|
4346
|
-
*
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
const activeEntries = existing ? existing.entries.filter((e) => e.timestamp > windowStart) : [];
|
|
4355
|
-
const currentSpend2 = activeEntries.reduce((sum, e) => sum + e.amount, 0);
|
|
4356
|
-
const oldest = activeEntries[0];
|
|
4357
|
-
return {
|
|
4358
|
-
allowed: false,
|
|
4359
|
-
currentSpend: currentSpend2,
|
|
4360
|
-
limit,
|
|
4361
|
-
windowMs,
|
|
4362
|
-
resetAtMs: oldest ? oldest.timestamp + windowMs : 0,
|
|
4363
|
-
reason: "invalid_amount"
|
|
4364
|
-
};
|
|
4365
|
-
}
|
|
4366
|
-
let bucket = this.buckets.get(key);
|
|
4367
|
-
if (!bucket) {
|
|
4368
|
-
bucket = { entries: [], limit, currency: "", windowMs };
|
|
4369
|
-
this.buckets.set(key, bucket);
|
|
4370
|
-
}
|
|
4371
|
-
bucket.limit = limit;
|
|
4372
|
-
bucket.windowMs = windowMs;
|
|
4373
|
-
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
4374
|
-
const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
|
|
4375
|
-
if (currentSpend + amount > limit) {
|
|
4376
|
-
const oldest = bucket.entries[0];
|
|
4377
|
-
return {
|
|
4378
|
-
allowed: false,
|
|
4379
|
-
currentSpend,
|
|
4380
|
-
limit,
|
|
4381
|
-
windowMs,
|
|
4382
|
-
resetAtMs: oldest ? oldest.timestamp + windowMs : 0
|
|
4383
|
-
};
|
|
4384
|
-
}
|
|
4385
|
-
bucket.entries.push({ timestamp: now, amount });
|
|
4386
|
-
const newSpend = currentSpend + amount;
|
|
4387
|
-
const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
|
|
4388
|
-
if (this.onWarning && newSpend / limit >= this.warningThreshold) {
|
|
4389
|
-
this.safeWarn({
|
|
4390
|
-
key,
|
|
4391
|
-
current_spend: newSpend,
|
|
4392
|
-
limit,
|
|
4393
|
-
currency: bucket.currency,
|
|
4394
|
-
window_ms: windowMs,
|
|
4395
|
-
reset_at_ms: resetAtMs
|
|
4396
|
-
});
|
|
4397
|
-
}
|
|
4398
|
-
return {
|
|
4399
|
-
allowed: true,
|
|
4400
|
-
currentSpend: newSpend,
|
|
4401
|
-
limit,
|
|
4402
|
-
windowMs,
|
|
4403
|
-
resetAtMs
|
|
4404
|
-
};
|
|
4405
|
-
}
|
|
4406
|
-
/**
|
|
4407
|
-
* Unconditionally record a spend against the limit.
|
|
4408
|
-
*
|
|
4409
|
-
* Unlike check(), this always appends the amount — even when it pushes the
|
|
4410
|
-
* window past the limit — because the spend it represents has already been
|
|
4411
|
-
* incurred. The sideband peeks at /evaluate and commits here at /audit once
|
|
4412
|
-
* the external call ran (issue #12, D3).
|
|
4413
|
-
*
|
|
4414
|
-
* Throws on a negative or non-finite amount: such amounts are rejected at
|
|
4415
|
-
* /evaluate, so one reaching record() is a logic bug we surface loudly rather
|
|
4416
|
-
* than silently corrupt the sliding-window sum. Warnings fire only while the
|
|
4417
|
-
* post-append spend stays within the limit (parity with check()).
|
|
4418
|
-
*/
|
|
4419
|
-
record(params) {
|
|
4420
|
-
const { key, amount, limit, windowMs } = params;
|
|
4421
|
-
if (!Number.isFinite(amount) || amount < 0) {
|
|
4422
|
-
throw new RangeError(
|
|
4423
|
-
`SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
|
|
4424
|
-
);
|
|
4425
|
-
}
|
|
4426
|
-
const now = this.now();
|
|
4427
|
-
const windowStart = now - windowMs;
|
|
4428
|
-
let bucket = this.buckets.get(key);
|
|
4429
|
-
if (!bucket) {
|
|
4430
|
-
bucket = { entries: [], limit, currency: "", windowMs };
|
|
4431
|
-
this.buckets.set(key, bucket);
|
|
4432
|
-
}
|
|
4433
|
-
bucket.limit = limit;
|
|
4434
|
-
bucket.windowMs = windowMs;
|
|
4435
|
-
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
4436
|
-
bucket.entries.push({ timestamp: now, amount });
|
|
4437
|
-
const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
|
|
4438
|
-
const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
|
|
4439
|
-
if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
|
|
4440
|
-
this.safeWarn({
|
|
4441
|
-
key,
|
|
4442
|
-
current_spend: currentSpend,
|
|
4443
|
-
limit,
|
|
4444
|
-
currency: bucket.currency,
|
|
4445
|
-
window_ms: windowMs,
|
|
4446
|
-
reset_at_ms: resetAtMs
|
|
4447
|
-
});
|
|
4448
|
-
}
|
|
4449
|
-
return {
|
|
4450
|
-
allowed: currentSpend <= limit,
|
|
4451
|
-
currentSpend,
|
|
4452
|
-
limit,
|
|
4453
|
-
windowMs,
|
|
4454
|
-
resetAtMs
|
|
4455
|
-
};
|
|
4456
|
-
}
|
|
4457
|
-
/**
|
|
4458
|
-
* Check the spend limit without recording the spend (non-destructive).
|
|
4459
|
-
*
|
|
4460
|
-
* Used by dry-run mode to determine what would happen without consuming
|
|
4461
|
-
* budget in the bucket.
|
|
4462
|
-
*/
|
|
4463
|
-
peek(params) {
|
|
4464
|
-
const { key, amount, limit, windowMs } = params;
|
|
4465
|
-
const now = this.now();
|
|
4466
|
-
const windowStart = now - windowMs;
|
|
4467
|
-
const bucket = this.buckets.get(key);
|
|
4468
|
-
if (!Number.isFinite(amount) || amount < 0) {
|
|
4469
|
-
const activeEntries2 = bucket ? bucket.entries.filter((e) => e.timestamp > windowStart) : [];
|
|
4470
|
-
const currentSpend2 = activeEntries2.reduce((sum, e) => sum + e.amount, 0);
|
|
4471
|
-
const oldest2 = activeEntries2[0];
|
|
4472
|
-
return {
|
|
4473
|
-
allowed: false,
|
|
4474
|
-
currentSpend: currentSpend2,
|
|
4475
|
-
limit,
|
|
4476
|
-
windowMs,
|
|
4477
|
-
resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0,
|
|
4478
|
-
reason: "invalid_amount"
|
|
4479
|
-
};
|
|
4480
|
-
}
|
|
4481
|
-
if (!bucket) {
|
|
4482
|
-
const wouldExceed = amount > limit;
|
|
4483
|
-
return {
|
|
4484
|
-
allowed: !wouldExceed,
|
|
4485
|
-
currentSpend: wouldExceed ? 0 : amount,
|
|
4486
|
-
limit,
|
|
4487
|
-
windowMs,
|
|
4488
|
-
resetAtMs: now + windowMs
|
|
4489
|
-
};
|
|
4490
|
-
}
|
|
4491
|
-
const activeEntries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
4492
|
-
const currentSpend = activeEntries.reduce((sum, e) => sum + e.amount, 0);
|
|
4493
|
-
if (currentSpend + amount > limit) {
|
|
4494
|
-
const oldest2 = activeEntries[0];
|
|
4495
|
-
return {
|
|
4496
|
-
allowed: false,
|
|
4497
|
-
currentSpend,
|
|
4498
|
-
limit,
|
|
4499
|
-
windowMs,
|
|
4500
|
-
resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0
|
|
4501
|
-
};
|
|
4502
|
-
}
|
|
4503
|
-
const newSpend = currentSpend + amount;
|
|
4504
|
-
const oldest = activeEntries[0];
|
|
4505
|
-
return {
|
|
4506
|
-
allowed: true,
|
|
4507
|
-
currentSpend: newSpend,
|
|
4508
|
-
limit,
|
|
4509
|
-
windowMs,
|
|
4510
|
-
resetAtMs: oldest ? oldest.timestamp + windowMs : now + windowMs
|
|
4511
|
-
};
|
|
4512
|
-
}
|
|
4513
|
-
/**
|
|
4514
|
-
* Set the display currency for a key. Called by the governed forwarder
|
|
4515
|
-
* after check() so dashboard reads include the currency label.
|
|
4516
|
-
*/
|
|
4517
|
-
setCurrency(key, currency) {
|
|
4518
|
-
const bucket = this.buckets.get(key);
|
|
4519
|
-
if (bucket) bucket.currency = currency;
|
|
4520
|
-
}
|
|
4521
|
-
// -------------------------------------------------------------------------
|
|
4522
|
-
// Read operations (for dashboard API)
|
|
4523
|
-
// -------------------------------------------------------------------------
|
|
4524
|
-
/** Get the current state of a single key. Returns undefined if not tracked. */
|
|
4525
|
-
getKeyState(key) {
|
|
4526
|
-
const bucket = this.buckets.get(key);
|
|
4527
|
-
if (!bucket) return void 0;
|
|
4528
|
-
const windowStart = this.now() - bucket.windowMs;
|
|
4529
|
-
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
4530
|
-
if (bucket.entries.length === 0) {
|
|
4531
|
-
this.buckets.delete(key);
|
|
4532
|
-
return void 0;
|
|
4533
|
-
}
|
|
4534
|
-
const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
|
|
4535
|
-
return {
|
|
4536
|
-
key,
|
|
4537
|
-
current_spend: currentSpend,
|
|
4538
|
-
limit: bucket.limit,
|
|
4539
|
-
currency: bucket.currency,
|
|
4540
|
-
window_ms: bucket.windowMs,
|
|
4541
|
-
reset_at_ms: (bucket.entries[0]?.timestamp ?? 0) + bucket.windowMs
|
|
4542
|
-
};
|
|
4543
|
-
}
|
|
4544
|
-
/** List all tracked keys with their current state. */
|
|
4545
|
-
listKeyStates() {
|
|
4546
|
-
const states = [];
|
|
4547
|
-
for (const key of [...this.buckets.keys()]) {
|
|
4548
|
-
const state = this.getKeyState(key);
|
|
4549
|
-
if (state) states.push(state);
|
|
4550
|
-
}
|
|
4551
|
-
return states;
|
|
4552
|
-
}
|
|
4553
|
-
// -------------------------------------------------------------------------
|
|
4554
|
-
// Maintenance
|
|
4555
|
-
// -------------------------------------------------------------------------
|
|
4556
|
-
/** Sweep all buckets: remove expired entries, delete empty buckets. */
|
|
4557
|
-
cleanup() {
|
|
4558
|
-
const now = this.now();
|
|
4559
|
-
for (const [key, bucket] of this.buckets) {
|
|
4560
|
-
const windowStart = now - bucket.windowMs;
|
|
4561
|
-
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
4562
|
-
if (bucket.entries.length === 0) {
|
|
4563
|
-
this.buckets.delete(key);
|
|
4564
|
-
}
|
|
4565
|
-
}
|
|
4566
|
-
}
|
|
4567
|
-
/** Clear all spend limit state. Called on policy hot-reload. */
|
|
4568
|
-
reset() {
|
|
4569
|
-
this.buckets.clear();
|
|
4570
|
-
}
|
|
4571
|
-
/**
|
|
4572
|
-
* Reconcile bucket state against a new policy's spend configuration.
|
|
4573
|
-
*
|
|
4574
|
-
* Walks every existing bucket and checks whether its last-seen
|
|
4575
|
-
* `{ limit, currency, windowMs }` tuple still appears in `validConfigs`.
|
|
4576
|
-
* Buckets whose config is unchanged are left untouched — cumulative spend
|
|
4577
|
-
* and elapsed-window progress are preserved across hot-reloads. Buckets
|
|
4578
|
-
* whose config is gone (rule changed or removed) are evicted so the next
|
|
4579
|
-
* check lazy-creates a fresh bucket under the new config.
|
|
4580
|
-
*
|
|
4581
|
-
* Keys built by {@link spendBucketKey} carry the owning rule's index, and
|
|
4582
|
-
* for those the tuple must match at THAT index (`config.ruleIndex`): a
|
|
4583
|
-
* reorder that shifts a spend rule's index evicts its old-index bucket
|
|
4584
|
-
* instead of leaving an orphan no rule reads again — or worse, letting
|
|
4585
|
-
* whatever rule now sits at that index adopt another rule's accrued spend.
|
|
4586
|
-
* Un-suffixed keys keep the tuple-anywhere match.
|
|
4587
|
-
*
|
|
4588
|
-
* Currency is part of the tuple because a USD→EUR switch is a meaningful
|
|
4589
|
-
* policy change — the same numeric limit buys a different amount of real
|
|
4590
|
-
* spend, so the bucket must reset. This replaces the old `reset()` call
|
|
4591
|
-
* on every hot-reload, which wiped all state even when the matching rule
|
|
4592
|
-
* was unchanged.
|
|
4593
|
-
*/
|
|
4594
|
-
reconcile(validConfigs) {
|
|
4595
|
-
const valid = /* @__PURE__ */ new Set();
|
|
4596
|
-
const byIndex = /* @__PURE__ */ new Map();
|
|
4597
|
-
for (const config of validConfigs) {
|
|
4598
|
-
const tuple = `${String(config.limit)}|${config.currency}|${String(config.windowMs)}`;
|
|
4599
|
-
if (config.ruleIndex === void 0) {
|
|
4600
|
-
valid.add(tuple);
|
|
4601
|
-
} else {
|
|
4602
|
-
byIndex.set(config.ruleIndex, tuple);
|
|
4603
|
-
}
|
|
4604
|
-
}
|
|
4605
|
-
for (const [key, bucket] of this.buckets) {
|
|
4606
|
-
const tuple = `${String(bucket.limit)}|${bucket.currency}|${String(bucket.windowMs)}`;
|
|
4607
|
-
const suffix = RULE_SUFFIX_RE.exec(key);
|
|
4608
|
-
const survives = suffix ? byIndex.get(Number(suffix[1])) === tuple : valid.has(tuple);
|
|
4609
|
-
if (!survives) {
|
|
4610
|
-
this.buckets.delete(key);
|
|
4611
|
-
}
|
|
4612
|
-
}
|
|
4613
|
-
}
|
|
4614
|
-
/** Stop the cleanup timer and mark as closed. */
|
|
4615
|
-
/**
|
|
4616
|
-
* Invoke the warning callback without letting a subscriber throw into the
|
|
4617
|
-
* limiter's caller: a warning fires after state has already mutated, and a
|
|
4618
|
-
* governed call must not be blocked (or double-charged on retry) by an
|
|
4619
|
-
* observability bug.
|
|
4620
|
-
*/
|
|
4621
|
-
safeWarn(state) {
|
|
4622
|
-
if (!this.onWarning) return;
|
|
4623
|
-
try {
|
|
4624
|
-
this.onWarning(state);
|
|
4625
|
-
} catch (err) {
|
|
4626
|
-
console.error("[helio] limit warning subscriber threw:", err);
|
|
4627
|
-
}
|
|
4628
|
-
}
|
|
4629
|
-
close() {
|
|
4630
|
-
if (this.closed) return;
|
|
4631
|
-
this.closed = true;
|
|
4632
|
-
if (this.timer) {
|
|
4633
|
-
clearInterval(this.timer);
|
|
4634
|
-
this.timer = null;
|
|
4635
|
-
}
|
|
4636
|
-
this.buckets.clear();
|
|
4637
|
-
}
|
|
4638
|
-
};
|
|
4639
|
-
|
|
4640
|
-
// src/policy/governed-forwarder.ts
|
|
4641
|
-
var POLICY_DENIED = -32001;
|
|
4642
|
-
function blocked(result) {
|
|
4643
|
-
return { proceed: false, result, approvalWaitMs: 0 };
|
|
4644
|
-
}
|
|
4645
|
-
function budgetChainBlock(entry, kind) {
|
|
4646
|
-
return {
|
|
4647
|
-
name: entry.budget.name,
|
|
4648
|
-
bucket_key: entry.bucketKey,
|
|
4649
|
-
allowed: entry.allowed,
|
|
4650
|
-
amount: entry.amount,
|
|
4651
|
-
spent: entry.spent,
|
|
4652
|
-
limit: entry.budget.limit,
|
|
4653
|
-
remaining: entry.remaining,
|
|
4654
|
-
currency: entry.budget.currency,
|
|
4655
|
-
...kind ? { kind } : {},
|
|
4656
|
-
...entry.stale ? { stale: true } : {}
|
|
4657
|
-
};
|
|
4658
|
-
}
|
|
4659
|
-
var GovernedForwarder = class {
|
|
4660
|
-
inner;
|
|
4661
|
-
policy;
|
|
4662
|
-
environment;
|
|
4663
|
-
session;
|
|
4664
|
-
auditWriter;
|
|
4665
|
-
evidenceStore;
|
|
4666
|
-
approvalRouter;
|
|
4667
|
-
rateLimiter;
|
|
4668
|
-
spendLimiter;
|
|
4669
|
-
budgetEngine;
|
|
4670
|
-
annotationCache = new ToolAnnotationCache();
|
|
4671
|
-
agentKeyWarned = false;
|
|
4672
|
-
senderKeyWarned = false;
|
|
4673
|
-
constructor(inner, policy, options) {
|
|
4674
|
-
this.inner = inner;
|
|
4675
|
-
this.policy = policy;
|
|
4676
|
-
this.environment = options?.environment;
|
|
4677
|
-
this.auditWriter = options?.auditWriter;
|
|
4678
|
-
this.evidenceStore = options?.evidenceStore;
|
|
4679
|
-
this.approvalRouter = options?.approvalRouter;
|
|
4680
|
-
this.rateLimiter = options?.rateLimiter;
|
|
4681
|
-
this.spendLimiter = options?.spendLimiter;
|
|
4682
|
-
this.budgetEngine = options?.budgetEngine;
|
|
4683
|
-
this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
|
|
4684
|
-
if (this.evidenceStore) {
|
|
4685
|
-
this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
|
|
4686
|
-
}
|
|
4687
|
-
}
|
|
4688
|
-
/**
|
|
4689
|
-
* Swap the compiled policy atomically and reconcile limit bucket state
|
|
4690
|
-
* against the new configuration.
|
|
4691
|
-
*
|
|
4692
|
-
* Rate and spend limit buckets are preserved when their underlying rule
|
|
4693
|
-
* config is unchanged — this is what makes a benign hot-reload (e.g. a
|
|
4694
|
-
* `vim :w` with no real edits, or a whitespace-only config change) safe:
|
|
4695
|
-
* operators do not get a surprise zero of their live rate/spend state
|
|
4696
|
-
* mid-window. Buckets whose config changed or whose rule was removed are
|
|
4697
|
-
* evicted by the limiters' `reconcile()` methods, so the next check
|
|
4698
|
-
* lazy-creates a fresh bucket under the new config.
|
|
4699
|
-
*
|
|
4700
|
-
* See `packages/proxy/src/policy/rate-limiter.ts` and `spend-limiter.ts`
|
|
4701
|
-
* for the per-bucket compare-and-evict semantics.
|
|
4717
|
+
* Rate and spend limit buckets are preserved when their underlying rule
|
|
4718
|
+
* config is unchanged — this is what makes a benign hot-reload (e.g. a
|
|
4719
|
+
* `vim :w` with no real edits, or a whitespace-only config change) safe:
|
|
4720
|
+
* operators do not get a surprise zero of their live rate/spend state
|
|
4721
|
+
* mid-window. Buckets whose config changed or whose rule was removed are
|
|
4722
|
+
* evicted by the limiters' `reconcile()` methods, so the next check
|
|
4723
|
+
* lazy-creates a fresh bucket under the new config.
|
|
4724
|
+
*
|
|
4725
|
+
* See `packages/proxy/src/policy/rate-limiter.ts` and `spend-limiter.ts`
|
|
4726
|
+
* for the per-bucket compare-and-evict semantics.
|
|
4702
4727
|
*/
|
|
4703
4728
|
updatePolicy(policy) {
|
|
4704
4729
|
this.policy = policy;
|
|
@@ -4710,7 +4735,13 @@ var GovernedForwarder = class {
|
|
|
4710
4735
|
for (const rule of policy.rules) {
|
|
4711
4736
|
const limits = rule.limits;
|
|
4712
4737
|
if (limits?.maxCalls !== void 0 && limits.windowMs !== void 0) {
|
|
4713
|
-
rateConfigs.push({
|
|
4738
|
+
rateConfigs.push({
|
|
4739
|
+
maxCalls: limits.maxCalls,
|
|
4740
|
+
windowMs: limits.windowMs,
|
|
4741
|
+
// Rate bucket keys are rule-discriminated (ruleBucketKey), so
|
|
4742
|
+
// reconcile must match tuples at the owning rule's index.
|
|
4743
|
+
ruleIndex: rule.index
|
|
4744
|
+
});
|
|
4714
4745
|
}
|
|
4715
4746
|
}
|
|
4716
4747
|
this.rateLimiter.reconcile(rateConfigs);
|
|
@@ -4724,7 +4755,7 @@ var GovernedForwarder = class {
|
|
|
4724
4755
|
limit: maxSpend.limit,
|
|
4725
4756
|
currency: maxSpend.currency,
|
|
4726
4757
|
windowMs: maxSpend.windowMs,
|
|
4727
|
-
// Spend bucket keys are rule-discriminated (
|
|
4758
|
+
// Spend bucket keys are rule-discriminated (ruleBucketKey), so
|
|
4728
4759
|
// reconcile must match tuples at the owning rule's index.
|
|
4729
4760
|
ruleIndex: rule.index
|
|
4730
4761
|
});
|
|
@@ -4870,7 +4901,8 @@ var GovernedForwarder = class {
|
|
|
4870
4901
|
origin: "mcp",
|
|
4871
4902
|
metadata: null,
|
|
4872
4903
|
// Drift is a cache event, not a request: no protocol claim exists.
|
|
4873
|
-
protocol_version: null
|
|
4904
|
+
protocol_version: null,
|
|
4905
|
+
upstream: this.upstreamName ?? null
|
|
4874
4906
|
});
|
|
4875
4907
|
}
|
|
4876
4908
|
async handleToolsCall(original) {
|
|
@@ -4914,7 +4946,8 @@ var GovernedForwarder = class {
|
|
|
4914
4946
|
evidenceStore: this.evidenceStore,
|
|
4915
4947
|
baselineAnnotations: this.annotationCache.get(toolName),
|
|
4916
4948
|
currentAnnotations: this.annotationCache.getCurrent(toolName),
|
|
4917
|
-
driftEvent: this.annotationCache.getDrift(toolName)
|
|
4949
|
+
driftEvent: this.annotationCache.getDrift(toolName),
|
|
4950
|
+
upstream: this.upstreamName
|
|
4918
4951
|
});
|
|
4919
4952
|
const auditRecordId = randomUUID2();
|
|
4920
4953
|
let result;
|
|
@@ -5057,6 +5090,8 @@ var GovernedForwarder = class {
|
|
|
5057
5090
|
tool_input: toolArguments ?? {},
|
|
5058
5091
|
matched_rule: decision.matchedRule,
|
|
5059
5092
|
session_id: request.session?.id ?? null,
|
|
5093
|
+
session_source: request.session?.source ?? null,
|
|
5094
|
+
upstream: this.upstreamName ?? null,
|
|
5060
5095
|
breached_budgets: gate.breachContexts,
|
|
5061
5096
|
approval: gate.approval
|
|
5062
5097
|
},
|
|
@@ -5204,8 +5239,9 @@ var GovernedForwarder = class {
|
|
|
5204
5239
|
toolName,
|
|
5205
5240
|
toolArguments,
|
|
5206
5241
|
sessionId: sessionGate.ok ? sessionGate.session : null,
|
|
5207
|
-
senderId: null
|
|
5242
|
+
senderId: null,
|
|
5208
5243
|
// adapter context; absent on the MCP path
|
|
5244
|
+
upstream: this.upstreamName ?? null
|
|
5209
5245
|
});
|
|
5210
5246
|
if (charges.length === 0 && failures.length === 0) return { kind: "proceed" };
|
|
5211
5247
|
const gated = gateBudgetCharges({ charges, failures }, sessionGate);
|
|
@@ -5351,7 +5387,8 @@ var GovernedForwarder = class {
|
|
|
5351
5387
|
record_kind: "tool_call",
|
|
5352
5388
|
origin: "mcp",
|
|
5353
5389
|
metadata: null,
|
|
5354
|
-
protocol_version: request.protocolVersion ?? null
|
|
5390
|
+
protocol_version: request.protocolVersion ?? null,
|
|
5391
|
+
upstream: this.upstreamName ?? null
|
|
5355
5392
|
});
|
|
5356
5393
|
}
|
|
5357
5394
|
return result;
|
|
@@ -5364,7 +5401,9 @@ var GovernedForwarder = class {
|
|
|
5364
5401
|
tool_name: toolName,
|
|
5365
5402
|
tool_input: toolArguments ?? {},
|
|
5366
5403
|
matched_rule: decision.matchedRule,
|
|
5367
|
-
session_id: request.session?.id ?? null
|
|
5404
|
+
session_id: request.session?.id ?? null,
|
|
5405
|
+
session_source: request.session?.source ?? null,
|
|
5406
|
+
upstream: this.upstreamName ?? null
|
|
5368
5407
|
},
|
|
5369
5408
|
request.signal
|
|
5370
5409
|
);
|
|
@@ -5446,8 +5485,9 @@ var GovernedForwarder = class {
|
|
|
5446
5485
|
}
|
|
5447
5486
|
handleRateLimit(request, decision, toolName) {
|
|
5448
5487
|
const limiter = this.rateLimiter;
|
|
5449
|
-
const
|
|
5450
|
-
|
|
5488
|
+
const matchedRule = decision.matchedRule;
|
|
5489
|
+
const limits = matchedRule?.limits;
|
|
5490
|
+
if (!matchedRule || !limits?.maxCalls || !limits.windowMs) {
|
|
5451
5491
|
const result = this.makePolicyMisconfiguredResult(
|
|
5452
5492
|
request,
|
|
5453
5493
|
decision,
|
|
@@ -5460,7 +5500,7 @@ var GovernedForwarder = class {
|
|
|
5460
5500
|
rateLimitResult: { allowed: false, current: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
|
|
5461
5501
|
};
|
|
5462
5502
|
}
|
|
5463
|
-
let
|
|
5503
|
+
let baseKey;
|
|
5464
5504
|
if (limits.key === "session") {
|
|
5465
5505
|
const sessionKey = this.gateSessionLimitKey(request);
|
|
5466
5506
|
if (sessionKey === null) {
|
|
@@ -5470,15 +5510,16 @@ var GovernedForwarder = class {
|
|
|
5470
5510
|
approvalWaitMs: 0
|
|
5471
5511
|
};
|
|
5472
5512
|
}
|
|
5473
|
-
|
|
5513
|
+
baseKey = sessionKey;
|
|
5474
5514
|
} else {
|
|
5475
|
-
|
|
5515
|
+
baseKey = this.buildLimitKey(limits.key, toolName);
|
|
5476
5516
|
}
|
|
5517
|
+
const key = ruleBucketKey(baseKey, matchedRule.index);
|
|
5477
5518
|
const params = { key, maxCalls: limits.maxCalls, windowMs: limits.windowMs };
|
|
5478
5519
|
const rateLimitResult = limiter.peek(params);
|
|
5479
5520
|
if (!rateLimitResult.allowed) {
|
|
5480
5521
|
const feedback = buildRateLimitedFeedback(decision, rateLimitResult);
|
|
5481
|
-
const message =
|
|
5522
|
+
const message = matchedRule.feedback?.message ?? `Rate limit exceeded for ${key}`;
|
|
5482
5523
|
return {
|
|
5483
5524
|
proceed: false,
|
|
5484
5525
|
result: makeErrorResult(request, POLICY_DENIED, message, { ...feedback }),
|
|
@@ -5525,7 +5566,7 @@ var GovernedForwarder = class {
|
|
|
5525
5566
|
} else {
|
|
5526
5567
|
baseKey = this.buildLimitKey(maxSpend.key, toolName);
|
|
5527
5568
|
}
|
|
5528
|
-
const key =
|
|
5569
|
+
const key = ruleBucketKey(baseKey, decision.matchedRule.index);
|
|
5529
5570
|
const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
|
|
5530
5571
|
if (typeof rawAmount !== "number") {
|
|
5531
5572
|
console.error(
|
|
@@ -5598,14 +5639,14 @@ var GovernedForwarder = class {
|
|
|
5598
5639
|
case "rate_limit":
|
|
5599
5640
|
if (this.rateLimiter && decision.matchedRule?.limits?.maxCalls && decision.matchedRule.limits.windowMs) {
|
|
5600
5641
|
const limits = decision.matchedRule.limits;
|
|
5601
|
-
const
|
|
5602
|
-
if (
|
|
5642
|
+
const baseKey = limits.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(limits.key, toolName);
|
|
5643
|
+
if (baseKey === null) {
|
|
5603
5644
|
wouldForward = false;
|
|
5604
5645
|
limitsOk = false;
|
|
5605
5646
|
sessionUnresolved = true;
|
|
5606
5647
|
} else {
|
|
5607
5648
|
const peekResult = this.rateLimiter.peek({
|
|
5608
|
-
key,
|
|
5649
|
+
key: ruleBucketKey(baseKey, decision.matchedRule.index),
|
|
5609
5650
|
maxCalls: decision.matchedRule.limits.maxCalls,
|
|
5610
5651
|
windowMs: decision.matchedRule.limits.windowMs
|
|
5611
5652
|
});
|
|
@@ -5638,7 +5679,7 @@ var GovernedForwarder = class {
|
|
|
5638
5679
|
sessionUnresolved = true;
|
|
5639
5680
|
} else {
|
|
5640
5681
|
const peekResult = this.spendLimiter.peek({
|
|
5641
|
-
key:
|
|
5682
|
+
key: ruleBucketKey(baseKey, decision.matchedRule.index),
|
|
5642
5683
|
amount: rawAmount,
|
|
5643
5684
|
limit: maxSpend.limit,
|
|
5644
5685
|
windowMs: maxSpend.windowMs
|
|
@@ -5658,7 +5699,8 @@ var GovernedForwarder = class {
|
|
|
5658
5699
|
toolName,
|
|
5659
5700
|
toolArguments,
|
|
5660
5701
|
sessionId: sessionGate.ok ? sessionGate.session : null,
|
|
5661
|
-
senderId: null
|
|
5702
|
+
senderId: null,
|
|
5703
|
+
upstream: this.upstreamName ?? null
|
|
5662
5704
|
});
|
|
5663
5705
|
if (failures.length > 0 || charges.length > 0) {
|
|
5664
5706
|
const gated = gateBudgetCharges({ charges, failures }, sessionGate);
|
|
@@ -5699,7 +5741,9 @@ var GovernedForwarder = class {
|
|
|
5699
5741
|
);
|
|
5700
5742
|
}
|
|
5701
5743
|
/**
|
|
5702
|
-
* Construct a non-session limit bucket key.
|
|
5744
|
+
* Construct a non-session limit bucket key. Tool-scope keys route through
|
|
5745
|
+
* the shared `toolLimitKey` leaf, which prefixes them with the configured
|
|
5746
|
+
* upstream name when one is set (issue #295). Session keys are deliberately
|
|
5703
5747
|
* NOT built here: they come only from the gate module's `sessionLimitKey`,
|
|
5704
5748
|
* whose `GatedSession` parameter makes skipping the identity gate a
|
|
5705
5749
|
* compile error (issue #218) — call sites branch on `key === 'session'`.
|
|
@@ -5713,7 +5757,7 @@ var GovernedForwarder = class {
|
|
|
5713
5757
|
'[helio] Warning: limits.key "agent" is not yet supported, falling back to "tool"'
|
|
5714
5758
|
);
|
|
5715
5759
|
}
|
|
5716
|
-
return
|
|
5760
|
+
return toolLimitKey(toolName, this.upstreamName);
|
|
5717
5761
|
case "sender_id":
|
|
5718
5762
|
if (!this.senderKeyWarned) {
|
|
5719
5763
|
this.senderKeyWarned = true;
|
|
@@ -5721,10 +5765,10 @@ var GovernedForwarder = class {
|
|
|
5721
5765
|
'[helio] Warning: limits.key "sender_id" has no sender on the MCP path, falling back to "tool"'
|
|
5722
5766
|
);
|
|
5723
5767
|
}
|
|
5724
|
-
return
|
|
5768
|
+
return toolLimitKey(toolName, this.upstreamName);
|
|
5725
5769
|
case "tool":
|
|
5726
5770
|
default:
|
|
5727
|
-
return
|
|
5771
|
+
return toolLimitKey(toolName, this.upstreamName);
|
|
5728
5772
|
}
|
|
5729
5773
|
}
|
|
5730
5774
|
/**
|
|
@@ -5879,7 +5923,8 @@ var GovernedForwarder = class {
|
|
|
5879
5923
|
record_kind: "tool_call",
|
|
5880
5924
|
origin: "mcp",
|
|
5881
5925
|
metadata: null,
|
|
5882
|
-
protocol_version: request.protocolVersion ?? null
|
|
5926
|
+
protocol_version: request.protocolVersion ?? null,
|
|
5927
|
+
upstream: this.upstreamName ?? null
|
|
5883
5928
|
};
|
|
5884
5929
|
const isEnforcementDecision = !isDryRun && (!forwarded || approvalOutcome !== void 0 || budgetApproval !== void 0);
|
|
5885
5930
|
if (isEnforcementDecision) {
|
|
@@ -5950,113 +5995,376 @@ var GovernedForwarder = class {
|
|
|
5950
5995
|
headers: { "content-type": "application/json" },
|
|
5951
5996
|
body
|
|
5952
5997
|
};
|
|
5953
|
-
return { response, durationMs: 0 };
|
|
5954
|
-
}
|
|
5955
|
-
makeEvidenceBlockResult(request, decision, evidenceResult, dependencyResult) {
|
|
5956
|
-
const reason = evidenceResult && !evidenceResult.satisfied ? evidenceResult.expired.length > 0 ? "evidence_expired" : "evidence_missing" : "dependency_missing";
|
|
5957
|
-
const builder = reason === "evidence_expired" ? buildEvidenceExpiredFeedback : reason === "evidence_missing" ? buildEvidenceMissingFeedback : buildDependencyMissingFeedback;
|
|
5958
|
-
const feedback = builder(decision, evidenceResult, dependencyResult);
|
|
5959
|
-
return makeErrorResult(
|
|
5960
|
-
request,
|
|
5961
|
-
POLICY_DENIED,
|
|
5962
|
-
`Evidence grounding failed: ${decision.reason}`,
|
|
5963
|
-
{ ...feedback }
|
|
5964
|
-
);
|
|
5965
|
-
}
|
|
5966
|
-
makeSessionRequiredBlockResult(request, decision) {
|
|
5967
|
-
const feedback = buildPolicyDeniedFeedback(decision);
|
|
5968
|
-
return makeErrorResult(request, POLICY_DENIED, decision.reason, {
|
|
5969
|
-
...feedback,
|
|
5970
|
-
retry_allowed: true
|
|
5971
|
-
});
|
|
5998
|
+
return { response, durationMs: 0 };
|
|
5999
|
+
}
|
|
6000
|
+
makeEvidenceBlockResult(request, decision, evidenceResult, dependencyResult) {
|
|
6001
|
+
const reason = evidenceResult && !evidenceResult.satisfied ? evidenceResult.expired.length > 0 ? "evidence_expired" : "evidence_missing" : "dependency_missing";
|
|
6002
|
+
const builder = reason === "evidence_expired" ? buildEvidenceExpiredFeedback : reason === "evidence_missing" ? buildEvidenceMissingFeedback : buildDependencyMissingFeedback;
|
|
6003
|
+
const feedback = builder(decision, evidenceResult, dependencyResult);
|
|
6004
|
+
return makeErrorResult(
|
|
6005
|
+
request,
|
|
6006
|
+
POLICY_DENIED,
|
|
6007
|
+
`Evidence grounding failed: ${decision.reason}`,
|
|
6008
|
+
{ ...feedback }
|
|
6009
|
+
);
|
|
6010
|
+
}
|
|
6011
|
+
makeSessionRequiredBlockResult(request, decision) {
|
|
6012
|
+
const feedback = buildPolicyDeniedFeedback(decision);
|
|
6013
|
+
return makeErrorResult(request, POLICY_DENIED, decision.reason, {
|
|
6014
|
+
...feedback,
|
|
6015
|
+
retry_allowed: true
|
|
6016
|
+
});
|
|
6017
|
+
}
|
|
6018
|
+
makeClientDisconnectedBlockResult(request, decision) {
|
|
6019
|
+
const feedback = buildClientDisconnectedFeedback(decision);
|
|
6020
|
+
return makeErrorResult(request, POLICY_DENIED, "Client disconnected before completion", {
|
|
6021
|
+
...feedback
|
|
6022
|
+
});
|
|
6023
|
+
}
|
|
6024
|
+
};
|
|
6025
|
+
function collectAllowedEvidenceKeys(policy) {
|
|
6026
|
+
const keys = /* @__PURE__ */ new Set();
|
|
6027
|
+
for (const rule of policy.rules) {
|
|
6028
|
+
for (const key of rule.evidence?.requires ?? []) {
|
|
6029
|
+
keys.add(key);
|
|
6030
|
+
}
|
|
6031
|
+
}
|
|
6032
|
+
return [...keys];
|
|
6033
|
+
}
|
|
6034
|
+
function makeErrorResult(request, code, message, data) {
|
|
6035
|
+
const body = {
|
|
6036
|
+
jsonrpc: "2.0",
|
|
6037
|
+
id: request.id ?? null,
|
|
6038
|
+
error: { code, message, data }
|
|
6039
|
+
};
|
|
6040
|
+
const response = {
|
|
6041
|
+
status: 200,
|
|
6042
|
+
headers: { "content-type": "application/json" },
|
|
6043
|
+
body
|
|
6044
|
+
};
|
|
6045
|
+
return { response, durationMs: 0 };
|
|
6046
|
+
}
|
|
6047
|
+
function approvedByOf(outcome) {
|
|
6048
|
+
return outcome && "resolvedBy" in outcome ? outcome.resolvedBy : null;
|
|
6049
|
+
}
|
|
6050
|
+
function hasJsonRpcError(result) {
|
|
6051
|
+
const body = result.response.body;
|
|
6052
|
+
return body?.["error"] !== void 0;
|
|
6053
|
+
}
|
|
6054
|
+
function classifyPrimeFailure(response) {
|
|
6055
|
+
if (response.status >= 400) {
|
|
6056
|
+
return `upstream returned HTTP ${String(response.status)} to tools/list (session/initialize may be required)`;
|
|
6057
|
+
}
|
|
6058
|
+
const rawBody = response.body;
|
|
6059
|
+
if (typeof rawBody !== "object" || rawBody === null) {
|
|
6060
|
+
return `upstream tools/list returned a non-JSON body (content-type ${response.headers["content-type"] ?? "unknown"})`;
|
|
6061
|
+
}
|
|
6062
|
+
const body = rawBody;
|
|
6063
|
+
const error = body["error"];
|
|
6064
|
+
if (typeof error === "string") {
|
|
6065
|
+
return `upstream tools/list returned a JSON-RPC error: ${error}`;
|
|
6066
|
+
}
|
|
6067
|
+
if (error !== null && typeof error === "object") {
|
|
6068
|
+
const message = error["message"];
|
|
6069
|
+
if (typeof message === "string") {
|
|
6070
|
+
return `upstream tools/list returned a JSON-RPC error: ${message}`;
|
|
6071
|
+
}
|
|
6072
|
+
}
|
|
6073
|
+
return "upstream tools/list response was missing result.tools";
|
|
6074
|
+
}
|
|
6075
|
+
function extractBlockReason(result) {
|
|
6076
|
+
const body = result.response.body;
|
|
6077
|
+
const error = body?.["error"];
|
|
6078
|
+
if (!error || typeof error !== "object") return null;
|
|
6079
|
+
const data = error["data"];
|
|
6080
|
+
if (!data || data["blocked"] !== true) return null;
|
|
6081
|
+
return typeof data["reason"] === "string" ? data["reason"] : null;
|
|
6082
|
+
}
|
|
6083
|
+
function buildEvidenceChain(evidenceResult, dependencyResult, blocked2) {
|
|
6084
|
+
if (!evidenceResult && !dependencyResult) return null;
|
|
6085
|
+
const chain = { blocked: blocked2 ?? false };
|
|
6086
|
+
if (evidenceResult) {
|
|
6087
|
+
chain["evidence"] = {
|
|
6088
|
+
required: [...evidenceResult.found, ...evidenceResult.missing, ...evidenceResult.expired],
|
|
6089
|
+
found: evidenceResult.found,
|
|
6090
|
+
missing: evidenceResult.missing,
|
|
6091
|
+
expired: evidenceResult.expired
|
|
6092
|
+
};
|
|
6093
|
+
}
|
|
6094
|
+
if (dependencyResult) {
|
|
6095
|
+
chain["dependencies"] = {
|
|
6096
|
+
satisfied: dependencyResult.satisfied,
|
|
6097
|
+
missing: dependencyResult.missing
|
|
6098
|
+
};
|
|
6099
|
+
}
|
|
6100
|
+
return chain;
|
|
6101
|
+
}
|
|
6102
|
+
|
|
6103
|
+
// src/policy/rate-limiter.ts
|
|
6104
|
+
var RateLimiter = class {
|
|
6105
|
+
buckets = /* @__PURE__ */ new Map();
|
|
6106
|
+
now;
|
|
6107
|
+
onWarning;
|
|
6108
|
+
warningThreshold;
|
|
6109
|
+
timer = null;
|
|
6110
|
+
closed = false;
|
|
6111
|
+
constructor(options = {}) {
|
|
6112
|
+
this.now = options.now ?? Date.now;
|
|
6113
|
+
this.onWarning = options.onWarning;
|
|
6114
|
+
this.warningThreshold = options.warningThreshold ?? 0.8;
|
|
6115
|
+
const intervalMs = options.cleanupIntervalMs ?? 6e4;
|
|
6116
|
+
if (intervalMs > 0) {
|
|
6117
|
+
this.timer = setInterval(() => {
|
|
6118
|
+
this.cleanup();
|
|
6119
|
+
}, intervalMs);
|
|
6120
|
+
this.timer.unref();
|
|
6121
|
+
}
|
|
6122
|
+
}
|
|
6123
|
+
// -------------------------------------------------------------------------
|
|
6124
|
+
// Core operations
|
|
6125
|
+
// -------------------------------------------------------------------------
|
|
6126
|
+
/**
|
|
6127
|
+
* Check and optionally record a call against the rate limit.
|
|
6128
|
+
*
|
|
6129
|
+
* Evicts expired timestamps, then checks the count:
|
|
6130
|
+
* - Under limit: records the timestamp and returns `allowed: true`
|
|
6131
|
+
* - At/over limit: does NOT record (blocked calls don't consume a slot)
|
|
6132
|
+
*/
|
|
6133
|
+
check(params) {
|
|
6134
|
+
const { key, maxCalls, windowMs } = params;
|
|
6135
|
+
const now = this.now();
|
|
6136
|
+
const windowStart = now - windowMs;
|
|
6137
|
+
let bucket = this.buckets.get(key);
|
|
6138
|
+
if (!bucket) {
|
|
6139
|
+
bucket = { timestamps: [], maxCalls, windowMs };
|
|
6140
|
+
this.buckets.set(key, bucket);
|
|
6141
|
+
}
|
|
6142
|
+
bucket.maxCalls = maxCalls;
|
|
6143
|
+
bucket.windowMs = windowMs;
|
|
6144
|
+
bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
|
|
6145
|
+
if (bucket.timestamps.length >= maxCalls) {
|
|
6146
|
+
const oldest = bucket.timestamps[0] ?? 0;
|
|
6147
|
+
return {
|
|
6148
|
+
allowed: false,
|
|
6149
|
+
current: bucket.timestamps.length,
|
|
6150
|
+
limit: maxCalls,
|
|
6151
|
+
windowMs,
|
|
6152
|
+
resetAtMs: oldest + windowMs
|
|
6153
|
+
};
|
|
6154
|
+
}
|
|
6155
|
+
bucket.timestamps.push(now);
|
|
6156
|
+
const current = bucket.timestamps.length;
|
|
6157
|
+
const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
|
|
6158
|
+
if (this.onWarning && current / maxCalls >= this.warningThreshold) {
|
|
6159
|
+
this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
|
|
6160
|
+
}
|
|
6161
|
+
return {
|
|
6162
|
+
allowed: true,
|
|
6163
|
+
current,
|
|
6164
|
+
limit: maxCalls,
|
|
6165
|
+
windowMs,
|
|
6166
|
+
resetAtMs
|
|
6167
|
+
};
|
|
6168
|
+
}
|
|
6169
|
+
/**
|
|
6170
|
+
* Unconditionally record a call against the rate limit.
|
|
6171
|
+
*
|
|
6172
|
+
* Unlike check(), this always appends the timestamp — even when the bucket
|
|
6173
|
+
* is already at/over the limit — because the call it represents has already
|
|
6174
|
+
* executed. The sideband splits decision from execution: /evaluate peeks
|
|
6175
|
+
* (non-destructive), and /audit calls record() once the external call ran,
|
|
6176
|
+
* so refusing to record at the limit (as check() does) would let real calls
|
|
6177
|
+
* escape accounting and under-count subsequent peeks. (issue #12, D3.)
|
|
6178
|
+
*
|
|
6179
|
+
* Warnings fire only while the post-append count stays within the limit —
|
|
6180
|
+
* exact parity with check(), which never warns on its over-limit path — so a
|
|
6181
|
+
* burst of over-limit audits cannot flood the dashboard's limit_warning feed.
|
|
6182
|
+
*/
|
|
6183
|
+
record(params) {
|
|
6184
|
+
const { key, maxCalls, windowMs } = params;
|
|
6185
|
+
const now = this.now();
|
|
6186
|
+
const windowStart = now - windowMs;
|
|
6187
|
+
let bucket = this.buckets.get(key);
|
|
6188
|
+
if (!bucket) {
|
|
6189
|
+
bucket = { timestamps: [], maxCalls, windowMs };
|
|
6190
|
+
this.buckets.set(key, bucket);
|
|
6191
|
+
}
|
|
6192
|
+
bucket.maxCalls = maxCalls;
|
|
6193
|
+
bucket.windowMs = windowMs;
|
|
6194
|
+
bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
|
|
6195
|
+
bucket.timestamps.push(now);
|
|
6196
|
+
const current = bucket.timestamps.length;
|
|
6197
|
+
const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
|
|
6198
|
+
if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
|
|
6199
|
+
this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
|
|
6200
|
+
}
|
|
6201
|
+
return {
|
|
6202
|
+
allowed: current <= maxCalls,
|
|
6203
|
+
current,
|
|
6204
|
+
limit: maxCalls,
|
|
6205
|
+
windowMs,
|
|
6206
|
+
resetAtMs
|
|
6207
|
+
};
|
|
5972
6208
|
}
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
6209
|
+
/**
|
|
6210
|
+
* Check the rate limit without recording the call (non-destructive).
|
|
6211
|
+
*
|
|
6212
|
+
* Used by dry-run mode to determine what would happen without consuming
|
|
6213
|
+
* a slot in the bucket.
|
|
6214
|
+
*/
|
|
6215
|
+
peek(params) {
|
|
6216
|
+
const { key, maxCalls, windowMs } = params;
|
|
6217
|
+
const now = this.now();
|
|
6218
|
+
const windowStart = now - windowMs;
|
|
6219
|
+
const bucket = this.buckets.get(key);
|
|
6220
|
+
if (!bucket) {
|
|
6221
|
+
return {
|
|
6222
|
+
allowed: true,
|
|
6223
|
+
current: 1,
|
|
6224
|
+
limit: maxCalls,
|
|
6225
|
+
windowMs,
|
|
6226
|
+
resetAtMs: now + windowMs
|
|
6227
|
+
};
|
|
6228
|
+
}
|
|
6229
|
+
const activeCount = bucket.timestamps.filter((ts) => ts > windowStart).length;
|
|
6230
|
+
if (activeCount >= maxCalls) {
|
|
6231
|
+
const oldest2 = bucket.timestamps.find((ts) => ts > windowStart) ?? 0;
|
|
6232
|
+
return {
|
|
6233
|
+
allowed: false,
|
|
6234
|
+
current: activeCount,
|
|
6235
|
+
limit: maxCalls,
|
|
6236
|
+
windowMs,
|
|
6237
|
+
resetAtMs: oldest2 + windowMs
|
|
6238
|
+
};
|
|
6239
|
+
}
|
|
6240
|
+
const oldest = bucket.timestamps.find((ts) => ts > windowStart) ?? now;
|
|
6241
|
+
return {
|
|
6242
|
+
allowed: true,
|
|
6243
|
+
current: activeCount + 1,
|
|
6244
|
+
limit: maxCalls,
|
|
6245
|
+
windowMs,
|
|
6246
|
+
resetAtMs: oldest + windowMs
|
|
6247
|
+
};
|
|
5978
6248
|
}
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
6249
|
+
// -------------------------------------------------------------------------
|
|
6250
|
+
// Read operations (for dashboard API)
|
|
6251
|
+
// -------------------------------------------------------------------------
|
|
6252
|
+
/** Get the current state of a single key. Returns undefined if not tracked. */
|
|
6253
|
+
getKeyState(key) {
|
|
6254
|
+
const bucket = this.buckets.get(key);
|
|
6255
|
+
if (!bucket) return void 0;
|
|
6256
|
+
const windowStart = this.now() - bucket.windowMs;
|
|
6257
|
+
bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
|
|
6258
|
+
if (bucket.timestamps.length === 0) {
|
|
6259
|
+
this.buckets.delete(key);
|
|
6260
|
+
return void 0;
|
|
5985
6261
|
}
|
|
6262
|
+
return {
|
|
6263
|
+
key,
|
|
6264
|
+
current: bucket.timestamps.length,
|
|
6265
|
+
limit: bucket.maxCalls,
|
|
6266
|
+
window_ms: bucket.windowMs,
|
|
6267
|
+
reset_at_ms: (bucket.timestamps[0] ?? 0) + bucket.windowMs
|
|
6268
|
+
};
|
|
5986
6269
|
}
|
|
5987
|
-
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
const response = {
|
|
5996
|
-
status: 200,
|
|
5997
|
-
headers: { "content-type": "application/json" },
|
|
5998
|
-
body
|
|
5999
|
-
};
|
|
6000
|
-
return { response, durationMs: 0 };
|
|
6001
|
-
}
|
|
6002
|
-
function approvedByOf(outcome) {
|
|
6003
|
-
return outcome && "resolvedBy" in outcome ? outcome.resolvedBy : null;
|
|
6004
|
-
}
|
|
6005
|
-
function hasJsonRpcError(result) {
|
|
6006
|
-
const body = result.response.body;
|
|
6007
|
-
return body?.["error"] !== void 0;
|
|
6008
|
-
}
|
|
6009
|
-
function classifyPrimeFailure(response) {
|
|
6010
|
-
if (response.status >= 400) {
|
|
6011
|
-
return `upstream returned HTTP ${String(response.status)} to tools/list (session/initialize may be required)`;
|
|
6270
|
+
/** List all tracked keys with their current state. */
|
|
6271
|
+
listKeyStates() {
|
|
6272
|
+
const states = [];
|
|
6273
|
+
for (const key of [...this.buckets.keys()]) {
|
|
6274
|
+
const state = this.getKeyState(key);
|
|
6275
|
+
if (state) states.push(state);
|
|
6276
|
+
}
|
|
6277
|
+
return states;
|
|
6012
6278
|
}
|
|
6013
|
-
|
|
6014
|
-
|
|
6015
|
-
|
|
6279
|
+
// -------------------------------------------------------------------------
|
|
6280
|
+
// Maintenance
|
|
6281
|
+
// -------------------------------------------------------------------------
|
|
6282
|
+
/** Sweep all buckets: remove expired timestamps, delete empty buckets. */
|
|
6283
|
+
cleanup() {
|
|
6284
|
+
const now = this.now();
|
|
6285
|
+
for (const [key, bucket] of this.buckets) {
|
|
6286
|
+
const windowStart = now - bucket.windowMs;
|
|
6287
|
+
bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
|
|
6288
|
+
if (bucket.timestamps.length === 0) {
|
|
6289
|
+
this.buckets.delete(key);
|
|
6290
|
+
}
|
|
6291
|
+
}
|
|
6016
6292
|
}
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
return `upstream tools/list returned a JSON-RPC error: ${error}`;
|
|
6293
|
+
/** Clear all rate limit state. Called on policy hot-reload. */
|
|
6294
|
+
reset() {
|
|
6295
|
+
this.buckets.clear();
|
|
6021
6296
|
}
|
|
6022
|
-
|
|
6023
|
-
|
|
6024
|
-
|
|
6025
|
-
|
|
6297
|
+
/**
|
|
6298
|
+
* Reconcile bucket state against a new policy's limit configuration.
|
|
6299
|
+
*
|
|
6300
|
+
* Walks every existing bucket and checks whether its last-seen
|
|
6301
|
+
* `{ maxCalls, windowMs }` tuple still appears in `validConfigs`.
|
|
6302
|
+
* Buckets whose config is still present are left untouched — counters and
|
|
6303
|
+
* elapsed-window progress are preserved across hot-reloads. Buckets whose
|
|
6304
|
+
* config is gone (rule changed or removed) are evicted so the next check
|
|
6305
|
+
* lazy-creates a fresh bucket under the new config.
|
|
6306
|
+
*
|
|
6307
|
+
* Keys built by `ruleBucketKey` (bucket-key.ts) carry the owning rule's
|
|
6308
|
+
* index, and for those the tuple must match at THAT index
|
|
6309
|
+
* (`config.ruleIndex`): a reorder that shifts a rate rule's index evicts
|
|
6310
|
+
* its old-index bucket instead of leaving an orphan no rule reads again —
|
|
6311
|
+
* or worse, letting whatever rule now sits at that index adopt another
|
|
6312
|
+
* rule's accrued calls. Un-suffixed keys keep the tuple-anywhere match,
|
|
6313
|
+
* but only against index-less configs — a caller that passes only indexed
|
|
6314
|
+
* configs (as the proxy does) evicts every un-suffixed bucket, fail-closed.
|
|
6315
|
+
*
|
|
6316
|
+
* This is the compare-and-evict semantic that replaces the old `reset()`
|
|
6317
|
+
* call on every hot-reload, which wiped all state even when the matching
|
|
6318
|
+
* rule was unchanged.
|
|
6319
|
+
*/
|
|
6320
|
+
reconcile(validConfigs) {
|
|
6321
|
+
const valid = /* @__PURE__ */ new Set();
|
|
6322
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
6323
|
+
for (const config of validConfigs) {
|
|
6324
|
+
const tuple = `${String(config.maxCalls)}|${String(config.windowMs)}`;
|
|
6325
|
+
if (config.ruleIndex === void 0) {
|
|
6326
|
+
valid.add(tuple);
|
|
6327
|
+
} else {
|
|
6328
|
+
byIndex.set(config.ruleIndex, tuple);
|
|
6329
|
+
}
|
|
6330
|
+
}
|
|
6331
|
+
for (const [key, bucket] of this.buckets) {
|
|
6332
|
+
const tuple = `${String(bucket.maxCalls)}|${String(bucket.windowMs)}`;
|
|
6333
|
+
const ruleIndex = parseRuleIndex(key);
|
|
6334
|
+
const survives = ruleIndex === void 0 ? valid.has(tuple) : byIndex.get(ruleIndex) === tuple;
|
|
6335
|
+
if (!survives) {
|
|
6336
|
+
this.buckets.delete(key);
|
|
6337
|
+
}
|
|
6026
6338
|
}
|
|
6027
6339
|
}
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
|
|
6033
|
-
|
|
6034
|
-
|
|
6035
|
-
|
|
6036
|
-
|
|
6037
|
-
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
chain["evidence"] = {
|
|
6043
|
-
required: [...evidenceResult.found, ...evidenceResult.missing, ...evidenceResult.expired],
|
|
6044
|
-
found: evidenceResult.found,
|
|
6045
|
-
missing: evidenceResult.missing,
|
|
6046
|
-
expired: evidenceResult.expired
|
|
6047
|
-
};
|
|
6340
|
+
/** Stop the cleanup timer and mark as closed. */
|
|
6341
|
+
/**
|
|
6342
|
+
* Invoke the warning callback without letting a subscriber throw into the
|
|
6343
|
+
* limiter's caller: a warning fires after state has already mutated, and a
|
|
6344
|
+
* governed call must not be blocked (or double-charged on retry) by an
|
|
6345
|
+
* observability bug.
|
|
6346
|
+
*/
|
|
6347
|
+
safeWarn(state) {
|
|
6348
|
+
if (!this.onWarning) return;
|
|
6349
|
+
try {
|
|
6350
|
+
this.onWarning(state);
|
|
6351
|
+
} catch (err) {
|
|
6352
|
+
console.error("[helio] limit warning subscriber threw:", err);
|
|
6353
|
+
}
|
|
6048
6354
|
}
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6355
|
+
close() {
|
|
6356
|
+
if (this.closed) return;
|
|
6357
|
+
this.closed = true;
|
|
6358
|
+
if (this.timer) {
|
|
6359
|
+
clearInterval(this.timer);
|
|
6360
|
+
this.timer = null;
|
|
6361
|
+
}
|
|
6362
|
+
this.buckets.clear();
|
|
6054
6363
|
}
|
|
6055
|
-
|
|
6056
|
-
}
|
|
6364
|
+
};
|
|
6057
6365
|
|
|
6058
|
-
// src/policy/
|
|
6059
|
-
var
|
|
6366
|
+
// src/policy/spend-limiter.ts
|
|
6367
|
+
var SpendLimiter = class {
|
|
6060
6368
|
buckets = /* @__PURE__ */ new Map();
|
|
6061
6369
|
now;
|
|
6062
6370
|
onWarning;
|
|
@@ -6079,128 +6387,185 @@ var RateLimiter = class {
|
|
|
6079
6387
|
// Core operations
|
|
6080
6388
|
// -------------------------------------------------------------------------
|
|
6081
6389
|
/**
|
|
6082
|
-
* Check and optionally record a
|
|
6390
|
+
* Check and optionally record a spend against the limit.
|
|
6083
6391
|
*
|
|
6084
|
-
* Evicts expired
|
|
6085
|
-
* - Under limit: records
|
|
6086
|
-
* -
|
|
6392
|
+
* Evicts expired entries, sums remaining amounts, then checks:
|
|
6393
|
+
* - Under limit (currentSpend + amount <= limit): records and returns `allowed: true`
|
|
6394
|
+
* - Would exceed: does NOT record (rejected spends don't consume budget)
|
|
6087
6395
|
*/
|
|
6088
6396
|
check(params) {
|
|
6089
|
-
const { key,
|
|
6397
|
+
const { key, amount, limit, windowMs } = params;
|
|
6090
6398
|
const now = this.now();
|
|
6091
6399
|
const windowStart = now - windowMs;
|
|
6400
|
+
if (!Number.isFinite(amount) || amount < 0) {
|
|
6401
|
+
const existing = this.buckets.get(key);
|
|
6402
|
+
const activeEntries = existing ? existing.entries.filter((e) => e.timestamp > windowStart) : [];
|
|
6403
|
+
const currentSpend2 = activeEntries.reduce((sum, e) => sum + e.amount, 0);
|
|
6404
|
+
const oldest = activeEntries[0];
|
|
6405
|
+
return {
|
|
6406
|
+
allowed: false,
|
|
6407
|
+
currentSpend: currentSpend2,
|
|
6408
|
+
limit,
|
|
6409
|
+
windowMs,
|
|
6410
|
+
resetAtMs: oldest ? oldest.timestamp + windowMs : 0,
|
|
6411
|
+
reason: "invalid_amount"
|
|
6412
|
+
};
|
|
6413
|
+
}
|
|
6092
6414
|
let bucket = this.buckets.get(key);
|
|
6093
6415
|
if (!bucket) {
|
|
6094
|
-
bucket = {
|
|
6416
|
+
bucket = { entries: [], limit, currency: "", windowMs };
|
|
6095
6417
|
this.buckets.set(key, bucket);
|
|
6096
6418
|
}
|
|
6097
|
-
bucket.
|
|
6419
|
+
bucket.limit = limit;
|
|
6098
6420
|
bucket.windowMs = windowMs;
|
|
6099
|
-
bucket.
|
|
6100
|
-
|
|
6101
|
-
|
|
6421
|
+
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
6422
|
+
const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
|
|
6423
|
+
if (currentSpend + amount > limit) {
|
|
6424
|
+
const oldest = bucket.entries[0];
|
|
6102
6425
|
return {
|
|
6103
6426
|
allowed: false,
|
|
6104
|
-
|
|
6105
|
-
limit
|
|
6427
|
+
currentSpend,
|
|
6428
|
+
limit,
|
|
6106
6429
|
windowMs,
|
|
6107
|
-
resetAtMs: oldest + windowMs
|
|
6430
|
+
resetAtMs: oldest ? oldest.timestamp + windowMs : 0
|
|
6108
6431
|
};
|
|
6109
6432
|
}
|
|
6110
|
-
bucket.
|
|
6111
|
-
const
|
|
6112
|
-
const resetAtMs = (bucket.
|
|
6113
|
-
if (this.onWarning &&
|
|
6114
|
-
this.safeWarn({
|
|
6433
|
+
bucket.entries.push({ timestamp: now, amount });
|
|
6434
|
+
const newSpend = currentSpend + amount;
|
|
6435
|
+
const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
|
|
6436
|
+
if (this.onWarning && newSpend / limit >= this.warningThreshold) {
|
|
6437
|
+
this.safeWarn({
|
|
6438
|
+
key,
|
|
6439
|
+
current_spend: newSpend,
|
|
6440
|
+
limit,
|
|
6441
|
+
currency: bucket.currency,
|
|
6442
|
+
window_ms: windowMs,
|
|
6443
|
+
reset_at_ms: resetAtMs
|
|
6444
|
+
});
|
|
6115
6445
|
}
|
|
6116
6446
|
return {
|
|
6117
6447
|
allowed: true,
|
|
6118
|
-
|
|
6119
|
-
limit
|
|
6448
|
+
currentSpend: newSpend,
|
|
6449
|
+
limit,
|
|
6120
6450
|
windowMs,
|
|
6121
6451
|
resetAtMs
|
|
6122
6452
|
};
|
|
6123
6453
|
}
|
|
6124
6454
|
/**
|
|
6125
|
-
* Unconditionally record a
|
|
6455
|
+
* Unconditionally record a spend against the limit.
|
|
6126
6456
|
*
|
|
6127
|
-
* Unlike check(), this always appends the
|
|
6128
|
-
*
|
|
6129
|
-
*
|
|
6130
|
-
*
|
|
6131
|
-
* so refusing to record at the limit (as check() does) would let real calls
|
|
6132
|
-
* escape accounting and under-count subsequent peeks. (issue #12, D3.)
|
|
6457
|
+
* Unlike check(), this always appends the amount — even when it pushes the
|
|
6458
|
+
* window past the limit — because the spend it represents has already been
|
|
6459
|
+
* incurred. The sideband peeks at /evaluate and commits here at /audit once
|
|
6460
|
+
* the external call ran (issue #12, D3).
|
|
6133
6461
|
*
|
|
6134
|
-
*
|
|
6135
|
-
*
|
|
6136
|
-
*
|
|
6462
|
+
* Throws on a negative or non-finite amount: such amounts are rejected at
|
|
6463
|
+
* /evaluate, so one reaching record() is a logic bug we surface loudly rather
|
|
6464
|
+
* than silently corrupt the sliding-window sum. Warnings fire only while the
|
|
6465
|
+
* post-append spend stays within the limit (parity with check()).
|
|
6137
6466
|
*/
|
|
6138
6467
|
record(params) {
|
|
6139
|
-
const { key,
|
|
6468
|
+
const { key, amount, limit, windowMs } = params;
|
|
6469
|
+
if (!Number.isFinite(amount) || amount < 0) {
|
|
6470
|
+
throw new RangeError(
|
|
6471
|
+
`SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
|
|
6472
|
+
);
|
|
6473
|
+
}
|
|
6140
6474
|
const now = this.now();
|
|
6141
6475
|
const windowStart = now - windowMs;
|
|
6142
6476
|
let bucket = this.buckets.get(key);
|
|
6143
6477
|
if (!bucket) {
|
|
6144
|
-
bucket = {
|
|
6478
|
+
bucket = { entries: [], limit, currency: "", windowMs };
|
|
6145
6479
|
this.buckets.set(key, bucket);
|
|
6146
6480
|
}
|
|
6147
|
-
bucket.
|
|
6481
|
+
bucket.limit = limit;
|
|
6148
6482
|
bucket.windowMs = windowMs;
|
|
6149
|
-
bucket.
|
|
6150
|
-
bucket.
|
|
6151
|
-
const
|
|
6152
|
-
const resetAtMs = (bucket.
|
|
6153
|
-
if (this.onWarning &&
|
|
6154
|
-
this.safeWarn({
|
|
6483
|
+
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
6484
|
+
bucket.entries.push({ timestamp: now, amount });
|
|
6485
|
+
const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
|
|
6486
|
+
const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
|
|
6487
|
+
if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
|
|
6488
|
+
this.safeWarn({
|
|
6489
|
+
key,
|
|
6490
|
+
current_spend: currentSpend,
|
|
6491
|
+
limit,
|
|
6492
|
+
currency: bucket.currency,
|
|
6493
|
+
window_ms: windowMs,
|
|
6494
|
+
reset_at_ms: resetAtMs
|
|
6495
|
+
});
|
|
6155
6496
|
}
|
|
6156
6497
|
return {
|
|
6157
|
-
allowed:
|
|
6158
|
-
|
|
6159
|
-
limit
|
|
6498
|
+
allowed: currentSpend <= limit,
|
|
6499
|
+
currentSpend,
|
|
6500
|
+
limit,
|
|
6160
6501
|
windowMs,
|
|
6161
6502
|
resetAtMs
|
|
6162
6503
|
};
|
|
6163
6504
|
}
|
|
6164
6505
|
/**
|
|
6165
|
-
* Check the
|
|
6506
|
+
* Check the spend limit without recording the spend (non-destructive).
|
|
6166
6507
|
*
|
|
6167
6508
|
* Used by dry-run mode to determine what would happen without consuming
|
|
6168
|
-
*
|
|
6509
|
+
* budget in the bucket.
|
|
6169
6510
|
*/
|
|
6170
6511
|
peek(params) {
|
|
6171
|
-
const { key,
|
|
6512
|
+
const { key, amount, limit, windowMs } = params;
|
|
6172
6513
|
const now = this.now();
|
|
6173
6514
|
const windowStart = now - windowMs;
|
|
6174
6515
|
const bucket = this.buckets.get(key);
|
|
6516
|
+
if (!Number.isFinite(amount) || amount < 0) {
|
|
6517
|
+
const activeEntries2 = bucket ? bucket.entries.filter((e) => e.timestamp > windowStart) : [];
|
|
6518
|
+
const currentSpend2 = activeEntries2.reduce((sum, e) => sum + e.amount, 0);
|
|
6519
|
+
const oldest2 = activeEntries2[0];
|
|
6520
|
+
return {
|
|
6521
|
+
allowed: false,
|
|
6522
|
+
currentSpend: currentSpend2,
|
|
6523
|
+
limit,
|
|
6524
|
+
windowMs,
|
|
6525
|
+
resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0,
|
|
6526
|
+
reason: "invalid_amount"
|
|
6527
|
+
};
|
|
6528
|
+
}
|
|
6175
6529
|
if (!bucket) {
|
|
6530
|
+
const wouldExceed = amount > limit;
|
|
6176
6531
|
return {
|
|
6177
|
-
allowed:
|
|
6178
|
-
|
|
6179
|
-
limit
|
|
6532
|
+
allowed: !wouldExceed,
|
|
6533
|
+
currentSpend: wouldExceed ? 0 : amount,
|
|
6534
|
+
limit,
|
|
6180
6535
|
windowMs,
|
|
6181
6536
|
resetAtMs: now + windowMs
|
|
6182
6537
|
};
|
|
6183
6538
|
}
|
|
6184
|
-
const
|
|
6185
|
-
|
|
6186
|
-
|
|
6539
|
+
const activeEntries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
6540
|
+
const currentSpend = activeEntries.reduce((sum, e) => sum + e.amount, 0);
|
|
6541
|
+
if (currentSpend + amount > limit) {
|
|
6542
|
+
const oldest2 = activeEntries[0];
|
|
6187
6543
|
return {
|
|
6188
6544
|
allowed: false,
|
|
6189
|
-
|
|
6190
|
-
limit
|
|
6545
|
+
currentSpend,
|
|
6546
|
+
limit,
|
|
6191
6547
|
windowMs,
|
|
6192
|
-
resetAtMs: oldest2 + windowMs
|
|
6548
|
+
resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0
|
|
6193
6549
|
};
|
|
6194
6550
|
}
|
|
6195
|
-
const
|
|
6551
|
+
const newSpend = currentSpend + amount;
|
|
6552
|
+
const oldest = activeEntries[0];
|
|
6196
6553
|
return {
|
|
6197
6554
|
allowed: true,
|
|
6198
|
-
|
|
6199
|
-
limit
|
|
6555
|
+
currentSpend: newSpend,
|
|
6556
|
+
limit,
|
|
6200
6557
|
windowMs,
|
|
6201
|
-
resetAtMs: oldest + windowMs
|
|
6558
|
+
resetAtMs: oldest ? oldest.timestamp + windowMs : now + windowMs
|
|
6202
6559
|
};
|
|
6203
6560
|
}
|
|
6561
|
+
/**
|
|
6562
|
+
* Set the display currency for a key. Called by the governed forwarder
|
|
6563
|
+
* after check() so dashboard reads include the currency label.
|
|
6564
|
+
*/
|
|
6565
|
+
setCurrency(key, currency) {
|
|
6566
|
+
const bucket = this.buckets.get(key);
|
|
6567
|
+
if (bucket) bucket.currency = currency;
|
|
6568
|
+
}
|
|
6204
6569
|
// -------------------------------------------------------------------------
|
|
6205
6570
|
// Read operations (for dashboard API)
|
|
6206
6571
|
// -------------------------------------------------------------------------
|
|
@@ -6209,17 +6574,19 @@ var RateLimiter = class {
|
|
|
6209
6574
|
const bucket = this.buckets.get(key);
|
|
6210
6575
|
if (!bucket) return void 0;
|
|
6211
6576
|
const windowStart = this.now() - bucket.windowMs;
|
|
6212
|
-
bucket.
|
|
6213
|
-
if (bucket.
|
|
6577
|
+
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
6578
|
+
if (bucket.entries.length === 0) {
|
|
6214
6579
|
this.buckets.delete(key);
|
|
6215
6580
|
return void 0;
|
|
6216
6581
|
}
|
|
6582
|
+
const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
|
|
6217
6583
|
return {
|
|
6218
6584
|
key,
|
|
6219
|
-
|
|
6220
|
-
limit: bucket.
|
|
6585
|
+
current_spend: currentSpend,
|
|
6586
|
+
limit: bucket.limit,
|
|
6587
|
+
currency: bucket.currency,
|
|
6221
6588
|
window_ms: bucket.windowMs,
|
|
6222
|
-
reset_at_ms: (bucket.
|
|
6589
|
+
reset_at_ms: (bucket.entries[0]?.timestamp ?? 0) + bucket.windowMs
|
|
6223
6590
|
};
|
|
6224
6591
|
}
|
|
6225
6592
|
/** List all tracked keys with their current state. */
|
|
@@ -6234,43 +6601,62 @@ var RateLimiter = class {
|
|
|
6234
6601
|
// -------------------------------------------------------------------------
|
|
6235
6602
|
// Maintenance
|
|
6236
6603
|
// -------------------------------------------------------------------------
|
|
6237
|
-
/** Sweep all buckets: remove expired
|
|
6604
|
+
/** Sweep all buckets: remove expired entries, delete empty buckets. */
|
|
6238
6605
|
cleanup() {
|
|
6239
6606
|
const now = this.now();
|
|
6240
6607
|
for (const [key, bucket] of this.buckets) {
|
|
6241
6608
|
const windowStart = now - bucket.windowMs;
|
|
6242
|
-
bucket.
|
|
6243
|
-
if (bucket.
|
|
6609
|
+
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
6610
|
+
if (bucket.entries.length === 0) {
|
|
6244
6611
|
this.buckets.delete(key);
|
|
6245
6612
|
}
|
|
6246
6613
|
}
|
|
6247
6614
|
}
|
|
6248
|
-
/** Clear all
|
|
6615
|
+
/** Clear all spend limit state. Called on policy hot-reload. */
|
|
6249
6616
|
reset() {
|
|
6250
6617
|
this.buckets.clear();
|
|
6251
6618
|
}
|
|
6252
6619
|
/**
|
|
6253
|
-
* Reconcile bucket state against a new policy's
|
|
6620
|
+
* Reconcile bucket state against a new policy's spend configuration.
|
|
6254
6621
|
*
|
|
6255
6622
|
* Walks every existing bucket and checks whether its last-seen
|
|
6256
|
-
* `{
|
|
6257
|
-
* Buckets whose config is
|
|
6258
|
-
* elapsed-window progress are preserved across hot-reloads. Buckets
|
|
6259
|
-
* config is gone (rule changed or removed) are evicted so the next
|
|
6260
|
-
* lazy-creates a fresh bucket under the new config.
|
|
6623
|
+
* `{ limit, currency, windowMs }` tuple still appears in `validConfigs`.
|
|
6624
|
+
* Buckets whose config is unchanged are left untouched — cumulative spend
|
|
6625
|
+
* and elapsed-window progress are preserved across hot-reloads. Buckets
|
|
6626
|
+
* whose config is gone (rule changed or removed) are evicted so the next
|
|
6627
|
+
* check lazy-creates a fresh bucket under the new config.
|
|
6261
6628
|
*
|
|
6262
|
-
*
|
|
6263
|
-
*
|
|
6264
|
-
* rule
|
|
6629
|
+
* Keys built by `ruleBucketKey` (bucket-key.ts) carry the owning rule's
|
|
6630
|
+
* index, and for those the tuple must match at THAT index (`config.ruleIndex`): a
|
|
6631
|
+
* reorder that shifts a spend rule's index evicts its old-index bucket
|
|
6632
|
+
* instead of leaving an orphan no rule reads again — or worse, letting
|
|
6633
|
+
* whatever rule now sits at that index adopt another rule's accrued spend.
|
|
6634
|
+
* Un-suffixed keys keep the tuple-anywhere match, but only against
|
|
6635
|
+
* index-less configs — a caller that passes only indexed configs (as the
|
|
6636
|
+
* proxy does) evicts every un-suffixed bucket, fail-closed.
|
|
6637
|
+
*
|
|
6638
|
+
* Currency is part of the tuple because a USD→EUR switch is a meaningful
|
|
6639
|
+
* policy change — the same numeric limit buys a different amount of real
|
|
6640
|
+
* spend, so the bucket must reset. This replaces the old `reset()` call
|
|
6641
|
+
* on every hot-reload, which wiped all state even when the matching rule
|
|
6642
|
+
* was unchanged.
|
|
6265
6643
|
*/
|
|
6266
6644
|
reconcile(validConfigs) {
|
|
6267
6645
|
const valid = /* @__PURE__ */ new Set();
|
|
6646
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
6268
6647
|
for (const config of validConfigs) {
|
|
6269
|
-
|
|
6648
|
+
const tuple = `${String(config.limit)}|${config.currency}|${String(config.windowMs)}`;
|
|
6649
|
+
if (config.ruleIndex === void 0) {
|
|
6650
|
+
valid.add(tuple);
|
|
6651
|
+
} else {
|
|
6652
|
+
byIndex.set(config.ruleIndex, tuple);
|
|
6653
|
+
}
|
|
6270
6654
|
}
|
|
6271
6655
|
for (const [key, bucket] of this.buckets) {
|
|
6272
|
-
const tuple = `${String(bucket.
|
|
6273
|
-
|
|
6656
|
+
const tuple = `${String(bucket.limit)}|${bucket.currency}|${String(bucket.windowMs)}`;
|
|
6657
|
+
const ruleIndex = parseRuleIndex(key);
|
|
6658
|
+
const survives = ruleIndex === void 0 ? valid.has(tuple) : byIndex.get(ruleIndex) === tuple;
|
|
6659
|
+
if (!survives) {
|
|
6274
6660
|
this.buckets.delete(key);
|
|
6275
6661
|
}
|
|
6276
6662
|
}
|
|
@@ -6350,14 +6736,18 @@ var BudgetEngine = class {
|
|
|
6350
6736
|
/**
|
|
6351
6737
|
* Resolve which budgets a call feeds and how much it charges each.
|
|
6352
6738
|
*
|
|
6353
|
-
* A contributor participates when its
|
|
6354
|
-
*
|
|
6355
|
-
*
|
|
6356
|
-
*
|
|
6357
|
-
*
|
|
6358
|
-
*
|
|
6359
|
-
*
|
|
6360
|
-
*
|
|
6739
|
+
* A contributor participates when its upstream scope admits the call's
|
|
6740
|
+
* door (absent scope admits every door; a scoped contributor never
|
|
6741
|
+
* participates when `ctx.upstream` is null — sideband, singular mode) AND
|
|
6742
|
+
* its tool glob matches the tool name AND every `match.input` condition
|
|
6743
|
+
* holds (absent conditions means the glob alone decides); the FIRST
|
|
6744
|
+
* participating contributor (config order, over that combined predicate)
|
|
6745
|
+
* supplies the amount field. A call that matches the glob but not the
|
|
6746
|
+
* conditions or the scope simply does not feed the budget — no charge, no
|
|
6747
|
+
* failure — and a later contributor may still participate. Once a
|
|
6748
|
+
* contributor is selected, a missing, non-numeric, negative, or non-finite
|
|
6749
|
+
* amount fails closed as a `failures` entry — the caller must deny the
|
|
6750
|
+
* call.
|
|
6361
6751
|
*/
|
|
6362
6752
|
resolveCharges(ctx) {
|
|
6363
6753
|
const charges = [];
|
|
@@ -6368,7 +6758,7 @@ var BudgetEngine = class {
|
|
|
6368
6758
|
};
|
|
6369
6759
|
for (const budget of this.budgets.values()) {
|
|
6370
6760
|
const contributor = budget.contributors.find(
|
|
6371
|
-
(c) => c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
|
|
6761
|
+
(c) => (c.upstreams === void 0 || ctx.upstream !== null && c.upstreams.includes(ctx.upstream)) && c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
|
|
6372
6762
|
);
|
|
6373
6763
|
if (!contributor) continue;
|
|
6374
6764
|
const raw = resolvePath(contributor.field, ctx.toolArguments ?? {});
|
|
@@ -6393,7 +6783,8 @@ var BudgetEngine = class {
|
|
|
6393
6783
|
budget,
|
|
6394
6784
|
bucketKey: this.bucketKey(budget, ctx),
|
|
6395
6785
|
amount: raw,
|
|
6396
|
-
generation: this.generations.get(budget.name) ?? 0
|
|
6786
|
+
generation: this.generations.get(budget.name) ?? 0,
|
|
6787
|
+
...ctx.upstream !== null && { upstream: ctx.upstream }
|
|
6397
6788
|
});
|
|
6398
6789
|
}
|
|
6399
6790
|
return { charges, failures };
|
|
@@ -6473,7 +6864,8 @@ var BudgetEngine = class {
|
|
|
6473
6864
|
remaining: snapshot.remaining,
|
|
6474
6865
|
limit: charge.budget.limit,
|
|
6475
6866
|
currency: charge.budget.currency,
|
|
6476
|
-
utilization: snapshot.spent / charge.budget.limit
|
|
6867
|
+
utilization: snapshot.spent / charge.budget.limit,
|
|
6868
|
+
upstream: charge.upstream ?? null
|
|
6477
6869
|
});
|
|
6478
6870
|
} catch (err) {
|
|
6479
6871
|
console.error("[helio] budget onCommit subscriber threw:", err);
|
|
@@ -6499,7 +6891,8 @@ var BudgetEngine = class {
|
|
|
6499
6891
|
attempted_amount: entry.amount,
|
|
6500
6892
|
spent: entry.spent,
|
|
6501
6893
|
limit: entry.budget.limit,
|
|
6502
|
-
currency: entry.budget.currency
|
|
6894
|
+
currency: entry.budget.currency,
|
|
6895
|
+
upstream: entry.upstream
|
|
6503
6896
|
});
|
|
6504
6897
|
} catch (err) {
|
|
6505
6898
|
console.error("[helio] budget onBreach subscriber threw:", err);
|
|
@@ -6809,7 +7202,8 @@ var BudgetEngine = class {
|
|
|
6809
7202
|
allowed: checkedAgainst + charge.amount <= charge.budget.limit,
|
|
6810
7203
|
spent,
|
|
6811
7204
|
remaining: Math.max(0, charge.budget.limit - spent),
|
|
6812
|
-
resetAtMs
|
|
7205
|
+
resetAtMs,
|
|
7206
|
+
upstream: charge.upstream ?? null
|
|
6813
7207
|
};
|
|
6814
7208
|
}
|
|
6815
7209
|
};
|
|
@@ -6839,6 +7233,14 @@ import Database from "better-sqlite3";
|
|
|
6839
7233
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
6840
7234
|
import { chmodSync } from "fs";
|
|
6841
7235
|
|
|
7236
|
+
// src/startup-error.ts
|
|
7237
|
+
var StartupError = class extends Error {
|
|
7238
|
+
constructor(message) {
|
|
7239
|
+
super(message);
|
|
7240
|
+
this.name = "StartupError";
|
|
7241
|
+
}
|
|
7242
|
+
};
|
|
7243
|
+
|
|
6842
7244
|
// src/upstream/response-summary.ts
|
|
6843
7245
|
function extractResponseSummary(body) {
|
|
6844
7246
|
if (body == null || typeof body !== "object") {
|
|
@@ -6933,7 +7335,8 @@ CREATE TABLE IF NOT EXISTS audit_records (
|
|
|
6933
7335
|
origin TEXT NOT NULL DEFAULT 'mcp',
|
|
6934
7336
|
metadata TEXT,
|
|
6935
7337
|
protocol_version TEXT,
|
|
6936
|
-
created_at TEXT NOT NULL
|
|
7338
|
+
created_at TEXT NOT NULL,
|
|
7339
|
+
upstream TEXT
|
|
6937
7340
|
);
|
|
6938
7341
|
`;
|
|
6939
7342
|
var CREATE_INDEX_DDL = `
|
|
@@ -6945,6 +7348,7 @@ CREATE INDEX IF NOT EXISTS idx_audit_block_reason ON audit_records (block_re
|
|
|
6945
7348
|
CREATE INDEX IF NOT EXISTS idx_audit_upstream_status_created_at ON audit_records (upstream_http_status, created_at);
|
|
6946
7349
|
CREATE INDEX IF NOT EXISTS idx_audit_record_kind ON audit_records (record_kind);
|
|
6947
7350
|
CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
|
|
7351
|
+
CREATE INDEX IF NOT EXISTS idx_audit_upstream ON audit_records (upstream);
|
|
6948
7352
|
`;
|
|
6949
7353
|
var INSERT_SQL = `
|
|
6950
7354
|
INSERT INTO audit_records (
|
|
@@ -6953,14 +7357,16 @@ INSERT INTO audit_records (
|
|
|
6953
7357
|
approved_by, upstream_response, upstream_error, upstream_latency_ms,
|
|
6954
7358
|
upstream_http_status,
|
|
6955
7359
|
total_duration_ms, approval_wait_ms, proxy_compute_ms,
|
|
6956
|
-
flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at
|
|
7360
|
+
flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at,
|
|
7361
|
+
upstream
|
|
6957
7362
|
) VALUES (
|
|
6958
7363
|
@id, @timestamp, @session_id, @session_source, @agent_id, @environment, @tool_name, @tool_input,
|
|
6959
7364
|
@policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
|
|
6960
7365
|
@approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
|
|
6961
7366
|
@upstream_http_status,
|
|
6962
7367
|
@total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
|
|
6963
|
-
@flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at
|
|
7368
|
+
@flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at,
|
|
7369
|
+
@upstream
|
|
6964
7370
|
)
|
|
6965
7371
|
`;
|
|
6966
7372
|
var REQUIRED_AUDIT_COLUMNS = [
|
|
@@ -6979,7 +7385,11 @@ var REQUIRED_AUDIT_COLUMNS = [
|
|
|
6979
7385
|
"session_source",
|
|
6980
7386
|
// Same clean break, same unreleased cycle (issue #219): released users see
|
|
6981
7387
|
// ONE break, at v0.12.0.
|
|
6982
|
-
"protocol_version"
|
|
7388
|
+
"protocol_version",
|
|
7389
|
+
// The one ratified exception to the clean break (issue #292): a
|
|
7390
|
+
// v0.12.0-complete database missing ONLY this column is migrated in place
|
|
7391
|
+
// by migrateAuditUpstreamColumn instead of failing the assertion.
|
|
7392
|
+
"upstream"
|
|
6983
7393
|
];
|
|
6984
7394
|
function deserializeRow(row) {
|
|
6985
7395
|
return {
|
|
@@ -7011,6 +7421,7 @@ function deserializeRow(row) {
|
|
|
7011
7421
|
origin: row.origin,
|
|
7012
7422
|
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
7013
7423
|
protocol_version: row.protocol_version,
|
|
7424
|
+
upstream: row.upstream,
|
|
7014
7425
|
created_at: row.created_at
|
|
7015
7426
|
};
|
|
7016
7427
|
}
|
|
@@ -7052,6 +7463,14 @@ function buildWhereClause(filters) {
|
|
|
7052
7463
|
conditions.push("session_id = ?");
|
|
7053
7464
|
params.push(filters.session_id);
|
|
7054
7465
|
}
|
|
7466
|
+
if (filters.session_source !== void 0) {
|
|
7467
|
+
conditions.push("session_source = ?");
|
|
7468
|
+
params.push(filters.session_source);
|
|
7469
|
+
}
|
|
7470
|
+
if (filters.upstream !== void 0) {
|
|
7471
|
+
conditions.push("upstream = ?");
|
|
7472
|
+
params.push(filters.upstream);
|
|
7473
|
+
}
|
|
7055
7474
|
if (filters.agent_id !== void 0) {
|
|
7056
7475
|
conditions.push("agent_id = ?");
|
|
7057
7476
|
params.push(filters.agent_id);
|
|
@@ -7083,6 +7502,22 @@ function buildWhereClause(filters) {
|
|
|
7083
7502
|
const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
7084
7503
|
return { clause, params };
|
|
7085
7504
|
}
|
|
7505
|
+
function migrateAuditUpstreamColumn(db) {
|
|
7506
|
+
const probe = () => {
|
|
7507
|
+
const rows = db.pragma("table_info(audit_records)");
|
|
7508
|
+
return new Set(rows.map((row) => row.name));
|
|
7509
|
+
};
|
|
7510
|
+
const existing = probe();
|
|
7511
|
+
const missing = REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name));
|
|
7512
|
+
if (missing.length !== 1 || missing[0] !== "upstream") return false;
|
|
7513
|
+
try {
|
|
7514
|
+
db.exec("ALTER TABLE audit_records ADD COLUMN upstream TEXT");
|
|
7515
|
+
} catch (err) {
|
|
7516
|
+
if (probe().has("upstream")) return false;
|
|
7517
|
+
throw err;
|
|
7518
|
+
}
|
|
7519
|
+
return true;
|
|
7520
|
+
}
|
|
7086
7521
|
function restrictAuditFilePerms(dbPath) {
|
|
7087
7522
|
if (dbPath === ":memory:" || process.platform === "win32") return;
|
|
7088
7523
|
try {
|
|
@@ -7111,6 +7546,9 @@ var AuditStore = class {
|
|
|
7111
7546
|
this.retentionMs = parseDuration(options.retention);
|
|
7112
7547
|
this.includeResponses = options.includeResponses;
|
|
7113
7548
|
this.db.exec(CREATE_TABLE_DDL);
|
|
7549
|
+
if (migrateAuditUpstreamColumn(this.db)) {
|
|
7550
|
+
console.error('[helio] Audit DB migrated: added column "upstream"');
|
|
7551
|
+
}
|
|
7114
7552
|
this.assertRequiredSchema(options.path);
|
|
7115
7553
|
this.db.exec(CREATE_INDEX_DDL);
|
|
7116
7554
|
this.insertStmt = this.db.prepare(INSERT_SQL);
|
|
@@ -7176,7 +7614,7 @@ var AuditStore = class {
|
|
|
7176
7614
|
const missing = REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name));
|
|
7177
7615
|
if (missing.length === 0) return;
|
|
7178
7616
|
const quotedColumns = missing.map((name) => `"${name}"`).join(", ");
|
|
7179
|
-
throw new
|
|
7617
|
+
throw new StartupError(
|
|
7180
7618
|
`[helio] Audit DB schema mismatch: missing required columns ${quotedColumns}. This local database was created by an older Helio build. Delete "${dbPath}", "${dbPath}-wal", and "${dbPath}-shm", then restart Helio.`
|
|
7181
7619
|
);
|
|
7182
7620
|
}
|
|
@@ -7219,6 +7657,7 @@ var AuditStore = class {
|
|
|
7219
7657
|
origin: record.origin,
|
|
7220
7658
|
metadata: record.metadata ? JSON.stringify(record.metadata) : null,
|
|
7221
7659
|
protocol_version: record.protocol_version,
|
|
7660
|
+
upstream: record.upstream ?? null,
|
|
7222
7661
|
created_at: now
|
|
7223
7662
|
});
|
|
7224
7663
|
return resolvedId;
|
|
@@ -7294,9 +7733,14 @@ var AuditStore = class {
|
|
|
7294
7733
|
const result = this.db.prepare(`SELECT COUNT(*) as total FROM audit_records ${clause}`).get(...params);
|
|
7295
7734
|
return result.total;
|
|
7296
7735
|
}
|
|
7297
|
-
/**
|
|
7298
|
-
|
|
7299
|
-
|
|
7736
|
+
/**
|
|
7737
|
+
* Get aggregate statistics for a time range. The optional upstream filter
|
|
7738
|
+
* scopes EVERY sub-aggregate (totals, by_decision, by_block_reason,
|
|
7739
|
+
* top_tools, approval_rate, per_hour) — "analytics for this door", not one
|
|
7740
|
+
* filtered chart. Exact match: null-upstream rows never match any value.
|
|
7741
|
+
*/
|
|
7742
|
+
aggregate(from, to, filters = {}) {
|
|
7743
|
+
const rangeFilters = { from, to, upstream: filters.upstream };
|
|
7300
7744
|
const { clause, params } = buildWhereClause(rangeFilters);
|
|
7301
7745
|
const totals = this.db.prepare(
|
|
7302
7746
|
`SELECT
|
|
@@ -7322,9 +7766,9 @@ var AuditStore = class {
|
|
|
7322
7766
|
).all(...params);
|
|
7323
7767
|
const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}`;
|
|
7324
7768
|
const top_tools = this.db.prepare(
|
|
7325
|
-
`SELECT tool_name, COUNT(*) as count
|
|
7769
|
+
`SELECT tool_name, upstream, COUNT(*) as count
|
|
7326
7770
|
FROM audit_records ${toolsClause}
|
|
7327
|
-
GROUP BY tool_name
|
|
7771
|
+
GROUP BY tool_name, upstream
|
|
7328
7772
|
ORDER BY count DESC
|
|
7329
7773
|
LIMIT 10`
|
|
7330
7774
|
).all(...params);
|
|
@@ -7408,10 +7852,11 @@ var CSV_HEADERS = [
|
|
|
7408
7852
|
"record_kind",
|
|
7409
7853
|
"origin",
|
|
7410
7854
|
"metadata",
|
|
7411
|
-
// Appended LAST (issues #218, #219): positional consumers of the
|
|
7412
|
-
// columns keep working — new columns always go at the end.
|
|
7855
|
+
// Appended LAST (issues #218, #219, #292): positional consumers of the
|
|
7856
|
+
// existing columns keep working — new columns always go at the end.
|
|
7413
7857
|
"session_source",
|
|
7414
|
-
"protocol_version"
|
|
7858
|
+
"protocol_version",
|
|
7859
|
+
"upstream"
|
|
7415
7860
|
];
|
|
7416
7861
|
var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
|
|
7417
7862
|
function csvEscape(value) {
|
|
@@ -7454,7 +7899,10 @@ var BUDGET_EVENT_CSV_HEADERS = [
|
|
|
7454
7899
|
"audit_record_id",
|
|
7455
7900
|
"timestamp",
|
|
7456
7901
|
"timestamp_ms",
|
|
7457
|
-
"created_at"
|
|
7902
|
+
"created_at",
|
|
7903
|
+
// Appended LAST (issue #292): positional consumers of the existing
|
|
7904
|
+
// columns keep working — new columns always go at the end.
|
|
7905
|
+
"upstream"
|
|
7458
7906
|
];
|
|
7459
7907
|
function eventToRow(event) {
|
|
7460
7908
|
return BUDGET_EVENT_CSV_HEADERS.map((h) => {
|
|
@@ -7770,19 +8218,23 @@ import { HTTPException } from "hono/http-exception";
|
|
|
7770
8218
|
import { z as z5 } from "zod";
|
|
7771
8219
|
|
|
7772
8220
|
// src/auth/bearer.ts
|
|
7773
|
-
import { createHash, timingSafeEqual } from "crypto";
|
|
8221
|
+
import { createHash as createHash2, timingSafeEqual } from "crypto";
|
|
8222
|
+
var BEARER_PREFIX = "Bearer ";
|
|
8223
|
+
var DIGEST_PATTERN = /^sha256:([0-9a-f]{64})$/;
|
|
7774
8224
|
function verifyBearer(authHeader, expected) {
|
|
7775
8225
|
if (!authHeader || !expected) return false;
|
|
7776
|
-
|
|
7777
|
-
const
|
|
7778
|
-
const
|
|
8226
|
+
if (!authHeader.startsWith(BEARER_PREFIX)) return false;
|
|
8227
|
+
const presented = authHeader.slice(BEARER_PREFIX.length);
|
|
8228
|
+
const storedHex = DIGEST_PATTERN.exec(expected)?.[1];
|
|
8229
|
+
const expectedDigest = storedHex !== void 0 ? Buffer.from(storedHex, "hex") : createHash2("sha256").update(expected, "utf-8").digest();
|
|
8230
|
+
const actualDigest = createHash2("sha256").update(presented, "utf-8").digest();
|
|
7779
8231
|
return timingSafeEqual(actualDigest, expectedDigest);
|
|
7780
8232
|
}
|
|
7781
8233
|
|
|
7782
8234
|
// src/sideband/governance-api.ts
|
|
7783
8235
|
import { Hono as Hono4 } from "hono";
|
|
7784
8236
|
import { z as z4 } from "zod";
|
|
7785
|
-
import { createHash as
|
|
8237
|
+
import { createHash as createHash3 } from "crypto";
|
|
7786
8238
|
var originSchema = z4.string().regex(/^[a-z0-9_-]{1,64}$/, "origin must match ^[a-z0-9_-]{1,64}$").default("sideband");
|
|
7787
8239
|
var metadataSchema = z4.record(z4.string(), z4.unknown()).nullish();
|
|
7788
8240
|
var toolDefinitionSchema = z4.object({
|
|
@@ -7939,7 +8391,7 @@ function auditPayloadHash(data) {
|
|
|
7939
8391
|
actual_amount: data.actual_amount ?? null,
|
|
7940
8392
|
evidence: canonicalEvidence(data.evidence)
|
|
7941
8393
|
};
|
|
7942
|
-
return
|
|
8394
|
+
return createHash3("sha256").update(canonicalize(semantic)).digest("hex");
|
|
7943
8395
|
}
|
|
7944
8396
|
function canonicalEvidence(evidence) {
|
|
7945
8397
|
if (!evidence || evidence.length === 0) return null;
|
|
@@ -8285,7 +8737,9 @@ var GovernanceService = class {
|
|
|
8285
8737
|
toolName,
|
|
8286
8738
|
toolArguments: req.arguments,
|
|
8287
8739
|
sessionId: budgetSessionGate.ok ? budgetSessionGate.session : null,
|
|
8288
|
-
senderId
|
|
8740
|
+
senderId,
|
|
8741
|
+
upstream: null
|
|
8742
|
+
// a sideband call has no upstream (issue #295)
|
|
8289
8743
|
});
|
|
8290
8744
|
const gatedCharges = charges.length > 0 || failures.length > 0 ? gateBudgetCharges({ charges, failures }, budgetSessionGate) : void 0;
|
|
8291
8745
|
if (gatedCharges && !gatedCharges.ok) {
|
|
@@ -9007,11 +9461,12 @@ var GovernanceService = class {
|
|
|
9007
9461
|
};
|
|
9008
9462
|
}
|
|
9009
9463
|
planRate(decision, toolName, sessionId, senderId) {
|
|
9010
|
-
const
|
|
9011
|
-
|
|
9464
|
+
const matchedRule = decision.matchedRule;
|
|
9465
|
+
const limits = matchedRule?.limits;
|
|
9466
|
+
if (!this.rateLimiter || !matchedRule || !limits?.maxCalls || !limits.windowMs) {
|
|
9012
9467
|
return { allowed: true };
|
|
9013
9468
|
}
|
|
9014
|
-
let
|
|
9469
|
+
let baseKey;
|
|
9015
9470
|
if (limits.key === "session") {
|
|
9016
9471
|
const gate = gateSession(sessionId, this.session.onUnresolved);
|
|
9017
9472
|
if (!gate.ok) {
|
|
@@ -9019,10 +9474,11 @@ var GovernanceService = class {
|
|
|
9019
9474
|
return { allowed: false, sessionUnresolved: true };
|
|
9020
9475
|
}
|
|
9021
9476
|
if (gate.anonymous) warnAnonymousPoolingOnce();
|
|
9022
|
-
|
|
9477
|
+
baseKey = sessionLimitKey(gate.session);
|
|
9023
9478
|
} else {
|
|
9024
|
-
|
|
9479
|
+
baseKey = buildLimitKey(limits.key, toolName, senderId);
|
|
9025
9480
|
}
|
|
9481
|
+
const key = ruleBucketKey(baseKey, matchedRule.index);
|
|
9026
9482
|
const peek = this.rateLimiter.peek({
|
|
9027
9483
|
key,
|
|
9028
9484
|
maxCalls: limits.maxCalls,
|
|
@@ -9054,7 +9510,7 @@ var GovernanceService = class {
|
|
|
9054
9510
|
} else {
|
|
9055
9511
|
baseKey = buildLimitKey(maxSpend.key, toolName, senderId);
|
|
9056
9512
|
}
|
|
9057
|
-
const key =
|
|
9513
|
+
const key = ruleBucketKey(baseKey, decision.matchedRule.index);
|
|
9058
9514
|
const rawAmount = resolvePath(maxSpend.field, args ?? {});
|
|
9059
9515
|
if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
|
|
9060
9516
|
return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
|
|
@@ -9213,7 +9669,9 @@ var GovernanceService = class {
|
|
|
9213
9669
|
origin: args.origin,
|
|
9214
9670
|
metadata: args.metadata,
|
|
9215
9671
|
// The sideband has no MCP wire, so no protocol claim exists.
|
|
9216
|
-
protocol_version: null
|
|
9672
|
+
protocol_version: null,
|
|
9673
|
+
// No door on the sideband either: upstream attribution is MCP-only.
|
|
9674
|
+
upstream: null
|
|
9217
9675
|
};
|
|
9218
9676
|
const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
|
|
9219
9677
|
if (isEnforcement) this.auditWriter.pushImmediate(record, id);
|
|
@@ -9278,7 +9736,7 @@ function buildLimitKey(keyType, toolName, senderId) {
|
|
|
9278
9736
|
case "agent":
|
|
9279
9737
|
case "tool":
|
|
9280
9738
|
default:
|
|
9281
|
-
return
|
|
9739
|
+
return toolLimitKey(toolName);
|
|
9282
9740
|
}
|
|
9283
9741
|
}
|
|
9284
9742
|
function senderIdOf(metadata) {
|
|
@@ -9477,7 +9935,7 @@ var AuditWriter = class {
|
|
|
9477
9935
|
};
|
|
9478
9936
|
|
|
9479
9937
|
// src/audit/header-mismatch.ts
|
|
9480
|
-
function buildHeaderMismatchAuditRecord(rejection, environment) {
|
|
9938
|
+
function buildHeaderMismatchAuditRecord(rejection, environment, upstream) {
|
|
9481
9939
|
return {
|
|
9482
9940
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9483
9941
|
session_id: rejection.session?.id ?? null,
|
|
@@ -9514,7 +9972,10 @@ function buildHeaderMismatchAuditRecord(rejection, environment) {
|
|
|
9514
9972
|
record_kind: "tool_call",
|
|
9515
9973
|
origin: "mcp",
|
|
9516
9974
|
metadata: null,
|
|
9517
|
-
protocol_version: rejection.protocolVersion ?? null
|
|
9975
|
+
protocol_version: rejection.protocolVersion ?? null,
|
|
9976
|
+
// The door context lives with the caller (the composition root), not
|
|
9977
|
+
// the rejection payload; singular composition passes nothing.
|
|
9978
|
+
upstream: upstream ?? null
|
|
9518
9979
|
};
|
|
9519
9980
|
}
|
|
9520
9981
|
|
|
@@ -9556,6 +10017,9 @@ var ApprovalQueue = class {
|
|
|
9556
10017
|
rule_index: params.rule_index,
|
|
9557
10018
|
channel_name: params.channel_name,
|
|
9558
10019
|
session_id: params.session_id,
|
|
10020
|
+
// Wire-darkness spelling: set only when attributed, never null.
|
|
10021
|
+
...params.session_source != null && { session_source: params.session_source },
|
|
10022
|
+
...params.upstream != null && { upstream: params.upstream },
|
|
9559
10023
|
requested_at: new Date(now).toISOString(),
|
|
9560
10024
|
timeout_at: new Date(now + params.timeout_ms).toISOString(),
|
|
9561
10025
|
timeout_ms: params.timeout_ms,
|
|
@@ -9675,6 +10139,8 @@ var ApprovalRouter = class {
|
|
|
9675
10139
|
rule_index: rule?.index ?? null,
|
|
9676
10140
|
channel_name: channelName,
|
|
9677
10141
|
session_id: params.session_id,
|
|
10142
|
+
session_source: params.session_source,
|
|
10143
|
+
upstream: params.upstream,
|
|
9678
10144
|
timeout_ms: timeoutMs,
|
|
9679
10145
|
breached_budgets: params.breached_budgets
|
|
9680
10146
|
});
|
|
@@ -9771,6 +10237,10 @@ var ApprovalRouter = class {
|
|
|
9771
10237
|
rule_index: rule?.index ?? null,
|
|
9772
10238
|
channel_name: `${NATIVE_CHANNEL_PREFIX}${params.origin}`,
|
|
9773
10239
|
session_id: params.session_id,
|
|
10240
|
+
// Adapter-supplied ids are sideband-attributed by definition (issue
|
|
10241
|
+
// #251); deriving it at this single choke point means no future
|
|
10242
|
+
// adapter can forget it. Upstream stays absent: no MCP door here.
|
|
10243
|
+
session_source: params.session_id != null ? "sideband" : null,
|
|
9774
10244
|
timeout_ms: timeoutMs,
|
|
9775
10245
|
breached_budgets: params.breached_budgets
|
|
9776
10246
|
});
|
|
@@ -9940,15 +10410,22 @@ function buildApprovalBlocks(ticket) {
|
|
|
9940
10410
|
const safeName = sanitizeCodeSpanContent(ticket.tool_name);
|
|
9941
10411
|
const rawInput = truncate(JSON.stringify(ticket.tool_input), MAX_INPUT_LENGTH);
|
|
9942
10412
|
const safeInput = sanitizeForCodeBlock(rawInput);
|
|
9943
|
-
const detailLines = [`*Tool:* \`${safeName}
|
|
10413
|
+
const detailLines = [`*Tool:* \`${safeName}\``];
|
|
10414
|
+
if (ticket.upstream) {
|
|
10415
|
+
detailLines.push(`*Upstream:* \`${sanitizeCodeSpanContent(ticket.upstream)}\``);
|
|
10416
|
+
}
|
|
10417
|
+
detailLines.push(`*Input:*
|
|
9944
10418
|
\`\`\`
|
|
9945
10419
|
${safeInput}
|
|
9946
|
-
\`\`\``
|
|
10420
|
+
\`\`\``);
|
|
9947
10421
|
if (ticket.matched_rule) {
|
|
9948
10422
|
detailLines.push(`*Rule:* ${sanitizeMrkdwnText(ticket.matched_rule)}`);
|
|
9949
10423
|
}
|
|
9950
10424
|
if (ticket.session_id) {
|
|
9951
|
-
|
|
10425
|
+
const sessionLine = `*Session:* \`${sanitizeCodeSpanContent(ticket.session_id)}\``;
|
|
10426
|
+
detailLines.push(
|
|
10427
|
+
ticket.session_source ? `${sessionLine} (${sanitizeMrkdwnText(ticket.session_source)})` : sessionLine
|
|
10428
|
+
);
|
|
9952
10429
|
}
|
|
9953
10430
|
const budgetBlocks = buildBudgetBlocks(ticket);
|
|
9954
10431
|
return [
|
|
@@ -10538,7 +11015,7 @@ function createApprovalApp(router, queue, options) {
|
|
|
10538
11015
|
// src/dashboard/api.ts
|
|
10539
11016
|
import { readFileSync } from "fs";
|
|
10540
11017
|
import { join } from "path";
|
|
10541
|
-
import { randomUUID as randomUUID8 } from "crypto";
|
|
11018
|
+
import { randomBytes as randomBytes2, randomUUID as randomUUID8 } from "crypto";
|
|
10542
11019
|
import { Hono as Hono8 } from "hono";
|
|
10543
11020
|
import { HTTPException as HTTPException2 } from "hono/http-exception";
|
|
10544
11021
|
import { z as z8 } from "zod";
|
|
@@ -10547,16 +11024,16 @@ import { serveStatic } from "@hono/node-server/serve-static";
|
|
|
10547
11024
|
import { streamSSE } from "hono/streaming";
|
|
10548
11025
|
|
|
10549
11026
|
// src/dashboard/session.ts
|
|
10550
|
-
import { createHash as
|
|
11027
|
+
import { createHash as createHash4, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
10551
11028
|
var DashboardSessionStore = class {
|
|
10552
|
-
|
|
11029
|
+
signingKey;
|
|
10553
11030
|
ttlMs;
|
|
10554
11031
|
now;
|
|
10555
11032
|
records = /* @__PURE__ */ new Map();
|
|
10556
11033
|
timer = null;
|
|
10557
11034
|
closed = false;
|
|
10558
11035
|
constructor(options) {
|
|
10559
|
-
this.
|
|
11036
|
+
this.signingKey = options.signingKey;
|
|
10560
11037
|
this.ttlMs = options.ttlMs ?? 8 * 60 * 60 * 1e3;
|
|
10561
11038
|
this.now = options.now ?? Date.now;
|
|
10562
11039
|
const cleanupIntervalMs = options.cleanupIntervalMs ?? 6e4;
|
|
@@ -10628,13 +11105,13 @@ var DashboardSessionStore = class {
|
|
|
10628
11105
|
const id = token.slice(0, dot);
|
|
10629
11106
|
const signature = token.slice(dot + 1);
|
|
10630
11107
|
const expected = this.sign(id);
|
|
10631
|
-
const actualDigest =
|
|
10632
|
-
const expectedDigest =
|
|
11108
|
+
const actualDigest = createHash4("sha256").update(signature).digest();
|
|
11109
|
+
const expectedDigest = createHash4("sha256").update(expected).digest();
|
|
10633
11110
|
if (!timingSafeEqual3(actualDigest, expectedDigest)) return void 0;
|
|
10634
11111
|
return id;
|
|
10635
11112
|
}
|
|
10636
11113
|
sign(id) {
|
|
10637
|
-
return createHmac3("sha256", this.
|
|
11114
|
+
return createHmac3("sha256", this.signingKey).update(id).digest("base64url");
|
|
10638
11115
|
}
|
|
10639
11116
|
};
|
|
10640
11117
|
|
|
@@ -10658,7 +11135,13 @@ var clampedQueryInt = (fallback, min, max) => z8.preprocess(
|
|
|
10658
11135
|
);
|
|
10659
11136
|
var feedQuerySchema = z8.object({
|
|
10660
11137
|
limit: clampedQueryInt(50, 1, 200),
|
|
10661
|
-
offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
|
|
11138
|
+
offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
|
|
11139
|
+
// The feed's server-side filters (issues #292, #316): attribution and
|
|
11140
|
+
// session identity source must narrow the fetch window itself, because
|
|
11141
|
+
// slicing an unfiltered newest-N window client-side would miss rare
|
|
11142
|
+
// matches on a busy stream.
|
|
11143
|
+
upstream: optionalQueryString,
|
|
11144
|
+
session_source: optionalQueryString
|
|
10662
11145
|
});
|
|
10663
11146
|
var auditExportQuerySchema = z8.object({
|
|
10664
11147
|
format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
|
|
@@ -10677,7 +11160,9 @@ var auditExportQuerySchema = z8.object({
|
|
|
10677
11160
|
origin: optionalQueryString,
|
|
10678
11161
|
record_kind: optionalQueryString,
|
|
10679
11162
|
channel_id: optionalQueryString,
|
|
10680
|
-
sender_id: optionalQueryString
|
|
11163
|
+
sender_id: optionalQueryString,
|
|
11164
|
+
upstream: optionalQueryString,
|
|
11165
|
+
session_source: optionalQueryString
|
|
10681
11166
|
});
|
|
10682
11167
|
var auditQuerySchema = z8.object({
|
|
10683
11168
|
limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
|
|
@@ -10697,7 +11182,9 @@ var auditQuerySchema = z8.object({
|
|
|
10697
11182
|
origin: optionalQueryString,
|
|
10698
11183
|
record_kind: optionalQueryString,
|
|
10699
11184
|
channel_id: optionalQueryString,
|
|
10700
|
-
sender_id: optionalQueryString
|
|
11185
|
+
sender_id: optionalQueryString,
|
|
11186
|
+
upstream: optionalQueryString,
|
|
11187
|
+
session_source: optionalQueryString
|
|
10701
11188
|
});
|
|
10702
11189
|
var budgetEventsQuerySchema = z8.object({
|
|
10703
11190
|
limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
|
|
@@ -10709,7 +11196,8 @@ var budgetEventsExportQuerySchema = z8.object({
|
|
|
10709
11196
|
});
|
|
10710
11197
|
var analyticsQuerySchema = z8.object({
|
|
10711
11198
|
from: optionalQueryString,
|
|
10712
|
-
to: optionalQueryString
|
|
11199
|
+
to: optionalQueryString,
|
|
11200
|
+
upstream: optionalQueryString
|
|
10713
11201
|
});
|
|
10714
11202
|
var authSessionBodySchema = z8.object({
|
|
10715
11203
|
secret: z8.string()
|
|
@@ -10770,6 +11258,8 @@ function isPrivateIpv4(host) {
|
|
|
10770
11258
|
if (a > 255 || b > 255 || c > 255 || d > 255) return false;
|
|
10771
11259
|
return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
|
|
10772
11260
|
}
|
|
11261
|
+
var MAX_SSE_CONNECTIONS = 256;
|
|
11262
|
+
var REFUSAL_LOG_WINDOW_MS2 = 1e4;
|
|
10773
11263
|
function createDashboardAppWithLifecycle(deps, options) {
|
|
10774
11264
|
const {
|
|
10775
11265
|
auditStore,
|
|
@@ -10783,7 +11273,10 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
10783
11273
|
budgets
|
|
10784
11274
|
} = deps;
|
|
10785
11275
|
const apiSecret = options?.apiSecret;
|
|
10786
|
-
const sessionStore = apiSecret ? new DashboardSessionStore({
|
|
11276
|
+
const sessionStore = apiSecret ? new DashboardSessionStore({
|
|
11277
|
+
signingKey: randomBytes2(32).toString("hex"),
|
|
11278
|
+
ttlMs: SESSION_TTL_MS
|
|
11279
|
+
}) : void 0;
|
|
10787
11280
|
const app = new Hono8();
|
|
10788
11281
|
app.onError((err, c) => {
|
|
10789
11282
|
if (err instanceof HTTPException2) return err.getResponse();
|
|
@@ -10906,7 +11399,10 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
10906
11399
|
const query = feedQuerySchema.parse(c.req.query());
|
|
10907
11400
|
const limit = query.limit;
|
|
10908
11401
|
const offset = query.offset;
|
|
10909
|
-
const result = auditStore.list(
|
|
11402
|
+
const result = auditStore.list(
|
|
11403
|
+
{ upstream: query.upstream, session_source: query.session_source },
|
|
11404
|
+
{ limit, offset, order: "desc" }
|
|
11405
|
+
);
|
|
10910
11406
|
return c.json({
|
|
10911
11407
|
data: result.records,
|
|
10912
11408
|
total: result.total,
|
|
@@ -10933,7 +11429,9 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
10933
11429
|
origin: query.origin,
|
|
10934
11430
|
record_kind: query.record_kind,
|
|
10935
11431
|
channel_id: query.channel_id,
|
|
10936
|
-
sender_id: query.sender_id
|
|
11432
|
+
sender_id: query.sender_id,
|
|
11433
|
+
upstream: query.upstream,
|
|
11434
|
+
session_source: query.session_source
|
|
10937
11435
|
};
|
|
10938
11436
|
const result = auditStore.listForExport(filters, limit);
|
|
10939
11437
|
if (format === "csv") {
|
|
@@ -10979,7 +11477,9 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
10979
11477
|
origin: query.origin,
|
|
10980
11478
|
record_kind: query.record_kind,
|
|
10981
11479
|
channel_id: query.channel_id,
|
|
10982
|
-
sender_id: query.sender_id
|
|
11480
|
+
sender_id: query.sender_id,
|
|
11481
|
+
upstream: query.upstream,
|
|
11482
|
+
session_source: query.session_source
|
|
10983
11483
|
};
|
|
10984
11484
|
const result = auditStore.list(filters, { limit, offset, order: "desc" });
|
|
10985
11485
|
return c.json({
|
|
@@ -11045,7 +11545,7 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
11045
11545
|
const defaultFrom = new Date(now.getTime() - 24 * 60 * 60 * 1e3).toISOString();
|
|
11046
11546
|
const from = query.from ?? defaultFrom;
|
|
11047
11547
|
const to = query.to ?? now.toISOString();
|
|
11048
|
-
const stats = auditStore.aggregate(from, to);
|
|
11548
|
+
const stats = auditStore.aggregate(from, to, { upstream: query.upstream });
|
|
11049
11549
|
return c.json(stats);
|
|
11050
11550
|
});
|
|
11051
11551
|
app.get("/api/evidence/:session_id", (c) => {
|
|
@@ -11057,17 +11557,22 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
11057
11557
|
let closed = false;
|
|
11058
11558
|
const heartbeatMs = Math.max(options?.sseHeartbeatMs ?? 3e4, 1e3);
|
|
11059
11559
|
const staleThresholdMs = heartbeatMs * 3;
|
|
11060
|
-
const sweepIntervalMs = Math.max(heartbeatMs * 2, 1e4);
|
|
11061
|
-
const
|
|
11062
|
-
|
|
11063
|
-
|
|
11064
|
-
|
|
11065
|
-
|
|
11066
|
-
|
|
11560
|
+
const sweepIntervalMs = options?.sweepIntervalMs ?? Math.max(heartbeatMs * 2, 1e4);
|
|
11561
|
+
const maxSseConnections = options?.maxSseConnections ?? MAX_SSE_CONNECTIONS;
|
|
11562
|
+
let sweepInterval;
|
|
11563
|
+
if (sweepIntervalMs > 0) {
|
|
11564
|
+
sweepInterval = setInterval(() => {
|
|
11565
|
+
const now = Date.now();
|
|
11566
|
+
for (const [id, conn] of activeConnections) {
|
|
11567
|
+
if (now - conn.lastWrite > staleThresholdMs) {
|
|
11568
|
+
conn.cleanup();
|
|
11569
|
+
conn.sever();
|
|
11570
|
+
activeConnections.delete(id);
|
|
11571
|
+
}
|
|
11067
11572
|
}
|
|
11068
|
-
}
|
|
11069
|
-
|
|
11070
|
-
|
|
11573
|
+
}, sweepIntervalMs);
|
|
11574
|
+
sweepInterval.unref();
|
|
11575
|
+
}
|
|
11071
11576
|
const close = () => {
|
|
11072
11577
|
if (closed) return;
|
|
11073
11578
|
closed = true;
|
|
@@ -11077,7 +11582,22 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
11077
11582
|
}
|
|
11078
11583
|
activeConnections.clear();
|
|
11079
11584
|
};
|
|
11585
|
+
let refusalCount = 0;
|
|
11586
|
+
let lastRefusalLogAt = null;
|
|
11587
|
+
const logRefusal = () => {
|
|
11588
|
+
refusalCount += 1;
|
|
11589
|
+
const now = Date.now();
|
|
11590
|
+
if (lastRefusalLogAt !== null && now - lastRefusalLogAt < REFUSAL_LOG_WINDOW_MS2) return;
|
|
11591
|
+
lastRefusalLogAt = now;
|
|
11592
|
+
console.error(
|
|
11593
|
+
`[helio] /api/events at connection cap (${String(maxSseConnections)}); refusing new streams (${String(refusalCount)} refusals so far).`
|
|
11594
|
+
);
|
|
11595
|
+
};
|
|
11080
11596
|
app.get("/api/events", (c) => {
|
|
11597
|
+
if (activeConnections.size >= maxSseConnections) {
|
|
11598
|
+
logRefusal();
|
|
11599
|
+
return c.json({ error: "connection capacity reached" }, 503);
|
|
11600
|
+
}
|
|
11081
11601
|
return streamSSE(c, async (stream) => {
|
|
11082
11602
|
if (closed) return;
|
|
11083
11603
|
const connId = randomUUID8();
|
|
@@ -11098,7 +11618,12 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
11098
11618
|
activeConnections.delete(connId);
|
|
11099
11619
|
releaseStream();
|
|
11100
11620
|
};
|
|
11101
|
-
|
|
11621
|
+
const sever = () => {
|
|
11622
|
+
stream.abort();
|
|
11623
|
+
c.env?.outgoing?.destroy();
|
|
11624
|
+
};
|
|
11625
|
+
if (activeConnections.size >= maxSseConnections) return;
|
|
11626
|
+
activeConnections.set(connId, { cleanup, sever, lastWrite: Date.now() });
|
|
11102
11627
|
try {
|
|
11103
11628
|
await stream.writeSSE({ data: "", event: "heartbeat" });
|
|
11104
11629
|
} catch {
|
|
@@ -11237,9 +11762,12 @@ export {
|
|
|
11237
11762
|
createApprovalApp,
|
|
11238
11763
|
createChannels,
|
|
11239
11764
|
createDashboardApp,
|
|
11765
|
+
createMultiApp,
|
|
11240
11766
|
createSidebandApp,
|
|
11241
11767
|
createSlackActionApp,
|
|
11242
11768
|
evaluatePolicy,
|
|
11769
|
+
isNamedConfig,
|
|
11770
|
+
isSingularConfig,
|
|
11243
11771
|
loadConfig,
|
|
11244
11772
|
matchRule,
|
|
11245
11773
|
startServer,
|