diff --git a/packages/opencode/src/altimate/observability/tracing.ts b/packages/opencode/src/altimate/observability/tracing.ts index 6665c1682..e2f88c1d4 100644 --- a/packages/opencode/src/altimate/observability/tracing.ts +++ b/packages/opencode/src/altimate/observability/tracing.ts @@ -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 + return ( + typeof trace.sessionId === "string" && + typeof trace.startedAt === "string" && + typeof trace.metadata === "object" && + trace.metadata !== null && + typeof trace.summary === "object" && + trace.summary !== null + ) +} const DEFAULT_MAX_FILES = 100 /** @@ -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 diff --git a/packages/opencode/src/altimate/observability/viewer.ts b/packages/opencode/src/altimate/observability/viewer.ts index 8fc6ab256..d48def392 100644 --- a/packages/opencode/src/altimate/observability/viewer.ts +++ b/packages/opencode/src/altimate/observability/viewer.ts @@ -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 ` @@ -292,7 +298,7 @@ pre.io { background: var(--bg); border: 1px solid var(--border); border-radius:
- + ${embedded ? "" : ``}
@@ -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() { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index db59b3d4d..28851760d 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -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 `` 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 { + 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 @@ -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) => { + 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) + try { + const { Trace } = await import("../altimate/observability/tracing") + const page = await Trace.listTracesPaginated(await tracesDir(), { + 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, + 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 `.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 diff --git a/packages/opencode/test/server/altimate-trace-routes.test.ts b/packages/opencode/test/server/altimate-trace-routes.test.ts new file mode 100644 index 000000000..e02e53849 --- /dev/null +++ b/packages/opencode/test/server/altimate-trace-routes.test.ts @@ -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 = {}) { + return Server.Default().request(urlPath, { method: "GET", headers }) +} + +async function writeTrace(sessionId: string, startedAt: string, metadata: Record = {}) { + 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 + 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 + 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 + 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 + expect(body.limit).toBe(200) + }) + + test("returns an empty page when there are no traces", async () => { + const body = (await (await get("/altimate/trace")).json()) as Record + 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("Altimate Trace") + 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) + }) +}) diff --git a/packages/opencode/test/skill/release-v0.5.20-adversarial.test.ts b/packages/opencode/test/skill/release-v0.5.20-adversarial.test.ts index a54c54287..cc47d93fb 100644 --- a/packages/opencode/test/skill/release-v0.5.20-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.5.20-adversarial.test.ts @@ -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" }, } await fs.writeFile(path.join(tmpDir, `${sessionId}.json`), JSON.stringify(trace)) }