Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion packages/opencode/src/altimate/observability/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,20 @@ export interface TraceExporter {
// ---------------------------------------------------------------------------

const DEFAULT_TRACES_DIR = path.join(Global.Path.data, "traces")

/** Whether a parsed `.json` from the traces dir has the fields a trace listing reads. */
function isTraceShaped(value: unknown): value is TraceFile {
if (typeof value !== "object" || value === null) return false
const trace = value as Partial<TraceFile>
return (
typeof trace.sessionId === "string" &&
typeof trace.startedAt === "string" &&
typeof trace.metadata === "object" &&
trace.metadata !== null &&
typeof trace.summary === "object" &&
trace.summary !== null
Comment on lines +185 to +186

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: summary: {} passes this check, but trace list then calls totalTokens.toLocaleString() and formats totalCost with .toFixed(), crashing the command. Validate the summary fields consumed by the listing before accepting the file as a TraceFile.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/altimate/observability/tracing.ts, line 185:

<comment>`summary: {}` passes this check, but `trace list` then calls `totalTokens.toLocaleString()` and formats `totalCost` with `.toFixed()`, crashing the command. Validate the summary fields consumed by the listing before accepting the file as a `TraceFile`.</comment>

<file context>
@@ -172,6 +172,20 @@ export interface TraceExporter {
+    typeof trace.startedAt === "string" &&
+    typeof trace.metadata === "object" &&
+    trace.metadata !== null &&
+    typeof trace.summary === "object" &&
+    trace.summary !== null
+  )
</file context>
Suggested change
typeof trace.summary === "object" &&
trace.summary !== null
typeof trace.summary === "object" &&
trace.summary !== null &&
typeof trace.summary.totalTokens === "number" &&
typeof trace.summary.totalCost === "number" &&
typeof trace.summary.totalToolCalls === "number" &&
typeof trace.summary.duration === "number" &&
typeof trace.summary.status === "string"

)
}
const DEFAULT_MAX_FILES = 100

/**
Expand Down Expand Up @@ -1485,7 +1499,11 @@ export class Trace {
if (!file.endsWith(".json")) continue
try {
const content = await fs.readFile(path.join(tracesDir, file), "utf-8")
const trace = JSON.parse(content) as TraceFile
const trace: unknown = JSON.parse(content)
// A file that parses but is not a trace (a foreign file in a user-set `tracing.dir`)
// is skipped like a corrupted one: listing it would fail every caller that reads
// its metadata or summary.
if (!isTraceShaped(trace)) continue
traces.push({ sessionId: trace.sessionId, file, trace })
} catch {
// Skip corrupted files
Expand Down
13 changes: 10 additions & 3 deletions packages/opencode/src/altimate/observability/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@

import { USER_MESSAGE_INPUT_MAX_CHARS, type TraceFile } from "./tracing"

export function renderTraceViewer(trace: TraceFile, options?: { live?: boolean; apiPath?: string }): string {
export function renderTraceViewer(
trace: TraceFile,
// `embedded`: shown inside a host (the IDE extension's editor tab) whose page URL is not
// shareable, so the Copy Link button is left out.
options?: { live?: boolean; apiPath?: string; embedded?: boolean },
): string {
const traceJSON = JSON.stringify(trace).replace(/<\//g, "<\\/")
const apiPath = options?.apiPath ?? "/api/trace"
const live = options?.live ?? false
const embedded = options?.embedded ?? false

return `<!DOCTYPE html>
<html lang="en">
Expand Down Expand Up @@ -292,7 +298,7 @@ pre.io { background: var(--bg); border: 1px solid var(--border); border-radius:
<div class="toolbar">
<button class="toolbar-btn primary" id="btn-share" title="Download self-contained HTML trace (session recording)">Share Trace</button>
<button class="toolbar-btn" id="btn-copy-summary" title="Copy markdown summary to clipboard">Copy Summary</button>
<button class="toolbar-btn" id="btn-copy-link" title="Copy current URL to clipboard">Copy Link</button>
${embedded ? "" : `<button class="toolbar-btn" id="btn-copy-link" title="Copy current URL to clipboard">Copy Link</button>`}
<div class="toolbar-spacer"></div>
<span class="toolbar-toast" id="toolbar-toast"></span>
</div>
Expand Down Expand Up @@ -1548,7 +1554,8 @@ function showDetail(span) {
});

// Copy Link
document.getElementById('btn-copy-link').addEventListener('click', function() {
var copyLinkBtn = document.getElementById('btn-copy-link');
if (copyLinkBtn) copyLinkBtn.addEventListener('click', function() {
var url = window.location.href;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(function() {
Expand Down
110 changes: 103 additions & 7 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,32 +94,56 @@ export namespace Server {
if (!CoreFlag.ALTIMATE_WORKSPACE) {
return { status: 409, body: { ok: false, error: "Workspace mode is not enabled for this server." } }
}
return browserOriginRefusal("Workspace actions", origin, host, password, fetchSite)
}
/** Why a browser-originated call to a local-only `/altimate/*` route must be refused, or undefined
* when it may run. `subject` names the routes in the error ("Workspace actions", "Traces"). */
export function browserOriginRefusal(
subject: string,
origin: string | undefined,
host: string | undefined,
password: string | undefined = Flag.OPENCODE_SERVER_PASSWORD,
fetchSite?: string,
): { status: 403; body: { ok: false; error: string } } | undefined {
// A browser labels every request it sends, including Origin-less ones such as an `<img>` GET
// from another site. Native clients send no such header, so only a browser's cross-site request
// is refused here; the Origin rules below handle the rest.
if (fetchSite && fetchSite !== "same-origin" && fetchSite !== "none") {
log.warn("refused cross-site workspace action", { fetchSite })
return { status: 403, body: { ok: false, error: "Workspace actions cannot be run from another site." } }
log.warn("refused cross-site request", { subject, fetchSite })
return { status: 403, body: { ok: false, error: `${subject} cannot be run from another site.` } }
}
if (!origin) return undefined
if (!password) {
log.warn("refused browser-originated workspace action on an unsecured server", { origin })
log.warn("refused browser-originated request on an unsecured server", { subject, origin })
return {
status: 403,
body: {
ok: false,
error: "Workspace actions cannot be run from a browser origin on an unsecured server. Set OPENCODE_SERVER_PASSWORD.",
error: `${subject} cannot be run from a browser origin on an unsecured server. Set OPENCODE_SERVER_PASSWORD.`,
},
}
}
// With a password set, basicAuth has vetted the credentials — but a browser replays cached
// Basic credentials on a cross-site form POST too, so only this server's own pages may call.
// Basic credentials on a cross-site request too, so only this server's own pages may call.
if (!sameOrigin(origin, host)) {
log.warn("refused cross-origin workspace action", { origin })
return { status: 403, body: { ok: false, error: "Workspace actions cannot be run from another origin." } }
log.warn("refused cross-origin request", { subject, origin })
return { status: 403, body: { ok: false, error: `${subject} cannot be run from another origin.` } }
}
return undefined
}
const TRACE_PAGE_SIZE = 50
const TRACE_PAGE_MAX = 200
const TRACE_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/
/** The traces directory, honoring `tracing.dir` like the CLI and TUI; a config that fails to load
* falls back to the default rather than hiding every trace. */
async function tracesDir(): Promise<string | undefined> {
try {
const { Config } = await import("../config/config")
return (await Config.get()).tracing?.dir
} catch {
return undefined
}
}
/** The skill registry this instance serves, reloaded so a skill written since it loaded is found
* — also one in a skills directory that did not exist at boot. Same in-context path as
* `refreshSkillRegistry` in session/prompt.ts: the facade's invalidate keeps the stale root list
Expand Down Expand Up @@ -1249,6 +1273,78 @@ export namespace Server {
}
})
// altimate_change end
// altimate_change start — GET /altimate/trace, GET /altimate/trace/:sessionID/view
// The TUI's `/traces` for the IDE extension, which runs this CLI headless: a page of the
// session traces and one trace's self-contained viewer page. Trace content carries prompts and
// tool output, so a browser origin is refused on the same terms as the workspace routes.
.get("/altimate/trace", async (c) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Require credentials or restrict both trace routes to trusted local callers; browserOriginRefusal accepts requests without Origin, allowing unauthenticated network clients to retrieve prompts and tool output when password auth is disabled.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/server/server.ts, line 1279:

<comment>Require credentials or restrict both trace routes to trusted local callers; `browserOriginRefusal` accepts requests without `Origin`, allowing unauthenticated network clients to retrieve prompts and tool output when password auth is disabled.</comment>

<file context>
@@ -1249,6 +1272,77 @@ export namespace Server {
+      // The TUI's `/traces` for the IDE extension, which runs this CLI headless: a page of the
+      // session traces and one trace's self-contained viewer page. Trace content carries prompts and
+      // tool output, so a browser origin is refused on the same terms as the workspace routes.
+      .get("/altimate/trace", async (c) => {
+        const refused = browserOriginRefusal(
+          "Traces",
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the reply on the kilo thread above: on an unsecured server these routes expose nothing that /session/:sessionID/message, /session/:sessionID/transcript and /pty do not already. serve binds to loopback by default and warns when no password is set. The origin check covers the browser vector specific to these routes.

const refused = browserOriginRefusal(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: Protect trace contents when the server has no password

browserOriginRefusal permits requests without Origin/Sec-Fetch-Site, and the global Basic-auth middleware also permits them when OPENCODE_SERVER_PASSWORD is unset. serve can bind to 0.0.0.0 (including via mDNS), so an unauthenticated network client can list all historical trace IDs here and fetch their viewer pages, including prompts and tool output, without any browser headers. These traces live in a shared directory, not just the requested project. Require authentication or otherwise restrict both new routes to a trusted local caller; origin headers alone cannot authenticate a raw HTTP client.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this one; these routes add no new exposure. serve binds to 127.0.0.1 unless --hostname or --mdns is passed, and it prints "server is unsecured" when OPENCODE_SERVER_PASSWORD is unset. On a server reachable without a password, the same raw client can already read every prompt and all tool output via GET /session/:sessionID/message and /session/:sessionID/transcript, and can open a shell via /pty. A trace-only restriction would not protect that data. The origin check here closes the one new vector these routes would add: a web page reading traces through the browser.

"Traces",
c.req.header("origin"),
c.req.header("host"),
undefined,
c.req.header("sec-fetch-site"),
)
if (refused) return c.json(refused.body, refused.status)
try {
const { Trace } = await import("../altimate/observability/tracing")
const page = await Trace.listTracesPaginated(await tracesDir(), {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Pagination still parses every trace file on every list request

Trace.listTracesPaginated first calls Trace.listTraces, which reads and JSON-parses every .json file (including all recorded tool I/O) and sorts the complete result before slicing. Thus even the default 50-row request costs I/O and memory proportional to the entire trace archive; limit also has no upper bound, allowing a single request to serialize all titles/prompts. With multi-MB traces or frequent quick-pick refreshes this can stall the headless server. Use bounded metadata/index reads for listing and cap the HTTP page size.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly fixed in 85656db: limit is now capped at 200, so one request cannot serialize the whole archive. The parse-every-file cost is unchanged. It is the same Trace.listTraces path the TUI /traces dialog and trace list already run on every open, and tracing.maxFiles (default 100, with pruning) bounds it. A summary-only reader needs an index or sidecar written by the tracer, which changes the trace writer, so it belongs in its own PR rather than this one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Trace.listTracesPaginated reads and parses every full JSON trace before slicing. With tracing.maxFiles: 0, each page request can load an unbounded history into memory; use a summary-only paginated reader.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/server/server.ts, line 1290:

<comment>`Trace.listTracesPaginated` reads and parses every full JSON trace before slicing. With `tracing.maxFiles: 0`, each page request can load an unbounded history into memory; use a summary-only paginated reader.</comment>

<file context>
@@ -1249,6 +1272,77 @@ export namespace Server {
+        if (refused) return c.json(refused.body, refused.status)
+        try {
+          const { Trace } = await import("../altimate/observability/tracing")
+          const page = await Trace.listTracesPaginated(await tracesDir(), {
+            offset: Number(c.req.query("offset") ?? 0),
+            limit: Number(c.req.query("limit") ?? TRACE_PAGE_SIZE),
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the thread above: the page size is capped at 200 in 85656db. A summary-only reader needs the tracer to write an index, which is a separate change. With tracing.maxFiles: 0 the user has opted out of pruning, and the TUI and trace list load the same full history today.

offset: Number(c.req.query("offset") ?? 0),
// Capped so one request cannot serialize the whole archive's titles and prompts.
limit: Math.min(Number(c.req.query("limit") ?? TRACE_PAGE_SIZE), TRACE_PAGE_MAX),
})
return c.json({
ok: true as const,
total: page.total,
offset: page.offset,
limit: page.limit,
traces: page.traces.map(({ sessionId, trace }) => ({
sessionID: sessionId,
title: trace.metadata.title || trace.metadata.prompt || sessionId,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR · Bug — one parseable non-trace .json in the traces dir makes this route return 500

Trace.listTraces (tracing.ts:1484-1493) only skips files that fail JSON.parse. Any file that parses but has no metadata/summary reaches this mapping, and trace.metadata.title (and trace.summary.* below) throws. Then the whole list fails, not just that entry. Such a file can be a foreign file in a user-set tracing.dir, or an older or partial trace.

Reproduced against this branch with one valid trace plus notes.json = {"hello":1}:

500 {"ok":false,"error":"undefined is not an object (evaluating 'trace.metadata.title')"}

The TUI list already guards this case (plugin/tui/altimate/trace-viewer.tsx:162-167: item.trace.metadata ?? {}, summary?.status).

Suggestion: filter out entries that aren't trace-shaped instead of optional-chaining them, so a foreign file isn't listed as a trace:

traces: page.traces
  .filter(({ trace }) => trace && typeof trace.metadata === "object" && typeof trace.summary === "object")
  .map(({ sessionId, trace }) => ({ /* … */ })),

total would ideally be computed after the same filter, so better still do it inside listTraces. Please also add a route test with one valid trace and one parseable non-trace file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 85656db. The guard went into Trace.listTraces, as you suggested: a .json that parses but lacks sessionId, startedAt, metadata or summary is skipped like a corrupted file. total counts only traces, and trace list no longer crashes on such a file either. I checked 89 real local traces, including a running one; all have both metadata and summary, since the writer always builds them. New route test: one valid trace plus notes.json = {"hello":1} → 200, total: 1. The v0.5.20 pagination fixture had no summary, so it now gets the one every written trace has. Those tests pin offset/limit clamping, not trace shape.

startedAt: trace.startedAt,
status: trace.summary.status,
duration: trace.summary.duration,
totalTokens: trace.summary.totalTokens,
totalCost: trace.summary.totalCost,
totalToolCalls: trace.summary.totalToolCalls,
})),
})
} catch (err) {
const error = err instanceof Error ? err.message : String(err)
log.error("trace list: failed", { error })
return c.json({ ok: false, error }, 500)
}
})
.get("/altimate/trace/:sessionID/view", async (c) => {
const refused = browserOriginRefusal(
"Traces",
c.req.header("origin"),
c.req.header("host"),
undefined,
c.req.header("sec-fetch-site"),
)
if (refused) return c.json(refused.body, refused.status)
const sessionID = c.req.param("sessionID")
// Trace files are `<sessionID>.json` in the traces dir; anything outside this alphabet
// could name a path elsewhere.
if (!TRACE_SESSION_ID.test(sessionID)) {
return c.json({ ok: false, error: `Invalid sessionID: ${sessionID}` }, 400)
}
try {
const [{ Trace }, { renderTraceViewer }] = await Promise.all([
import("../altimate/observability/tracing"),
import("../altimate/observability/viewer"),
])
const trace = await Trace.loadTrace(sessionID, await tracesDir())
if (!trace) return c.json({ ok: false, error: `Trace not found: ${sessionID}` }, 404)
return c.html(renderTraceViewer(trace, { embedded: true }))
} catch (err) {
const error = err instanceof Error ? err.message : String(err)
log.error("trace view: failed", { error })
return c.json({ ok: false, error }, 500)
}
})
// altimate_change end
.all("/*", async (c) => {
const path = c.req.path

Expand Down
154 changes: 154 additions & 0 deletions packages/opencode/test/server/altimate-trace-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// altimate_change - new file
//
// `GET /altimate/trace` and `GET /altimate/trace/:sessionID/view`: the TUI's `/traces` for the IDE
// extension. Traces are real files in a temp `tracing.dir`, so these also cover that the routes
// honor the configured directory.
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Server } from "../../src/server/server"
import { Config } from "../../src/config/config"
import { resetDatabase } from "./db"
import { disposeAllInstances } from "../fixture/fixture"

let dir: string
let spies: Array<{ mockRestore: () => void }> = []

function get(urlPath: string, headers: Record<string, string> = {}) {
return Server.Default().request(urlPath, { method: "GET", headers })
}

async function writeTrace(sessionId: string, startedAt: string, metadata: Record<string, unknown> = {}) {
const trace = {
version: 2,
traceId: `trace-${sessionId}`,
sessionId,
startedAt,
metadata,
spans: [],
summary: {
totalTokens: 1200,
totalCost: 0.0123,
totalToolCalls: 3,
totalGenerations: 2,
duration: 4500,
status: "completed",
tokens: { input: 1000, output: 200, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
},
}
await fs.writeFile(path.join(dir, `${sessionId}.json`), JSON.stringify(trace))
}

beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "altimate-trace-routes-"))
spies.push(spyOn(Config, "get").mockResolvedValue({ tracing: { dir } } as never))
})

afterEach(async () => {
for (const spy of spies) spy.mockRestore()
spies = []
await fs.rm(dir, { recursive: true, force: true })
await disposeAllInstances()
await resetDatabase()
})

describe("GET /altimate/trace", () => {
test("lists traces newest first with their summary", async () => {
await writeTrace("ses_old", "2026-10-01T10:00:00.000Z", { title: "Old session" })
await writeTrace("ses_new", "2026-10-02T10:00:00.000Z", { prompt: "Explain the orders model" })

const response = await get("/altimate/trace")
expect(response.status).toBe(200)
const body = (await response.json()) as Record<string, any>
expect(body.ok).toBe(true)
expect(body.total).toBe(2)
expect(body.traces.map((t: { sessionID: string }) => t.sessionID)).toEqual(["ses_new", "ses_old"])
// Title falls back to the prompt when the session has no title.
expect(body.traces[0]).toEqual({
sessionID: "ses_new",
title: "Explain the orders model",
startedAt: "2026-10-02T10:00:00.000Z",
status: "completed",
duration: 4500,
totalTokens: 1200,
totalCost: 0.0123,
totalToolCalls: 3,
})
expect(body.traces[1].title).toBe("Old session")
})

test("pages with offset and limit", async () => {
await writeTrace("ses_a", "2026-10-01T10:00:00.000Z")
await writeTrace("ses_b", "2026-10-02T10:00:00.000Z")
await writeTrace("ses_c", "2026-10-03T10:00:00.000Z")

const body = (await (await get("/altimate/trace?offset=1&limit=1")).json()) as Record<string, any>
expect(body.total).toBe(3)
expect(body.offset).toBe(1)
expect(body.limit).toBe(1)
expect(body.traces.map((t: { sessionID: string }) => t.sessionID)).toEqual(["ses_b"])
})

test("skips a parseable .json that is not a trace instead of failing the list", async () => {
await writeTrace("ses_real", "2026-10-01T10:00:00.000Z", { title: "Real session" })
await fs.writeFile(path.join(dir, "notes.json"), JSON.stringify({ hello: 1 }))

const response = await get("/altimate/trace")
expect(response.status).toBe(200)
const body = (await response.json()) as Record<string, any>
expect(body.total).toBe(1)
expect(body.traces.map((t: { sessionID: string }) => t.sessionID)).toEqual(["ses_real"])
})

test("caps the page size", async () => {
await writeTrace("ses_a", "2026-10-01T10:00:00.000Z")
const body = (await (await get("/altimate/trace?limit=100000")).json()) as Record<string, any>
expect(body.limit).toBe(200)
})
Comment on lines +104 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This test does not exercise the cap: it writes only one trace and checks the echoed limit, so returning every matching trace would still pass. Seed more than 200 traces and assert that the response contains at most 200.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/test/server/altimate-trace-routes.test.ts, line 104:

<comment>This test does not exercise the cap: it writes only one trace and checks the echoed `limit`, so returning every matching trace would still pass. Seed more than 200 traces and assert that the response contains at most 200.</comment>

<file context>
@@ -90,6 +90,23 @@ describe("GET /altimate/trace", () => {
+    expect(body.traces.map((t: { sessionID: string }) => t.sessionID)).toEqual(["ses_real"])
+  })
+
+  test("caps the page size", async () => {
+    await writeTrace("ses_a", "2026-10-01T10:00:00.000Z")
+    const body = (await (await get("/altimate/trace?limit=100000")).json()) as Record<string, any>
</file context>
Suggested change
test("caps the page size", async () => {
await writeTrace("ses_a", "2026-10-01T10:00:00.000Z")
const body = (await (await get("/altimate/trace?limit=100000")).json()) as Record<string, any>
expect(body.limit).toBe(200)
})
test("caps the page size", async () => {
await Promise.all(
Array.from({ length: 201 }, (_, i) =>
writeTrace(`ses_${i}`, new Date(Date.UTC(2026, 0, i + 1)).toISOString()),
),
)
const body = (await (await get("/altimate/trace?limit=100000")).json()) as Record<string, any>
expect(body.limit).toBe(200)
expect(body.traces).toHaveLength(200)
})


test("returns an empty page when there are no traces", async () => {
const body = (await (await get("/altimate/trace")).json()) as Record<string, any>
expect(body).toEqual({ ok: true, total: 0, offset: 0, limit: 50, traces: [] })
})

test("refuses a cross-site browser request", async () => {
await writeTrace("ses_a", "2026-10-01T10:00:00.000Z")
const response = await get("/altimate/trace", { "sec-fetch-site": "cross-site" })
expect(response.status).toBe(403)
expect(await response.json()).toEqual({ ok: false, error: "Traces cannot be run from another site." })
})
})

describe("GET /altimate/trace/:sessionID/view", () => {
test("serves the viewer page for the trace", async () => {
await writeTrace("ses_view", "2026-10-01T10:00:00.000Z", { title: "Viewer session" })

const response = await get("/altimate/trace/ses_view/view")
expect(response.status).toBe(200)
expect(response.headers.get("content-type")).toContain("text/html")
const html = await response.text()
expect(html).toContain("<title>Altimate Trace</title>")
expect(html).toContain("Viewer session")
// The IDE's editor tab has no shareable URL, so the viewer leaves Copy Link out.
expect(html).not.toContain('id="btn-copy-link"')
expect(html).toContain('id="btn-share"')
})

test("returns 404 for an unknown trace", async () => {
const response = await get("/altimate/trace/ses_missing/view")
expect(response.status).toBe(404)
expect(await response.json()).toEqual({ ok: false, error: "Trace not found: ses_missing" })
})

test("rejects a session ID that could name a path outside the traces dir", async () => {
const response = await get("/altimate/trace/..%2Fsecrets/view")
expect(response.status).toBe(400)
})

test("refuses a browser origin on an unsecured server", async () => {
await writeTrace("ses_view", "2026-10-01T10:00:00.000Z")
const response = await get("/altimate/trace/ses_view/view", { origin: "https://evil.example" })
expect(response.status).toBe(403)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ describe("v0.5.20 release: listTracesPaginated adversarial", () => {
endedAt: new Date(Date.now() - i * 60_000 + 30_000).toISOString(),
spans: [],
metadata: { provider: "test", model: "test-model", directory: "/tmp" },
// Every trace the writer produces has a summary; listTraces skips files without one.
summary: { totalTokens: 0, totalCost: 0, totalToolCalls: 0, totalGenerations: 0, duration: 30_000, status: "completed" },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This fixture omits the required summary.tokens breakdown that the writer emits. Add the five token counters so pagination tests use representative trace data.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/test/skill/release-v0.5.20-adversarial.test.ts, line 151:

<comment>This fixture omits the required `summary.tokens` breakdown that the writer emits. Add the five token counters so pagination tests use representative trace data.</comment>

<file context>
@@ -147,6 +147,8 @@ describe("v0.5.20 release: listTracesPaginated adversarial", () => {
         spans: [],
         metadata: { provider: "test", model: "test-model", directory: "/tmp" },
+        // Every trace the writer produces has a summary; listTraces skips files without one.
+        summary: { totalTokens: 0, totalCost: 0, totalToolCalls: 0, totalGenerations: 0, duration: 30_000, status: "completed" },
       }
       await fs.writeFile(path.join(tmpDir, `${sessionId}.json`), JSON.stringify(trace))
</file context>
Suggested change
summary: { totalTokens: 0, totalCost: 0, totalToolCalls: 0, totalGenerations: 0, duration: 30_000, status: "completed" },
summary: {
totalTokens: 0,
totalCost: 0,
totalToolCalls: 0,
totalGenerations: 0,
duration: 30_000,
status: "completed",
tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
},

}
await fs.writeFile(path.join(tmpDir, `${sessionId}.json`), JSON.stringify(trace))
}
Expand Down
Loading