Skip to content

Commit dcada75

Browse files
anandgupta42claude
andcommitted
release: v0.5.20
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dafd16a commit dcada75

File tree

9 files changed

+322
-8
lines changed

9 files changed

+322
-8
lines changed

.github/meta/commit.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
feat: add ClickHouse warehouse driver
1+
release: v0.5.20

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.5.20] - 2026-04-09
9+
10+
### Added
11+
12+
- **Altimate model auto-selection** — when Altimate credentials are configured and no model is explicitly chosen, `altimate-backend/altimate-default` is selected automatically. Respects the `provider` filter in config if set. No manual `/model` selection needed for first-time Altimate users. (#665)
13+
14+
### Fixed
15+
16+
- **Connection string passwords with special characters** — passwords containing `@`, `#`, `:`, `/`, or other URI-reserved characters are now automatically percent-encoded in `connection_string` configs. Previously these caused cryptic authentication failures because the URI parser split on the wrong delimiter. Already-encoded passwords (`%XX`) are left untouched. Affects all URI-based drivers (PostgreSQL, MongoDB, ClickHouse). (#597, closes #589)
17+
- **`trace list` pagination**`trace list` now supports `--offset` for navigating large trace histories, displays "Showing X-Y of N" with a next-page hint, and caps the TUI trace dialog at 500 items (up from 50) with an overflow message pointing to the CLI for the full set. (#596, closes #418)
18+
- **ClickHouse edge-case hardening** — added tests for `LowCardinality(Nullable(...))` nullability detection, `Map`/`Tuple` wrapper handling, undefined type fallback, and SQL comment/string-escape edge cases in the LIMIT injection guard. (#599, closes #592)
19+
20+
### Testing
21+
22+
- 31 new adversarial tests covering connection string sanitization (injection, encoding edge cases, ReDoS, Unicode, null bytes), pagination boundary math (Infinity, NaN, fractional, negative inputs), and `Provider.parseModel` edge cases.
23+
824
## [0.5.19] - 2026-04-04
925

1026
### Added

docs/docs/configure/providers.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ Managed LLM access with dynamic routing across Sonnet 4.6, Opus 4.6, GPT-5.4, GP
3838

3939
For pricing, security, and data handling details, see the [Altimate LLM Gateway guide](https://datamates-docs.myaltimate.com/user-guide/components/llm-gateway/).
4040

41+
!!! tip "Automatic model selection"
42+
When Altimate credentials are configured and no model is explicitly chosen, `altimate-backend/altimate-default` is selected automatically. You can override this by setting `model` in your config or by restricting the `provider` section to specific providers only.
43+
4144
## Anthropic
4245

4346
```json

docs/docs/configure/trace.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,9 @@ All traces are stored in the traces directory and persist across sessions. Use `
346346
# Show the last 50 traces
347347
altimate-code trace list -n 50
348348

349+
# Navigate to the next page
350+
altimate-code trace list --offset 50 -n 50
351+
349352
# View any historical trace
350353
altimate-code trace view <session-id>
351354
```

docs/docs/configure/warehouses.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ If you're already authenticated via `gcloud`, omit `credentials_path`:
153153
}
154154
```
155155

156+
!!! tip "Special characters in passwords"
157+
Passwords with special characters (`@`, `#`, `:`, `/`) are automatically percent-encoded. No manual escaping required.
158+
156159
## Redshift
157160

158161
```json

packages/drivers/src/normalize.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ export function sanitizeConnectionString(connectionString: string): string {
167167
const afterAtHasDelim = /[/?#]/.test(afterAt)
168168
const beforeAtHasDelim = /[/?#]/.test(beforeAt)
169169
if (!afterAtHasDelim && beforeAtHasDelim) {
170+
// Ambiguous '@' — likely in query/path/fragment, not userinfo separator.
171+
// Return unchanged; caller should pre-encode the password if auth fails.
172+
console.debug?.("sanitizeConnectionString: ambiguous '@' detected, skipping encoding")
170173
return connectionString
171174
}
172175

packages/opencode/src/acp/agent.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1550,17 +1550,17 @@ export namespace ACP {
15501550

15511551
const directory = cwd ?? process.cwd()
15521552

1553-
const specified = await sdk.config
1553+
// altimate_change start — capture provider filter alongside model config
1554+
const userConfig = await sdk.config
15541555
.get({ directory }, { throwOnError: true })
1555-
.then((resp) => {
1556-
const cfg = resp.data
1557-
if (!cfg || !cfg.model) return undefined
1558-
return Provider.parseModel(cfg.model)
1559-
})
1556+
.then((resp) => resp.data)
15601557
.catch((error) => {
15611558
log.error("failed to load user config for default model", { error })
15621559
return undefined
15631560
})
1561+
const specified = userConfig?.model ? Provider.parseModel(userConfig.model) : undefined
1562+
const providerFilter = userConfig?.provider as Record<string, unknown> | undefined
1563+
// altimate_change end
15641564

15651565
const providers = await sdk.config
15661566
.providers({ directory }, { throwOnError: true })
@@ -1579,7 +1579,11 @@ export namespace ACP {
15791579

15801580
// altimate_change start — default to altimate-backend when configured and no model chosen yet
15811581
const altimateProvider = providers.find((p) => p.id === "altimate-backend")
1582-
if (altimateProvider && altimateProvider.models["altimate-default"]) {
1582+
if (
1583+
altimateProvider &&
1584+
altimateProvider.models["altimate-default"] &&
1585+
(!providerFilter || Object.keys(providerFilter).includes("altimate-backend"))
1586+
) {
15831587
return {
15841588
providerID: ProviderID.make("altimate-backend"),
15851589
modelID: ModelID.make("altimate-default"),

packages/opencode/src/provider/provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1645,6 +1645,7 @@ export namespace Provider {
16451645
altimateProvider.models[ModelID.make("altimate-default")] &&
16461646
(!cfg.provider || Object.keys(cfg.provider).includes(String(altimateProviderID)))
16471647
) {
1648+
log.info("defaulting to altimate-backend/altimate-default (no model configured)")
16481649
return {
16491650
providerID: altimateProviderID,
16501651
modelID: ModelID.make("altimate-default"),
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
/**
2+
* Adversarial tests for v0.5.20 release features:
3+
*
4+
* 1. sanitizeConnectionString (normalize.ts) — injection, encoding edge cases
5+
* 2. listTracesPaginated (tracing.ts) — boundary math, adversarial inputs
6+
* 3. Provider defaultModel altimate-backend preference — provider filter guard
7+
*/
8+
9+
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
10+
import { sanitizeConnectionString, normalizeConfig } from "@altimateai/drivers"
11+
import { Trace } from "../../src/altimate/observability/tracing"
12+
import { Provider } from "../../src/provider/provider"
13+
import fs from "fs/promises"
14+
import os from "os"
15+
import path from "path"
16+
17+
// ─────────────────────────────────────────────────────────────
18+
// 1. sanitizeConnectionString — adversarial URI inputs
19+
// ─────────────────────────────────────────────────────────────
20+
21+
describe("v0.5.20 release: sanitizeConnectionString adversarial", () => {
22+
test("password with all URI-reserved characters is encoded correctly", () => {
23+
const uri = "postgresql://user:p@ss:w#rd/sl@sh@host:5432/db"
24+
const result = sanitizeConnectionString(uri)
25+
// The encoded URI should be parseable
26+
expect(result).toContain("host:5432/db")
27+
// Original reserved chars in password should be percent-encoded
28+
expect(result).not.toMatch(/user:p@ss/)
29+
})
30+
31+
test("empty password is left empty, not encoded", () => {
32+
const uri = "postgresql://user:@host:5432/db"
33+
const result = sanitizeConnectionString(uri)
34+
expect(result).toBe("postgresql://user:@host:5432/db")
35+
})
36+
37+
test("username-only (no colon) with @ is handled", () => {
38+
const uri = "postgresql://alice%40example.com@host:5432/db"
39+
const result = sanitizeConnectionString(uri)
40+
// Already-encoded @ in username should round-trip
41+
expect(result).toContain("host:5432/db")
42+
})
43+
44+
test("non-URI string is returned unchanged", () => {
45+
const tns = "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=myhost)(PORT=1521))(CONNECT_DATA=(SID=ORCL)))"
46+
expect(sanitizeConnectionString(tns)).toBe(tns)
47+
})
48+
49+
test("empty string is returned unchanged", () => {
50+
expect(sanitizeConnectionString("")).toBe("")
51+
})
52+
53+
test("scheme-only string with no authority is returned unchanged", () => {
54+
expect(sanitizeConnectionString("postgresql://")).toBe("postgresql://")
55+
})
56+
57+
test("password with percent-encoded sequences round-trips idempotently", () => {
58+
const uri = "postgresql://user:p%40ss%23word@host:5432/db"
59+
const result = sanitizeConnectionString(uri)
60+
// Already-encoded values should survive unchanged
61+
expect(result).toBe(uri)
62+
})
63+
64+
test("malformed percent sequence (%ZZ) is encoded rather than crashing", () => {
65+
const uri = "postgresql://user:bad%ZZpass@host:5432/db"
66+
const result = sanitizeConnectionString(uri)
67+
// Should not throw, and host should be preserved
68+
expect(result).toContain("host:5432/db")
69+
})
70+
71+
test("very long password (10KB) does not cause ReDoS or hang", () => {
72+
const longPass = "a".repeat(10_000)
73+
const uri = `postgresql://user:${longPass}@host:5432/db`
74+
const start = performance.now()
75+
const result = sanitizeConnectionString(uri)
76+
const elapsed = performance.now() - start
77+
expect(elapsed).toBeLessThan(1000) // should complete in <1s
78+
expect(result).toContain("host:5432/db")
79+
})
80+
81+
test("password with unicode characters is encoded correctly", () => {
82+
const uri = "postgresql://user:pässwörd@host:5432/db"
83+
const result = sanitizeConnectionString(uri)
84+
expect(result).toContain("host:5432/db")
85+
// Unicode should be percent-encoded
86+
expect(result).not.toContain("ä")
87+
expect(result).not.toContain("ö")
88+
})
89+
90+
test("normalizeConfig wires sanitizeConnectionString for connection_string field", () => {
91+
const config = {
92+
type: "postgres" as const,
93+
connection_string: "postgresql://user:p@ss@host:5432/db",
94+
}
95+
const result = normalizeConfig(config)
96+
// The @ in password should be encoded after normalization
97+
expect(result.connection_string).toBeDefined()
98+
expect(result.connection_string).toContain("host:5432/db")
99+
expect(result.connection_string).not.toMatch(/user:p@ss@/)
100+
})
101+
102+
test("normalizeConfig handles connectionString alias", () => {
103+
const config = {
104+
type: "postgres" as const,
105+
connectionString: "postgresql://user:p@ss@host:5432/db",
106+
} as any
107+
const result = normalizeConfig(config)
108+
// connectionString alias should be normalized to connection_string and sanitized
109+
expect(result.connection_string).toBeDefined()
110+
})
111+
112+
test("connection string with SQL injection in password does not affect host parsing", () => {
113+
const uri = "postgresql://user:'; DROP TABLE users;--@host:5432/db"
114+
const result = sanitizeConnectionString(uri)
115+
// SQL in password is just text — should be encoded, host preserved
116+
expect(result).toContain("host:5432/db")
117+
})
118+
119+
test("connection string with null bytes in password", () => {
120+
const uri = "postgresql://user:pass\x00word@host:5432/db"
121+
const result = sanitizeConnectionString(uri)
122+
expect(result).toContain("host:5432/db")
123+
})
124+
})
125+
126+
// ─────────────────────────────────────────────────────────────
127+
// 2. listTracesPaginated — boundary math adversarial
128+
// ─────────────────────────────────────────────────────────────
129+
130+
describe("v0.5.20 release: listTracesPaginated adversarial", () => {
131+
let tmpDir: string
132+
133+
beforeEach(async () => {
134+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "trace-adv-"))
135+
})
136+
137+
afterEach(async () => {
138+
await fs.rm(tmpDir, { recursive: true, force: true })
139+
})
140+
141+
async function seedTraces(count: number) {
142+
for (let i = 0; i < count; i++) {
143+
const sessionId = `sess-${String(i).padStart(4, "0")}`
144+
const trace = {
145+
sessionId,
146+
startedAt: new Date(Date.now() - i * 60_000).toISOString(),
147+
endedAt: new Date(Date.now() - i * 60_000 + 30_000).toISOString(),
148+
spans: [],
149+
metadata: { provider: "test", model: "test-model", directory: "/tmp" },
150+
}
151+
await fs.writeFile(path.join(tmpDir, `${sessionId}.json`), JSON.stringify(trace))
152+
}
153+
}
154+
155+
test("offset = Infinity clamps to 0, returns first page", async () => {
156+
await seedTraces(5)
157+
const result = await Trace.listTracesPaginated(tmpDir, { offset: Infinity, limit: 10 })
158+
expect(result.offset).toBe(0)
159+
expect(result.traces.length).toBe(5)
160+
})
161+
162+
test("limit = Infinity clamps to 20 (default)", async () => {
163+
await seedTraces(5)
164+
const result = await Trace.listTracesPaginated(tmpDir, { offset: 0, limit: Infinity })
165+
expect(result.limit).toBe(20)
166+
expect(result.traces.length).toBe(5)
167+
})
168+
169+
test("offset = -Infinity clamps to 0", async () => {
170+
await seedTraces(3)
171+
const result = await Trace.listTracesPaginated(tmpDir, { offset: -Infinity, limit: 10 })
172+
expect(result.offset).toBe(0)
173+
expect(result.traces.length).toBe(3)
174+
})
175+
176+
test("limit = -1 clamps to 1", async () => {
177+
await seedTraces(3)
178+
const result = await Trace.listTracesPaginated(tmpDir, { offset: 0, limit: -1 })
179+
expect(result.limit).toBe(1)
180+
expect(result.traces.length).toBe(1)
181+
})
182+
183+
test("NaN offset and limit fall back to defaults", async () => {
184+
await seedTraces(5)
185+
const result = await Trace.listTracesPaginated(tmpDir, { offset: NaN, limit: NaN })
186+
expect(result.offset).toBe(0)
187+
expect(result.limit).toBe(20)
188+
expect(result.traces.length).toBe(5)
189+
})
190+
191+
test("offset = total returns empty traces array", async () => {
192+
await seedTraces(3)
193+
const result = await Trace.listTracesPaginated(tmpDir, { offset: 3, limit: 10 })
194+
expect(result.traces).toEqual([])
195+
expect(result.total).toBe(3)
196+
})
197+
198+
test("offset > total returns empty traces array", async () => {
199+
await seedTraces(3)
200+
const result = await Trace.listTracesPaginated(tmpDir, { offset: 100, limit: 10 })
201+
expect(result.traces).toEqual([])
202+
expect(result.total).toBe(3)
203+
})
204+
205+
test("empty directory returns zero total and empty array", async () => {
206+
const result = await Trace.listTracesPaginated(tmpDir, { offset: 0, limit: 10 })
207+
expect(result.total).toBe(0)
208+
expect(result.traces).toEqual([])
209+
expect(result.offset).toBe(0)
210+
})
211+
212+
test("fractional offset 2.7 truncates to 2", async () => {
213+
await seedTraces(5)
214+
const result = await Trace.listTracesPaginated(tmpDir, { offset: 2.7, limit: 10 })
215+
expect(result.offset).toBe(2)
216+
expect(result.traces.length).toBe(3)
217+
})
218+
219+
test("fractional limit 1.9 truncates to 1", async () => {
220+
await seedTraces(5)
221+
const result = await Trace.listTracesPaginated(tmpDir, { offset: 0, limit: 1.9 })
222+
expect(result.limit).toBe(1)
223+
expect(result.traces.length).toBe(1)
224+
})
225+
226+
test("limit = 0 clamps to 1", async () => {
227+
await seedTraces(3)
228+
const result = await Trace.listTracesPaginated(tmpDir, { offset: 0, limit: 0 })
229+
expect(result.limit).toBe(1)
230+
expect(result.traces.length).toBe(1)
231+
})
232+
233+
test("no options uses defaults (offset=0, limit=20)", async () => {
234+
await seedTraces(25)
235+
const result = await Trace.listTracesPaginated(tmpDir)
236+
expect(result.offset).toBe(0)
237+
expect(result.limit).toBe(20)
238+
expect(result.traces.length).toBe(20)
239+
expect(result.total).toBe(25)
240+
})
241+
242+
test("non-JSON files in traces dir are silently skipped", async () => {
243+
await seedTraces(2)
244+
// Add a non-JSON file
245+
await fs.writeFile(path.join(tmpDir, "garbage.txt"), "not json")
246+
await fs.writeFile(path.join(tmpDir, ".DS_Store"), "mac metadata")
247+
const result = await Trace.listTracesPaginated(tmpDir, { offset: 0, limit: 50 })
248+
// Should only include the valid trace files
249+
expect(result.total).toBe(2)
250+
})
251+
})
252+
253+
// ─────────────────────────────────────────────────────────────
254+
// 3. Provider.parseModel — edge cases
255+
// ─────────────────────────────────────────────────────────────
256+
257+
describe("v0.5.20 release: Provider.parseModel adversarial", () => {
258+
test("model string with multiple slashes preserves all parts", () => {
259+
const result = Provider.parseModel("altimate-backend/altimate-default")
260+
expect(String(result.providerID)).toBe("altimate-backend")
261+
expect(String(result.modelID)).toBe("altimate-default")
262+
})
263+
264+
test("model string with nested slashes preserves full model ID", () => {
265+
const result = Provider.parseModel("openai/gpt-4o/2024-05-13")
266+
expect(String(result.providerID)).toBe("openai")
267+
expect(String(result.modelID)).toBe("gpt-4o/2024-05-13")
268+
})
269+
270+
test("model string with no slash puts everything in providerID", () => {
271+
const result = Provider.parseModel("standalone")
272+
expect(String(result.providerID)).toBe("standalone")
273+
expect(String(result.modelID)).toBe("")
274+
})
275+
276+
test("empty model string does not throw", () => {
277+
const result = Provider.parseModel("")
278+
expect(String(result.providerID)).toBe("")
279+
expect(String(result.modelID)).toBe("")
280+
})
281+
})

0 commit comments

Comments
 (0)