Skip to content

Commit d2cf6b3

Browse files
committed
release: v0.8.4
1 parent 3982d8d commit d2cf6b3

2 files changed

Lines changed: 243 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ 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.8.4] - 2026-06-05
9+
10+
A trace-durability patch. Open `/traces` mid-session and you'd see a rich waterfall — then the moment the agent finished its turn the view collapsed to a single "system-prompt" span, the Summary tab's *"What was asked"* showed *"No prompt recorded"*, and the Chat tab dropped every user turn but the last. The data was genuinely gone from disk, not just hidden in the viewer. This release stops the on-disk trace from being overwritten after each turn and makes the file authoritative across worker restarts. A five-persona pre-release review drove a follow-up wording fix so a reconstructed trace isn't misread as a failed run.
11+
12+
### Fixed
13+
14+
- **Session traces no longer lose their data after every agent turn — the waterfall, summary prompt, and chat tab all stay intact.** A `session.status === "idle"` handler in the shared TUI worker fired once *per turn* (not once per session), ending the `Trace` and evicting it from cache; the next event reconstructed a fresh `Trace` whose near-empty initial state overwrote the rich `ses_<id>.json` on disk with a one-span file. Symptoms: the **Waterfall** collapsed to a lone "system-prompt" span, the **Summary** tab's *"What was asked"* went to *"No prompt recorded"*, and the **Chat** tab kept only the last user turn. The destructive idle handler is removed (sessions are long-lived — finalization happens on shutdown and `MAX_TRACES` eviction only), trace reconstruction now **rehydrates from the on-disk file** before falling back to a fresh start, and each user prompt is recorded as its own `user-message` span so multi-turn sessions render every turn in order. Authored-text scoping keeps synthetic parts (MCP banners, decoded file contents, reminders) out of the prompt and chat surfaces. Distinct from the v0.8.1 trace-corruption fix (#865), which addressed intra-instance concurrency — this is the worker-level cache lifecycle. (#895)
15+
- **A trace reconstructed after a restart no longer reads as a failed run.** When the on-disk trace is rehydrated (worker restart or `MAX_TRACES` eviction), any generation span still in flight is closed and marked so its boundary stays visible in the waterfall. The status message now states plainly that altimate-code restarted before the step finished recording and that this is **not an agent failure** — so an on-call reader debugging from a trace isn't sent chasing a phantom incident. (#895)
16+
817
## [0.8.3] - 2026-06-04
918

1019
A plan-mode reliability patch for the hosted gateway and other non-Anthropic models. Ask the `plan` agent to plan something benign — *"plan a feature to add a verify-output button"* — on `altimate-backend/altimate-default` (GPT-5.x) and it could refuse outright with *"I'm sorry, but I cannot assist with that request."* This release fixes the refusal at its source, hardens the fix against prompt-injection, and rewrites a warning that was misdiagnosing the symptom.
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
/**
2+
* Adversarial coverage for the v0.8.4 release.
3+
*
4+
* v0.8.4 ships one user-facing fix (#895): session traces no longer lose their
5+
* data after each agent turn. The mechanism is `Trace.rehydrateFromFile` (load
6+
* the rich on-disk trace on cache miss instead of clobbering it), per-turn
7+
* `logUserMessage` spans feeding the chat tab, and marking in-flight generation
8+
* spans interrupted on reconstruction. The release-review follow-up reworded the
9+
* interrupt `statusMessage` so a recorder restart isn't read as an agent failure.
10+
*
11+
* The happy-path and edge behavior is covered in `tracing-rehydrate.test.ts` and
12+
* `worker-trace-clearing.test.ts`. This file is strictly adversarial: malformed /
13+
* type-confused on-disk files, path traversal, prototype pollution, boundary
14+
* truncation, and `</script>` breakout through the new user-message → viewer path.
15+
*/
16+
17+
import { describe, expect, test } from "bun:test"
18+
import fs from "fs/promises"
19+
import path from "path"
20+
import {
21+
Trace,
22+
FileExporter,
23+
USER_MESSAGE_INPUT_MAX_CHARS,
24+
type TraceFile,
25+
} from "../../src/altimate/observability/tracing"
26+
import { renderTraceViewer } from "../../src/altimate/observability/viewer"
27+
import { tmpdir } from "../fixture/fixture"
28+
29+
function makeTrace(dir: string) {
30+
return Trace.withExporters([new FileExporter(dir)])
31+
}
32+
33+
function safeName(sessionId: string) {
34+
return sessionId.replace(/[/\\.:]/g, "_") || "unknown"
35+
}
36+
37+
async function readTraceFile(dir: string, sessionId: string): Promise<TraceFile> {
38+
const filePath = path.join(dir, `${safeName(sessionId)}.json`)
39+
return JSON.parse(await fs.readFile(filePath, "utf-8"))
40+
}
41+
42+
// Write a raw string to the on-disk path `rehydrateFromFile` would read for `sessionId`.
43+
async function stageRaw(dir: string, sessionId: string, raw: string) {
44+
await fs.writeFile(path.join(dir, `${safeName(sessionId)}.json`), raw)
45+
}
46+
47+
describe("v0.8.4 adversarial — rehydrateFromFile rejects malformed input without throwing", () => {
48+
test("truncated / non-JSON file returns false, does not throw", async () => {
49+
await using tmp = await tmpdir()
50+
const id = "ses_corrupt_json"
51+
await stageRaw(tmp.path, id, '{ "spans": [ this is not valid json')
52+
const trace = makeTrace(tmp.path)
53+
// Must not throw — a torn/garbage file during a postmortem must degrade
54+
// to "no rehydrate", never crash the worker event loop.
55+
expect(await trace.rehydrateFromFile(id)).toBe(false)
56+
})
57+
58+
test("empty file returns false", async () => {
59+
await using tmp = await tmpdir()
60+
const id = "ses_empty_file"
61+
await stageRaw(tmp.path, id, "")
62+
expect(await makeTrace(tmp.path).rehydrateFromFile(id)).toBe(false)
63+
})
64+
65+
test("spans is not an array (type confusion) returns false", async () => {
66+
await using tmp = await tmpdir()
67+
const id = "ses_spans_not_array"
68+
await stageRaw(
69+
tmp.path,
70+
id,
71+
JSON.stringify({ version: 2, traceId: "t", sessionId: id, metadata: {}, spans: "definitely-not-an-array" }),
72+
)
73+
expect(await makeTrace(tmp.path).rehydrateFromFile(id)).toBe(false)
74+
})
75+
76+
test("empty spans array returns false", async () => {
77+
await using tmp = await tmpdir()
78+
const id = "ses_empty_spans"
79+
await stageRaw(tmp.path, id, JSON.stringify({ version: 2, traceId: "t", sessionId: id, metadata: {}, spans: [] }))
80+
expect(await makeTrace(tmp.path).rehydrateFromFile(id)).toBe(false)
81+
})
82+
83+
test("spans present but no session root span returns false", async () => {
84+
await using tmp = await tmpdir()
85+
const id = "ses_no_root"
86+
// parentSpanId is null but kind is "generation", not "session" — there is no
87+
// valid root, so reconstruction must refuse rather than build a headless trace.
88+
await stageRaw(
89+
tmp.path,
90+
id,
91+
JSON.stringify({
92+
version: 2,
93+
traceId: "t",
94+
sessionId: id,
95+
metadata: {},
96+
spans: [{ spanId: "g", parentSpanId: null, name: "gen", kind: "generation", startTime: 0, status: "ok" }],
97+
}),
98+
)
99+
expect(await makeTrace(tmp.path).rehydrateFromFile(id)).toBe(false)
100+
})
101+
102+
test("empty-string sessionId is handled gracefully (no throw, false)", async () => {
103+
await using tmp = await tmpdir()
104+
// "" sanitizes to "unknown"; no such file exists, so it returns false rather
105+
// than throwing on a degenerate id.
106+
expect(await makeTrace(tmp.path).rehydrateFromFile("")).toBe(false)
107+
})
108+
})
109+
110+
describe("v0.8.4 adversarial — rehydrateFromFile injection resistance", () => {
111+
test("path-traversal sessionId cannot read a file outside the snapshot dir", async () => {
112+
await using tmp = await tmpdir()
113+
await using outside = await tmpdir()
114+
// Plant a valid-looking trace OUTSIDE the snapshot dir, then try to reach it
115+
// via traversal. The sanitizer collapses / \ . : to _, so the lookup stays
116+
// inside the snapshot dir and finds nothing.
117+
const evil = "../../../../" + path.basename(outside.path) + "/ses_secret"
118+
await fs.writeFile(
119+
path.join(outside.path, "ses_secret.json"),
120+
JSON.stringify({
121+
version: 2,
122+
traceId: "t",
123+
sessionId: "ses_secret",
124+
metadata: { prompt: "SECRET" },
125+
spans: [{ spanId: "r", parentSpanId: null, name: "ses_secret", kind: "session", startTime: 0, status: "ok" }],
126+
}),
127+
)
128+
const trace = makeTrace(tmp.path)
129+
expect(await trace.rehydrateFromFile(evil)).toBe(false)
130+
})
131+
132+
test("__proto__ payload in a trace file does not pollute Object.prototype", async () => {
133+
await using tmp = await tmpdir()
134+
const id = "ses_proto_pollution"
135+
// Hand-written so the literal `__proto__` key survives (JSON.stringify of a
136+
// normal object would not emit it). A tampered/hostile trace file must never
137+
// mutate the running process's prototype chain when reconstructed.
138+
const raw = `{
139+
"version": 2,
140+
"traceId": "t",
141+
"sessionId": "${id}",
142+
"metadata": { "__proto__": { "polluted": "yes" } },
143+
"spans": [
144+
{ "spanId": "r", "parentSpanId": null, "name": "${id}", "kind": "session", "startTime": 0, "status": "ok",
145+
"__proto__": { "pollutedSpan": "yes" } }
146+
]
147+
}`
148+
await stageRaw(tmp.path, id, raw)
149+
const trace = makeTrace(tmp.path)
150+
expect(await trace.rehydrateFromFile(id)).toBe(true)
151+
// No prototype pollution leaked into the global Object prototype.
152+
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
153+
expect(({} as Record<string, unknown>).pollutedSpan).toBeUndefined()
154+
expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined()
155+
})
156+
})
157+
158+
describe("v0.8.4 adversarial — interrupt status disambiguation (#895 review follow-up)", () => {
159+
test("rehydrated in-flight generation span is marked interrupted AND explicitly not-an-agent-failure", async () => {
160+
await using tmp = await tmpdir()
161+
const id = "ses_interrupt_msg"
162+
163+
const original = makeTrace(tmp.path)
164+
original.startTrace(id, {})
165+
original.logStepStart({ id: "step-1" } as any) // opens a generation span
166+
original.logToolCall({ tool: "read", state: { status: "completed", input: { f: "a" } } } as any) // forces snapshot
167+
await original.flush()
168+
const before = await readTraceFile(tmp.path, id)
169+
const openGen = before.spans.find((s) => s.kind === "generation")
170+
expect(openGen?.endTime).toBeUndefined()
171+
172+
const reconstructed = makeTrace(tmp.path)
173+
expect(await reconstructed.rehydrateFromFile(id)).toBe(true)
174+
reconstructed.logToolCall({ tool: "read", state: { status: "completed", input: { f: "b" } } } as any)
175+
await reconstructed.flush()
176+
177+
const after = await readTraceFile(tmp.path, id)
178+
const gen = after.spans.find((s) => s.spanId === openGen?.spanId)
179+
expect(gen?.status).toBe("error") // boundary stays visible in the waterfall
180+
const msg = String(gen?.statusMessage ?? "")
181+
// The reworded message must keep "interrupted" (the rehydrate behavioral
182+
// contract) and must spell out that this is a recorder restart, not a failure,
183+
// so an on-call reader doesn't chase a phantom incident.
184+
expect(msg).toMatch(/interrupted/i)
185+
expect(msg).toMatch(/not an agent failure/i)
186+
// Guard against regressing back to the old internal jargon.
187+
expect(msg).not.toMatch(/cache eviction/i)
188+
})
189+
})
190+
191+
describe("v0.8.4 adversarial — logUserMessage boundary + chat-tab injection", () => {
192+
test("input is truncated at the cap boundary, not one char over", async () => {
193+
await using tmp = await tmpdir()
194+
const id = "ses_user_truncation"
195+
const trace = makeTrace(tmp.path)
196+
trace.startTrace(id, {})
197+
// Exactly at the cap: preserved verbatim (slice only fires for length > cap).
198+
trace.logUserMessage("a".repeat(USER_MESSAGE_INPUT_MAX_CHARS))
199+
// One over the cap: truncated to exactly the cap.
200+
trace.logUserMessage("b".repeat(USER_MESSAGE_INPUT_MAX_CHARS + 1))
201+
// Empty string: not recorded at all.
202+
trace.logUserMessage("")
203+
await trace.flush()
204+
205+
const file = await readTraceFile(tmp.path, id)
206+
const userSpans = file.spans.filter((s) => s.kind === "user-message")
207+
expect(userSpans).toHaveLength(2)
208+
expect((userSpans[0] as any).input.length).toBe(USER_MESSAGE_INPUT_MAX_CHARS)
209+
expect((userSpans[1] as any).input.length).toBe(USER_MESSAGE_INPUT_MAX_CHARS)
210+
expect((userSpans[1] as any).input.endsWith("b")).toBe(true)
211+
})
212+
213+
test("a </script> payload in a user message cannot break out of the viewer's embedded JSON", async () => {
214+
await using tmp = await tmpdir()
215+
const id = "ses_xss_breakout"
216+
const payload = "</script><img src=x onerror=alert(1)>"
217+
218+
const trace = makeTrace(tmp.path)
219+
trace.startTrace(id, { prompt: payload })
220+
trace.logUserMessage(payload) // #895 added user-message spans to the chat tab
221+
trace.logToolCall({ tool: "read", state: { status: "completed", input: { f: "a" } } } as any)
222+
await trace.flush()
223+
224+
const file = await readTraceFile(tmp.path, id)
225+
const html = renderTraceViewer(file)
226+
227+
// The raw payload must never appear verbatim — that would close the inline
228+
// <script> and inject an <img onerror> into the page.
229+
expect(html).not.toContain("</script><img src=x onerror=alert(1)>")
230+
// It must instead appear in escaped form (the `<\/` replacement on the
231+
// embedded trace JSON), proving the data was neutralized, not dropped.
232+
expect(html).toContain("<\\/script>")
233+
})
234+
})

0 commit comments

Comments
 (0)