From 587f79c2cf6a182308d3d6aec3b6b05db773d081 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 18 Oct 2026 19:53:23 -0700 Subject: [PATCH 1/6] fix: point the `warehouse_list` no-connections hint at the real config locations The hint told users to create `connections.json` in `.opencode/`, which the registry never reads. It now names the global and project `.altimate-code/connections.json` paths and the `ALTIMATE_CODE_CONN_*` variables, mentions `warehouse_add`, and keeps the example JSON. The paths come from `globalConfigPath()`, `localConfigPath()` and `CONN_ENV_PREFIX`, now exported from the registry, so the hint cannot drift. A repo sweep found no other reference to the old location. Closes #1558 Co-Authored-By: Claude Opus 5.5 --- .../altimate/native/connections/registry.ts | 10 ++-- .../src/altimate/tools/warehouse-list.ts | 25 ++++++++- .../test/altimate/warehouse-list-hint.test.ts | 54 +++++++++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/altimate/warehouse-list-hint.test.ts diff --git a/packages/opencode/src/altimate/native/connections/registry.ts b/packages/opencode/src/altimate/native/connections/registry.ts index b3f11b7895..a178e73aa7 100644 --- a/packages/opencode/src/altimate/native/connections/registry.ts +++ b/packages/opencode/src/altimate/native/connections/registry.ts @@ -58,7 +58,7 @@ const SCRATCH_NAME_CONFLICT = // Config file paths // --------------------------------------------------------------------------- -function globalConfigPath(): string { +export function globalConfigPath(): string { // altimate_change start — the same home Global.Path uses, so the test preload's OPENCODE_TEST_HOME keeps the // suite out of the developer's real connections (Bun caches os.homedir(), so changing HOME does not). return path.join(Global.Path.home, ".altimate-code", "connections.json") @@ -93,7 +93,7 @@ function projectRoot(): string { } // altimate_change end -function localConfigPath(): string { +export function localConfigPath(): string { // altimate_change start — resolve against the project, not the launch cwd return path.join(projectRoot(), ".altimate-code", "connections.json") // altimate_change end @@ -172,9 +172,13 @@ function loadFromFile(filePath: string): Record { } } +// altimate_change start — exported so user-facing hints name the variable the loader reads +export const CONN_ENV_PREFIX = "ALTIMATE_CODE_CONN_" +// altimate_change end + function loadFromEnv(): Record { const result: Record = {} - const prefix = "ALTIMATE_CODE_CONN_" + const prefix = CONN_ENV_PREFIX for (const [key, value] of Object.entries(process.env)) { if (!key.startsWith(prefix) || !value) continue diff --git a/packages/opencode/src/altimate/tools/warehouse-list.ts b/packages/opencode/src/altimate/tools/warehouse-list.ts index 21dcdfab46..ba897b4c29 100644 --- a/packages/opencode/src/altimate/tools/warehouse-list.ts +++ b/packages/opencode/src/altimate/tools/warehouse-list.ts @@ -5,6 +5,29 @@ import { Dispatcher } from "../native" import * as Precedence from "../workspace/precedence" // altimate_change end +// altimate_change start — point at the locations the registry actually reads +import { CONN_ENV_PREFIX, globalConfigPath, localConfigPath } from "../native/connections/registry" + +/** The no-connections hint. Paths come from the registry's own helpers so it cannot drift from what is loaded. */ +export function noConnectionsMessage(): string { + return [ + "No warehouse connections configured.", + "", + "Connections are read from these sources (later ones override earlier ones):", + ` 1. ${globalConfigPath()} (global)`, + ` 2. ${localConfigPath()} (this project)`, + ` 3. ${CONN_ENV_PREFIX} environment variables, each holding one connection's config as JSON`, + "", + "Use the warehouse_add tool to add one, or create a connections.json at one of the paths above with:", + "{", + ' "my-db": { "type": "postgres", "host": "localhost", "port": 5432, "database": "mydb", "user": "user", "password": "pass" }', + "}", + "", + "Do not put connections.json in .opencode/; it is not read from there.", + ].join("\n") +} +// altimate_change end + export const WarehouseListTool = Tool.define("warehouse_list", { description: "List all configured warehouse connections. Shows connection name, type, and database.", parameters: z.object({}), @@ -17,7 +40,7 @@ export const WarehouseListTool = Tool.define("warehouse_list", { return { title: "Warehouses: none configured", metadata: { count: 0 }, - output: "No warehouse connections configured.\n\nTo add a connection, create a connections.json file in .opencode/ with:\n{\n \"my-db\": { \"type\": \"postgres\", \"host\": \"localhost\", \"port\": 5432, \"database\": \"mydb\", \"user\": \"user\", \"password\": \"pass\" }\n}", + output: noConnectionsMessage(), } } diff --git a/packages/opencode/test/altimate/warehouse-list-hint.test.ts b/packages/opencode/test/altimate/warehouse-list-hint.test.ts new file mode 100644 index 0000000000..f1ba3329fa --- /dev/null +++ b/packages/opencode/test/altimate/warehouse-list-hint.test.ts @@ -0,0 +1,54 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import * as Registry from "../../src/altimate/native/connections/registry" +import { noConnectionsMessage } from "../../src/altimate/tools/warehouse-list" + +let project: string +let prevCwd: string + +beforeEach(() => { + prevCwd = process.cwd() + project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "whl-hint-"))) + process.chdir(project) + Registry.reset() +}) + +afterEach(() => { + process.chdir(prevCwd) + Registry.reset() + fs.rmSync(project, { recursive: true, force: true }) +}) + +describe("warehouse_list no-connections hint", () => { + test("names the paths and env prefix the registry reads, not .opencode", () => { + const msg = noConnectionsMessage() + expect(msg).toContain(Registry.globalConfigPath()) + expect(msg).toContain(Registry.localConfigPath()) + expect(msg).toContain(Registry.CONN_ENV_PREFIX) + expect(msg).toContain("warehouse_add") + expect(msg).toContain('"type": "postgres"') + expect(msg).not.toContain(".opencode/connections.json") + expect(Registry.localConfigPath()).toBe(path.join(project, ".altimate-code", "connections.json")) + }) + + test("a connections.json written to the project path named by the hint is loaded", () => { + const named = Registry.localConfigPath() + expect(noConnectionsMessage()).toContain(named) + fs.mkdirSync(path.dirname(named), { recursive: true }) + fs.writeFileSync(named, JSON.stringify({ "hint-db": { type: "postgres", host: "localhost", database: "d" } })) + Registry.load() + expect(Registry.names()).toContain("hint-db") + }) + + test("a connections.json at the legacy .opencode location is not loaded", () => { + fs.mkdirSync(path.join(project, ".opencode")) + fs.writeFileSync( + path.join(project, ".opencode", "connections.json"), + JSON.stringify({ "wrong-db": { type: "postgres", host: "localhost" } }), + ) + Registry.load() + expect(Registry.names()).not.toContain("wrong-db") + }) +}) From a3c72743604e129d774072d3558c5882468cbd73 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 18 Oct 2026 20:10:01 -0700 Subject: [PATCH 2/6] fix: reload connection config edited after the first load and lead the hint with `warehouse_add` The registry loaded once per process, so a `connections.json` created (or an `ALTIMATE_CODE_CONN_*` variable set) after an empty `warehouse_list` stayed invisible until restart. `ensureLoaded()` now compares a stat/env fingerprint of the sources against the last load and re-reads them when it changed, dropping cached connectors whose config changed. Connections added in-process keep their in-memory credentials; `setConfigs()` injections are never reloaded. The hint now leads with `warehouse_add`, notes the reload behaviour and that credentials can come from `ALTIMATE_CODE_CONN_*` instead of a plaintext password. Co-Authored-By: Claude Opus 5.5 --- .../altimate/native/connections/registry.ts | 66 +++++++++++++++++++ .../src/altimate/tools/warehouse-list.ts | 7 +- .../test/altimate/warehouse-list-hint.test.ts | 54 ++++++++++++++- 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/native/connections/registry.ts b/packages/opencode/src/altimate/native/connections/registry.ts index a178e73aa7..aa48cf1d9e 100644 --- a/packages/opencode/src/altimate/native/connections/registry.ts +++ b/packages/opencode/src/altimate/native/connections/registry.ts @@ -221,11 +221,61 @@ export function load(): void { if (configs.has(SCRATCH_NAME)) Log.Default.warn(SCRATCH_NAME_CONFLICT) loaded = true + // altimate_change start + injected = false + loadedSignature = sourceSignature() + // altimate_change end +} + +// altimate_change start — pick up config edited after the first load (e.g. an agent following the +// `warehouse_list` hint) without a restart. Stat-based: two stats plus the env scan per access. +/** True once configs were injected with setConfigs(); they have no on-disk source to compare. */ +let injected = false +/** Fingerprint of the sources behind the last load. */ +let loadedSignature = "" +/** Connections added in this process, with the credentials the sanitized file no longer holds. */ +const sessionAdded = new Map() + +function statSignature(filePath: string): string { + try { + const st = fs.statSync(filePath) + return `${filePath}:${st.mtimeMs}:${st.size}` + } catch { + return `${filePath}:-` + } +} + +function sourceSignature(): string { + const env = Object.entries(process.env) + .filter(([key, value]) => key.startsWith(CONN_ENV_PREFIX) && value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`) + return [statSignature(globalConfigPath()), statSignature(localConfigPath()), ...env].join("\n") +} + +/** Re-read the sources, keeping this process's own added connections and dropping stale connectors. */ +function refresh(): void { + const previous = new Map(configs) + load() + for (const [name, config] of sessionAdded) { + // A project file or env var defining the name outranks what this process added to the global file. + const overridden = Object.hasOwn(loadFromFile(localConfigPath()), name) || Object.hasOwn(loadFromEnv(), name) + if (!overridden && configs.has(name)) configs.set(name, config) + } + for (const [name, connector] of [...connectors]) { + if (JSON.stringify(configs.get(name)) === JSON.stringify(previous.get(name))) continue + connectors.delete(name) + connector.close().catch(() => {}) + } } +// altimate_change end /** Ensure configs are loaded. */ function ensureLoaded(): void { if (!loaded) load() + // altimate_change start + else if (!injected && sourceSignature() !== loadedSignature) refresh() + // altimate_change end } // --------------------------------------------------------------------------- @@ -748,6 +798,10 @@ export async function add( // session can connect even when keytar is unavailable. Only the disk // file uses the sanitized version (credentials stripped). configs.set(name, normalized) + // altimate_change start — our own write is not an external edit, and the credentials stay in memory + sessionAdded.set(name, normalized) + loadedSignature = sourceSignature() + // altimate_change end // Clear cached connector const cached = connectors.get(name) @@ -807,6 +861,10 @@ export async function remove(name: string): Promise<{ success: boolean; error?: // Remove from in-memory configs.delete(name) + // altimate_change start + sessionAdded.delete(name) + loadedSignature = sourceSignature() + // altimate_change end // altimate_change start — remove its secrets from the OS credential store too. Entries are keyed by name // only, so they are kept while this project's config or an env var still defines a connection by that name. @@ -865,6 +923,10 @@ export function reset(): void { pending.clear() loaded = false censusSent = false + // altimate_change start + injected = false + sessionAdded.clear() + // altimate_change end } /** @@ -876,4 +938,8 @@ export function setConfigs(newConfigs: Record): void { configs.set(name, config) } loaded = true + // altimate_change start + injected = true + sessionAdded.clear() + // altimate_change end } diff --git a/packages/opencode/src/altimate/tools/warehouse-list.ts b/packages/opencode/src/altimate/tools/warehouse-list.ts index ba897b4c29..c6b52907de 100644 --- a/packages/opencode/src/altimate/tools/warehouse-list.ts +++ b/packages/opencode/src/altimate/tools/warehouse-list.ts @@ -13,15 +13,18 @@ export function noConnectionsMessage(): string { return [ "No warehouse connections configured.", "", - "Connections are read from these sources (later ones override earlier ones):", + "Preferred: use the warehouse_add tool. It stores passwords and keys in the OS credential store instead of a plaintext file.", + "", + "Alternatively, connections are read from these sources (later ones override earlier ones):", ` 1. ${globalConfigPath()} (global)`, ` 2. ${localConfigPath()} (this project)`, ` 3. ${CONN_ENV_PREFIX} environment variables, each holding one connection's config as JSON`, "", - "Use the warehouse_add tool to add one, or create a connections.json at one of the paths above with:", + "Changes to these sources are picked up on the next call; no restart is needed. Example connections.json:", "{", ' "my-db": { "type": "postgres", "host": "localhost", "port": 5432, "database": "mydb", "user": "user", "password": "pass" }', "}", + `To keep the password out of the file, omit it and set ${CONN_ENV_PREFIX} to the full config as JSON instead.`, "", "Do not put connections.json in .opencode/; it is not read from there.", ].join("\n") diff --git a/packages/opencode/test/altimate/warehouse-list-hint.test.ts b/packages/opencode/test/altimate/warehouse-list-hint.test.ts index f1ba3329fa..23836950dd 100644 --- a/packages/opencode/test/altimate/warehouse-list-hint.test.ts +++ b/packages/opencode/test/altimate/warehouse-list-hint.test.ts @@ -3,7 +3,23 @@ import * as fs from "fs" import * as os from "os" import * as path from "path" import * as Registry from "../../src/altimate/native/connections/registry" -import { noConnectionsMessage } from "../../src/altimate/tools/warehouse-list" +import { WarehouseListTool, noConnectionsMessage } from "../../src/altimate/tools/warehouse-list" +import { registerAll } from "../../src/altimate/native/connections/register" +import { SessionID, MessageID } from "../../src/session/schema" +import { initTool } from "./tool-fixture" + +function toolCtx() { + return { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "call_test", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + } as any +} let project: string let prevCwd: string @@ -13,6 +29,7 @@ beforeEach(() => { project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "whl-hint-"))) process.chdir(project) Registry.reset() + registerAll() }) afterEach(() => { @@ -51,4 +68,39 @@ describe("warehouse_list no-connections hint", () => { Registry.load() expect(Registry.names()).not.toContain("wrong-db") }) + + test("the tool lists a connection created after an empty answer, in the same process", async () => { + const tool = await initTool(WarehouseListTool) + const ctx = toolCtx() + const first = await tool.execute({}, ctx) + expect(first.output).toContain(Registry.localConfigPath()) + + const named = Registry.localConfigPath() + fs.mkdirSync(path.dirname(named), { recursive: true }) + fs.writeFileSync(named, JSON.stringify({ "late-db": { type: "postgres", host: "localhost", database: "d" } })) + + const second = await tool.execute({}, ctx) + expect(second.output).toContain("late-db") + // the lookup sql tools use resolves it too + expect(Registry.getConfig("late-db")?.type).toBe("postgres") + }) + + test("an env var set after an empty answer is picked up, and removing a file drops its connection", async () => { + const tool = await initTool(WarehouseListTool) + const ctx = toolCtx() + await tool.execute({}, ctx) + process.env.ALTIMATE_CODE_CONN_ENVLATE = JSON.stringify({ type: "postgres", host: "h" }) + try { + expect((await tool.execute({}, ctx)).output).toContain("envlate") + } finally { + delete process.env.ALTIMATE_CODE_CONN_ENVLATE + } + expect((await tool.execute({}, ctx)).output).toContain("No warehouse connections") + }) + + test("setConfigs-injected connections are not discarded by the staleness check", () => { + Registry.setConfigs({ injected: { type: "postgres" } }) + expect(Registry.names()).toEqual(["injected"]) + expect(Registry.names()).toEqual(["injected"]) + }) }) From f27bdc6b9c41fb7f1a7c329adde77016abce72fb Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 18 Oct 2026 20:12:41 -0700 Subject: [PATCH 3/6] fix: keep a refreshed connection config authoritative over stale in-memory state Review of the reload change found two ways an old target could survive a config edit: a connection added in-process was restored over a later edit of its global entry, and a connect already in flight could cache a connector built from the previous config. - `add()` now remembers the sanitized form it wrote; the in-memory copy (with credentials) is restored only while the file still holds exactly that. - Refresh bumps a config generation; a connect that finished against an obsolete config is closed and fails with a retry message instead of being cached. Co-Authored-By: Claude Opus 5.5 --- .../altimate/native/connections/registry.ts | 32 +++++++++++++++---- .../test/altimate/warehouse-list-hint.test.ts | 15 +++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/altimate/native/connections/registry.ts b/packages/opencode/src/altimate/native/connections/registry.ts index aa48cf1d9e..1a46152660 100644 --- a/packages/opencode/src/altimate/native/connections/registry.ts +++ b/packages/opencode/src/altimate/native/connections/registry.ts @@ -233,8 +233,13 @@ export function load(): void { let injected = false /** Fingerprint of the sources behind the last load. */ let loadedSignature = "" -/** Connections added in this process, with the credentials the sanitized file no longer holds. */ -const sessionAdded = new Map() +/** Bumped whenever a refresh changes a connection's config, so an in-flight connect can tell it is obsolete. */ +let configGeneration = 0 +/** + * Connections added in this process: the full config (with credentials the sanitized file no longer holds) and + * the sanitized form that was written, so a later edit of the file is not overridden by the older in-memory copy. + */ +const sessionAdded = new Map() function statSignature(filePath: string): string { try { @@ -257,10 +262,15 @@ function sourceSignature(): string { function refresh(): void { const previous = new Map(configs) load() - for (const [name, config] of sessionAdded) { - // A project file or env var defining the name outranks what this process added to the global file. + for (const [name, { full, written }] of sessionAdded) { + // Only while the file still holds what add() wrote: a project file or env var defining the name outranks it, + // and a later edit of the global entry must win over the older in-memory copy. const overridden = Object.hasOwn(loadFromFile(localConfigPath()), name) || Object.hasOwn(loadFromEnv(), name) - if (!overridden && configs.has(name)) configs.set(name, config) + if (!overridden && JSON.stringify(configs.get(name)) === JSON.stringify(written)) configs.set(name, full) + else if (!overridden) sessionAdded.delete(name) + } + for (const name of new Set([...previous.keys(), ...configs.keys()])) { + if (JSON.stringify(configs.get(name)) !== JSON.stringify(previous.get(name))) configGeneration++ } for (const [name, connector] of [...connectors]) { if (JSON.stringify(configs.get(name)) === JSON.stringify(previous.get(name))) continue @@ -600,6 +610,9 @@ export async function get(name: string): Promise { }) } catch {} // altimate_change end + // altimate_change start + const generation = configGeneration + // altimate_change end const promise = (async () => { try { const connector = await createConnector(name, config) @@ -610,6 +623,13 @@ export async function get(name: string): Promise { closeTunnel(name) throw connectErr } + // altimate_change start — the config changed while this connected; caching it would pin the old target + if (generation !== configGeneration && JSON.stringify(configs.get(name)) !== JSON.stringify(config)) { + await connector.close().catch(() => {}) + closeTunnel(name) + throw new Error(`Connection "${name}" changed while it was connecting; retry.`) + } + // altimate_change end connectors.set(name, connector) fileLog("INFO", "warehouse-connect", "connected", { name, type: config.type, auth: authMethod, duration_ms: Date.now() - startTime }) try { @@ -799,7 +819,7 @@ export async function add( // file uses the sanitized version (credentials stripped). configs.set(name, normalized) // altimate_change start — our own write is not an external edit, and the credentials stay in memory - sessionAdded.set(name, normalized) + sessionAdded.set(name, { full: normalized, written: sanitized }) loadedSignature = sourceSignature() // altimate_change end diff --git a/packages/opencode/test/altimate/warehouse-list-hint.test.ts b/packages/opencode/test/altimate/warehouse-list-hint.test.ts index 23836950dd..2454a15760 100644 --- a/packages/opencode/test/altimate/warehouse-list-hint.test.ts +++ b/packages/opencode/test/altimate/warehouse-list-hint.test.ts @@ -103,4 +103,19 @@ describe("warehouse_list no-connections hint", () => { expect(Registry.names()).toEqual(["injected"]) expect(Registry.names()).toEqual(["injected"]) }) + + test("a connection added in-process yields to a later edit of its global entry", async () => { + const added = await Registry.add("edit-me", { type: "postgres", host: "old-host", database: "d" }) + expect(added.success).toBe(true) + try { + expect(Registry.getConfig("edit-me")?.host).toBe("old-host") + const globalPath = Registry.globalConfigPath() + const onDisk = JSON.parse(fs.readFileSync(globalPath, "utf-8")) + onDisk["edit-me"].host = "new-host" + fs.writeFileSync(globalPath, JSON.stringify(onDisk)) + expect(Registry.getConfig("edit-me")?.host).toBe("new-host") + } finally { + await Registry.remove("edit-me") + } + }) }) From 55bf62b96d43f26e8292bf59666f5dbc203654ec Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 18 Oct 2026 20:24:37 -0700 Subject: [PATCH 4/6] fix: reload connection config only while the registry is empty The fingerprint-based reload replaced the process-global config map when the project directory or a file changed, which could close another project's connectors and drop in-memory credentials of a keyring-less `warehouse_add`. Replace it with a structural rule: re-read the sources only while the registry holds zero connections (the case the `warehouse_list` hint addresses). Once any connection is loaded nothing is replaced or closed, so edits to an existing file take effect after a restart. `setConfigs()` injections are not reloaded. The hint says so. Co-Authored-By: Claude Opus 5.5 --- .../altimate/native/connections/registry.ts | 87 ++----------------- .../src/altimate/tools/warehouse-list.ts | 2 +- .../test/altimate/warehouse-list-hint.test.ts | 46 ++++++---- 3 files changed, 39 insertions(+), 96 deletions(-) diff --git a/packages/opencode/src/altimate/native/connections/registry.ts b/packages/opencode/src/altimate/native/connections/registry.ts index 1a46152660..07bb133241 100644 --- a/packages/opencode/src/altimate/native/connections/registry.ts +++ b/packages/opencode/src/altimate/native/connections/registry.ts @@ -221,73 +221,22 @@ export function load(): void { if (configs.has(SCRATCH_NAME)) Log.Default.warn(SCRATCH_NAME_CONFLICT) loaded = true - // altimate_change start - injected = false - loadedSignature = sourceSignature() - // altimate_change end -} - -// altimate_change start — pick up config edited after the first load (e.g. an agent following the -// `warehouse_list` hint) without a restart. Stat-based: two stats plus the env scan per access. -/** True once configs were injected with setConfigs(); they have no on-disk source to compare. */ -let injected = false -/** Fingerprint of the sources behind the last load. */ -let loadedSignature = "" -/** Bumped whenever a refresh changes a connection's config, so an in-flight connect can tell it is obsolete. */ -let configGeneration = 0 -/** - * Connections added in this process: the full config (with credentials the sanitized file no longer holds) and - * the sanitized form that was written, so a later edit of the file is not overridden by the older in-memory copy. - */ -const sessionAdded = new Map() - -function statSignature(filePath: string): string { - try { - const st = fs.statSync(filePath) - return `${filePath}:${st.mtimeMs}:${st.size}` - } catch { - return `${filePath}:-` - } -} - -function sourceSignature(): string { - const env = Object.entries(process.env) - .filter(([key, value]) => key.startsWith(CONN_ENV_PREFIX) && value) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, value]) => `${key}=${value}`) - return [statSignature(globalConfigPath()), statSignature(localConfigPath()), ...env].join("\n") -} - -/** Re-read the sources, keeping this process's own added connections and dropping stale connectors. */ -function refresh(): void { - const previous = new Map(configs) - load() - for (const [name, { full, written }] of sessionAdded) { - // Only while the file still holds what add() wrote: a project file or env var defining the name outranks it, - // and a later edit of the global entry must win over the older in-memory copy. - const overridden = Object.hasOwn(loadFromFile(localConfigPath()), name) || Object.hasOwn(loadFromEnv(), name) - if (!overridden && JSON.stringify(configs.get(name)) === JSON.stringify(written)) configs.set(name, full) - else if (!overridden) sessionAdded.delete(name) - } - for (const name of new Set([...previous.keys(), ...configs.keys()])) { - if (JSON.stringify(configs.get(name)) !== JSON.stringify(previous.get(name))) configGeneration++ - } - for (const [name, connector] of [...connectors]) { - if (JSON.stringify(configs.get(name)) === JSON.stringify(previous.get(name))) continue - connectors.delete(name) - connector.close().catch(() => {}) - } } -// altimate_change end /** Ensure configs are loaded. */ function ensureLoaded(): void { if (!loaded) load() - // altimate_change start - else if (!injected && sourceSignature() !== loadedSignature) refresh() + // altimate_change start — the registry latches on first load, so a connections file created after an empty + // answer (the `warehouse_list` hint) was never seen. Re-read only while nothing is loaded: there is no state to + // replace or close, and a populated registry never changes under a running session. + else if (configs.size === 0 && !injected) load() // altimate_change end } +// altimate_change start — set by setConfigs() (tests): an injected empty registry is not a stale one +let injected = false +// altimate_change end + // --------------------------------------------------------------------------- // Driver factory // --------------------------------------------------------------------------- @@ -610,9 +559,6 @@ export async function get(name: string): Promise { }) } catch {} // altimate_change end - // altimate_change start - const generation = configGeneration - // altimate_change end const promise = (async () => { try { const connector = await createConnector(name, config) @@ -623,13 +569,6 @@ export async function get(name: string): Promise { closeTunnel(name) throw connectErr } - // altimate_change start — the config changed while this connected; caching it would pin the old target - if (generation !== configGeneration && JSON.stringify(configs.get(name)) !== JSON.stringify(config)) { - await connector.close().catch(() => {}) - closeTunnel(name) - throw new Error(`Connection "${name}" changed while it was connecting; retry.`) - } - // altimate_change end connectors.set(name, connector) fileLog("INFO", "warehouse-connect", "connected", { name, type: config.type, auth: authMethod, duration_ms: Date.now() - startTime }) try { @@ -818,10 +757,6 @@ export async function add( // session can connect even when keytar is unavailable. Only the disk // file uses the sanitized version (credentials stripped). configs.set(name, normalized) - // altimate_change start — our own write is not an external edit, and the credentials stay in memory - sessionAdded.set(name, { full: normalized, written: sanitized }) - loadedSignature = sourceSignature() - // altimate_change end // Clear cached connector const cached = connectors.get(name) @@ -881,10 +816,6 @@ export async function remove(name: string): Promise<{ success: boolean; error?: // Remove from in-memory configs.delete(name) - // altimate_change start - sessionAdded.delete(name) - loadedSignature = sourceSignature() - // altimate_change end // altimate_change start — remove its secrets from the OS credential store too. Entries are keyed by name // only, so they are kept while this project's config or an env var still defines a connection by that name. @@ -945,7 +876,6 @@ export function reset(): void { censusSent = false // altimate_change start injected = false - sessionAdded.clear() // altimate_change end } @@ -960,6 +890,5 @@ export function setConfigs(newConfigs: Record): void { loaded = true // altimate_change start injected = true - sessionAdded.clear() // altimate_change end } diff --git a/packages/opencode/src/altimate/tools/warehouse-list.ts b/packages/opencode/src/altimate/tools/warehouse-list.ts index c6b52907de..825657931b 100644 --- a/packages/opencode/src/altimate/tools/warehouse-list.ts +++ b/packages/opencode/src/altimate/tools/warehouse-list.ts @@ -20,7 +20,7 @@ export function noConnectionsMessage(): string { ` 2. ${localConfigPath()} (this project)`, ` 3. ${CONN_ENV_PREFIX} environment variables, each holding one connection's config as JSON`, "", - "Changes to these sources are picked up on the next call; no restart is needed. Example connections.json:", + "After adding the first connection, call warehouse_list again. Edits to an existing connections.json take effect after a restart. Example connections.json:", "{", ' "my-db": { "type": "postgres", "host": "localhost", "port": 5432, "database": "mydb", "user": "user", "password": "pass" }', "}", diff --git a/packages/opencode/test/altimate/warehouse-list-hint.test.ts b/packages/opencode/test/altimate/warehouse-list-hint.test.ts index 2454a15760..bcbd64f187 100644 --- a/packages/opencode/test/altimate/warehouse-list-hint.test.ts +++ b/packages/opencode/test/altimate/warehouse-list-hint.test.ts @@ -85,7 +85,7 @@ describe("warehouse_list no-connections hint", () => { expect(Registry.getConfig("late-db")?.type).toBe("postgres") }) - test("an env var set after an empty answer is picked up, and removing a file drops its connection", async () => { + test("an env var set after an empty answer is picked up", async () => { const tool = await initTool(WarehouseListTool) const ctx = toolCtx() await tool.execute({}, ctx) @@ -95,27 +95,41 @@ describe("warehouse_list no-connections hint", () => { } finally { delete process.env.ALTIMATE_CODE_CONN_ENVLATE } - expect((await tool.execute({}, ctx)).output).toContain("No warehouse connections") }) - test("setConfigs-injected connections are not discarded by the staleness check", () => { - Registry.setConfigs({ injected: { type: "postgres" } }) - expect(Registry.names()).toEqual(["injected"]) - expect(Registry.names()).toEqual(["injected"]) + test("with a connection loaded, editing the file does not replace the configs", () => { + const named = Registry.localConfigPath() + fs.mkdirSync(path.dirname(named), { recursive: true }) + fs.writeFileSync(named, JSON.stringify({ "db-a": { type: "postgres", host: "old" } })) + expect(Registry.getConfig("db-a")?.host).toBe("old") + fs.writeFileSync(named, JSON.stringify({ "db-a": { type: "postgres", host: "new" }, "db-b": { type: "postgres" } })) + expect(Registry.getConfig("db-a")?.host).toBe("old") + expect(Registry.names()).toEqual(["db-a"]) }) - test("a connection added in-process yields to a later edit of its global entry", async () => { - const added = await Registry.add("edit-me", { type: "postgres", host: "old-host", database: "d" }) - expect(added.success).toBe(true) + test("a populated registry is not clobbered when the working project changes", () => { + const named = Registry.localConfigPath() + fs.mkdirSync(path.dirname(named), { recursive: true }) + fs.writeFileSync(named, JSON.stringify({ "proj-a": { type: "postgres" } })) + expect(Registry.names()).toEqual(["proj-a"]) + + const other = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "whl-hint-b-"))) try { - expect(Registry.getConfig("edit-me")?.host).toBe("old-host") - const globalPath = Registry.globalConfigPath() - const onDisk = JSON.parse(fs.readFileSync(globalPath, "utf-8")) - onDisk["edit-me"].host = "new-host" - fs.writeFileSync(globalPath, JSON.stringify(onDisk)) - expect(Registry.getConfig("edit-me")?.host).toBe("new-host") + fs.mkdirSync(path.join(other, ".altimate-code")) + fs.writeFileSync( + path.join(other, ".altimate-code", "connections.json"), + JSON.stringify({ "proj-b": { type: "postgres" } }), + ) + process.chdir(other) + expect(Registry.names()).toEqual(["proj-a"]) } finally { - await Registry.remove("edit-me") + process.chdir(project) + fs.rmSync(other, { recursive: true, force: true }) } }) + + test("setConfigs-injected connections are kept", () => { + Registry.setConfigs({ injected: { type: "postgres" } }) + expect(Registry.names()).toEqual(["injected"]) + }) }) From 434a319f340296b6ac4e607ca3022a3e636ca2be Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 18 Oct 2026 20:35:49 -0700 Subject: [PATCH 5/6] fix: do not load another project's connections file on an empty-registry reload The registry is process-global. Record the project whose local file it was loaded with; the empty-registry reload re-reads the project-local file only when the current project is that one. The global file and `ALTIMATE_CODE_CONN_*` stay process-wide. The `warehouse_list` hint advertises the project path only when it would be picked up, and says environment variables are read at process start. Co-Authored-By: Claude Opus 5.5 --- .../altimate/native/connections/registry.ts | 23 +++++++++++-- .../src/altimate/tools/warehouse-list.ts | 8 ++--- .../test/altimate/warehouse-list-hint.test.ts | 34 ++++++++++++++++++- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/altimate/native/connections/registry.ts b/packages/opencode/src/altimate/native/connections/registry.ts index 07bb133241..2d84ca37b2 100644 --- a/packages/opencode/src/altimate/native/connections/registry.ts +++ b/packages/opencode/src/altimate/native/connections/registry.ts @@ -197,14 +197,17 @@ function loadFromEnv(): Record { } /** Load all connection configs. Local overrides global; env overrides both. */ -export function load(): void { +export function load(includeProjectConfig = true): void { configs.clear() // altimate_change start — absolutize store paths against the directory that // declared them, so a later process.chdir() (--dir) cannot move the store. const base = projectRoot() const global = resolveStorePaths(loadFromFile(globalConfigPath()), path.dirname(globalConfigPath())) - const local = resolveStorePaths(loadFromFile(localConfigPath()), base) + // altimate_change start — see ensureLoaded(): a later reload must not pull in another project's file + const local = includeProjectConfig ? resolveStorePaths(loadFromFile(localConfigPath()), base) : {} + if (includeProjectConfig) loadedProjectDir = base + // altimate_change end const env = resolveStorePaths(loadFromEnv(), base) // altimate_change end @@ -229,10 +232,23 @@ function ensureLoaded(): void { // altimate_change start — the registry latches on first load, so a connections file created after an empty // answer (the `warehouse_list` hint) was never seen. Re-read only while nothing is loaded: there is no state to // replace or close, and a populated registry never changes under a running session. - else if (configs.size === 0 && !injected) load() + else if (configs.size === 0 && !injected) load(projectConfigApplies()) // altimate_change end } +// altimate_change start — the registry is process-global; this is the project whose local file it was loaded with +let loadedProjectDir: string | undefined + +/** + * Whether the current project's local connections file is the one this registry reads. A server serving several + * directories shares one registry (pre-existing: the first load's project-local file applies process-wide), so a + * later empty-registry reload must not load another project's file into it. + */ +export function projectConfigApplies(): boolean { + return loadedProjectDir === undefined || loadedProjectDir === projectRoot() +} +// altimate_change end + // altimate_change start — set by setConfigs() (tests): an injected empty registry is not a stale one let injected = false // altimate_change end @@ -876,6 +892,7 @@ export function reset(): void { censusSent = false // altimate_change start injected = false + loadedProjectDir = undefined // altimate_change end } diff --git a/packages/opencode/src/altimate/tools/warehouse-list.ts b/packages/opencode/src/altimate/tools/warehouse-list.ts index 825657931b..c0b73ded9a 100644 --- a/packages/opencode/src/altimate/tools/warehouse-list.ts +++ b/packages/opencode/src/altimate/tools/warehouse-list.ts @@ -6,7 +6,7 @@ import * as Precedence from "../workspace/precedence" // altimate_change end // altimate_change start — point at the locations the registry actually reads -import { CONN_ENV_PREFIX, globalConfigPath, localConfigPath } from "../native/connections/registry" +import { CONN_ENV_PREFIX, globalConfigPath, localConfigPath, projectConfigApplies } from "../native/connections/registry" /** The no-connections hint. Paths come from the registry's own helpers so it cannot drift from what is loaded. */ export function noConnectionsMessage(): string { @@ -17,10 +17,10 @@ export function noConnectionsMessage(): string { "", "Alternatively, connections are read from these sources (later ones override earlier ones):", ` 1. ${globalConfigPath()} (global)`, - ` 2. ${localConfigPath()} (this project)`, - ` 3. ${CONN_ENV_PREFIX} environment variables, each holding one connection's config as JSON`, + ...(projectConfigApplies() ? [` 2. ${localConfigPath()} (this project)`] : []), + ` ${projectConfigApplies() ? 3 : 2}. ${CONN_ENV_PREFIX} environment variables, each holding one connection's config as JSON (read when the process starts)`, "", - "After adding the first connection, call warehouse_list again. Edits to an existing connections.json take effect after a restart. Example connections.json:", + "After adding the first connection file, call warehouse_list again. Edits to an existing connections.json take effect after a restart. Example connections.json:", "{", ' "my-db": { "type": "postgres", "host": "localhost", "port": 5432, "database": "mydb", "user": "user", "password": "pass" }', "}", diff --git a/packages/opencode/test/altimate/warehouse-list-hint.test.ts b/packages/opencode/test/altimate/warehouse-list-hint.test.ts index bcbd64f187..c7e257e57d 100644 --- a/packages/opencode/test/altimate/warehouse-list-hint.test.ts +++ b/packages/opencode/test/altimate/warehouse-list-hint.test.ts @@ -89,11 +89,13 @@ describe("warehouse_list no-connections hint", () => { const tool = await initTool(WarehouseListTool) const ctx = toolCtx() await tool.execute({}, ctx) + const previous = process.env.ALTIMATE_CODE_CONN_ENVLATE process.env.ALTIMATE_CODE_CONN_ENVLATE = JSON.stringify({ type: "postgres", host: "h" }) try { expect((await tool.execute({}, ctx)).output).toContain("envlate") } finally { - delete process.env.ALTIMATE_CODE_CONN_ENVLATE + if (previous === undefined) delete process.env.ALTIMATE_CODE_CONN_ENVLATE + else process.env.ALTIMATE_CODE_CONN_ENVLATE = previous } }) @@ -128,6 +130,36 @@ describe("warehouse_list no-connections hint", () => { } }) + test("an empty registry first loaded for one project does not pick up another project's file", () => { + expect(Registry.names()).toEqual([]) + const dirA = project + const other = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "whl-hint-b-"))) + try { + fs.mkdirSync(path.join(other, ".altimate-code")) + fs.writeFileSync( + path.join(other, ".altimate-code", "connections.json"), + JSON.stringify({ "proj-b": { type: "postgres" } }), + ) + process.chdir(other) + expect(Registry.names()).toEqual([]) + expect(Registry.projectConfigApplies()).toBe(false) + expect(noConnectionsMessage()).not.toContain(Registry.localConfigPath()) + expect(noConnectionsMessage()).toContain(Registry.globalConfigPath()) + process.chdir(dirA) + expect(Registry.projectConfigApplies()).toBe(true) + // the same project still picks up its own file after an empty answer + fs.mkdirSync(path.join(dirA, ".altimate-code")) + fs.writeFileSync( + path.join(dirA, ".altimate-code", "connections.json"), + JSON.stringify({ "proj-a": { type: "postgres" } }), + ) + expect(Registry.names()).toEqual(["proj-a"]) + } finally { + process.chdir(dirA) + fs.rmSync(other, { recursive: true, force: true }) + } + }) + test("setConfigs-injected connections are kept", () => { Registry.setConfigs({ injected: { type: "postgres" } }) expect(Registry.names()).toEqual(["injected"]) From 10ba54c5b06560e5fada9a5beca13984ee14d67f Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 19 Oct 2026 10:47:15 -0700 Subject: [PATCH 6/6] fix: clarify `warehouse_list` hint and clear the test-injection flag on load - Hint: the OS credential store is used only when available; `ALTIMATE_CODE_CONN_*` must be set before the process starts. - `load()` clears the `setConfigs()` injection flag, so a real load can still re-read when empty. - Hint tests isolate ambient `ALTIMATE_CODE_CONN_*` variables. Co-Authored-By: Claude Opus 5.5 --- .../opencode/src/altimate/native/connections/registry.ts | 3 +++ packages/opencode/src/altimate/tools/warehouse-list.ts | 4 ++-- .../opencode/test/altimate/warehouse-list-hint.test.ts | 9 +++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/native/connections/registry.ts b/packages/opencode/src/altimate/native/connections/registry.ts index 2d84ca37b2..1f6195cc9f 100644 --- a/packages/opencode/src/altimate/native/connections/registry.ts +++ b/packages/opencode/src/altimate/native/connections/registry.ts @@ -224,6 +224,9 @@ export function load(includeProjectConfig = true): void { if (configs.has(SCRATCH_NAME)) Log.Default.warn(SCRATCH_NAME_CONFLICT) loaded = true + // altimate_change start — real sources were read, so the registry is no longer test-injected + injected = false + // altimate_change end } /** Ensure configs are loaded. */ diff --git a/packages/opencode/src/altimate/tools/warehouse-list.ts b/packages/opencode/src/altimate/tools/warehouse-list.ts index c0b73ded9a..eb6c2ba66a 100644 --- a/packages/opencode/src/altimate/tools/warehouse-list.ts +++ b/packages/opencode/src/altimate/tools/warehouse-list.ts @@ -13,12 +13,12 @@ export function noConnectionsMessage(): string { return [ "No warehouse connections configured.", "", - "Preferred: use the warehouse_add tool. It stores passwords and keys in the OS credential store instead of a plaintext file.", + `Preferred: use the warehouse_add tool. It stores passwords and keys in the OS credential store when one is available; otherwise it keeps them out of the file for this session only, so use ${CONN_ENV_PREFIX} to make them persistent.`, "", "Alternatively, connections are read from these sources (later ones override earlier ones):", ` 1. ${globalConfigPath()} (global)`, ...(projectConfigApplies() ? [` 2. ${localConfigPath()} (this project)`] : []), - ` ${projectConfigApplies() ? 3 : 2}. ${CONN_ENV_PREFIX} environment variables, each holding one connection's config as JSON (read when the process starts)`, + ` ${projectConfigApplies() ? 3 : 2}. ${CONN_ENV_PREFIX} environment variables, each holding one connection's config as JSON (set them in the shell before starting altimate-code; a running process cannot see variables exported later)`, "", "After adding the first connection file, call warehouse_list again. Edits to an existing connections.json take effect after a restart. Example connections.json:", "{", diff --git a/packages/opencode/test/altimate/warehouse-list-hint.test.ts b/packages/opencode/test/altimate/warehouse-list-hint.test.ts index c7e257e57d..2128e69a24 100644 --- a/packages/opencode/test/altimate/warehouse-list-hint.test.ts +++ b/packages/opencode/test/altimate/warehouse-list-hint.test.ts @@ -23,8 +23,16 @@ function toolCtx() { let project: string let prevCwd: string +let ambientConnEnv: Record beforeEach(() => { + // Ambient connection env vars would make the empty-registry assertions depend on the caller's shell. + ambientConnEnv = {} + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith(Registry.CONN_ENV_PREFIX) || value === undefined) continue + ambientConnEnv[key] = value + delete process.env[key] + } prevCwd = process.cwd() project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "whl-hint-"))) process.chdir(project) @@ -36,6 +44,7 @@ afterEach(() => { process.chdir(prevCwd) Registry.reset() fs.rmSync(project, { recursive: true, force: true }) + Object.assign(process.env, ambientConnEnv) }) describe("warehouse_list no-connections hint", () => {