-
Notifications
You must be signed in to change notification settings - Fork 134
fix(mcp): scope diagnostics to the project they came from #1212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -156,12 +156,16 @@ async function substituteWellKnownRemoteConfig(input: { | |
| dir: string | ||
| source: string | ||
| env: Record<string, string> | ||
| // altimate_change start — upstream_fix (#701): the project this load belongs to, so the | ||
| // blanked-variable record can be attributed to it rather than guessed from the path. | ||
| projectDir: string | ||
| // altimate_change end | ||
| }) { | ||
| if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined | ||
|
|
||
| // altimate_change start — upstream_fix (#701): the url and every header below publish under | ||
| // this same source, so clear once here and let those calls union into one record. | ||
| ConfigVariable.resetBlankedEnvVars(input.source) | ||
| ConfigVariable.resetBlankedEnvVars(input.source, input.projectDir) | ||
| // altimate_change end | ||
| const url = await ConfigVariable.substitute({ | ||
| text: input.value.url, | ||
|
|
@@ -345,14 +349,6 @@ export const layer = Layer.effect( | |
|
|
||
| const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record<string, string>) { | ||
| yield* Effect.logInfo("loading", { path: filepath }) | ||
| // altimate_change start — upstream_fix (#701): substitution unions now, so whoever begins a | ||
| // load clears this source first. Before the empty-file return, not after: a config that is | ||
| // deleted or emptied must drop the names it recorded while it still had a `{env:VAR}`, | ||
| // otherwise `mcp list` warns about a variable that appears in no config at all. | ||
| // Deliberately NOT inside loadConfig — the well-known flow records url/header blanks under | ||
| // the same source before calling it, and a reset in there threw those names away. | ||
| ConfigVariable.resetBlankedEnvVars(filepath) | ||
| // altimate_change end | ||
| const text = yield* readConfigFile(filepath) | ||
| if (!text) return {} as Info | ||
| return yield* loadConfig(text, { path: filepath }, env) | ||
|
|
@@ -372,6 +368,13 @@ export const layer = Layer.effect( | |
| .pipe(Effect.catch(() => Effect.void)) | ||
| } | ||
| } | ||
| // altimate_change start — upstream_fix (#701): declare ownership before each load, so a | ||
| // diagnostic can be attributed to a project or to every project. Clearing here also means a | ||
| // config that is deleted or emptied drops what it recorded while it still had a `{env:VAR}`. | ||
| for (const f of ["config.json", "opencode.json", "opencode.jsonc", "altimate-code.json", "altimate-code.jsonc"]) { | ||
| ConfigVariable.resetBlankedEnvVars(path.join(Global.Path.config, f), ConfigVariable.SHARED_CONFIG) | ||
| } | ||
| // altimate_change end | ||
| result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env)) | ||
| result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env)) | ||
| result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env)) | ||
|
|
@@ -488,6 +491,9 @@ export const layer = Layer.effect( | |
| dir: url, | ||
| source: wellknownURL, | ||
| env: authEnv, | ||
| // altimate_change start — upstream_fix (#701): attribute this load to the project. | ||
| projectDir: ctx.directory, | ||
| // altimate_change end | ||
| }), | ||
| ) | ||
| const fetchedConfig = remote | ||
|
|
@@ -523,12 +529,18 @@ export const layer = Layer.effect( | |
| yield* merge(Global.Path.config, global, "global") | ||
|
|
||
| if (Flag.OPENCODE_CONFIG) { | ||
| // altimate_change start — upstream_fix (#701): a process-wide config every instance loads. | ||
| ConfigVariable.resetBlankedEnvVars(Flag.OPENCODE_CONFIG, ConfigVariable.SHARED_CONFIG) | ||
| // altimate_change end | ||
| yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv)) | ||
| yield* Effect.logDebug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) | ||
| } | ||
|
|
||
| if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { | ||
| for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) { | ||
| // altimate_change start — upstream_fix (#701): this file belongs to this project. | ||
| ConfigVariable.resetBlankedEnvVars(file, ctx.directory) | ||
| // altimate_change end | ||
| yield* merge(file, yield* loadFile(file, authEnv), "local") | ||
| } | ||
| } | ||
|
|
@@ -561,6 +573,9 @@ export const layer = Layer.effect( | |
| // altimate_change end | ||
| const source = path.join(dir, file) | ||
| yield* Effect.logDebug(`loading config from ${source}`) | ||
| // altimate_change start — upstream_fix (#701): loaded for this instance. | ||
| ConfigVariable.resetBlankedEnvVars(source, ctx.directory) | ||
| // altimate_change end | ||
| yield* merge(source, yield* loadFile(source, authEnv)) | ||
| result.agent ??= {} | ||
| result.mode ??= {} | ||
|
|
@@ -611,7 +626,7 @@ export const layer = Layer.effect( | |
| if (process.env.OPENCODE_CONFIG_CONTENT) { | ||
| const source = "OPENCODE_CONFIG_CONTENT" | ||
| // altimate_change start — upstream_fix (#701): clear before this load. | ||
| ConfigVariable.resetBlankedEnvVars(source) | ||
| ConfigVariable.resetBlankedEnvVars(source, ctx.directory) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Shared config sources are scoped to a single project, dropping their blank-env diagnostics for every other project
Scoping them to Reply with |
||
| // altimate_change end | ||
| const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { | ||
| dir: ctx.directory, | ||
|
|
@@ -641,7 +656,7 @@ export const layer = Layer.effect( | |
| if (Option.isSome(configOpt)) { | ||
| const source = `${url}/api/config` | ||
| // altimate_change start — upstream_fix (#701): clear before this load. | ||
| ConfigVariable.resetBlankedEnvVars(source) | ||
| ConfigVariable.resetBlankedEnvVars(source, ctx.directory) | ||
| // altimate_change end | ||
| const next = yield* loadConfig(JSON.stringify(configOpt.value), { | ||
| dir: path.dirname(source), | ||
|
|
@@ -679,6 +694,9 @@ export const layer = Layer.effect( | |
| // altimate_change end | ||
| const source = path.join(managedDir, file) | ||
| // altimate_change start — note a managed datamate key before merging | ||
| // altimate_change start — upstream_fix (#701): MDM-deployed, machine-wide. | ||
| ConfigVariable.resetBlankedEnvVars(source, ConfigVariable.SHARED_CONFIG) | ||
| // altimate_change end | ||
|
Comment on lines
+697
to
+699
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Remove the newly nested Both changes open a marker inside an existing marker block. Keep the code inside the outer block and remove only the inner start/end markers.
As per coding guidelines, “Keep 📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| const managedFile = yield* loadFile(source) | ||
| if (managedFile?.mcp && DATAMATE_KEY in managedFile.mcp) managedOwnsDatamate = true | ||
| yield* merge(source, managedFile, "global") | ||
|
|
@@ -690,7 +708,7 @@ export const layer = Layer.effect( | |
| const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences()) | ||
| if (managed) { | ||
| // altimate_change start — upstream_fix (#701): clear before this load. | ||
| ConfigVariable.resetBlankedEnvVars(managed.source) | ||
| ConfigVariable.resetBlankedEnvVars(managed.source, ConfigVariable.SHARED_CONFIG) | ||
| // altimate_change end | ||
| // altimate_change start — note a managed datamate key before merging | ||
| const managedPrefs = yield* loadConfig(managed.text, { | ||
|
|
@@ -774,8 +792,9 @@ export const layer = Layer.effect( | |
| const configured = (result.mcp as Record<string, any>)[name] | ||
| setConfigDrift( | ||
| name, | ||
| discoveredSource(name) ?? sources.join(", "), | ||
| discoveredSource(name, ctx.directory) ?? sources.join(", "), | ||
| driftFields(server as Record<string, any>, configured), | ||
| ctx.directory, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -107,7 +107,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: | |
| // altimate_change start — upstream_fix (#701): substitution unions now instead of | ||
| // replacing, so every caller clears first. Without this a `{env:VAR}` in tui.json that | ||
| // was later fixed kept being reported blank for the life of the process. | ||
| ConfigVariable.resetBlankedEnvVars(configFilepath) | ||
| ConfigVariable.resetBlankedEnvVars(configFilepath, ctx.directory) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Keep shared TUI configuration sources shared.
Pass ownership into 🤖 Prompt for AI Agents |
||
| // altimate_change end | ||
| const expanded = yield* Effect.promise(() => | ||
| ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,21 +32,56 @@ type SubstituteInput = ParseSource & { | |
| // An unresolved bare `${VAR}` is left LITERAL above on purpose, so it stays visible and is not | ||
| // recorded here. `{env:VAR}` has no such deferral — it becomes "" and the config parses clean, so | ||
| // a missing `{env:SNOWFLAKE_PASSWORD}` launches an MCP server with a blank credential and fails | ||
| // later with an error naming neither the variable nor this file. Keyed by config source; the | ||
| // newest parse of a file replaces its entry so a fixed variable stops being reported. | ||
| // later with an error naming neither the variable nor this file. | ||
| const _blankedEnv = new Map<string, Set<string>>() | ||
|
|
||
| /** Drop `src`'s record so a load starts clean; substitution then unions within that load. */ | ||
| export function resetBlankedEnvVars(src: string) { | ||
| /** | ||
| * Who a config source belongs to: a project directory, or SHARED for one every instance loads. | ||
| * | ||
| * Declared by the loader rather than guessed from the path. An earlier attempt inferred it — | ||
| * "under this project, or under $HOME/the config dir, is mine" — which is wrong in the ordinary | ||
| * case, because projects live under $HOME: `/Users/me/code/projB/altimate-code.json` was | ||
| * classified as shared and leaked into project A's diagnostics. The loader always knows; the | ||
| * path never reliably tells you. | ||
| */ | ||
| const _sourceOwner = new Map<string, string>() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: Entries are added on every Reply with |
||
|
|
||
| /** Marker for a config every instance in the process loads: global config, OPENCODE_CONFIG, managed. */ | ||
| export const SHARED_CONFIG = "\u0000shared" | ||
|
|
||
| /** | ||
| * Drop `src`'s record so a load starts clean, and declare who it belongs to. | ||
| * | ||
| * `owner` is the project directory being loaded, or `SHARED_CONFIG`. Substitution then unions | ||
| * into the source within that load. | ||
| */ | ||
| export function resetBlankedEnvVars(src: string, owner: string) { | ||
| _blankedEnv.delete(src) | ||
| _sourceOwner.set(src, owner) | ||
| } | ||
|
|
||
| /** Variable names that silently became "" during config substitution, grouped by config source. */ | ||
| export function blankedEnvVars(): { source: string; names: string[] }[] { | ||
| /** | ||
| * Variable names that silently became "" while loading `projectDir`, grouped by config source. | ||
| * | ||
| * Returns this project's own sources plus the shared ones. A source whose owner was never | ||
| * declared is omitted: a diagnostic that cannot be attributed is not worth showing to the wrong | ||
| * session, and every loader in this file declares one. | ||
| */ | ||
| export function blankedEnvVars(projectDir: string): { source: string; names: string[] }[] { | ||
| return [..._blankedEnv.entries()] | ||
| .filter(([src]) => { | ||
| const owner = _sourceOwner.get(src) | ||
| return owner === projectDir || owner === SHARED_CONFIG | ||
| }) | ||
| .map(([src, names]) => ({ source: src, names: [...names].sort() })) | ||
| .sort((a, b) => a.source.localeCompare(b.source)) | ||
| } | ||
|
|
||
| /** Test seam — forget every source's ownership and recorded names. */ | ||
| export function resetAllBlankedEnvVars() { | ||
| _blankedEnv.clear() | ||
| _sourceOwner.clear() | ||
| } | ||
| // altimate_change end | ||
|
|
||
| function source(input: ParseSource) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Use a unique ownership key for reused virtual sources.
resetBlankedEnvVarsstores one owner per source key. These calls pass a project directory for source identifiers that can be reused by multiple instances: the well-known URL,"OPENCODE_CONFIG_CONTENT", and${url}/api/config.When Project B loads one of these sources, it deletes Project A's record and assigns ownership to Project B. Project A then loses its diagnostics.
Use a project-qualified source key for project-scoped sources. Pass
ConfigVariable.SHARED_CONFIGfor sources that are process-wide. Keep any internal scoped key separate from the display source.Also applies to: 629-629, 659-659
🤖 Prompt for AI Agents
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Clear diagnostics when an optional source disappears.
These resets run only when the source has content. A missing or invalid remote configuration returns before Line 168. An empty
OPENCODE_CONFIG_CONTENTskips Line 629. A missing organization configuration skips Line 659.If a source was loaded earlier, its old blanked-variable names remain visible after the source is removed. Move each reset and ownership declaration before its optional-source guard.
Also applies to: 629-629, 659-659
🤖 Prompt for AI Agents