From 524b638150913a9bfd4f2fe11f87cfe4cd379622 Mon Sep 17 00:00:00 2001 From: robo7 Date: Fri, 13 Mar 2026 22:33:39 +0800 Subject: [PATCH 1/6] fix: include reset/deleted transcripts in usage summaries --- src/infra/session-cost-usage.test.ts | 86 ++++++++++++++++++++++++++++ src/infra/session-cost-usage.ts | 18 ++++-- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index ba9e10b1f4a..bacb2ead581 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -231,6 +231,92 @@ describe("session cost usage", () => { }); }); + it("includes reset and deleted transcript files in aggregate usage summaries", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-archived-")); + const sessionsDir = path.join(root, "agents", "main", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + const day = new Date("2026-03-13T10:00:00.000Z"); + + const files = [ + "sess-active.jsonl", + "sess-reset.jsonl.reset.2026-03-13T10-05-00.000Z", + "sess-deleted.jsonl.deleted.2026-03-13T10-10-00.000Z", + ]; + + for (const [i, name] of files.entries()) { + const filePath = path.join(sessionsDir, name); + await fs.writeFile( + filePath, + JSON.stringify({ + type: "message", + timestamp: day.toISOString(), + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.2", + usage: { + input: 10 + i, + output: 20 + i, + totalTokens: 30 + i * 2, + cost: { total: 0.01 * (i + 1) }, + }, + }, + }), + "utf-8", + ); + } + + await withStateDir(root, async () => { + const summary = await loadCostUsageSummary({ + startMs: new Date("2026-03-13T00:00:00.000Z").getTime(), + endMs: new Date("2026-03-13T23:59:59.999Z").getTime(), + }); + expect(summary.daily).toHaveLength(1); + expect(summary.totals.totalTokens).toBe(96); + expect(summary.totals.totalCost).toBeCloseTo(0.06, 8); + }); + }); + + it("discovers archived reset/deleted transcripts and preserves base session ids", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discover-archived-")); + const sessionsDir = path.join(root, "agents", "main", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + const files = [ + ["sess-active.jsonl", "active hello"], + ["sess-reset.jsonl.reset.2026-03-13T10-05-00.000Z", "reset hello"], + ["sess-deleted.jsonl.deleted.2026-03-13T10-10-00.000Z", "deleted hello"], + ] as const; + + for (const [name, text] of files) { + const filePath = path.join(sessionsDir, name); + await fs.writeFile( + filePath, + JSON.stringify({ + type: "message", + timestamp: "2026-03-13T10:00:00.000Z", + message: { + role: "user", + content: text, + }, + }), + "utf-8", + ); + } + + await withStateDir(root, async () => { + const sessions = await discoverAllSessions({ + startMs: new Date("2026-03-13T00:00:00.000Z").getTime(), + }); + const ids = sessions.map((session) => session.sessionId).sort(); + expect(ids).toEqual(["sess-active", "sess-deleted", "sess-reset"]); + const reset = sessions.find((session) => session.sessionId === "sess-reset"); + const deleted = sessions.find((session) => session.sessionId === "sess-deleted"); + expect(reset?.firstUserMessage).toContain("reset hello"); + expect(deleted?.firstUserMessage).toContain("deleted hello"); + }); + }); + it("resolves non-main absolute sessionFile using explicit agentId for cost summary", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-agent-")); const workerSessionsDir = path.join(root, "agents", "worker1", "sessions"); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 230ebd60c2e..e4b9b4af466 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -53,6 +53,16 @@ export type { SessionUsageTimeSeries, } from "./session-cost-usage.types.js"; +const SESSION_TRANSCRIPT_FILE_RE = /\.jsonl(?:\.(?:reset|deleted)\..+)?$/; + +const isSessionTranscriptFilename = (name: string): boolean => + SESSION_TRANSCRIPT_FILE_RE.test(name); + +const deriveSessionIdFromFilename = (name: string): string => { + const match = name.match(/^(.*?\.jsonl)(?:\.(?:reset|deleted)\..+)?$/); + return match ? match[1].slice(0, -6) : name.replace(/\.jsonl$/, ""); +}; + const emptyTotals = (): CostUsageTotals => ({ input: 0, output: 0, @@ -318,7 +328,7 @@ export async function loadCostUsageSummary(params?: { const files = ( await Promise.all( entries - .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .filter((entry) => entry.isFile() && isSessionTranscriptFilename(entry.name)) .map(async (entry) => { const filePath = path.join(sessionsDir, entry.name); const stats = await fs.promises.stat(filePath).catch(() => null); @@ -393,7 +403,7 @@ export async function discoverAllSessions(params?: { const discovered: DiscoveredSession[] = []; for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".jsonl")) { + if (!entry.isFile() || !isSessionTranscriptFilename(entry.name)) { continue; } @@ -409,8 +419,8 @@ export async function discoverAllSessions(params?: { } // Do not exclude by endMs: a session can have activity in range even if it continued later. - // Extract session ID from filename (remove .jsonl) - const sessionId = entry.name.slice(0, -6); + // Extract session ID from filename, preserving base session id for archived reset/deleted transcripts. + const sessionId = deriveSessionIdFromFilename(entry.name); // Try to read first user message for label extraction let firstUserMessage: string | undefined; From 8636e745e6118fd1766ecd1ac8131a3ef581f166 Mon Sep 17 00:00:00 2001 From: robo7 Date: Fri, 13 Mar 2026 22:45:37 +0800 Subject: [PATCH 2/6] fix: ignore backup artifacts in usage transcript scan --- src/infra/session-cost-usage.test.ts | 7 ++++++- src/infra/session-cost-usage.ts | 28 ++++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index bacb2ead581..15dc30e4f6e 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -241,6 +241,8 @@ describe("session cost usage", () => { "sess-active.jsonl", "sess-reset.jsonl.reset.2026-03-13T10-05-00.000Z", "sess-deleted.jsonl.deleted.2026-03-13T10-10-00.000Z", + "sess-backup.jsonl.bak.2026-03-13T10-20-00.000Z", + "sessions.json.bak.1737420882", ]; for (const [i, name] of files.entries()) { @@ -286,6 +288,7 @@ describe("session cost usage", () => { ["sess-active.jsonl", "active hello"], ["sess-reset.jsonl.reset.2026-03-13T10-05-00.000Z", "reset hello"], ["sess-deleted.jsonl.deleted.2026-03-13T10-10-00.000Z", "deleted hello"], + ["foo.jsonl.bar.jsonl.reset.2026-03-13T10-15-00.000Z", "compound hello"], ] as const; for (const [name, text] of files) { @@ -309,11 +312,13 @@ describe("session cost usage", () => { startMs: new Date("2026-03-13T00:00:00.000Z").getTime(), }); const ids = sessions.map((session) => session.sessionId).sort(); - expect(ids).toEqual(["sess-active", "sess-deleted", "sess-reset"]); + expect(ids).toEqual(["foo.jsonl.bar", "sess-active", "sess-deleted", "sess-reset"]); const reset = sessions.find((session) => session.sessionId === "sess-reset"); const deleted = sessions.find((session) => session.sessionId === "sess-deleted"); + const compound = sessions.find((session) => session.sessionId === "foo.jsonl.bar"); expect(reset?.firstUserMessage).toContain("reset hello"); expect(deleted?.firstUserMessage).toContain("deleted hello"); + expect(compound?.firstUserMessage).toContain("compound hello"); }); }); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index e4b9b4af466..3cf6385e295 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -9,6 +9,10 @@ import { resolveSessionFilePath, resolveSessionTranscriptsDirForAgent, } from "../config/sessions/paths.js"; +import { + isPrimarySessionTranscriptFileName, + parseSessionArchiveTimestamp, +} from "../config/sessions/artifacts.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { stripEnvelope, stripMessageIdHints } from "../shared/chat-envelope.js"; import { countToolResults, extractToolCallNames } from "../utils/transcript-tools.js"; @@ -53,14 +57,30 @@ export type { SessionUsageTimeSeries, } from "./session-cost-usage.types.js"; -const SESSION_TRANSCRIPT_FILE_RE = /\.jsonl(?:\.(?:reset|deleted)\..+)?$/; +const isArchivedUsageTranscriptFilename = (name: string): boolean => + parseSessionArchiveTimestamp(name, "reset") !== null || + parseSessionArchiveTimestamp(name, "deleted") !== null; const isSessionTranscriptFilename = (name: string): boolean => - SESSION_TRANSCRIPT_FILE_RE.test(name); + isPrimarySessionTranscriptFileName(name) || isArchivedUsageTranscriptFilename(name); const deriveSessionIdFromFilename = (name: string): string => { - const match = name.match(/^(.*?\.jsonl)(?:\.(?:reset|deleted)\..+)?$/); - return match ? match[1].slice(0, -6) : name.replace(/\.jsonl$/, ""); + for (const reason of ["reset", "deleted"] as const) { + const archivedAt = parseSessionArchiveTimestamp(name, reason); + if (archivedAt === null) { + continue; + } + const marker = `.${reason}.`; + const index = name.lastIndexOf(marker); + if (index >= 0) { + const base = name.slice(0, index); + if (base.endsWith(".jsonl")) { + return base.slice(0, -6); + } + return base; + } + } + return name.endsWith(".jsonl") ? name.slice(0, -6) : name; }; const emptyTotals = (): CostUsageTotals => ({ From 3b88d615b0dd9dc3f5d99bc4b4d7391025b5ac55 Mon Sep 17 00:00:00 2001 From: robo7 Date: Fri, 13 Mar 2026 23:06:37 +0800 Subject: [PATCH 3/6] style: fix import order for oxfmt --- src/infra/session-cost-usage.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 3cf6385e295..663739ca1ca 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -5,14 +5,14 @@ import type { NormalizedUsage, UsageLike } from "../agents/usage.js"; import { normalizeUsage } from "../agents/usage.js"; import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js"; import type { OpenClawConfig } from "../config/config.js"; -import { - resolveSessionFilePath, - resolveSessionTranscriptsDirForAgent, -} from "../config/sessions/paths.js"; import { isPrimarySessionTranscriptFileName, parseSessionArchiveTimestamp, } from "../config/sessions/artifacts.js"; +import { + resolveSessionFilePath, + resolveSessionTranscriptsDirForAgent, +} from "../config/sessions/paths.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { stripEnvelope, stripMessageIdHints } from "../shared/chat-envelope.js"; import { countToolResults, extractToolCallNames } from "../utils/transcript-tools.js"; From 038186e30d677ced021d48302642af43f6f66e7e Mon Sep 17 00:00:00 2001 From: robo7 Date: Sat, 14 Mar 2026 00:02:26 +0800 Subject: [PATCH 4/6] fix: preserve archived transcript identity in usage resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract findArchivedTranscript() helper for reuse across resolution paths - resolveSessionUsageFileOrRespond: fall back to archived transcript when primary file is missing (reset/deleted sessions) - Archived keys use :: delimiter with embedded filename for uniqueness (e.g. agent:main:sess-foo::sess-foo.jsonl.reset.2026-03-13T10-00-00Z) - resolveSessionUsageFileOrRespond detects :: keys and resolves directly to archived file with path traversal guard - sessions.usage specific-key branch: fall back to findArchivedTranscript when primary transcript doesn't exist - DiscoveredSession.archived flag for downstream consumers - Fix .sort() → .toSorted() for oxlint compliance Closes review feedback on PR #45204. --- src/gateway/server-methods/usage.ts | 237 ++++++++++++++++++++------ src/infra/session-cost-usage.test.ts | 6 + src/infra/session-cost-usage.ts | 2 + src/infra/session-cost-usage.types.ts | 3 + 4 files changed, 196 insertions(+), 52 deletions(-) diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 8b6be35f654..e530029a647 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -1,8 +1,11 @@ import fs from "node:fs"; +import path from "node:path"; import { loadConfig } from "../../config/config.js"; +import { parseSessionArchiveTimestamp } from "../../config/sessions/artifacts.js"; import { resolveSessionFilePath, resolveSessionFilePathOptions, + resolveSessionTranscriptsDirForAgent, } from "../../config/sessions/paths.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import { loadProviderUsageSummary } from "../../infra/provider-usage.js"; @@ -70,6 +73,46 @@ function resolveSessionUsageFileOrRespond( sessionFile: string; } | null { const config = loadConfig(); + + // Handle archived transcript keys that embed the filename after `::` + // e.g. "agent:main:sess-foo::sess-foo.jsonl.reset.2026-03-13T10-00-00Z" + const archiveDelim = key.indexOf("::"); + if (archiveDelim !== -1) { + const baseKey = key.slice(0, archiveDelim); + const archiveFilename = key.slice(archiveDelim + 2); + const parsed = parseAgentSessionKey(baseKey); + const agentId = parsed?.agentId; + const rawSessionId = parsed?.rest ?? baseKey; + const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId); + const sessionFile = path.join(sessionsDir, archiveFilename); + // Guard against path traversal: the resolved file must remain inside sessionsDir + let realSessionsDir: string; + try { + realSessionsDir = fs.realpathSync(sessionsDir); + } catch { + // Sessions directory doesn't exist for this agent — no archived file possible + // Fall through to normal resolution + realSessionsDir = ""; + } + const realSessionFile = path.resolve(sessionFile); + if ( + realSessionsDir && + !realSessionFile.startsWith(realSessionsDir + path.sep) && + realSessionFile !== realSessionsDir + ) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `Invalid archived session key: ${key}`), + ); + return null; + } + if (fs.existsSync(sessionFile)) { + return { config, entry: undefined, agentId, sessionId: rawSessionId, sessionFile }; + } + // Fall through to normal resolution if the archived file no longer exists + } + const { entry, storePath } = loadSessionEntry(key); // For discovered sessions (not in store), try using key as sessionId directly @@ -90,9 +133,57 @@ function resolveSessionUsageFileOrRespond( return null; } + // When the primary transcript no longer exists (reset/deleted), fall back to + // the most recent archived transcript for the same session id so that + // detail/log/timeseries requests can still read historical data. + if (!fs.existsSync(sessionFile)) { + const fallback = findArchivedTranscript(sessionId, agentId); + if (fallback) { + sessionFile = fallback; + } + } + return { config, entry, agentId, sessionId, sessionFile }; } +/** + * Scan the sessions directory for archived (reset/deleted) transcripts matching + * the given sessionId and return the path to the most recently modified one, + * or `null` if none are found. + */ +function findArchivedTranscript(sessionId: string, agentId: string | undefined): string | null { + const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId); + const prefix = `${sessionId}.jsonl.`; + try { + const candidates = fs.readdirSync(sessionsDir).filter((name) => { + if (!name.startsWith(prefix)) { + return false; + } + return ( + parseSessionArchiveTimestamp(name, "reset") !== null || + parseSessionArchiveTimestamp(name, "deleted") !== null + ); + }); + if (candidates.length === 0) { + return null; + } + const resolved = candidates + .map((name) => { + const full = path.join(sessionsDir, name); + try { + return { path: full, mtime: fs.statSync(full).mtimeMs }; + } catch { + return null; + } + }) + .filter((c): c is { path: string; mtime: number } => c !== null) + .toSorted((a, b) => b.mtime - a.mtime); + return resolved[0]?.path ?? null; + } catch { + return null; + } +} + const parseDateParts = ( raw: unknown, ): { year: number; monthIndex: number; day: number } | undefined => { @@ -408,54 +499,90 @@ export const usageHandlers: GatewayRequestHandlers = { // Optimization: If a specific key is requested, skip full directory scan if (specificKey) { - const parsed = parseAgentSessionKey(specificKey); - const agentIdFromKey = parsed?.agentId; - const keyRest = parsed?.rest ?? specificKey; - - // Prefer the store entry when available, even if the caller provides a discovered key - // (`agent::`) for a session that now has a canonical store key. - const storeBySessionId = buildStoreBySessionId(store); - - const storeMatch = store[specificKey] - ? { key: specificKey, entry: store[specificKey] } - : null; - const storeByIdMatch = storeBySessionId.get(keyRest) ?? null; - const resolvedStoreKey = storeMatch?.key ?? storeByIdMatch?.key ?? specificKey; - const storeEntry = storeMatch?.entry ?? storeByIdMatch?.entry; - const sessionId = storeEntry?.sessionId ?? keyRest; - - // Resolve the session file path - let sessionFile: string; - try { - const pathOpts = resolveSessionFilePathOptions({ - storePath: storePath !== "(multiple)" ? storePath : undefined, - agentId: agentIdFromKey, - }); - sessionFile = resolveSessionFilePath(sessionId, storeEntry, pathOpts); - } catch { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session reference: ${specificKey}`), - ); - return; - } - - try { - const stats = fs.statSync(sessionFile); - if (stats.isFile()) { - mergedEntries.push({ - key: resolvedStoreKey, - sessionId, - sessionFile, - label: storeEntry?.label, - updatedAt: storeEntry?.updatedAt ?? stats.mtimeMs, - storeEntry, - }); + // Archived keys use "::" to embed the transcript filename — delegate to + // resolveSessionUsageFileOrRespond which already handles traversal checks. + const specificArchiveDelim = specificKey.indexOf("::"); + if (specificArchiveDelim !== -1) { + const resolved = resolveSessionUsageFileOrRespond(specificKey, respond); + if (!resolved) { + return; // respond already called } - } catch { - // File doesn't exist - no results for this key - } + try { + const stats = fs.statSync(resolved.sessionFile); + if (stats.isFile()) { + mergedEntries.push({ + key: specificKey, + sessionId: resolved.sessionId, + sessionFile: resolved.sessionFile, + label: undefined, + updatedAt: stats.mtimeMs, + }); + } + } catch { + // Archived file no longer exists — empty result + } + } else { + const parsed = parseAgentSessionKey(specificKey); + const agentIdFromKey = parsed?.agentId; + const keyRest = parsed?.rest ?? specificKey; + + // Prefer the store entry when available, even if the caller provides a discovered key + // (`agent::`) for a session that now has a canonical store key. + const storeBySessionId = buildStoreBySessionId(store); + + const storeMatch = store[specificKey] + ? { key: specificKey, entry: store[specificKey] } + : null; + const storeByIdMatch = storeBySessionId.get(keyRest) ?? null; + const resolvedStoreKey = storeMatch?.key ?? storeByIdMatch?.key ?? specificKey; + const storeEntry = storeMatch?.entry ?? storeByIdMatch?.entry; + const sessionId = storeEntry?.sessionId ?? keyRest; + + // Resolve the session file path + let sessionFile: string; + try { + const pathOpts = resolveSessionFilePathOptions({ + storePath: storePath !== "(multiple)" ? storePath : undefined, + agentId: agentIdFromKey, + }); + sessionFile = resolveSessionFilePath(sessionId, storeEntry, pathOpts); + } catch { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session reference: ${specificKey}`), + ); + return; + } + + try { + const stats = fs.statSync(sessionFile); + if (stats.isFile()) { + mergedEntries.push({ + key: resolvedStoreKey, + sessionId, + sessionFile, + label: storeEntry?.label, + updatedAt: storeEntry?.updatedAt ?? stats.mtimeMs, + storeEntry, + }); + } + } catch { + // Primary transcript doesn't exist — try archived fallback + const fallback = findArchivedTranscript(sessionId, agentIdFromKey); + if (fallback) { + const fallbackStats = fs.statSync(fallback); + mergedEntries.push({ + key: resolvedStoreKey, + sessionId, + sessionFile: fallback, + label: storeEntry?.label, + updatedAt: storeEntry?.updatedAt ?? fallbackStats.mtimeMs, + storeEntry, + }); + } + } + } // close non-archived specific-key branch } else { // Full discovery for list view const discoveredSessions = await discoverAllSessionsForUsage({ @@ -480,10 +607,16 @@ export const usageHandlers: GatewayRequestHandlers = { storeEntry: storeMatch.entry, }); } else { - // Unnamed session - use session ID as key, no label + // Unnamed session - use session ID as key, no label. + // For archived transcripts, embed the archive filename in the key so + // that multiple archived transcripts for the same base sessionId get + // distinct keys and can be individually resolved later. + const baseKey = `agent:${discovered.agentId}:${discovered.sessionId}`; + const key = discovered.archived + ? `${baseKey}::${path.basename(discovered.sessionFile)}` + : baseKey; mergedEntries.push({ - // Keep agentId in the key so the dashboard can attribute sessions and later fetch logs. - key: `agent:${discovered.agentId}:${discovered.sessionId}`, + key, sessionId: discovered.sessionId, sessionFile: discovered.sessionFile, label: undefined, // No label for unnamed sessions @@ -494,10 +627,10 @@ export const usageHandlers: GatewayRequestHandlers = { } // Sort by most recent first - mergedEntries.sort((a, b) => b.updatedAt - a.updatedAt); + const sortedEntries = mergedEntries.toSorted((a, b) => b.updatedAt - a.updatedAt); // Apply limit - const limitedEntries = mergedEntries.slice(0, limit); + const limitedEntries = sortedEntries.slice(0, limit); // Load usage for each session const sessions: SessionUsageEntry[] = []; diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 15dc30e4f6e..bed710592ae 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -316,9 +316,15 @@ describe("session cost usage", () => { const reset = sessions.find((session) => session.sessionId === "sess-reset"); const deleted = sessions.find((session) => session.sessionId === "sess-deleted"); const compound = sessions.find((session) => session.sessionId === "foo.jsonl.bar"); + const active = sessions.find((session) => session.sessionId === "sess-active"); expect(reset?.firstUserMessage).toContain("reset hello"); expect(deleted?.firstUserMessage).toContain("deleted hello"); expect(compound?.firstUserMessage).toContain("compound hello"); + // Archived transcripts should be flagged so callers use sessionFile directly + expect(reset?.archived).toBe(true); + expect(deleted?.archived).toBe(true); + expect(compound?.archived).toBe(true); + expect(active?.archived).toBeUndefined(); }); }); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 663739ca1ca..71025699aeb 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -441,6 +441,7 @@ export async function discoverAllSessions(params?: { // Extract session ID from filename, preserving base session id for archived reset/deleted transcripts. const sessionId = deriveSessionIdFromFilename(entry.name); + const archived = isArchivedUsageTranscriptFilename(entry.name); // Try to read first user message for label extraction let firstUserMessage: string | undefined; @@ -482,6 +483,7 @@ export async function discoverAllSessions(params?: { sessionFile: filePath, mtime: stats.mtimeMs, firstUserMessage, + ...(archived ? { archived } : {}), }); } diff --git a/src/infra/session-cost-usage.types.ts b/src/infra/session-cost-usage.types.ts index 70de453bcd9..cd33368d4de 100644 --- a/src/infra/session-cost-usage.types.ts +++ b/src/infra/session-cost-usage.types.ts @@ -143,6 +143,9 @@ export type DiscoveredSession = { sessionFile: string; mtime: number; firstUserMessage?: string; + /** When true, the transcript is an archived reset/deleted file. Callers + * should use `sessionFile` directly instead of resolving via `sessionId`. */ + archived?: boolean; }; export type SessionUsageTimePoint = SharedSessionUsageTimePoint; From 5dda42a49fcbb8f3e2dc9ccc07cdf069d1753342 Mon Sep 17 00:00:00 2001 From: robo7 Date: Sat, 14 Mar 2026 00:11:52 +0800 Subject: [PATCH 5/6] fix: enforce archive path containment when sessions dir is missing Use path.resolve(sessionsDir) as fallback when realpathSync fails, ensuring the traversal guard stays active even for nonexistent agent directories. Previously the guard was skipped entirely. --- src/gateway/server-methods/usage.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index e530029a647..865ab231d7e 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -90,13 +90,12 @@ function resolveSessionUsageFileOrRespond( try { realSessionsDir = fs.realpathSync(sessionsDir); } catch { - // Sessions directory doesn't exist for this agent — no archived file possible - // Fall through to normal resolution - realSessionsDir = ""; + // Sessions directory doesn't exist — use path.resolve as fallback base + // to still enforce containment against traversal attacks + realSessionsDir = path.resolve(sessionsDir); } const realSessionFile = path.resolve(sessionFile); if ( - realSessionsDir && !realSessionFile.startsWith(realSessionsDir + path.sep) && realSessionFile !== realSessionsDir ) { From 59087d5198e887282e5eafdb27e06a398752fd85 Mon Sep 17 00:00:00 2001 From: robo7 Date: Sat, 14 Mar 2026 00:23:24 +0800 Subject: [PATCH 6/6] =?UTF-8?q?style:=20fix=20remaining=20.sort()=20?= =?UTF-8?q?=E2=86=92=20.toSorted()=20in=20test=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/infra/session-cost-usage.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index bed710592ae..15a3c0c2e1d 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -311,7 +311,7 @@ describe("session cost usage", () => { const sessions = await discoverAllSessions({ startMs: new Date("2026-03-13T00:00:00.000Z").getTime(), }); - const ids = sessions.map((session) => session.sessionId).sort(); + const ids = sessions.map((session) => session.sessionId).toSorted(); expect(ids).toEqual(["foo.jsonl.bar", "sess-active", "sess-deleted", "sess-reset"]); const reset = sessions.find((session) => session.sessionId === "sess-reset"); const deleted = sessions.find((session) => session.sessionId === "sess-deleted");