Skip to content

Commit 49b5615

Browse files
anandgupta42claude
andauthored
fix: point the warehouse_list no-connections hint at the real config locations (#1559)
* 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
1 parent aca8dac commit 49b5615

3 files changed

Lines changed: 247 additions & 6 deletions

File tree

‎packages/opencode/src/altimate/native/connections/registry.ts‎

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const SCRATCH_NAME_CONFLICT =
5858
// Config file paths
5959
// ---------------------------------------------------------------------------
6060

61-
function globalConfigPath(): string {
61+
export function globalConfigPath(): string {
6262
// altimate_change start — the same home Global.Path uses, so the test preload's OPENCODE_TEST_HOME keeps the
6363
// suite out of the developer's real connections (Bun caches os.homedir(), so changing HOME does not).
6464
return path.join(Global.Path.home, ".altimate-code", "connections.json")
@@ -93,7 +93,7 @@ function projectRoot(): string {
9393
}
9494
// altimate_change end
9595

96-
function localConfigPath(): string {
96+
export function localConfigPath(): string {
9797
// altimate_change start — resolve against the project, not the launch cwd
9898
return path.join(projectRoot(), ".altimate-code", "connections.json")
9999
// altimate_change end
@@ -172,9 +172,13 @@ function loadFromFile(filePath: string): Record<string, ConnectionConfig> {
172172
}
173173
}
174174

175+
// altimate_change start — exported so user-facing hints name the variable the loader reads
176+
export const CONN_ENV_PREFIX = "ALTIMATE_CODE_CONN_"
177+
// altimate_change end
178+
175179
function loadFromEnv(): Record<string, ConnectionConfig> {
176180
const result: Record<string, ConnectionConfig> = {}
177-
const prefix = "ALTIMATE_CODE_CONN_"
181+
const prefix = CONN_ENV_PREFIX
178182

179183
for (const [key, value] of Object.entries(process.env)) {
180184
if (!key.startsWith(prefix) || !value) continue
@@ -193,14 +197,17 @@ function loadFromEnv(): Record<string, ConnectionConfig> {
193197
}
194198

195199
/** Load all connection configs. Local overrides global; env overrides both. */
196-
export function load(): void {
200+
export function load(includeProjectConfig = true): void {
197201
configs.clear()
198202

199203
// altimate_change start — absolutize store paths against the directory that
200204
// declared them, so a later process.chdir() (--dir) cannot move the store.
201205
const base = projectRoot()
202206
const global = resolveStorePaths(loadFromFile(globalConfigPath()), path.dirname(globalConfigPath()))
203-
const local = resolveStorePaths(loadFromFile(localConfigPath()), base)
207+
// altimate_change start — see ensureLoaded(): a later reload must not pull in another project's file
208+
const local = includeProjectConfig ? resolveStorePaths(loadFromFile(localConfigPath()), base) : {}
209+
if (includeProjectConfig) loadedProjectDir = base
210+
// altimate_change end
204211
const env = resolveStorePaths(loadFromEnv(), base)
205212
// altimate_change end
206213

@@ -217,13 +224,38 @@ export function load(): void {
217224

218225
if (configs.has(SCRATCH_NAME)) Log.Default.warn(SCRATCH_NAME_CONFLICT)
219226
loaded = true
227+
// altimate_change start — real sources were read, so the registry is no longer test-injected
228+
injected = false
229+
// altimate_change end
220230
}
221231

222232
/** Ensure configs are loaded. */
223233
function ensureLoaded(): void {
224234
if (!loaded) load()
235+
// altimate_change start — the registry latches on first load, so a connections file created after an empty
236+
// answer (the `warehouse_list` hint) was never seen. Re-read only while nothing is loaded: there is no state to
237+
// replace or close, and a populated registry never changes under a running session.
238+
else if (configs.size === 0 && !injected) load(projectConfigApplies())
239+
// altimate_change end
225240
}
226241

242+
// altimate_change start — the registry is process-global; this is the project whose local file it was loaded with
243+
let loadedProjectDir: string | undefined
244+
245+
/**
246+
* Whether the current project's local connections file is the one this registry reads. A server serving several
247+
* directories shares one registry (pre-existing: the first load's project-local file applies process-wide), so a
248+
* later empty-registry reload must not load another project's file into it.
249+
*/
250+
export function projectConfigApplies(): boolean {
251+
return loadedProjectDir === undefined || loadedProjectDir === projectRoot()
252+
}
253+
// altimate_change end
254+
255+
// altimate_change start — set by setConfigs() (tests): an injected empty registry is not a stale one
256+
let injected = false
257+
// altimate_change end
258+
227259
// ---------------------------------------------------------------------------
228260
// Driver factory
229261
// ---------------------------------------------------------------------------
@@ -861,6 +893,10 @@ export function reset(): void {
861893
pending.clear()
862894
loaded = false
863895
censusSent = false
896+
// altimate_change start
897+
injected = false
898+
loadedProjectDir = undefined
899+
// altimate_change end
864900
}
865901

866902
/**
@@ -872,4 +908,7 @@ export function setConfigs(newConfigs: Record<string, ConnectionConfig>): void {
872908
configs.set(name, config)
873909
}
874910
loaded = true
911+
// altimate_change start
912+
injected = true
913+
// altimate_change end
875914
}

‎packages/opencode/src/altimate/tools/warehouse-list.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@ import { Dispatcher } from "../native"
55
import * as Precedence from "../workspace/precedence"
66
// altimate_change end
77

8+
// altimate_change start — point at the locations the registry actually reads
9+
import { CONN_ENV_PREFIX, globalConfigPath, localConfigPath, projectConfigApplies } from "../native/connections/registry"
10+
11+
/** The no-connections hint. Paths come from the registry's own helpers so it cannot drift from what is loaded. */
12+
export function noConnectionsMessage(): string {
13+
return [
14+
"No warehouse connections configured.",
15+
"",
16+
`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}<NAME> to make them persistent.`,
17+
"",
18+
"Alternatively, connections are read from these sources (later ones override earlier ones):",
19+
` 1. ${globalConfigPath()} (global)`,
20+
...(projectConfigApplies() ? [` 2. ${localConfigPath()} (this project)`] : []),
21+
` ${projectConfigApplies() ? 3 : 2}. ${CONN_ENV_PREFIX}<NAME> 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)`,
22+
"",
23+
"After adding the first connection file, call warehouse_list again. Edits to an existing connections.json take effect after a restart. Example connections.json:",
24+
"{",
25+
' "my-db": { "type": "postgres", "host": "localhost", "port": 5432, "database": "mydb", "user": "user", "password": "pass" }',
26+
"}",
27+
`To keep the password out of the file, omit it and set ${CONN_ENV_PREFIX}<NAME> to the full config as JSON instead.`,
28+
"",
29+
"Do not put connections.json in .opencode/; it is not read from there.",
30+
].join("\n")
31+
}
32+
// altimate_change end
33+
834
export const WarehouseListTool = Tool.define("warehouse_list", {
935
description: "List all configured warehouse connections. Shows connection name, type, and database.",
1036
parameters: z.object({}),
@@ -17,7 +43,7 @@ export const WarehouseListTool = Tool.define("warehouse_list", {
1743
return {
1844
title: "Warehouses: none configured",
1945
metadata: { count: 0 },
20-
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}",
46+
output: noConnectionsMessage(),
2147
}
2248
}
2349

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
2+
import * as fs from "fs"
3+
import * as os from "os"
4+
import * as path from "path"
5+
import * as Registry from "../../src/altimate/native/connections/registry"
6+
import { WarehouseListTool, noConnectionsMessage } from "../../src/altimate/tools/warehouse-list"
7+
import { registerAll } from "../../src/altimate/native/connections/register"
8+
import { SessionID, MessageID } from "../../src/session/schema"
9+
import { initTool } from "./tool-fixture"
10+
11+
function toolCtx() {
12+
return {
13+
sessionID: SessionID.make("ses_test"),
14+
messageID: MessageID.make("msg_test"),
15+
callID: "call_test",
16+
agent: "build",
17+
abort: AbortSignal.any([]),
18+
messages: [],
19+
metadata: () => {},
20+
ask: async () => {},
21+
} as any
22+
}
23+
24+
let project: string
25+
let prevCwd: string
26+
let ambientConnEnv: Record<string, string>
27+
28+
beforeEach(() => {
29+
// Ambient connection env vars would make the empty-registry assertions depend on the caller's shell.
30+
ambientConnEnv = {}
31+
for (const [key, value] of Object.entries(process.env)) {
32+
if (!key.startsWith(Registry.CONN_ENV_PREFIX) || value === undefined) continue
33+
ambientConnEnv[key] = value
34+
delete process.env[key]
35+
}
36+
prevCwd = process.cwd()
37+
project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "whl-hint-")))
38+
process.chdir(project)
39+
Registry.reset()
40+
registerAll()
41+
})
42+
43+
afterEach(() => {
44+
process.chdir(prevCwd)
45+
Registry.reset()
46+
fs.rmSync(project, { recursive: true, force: true })
47+
Object.assign(process.env, ambientConnEnv)
48+
})
49+
50+
describe("warehouse_list no-connections hint", () => {
51+
test("names the paths and env prefix the registry reads, not .opencode", () => {
52+
const msg = noConnectionsMessage()
53+
expect(msg).toContain(Registry.globalConfigPath())
54+
expect(msg).toContain(Registry.localConfigPath())
55+
expect(msg).toContain(Registry.CONN_ENV_PREFIX)
56+
expect(msg).toContain("warehouse_add")
57+
expect(msg).toContain('"type": "postgres"')
58+
expect(msg).not.toContain(".opencode/connections.json")
59+
expect(Registry.localConfigPath()).toBe(path.join(project, ".altimate-code", "connections.json"))
60+
})
61+
62+
test("a connections.json written to the project path named by the hint is loaded", () => {
63+
const named = Registry.localConfigPath()
64+
expect(noConnectionsMessage()).toContain(named)
65+
fs.mkdirSync(path.dirname(named), { recursive: true })
66+
fs.writeFileSync(named, JSON.stringify({ "hint-db": { type: "postgres", host: "localhost", database: "d" } }))
67+
Registry.load()
68+
expect(Registry.names()).toContain("hint-db")
69+
})
70+
71+
test("a connections.json at the legacy .opencode location is not loaded", () => {
72+
fs.mkdirSync(path.join(project, ".opencode"))
73+
fs.writeFileSync(
74+
path.join(project, ".opencode", "connections.json"),
75+
JSON.stringify({ "wrong-db": { type: "postgres", host: "localhost" } }),
76+
)
77+
Registry.load()
78+
expect(Registry.names()).not.toContain("wrong-db")
79+
})
80+
81+
test("the tool lists a connection created after an empty answer, in the same process", async () => {
82+
const tool = await initTool(WarehouseListTool)
83+
const ctx = toolCtx()
84+
const first = await tool.execute({}, ctx)
85+
expect(first.output).toContain(Registry.localConfigPath())
86+
87+
const named = Registry.localConfigPath()
88+
fs.mkdirSync(path.dirname(named), { recursive: true })
89+
fs.writeFileSync(named, JSON.stringify({ "late-db": { type: "postgres", host: "localhost", database: "d" } }))
90+
91+
const second = await tool.execute({}, ctx)
92+
expect(second.output).toContain("late-db")
93+
// the lookup sql tools use resolves it too
94+
expect(Registry.getConfig("late-db")?.type).toBe("postgres")
95+
})
96+
97+
test("an env var set after an empty answer is picked up", async () => {
98+
const tool = await initTool(WarehouseListTool)
99+
const ctx = toolCtx()
100+
await tool.execute({}, ctx)
101+
const previous = process.env.ALTIMATE_CODE_CONN_ENVLATE
102+
process.env.ALTIMATE_CODE_CONN_ENVLATE = JSON.stringify({ type: "postgres", host: "h" })
103+
try {
104+
expect((await tool.execute({}, ctx)).output).toContain("envlate")
105+
} finally {
106+
if (previous === undefined) delete process.env.ALTIMATE_CODE_CONN_ENVLATE
107+
else process.env.ALTIMATE_CODE_CONN_ENVLATE = previous
108+
}
109+
})
110+
111+
test("with a connection loaded, editing the file does not replace the configs", () => {
112+
const named = Registry.localConfigPath()
113+
fs.mkdirSync(path.dirname(named), { recursive: true })
114+
fs.writeFileSync(named, JSON.stringify({ "db-a": { type: "postgres", host: "old" } }))
115+
expect(Registry.getConfig("db-a")?.host).toBe("old")
116+
fs.writeFileSync(named, JSON.stringify({ "db-a": { type: "postgres", host: "new" }, "db-b": { type: "postgres" } }))
117+
expect(Registry.getConfig("db-a")?.host).toBe("old")
118+
expect(Registry.names()).toEqual(["db-a"])
119+
})
120+
121+
test("a populated registry is not clobbered when the working project changes", () => {
122+
const named = Registry.localConfigPath()
123+
fs.mkdirSync(path.dirname(named), { recursive: true })
124+
fs.writeFileSync(named, JSON.stringify({ "proj-a": { type: "postgres" } }))
125+
expect(Registry.names()).toEqual(["proj-a"])
126+
127+
const other = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "whl-hint-b-")))
128+
try {
129+
fs.mkdirSync(path.join(other, ".altimate-code"))
130+
fs.writeFileSync(
131+
path.join(other, ".altimate-code", "connections.json"),
132+
JSON.stringify({ "proj-b": { type: "postgres" } }),
133+
)
134+
process.chdir(other)
135+
expect(Registry.names()).toEqual(["proj-a"])
136+
} finally {
137+
process.chdir(project)
138+
fs.rmSync(other, { recursive: true, force: true })
139+
}
140+
})
141+
142+
test("an empty registry first loaded for one project does not pick up another project's file", () => {
143+
expect(Registry.names()).toEqual([])
144+
const dirA = project
145+
const other = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "whl-hint-b-")))
146+
try {
147+
fs.mkdirSync(path.join(other, ".altimate-code"))
148+
fs.writeFileSync(
149+
path.join(other, ".altimate-code", "connections.json"),
150+
JSON.stringify({ "proj-b": { type: "postgres" } }),
151+
)
152+
process.chdir(other)
153+
expect(Registry.names()).toEqual([])
154+
expect(Registry.projectConfigApplies()).toBe(false)
155+
expect(noConnectionsMessage()).not.toContain(Registry.localConfigPath())
156+
expect(noConnectionsMessage()).toContain(Registry.globalConfigPath())
157+
process.chdir(dirA)
158+
expect(Registry.projectConfigApplies()).toBe(true)
159+
// the same project still picks up its own file after an empty answer
160+
fs.mkdirSync(path.join(dirA, ".altimate-code"))
161+
fs.writeFileSync(
162+
path.join(dirA, ".altimate-code", "connections.json"),
163+
JSON.stringify({ "proj-a": { type: "postgres" } }),
164+
)
165+
expect(Registry.names()).toEqual(["proj-a"])
166+
} finally {
167+
process.chdir(dirA)
168+
fs.rmSync(other, { recursive: true, force: true })
169+
}
170+
})
171+
172+
test("setConfigs-injected connections are kept", () => {
173+
Registry.setConfigs({ injected: { type: "postgres" } })
174+
expect(Registry.names()).toEqual(["injected"])
175+
})
176+
})

0 commit comments

Comments
 (0)