@cortexkit/opencode-magic-context 0.33.1 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/agent-disable.d.ts +22 -0
- package/dist/config/agent-disable.d.ts.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/project-security.d.ts +7 -0
- package/dist/config/project-security.d.ts.map +1 -1
- package/dist/config/schema/magic-context.d.ts +82 -2
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/config/transform-mode.d.ts +2 -0
- package/dist/config/transform-mode.d.ts.map +1 -1
- package/dist/{conflict-warning-hook-n48c783v.js → conflict-warning-hook-fvpfvt0w.js} +1 -1
- package/dist/{derive-budgets-e9j09sjr.js → derive-budgets-9crp0cvd.js} +1 -1
- package/dist/features/builtin-commands/commands.d.ts +1 -1
- package/dist/features/builtin-commands/commands.d.ts.map +1 -1
- package/dist/features/magic-context/compaction-marker.d.ts +57 -0
- package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
- package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
- package/dist/features/magic-context/fail-closed-block.d.ts +10 -0
- package/dist/features/magic-context/fail-closed-block.d.ts.map +1 -1
- package/dist/features/magic-context/git-commits/storage-git-commit-embeddings.d.ts.map +1 -1
- package/dist/features/magic-context/git-commits/storage-git-commits.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-local.d.ts +7 -1
- package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
- package/dist/features/magic-context/memory/memory-migration.d.ts.map +1 -1
- package/dist/features/magic-context/memory/project-identity.d.ts +2 -6
- package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
- package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
- package/dist/features/magic-context/mural/render-mural.d.ts.map +1 -1
- package/dist/features/magic-context/overflow-detection.d.ts +8 -1
- package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/scheduler.d.ts.map +1 -1
- package/dist/features/magic-context/schema-fence-probe.d.ts +28 -0
- package/dist/features/magic-context/schema-fence-probe.d.ts.map +1 -0
- package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +35 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +38 -4
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta.d.ts +1 -1
- package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +1 -1
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
- package/dist/hooks/magic-context/boundary-execution.d.ts +1 -1
- package/dist/hooks/magic-context/boundary-execution.d.ts.map +1 -1
- package/dist/hooks/magic-context/cache-busting-signals.d.ts +1 -0
- package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -1
- package/dist/hooks/magic-context/child-session-spawn.d.ts +26 -0
- package/dist/hooks/magic-context/child-session-spawn.d.ts.map +1 -0
- package/dist/hooks/magic-context/command-handler.d.ts +2 -0
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/compaction-off-transition.d.ts +96 -0
- package/dist/hooks/magic-context/compaction-off-transition.d.ts.map +1 -0
- package/dist/hooks/magic-context/compartment-runner-historian.d.ts +2 -0
- package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-mapping.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-trigger.d.ts +12 -5
- package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
- package/dist/hooks/magic-context/ctx-reduce-availability.d.ts +47 -1
- package/dist/hooks/magic-context/ctx-reduce-availability.d.ts.map +1 -1
- package/dist/hooks/magic-context/emergency-drop.d.ts +2 -2
- package/dist/hooks/magic-context/event-handler.d.ts +7 -0
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/event-resolvers.d.ts +13 -21
- package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +2 -0
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -1
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts +1 -1
- package/dist/hooks/magic-context/hook-handlers.d.ts +1 -0
- package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts +11 -1
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +9 -0
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -1
- package/dist/hooks/magic-context/rust-mode-transform.d.ts +3 -0
- package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/send-session-notification.d.ts +20 -1
- package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts +3 -0
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +30 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +27 -0
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/{index-ey1bc14z.js → index-b563sga5.js} +7 -264
- package/dist/{index-y1c9tcm3.js → index-cn960ffd.js} +101 -20
- package/dist/{index-xyaekybw.js → index-dtas91s5.js} +6 -4
- package/dist/index-fcyvznch.js +279 -0
- package/dist/{index-63dq2g4d.js → index-ry8rh8cv.js} +138 -24
- package/dist/{index-c2gahrks.js → index-s0mdn690.js} +1599 -892
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1292 -623
- package/dist/plugin/embedding-bootstrap.d.ts.map +1 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts +11 -0
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/messages-transform.d.ts +10 -0
- package/dist/plugin/messages-transform.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts +3 -7
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/plugin/tool-registry.d.ts +8 -0
- package/dist/plugin/tool-registry.d.ts.map +1 -1
- package/dist/{recomp-orchestrator-2yb2hfh2.js → recomp-orchestrator-8e82vmvb.js} +6 -4
- package/dist/send-session-notification-6njqewzk.js +20 -0
- package/dist/shared/conflict-detector.d.ts +38 -1
- package/dist/shared/conflict-detector.d.ts.map +1 -1
- package/dist/shared/conflict-fixer.d.ts +18 -1
- package/dist/shared/conflict-fixer.d.ts.map +1 -1
- package/dist/shared/context-limit-provenance.d.ts +3 -0
- package/dist/shared/context-limit-provenance.d.ts.map +1 -0
- package/dist/shared/escalation-bands.d.ts +9 -0
- package/dist/shared/escalation-bands.d.ts.map +1 -0
- package/dist/shared/format-threshold.d.ts +1 -1
- package/dist/shared/models-dev-cache.d.ts +29 -3
- package/dist/shared/models-dev-cache.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/rpc-types.d.ts +46 -1
- package/dist/shared/rpc-types.d.ts.map +1 -1
- package/dist/shared/storage-permissions.d.ts +5 -0
- package/dist/shared/storage-permissions.d.ts.map +1 -0
- package/dist/{storage-meta-session-td6bwtky.js → storage-meta-session-xt41zbw4.js} +2 -1
- package/dist/tui/compaction-off.d.ts +13 -0
- package/dist/tui/compaction-off.d.ts.map +1 -0
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/dist/tui-compiled/compaction-off.d.ts +13 -0
- package/dist/tui-compiled/compaction-off.d.ts.map +1 -0
- package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
- package/package.json +7 -2
- package/src/agents/dreamer.ts +68 -0
- package/src/agents/hidden-agent-registrations.ts +331 -0
- package/src/agents/historian.ts +3 -0
- package/src/agents/language-directive.test.ts +138 -0
- package/src/agents/language-directive.ts +121 -0
- package/src/agents/magic-context-prompt.language.test.ts +35 -0
- package/src/agents/magic-context-prompt.test.ts +292 -0
- package/src/agents/magic-context-prompt.ts +182 -0
- package/src/agents/permissions.test.ts +252 -0
- package/src/agents/permissions.ts +186 -0
- package/src/agents/sidekick.ts +1 -0
- package/src/agents/smart-note-compiler.ts +1 -0
- package/src/config/agent-disable.ts +106 -0
- package/src/config/compaction-accessor-guard.test.ts +82 -0
- package/src/config/compaction-backcompat.test.ts +52 -0
- package/src/config/index.test.ts +904 -0
- package/src/config/index.ts +601 -0
- package/src/config/migrate-config-location.test.ts +332 -0
- package/src/config/migrate-config-location.ts +635 -0
- package/src/config/migrate-dreamer-v2.test.ts +287 -0
- package/src/config/migrate-dreamer-v2.ts +336 -0
- package/src/config/migrate-experimental.ts +159 -0
- package/src/config/project-security.test.ts +366 -0
- package/src/config/project-security.ts +588 -0
- package/src/config/prune-config-leaf.test.ts +78 -0
- package/src/config/prune-config-leaf.ts +68 -0
- package/src/config/removed-config-keys.test.ts +116 -0
- package/src/config/schema/agent-overrides.ts +45 -0
- package/src/config/schema/magic-context.test.ts +358 -0
- package/src/config/schema/magic-context.ts +1002 -0
- package/src/config/transform-mode.test.ts +62 -0
- package/src/config/transform-mode.ts +34 -0
- package/src/config/variable.test.ts +323 -0
- package/src/config/variable.ts +192 -0
- package/src/features/magic-context/defaults.ts +1 -0
- package/src/features/magic-context/dreamer/cron.ts +243 -0
- package/src/features/magic-context/dreamer/task-registry.ts +104 -0
- package/src/shared/conflict-detector.test.ts +121 -0
- package/src/shared/conflict-detector.ts +50 -3
- package/src/shared/conflict-fixer.test.ts +309 -0
- package/src/shared/conflict-fixer.ts +82 -5
- package/src/shared/context-limit-provenance.ts +6 -0
- package/src/shared/escalation-bands.test.ts +23 -0
- package/src/shared/escalation-bands.ts +18 -0
- package/src/shared/format-threshold.ts +1 -1
- package/src/shared/models-dev-cache.test.ts +171 -0
- package/src/shared/models-dev-cache.ts +195 -38
- package/src/shared/rpc-server.ts +27 -18
- package/src/shared/rpc-types.ts +46 -1
- package/src/shared/storage-permissions.ts +20 -0
- package/src/tui/compaction-off.test.ts +56 -0
- package/src/tui/compaction-off.ts +29 -0
- package/src/tui/data/context-db.ts +6 -0
- package/src/tui/index.tsx +113 -65
- package/src/tui/slots/sidebar-content.tsx +139 -83
- package/src/tui-compiled/compaction-off.ts +29 -0
- package/src/tui-compiled/data/context-db.ts +6 -0
- package/src/tui-compiled/index.tsx +558 -455
- package/src/tui-compiled/slots/sidebar-content.tsx +237 -161
- package/dist/send-session-notification-tghq9gnh.js +0 -11
- /package/dist/{index-v7fmc1s2.js → index-ygcng9t8.js} +0 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import { DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE } from "./schema/magic-context";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Security hardening for PROJECT-level (repo-supplied, untrusted) config.
|
|
5
|
+
*
|
|
6
|
+
* A project config lives inside a repository the user cloned. Opening a repo
|
|
7
|
+
* must never let that repo's config escalate privilege or exfiltrate secrets.
|
|
8
|
+
* These helpers run on the raw project config BEFORE/AFTER it is merged over the
|
|
9
|
+
* trusted user config, mutating the relevant object in place and returning
|
|
10
|
+
* human-readable warnings.
|
|
11
|
+
*
|
|
12
|
+
* Shared by both harnesses (OpenCode `config/index.ts` and Pi
|
|
13
|
+
* `config/index.ts`) so the trust boundary is identical cross-harness.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Hidden agents that run with elevated/autonomous capability. */
|
|
17
|
+
const HIDDEN_AGENT_KEYS = ["historian", "dreamer", "sidekick"] as const;
|
|
18
|
+
const HISTORIAN_USER_ONLY_FIELDS = ["model", "fallback_models"] as const;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fields on a hidden-agent block that constitute a privilege-escalation /
|
|
22
|
+
* code-execution vector when set from an untrusted repo:
|
|
23
|
+
*
|
|
24
|
+
* - `prompt` — reprograms the agent's instructions. The dreamer runs
|
|
25
|
+
* AUTONOMOUSLY in the background with `bash`/`edit`/`webfetch`,
|
|
26
|
+
* so a repo-supplied prompt is an unattended exfil/RCE path.
|
|
27
|
+
* - `permission` — broadens the agent's per-tool permissions.
|
|
28
|
+
* - `tools` — enable/disable map; could flip a denied tool (e.g. `bash`)
|
|
29
|
+
* on for an agent whose allow-list intentionally excludes it.
|
|
30
|
+
* - `system_prompt` — sidekick's custom system prompt. It takes precedence over
|
|
31
|
+
* the built-in prompt (sidekick/agent.ts reads
|
|
32
|
+
* `config.system_prompt` before `config.prompt`), so leaving it
|
|
33
|
+
* unstripped reopens the exact reprogramming vector `prompt`
|
|
34
|
+
* closes — a cloned repo could rewrite sidekick's instructions
|
|
35
|
+
* via `/ctx-aug`.
|
|
36
|
+
*
|
|
37
|
+
* Dreamer model/cadence fields are deliberately NOT stripped: a repo may tune
|
|
38
|
+
* its own dreamer overlays and schedules through the user's provider auth.
|
|
39
|
+
* Historian model selection stays USER-tier only, and compaction thresholds are
|
|
40
|
+
* project raise-only, so a cloned repo cannot force earlier compaction or extra
|
|
41
|
+
* historian spend on the user's dime.
|
|
42
|
+
*/
|
|
43
|
+
const AGENT_ESCALATION_FIELDS = ["prompt", "permission", "tools", "system_prompt"] as const;
|
|
44
|
+
const EMBEDDING_DESTINATION_FIELDS = ["endpoint", "provider", "fallback_provider"] as const;
|
|
45
|
+
const PERCENTAGE_THRESHOLD_REASON =
|
|
46
|
+
"security: a repository may only raise compaction thresholds above the user's effective value; it cannot force earlier historian work or cloned-repo cost escalation.";
|
|
47
|
+
const TOKEN_THRESHOLD_REASON =
|
|
48
|
+
"security: a repository may only raise execute_threshold_tokens above the user's trusted token threshold; it cannot force earlier historian work or cloned-repo cost escalation.";
|
|
49
|
+
const TOKEN_THRESHOLD_INTRODUCTION_REASON =
|
|
50
|
+
"security: a repository cannot introduce a new execute_threshold_tokens override when the user has no trusted token threshold for that key; that could force earlier historian work or cloned-repo cost escalation.";
|
|
51
|
+
|
|
52
|
+
interface PercentageThresholdConfig {
|
|
53
|
+
defaultValue: number;
|
|
54
|
+
overrides: Map<string, number>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface TokenThresholdConfig {
|
|
58
|
+
defaultValue: number | undefined;
|
|
59
|
+
overrides: Map<string, number>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
63
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isValidPercentageThreshold(value: unknown): value is number {
|
|
67
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 20 && value <= 80;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isValidTokenThreshold(value: unknown): value is number {
|
|
71
|
+
return (
|
|
72
|
+
typeof value === "number" && Number.isFinite(value) && value >= 5_000 && value <= 2_000_000
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeTrustedPercentageThresholds(value: unknown): PercentageThresholdConfig {
|
|
77
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
78
|
+
return { defaultValue: value, overrides: new Map() };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
isPlainObject(value) &&
|
|
83
|
+
typeof value.default === "number" &&
|
|
84
|
+
Number.isFinite(value.default)
|
|
85
|
+
) {
|
|
86
|
+
const overrides = new Map<string, number>();
|
|
87
|
+
for (const [key, child] of Object.entries(value)) {
|
|
88
|
+
if (key === "default") continue;
|
|
89
|
+
if (typeof child === "number" && Number.isFinite(child)) {
|
|
90
|
+
overrides.set(key, child);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { defaultValue: value.default, overrides };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { defaultValue: DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE, overrides: new Map() };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeTrustedTokenThresholds(value: unknown): TokenThresholdConfig {
|
|
100
|
+
if (!isPlainObject(value)) {
|
|
101
|
+
return { defaultValue: undefined, overrides: new Map() };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const overrides = new Map<string, number>();
|
|
105
|
+
for (const [key, child] of Object.entries(value)) {
|
|
106
|
+
if (key === "default") continue;
|
|
107
|
+
if (typeof child === "number" && Number.isFinite(child)) {
|
|
108
|
+
overrides.set(key, child);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
defaultValue:
|
|
114
|
+
typeof value.default === "number" && Number.isFinite(value.default)
|
|
115
|
+
? value.default
|
|
116
|
+
: undefined,
|
|
117
|
+
overrides,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function clonePercentageThresholds(value: PercentageThresholdConfig): PercentageThresholdConfig {
|
|
122
|
+
return {
|
|
123
|
+
defaultValue: value.defaultValue,
|
|
124
|
+
overrides: new Map(value.overrides),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function cloneTokenThresholds(value: TokenThresholdConfig): TokenThresholdConfig {
|
|
129
|
+
return {
|
|
130
|
+
defaultValue: value.defaultValue,
|
|
131
|
+
overrides: new Map(value.overrides),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function percentageThresholdsEqual(
|
|
136
|
+
left: PercentageThresholdConfig,
|
|
137
|
+
right: PercentageThresholdConfig,
|
|
138
|
+
): boolean {
|
|
139
|
+
if (left.defaultValue !== right.defaultValue) return false;
|
|
140
|
+
if (left.overrides.size !== right.overrides.size) return false;
|
|
141
|
+
for (const [key, value] of left.overrides) {
|
|
142
|
+
if (right.overrides.get(key) !== value) return false;
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function setMergedPercentageThreshold(
|
|
148
|
+
mergedRaw: Record<string, unknown>,
|
|
149
|
+
value: PercentageThresholdConfig,
|
|
150
|
+
): void {
|
|
151
|
+
if (value.overrides.size === 0) {
|
|
152
|
+
mergedRaw.execute_threshold_percentage = value.defaultValue;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const serialized: Record<string, number> = { default: value.defaultValue };
|
|
157
|
+
for (const [key, threshold] of value.overrides) {
|
|
158
|
+
serialized[key] = threshold;
|
|
159
|
+
}
|
|
160
|
+
mergedRaw.execute_threshold_percentage = serialized;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function setMergedTokenThreshold(
|
|
164
|
+
mergedRaw: Record<string, unknown>,
|
|
165
|
+
value: TokenThresholdConfig,
|
|
166
|
+
): void {
|
|
167
|
+
if (value.defaultValue === undefined && value.overrides.size === 0) {
|
|
168
|
+
delete mergedRaw.execute_threshold_tokens;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const serialized: Record<string, number> = {};
|
|
173
|
+
if (value.defaultValue !== undefined) {
|
|
174
|
+
serialized.default = value.defaultValue;
|
|
175
|
+
}
|
|
176
|
+
for (const [key, threshold] of value.overrides) {
|
|
177
|
+
serialized[key] = threshold;
|
|
178
|
+
}
|
|
179
|
+
mergedRaw.execute_threshold_tokens = serialized;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function makeProjectThresholdWarning(field: string, reason: string): string {
|
|
183
|
+
return `Ignoring ${field} from project config (${reason})`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Strip unsafe fields from a raw PROJECT config IN PLACE, before it is merged
|
|
188
|
+
* over the user config. Returns warnings describing what was ignored.
|
|
189
|
+
*
|
|
190
|
+
* Closes:
|
|
191
|
+
* - `auto_update` — a repo must not suppress plugin self-updates (which can
|
|
192
|
+
* carry security fixes).
|
|
193
|
+
* - `fail_closed_blocking` — a repo must not un-block (or force-block) the
|
|
194
|
+
* loud inoperability gate; only the user may restore silent degrade.
|
|
195
|
+
* - `allow_home_project` — only the user may opt a home-directory session
|
|
196
|
+
* into a durable project identity.
|
|
197
|
+
* - `output_reserve` — only the user may change the process-wide safe input budget.
|
|
198
|
+
* - `language`: a repo must not inject prompt text through a user preference.
|
|
199
|
+
* - `sqlite` — `sqlite.cache_size_mb` / `mmap_size_mb` become PRAGMAs on the
|
|
200
|
+
* process-global shared DB handle (one connection across every project in the
|
|
201
|
+
* process). A cloned repo could set a huge value to exhaust host memory /
|
|
202
|
+
* address space — a resource-exhaustion vector with no legitimate per-repo
|
|
203
|
+
* use. Honor user-level config only.
|
|
204
|
+
* - `storage.enforce_private_permissions` — changing a shared store from
|
|
205
|
+
* owner-private to group-readable changes every session and memory's local
|
|
206
|
+
* confidentiality. Only the machine operator's user config may opt into an
|
|
207
|
+
* externally managed trusted-group deployment.
|
|
208
|
+
* - `embedding.endpoint` / `embedding.provider` — a repo must not choose
|
|
209
|
+
* where private memory/search/commit text is embedded. User-level config is
|
|
210
|
+
* the trust boundary for embedding destinations.
|
|
211
|
+
* - `transform_mode` is intentionally allowed at project tier so a repository
|
|
212
|
+
* can opt its own runtime into the experimental Rust pipeline. The resolver
|
|
213
|
+
* requires trusted user-level `subc` configuration before Rust can activate.
|
|
214
|
+
* - `historian.model` / `historian.fallback_models` — historian model spend is
|
|
215
|
+
* user-level only; a cloned repo cannot force extra compaction cost.
|
|
216
|
+
* - `pi.subagent_extensions` — a cloned repo must not choose which extensions
|
|
217
|
+
* the user's Pi child processes load.
|
|
218
|
+
* - hidden-agent `prompt`/`permission`/`tools` — a repo must not reprogram or
|
|
219
|
+
* re-permission the historian/dreamer/sidekick.
|
|
220
|
+
*/
|
|
221
|
+
export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknown>): string[] {
|
|
222
|
+
const warnings: string[] = [];
|
|
223
|
+
|
|
224
|
+
if ("auto_update" in projectRaw) {
|
|
225
|
+
delete projectRaw.auto_update;
|
|
226
|
+
warnings.push(
|
|
227
|
+
"Ignoring auto_update from project config (security: this setting only honors user-level config).",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if ("fail_closed_blocking" in projectRaw) {
|
|
232
|
+
delete projectRaw.fail_closed_blocking;
|
|
233
|
+
warnings.push(
|
|
234
|
+
"Ignoring fail_closed_blocking from project config (security: only user-level config may disable or force the loud inoperability gate).",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if ("allow_home_project" in projectRaw) {
|
|
239
|
+
delete projectRaw.allow_home_project;
|
|
240
|
+
warnings.push(
|
|
241
|
+
"Ignoring allow_home_project from project config (security: only user-level config may opt the user's home directory into Magic Context).",
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// compaction.enabled is USER-tier only: a cloned repo must never silently
|
|
246
|
+
// disable the user's context management (it changes how the window is
|
|
247
|
+
// owned and interacts with native compaction state). Field-scoped, not
|
|
248
|
+
// block-scoped: a sibling key in a project `compaction` block survives the
|
|
249
|
+
// strip, so a future project-tier compaction knob can still land.
|
|
250
|
+
const compaction = projectRaw.compaction;
|
|
251
|
+
if (isPlainObject(compaction) && "enabled" in compaction) {
|
|
252
|
+
delete compaction.enabled;
|
|
253
|
+
warnings.push(
|
|
254
|
+
"Ignoring compaction.enabled from project config (security: only user-level config may disable Magic Context's context-window management; a cloned repo cannot change how the user's window is owned).",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if ("output_reserve" in projectRaw) {
|
|
259
|
+
delete projectRaw.output_reserve;
|
|
260
|
+
warnings.push(
|
|
261
|
+
"Ignoring output_reserve from project config (security: output-token reservation only honors user-level config).",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if ("language" in projectRaw) {
|
|
266
|
+
delete projectRaw.language;
|
|
267
|
+
warnings.push(
|
|
268
|
+
"Ignoring language from project config (security: output language is a user-level setting).",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if ("sqlite" in projectRaw) {
|
|
273
|
+
delete projectRaw.sqlite;
|
|
274
|
+
warnings.push(
|
|
275
|
+
"Ignoring sqlite.* from project config (security: SQLite cache/mmap PRAGMAs apply to the " +
|
|
276
|
+
"process-global shared database handle; only user-level config may set them).",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// storage.enforce_private_permissions is USER-tier only because disabling it
|
|
281
|
+
// changes the confidentiality of the process-global shared store. Field-scoped
|
|
282
|
+
// stripping preserves future project-tier storage settings in the same block.
|
|
283
|
+
const storage = projectRaw.storage;
|
|
284
|
+
if (isPlainObject(storage) && "enforce_private_permissions" in storage) {
|
|
285
|
+
delete storage.enforce_private_permissions;
|
|
286
|
+
warnings.push(
|
|
287
|
+
"Ignoring storage.enforce_private_permissions from project config (security: only user-level config may opt into externally managed shared storage permissions).",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const pi = projectRaw.pi;
|
|
292
|
+
if (isPlainObject(pi) && "subagent_extensions" in pi) {
|
|
293
|
+
delete pi.subagent_extensions;
|
|
294
|
+
warnings.push(
|
|
295
|
+
"Ignoring pi.subagent_extensions from project config (security: only user-level config may choose extensions loaded by Pi subagent children).",
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
for (const field of ["subc", "shadow_embedding"] as const) {
|
|
300
|
+
if (field in projectRaw) {
|
|
301
|
+
delete projectRaw[field];
|
|
302
|
+
warnings.push(
|
|
303
|
+
`Ignoring ${field} from project config (security: daemon routing and developer-only embedding traffic are user-level settings).`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const embedding = projectRaw.embedding;
|
|
309
|
+
if (isPlainObject(embedding)) {
|
|
310
|
+
const removed: string[] = [];
|
|
311
|
+
for (const field of EMBEDDING_DESTINATION_FIELDS) {
|
|
312
|
+
if (field in embedding) {
|
|
313
|
+
delete embedding[field];
|
|
314
|
+
removed.push(field);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (removed.length > 0) {
|
|
318
|
+
warnings.push(
|
|
319
|
+
`Ignoring embedding.${removed.join("/")} from project config ` +
|
|
320
|
+
"(security: a repository cannot choose where private text is embedded).",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const historian = projectRaw.historian;
|
|
326
|
+
if (isPlainObject(historian)) {
|
|
327
|
+
const removed: string[] = [];
|
|
328
|
+
for (const field of HISTORIAN_USER_ONLY_FIELDS) {
|
|
329
|
+
if (field in historian) {
|
|
330
|
+
delete historian[field];
|
|
331
|
+
removed.push(field);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (removed.length > 0) {
|
|
335
|
+
warnings.push(
|
|
336
|
+
`Ignoring historian.${removed.join("/")} from project config ` +
|
|
337
|
+
"(security: historian model selection is user-level only; a repository cannot force extra compaction cost).",
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const agentKey of HIDDEN_AGENT_KEYS) {
|
|
343
|
+
const block = projectRaw[agentKey];
|
|
344
|
+
if (!isPlainObject(block)) continue;
|
|
345
|
+
const removed: string[] = [];
|
|
346
|
+
for (const field of AGENT_ESCALATION_FIELDS) {
|
|
347
|
+
if (field in block) {
|
|
348
|
+
delete block[field];
|
|
349
|
+
removed.push(field);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (removed.length > 0) {
|
|
353
|
+
warnings.push(
|
|
354
|
+
`Ignoring ${agentKey}.${removed.join("/")} from project config ` +
|
|
355
|
+
"(security: a repository cannot reprogram or re-permission hidden agents).",
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return warnings;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Clamp project-tier compaction thresholds after merge so a cloned repository
|
|
365
|
+
* may only DELAY compaction relative to the trusted user/default settings. A
|
|
366
|
+
* repo may never lower thresholds in a way that forces earlier historian work
|
|
367
|
+
* or cloned-repo cost escalation on the user's account.
|
|
368
|
+
*/
|
|
369
|
+
export function constrainProjectThresholdOverrides(args: {
|
|
370
|
+
mergedRaw: Record<string, unknown>;
|
|
371
|
+
projectRaw: Record<string, unknown>;
|
|
372
|
+
trustedBaseConfig: {
|
|
373
|
+
execute_threshold_percentage?: unknown;
|
|
374
|
+
execute_threshold_tokens?: unknown;
|
|
375
|
+
};
|
|
376
|
+
}): string[] {
|
|
377
|
+
const warnings: string[] = [];
|
|
378
|
+
const basePercentage = normalizeTrustedPercentageThresholds(
|
|
379
|
+
args.trustedBaseConfig.execute_threshold_percentage,
|
|
380
|
+
);
|
|
381
|
+
const baseTokens = normalizeTrustedTokenThresholds(
|
|
382
|
+
args.trustedBaseConfig.execute_threshold_tokens,
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
if ("execute_threshold_percentage" in args.projectRaw) {
|
|
386
|
+
const projectValue = args.projectRaw.execute_threshold_percentage;
|
|
387
|
+
|
|
388
|
+
if (isValidPercentageThreshold(projectValue)) {
|
|
389
|
+
const constrained = clonePercentageThresholds(basePercentage);
|
|
390
|
+
constrained.defaultValue = Math.max(basePercentage.defaultValue, projectValue);
|
|
391
|
+
for (const [modelKey, threshold] of basePercentage.overrides) {
|
|
392
|
+
const raisedThreshold = Math.max(threshold, projectValue);
|
|
393
|
+
if (raisedThreshold === constrained.defaultValue) {
|
|
394
|
+
constrained.overrides.delete(modelKey);
|
|
395
|
+
} else {
|
|
396
|
+
constrained.overrides.set(modelKey, raisedThreshold);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
setMergedPercentageThreshold(args.mergedRaw, constrained);
|
|
400
|
+
if (percentageThresholdsEqual(constrained, basePercentage)) {
|
|
401
|
+
warnings.push(
|
|
402
|
+
makeProjectThresholdWarning(
|
|
403
|
+
"execute_threshold_percentage",
|
|
404
|
+
PERCENTAGE_THRESHOLD_REASON,
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
} else if (isPlainObject(projectValue)) {
|
|
409
|
+
const constrained = clonePercentageThresholds(basePercentage);
|
|
410
|
+
let touchedValidEntry = false;
|
|
411
|
+
|
|
412
|
+
if (isValidPercentageThreshold(projectValue.default)) {
|
|
413
|
+
touchedValidEntry = true;
|
|
414
|
+
if (projectValue.default > basePercentage.defaultValue) {
|
|
415
|
+
constrained.defaultValue = projectValue.default;
|
|
416
|
+
} else {
|
|
417
|
+
warnings.push(
|
|
418
|
+
makeProjectThresholdWarning(
|
|
419
|
+
"execute_threshold_percentage.default",
|
|
420
|
+
PERCENTAGE_THRESHOLD_REASON,
|
|
421
|
+
),
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
for (const [modelKey, rawValue] of Object.entries(projectValue)) {
|
|
427
|
+
if (modelKey === "default") continue;
|
|
428
|
+
if (!isValidPercentageThreshold(rawValue)) continue;
|
|
429
|
+
touchedValidEntry = true;
|
|
430
|
+
const baseValue =
|
|
431
|
+
basePercentage.overrides.get(modelKey) ?? basePercentage.defaultValue;
|
|
432
|
+
if (rawValue > baseValue) {
|
|
433
|
+
if (rawValue === constrained.defaultValue) {
|
|
434
|
+
constrained.overrides.delete(modelKey);
|
|
435
|
+
} else {
|
|
436
|
+
constrained.overrides.set(modelKey, rawValue);
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
warnings.push(
|
|
440
|
+
makeProjectThresholdWarning(
|
|
441
|
+
`execute_threshold_percentage.${modelKey}`,
|
|
442
|
+
PERCENTAGE_THRESHOLD_REASON,
|
|
443
|
+
),
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (touchedValidEntry) {
|
|
449
|
+
setMergedPercentageThreshold(args.mergedRaw, constrained);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (
|
|
455
|
+
"execute_threshold_tokens" in args.projectRaw &&
|
|
456
|
+
isPlainObject(args.projectRaw.execute_threshold_tokens)
|
|
457
|
+
) {
|
|
458
|
+
const projectValue = args.projectRaw.execute_threshold_tokens;
|
|
459
|
+
const constrained = cloneTokenThresholds(baseTokens);
|
|
460
|
+
let touchedValidEntry = false;
|
|
461
|
+
|
|
462
|
+
if (isValidTokenThreshold(projectValue.default)) {
|
|
463
|
+
touchedValidEntry = true;
|
|
464
|
+
if (baseTokens.defaultValue === undefined) {
|
|
465
|
+
warnings.push(
|
|
466
|
+
makeProjectThresholdWarning(
|
|
467
|
+
"execute_threshold_tokens.default",
|
|
468
|
+
TOKEN_THRESHOLD_INTRODUCTION_REASON,
|
|
469
|
+
),
|
|
470
|
+
);
|
|
471
|
+
} else if (projectValue.default > baseTokens.defaultValue) {
|
|
472
|
+
constrained.defaultValue = projectValue.default;
|
|
473
|
+
} else {
|
|
474
|
+
warnings.push(
|
|
475
|
+
makeProjectThresholdWarning(
|
|
476
|
+
"execute_threshold_tokens.default",
|
|
477
|
+
TOKEN_THRESHOLD_REASON,
|
|
478
|
+
),
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
for (const [modelKey, rawValue] of Object.entries(projectValue)) {
|
|
484
|
+
if (modelKey === "default") continue;
|
|
485
|
+
if (!isValidTokenThreshold(rawValue)) continue;
|
|
486
|
+
touchedValidEntry = true;
|
|
487
|
+
const baseValue = baseTokens.overrides.get(modelKey) ?? baseTokens.defaultValue;
|
|
488
|
+
if (baseValue === undefined) {
|
|
489
|
+
warnings.push(
|
|
490
|
+
makeProjectThresholdWarning(
|
|
491
|
+
`execute_threshold_tokens.${modelKey}`,
|
|
492
|
+
TOKEN_THRESHOLD_INTRODUCTION_REASON,
|
|
493
|
+
),
|
|
494
|
+
);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (rawValue > baseValue) {
|
|
498
|
+
if (rawValue === constrained.defaultValue) {
|
|
499
|
+
constrained.overrides.delete(modelKey);
|
|
500
|
+
} else {
|
|
501
|
+
constrained.overrides.set(modelKey, rawValue);
|
|
502
|
+
}
|
|
503
|
+
} else {
|
|
504
|
+
warnings.push(
|
|
505
|
+
makeProjectThresholdWarning(
|
|
506
|
+
`execute_threshold_tokens.${modelKey}`,
|
|
507
|
+
TOKEN_THRESHOLD_REASON,
|
|
508
|
+
),
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (touchedValidEntry) {
|
|
514
|
+
setMergedTokenThreshold(args.mergedRaw, constrained);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return warnings;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* After the project config has been merged over the user config, drop the
|
|
523
|
+
* user's inherited `embedding.api_key` when the project redirected the embedding
|
|
524
|
+
* `endpoint` without supplying its own key.
|
|
525
|
+
*
|
|
526
|
+
* Threat: a malicious repo overrides only `embedding.endpoint` → an attacker
|
|
527
|
+
* server, inheriting the user's `embedding.api_key`, which is then sent as
|
|
528
|
+
* `Authorization: Bearer …` to that server. The victim did nothing but clone the
|
|
529
|
+
* repo. Dropping the inherited key means a redirected endpoint never receives
|
|
530
|
+
* the user's secret; a project that genuinely points at a different endpoint
|
|
531
|
+
* must supply its own key.
|
|
532
|
+
*
|
|
533
|
+
* `projectRaw` is the raw project config (pre-merge, so we can see what the
|
|
534
|
+
* project itself declared). `mergedRaw` is the post-merge result, mutated in
|
|
535
|
+
* place. `userRaw` is the trusted user config; when supplied, the key is dropped
|
|
536
|
+
* only when the project endpoint ACTUALLY differs from the user's endpoint — a
|
|
537
|
+
* project repeating the user's own endpoint (e.g. only to change `model`) is not
|
|
538
|
+
* a redirect and must keep the inherited key. Returns warnings.
|
|
539
|
+
*/
|
|
540
|
+
function normalizeEndpoint(value: unknown): string | undefined {
|
|
541
|
+
if (typeof value !== "string") return undefined;
|
|
542
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
543
|
+
return trimmed.length > 0 ? trimmed.toLowerCase() : undefined;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function dropInheritedEmbeddingKeyOnRedirect(
|
|
547
|
+
projectRaw: Record<string, unknown>,
|
|
548
|
+
mergedRaw: Record<string, unknown>,
|
|
549
|
+
userRaw?: Record<string, unknown>,
|
|
550
|
+
): string[] {
|
|
551
|
+
const projectEmbedding = projectRaw.embedding;
|
|
552
|
+
if (!isPlainObject(projectEmbedding)) return [];
|
|
553
|
+
|
|
554
|
+
// Only an ENDPOINT redirect changes WHERE the bytes (and the Authorization
|
|
555
|
+
// header) are sent. A provider-only change keeps the user's endpoint, so it
|
|
556
|
+
// is not an exfiltration vector.
|
|
557
|
+
const redirectsEndpoint = "endpoint" in projectEmbedding;
|
|
558
|
+
if (!redirectsEndpoint) return [];
|
|
559
|
+
|
|
560
|
+
// A project that merely repeats the user's OWN endpoint (e.g. to override
|
|
561
|
+
// `model` while keeping the same server) is not a redirect — the key was
|
|
562
|
+
// always destined for that endpoint. Only drop when the destination
|
|
563
|
+
// actually changed. When userRaw is absent we cannot tell, so fall back to
|
|
564
|
+
// the conservative presence-based drop.
|
|
565
|
+
const userEmbedding = userRaw?.embedding;
|
|
566
|
+
if (isPlainObject(userEmbedding)) {
|
|
567
|
+
const projectEndpoint = normalizeEndpoint(projectEmbedding.endpoint);
|
|
568
|
+
const userEndpoint = normalizeEndpoint(userEmbedding.endpoint);
|
|
569
|
+
if (projectEndpoint !== undefined && projectEndpoint === userEndpoint) {
|
|
570
|
+
return [];
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const providesOwnKey =
|
|
575
|
+
typeof projectEmbedding.api_key === "string" && projectEmbedding.api_key.length > 0;
|
|
576
|
+
if (providesOwnKey) return [];
|
|
577
|
+
|
|
578
|
+
const mergedEmbedding = mergedRaw.embedding;
|
|
579
|
+
if (!isPlainObject(mergedEmbedding)) return [];
|
|
580
|
+
if (!("api_key" in mergedEmbedding)) return [];
|
|
581
|
+
|
|
582
|
+
delete mergedEmbedding.api_key;
|
|
583
|
+
return [
|
|
584
|
+
"Dropped inherited user embedding api_key because project config redirected " +
|
|
585
|
+
"embedding.endpoint without supplying its own key (security: prevents key " +
|
|
586
|
+
"exfiltration to a repository-chosen endpoint).",
|
|
587
|
+
];
|
|
588
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { pruneNestedConfigLeaf } from "./prune-config-leaf";
|
|
4
|
+
|
|
5
|
+
describe("pruneNestedConfigLeaf", () => {
|
|
6
|
+
it("prunes a 2-level leaf and keeps siblings", () => {
|
|
7
|
+
const block = { enabled: false, injection_budget_tokens: 4000 };
|
|
8
|
+
const result = pruneNestedConfigLeaf(block, ["injection_budget_tokens"]);
|
|
9
|
+
expect(result).not.toBeNull();
|
|
10
|
+
expect(result?.block).toEqual({ enabled: false });
|
|
11
|
+
expect(result?.removed).toBe("injection_budget_tokens");
|
|
12
|
+
// original untouched
|
|
13
|
+
expect(block).toEqual({ enabled: false, injection_budget_tokens: 4000 });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("prunes the DEEPEST leaf of a 3-level path, preserving a disabled sibling", () => {
|
|
17
|
+
// The regression: an invalid memory.git_commit_indexing.since_days must
|
|
18
|
+
// drop only `since_days`, NOT the whole git_commit_indexing block (which
|
|
19
|
+
// would lose `enabled: false` and let the default restore enabled: true).
|
|
20
|
+
const block = {
|
|
21
|
+
enabled: true,
|
|
22
|
+
git_commit_indexing: { enabled: false, since_days: 99999 },
|
|
23
|
+
};
|
|
24
|
+
const result = pruneNestedConfigLeaf(block, ["git_commit_indexing", "since_days"]);
|
|
25
|
+
expect(result).not.toBeNull();
|
|
26
|
+
expect(result?.block).toEqual({
|
|
27
|
+
enabled: true,
|
|
28
|
+
git_commit_indexing: { enabled: false },
|
|
29
|
+
});
|
|
30
|
+
expect(result?.removed).toBe("git_commit_indexing.since_days");
|
|
31
|
+
// original deeply untouched (no mutation of nested object)
|
|
32
|
+
expect(block.git_commit_indexing).toEqual({ enabled: false, since_days: 99999 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("prunes the owning field when an intermediate segment is not an object", () => {
|
|
36
|
+
// The path descends into a non-object (here `git_commit_indexing` is a
|
|
37
|
+
// bare bool, not a block). We can't reach `since_days`, but we prune the
|
|
38
|
+
// owning field so it falls back to its default — NOT collapse to all
|
|
39
|
+
// defaults (which dropping to null upstream would cause).
|
|
40
|
+
const block = { git_commit_indexing: true, keep_me: { enabled: false } };
|
|
41
|
+
const result = pruneNestedConfigLeaf(block, ["git_commit_indexing", "since_days"]);
|
|
42
|
+
expect(result).not.toBeNull();
|
|
43
|
+
expect(result?.removed).toBe("git_commit_indexing");
|
|
44
|
+
expect(result?.block).toEqual({ keep_me: { enabled: false } });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("prunes an invalid array-element leaf to its owning field", () => {
|
|
48
|
+
// system_prompt_injection.skip_signatures[0] invalid → Zod path descends
|
|
49
|
+
// into the array. Prune skip_signatures (→ default), keep siblings.
|
|
50
|
+
const block = {
|
|
51
|
+
system_prompt_injection: { enabled: true, skip_signatures: [123] },
|
|
52
|
+
};
|
|
53
|
+
const result = pruneNestedConfigLeaf(block, [
|
|
54
|
+
"system_prompt_injection",
|
|
55
|
+
"skip_signatures",
|
|
56
|
+
0,
|
|
57
|
+
]);
|
|
58
|
+
expect(result).not.toBeNull();
|
|
59
|
+
expect(result?.removed).toBe("system_prompt_injection.skip_signatures");
|
|
60
|
+
expect(result?.block).toEqual({ system_prompt_injection: { enabled: true } });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns null when the leaf is absent", () => {
|
|
64
|
+
const block = { auto_search: { enabled: false } };
|
|
65
|
+
expect(pruneNestedConfigLeaf(block, ["auto_search", "missing"])).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns null for an empty path", () => {
|
|
69
|
+
expect(pruneNestedConfigLeaf({ a: 1 }, [])).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("deep-clones intermediate objects (no shared references with input)", () => {
|
|
73
|
+
const inner = { enabled: false, since_days: 1 };
|
|
74
|
+
const block = { git_commit_indexing: inner };
|
|
75
|
+
const result = pruneNestedConfigLeaf(block, ["git_commit_indexing", "since_days"]);
|
|
76
|
+
expect(result?.block.git_commit_indexing).not.toBe(inner);
|
|
77
|
+
});
|
|
78
|
+
});
|