@cortexkit/opencode-magic-context 0.35.1 → 0.36.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 +9 -5
- package/dist/agents/permissions.d.ts +17 -0
- package/dist/agents/permissions.d.ts.map +1 -1
- package/dist/{announcement-gms2nhya.js → announcement-6c1g4es4.js} +2 -2
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/schema/magic-context.d.ts +10 -0
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/{conflict-warning-hook-p2pmhvn7.js → conflict-warning-hook-mt0ttc4x.js} +5 -5
- package/dist/{derive-budgets-9crp0cvd.js → derive-budgets-88mxk2w1.js} +3 -3
- package/dist/features/magic-context/context-authority.d.ts +12 -0
- package/dist/features/magic-context/context-authority.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/classify.d.ts +2 -0
- package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +4 -0
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/lease.d.ts +10 -3
- package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/map-memories.d.ts +2 -0
- package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/module-apply.d.ts +5 -0
- package/dist/features/magic-context/dreamer/module-apply.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/promote-primers.d.ts +2 -0
- package/dist/features/magic-context/dreamer/promote-primers.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/refresh-primers.d.ts +2 -0
- package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-executor.d.ts +1 -0
- package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-scheduler.d.ts +5 -2
- package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/verify.d.ts +2 -0
- package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
- package/dist/features/magic-context/message-index-async.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 +2 -0
- package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/schema-fence-probe.d.ts +1 -0
- package/dist/features/magic-context/schema-fence-probe.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +38 -0
- package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -0
- package/dist/features/magic-context/smart-notes/runner.d.ts +1 -0
- package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/storage.d.ts +3 -3
- package/dist/features/magic-context/smart-notes/storage.d.ts.map +1 -1
- package/dist/features/magic-context/storage-clone.d.ts +6 -0
- package/dist/features/magic-context/storage-clone.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +2 -2
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-identity-merge.d.ts.map +1 -1
- package/dist/features/magic-context/storage-notes.d.ts +13 -0
- package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tags.d.ts +2 -0
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +2 -2
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts +2 -0
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
- package/dist/hooks/magic-context/child-session-spawn.d.ts +1 -0
- package/dist/hooks/magic-context/child-session-spawn.d.ts.map +1 -1
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/compaction-off-transition.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-render-epoch.d.ts +5 -3
- package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -1
- package/dist/hooks/magic-context/embed-session-state.d.ts +1 -1
- package/dist/hooks/magic-context/embed-session-state.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts +3 -0
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +8 -0
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-replay.d.ts +5 -2
- package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-slot.d.ts +15 -1
- package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-state-sync.d.ts +7 -0
- package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-transport.d.ts +29 -3
- package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
- package/dist/hooks/magic-context/raw-fallback-context-limit.d.ts +10 -0
- package/dist/hooks/magic-context/raw-fallback-context-limit.d.ts.map +1 -0
- package/dist/hooks/magic-context/read-session-formatting.d.ts +1 -0
- package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
- package/dist/hooks/magic-context/rust-mode-transform.d.ts +10 -2
- package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/strip-content.d.ts +6 -1
- package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +1 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/{index-fkyamz2w.js → index-1skzp4dc.js} +444 -98
- package/dist/{index-w0nygb4p.js → index-3tnkymva.js} +2 -2
- package/dist/{index-dpdnjawy.js → index-3zqcdwqr.js} +140 -13
- package/dist/{index-0c9dmw9m.js → index-8mtsfhyg.js} +33 -3
- package/dist/{index-k1esqfwk.js → index-f1tnb3vm.js} +1 -1
- package/dist/{index-kvrcg31b.js → index-mmrhhk9a.js} +8 -1
- package/dist/{index-h5zjfesc.js → index-nsx2n9eg.js} +3 -3
- package/dist/{index-dmr999a2.js → index-qf20mv7e.js} +1 -1
- package/dist/{index-73xz0247.js → index-tsgs7td8.js} +1 -1
- package/dist/{index-ry8rh8cv.js → index-wgvxkqpx.js} +5 -4
- package/dist/{index-8aaadh98.js → index-zp6x6s6a.js} +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1556 -619
- package/dist/plugin/dream-timer.d.ts +1 -0
- package/dist/plugin/dream-timer.d.ts.map +1 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts +3 -0
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/messages-transform.d.ts +5 -5
- package/dist/plugin/messages-transform.d.ts.map +1 -1
- package/dist/plugin/rust-tool-backends.d.ts +4 -0
- package/dist/plugin/rust-tool-backends.d.ts.map +1 -1
- package/dist/{recomp-orchestrator-29f4rqax.js → recomp-orchestrator-a0g9b52q.js} +8 -8
- package/dist/{safe-notification-target-q9jk5n44.js → safe-notification-target-c11fdda2.js} +3 -3
- package/dist/{send-session-notification-sr5g06hz.js → send-session-notification-bgtf4853.js} +4 -4
- package/dist/shared/conflict-fixer.d.ts.map +1 -1
- package/dist/shared/data-path.d.ts +27 -0
- package/dist/shared/data-path.d.ts.map +1 -1
- package/dist/shared/harness-provider-map.d.ts +23 -20
- package/dist/shared/harness-provider-map.d.ts.map +1 -1
- package/dist/shared/jsonc-edit.d.ts +18 -0
- package/dist/shared/jsonc-edit.d.ts.map +1 -0
- package/dist/shared/jsonc-parser.d.ts +11 -1
- package/dist/shared/jsonc-parser.d.ts.map +1 -1
- package/dist/shared/prompt-surface-runtime.d.ts +15 -2
- package/dist/shared/prompt-surface-runtime.d.ts.map +1 -1
- package/dist/shared/prompt-surface.d.ts +5 -3
- package/dist/shared/prompt-surface.d.ts.map +1 -1
- package/dist/shared/rpc-utils.d.ts +16 -0
- package/dist/shared/rpc-utils.d.ts.map +1 -1
- package/dist/shared/tui-preferences.d.ts +9 -0
- package/dist/shared/tui-preferences.d.ts.map +1 -1
- package/dist/{storage-meta-session-ev8b55a9.js → storage-meta-session-df9tstzr.js} +4 -4
- package/dist/tools/ctx-note/tools.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/agents/permissions.ts +87 -0
- package/src/config/compaction-accessor-guard.test.ts +9 -4
- package/src/config/index.test.ts +23 -0
- package/src/config/index.ts +41 -9
- package/src/config/schema/magic-context.test.ts +4 -1
- package/src/config/schema/magic-context.ts +18 -3
- package/src/shared/conflict-fixer.test.ts +174 -0
- package/src/shared/conflict-fixer.ts +66 -70
- package/src/shared/data-path.test.ts +65 -9
- package/src/shared/data-path.ts +66 -1
- package/src/shared/harness-provider-map.test.ts +42 -1
- package/src/shared/harness-provider-map.ts +48 -26
- package/src/shared/jsonc-edit.ts +295 -0
- package/src/shared/jsonc-parser.test.ts +33 -0
- package/src/shared/jsonc-parser.ts +54 -2
- package/src/shared/prompt-surface-runtime.ts +13 -5
- package/src/shared/prompt-surface.test.ts +53 -2
- package/src/shared/prompt-surface.ts +20 -9
- package/src/shared/rpc-utils.test.ts +36 -0
- package/src/shared/rpc-utils.ts +71 -0
- package/src/shared/tui-preferences.test.ts +52 -2
- package/src/shared/tui-preferences.ts +42 -17
|
@@ -20,6 +20,8 @@ const savedEnv = {
|
|
|
20
20
|
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
|
|
21
21
|
LOCALAPPDATA: process.env.LOCALAPPDATA,
|
|
22
22
|
MAGIC_CONTEXT_LOG_PATH: process.env.MAGIC_CONTEXT_LOG_PATH,
|
|
23
|
+
MAGIC_CONTEXT_TEST_DATA_DIR: process.env.MAGIC_CONTEXT_TEST_DATA_DIR,
|
|
24
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
describe("data-path", () => {
|
|
@@ -36,14 +38,14 @@ describe("data-path", () => {
|
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
afterEach(() => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
process.env
|
|
46
|
-
|
|
41
|
+
// Restore-or-delete every var this suite touches. Several tests lift a
|
|
42
|
+
// guard (NODE_ENV, MAGIC_CONTEXT_TEST_DATA_DIR) to assert production
|
|
43
|
+
// shape, so a restore that skips the unset case would leak state into
|
|
44
|
+
// the next test and make results order-dependent.
|
|
45
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
46
|
+
if (value !== undefined) process.env[key] = value;
|
|
47
|
+
else delete process.env[key];
|
|
48
|
+
}
|
|
47
49
|
});
|
|
48
50
|
|
|
49
51
|
test("getCacheDir falls back to <homedir>/.cache when XDG_CACHE_HOME is unset (all platforms)", () => {
|
|
@@ -89,8 +91,62 @@ describe("data-path", () => {
|
|
|
89
91
|
// Cross-harness shared path: both OpenCode and Pi plugins read/write here,
|
|
90
92
|
// unlike the legacy opencode/storage/plugin/magic-context location which
|
|
91
93
|
// was OpenCode-specific. See ARCHITECTURE_DECISIONS memory for rationale.
|
|
94
|
+
// Production shape, so both test-isolation guards (the preload's data
|
|
95
|
+
// dir and the NODE_ENV backstop bun sets for every `bun test`) are
|
|
96
|
+
// lifted for the duration of this assertion.
|
|
97
|
+
const savedTestDir = process.env.MAGIC_CONTEXT_TEST_DATA_DIR;
|
|
98
|
+
const savedNodeEnv = process.env.NODE_ENV;
|
|
99
|
+
delete process.env.MAGIC_CONTEXT_TEST_DATA_DIR;
|
|
100
|
+
delete process.env.NODE_ENV;
|
|
101
|
+
try {
|
|
102
|
+
expect(getMagicContextStorageDir()).toBe(
|
|
103
|
+
path.join(os.homedir(), ".local", "share", "cortexkit", "magic-context"),
|
|
104
|
+
);
|
|
105
|
+
} finally {
|
|
106
|
+
if (savedTestDir !== undefined) process.env.MAGIC_CONTEXT_TEST_DATA_DIR = savedTestDir;
|
|
107
|
+
if (savedNodeEnv !== undefined) process.env.NODE_ENV = savedNodeEnv;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("getMagicContextStorageDir backstops to a temp dir under NODE_ENV=test with no guard set", () => {
|
|
112
|
+
// CWD-independent backstop: a `bun test` from a dir whose bunfig has no
|
|
113
|
+
// `[test] preload` runs every suite with neither guard env var set. The
|
|
114
|
+
// DB resolver used to own this branch, so direct callers of this helper
|
|
115
|
+
// (the CLI doctors' own PRAGMA integrity_check) still reached the real
|
|
116
|
+
// shared DB. Memoized, so repeated calls must agree — openDatabase()
|
|
117
|
+
// caches by path.
|
|
118
|
+
const savedTestDir = process.env.MAGIC_CONTEXT_TEST_DATA_DIR;
|
|
119
|
+
delete process.env.MAGIC_CONTEXT_TEST_DATA_DIR;
|
|
120
|
+
process.env.NODE_ENV = "test";
|
|
121
|
+
try {
|
|
122
|
+
const resolved = getMagicContextStorageDir();
|
|
123
|
+
expect(resolved).not.toContain(path.join(os.homedir(), ".local", "share"));
|
|
124
|
+
expect(resolved.endsWith(path.join("cortexkit", "magic-context"))).toBe(true);
|
|
125
|
+
expect(getMagicContextStorageDir()).toBe(resolved);
|
|
126
|
+
} finally {
|
|
127
|
+
if (savedTestDir !== undefined) process.env.MAGIC_CONTEXT_TEST_DATA_DIR = savedTestDir;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("getMagicContextStorageDir honors MAGIC_CONTEXT_TEST_DATA_DIR when XDG_DATA_HOME is unset", () => {
|
|
132
|
+
// The hole this closes: a test that deletes XDG_DATA_HOME to exercise
|
|
133
|
+
// path fallbacks used to resolve to the user's REAL shared storage,
|
|
134
|
+
// because bun caches os.homedir() and a mutated process.env.HOME cannot
|
|
135
|
+
// move getDataDir(). Callers that build their own context.db path (the
|
|
136
|
+
// CLI doctors) then ran integrity checks against production data.
|
|
137
|
+
process.env.MAGIC_CONTEXT_TEST_DATA_DIR = "/tmp/mc-test-isolation";
|
|
92
138
|
expect(getMagicContextStorageDir()).toBe(
|
|
93
|
-
path.join(
|
|
139
|
+
path.join("/tmp/mc-test-isolation", "cortexkit", "magic-context"),
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("getMagicContextStorageDir prefers XDG_DATA_HOME over MAGIC_CONTEXT_TEST_DATA_DIR", () => {
|
|
144
|
+
// A test managing its own per-test data home is already controlled, and
|
|
145
|
+
// several suites depend on that dir being honored.
|
|
146
|
+
process.env.MAGIC_CONTEXT_TEST_DATA_DIR = "/tmp/mc-test-isolation";
|
|
147
|
+
process.env.XDG_DATA_HOME = "/tmp/custom-data";
|
|
148
|
+
expect(getMagicContextStorageDir()).toBe(
|
|
149
|
+
path.join("/tmp/custom-data", "cortexkit", "magic-context"),
|
|
94
150
|
);
|
|
95
151
|
});
|
|
96
152
|
|
package/src/shared/data-path.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { getHarness, type HarnessId } from "./harness";
|
|
@@ -167,11 +167,76 @@ export function getOpenCodeStorageDir(): string {
|
|
|
167
167
|
* - Future cross-harness session migration
|
|
168
168
|
*
|
|
169
169
|
* Layout: <XDG_DATA_HOME>/cortexkit/magic-context/
|
|
170
|
+
*
|
|
171
|
+
* TEST-ISOLATION GUARD. `openDatabase()` has been guarded in
|
|
172
|
+
* `resolveDatabasePath()` since the 2026-06-01 (v26) and 2026-06-19 (v41)
|
|
173
|
+
* incidents, in which unisolated tests migrated the user's REAL shared DB.
|
|
174
|
+
* Every OTHER caller bypassed that guard — notably the CLI doctors, which
|
|
175
|
+
* build `join(getMagicContextStorageDir(), "context.db")` themselves and run
|
|
176
|
+
* `PRAGMA integrity_check` against it. A test that deletes XDG_DATA_HOME to
|
|
177
|
+
* exercise path fallbacks cannot restore isolation by setting
|
|
178
|
+
* `process.env.HOME`: bun caches `os.homedir()` at startup, so `getDataDir()`
|
|
179
|
+
* still resolves to the real home. MAGIC_CONTEXT_TEST_DATA_DIR — set by
|
|
180
|
+
* `packages/plugin/test-preload.ts`, and restored by any test that touches it
|
|
181
|
+
* — is honored here, so the preload's "cannot be defeated" guarantee holds for
|
|
182
|
+
* every caller, not just `openDatabase()`. It is never set in production.
|
|
183
|
+
*
|
|
184
|
+
* CWD-INDEPENDENT BACKSTOP. The preload only runs when `bun test`'s CWD has a
|
|
185
|
+
* bunfig wiring `[test] preload`. A run from a dir WITHOUT that wiring (a
|
|
186
|
+
* package missing its bunfig, or a brand-new one) executes every *.test.ts with
|
|
187
|
+
* NO preload, and this resolver would hand back the REAL shared path — which is
|
|
188
|
+
* how the live DB reached v41. Bun sets NODE_ENV=test for EVERY `bun test`
|
|
189
|
+
* regardless of CWD, and production never sets it, so that window redirects to
|
|
190
|
+
* a memoized throwaway dir instead. It lives here rather than in
|
|
191
|
+
* `resolveDatabasePath()` so direct callers (the CLI doctors' own
|
|
192
|
+
* `PRAGMA integrity_check`, announcements, the models.dev cache) are covered
|
|
193
|
+
* too — they never go through the DB resolver.
|
|
194
|
+
*
|
|
195
|
+
* XDG_DATA_HOME still wins over both: a test that manages its own data home is
|
|
196
|
+
* already controlled, and production has no test dir set at all.
|
|
170
197
|
*/
|
|
171
198
|
export function getMagicContextStorageDir(): string {
|
|
199
|
+
if (!process.env.XDG_DATA_HOME) {
|
|
200
|
+
const testDataDir = process.env.MAGIC_CONTEXT_TEST_DATA_DIR;
|
|
201
|
+
if (testDataDir) {
|
|
202
|
+
return path.join(testDataDir, "cortexkit", "magic-context");
|
|
203
|
+
}
|
|
204
|
+
if (process.env.NODE_ENV === "test") {
|
|
205
|
+
return getTestBackstopStorageDir();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
172
208
|
return path.join(getDataDir(), "cortexkit", "magic-context");
|
|
173
209
|
}
|
|
174
210
|
|
|
211
|
+
let testBackstopStorageDir: string | null = null;
|
|
212
|
+
let testBackstopWarned = false;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Memoized per process so repeated calls in the same unisolated test resolve to
|
|
216
|
+
* the SAME path — `openDatabase()` caches by path, and a fresh temp dir per call
|
|
217
|
+
* would defeat that cache and hand back different DB handles.
|
|
218
|
+
*/
|
|
219
|
+
function getTestBackstopStorageDir(): string {
|
|
220
|
+
if (!testBackstopStorageDir) {
|
|
221
|
+
testBackstopStorageDir = path.join(
|
|
222
|
+
mkdtempSync(path.join(os.tmpdir(), "mc-test-db-backstop-")),
|
|
223
|
+
"cortexkit",
|
|
224
|
+
"magic-context",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (!testBackstopWarned) {
|
|
228
|
+
testBackstopWarned = true;
|
|
229
|
+
// Deliberately console, not the logger: logger.ts imports this module.
|
|
230
|
+
console.warn(
|
|
231
|
+
"[magic-context] TEST BACKSTOP: NODE_ENV=test with no MAGIC_CONTEXT_TEST_DATA_DIR " +
|
|
232
|
+
`— redirecting storage to a throwaway temp dir (${testBackstopStorageDir}) so no ` +
|
|
233
|
+
"test can touch the user's real shared database. Wire `[test] preload` in this " +
|
|
234
|
+
"package's bunfig.toml.",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
return testBackstopStorageDir;
|
|
238
|
+
}
|
|
239
|
+
|
|
175
240
|
/**
|
|
176
241
|
* Legacy magic-context storage directory used by the OpenCode plugin before the
|
|
177
242
|
* shared cortexkit path. Used only for one-time migration of existing data into
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ompModelRefToCanonical,
|
|
4
|
+
piModelRefToCanonical,
|
|
5
|
+
resolveModelRefForOmp,
|
|
6
|
+
resolveModelRefForPi,
|
|
7
|
+
} from "./harness-provider-map";
|
|
3
8
|
|
|
4
9
|
describe("harness-provider-map", () => {
|
|
5
10
|
describe("resolveModelRefForPi (canonical -> Pi, used when spawning)", () => {
|
|
@@ -61,3 +66,39 @@ describe("harness-provider-map", () => {
|
|
|
61
66
|
});
|
|
62
67
|
});
|
|
63
68
|
});
|
|
69
|
+
|
|
70
|
+
describe("OMP provider boundary", () => {
|
|
71
|
+
const representativeRefs = [
|
|
72
|
+
["openai/gpt-5.5", "openai-codex/gpt-5.5"],
|
|
73
|
+
["google/antigravity/gemini-3.5-flash", "google-antigravity/antigravity/gemini-3.5-flash"],
|
|
74
|
+
["anthropic/claude-opus-4-8", "anthropic/claude-opus-4-8"],
|
|
75
|
+
["@scope/provider/nested/model", "@scope/provider/nested/model"],
|
|
76
|
+
] as const;
|
|
77
|
+
|
|
78
|
+
it.each(
|
|
79
|
+
representativeRefs,
|
|
80
|
+
)("round-trips canonical %s through OMP selector %s", (canonical, omp) => {
|
|
81
|
+
expect(resolveModelRefForOmp(canonical)).toBe(omp);
|
|
82
|
+
expect(ompModelRefToCanonical(omp)).toBe(canonical);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("normalizes an already-native OMP selector idempotently", () => {
|
|
86
|
+
const selector = "openai-codex/team/nested/gpt-5.5";
|
|
87
|
+
expect(resolveModelRefForOmp(selector)).toBe(selector);
|
|
88
|
+
expect(resolveModelRefForOmp(ompModelRefToCanonical(selector))).toBe(selector);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("passes through provider ids that collide with Object.prototype members", () => {
|
|
92
|
+
for (const ref of [
|
|
93
|
+
"constructor/model",
|
|
94
|
+
"toString/model",
|
|
95
|
+
"__proto__/model",
|
|
96
|
+
"hasOwnProperty/model",
|
|
97
|
+
]) {
|
|
98
|
+
expect(resolveModelRefForOmp(ref)).toBe(ref);
|
|
99
|
+
expect(ompModelRefToCanonical(ref)).toBe(ref);
|
|
100
|
+
expect(resolveModelRefForPi(ref)).toBe(ref);
|
|
101
|
+
expect(piModelRefToCanonical(ref)).toBe(ref);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -2,54 +2,66 @@
|
|
|
2
2
|
* Provider-id translation between the canonical (OpenCode) form stored in the
|
|
3
3
|
* shared magic-context config and Pi's harness-native provider ids.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Provider IDs are a harness boundary, not a database identity. Shared config
|
|
6
|
+
* always stores the canonical OpenCode form. Each non-OpenCode harness owns an
|
|
7
|
+
* explicit pair of edge transforms:
|
|
8
8
|
*
|
|
9
|
-
* canonical (OpenCode) Pi
|
|
10
|
-
* --------------------
|
|
9
|
+
* canonical (OpenCode) Pi / OMP selector
|
|
10
|
+
* -------------------- -----------------
|
|
11
11
|
* openai/<model> openai-codex/<model>
|
|
12
12
|
* google/<model> google-antigravity/<model>
|
|
13
13
|
* anthropic/<model> anthropic/<model> (same; every other provider too)
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* should win. Pi therefore starts with the preferred auth-plugin form at the
|
|
19
|
-
* spawn boundary and subagent-runner may retry once with the untranslated
|
|
20
|
-
* canonical form when that form reports missing credentials. The runner caches
|
|
21
|
-
* the winning form per provider prefix in memory only; setup writes still use
|
|
22
|
-
* the reverse mapping below so configs remain canonical.
|
|
15
|
+
* Pi and OMP currently expose the same two subscription-provider aliases, but
|
|
16
|
+
* their exported functions remain distinct deliberately. A future OMP catalog
|
|
17
|
+
* rename must not silently change plain-Pi behavior (or vice versa).
|
|
23
18
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
19
|
+
* The mapping is intentionally not a one-to-one provider identity. Both
|
|
20
|
+
* harnesses also expose plain `openai` and `google` providers for direct API
|
|
21
|
+
* keys, while the canonical prefix does not record whether a subscription or
|
|
22
|
+
* API-key backend should win. The runtime starts with the preferred
|
|
23
|
+
* subscription form and subagent-runner may retry once with the untranslated
|
|
24
|
+
* canonical form when that form reports missing credentials.
|
|
30
25
|
*
|
|
31
|
-
*
|
|
26
|
+
* Only the provider prefix before the first slash is translated. Model IDs,
|
|
27
|
+
* including nested IDs containing more slashes, are preserved byte-for-byte.
|
|
28
|
+
* Scoped or otherwise unknown provider prefixes are identities.
|
|
29
|
+
*
|
|
30
|
+
* OpenCode needs no translation because canonical is its native form.
|
|
32
31
|
*/
|
|
33
32
|
|
|
34
|
-
const CANONICAL_TO_PI_PROVIDER: Record<string, string
|
|
33
|
+
const CANONICAL_TO_PI_PROVIDER: Readonly<Record<string, string>> = {
|
|
35
34
|
openai: "openai-codex",
|
|
36
35
|
google: "google-antigravity",
|
|
37
36
|
};
|
|
38
37
|
|
|
39
|
-
const PI_TO_CANONICAL_PROVIDER: Record<string, string
|
|
38
|
+
const PI_TO_CANONICAL_PROVIDER: Readonly<Record<string, string>> = {
|
|
39
|
+
"openai-codex": "openai",
|
|
40
|
+
"google-antigravity": "google",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const CANONICAL_TO_OMP_PROVIDER: Readonly<Record<string, string>> = {
|
|
44
|
+
openai: "openai-codex",
|
|
45
|
+
google: "google-antigravity",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const OMP_TO_CANONICAL_PROVIDER: Readonly<Record<string, string>> = {
|
|
40
49
|
"openai-codex": "openai",
|
|
41
50
|
"google-antigravity": "google",
|
|
42
51
|
};
|
|
43
52
|
|
|
44
53
|
/** Remap only the provider prefix (text before the first "/"), preserving the
|
|
45
|
-
* model id verbatim. No "/", empty provider, or unmapped provider -> unchanged.
|
|
46
|
-
|
|
54
|
+
* model id verbatim. No "/", empty provider, or unmapped provider -> unchanged.
|
|
55
|
+
* Lookups are own-property only, so provider ids that collide with
|
|
56
|
+
* `Object.prototype` members (`constructor/model`, `toString/model`) stay
|
|
57
|
+
* identities instead of becoming garbage prefixes. */
|
|
58
|
+
function remapProviderPrefix(ref: string, map: Readonly<Record<string, string>>): string {
|
|
47
59
|
if (typeof ref !== "string") return ref;
|
|
48
60
|
const slash = ref.indexOf("/");
|
|
49
61
|
if (slash <= 0) return ref;
|
|
50
62
|
const provider = ref.slice(0, slash);
|
|
51
|
-
|
|
52
|
-
return
|
|
63
|
+
if (!Object.hasOwn(map, provider)) return ref;
|
|
64
|
+
return `${map[provider]}${ref.slice(slash)}`;
|
|
53
65
|
}
|
|
54
66
|
|
|
55
67
|
/** Pi-native `provider/model` -> canonical (OpenCode). Identity when unmapped.
|
|
@@ -64,3 +76,13 @@ export function piModelRefToCanonical(ref: string): string {
|
|
|
64
76
|
export function resolveModelRefForPi(ref: string): string {
|
|
65
77
|
return remapProviderPrefix(piModelRefToCanonical(ref), CANONICAL_TO_PI_PROVIDER);
|
|
66
78
|
}
|
|
79
|
+
|
|
80
|
+
/** OMP-native selector -> canonical shared-config model reference. */
|
|
81
|
+
export function ompModelRefToCanonical(ref: string): string {
|
|
82
|
+
return remapProviderPrefix(ref, OMP_TO_CANONICAL_PROVIDER);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Canonical shared-config model reference -> OMP-native selector. */
|
|
86
|
+
export function resolveModelRefForOmp(ref: string): string {
|
|
87
|
+
return remapProviderPrefix(ompModelRefToCanonical(ref), CANONICAL_TO_OMP_PROVIDER);
|
|
88
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyEdits,
|
|
3
|
+
createScanner,
|
|
4
|
+
findNodeAtLocation,
|
|
5
|
+
getNodeValue,
|
|
6
|
+
type JSONPath,
|
|
7
|
+
modify,
|
|
8
|
+
type Node,
|
|
9
|
+
type ParseError,
|
|
10
|
+
parseTree,
|
|
11
|
+
} from "jsonc-parser";
|
|
12
|
+
|
|
13
|
+
interface TokenLocation {
|
|
14
|
+
offset: number;
|
|
15
|
+
length: number;
|
|
16
|
+
line: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* jsonc-parser exposes SyntaxKind as a const enum, which cannot be referenced
|
|
21
|
+
* from this isolated TypeScript module. Keep the scanner token values local.
|
|
22
|
+
*/
|
|
23
|
+
const TOKEN_COMMA = 5;
|
|
24
|
+
const TOKEN_LINE_COMMENT = 12;
|
|
25
|
+
const TOKEN_BLOCK_COMMENT = 13;
|
|
26
|
+
const TOKEN_EOF = 17;
|
|
27
|
+
|
|
28
|
+
function parseDocument(text: string): Node {
|
|
29
|
+
const errors: ParseError[] = [];
|
|
30
|
+
const root = parseTree(text, errors, { allowTrailingComma: true });
|
|
31
|
+
if (!root || errors.length > 0) {
|
|
32
|
+
throw new Error("Cannot edit invalid JSONC");
|
|
33
|
+
}
|
|
34
|
+
return root;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findNode(text: string, path: JSONPath): Node | undefined {
|
|
38
|
+
return findNodeAtLocation(parseDocument(text), path);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findComma(text: string, start: number, end: number): TokenLocation | undefined {
|
|
42
|
+
const scanner = createScanner(text, false);
|
|
43
|
+
scanner.setPosition(start);
|
|
44
|
+
|
|
45
|
+
for (;;) {
|
|
46
|
+
const kind = scanner.scan();
|
|
47
|
+
const offset = scanner.getTokenOffset();
|
|
48
|
+
if (kind === TOKEN_EOF || offset >= end) return undefined;
|
|
49
|
+
if (kind === TOKEN_COMMA) {
|
|
50
|
+
return {
|
|
51
|
+
offset,
|
|
52
|
+
length: scanner.getTokenLength(),
|
|
53
|
+
line: scanner.getTokenStartLine(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function lineStart(text: string, offset: number): number {
|
|
60
|
+
const previousNewline = text.lastIndexOf("\n", offset - 1);
|
|
61
|
+
return previousNewline === -1 ? 0 : previousNewline + 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function lineEnd(text: string, offset: number): number {
|
|
65
|
+
const nextNewline = text.indexOf("\n", offset);
|
|
66
|
+
if (nextNewline === -1) return text.length;
|
|
67
|
+
return text[nextNewline - 1] === "\r" ? nextNewline - 1 : nextNewline;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function inlineCommentEnd(
|
|
71
|
+
text: string,
|
|
72
|
+
start: number,
|
|
73
|
+
line: number,
|
|
74
|
+
limit: number,
|
|
75
|
+
): number | undefined {
|
|
76
|
+
const scanner = createScanner(text, false);
|
|
77
|
+
scanner.setPosition(start);
|
|
78
|
+
let end: number | undefined;
|
|
79
|
+
|
|
80
|
+
for (;;) {
|
|
81
|
+
const kind = scanner.scan();
|
|
82
|
+
const offset = scanner.getTokenOffset();
|
|
83
|
+
if (kind === TOKEN_EOF || offset >= limit || scanner.getTokenStartLine() !== line) {
|
|
84
|
+
return end;
|
|
85
|
+
}
|
|
86
|
+
if (kind === TOKEN_LINE_COMMENT || kind === TOKEN_BLOCK_COMMENT) {
|
|
87
|
+
end = offset + scanner.getTokenLength();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Keep comments written inline after a preceding separator with that preceding
|
|
94
|
+
* entry. Comments on a later line belong to the entry that follows the comma.
|
|
95
|
+
*/
|
|
96
|
+
function startAfterPrecedingInlineComments(
|
|
97
|
+
text: string,
|
|
98
|
+
comma: TokenLocation,
|
|
99
|
+
entryOffset: number,
|
|
100
|
+
): number {
|
|
101
|
+
const commentEnd = inlineCommentEnd(text, comma.offset + comma.length, comma.line, entryOffset);
|
|
102
|
+
if (commentEnd === undefined) return comma.offset + comma.length;
|
|
103
|
+
|
|
104
|
+
const nextNewline = text.indexOf("\n", commentEnd);
|
|
105
|
+
return nextNewline === -1 ? commentEnd : nextNewline + 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Remove inline comments following a removed entry's comma with that entry. */
|
|
109
|
+
function endAfterFollowingInlineComments(
|
|
110
|
+
text: string,
|
|
111
|
+
comma: TokenLocation,
|
|
112
|
+
nextEntryOffset: number,
|
|
113
|
+
): number {
|
|
114
|
+
const commentEnd = inlineCommentEnd(
|
|
115
|
+
text,
|
|
116
|
+
comma.offset + comma.length,
|
|
117
|
+
comma.line,
|
|
118
|
+
nextEntryOffset,
|
|
119
|
+
);
|
|
120
|
+
return commentEnd === undefined ? comma.offset + comma.length : lineEnd(text, commentEnd);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function closingWhitespace(text: string, entryEnd: number, closingBracket: number): string {
|
|
124
|
+
const trailing = text.slice(entryEnd, closingBracket);
|
|
125
|
+
const finalNewline = trailing.lastIndexOf("\n");
|
|
126
|
+
if (finalNewline === -1) return "";
|
|
127
|
+
const newlineStart = trailing[finalNewline - 1] === "\r" ? finalNewline - 1 : finalNewline;
|
|
128
|
+
return trailing.slice(newlineStart);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function removeArrayEntry(text: string, array: Node, index: number): string {
|
|
132
|
+
const entries = array.children ?? [];
|
|
133
|
+
const entry = entries[index];
|
|
134
|
+
if (!entry) return text;
|
|
135
|
+
|
|
136
|
+
const closingBracket = array.offset + array.length - 1;
|
|
137
|
+
if (entries.length === 1) {
|
|
138
|
+
const start = array.offset + 1;
|
|
139
|
+
const replacement = closingWhitespace(text, entry.offset + entry.length, closingBracket);
|
|
140
|
+
return text.slice(0, start) + replacement + text.slice(closingBracket);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (index === entries.length - 1) {
|
|
144
|
+
const previous = entries[index - 1];
|
|
145
|
+
if (!previous) return text;
|
|
146
|
+
const comma = findComma(text, previous.offset + previous.length, entry.offset);
|
|
147
|
+
if (!comma) return text;
|
|
148
|
+
const replacement = closingWhitespace(text, entry.offset + entry.length, closingBracket);
|
|
149
|
+
return text.slice(0, comma.offset) + replacement + text.slice(closingBracket);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const next = entries[index + 1];
|
|
153
|
+
if (!next) return text;
|
|
154
|
+
const followingComma = findComma(text, entry.offset + entry.length, next.offset);
|
|
155
|
+
if (!followingComma) return text;
|
|
156
|
+
|
|
157
|
+
const start =
|
|
158
|
+
index === 0
|
|
159
|
+
? array.offset + 1
|
|
160
|
+
: (() => {
|
|
161
|
+
const previous = entries[index - 1];
|
|
162
|
+
if (!previous) return entry.offset;
|
|
163
|
+
const precedingComma = findComma(
|
|
164
|
+
text,
|
|
165
|
+
previous.offset + previous.length,
|
|
166
|
+
entry.offset,
|
|
167
|
+
);
|
|
168
|
+
return precedingComma
|
|
169
|
+
? startAfterPrecedingInlineComments(text, precedingComma, entry.offset)
|
|
170
|
+
: entry.offset;
|
|
171
|
+
})();
|
|
172
|
+
const end = endAfterFollowingInlineComments(text, followingComma, next.offset);
|
|
173
|
+
|
|
174
|
+
return text.slice(0, start) + text.slice(end);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function indentationAt(text: string, offset: number): string {
|
|
178
|
+
const prefix = text.slice(lineStart(text, offset), offset);
|
|
179
|
+
return /^[\t ]*$/.test(prefix) ? prefix : "";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function inferIndent(text: string, array: Node): string {
|
|
183
|
+
const firstEntry = array.children?.[0];
|
|
184
|
+
if (firstEntry) return indentationAt(text, firstEntry.offset);
|
|
185
|
+
|
|
186
|
+
const closingBracket = array.offset + array.length - 1;
|
|
187
|
+
const closingIndent = indentationAt(text, closingBracket);
|
|
188
|
+
return `${closingIndent}${closingIndent.includes("\t") ? "\t" : " "}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function appendArrayValue(text: string, array: Node, value: unknown): string {
|
|
192
|
+
const entries = array.children ?? [];
|
|
193
|
+
const closingBracket = array.offset + array.length - 1;
|
|
194
|
+
const serialized = JSON.stringify(value);
|
|
195
|
+
|
|
196
|
+
if (entries.length === 0) {
|
|
197
|
+
if (!text.slice(array.offset, closingBracket).includes("\n")) {
|
|
198
|
+
return text.slice(0, closingBracket) + serialized + text.slice(closingBracket);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const indent = inferIndent(text, array);
|
|
202
|
+
const closingLineStart = lineStart(text, closingBracket);
|
|
203
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
204
|
+
return (
|
|
205
|
+
text.slice(0, closingLineStart) +
|
|
206
|
+
indent +
|
|
207
|
+
serialized +
|
|
208
|
+
eol +
|
|
209
|
+
text.slice(closingLineStart)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const lastEntry = entries.at(-1);
|
|
214
|
+
if (!lastEntry) return text;
|
|
215
|
+
const trailingComma = findComma(text, lastEntry.offset + lastEntry.length, closingBracket);
|
|
216
|
+
const isMultiline = text.slice(array.offset, closingBracket).includes("\n");
|
|
217
|
+
|
|
218
|
+
if (!isMultiline) {
|
|
219
|
+
return (
|
|
220
|
+
text.slice(0, lastEntry.offset + lastEntry.length) +
|
|
221
|
+
`,${serialized}` +
|
|
222
|
+
text.slice(lastEntry.offset + lastEntry.length)
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const closingLineStart = lineStart(text, closingBracket);
|
|
227
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
228
|
+
const inserted = `${inferIndent(text, array)}${serialized}${trailingComma ? "," : ""}${eol}`;
|
|
229
|
+
const withValue = text.slice(0, closingLineStart) + inserted + text.slice(closingLineStart);
|
|
230
|
+
|
|
231
|
+
if (trailingComma) return withValue;
|
|
232
|
+
return (
|
|
233
|
+
withValue.slice(0, lastEntry.offset + lastEntry.length) +
|
|
234
|
+
"," +
|
|
235
|
+
withValue.slice(lastEntry.offset + lastEntry.length)
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Replace one JSONC value without reserializing the rest of the document. New
|
|
241
|
+
* paths use jsonc-parser's structural edit so the original document remains
|
|
242
|
+
* untouched outside the inserted property.
|
|
243
|
+
*/
|
|
244
|
+
export function setJsoncValue(text: string, path: JSONPath, value: unknown): string {
|
|
245
|
+
const node = findNode(text, path);
|
|
246
|
+
if (node) {
|
|
247
|
+
if (Object.is(getNodeValue(node), value)) return text;
|
|
248
|
+
const serialized = JSON.stringify(value);
|
|
249
|
+
return text.slice(0, node.offset) + serialized + text.slice(node.offset + node.length);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return applyEdits(text, modify(text, path, value, {}));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Remove matching array entries while retaining the exact bytes for survivor
|
|
257
|
+
* comments and surrounding JSONC regions.
|
|
258
|
+
*/
|
|
259
|
+
export function removeJsoncArrayEntries(
|
|
260
|
+
text: string,
|
|
261
|
+
path: JSONPath,
|
|
262
|
+
shouldRemove: (entry: unknown) => boolean,
|
|
263
|
+
): { text: string; removed: boolean } {
|
|
264
|
+
let nextText = text;
|
|
265
|
+
let removed = false;
|
|
266
|
+
|
|
267
|
+
for (;;) {
|
|
268
|
+
const array = findNode(nextText, path);
|
|
269
|
+
if (array?.type !== "array") return { text: nextText, removed };
|
|
270
|
+
const index = (array.children ?? []).findIndex((entry) =>
|
|
271
|
+
shouldRemove(getNodeValue(entry)),
|
|
272
|
+
);
|
|
273
|
+
if (index === -1) return { text: nextText, removed };
|
|
274
|
+
|
|
275
|
+
const updated = removeArrayEntry(nextText, array, index);
|
|
276
|
+
if (updated === nextText) return { text: nextText, removed };
|
|
277
|
+
nextText = updated;
|
|
278
|
+
removed = true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Append values to an existing JSONC array without reserializing sibling fields. */
|
|
283
|
+
export function appendJsoncArrayValues(text: string, path: JSONPath, values: unknown[]): string {
|
|
284
|
+
let nextText = text;
|
|
285
|
+
|
|
286
|
+
for (const value of values) {
|
|
287
|
+
const array = findNode(nextText, path);
|
|
288
|
+
if (array?.type !== "array") {
|
|
289
|
+
return setJsoncValue(nextText, path, values);
|
|
290
|
+
}
|
|
291
|
+
nextText = appendArrayValue(nextText, array, value);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return nextText;
|
|
295
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { parseJsonc } from "./jsonc-parser";
|
|
4
|
+
|
|
5
|
+
describe("parseJsonc prototype-pollution hardening", () => {
|
|
6
|
+
it("rejects dangerous keys recursively, including inside arrays", () => {
|
|
7
|
+
const rejected: string[] = [];
|
|
8
|
+
const parsed = parseJsonc<Record<string, unknown>>(
|
|
9
|
+
`{
|
|
10
|
+
"safe": 1,
|
|
11
|
+
"constructor": { "hidden": true },
|
|
12
|
+
"nested": { "prototype": { "hidden": true }, "safe": 2 },
|
|
13
|
+
"items": [
|
|
14
|
+
{ "__proto__": { "polluted": true }, "safe": 3 },
|
|
15
|
+
{ "safe": 4 }
|
|
16
|
+
]
|
|
17
|
+
}`,
|
|
18
|
+
{ onRejectedKey: (path) => rejected.push(path.join(".")) },
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
expect(rejected).toEqual(["constructor", "nested.prototype", "items.0.__proto__"]);
|
|
22
|
+
expect(Object.hasOwn(parsed, "constructor")).toBe(false);
|
|
23
|
+
|
|
24
|
+
const nested = parsed.nested as Record<string, unknown>;
|
|
25
|
+
expect(nested).toEqual({ safe: 2 });
|
|
26
|
+
expect(Object.getPrototypeOf(nested)).toBe(Object.prototype);
|
|
27
|
+
|
|
28
|
+
const items = parsed.items as Array<Record<string, unknown>>;
|
|
29
|
+
expect(items).toEqual([{ safe: 3 }, { safe: 4 }]);
|
|
30
|
+
expect(Object.getPrototypeOf(items[0])).toBe(Object.prototype);
|
|
31
|
+
expect("polluted" in items[0]).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|