Skip to content

Commit 131de2c

Browse files
mason: align TS config trust tiers
Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent f9f19b5 commit 131de2c

10 files changed

Lines changed: 672 additions & 49 deletions

File tree

packages/docs/src/content/docs/reference/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Add the schema line for editor validation and autocomplete:
2323
```
2424

2525
:::note
26-
Project-level configs cannot use `{env:VAR}` / `{file:path}` expansion and cannot set `sqlite.*` or override hidden-agent prompts/permissions — these are security boundaries against untrusted repositories. User-level config has no such restriction.
26+
Project-level configs cannot use `{env:VAR}` / `{file:path}` expansion. A cloned repository also cannot set `sqlite.*`, hidden-agent prompts/permissions, `historian.model`, or `historian.fallback_models`. Project `execute_threshold_percentage` / `execute_threshold_tokens` may only RAISE thresholds relative to the user's effective settings (a repo may delay compaction, not make it happen earlier). Dreamer model/schedule/task tuning and `memory.enabled` remain allowed project overrides.
2727
:::
2828

2929
## Top-level switches

packages/pi-plugin/src/config/index.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,42 @@ describe("loadPiConfig", () => {
295295
);
296296
});
297297

298+
it("keeps historian model selection user-owned when project config tries to override it", () => {
299+
const cwd = makeTempRoot("mc-pi-cwd-");
300+
const home = makeTempRoot("mc-pi-home-");
301+
withHome(home);
302+
writeUserConfig(
303+
home,
304+
JSON.stringify({
305+
historian: {
306+
model: "anthropic/user-historian",
307+
fallback_models: ["anthropic/user-fallback"],
308+
},
309+
}),
310+
);
311+
writeProjectConfig(
312+
cwd,
313+
JSON.stringify({
314+
historian: {
315+
model: "anthropic/project-historian",
316+
fallback_models: ["anthropic/project-fallback"],
317+
temperature: 0.2,
318+
},
319+
}),
320+
);
321+
322+
const result = loadPiConfig({ cwd });
323+
324+
expect(result.config.historian?.model).toBe("anthropic/user-historian");
325+
expect(result.config.historian?.fallback_models).toEqual([
326+
"anthropic/user-fallback",
327+
]);
328+
expect(result.config.historian?.temperature).toBe(0.2);
329+
expect(result.warnings.join("\n")).toContain(
330+
"Ignoring historian.model/fallback_models",
331+
);
332+
});
333+
298334
it("migrates legacy agent enabled keys before schema parsing", () => {
299335
const cwd = makeTempRoot("mc-pi-cwd-");
300336
const home = makeTempRoot("mc-pi-home-");

packages/pi-plugin/src/config/index.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { migrateLegacyAgentEnabledInMemory } from "@magic-context/core/config/ag
1212
import { migrateDreamerV2 } from "@magic-context/core/config/migrate-dreamer-v2";
1313
import { migrateLegacyExperimental } from "@magic-context/core/config/migrate-experimental";
1414
import {
15+
constrainProjectThresholdOverrides,
1516
dropInheritedEmbeddingKeyOnRedirect,
1617
stripUnsafeProjectConfigFields,
1718
} from "@magic-context/core/config/project-security";
@@ -63,12 +64,11 @@ interface LoadedConfigFile {
6364
loadOutcome: LoadOutcome;
6465
}
6566

66-
// Hard cutover: config is read ONLY from the shared CortexKit location. The
67-
// legacy Pi paths (~/.pi/agent/, <root>/.pi/) are touched only by the location
68-
// migrator (migrateMagicContextConfigLocations), which runs at Pi init before
69-
// the loader and moves them to the CortexKit path. They are never a read
70-
// fallback. The CortexKit target normalizes to .jsonc; we still detect a
71-
// pre-existing .json at the target for resilience.
67+
// Shared CortexKit paths are the primary config location. When that base is
68+
// absent because migration refused/not-yet-ran, Pi may still READ its own legacy
69+
// paths as a non-destructive fallback (see resolvePiLegacyFallback) rather than
70+
// silently using schema defaults. The CortexKit target normalizes to .jsonc; we
71+
// still detect a pre-existing .json at the target for resilience.
7272
function getProjectConfigPaths(cwd: string): string[] {
7373
const basePath = cortexKitProjectConfigBasePath(cwd);
7474
return [`${basePath}.jsonc`, `${basePath}.json`];
@@ -343,6 +343,10 @@ export function loadPiConfig(
343343
// The trusted user config (sorted first) — passed to the embedding-redirect
344344
// guard so a project repeating the user's own endpoint is not a redirect.
345345
const userRaw = mergeFiles.find((f) => f.scope === "user")?.config;
346+
// Threshold trust boundary is relative to the USER/default effective config:
347+
// a cloned repo may delay compaction, but it may not lower thresholds in a
348+
// way that forces extra historian work on the user's account.
349+
const trustedBaseConfig = parsePiConfig(userRaw ?? {}).config;
346350

347351
for (const loaded of mergeFiles) {
348352
const prefix =
@@ -364,6 +368,13 @@ export function loadPiConfig(
364368
)) {
365369
warnings.push(`${prefix} ${warning}`);
366370
}
371+
for (const warning of constrainProjectThresholdOverrides({
372+
mergedRaw: rawConfig,
373+
projectRaw,
374+
trustedBaseConfig,
375+
})) {
376+
warnings.push(`${prefix} ${warning}`);
377+
}
367378
} else {
368379
rawConfig = mergeRawConfigs(rawConfig, loaded.config);
369380
}
@@ -503,6 +514,10 @@ export function loadPiConfigDetailed(
503514
return a.scope === "user" ? -1 : 1;
504515
});
505516
const userRaw = mergeFiles.find((f) => f.scope === "user")?.config;
517+
// Threshold trust boundary is relative to the USER/default effective config:
518+
// a cloned repo may delay compaction, but it may not lower thresholds in a
519+
// way that forces extra historian work on the user's account.
520+
const trustedBaseConfig = parsePiConfig(userRaw ?? {}).config;
506521

507522
for (const loaded of mergeFiles) {
508523
const prefix =
@@ -522,6 +537,13 @@ export function loadPiConfigDetailed(
522537
)) {
523538
warnings.push(`${prefix} ${warning}`);
524539
}
540+
for (const warning of constrainProjectThresholdOverrides({
541+
mergedRaw: rawConfig,
542+
projectRaw,
543+
trustedBaseConfig,
544+
})) {
545+
warnings.push(`${prefix} ${warning}`);
546+
}
525547
} else {
526548
rawConfig = mergeRawConfigs(rawConfig, loaded.config);
527549
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { afterEach, describe, expect, it, spyOn } from "bun:test";
2+
3+
import * as loggerModule from "@magic-context/core/shared/logger";
4+
5+
import { __test } from "./index";
6+
7+
afterEach(() => {
8+
__test.resetLoggedPiConfigDirs();
9+
});
10+
11+
describe("Pi config load logging", () => {
12+
it("dedupes /cd config warnings per directory", () => {
13+
const logSpy = spyOn(loggerModule, "log").mockImplementation(
14+
() => undefined,
15+
);
16+
try {
17+
__test.logPiConfigLoad({
18+
dir: "/tmp/project-a",
19+
loadedFromPaths: ["/tmp/project-a/.cortexkit/magic-context.jsonc"],
20+
warnings: ["Ignoring historian.model from project config"],
21+
dedupe: true,
22+
});
23+
__test.logPiConfigLoad({
24+
dir: "/tmp/project-a",
25+
loadedFromPaths: ["/tmp/project-a/.cortexkit/magic-context.jsonc"],
26+
warnings: ["Ignoring historian.model from project config"],
27+
dedupe: true,
28+
});
29+
__test.logPiConfigLoad({
30+
dir: "/tmp/project-b",
31+
loadedFromPaths: [],
32+
warnings: ["Ignoring execute_threshold_percentage from project config"],
33+
dedupe: true,
34+
});
35+
36+
const messages = logSpy.mock.calls.map(([message]) => String(message));
37+
expect(
38+
messages.filter((message) => message.includes("config loaded from:")),
39+
).toHaveLength(1);
40+
expect(
41+
messages.filter((message) =>
42+
message.includes(
43+
"config: no magic-context.jsonc found, using schema defaults",
44+
),
45+
),
46+
).toHaveLength(1);
47+
expect(
48+
messages.filter((message) =>
49+
message.includes("Ignoring historian.model from project config"),
50+
),
51+
).toHaveLength(1);
52+
expect(
53+
messages.filter((message) =>
54+
message.includes(
55+
"Ignoring execute_threshold_percentage from project config",
56+
),
57+
),
58+
).toHaveLength(1);
59+
} finally {
60+
logSpy.mockRestore();
61+
}
62+
});
63+
});

packages/pi-plugin/src/index.ts

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
*/
2222

2323
import { createRequire } from "node:module";
24-
import { join } from "node:path";
24+
import { join, resolve } from "node:path";
2525
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2626
import { isDreamerRunnable } from "@magic-context/core/config/agent-disable";
2727
import { migrateMagicContextConfigLocations } from "@magic-context/core/config/migrate-config-location";
@@ -297,10 +297,14 @@ function warn(message: string, data?: unknown): void {
297297
}
298298

299299
// Migrate config from the legacy per-harness locations to the shared CortexKit
300-
// location BEFORE any loadPiConfig (hard cutover: the loader reads only
301-
// CortexKit). Memoized per directory so the per-cwd switch sites don't re-run
302-
// the (idempotent, lock-guarded) migration on every pass. Fails open.
300+
// location BEFORE any loadPiConfig. The loader prefers the shared CortexKit
301+
// paths and only falls back to Pi-owned legacy files when that base is absent.
302+
// Memoized per directory so the per-cwd switch sites don't re-run the
303+
// (idempotent, lock-guarded) migration on every pass. Fails open.
303304
const migratedConfigDirs = new Set<string>();
305+
// Memoized per directory so repeated /cd lookups do not spam the same config
306+
// summary/warning lines on every hot-path config resolution.
307+
const loggedPiConfigDirs = new Set<string>();
304308
function ensureConfigLocationsMigrated(dir: string): void {
305309
if (migratedConfigDirs.has(dir)) return;
306310
migratedConfigDirs.add(dir);
@@ -310,6 +314,34 @@ function ensureConfigLocationsMigrated(dir: string): void {
310314
});
311315
}
312316

317+
function logPiConfigLoad(args: {
318+
dir: string;
319+
loadedFromPaths: string[];
320+
warnings: string[];
321+
dedupe?: boolean;
322+
}): void {
323+
const key = resolve(args.dir);
324+
if (args.dedupe && loggedPiConfigDirs.has(key)) return;
325+
if (args.dedupe) {
326+
loggedPiConfigDirs.add(key);
327+
}
328+
if (args.loadedFromPaths.length > 0) {
329+
info(`config loaded from: ${args.loadedFromPaths.join(", ")}`);
330+
} else {
331+
info("config: no magic-context.jsonc found, using schema defaults");
332+
}
333+
for (const warning of args.warnings) {
334+
warn(`config: ${warning}`);
335+
}
336+
}
337+
338+
export const __test = {
339+
logPiConfigLoad,
340+
resetLoggedPiConfigDirs(): void {
341+
loggedPiConfigDirs.clear();
342+
},
343+
};
344+
313345
function formatTokens(value: number): string {
314346
return value.toLocaleString();
315347
}
@@ -449,9 +481,10 @@ setHarness("pi");
449481
// ---------------------------------------------------------------------------
450482
// Config-driven resolvers
451483
//
452-
// Step 5b replaced the env-var stop-gaps with `loadPiConfig()` which reads
453-
// $cwd/.pi/magic-context.jsonc (project) + ~/.pi/agent/magic-context.jsonc
454-
// (user) and merges them through the shared Zod schema. The resolvers below
484+
// Step 5b replaced the env-var stop-gaps with `loadPiConfig()`, which reads
485+
// the shared CortexKit config paths (project `.cortexkit/`, user `~/.config/`)
486+
// and falls back to Pi-owned legacy files only until migration completes. The
487+
// resolvers below
455488
// adapt the schema-shaped config into the Pi-specific options the various
456489
// registration helpers expect.
457490
//
@@ -655,10 +688,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
655688
);
656689

657690
// Step 5b: load the user's full magic-context.jsonc config. The loader
658-
// reads $cwd/.pi/magic-context.jsonc and ~/.pi/agent/magic-context.jsonc
659-
// (Pi convention), validates them through the shared Zod schema, falls
660-
// back to defaults for invalid fields per-key, and returns merged
661-
// config + warnings.
691+
// reads the shared CortexKit project/user paths, validates them through the
692+
// shared Zod schema, falls back to Pi-owned legacy files only while migration
693+
// is incomplete, and uses defaults for invalid fields per-key. It returns
694+
// the merged config plus warnings.
662695
//
663696
// We surface warnings via the standard `warn()` channel so users see
664697
// them in the magic-context log. Loading never throws — bad config
@@ -667,14 +700,12 @@ export default async function (pi: ExtensionAPI): Promise<void> {
667700
const { config, warnings, loadedFromPaths } = loadPiConfig({
668701
cwd: projectDir,
669702
});
670-
if (loadedFromPaths.length > 0) {
671-
info(`config loaded from: ${loadedFromPaths.join(", ")}`);
672-
} else {
673-
info("config: no magic-context.jsonc found, using schema defaults");
674-
}
675-
for (const w of warnings) {
676-
warn(`config: ${w}`);
677-
}
703+
logPiConfigLoad({
704+
dir: projectDir,
705+
loadedFromPaths,
706+
warnings,
707+
dedupe: true,
708+
});
678709

679710
// Pi opens the shared DB before config is available (above), so apply the
680711
// configured SQLite tuning to the already-open connection now. cache_size /
@@ -809,7 +840,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
809840
const cached = projectDepsByDir.get(dir);
810841
if (cached) return cached;
811842
ensureConfigLocationsMigrated(dir);
812-
const switchedConfig = loadPiConfig({ cwd: dir }).config;
843+
const switchedLoad = loadPiConfig({ cwd: dir });
844+
logPiConfigLoad({
845+
dir,
846+
loadedFromPaths: switchedLoad.loadedFromPaths,
847+
warnings: switchedLoad.warnings,
848+
dedupe: true,
849+
});
850+
const switchedConfig = switchedLoad.config;
813851
const switchedIdentity =
814852
identityOverride ?? resolveProjectIdentityOrFallback(dir);
815853
const built = buildProjectDeps(dir, switchedIdentity, switchedConfig);
@@ -1338,7 +1376,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
13381376

13391377
// Use effectiveConfig (re-resolved from the CURRENT checkout's cwd on
13401378
// a project switch) for every system-prompt decision below — a
1341-
// switched-into project may carry its own .pi/magic-context.jsonc
1379+
// switched-into project may carry its own .cortexkit/magic-context.jsonc
13421380
// (memory/docs/key-files/injection toggles). Reusing boot `config`
13431381
// would render the launch project's adjuncts in the new checkout.
13441382
if (effectiveConfig.system_prompt_injection?.enabled === false) {

packages/plugin/scripts/build-config-docs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ Add the schema line for editor validation and autocomplete:
233233
\`\`\`
234234
235235
:::note
236-
Project-level configs cannot use \`{env:VAR}\` / \`{file:path}\` expansion and cannot set \`sqlite.*\` or override hidden-agent prompts/permissions — these are security boundaries against untrusted repositories. User-level config has no such restriction.
236+
Project-level configs cannot use \`{env:VAR}\` / \`{file:path}\` expansion. A cloned repository also cannot set \`sqlite.*\`, hidden-agent prompts/permissions, \`historian.model\`, or \`historian.fallback_models\`. Project \`execute_threshold_percentage\` / \`execute_threshold_tokens\` may only RAISE thresholds relative to the user's effective settings (a repo may delay compaction, not make it happen earlier). Dreamer model/schedule/task tuning and \`memory.enabled\` remain allowed project overrides.
237237
:::
238238
239239
${sections.join("\n\n")}

0 commit comments

Comments
 (0)