From ba6fc1cfa3a17d0bfe92e214d8ddfd953ce94355 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 20:50:53 +0800 Subject: [PATCH] fix: harden session query authorization --- ...model-facing-session-query-tools.i18n.yaml | 4 +- ...-07-24-model-facing-session-query-tools.md | 6 +- ...-24-model-facing-session-query-tools.zh.md | 6 +- docs/config-catalog.md | 2 +- .../tool-session-query/README.md | 4 +- .../tool-session-query/src/index.ts | 296 +++++++--- .../tests/tool-session-query.spec.ts | 539 +++++++++++++++++- 7 files changed, 758 insertions(+), 99 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index cc975b746d..88363d0aca 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-model-facing-session-query-tools.md: 75aa8eef1b27ca64b49710ba055b33d091f514ad -2026-07-24-model-facing-session-query-tools.zh.md: a59f88b38ac88b9a88cb55e799847c283145205a +2026-07-24-model-facing-session-query-tools.md: aea490f3569dd95bffb6ebbaae5a130e6440c281 +2026-07-24-model-facing-session-query-tools.zh.md: eae100375fe7abf91ba3e503808a6d98b540255e diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index 75aa8eef1b..aea490f356 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -14,12 +14,14 @@ The unified `ctx.sessionQuery` service exposes exact reads, filters, relationshi `session_search` groups full-text matches by session and exposes typed session and event metadata filters. `session_event_search` searches one session, defaulting to the caller's current session. `session_trace` returns the complete authorized ancestor chain and recursive descendant trees. `session_event_trace` returns every known positional replacement and direct provenance relationship for one event. `session_event_read` returns the exact target event as unabridged JSON and optionally summarizes a bounded raw-event window; omitted `before` and `after` values mean target-only. -Model-facing filters use flat snake-case fields. Timestamps are timezone-qualified ISO 8601 strings at the tool boundary, convert to inclusive epoch-millisecond ranges for the service, and render as UTC ISO 8601. List values are ORed inside one filter while separate filters are ANDed. Parent ids and the root-session marker share one parent clause. Event type strings remain open because `SessionEventMap` is merge-extensible; availability and event surface use closed values. +Model-facing filters use flat snake-case fields. Timestamps are timezone-qualified ISO 8601 strings at the tool boundary, convert to inclusive epoch-millisecond ranges for the service, and render as UTC ISO 8601. List values are ORed inside one filter while separate filters are ANDed. Requested parent ids are deduplicated and authority-filtered before FTS, so only parents in the caller workspace enter the provider clause; missing and cross-workspace guesses behave identically, while the root-session marker remains independently ORed into that clause. Event type strings remain open because `SessionEventMap` is merge-extensible; availability and event surface use closed values. ## Workspace authority Every executor derives its caller from immutable `ToolExecution.exec.agent` identity and never accepts a model-supplied workspace. A target is authorized only when its observed `cwd` exactly equals the caller session's `cwd`. Cross-session search always adds that workspace filter. Direct operations preflight the target and then validate the header returned from the same service observation as every event-search page, event trace, event read, lineage target, or folded title before rendering its payload. This prevents a live or persisted target replacement between the check and use from crossing the workspace boundary. Lineage rendering stops at an unauthorized ancestor or descendant subtree without revealing the hidden session id. A caller whose session has no `cwd` can inspect only its own session; missing agent identity fails closed. +Every trusted `ctx.sessionQuery` call crosses one model-boundary sanitizer. It checks the execution signal first, preserving caller cancellation exactly. For other failures it records the available corpus or provider diagnostic chain in the internal log on a best-effort basis, substituting a fixed placeholder when the value cannot be safely inspected. Diagnostic formatting and error classification are independently guarded, so an unprintable nested cause cannot escape or prevent a safely classified outer error, while unsafe classification or logging returns the fixed generic `SESSION_QUERY_TOOL_FAILED` code and message. Per-title failures use the same sanitizer before becoming unavailable markers. Tool-owned input-validation and authorization errors remain precise because they are created outside this service boundary. + The search tools expose prior work rather than the operation that is performing the search. `session_search` omits the caller's session. When `session_event_search` targets the caller's session, it intersects the requested sequence range with the event immediately before the current `step/start`, excluding the current assistant message and tool call as well as the query arguments indexed from that call. ## Cursor-free results and spill @@ -44,7 +46,7 @@ The shipped ACP, TUI, and Web compositions all mount the consumer beside `ctx.se ## Verification -Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, changed-observation rejection, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, parent-filter preauthorization and oracle resistance, changed-observation rejection, service-diagnostic redaction for ordinary and adversarial unknown values, best-effort cyclic-cause logging, logger-failure containment, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index a59f88b38a..eae100375f 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -14,12 +14,14 @@ Status: implemented `session_search` 按会话聚合全文匹配,并公开带类型的会话与事件元数据过滤条件。`session_event_search` 搜索一个会话,默认目标为调用者的当前会话。`session_trace` 返回完整的已授权祖先链与递归后代树。`session_event_trace` 返回一个事件所有已知的位置替换关系与直接来源关系。`session_event_read` 以未删节 JSON 返回准确的目标事件,并可选择汇总一个有界的原始事件窗口;省略 `before` 与 `after` 时只返回目标。 -面向模型的过滤条件使用扁平的 snake-case 字段。工具边界上的时间戳采用带时区的 ISO 8601 字符串,转换为服务使用的闭区间毫秒时间戳,并以 UTC ISO 8601 渲染。同一个过滤条件中的列表值按 OR 组合,不同过滤条件按 AND 组合。父会话 id 与根会话标记共用一个父级条件。由于 `SessionEventMap` 可通过声明合并扩展,事件类型字符串保持开放;可用状态与事件表层使用封闭取值。 +面向模型的过滤条件使用扁平的 snake-case 字段。工具边界上的时间戳采用带时区的 ISO 8601 字符串,转换为服务使用的闭区间毫秒时间戳,并以 UTC ISO 8601 渲染。同一个过滤条件中的列表值按 OR 组合,不同过滤条件按 AND 组合。请求的父会话 id 会在 FTS 之前去重并按权限过滤,因此只有调用者工作区中的父会话会进入提供方条件;缺失与跨工作区的猜测具有相同行为,而根会话标记仍会独立按 OR 加入该条件。由于 `SessionEventMap` 可通过声明合并扩展,事件类型字符串保持开放;可用状态与事件表层使用封闭取值。 ## 工作区权限 每个执行器都从不可变的 `ToolExecution.exec.agent` 身份推导调用者,绝不接受模型提供的工作区。只有当目标观测中的 `cwd` 与调用者会话的 `cwd` 完全相同时,目标才获授权。跨会话搜索始终附加该工作区过滤条件。直接操作先预检目标,然后在渲染负载前,校验与每一页事件搜索结果、事件追踪、事件读取、谱系目标或折叠标题来自同一服务观测的会话头。这样,即使实时或持久化目标在检查与使用之间被替换,也无法跨越工作区边界。谱系渲染在遇到未授权的祖先或后代子树时停止,且不泄露被隐藏的会话 id。调用者会话没有 `cwd` 时只能检查自身会话;缺少 agent 身份时按失败关闭处理。 +每个受信任的 `ctx.sessionQuery` 调用都会经过同一个模型边界净化器。它首先检查执行信号,准确保留调用者取消。对于其他失败,它会尽力把可获得的语料或提供方诊断链写入内部日志;当值无法安全检查时,则改用固定占位符。诊断格式化与错误分类各自受到保护,因此无法打印的嵌套 cause 既不会逃逸,也不会阻止对外层错误进行安全分类;分类不安全或日志记录失败时,则返回固定的通用错误码 `SESSION_QUERY_TOOL_FAILED` 及其消息。逐标题失败也会先经过同一个净化器,再转为不可用标记。工具自身的输入校验与授权错误在该服务边界之外创建,因此仍保留精确消息。 + 搜索工具公开的是既往工作,而不是正在执行搜索的操作本身。`session_search` 排除调用者会话。`session_event_search` 以调用者会话为目标时,会把请求的序号范围与当前 `step/start` 之前的最后一个事件取交集,从而排除当前 assistant 消息、工具调用,以及从该次调用中建立索引的查询参数。 ## 无游标结果与 spill @@ -44,7 +46,7 @@ Status: implemented ## 验证 -包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、变更观测拒绝、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 ## 后果 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 700cd49e71..88d8f1bbaa 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1439,7 +1439,7 @@ export interface Config { } ``` -Source: [`packages/session-query/tool-session-query/src/index.ts:51`](../packages/session-query/tool-session-query/src/index.ts) +Source: [`packages/session-query/tool-session-query/src/index.ts:52`](../packages/session-query/tool-session-query/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` diff --git a/packages/session-query/tool-session-query/README.md b/packages/session-query/tool-session-query/README.md index 7b40527f62..504405d698 100644 --- a/packages/session-query/tool-session-query/README.md +++ b/packages/session-query/tool-session-query/README.md @@ -11,7 +11,9 @@ Workspace-authorized model tools over `ctx.sessionQuery`. The package depends on The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Because one search consumes generation-bound provider cursors internally, both search tools execute exclusively with sibling tool calls; the three exact trace/read tools opt into parallel execution. Every exact executor passes its unchanged execution signal through authorization and the service trace/read, so cancellation waits for cooperative persistence cleanup and retains the signal's exact reason. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters. -`session_search` always omits the caller session. A current-session `session_event_search` stops immediately before the step that invoked it, so the active assistant output and logged tool call cannot match themselves. Direct targets are authorized before trace, event, or title reads. Lineage output replaces unauthorized ancestor and descendant boundaries with markers that contain no hidden session id. +`session_search` always omits the caller session. Requested parent ids are deduplicated and checked against caller-workspace authority before FTS; only authorized ids reach the provider, while missing and cross-workspace guesses behave identically and the root marker remains independently ORed. A current-session `session_event_search` stops immediately before the step that invoked it, so the active assistant output and logged tool call cannot match themselves. Direct targets are authorized before trace, event, or title reads. Lineage output replaces unauthorized ancestor and descendant boundaries with markers that contain no hidden session id. + +Every trusted `ctx.sessionQuery` call crosses one model-boundary sanitizer. Caller cancellation is checked first and preserved exactly. Available corpus and provider diagnostics, including safely inspectable nested causes, are logged internally on a best-effort basis; unprintable failures use a fixed log placeholder. Diagnostic formatting and error classification are independently guarded, so an unprintable cause cannot escape or prevent a safely classified outer error, while unsafe classification or logging falls back to the fixed `SESSION_QUERY_TOOL_FAILED` code and message. Local argument-validation and authorization errors retain their precise tool-owned messages. The package deliberately performs no byte or character truncation and does not import a spill backend. Deployments that need bounded inline output mount `@deepseek-ai/dsh-spill-policy`, which can replace the rendered text after execution while retaining the complete result. diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index 0ffbeeb836..e05ab89218 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -29,6 +29,7 @@ import { type SessionLineageTrace, type SessionRecord, type SessionResultFilter, + type SessionQueryErrorCode, type SessionSearchCursor, type SessionSearchHit, } from '@deepseek-ai/dsh-session-query' @@ -196,6 +197,76 @@ const PROMPT_TEXT = + 'events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with ' + 'session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data.' +interface ModelSafeServiceFailure { + readonly code: SessionQueryErrorCode | 'SESSION_QUERY_TOOL_FAILED' + readonly message: string +} + +const UNPRINTABLE_SERVICE_ERROR = '[unprintable session query failure]' + +const SAFE_SESSION_QUERY_FAILURES = { + SESSION_QUERY_ABORTED: { + code: 'SESSION_QUERY_ABORTED', + message: 'session query was cancelled', + }, + SESSION_QUERY_EVENT_NOT_FOUND: { + code: 'SESSION_QUERY_EVENT_NOT_FOUND', + message: 'session event was not found', + }, + SESSION_QUERY_INDEX_FAILED: { + code: 'SESSION_QUERY_INDEX_FAILED', + message: 'session search index is unavailable', + }, + SESSION_QUERY_INVALID_CONFIG: { + code: 'SESSION_QUERY_TOOL_FAILED', + message: 'session query operation failed', + }, + SESSION_QUERY_INVALID_CURSOR: { + code: 'SESSION_QUERY_INVALID_CURSOR', + message: 'session search continuation is invalid', + }, + SESSION_QUERY_INVALID_FILTER: { + code: 'SESSION_QUERY_INVALID_FILTER', + message: 'session query filters were rejected', + }, + SESSION_QUERY_INVALID_LIMIT: { + code: 'SESSION_QUERY_INVALID_LIMIT', + message: 'session query result limit was rejected', + }, + SESSION_QUERY_INVALID_QUERY: { + code: 'SESSION_QUERY_INVALID_QUERY', + message: 'session query was rejected', + }, + SESSION_QUERY_INVALID_LINEAGE: { + code: 'SESSION_QUERY_INVALID_LINEAGE', + message: 'session lineage is invalid', + }, + SESSION_QUERY_INVALID_SURFACE: { + code: 'SESSION_QUERY_INVALID_SURFACE', + message: 'session event history is invalid', + }, + SESSION_QUERY_INVALID_WINDOW: { + code: 'SESSION_QUERY_INVALID_WINDOW', + message: 'session event window is invalid', + }, + SESSION_QUERY_PERSISTENCE_FAILED: { + code: 'SESSION_QUERY_PERSISTENCE_FAILED', + message: 'session history storage is unavailable', + }, + SESSION_QUERY_SESSION_NOT_FOUND: { + code: 'SESSION_QUERY_SESSION_NOT_FOUND', + message: 'session was not found', + }, + SESSION_QUERY_STALE_CURSOR: { + code: 'SESSION_QUERY_STALE_CURSOR', + message: 'session history changed while paging; retry the complete search call', + }, + SESSION_QUERY_SOURCE_CONFLICT: { + code: 'SESSION_QUERY_TOOL_FAILED', + message: 'session query operation failed', + }, +} satisfies Record + /** Register all five tools and their shared model guidance. */ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) @@ -306,12 +377,11 @@ async function authorizeTarget( if (target === caller.id) return const cwd = caller.header.cwd if (cwd === undefined) throw unauthorizedTarget() - signal.throwIfAborted() - const records = await ctx.sessionQuery.filterSessions([ - { kind: 'id', values: [target] }, - { kind: 'cwd', values: [cwd] }, - ], signal) - signal.throwIfAborted() + const records = await sessionQueryCall(ctx, signal, 'target authorization', () => + ctx.sessionQuery.filterSessions([ + { kind: 'id', values: [target] }, + { kind: 'cwd', values: [cwd] }, + ], signal)) if (records.length !== 1) throw unauthorizedTarget() } @@ -322,6 +392,57 @@ function unauthorizedTarget(): HarnessError { ) } +async function sessionQueryCall( + ctx: Context, + signal: AbortSignal, + operation: string, + call: () => Promise, +): Promise { + signal.throwIfAborted() + try { + const value = await call() + signal.throwIfAborted() + return value + } catch (error: unknown) { + signal.throwIfAborted() + throw sanitizeSessionQueryError(ctx, operation, error) + } +} + +function sanitizeSessionQueryError( + ctx: Context, + operation: string, + error: unknown, +): HarnessError { + const generic = genericSessionQueryFailure() + const diagnostic = fullError(error) + try { + ctx.logger.warn(`tool-session-query: ${operation} failed: ${diagnostic}`) + if (error instanceof SessionQueryError) { + const code: unknown = error.code + const failure = typeof code === 'string' && Object.hasOwn(SAFE_SESSION_QUERY_FAILURES, code) + ? SAFE_SESSION_QUERY_FAILURES[code as SessionQueryErrorCode] + : undefined + if (failure !== undefined && failure.code !== 'SESSION_QUERY_TOOL_FAILED') { + return new SessionQueryError(failure.message, failure.code) + } + } + if (error instanceof HarnessError && error.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') { + return unauthorizedTarget() + } + } catch { + return generic + } + return generic +} + +function genericSessionQueryFailure(): HarnessError { + return new HarnessError( + 'session query operation failed', + 'SESSION_QUERY_TOOL_FAILED', + ) +} + async function executeSessionSearch( ctx: Context, args: SessionSearchArgs, @@ -338,7 +459,6 @@ async function executeSessionSearch( } const query = normalizeQuery(args.query) const sessionFilters = buildSessionFilters(args) - sessionFilters.push({ kind: 'cwd', values: [cwd] }) const eventFilters = buildEventFilters({ seqFrom: args.event_seq_from, seqTo: args.event_seq_to, @@ -347,15 +467,28 @@ async function executeSessionSearch( eventTypes: args.event_types, surfaces: args.event_surfaces, }) + const requestedParentIds = materializeParentSessionIds(args.parent_session_ids) + if (requestedParentIds !== undefined || args.include_root_sessions === true) { + const authorizedParentIds = requestedParentIds === undefined + ? new Set() + : await authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal) + const parentValues: Array = requestedParentIds + ?.filter(id => authorizedParentIds.has(id)) ?? [] + if (args.include_root_sessions === true) parentValues.push(null) + if (parentValues.length === 0) return formatEmptySessionSearch() + sessionFilters.push({ kind: 'parent', values: parentValues }) + } + sessionFilters.push({ kind: 'cwd', values: [cwd] }) const collected = await collectPages( maxResults, exec.signal, - cursor => ctx.sessionQuery.searchSessions({ - query, - sessionFilters, - eventFilters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal }), + cursor => sessionQueryCall(ctx, exec.signal, 'session search', () => + ctx.sessionQuery.searchSessions({ + query, + sessionFilters, + eventFilters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal })), hit => hit.header.id !== caller.id && recordAuthorized(hit, caller), ) @@ -404,12 +537,13 @@ async function executeEventSearch( maxResults, exec.signal, async (cursor): Promise => { - const page = await ctx.sessionQuery.searchEvents({ - sessionId, - query, - filters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal }) + const page = await sessionQueryCall(ctx, exec.signal, 'event search', () => + ctx.sessionQuery.searchEvents({ + sessionId, + query, + filters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal })) assertObservedTargetAuthorized(caller, sessionId, page.session) return page }, @@ -426,20 +560,8 @@ async function executeSessionTrace( const caller = callerOf(exec) const sessionId = targetId(args, caller) await authorizeTarget(ctx, caller, sessionId, exec.signal) - let trace: SessionLineageTrace - try { - trace = await ctx.sessionQuery.traceSession(sessionId, exec.signal) - } catch (error: unknown) { - exec.signal.throwIfAborted() - if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_INVALID_LINEAGE') { - throw new SessionQueryError( - 'session lineage is invalid', - 'SESSION_QUERY_INVALID_LINEAGE', - ) - } - throw error - } - exec.signal.throwIfAborted() + const trace = await sessionQueryCall(ctx, exec.signal, 'session lineage trace', () => + ctx.sessionQuery.traceSession(sessionId, exec.signal)) assertObservedTargetAuthorized(caller, sessionId, trace.target.header) const ancestors: SessionRecord[] = [] @@ -471,8 +593,8 @@ async function executeEventTrace( const caller = callerOf(exec) const sessionId = targetId(args, caller) await authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }, exec.signal) - exec.signal.throwIfAborted() + const trace = await sessionQueryCall(ctx, exec.signal, 'event trace', () => + ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }, exec.signal)) assertObservedTargetAuthorized(caller, sessionId, trace.session) const title = await readTitle(ctx, caller, sessionId, exec.signal) return formatEventTrace(sessionId, title, trace) @@ -489,13 +611,13 @@ async function executeEventRead( const caller = callerOf(exec) const sessionId = targetId(args, caller) await authorizeTarget(ctx, caller, sessionId, exec.signal) - const window = await ctx.sessionQuery.readEvent({ - sessionId, - seq: args.seq, - ...args.before === undefined ? {} : { before: args.before }, - ...args.after === undefined ? {} : { after: args.after }, - }, exec.signal) - exec.signal.throwIfAborted() + const window = await sessionQueryCall(ctx, exec.signal, 'event read', () => + ctx.sessionQuery.readEvent({ + sessionId, + seq: args.seq, + ...args.before === undefined ? {} : { before: args.before }, + ...args.after === undefined ? {} : { after: args.after }, + }, exec.signal)) assertObservedTargetAuthorized(caller, sessionId, window.session) const title = await readTitle(ctx, caller, sessionId, exec.signal) return formatEventRead(sessionId, title, window) @@ -509,15 +631,6 @@ function buildSessionFilters(args: SessionSearchArgs): SessionResultFilter[] { } const created = timestampRange('created_at', args.created_at_from, args.created_at_to) if (created !== undefined) filters.push({ kind: 'created-at', ...created }) - if (args.parent_session_ids !== undefined || args.include_root_sessions === true) { - const values: Array = [] - if (args.parent_session_ids !== undefined) { - assertNonEmptyArray('parent_session_ids', args.parent_session_ids) - values.push(...args.parent_session_ids.map(SessionId)) - } - if (args.include_root_sessions === true) values.push(null) - filters.push({ kind: 'parent', values }) - } if (args.availability !== undefined) { assertNonEmptyArray('availability', args.availability) filters.push({ kind: 'availability', values: args.availability }) @@ -525,6 +638,12 @@ function buildSessionFilters(args: SessionSearchArgs): SessionResultFilter[] { return filters } +function materializeParentSessionIds(values: readonly string[] | undefined): SessionIdValue[] | undefined { + if (values === undefined) return undefined + assertNonEmptyArray('parent_session_ids', values) + return [...new Set(values.map(SessionId))] +} + interface EventFilterInput { readonly seqFrom?: number | undefined readonly seqTo?: number | undefined @@ -737,19 +856,7 @@ async function collectPages( let cursor: SessionSearchCursor | undefined while (true) { signal.throwIfAborted() - let page: Awaited> - try { - page = await request(cursor) - } catch (error: unknown) { - if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_STALE_CURSOR') { - throw new SessionQueryError( - 'session history changed while paging; retry the complete search call', - 'SESSION_QUERY_STALE_CURSOR', - { cause: error }, - ) - } - throw error - } + const page = await request(cursor) signal.throwIfAborted() for (const item of page.items) { if (!accept(item)) continue @@ -799,13 +906,17 @@ async function authorizeSessionIds( const cwd = caller.header.cwd const other = unique.filter(id => id !== caller.id) if (cwd === undefined || other.length === 0) return authorized - signal.throwIfAborted() - const records = await ctx.sessionQuery.filterSessions([ - { kind: 'id', values: other }, - { kind: 'cwd', values: [cwd] }, - ], signal) - signal.throwIfAborted() - for (const record of records) authorized.add(record.header.id) + const records = await sessionQueryCall(ctx, signal, 'session-id authorization', () => + ctx.sessionQuery.filterSessions([ + { kind: 'id', values: other }, + { kind: 'cwd', values: [cwd] }, + ], signal)) + const requested = new Set(other) + for (const record of records) { + if (requested.has(record.header.id) && recordAuthorized(record, caller)) { + authorized.add(record.header.id) + } + } return authorized } @@ -816,12 +927,11 @@ async function readTitles( signal: AbortSignal, ): Promise { const result = new Map() - signal.throwIfAborted() - const observations = await ctx.sessionQuery.readTitleSnapshots(ids, signal) - signal.throwIfAborted() + const observations = await sessionQueryCall(ctx, signal, 'title observation', () => + ctx.sessionQuery.readTitleSnapshots(ids, signal)) for (const observation of observations) { if (observation.status === 'rejected') { - result.set(observation.sessionId, unavailableTitle(ctx, observation.sessionId, observation.reason)) + result.set(observation.sessionId, unavailableTitle(ctx, observation.reason)) continue } assertObservedTargetAuthorized(caller, observation.sessionId, observation.value.session) @@ -841,17 +951,35 @@ async function readTitle( function unavailableTitle( ctx: Context, - id: SessionIdValue, error: unknown, ): TitleView { - if (error instanceof HarnessError && error.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw error - const code = error instanceof HarnessError ? error.code : 'UNKNOWN' - ctx.logger.warn(`tool-session-query: title read failed for session "${id}": ${fullError(error)}`) - return { text: 'untitled', unavailableCode: code } + const sanitized = sanitizeSessionQueryError(ctx, 'title observation item', error) + if (sanitized.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw sanitized + return { text: 'untitled', unavailableCode: sanitized.code } } function fullError(error: unknown): string { - return error instanceof Error ? error.stack ?? String(error) : String(error) + try { + return renderFullError(error) + } catch { + return UNPRINTABLE_SERVICE_ERROR + } +} + +function renderFullError(error: unknown): string { + if (!(error instanceof Error)) return String(error) + const diagnostics: string[] = [] + const seen = new Set() + let current: unknown = error + while (current instanceof Error && !seen.has(current)) { + seen.add(current) + diagnostics.push(current.stack ?? String(current)) + current = current.cause + } + /* v8 ignore next -- defensive containment for a cyclic Error.cause graph */ + if (current instanceof Error) diagnostics.push('[circular error cause]') + else if (current !== undefined) diagnostics.push(renderFullError(current)) + return diagnostics.join('\nCaused by: ') } function authorizeDescendants( @@ -927,7 +1055,7 @@ function formatSessionSearch( titles: CompleteTitleMap, authorizedParents: ReadonlySet, ): string { - if (collected.items.length === 0) return 'No prior session matches found.' + if (collected.items.length === 0) return formatEmptySessionSearch() const lines = [`Session search results (${collected.items.length}):`] for (const [index, hit] of collected.items.entries()) { const parent = hit.header.parentSession === undefined @@ -955,6 +1083,10 @@ function formatSessionSearch( return lines.join('\n') } +function formatEmptySessionSearch(): string { + return 'No prior session matches found.' +} + function formatEventSearch( sessionId: SessionIdValue, title: TitleView, diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index d52a4d48aa..35ab1fa408 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -363,6 +363,7 @@ describe('input validation and translation', () => { it('normalizes the query and compiles inclusive session/event filters with one parent OR clause', async () => { const mounted = await mount() + createSession(mounted.ctx, 'parent', '/work') await mounted.call('session_search', { query: ' alpha beta ', session_ids: ['a', 'b'], @@ -388,8 +389,8 @@ describe('input validation and translation', () => { from: Date.parse('2026-07-24T00:00:00+08:00'), to: Date.parse('2026-07-24T01:00:00+08:00'), }, - { kind: 'parent', values: ['parent', null] }, { kind: 'availability', values: ['live'] }, + { kind: 'parent', values: ['parent', null] }, { kind: 'cwd', values: ['/work'] }, ], eventFilters: [ @@ -538,6 +539,7 @@ describe('input validation and translation', () => { it('compiles one-sided timestamps and independent root/parent clauses', async () => { const mounted = await mount() + createSession(mounted.ctx, 'parent', '/work') await mounted.call('session_search', { query: 'q', created_at_from: '2024-02-29T00:00Z', @@ -596,6 +598,191 @@ describe('workspace authority and lineage redaction', () => { .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') }) + it('makes hidden and nonexistent parent guesses indistinguishable without calling search', async () => { + const mounted = await mount() + const hiddenParent = createSession(mounted.ctx, 'guessed-hidden-parent-secret', '/outside') + const visibleChild = createSession( + mounted.ctx, + 'visible-child-of-hidden-parent', + '/work', + 20, + hiddenParent.id, + ) + FakeQuery.sessionSearch = () => Promise.resolve({ + items: [sessionHit(visibleChild.id, '/work', 'must not be discoverable', hiddenParent.id)], + }) + + const hidden = await mounted.call('session_search', { + query: 'needle', + parent_session_ids: [hiddenParent.id], + }) + const missing = await mounted.call('session_search', { + query: 'needle', + parent_session_ids: ['guessed-missing-parent'], + }) + + expect(hidden).toEqual(missing) + expect(text(hidden)).toBe('No prior session matches found.') + expect(JSON.stringify(hidden)).not.toContain(visibleChild.id) + expect(FakeQuery.sessionRequests).toEqual([]) + }) + + it('deduplicates parent guesses and sends only authorized parents plus the root marker', async () => { + const mounted = await mount() + const visible = createSession(mounted.ctx, 'visible-parent', '/work') + const hidden = createSession(mounted.ctx, 'hidden-parent-filter-secret', '/outside') + + await mounted.call('session_search', { + query: 'needle', + parent_session_ids: [visible.id, hidden.id, visible.id, 'missing-parent'], + include_root_sessions: true, + }) + await mounted.call('session_search', { + query: 'needle', + parent_session_ids: [hidden.id], + include_root_sessions: true, + }) + await mounted.call('session_search', { + query: 'needle', + parent_session_ids: ['missing-parent'], + include_root_sessions: true, + }) + + const parentValues = FakeQuery.sessionRequests.map(request => + request.sessionFilters?.find(filter => filter.kind === 'parent')) + expect(parentValues).toEqual([ + { kind: 'parent', values: [visible.id, null] }, + { kind: 'parent', values: [null] }, + { kind: 'parent', values: [null] }, + ]) + }) + + it('rejects unrequested or unauthorized records returned during parent preauthorization', async () => { + const mounted = await mount() + const requested = SessionId('requested-parent') + vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions').mockResolvedValueOnce([ + { header: header('unrequested-parent', '/work'), live: true, persisted: false }, + { header: header(requested, '/outside'), live: true, persisted: false }, + ]) + + const result = await mounted.call('session_search', { + query: 'needle', + parent_session_ids: [requested], + }) + + expect(text(result)).toBe('No prior session matches found.') + expect(FakeQuery.sessionRequests).toEqual([]) + }) + + it('validates every other search filter before parent preauthorization', async () => { + const mounted = await mount() + const filterSessions = vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions') + + const result = await mounted.call('session_search', { + query: 'needle', + parent_session_ids: ['guessed-parent'], + event_seq_from: -1, + }) + + expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER') + expect(filterSessions).not.toHaveBeenCalled() + expect(FakeQuery.sessionRequests).toEqual([]) + }) + + it('sanitizes parent preauthorization failures without calling search', async () => { + const mounted = await mount() + const secret = 'conflict at hidden-parent-preauthorization-secret' + vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions').mockRejectedValueOnce( + new SessionQueryError(secret, 'SESSION_QUERY_SOURCE_CONFLICT'), + ) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call('session_search', { + query: 'needle', + parent_session_ids: ['guessed-parent'], + }) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED') + expect(text(result)).toBe('Error: session query operation failed') + expect(JSON.stringify(result)).not.toContain(secret) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret)) + expect(FakeQuery.sessionRequests).toEqual([]) + }) + + it('sanitizes direct-target authorization failures before event search', async () => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'authorization-failure-target', '/work') + const secret = 'conflict with hidden-authorization-session-secret' + vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions').mockRejectedValueOnce( + new SessionQueryError(secret, 'SESSION_QUERY_SOURCE_CONFLICT'), + ) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call('session_event_search', { + session_id: target.id, + query: 'needle', + }) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED') + expect(text(result)).toBe('Error: session query operation failed') + expect(JSON.stringify(result)).not.toContain(secret) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret)) + expect(FakeQuery.eventRequests).toEqual([]) + }) + + it('preserves parent-preauthorization cancellation and waits for cleanup without logging it', async () => { + const mounted = await mount() + const controller = new AbortController() + const cancellation = new SessionQueryError( + 'parent preauthorization cancelled', + 'SESSION_QUERY_ABORTED', + ) + const started = Promise.withResolvers() + const abortObserved = Promise.withResolvers() + const cleanup = Promise.withResolvers() + let active = false + vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions') + .mockImplementation(async (_filters, signal) => { + if (signal === undefined) throw new Error('expected parent-authorization signal') + active = true + const aborted = new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + started.resolve(undefined) + await aborted + abortObserved.resolve(undefined) + await cleanup.promise + active = false + signal.throwIfAborted() + return [] + }) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const pending = mounted.call('session_search', { + query: 'needle', + parent_session_ids: ['guessed-parent'], + }, { signal: controller.signal }) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + await started.promise + controller.abort(cancellation) + await abortObserved.promise + + expect(settled).toBe(false) + expect(active).toBe(true) + expect(FakeQuery.sessionRequests).toEqual([]) + + cleanup.resolve(undefined) + const result = await pending + expect(active).toBe(false) + expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED') + expect(text(result)).toBe('Error: parent preauthorization cancelled') + expect(warn).not.toHaveBeenCalled() + }) + it('redacts an unauthorized ancestor and prunes unauthorized descendant subtrees without hidden ids', async () => { const mounted = await mount() const hiddenParent = createSession(mounted.ctx, 'hidden-parent-secret', '/outside') @@ -635,6 +822,16 @@ describe('workspace authority and lineage redaction', () => { }) it.each([ + { + name: 'sensitive source conflict', + makeError: () => new SessionQueryError( + 'conflict with hidden-lineage-session-secret', + 'SESSION_QUERY_SOURCE_CONFLICT', + ), + code: 'SESSION_QUERY_TOOL_FAILED', + message: 'session query operation failed', + secret: 'hidden-lineage-session-secret', + }, { name: 'typed query error', makeError: () => new SessionQueryError( @@ -642,23 +839,56 @@ describe('workspace authority and lineage redaction', () => { 'SESSION_QUERY_PERSISTENCE_FAILED', ), code: 'SESSION_QUERY_PERSISTENCE_FAILED', - message: 'unrelated persistence failure', + message: 'session history storage is unavailable', + secret: 'unrelated persistence failure', }, { name: 'plain error', makeError: () => new Error('unrelated plain trace failure'), - code: undefined, - message: 'unrelated plain trace failure', + code: 'SESSION_QUERY_TOOL_FAILED', + message: 'session query operation failed', + secret: 'unrelated plain trace failure', }, - ])('preserves an unrelated $name from lineage tracing', async ({ makeError, code, message }) => { + ])('sanitizes an unrelated $name from lineage tracing', async ({ makeError, code, message, secret }) => { const mounted = await mount() const target = createSession(mounted.ctx, 'trace-failure-target', '/work') + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockRejectedValueOnce(makeError()) const result = await mounted.call('session_trace', { session_id: target.id }) expect(errorCode(result)).toBe(code) expect(text(result)).toBe(`Error: ${message}`) + expect(JSON.stringify(result)).not.toContain(secret) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret)) + }) + + it.each([ + 'session_event_trace', + 'session_event_read', + ] as const)('sanitizes typed service diagnostics from %s', async (toolName) => { + const mounted = await mount() + const target = createSession(mounted.ctx, `${toolName}-failure-target`, '/work') + target.append( + 'user/message', + { content: [{ type: 'text', text: 'event' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + const secret = `event missing beside hidden-${toolName}-secret` + const failure = new SessionQueryError(secret, 'SESSION_QUERY_EVENT_NOT_FOUND') + if (toolName === 'session_event_trace') { + vi.spyOn(mounted.ctx.sessionQuery, 'traceEvent').mockRejectedValueOnce(failure) + } else { + vi.spyOn(mounted.ctx.sessionQuery, 'readEvent').mockRejectedValueOnce(failure) + } + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call(toolName, { session_id: target.id, seq: 0 }) + + expect(errorCode(result)).toBe('SESSION_QUERY_EVENT_NOT_FOUND') + expect(text(result)).toBe('Error: session event was not found') + expect(JSON.stringify(result)).not.toContain(secret) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret)) }) it.each([ @@ -681,6 +911,7 @@ describe('workspace authority and lineage redaction', () => { const started = Promise.withResolvers() const abortObserved = Promise.withResolvers() const cleanup = Promise.withResolvers() + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) let observedSignal: AbortSignal | undefined let active = false const holdExactRead = async (signal?: AbortSignal): Promise => { @@ -731,6 +962,7 @@ describe('workspace authority and lineage redaction', () => { expect(active).toBe(false) expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED') expect(text(result)).toBe(`Error: ${toolName} cancelled`) + expect(warn).not.toHaveBeenCalled() }) it('preserves caller cancellation while a lineage trace is pending', async () => { @@ -1052,6 +1284,239 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => expect(output).not.toContain('Result cap reached') }) + it.each([ + { + toolName: 'session_search', + args: { query: 'needle' }, + secrets: [ + 'session source conflict at hidden-search-session-secret', + 'hidden-search-cause-secret', + ], + failure: () => new SessionQueryError( + 'session source conflict at hidden-search-session-secret', + 'SESSION_QUERY_SOURCE_CONFLICT', + { cause: new Error('hidden-search-cause-secret') }, + ), + }, + { + toolName: 'session_event_search', + args: { query: 'needle' }, + secrets: [ + 'plain event provider failure at hidden-event-session-secret', + 'hidden-event-cause-secret', + ], + failure: () => new Error( + 'plain event provider failure at hidden-event-session-secret', + { cause: 'hidden-event-cause-secret' }, + ), + }, + ] as const)('sanitizes $toolName provider diagnostics', async ({ toolName, args, secrets, failure }) => { + const mounted = await mount() + if (toolName === 'session_search') { + FakeQuery.sessionSearch = () => Promise.reject(failure()) + } else { + FakeQuery.eventSearch = () => Promise.reject(failure()) + } + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call(toolName, args) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED') + expect(text(result)).toBe('Error: session query operation failed') + for (const secret of secrets) { + expect(JSON.stringify(result)).not.toContain(secret) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret)) + } + }) + + it.each([ + { + name: 'a hostile prototype trap', + secrets: ['proxy payload secret', 'getPrototypeOf secondary secret'], + diagnostic: '[unprintable session query failure]', + failure: (): unknown => new Proxy( + { payload: 'proxy payload secret' }, + { + getPrototypeOf() { + throw new Error('getPrototypeOf secondary secret') + }, + }, + ), + }, + { + name: 'a throwing stack getter', + secrets: ['stack primary secret', 'stack getter secondary secret'], + diagnostic: '[unprintable session query failure]', + failure: (): unknown => { + const error = new Error('stack primary secret') + Object.defineProperty(error, 'stack', { + get() { + throw new Error('stack getter secondary secret') + }, + }) + return error + }, + }, + { + name: 'a throwing cause getter', + secrets: ['cause primary secret', 'cause getter secondary secret'], + diagnostic: '[unprintable session query failure]', + failure: (): unknown => { + const error = new Error('cause primary secret') + Object.defineProperty(error, 'cause', { + get() { + throw new Error('cause getter secondary secret') + }, + }) + return error + }, + }, + { + name: 'throwing string coercion', + secrets: ['string payload secret', 'string coercion secondary secret'], + diagnostic: '[unprintable session query failure]', + failure: (): unknown => ({ + payload: 'string payload secret', + [Symbol.toPrimitive]() { + throw new Error('string coercion secondary secret') + }, + }), + }, + { + name: 'a throwing code getter', + secrets: ['code primary secret', 'code getter secondary secret'], + diagnostic: 'code primary secret', + failure: (): unknown => { + const error = new SessionQueryError( + 'code primary secret', + 'SESSION_QUERY_PERSISTENCE_FAILED', + ) + Object.defineProperty(error, 'code', { + get() { + throw new Error('code getter secondary secret') + }, + }) + return error + }, + }, + { + name: 'an unknown string code', + secrets: ['unknown code primary secret', '__proto__'], + diagnostic: 'unknown code primary secret', + failure: (): unknown => { + const error = new SessionQueryError( + 'unknown code primary secret', + 'SESSION_QUERY_PERSISTENCE_FAILED', + ) + Object.defineProperty(error, 'code', { value: '__proto__' }) + return error + }, + }, + { + name: 'a non-string code', + secrets: ['non-string code primary secret', 'non-string code secondary secret'], + diagnostic: 'non-string code primary secret', + failure: (): unknown => { + const error = new SessionQueryError( + 'non-string code primary secret', + 'SESSION_QUERY_PERSISTENCE_FAILED', + ) + Object.defineProperty(error, 'code', { + value: { + toString() { + throw new Error('non-string code secondary secret') + }, + }, + }) + return error + }, + }, + ])('fails generic when inspecting $name is unsafe', async ({ secrets, diagnostic, failure }) => { + const mounted = await mount() + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- hostile unknown rejection is the scenario + FakeQuery.sessionSearch = () => Promise.reject(failure()) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call('session_search', { query: 'needle' }) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED') + expect(text(result)).toBe('Error: session query operation failed') + for (const secret of secrets) expect(JSON.stringify(result)).not.toContain(secret) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(diagnostic)) + }) + + it('retains a fixed safe typed failure when only its nested diagnostic is unprintable', async () => { + const mounted = await mount() + const primary = 'typed outer diagnostic secret' + const nested = 'nested prototype secondary secret' + const cause = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error(nested) + }, + }, + ) + FakeQuery.sessionSearch = () => Promise.reject( + new SessionQueryError( + primary, + 'SESSION_QUERY_PERSISTENCE_FAILED', + { cause }, + ), + ) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call('session_search', { query: 'needle' }) + + expect(errorCode(result)).toBe('SESSION_QUERY_PERSISTENCE_FAILED') + expect(text(result)).toBe('Error: session history storage is unavailable') + expect(JSON.stringify(result)).not.toContain(primary) + expect(JSON.stringify(result)).not.toContain(nested) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[unprintable session query failure]')) + }) + + it('logs an inspectable cyclic cause chain without exposing it', async () => { + const mounted = await mount() + const outer = new Error('cyclic outer secret') + const inner = new Error('cyclic inner secret') + Object.defineProperty(outer, 'cause', { value: inner }) + Object.defineProperty(inner, 'cause', { value: outer }) + FakeQuery.sessionSearch = () => Promise.reject(outer) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call('session_search', { query: 'needle' }) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED') + expect(text(result)).toBe('Error: session query operation failed') + expect(JSON.stringify(result)).not.toContain('cyclic outer secret') + expect(JSON.stringify(result)).not.toContain('cyclic inner secret') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cyclic outer secret')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cyclic inner secret')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[circular error cause]')) + }) + + it('fails generic when internal warning logging throws', async () => { + const mounted = await mount() + const primary = 'typed persistence primary secret' + const secondary = 'logger warning secondary secret' + FakeQuery.sessionSearch = () => Promise.reject( + new SessionQueryError(primary, 'SESSION_QUERY_PERSISTENCE_FAILED'), + ) + const warn = vi.spyOn(mounted.ctx.logger, 'warn') + .mockImplementation(() => undefined) + .mockImplementationOnce(() => { + throw new Error(secondary) + }) + + const result = await mounted.call('session_search', { query: 'needle' }) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED') + expect(text(result)).toBe('Error: session query operation failed') + expect(JSON.stringify(result)).not.toContain(primary) + expect(JSON.stringify(result)).not.toContain(secondary) + expect(warn).toHaveBeenCalledTimes(1) + }) + it('preserves stale-cursor diagnostics without transparently restarting', async () => { const mounted = await mount({ maxSearchResults: 2 }) const cursor = SessionSearchCursor('stale-next') @@ -1070,6 +1535,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => FakeQuery.sessionSearch = () => Promise.resolve({ items: [], nextCursor: cursor }) const result = await mounted.call('session_search', { query: 'needle' }) expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_CURSOR') + expect(text(result)).toBe('Error: session-search provider repeated a continuation cursor') expect(FakeQuery.sessionRequests).toHaveLength(2) }) @@ -1166,7 +1632,8 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) const result = await mounted.call('session_search', { query: 'needle' }) expect(result.isError).toBe(false) - expect(text(result)).toContain('untitled (title unavailable: TITLE_BACKEND)') + expect(text(result)).toContain('untitled (title unavailable: SESSION_QUERY_TOOL_FAILED)') + expect(JSON.stringify(result)).not.toContain('title backend failed') expect(warn).toHaveBeenCalledWith(expect.stringContaining('title backend failed')) expect(warn).toHaveBeenCalledWith(expect.stringContaining('HarnessError')) }) @@ -1174,7 +1641,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => it('reports unknown title failures and preserves an Error without a stack', async () => { const mounted = await mount() const first = createSession(mounted.ctx, 'unknown-title', '/work') - const second = createSession(mounted.ctx, 'stackless-title', '/work') + const second = createSession(mounted.ctx, 'second-title-failure', '/work') const stackless = new Error('stackless') Object.defineProperty(stackless, 'stack', { value: undefined }) const readTitles = vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots') @@ -1190,13 +1657,62 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => }) const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) const result = await mounted.call('session_search', { query: 'needle' }) - expect(text(result)).toContain('title unavailable: UNKNOWN') + expect(text(result)).toContain('title unavailable: SESSION_QUERY_TOOL_FAILED') + expect(JSON.stringify(result)).not.toContain('string failure') + expect(JSON.stringify(result)).not.toContain('stackless') expect(readTitles).toHaveBeenCalledTimes(1) expect(readTitles.mock.calls[0]?.[0]).toEqual([first.id, second.id]) expect(warn).toHaveBeenCalledWith(expect.stringContaining('string failure')) expect(warn).toHaveBeenCalledWith(expect.stringContaining('Error: stackless')) }) + it('isolates an unprintable per-title failure behind the generic unavailable marker', async () => { + const mounted = await mount() + const hit = createSession(mounted.ctx, 'hostile-title-failure', '/work') + const primary = 'per-title proxy payload secret' + const secondary = 'per-title prototype secondary secret' + const reason = new Proxy( + { payload: primary }, + { + getPrototypeOf() { + throw new Error(secondary) + }, + }, + ) + FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] }) + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValueOnce([{ + sessionId: hit.id, + status: 'rejected', + reason, + }]) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call('session_search', { query: 'needle' }) + + expect(result.isError).toBe(false) + expect(text(result)).toContain('untitled (title unavailable: SESSION_QUERY_TOOL_FAILED)') + expect(JSON.stringify(result)).not.toContain(primary) + expect(JSON.stringify(result)).not.toContain(secondary) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[unprintable session query failure]')) + }) + + it('sanitizes a thrown batch-title service failure instead of rendering its diagnostic', async () => { + const mounted = await mount() + const hit = createSession(mounted.ctx, 'thrown-title-failure', '/work') + const secret = 'title batch failed beside hidden-title-session-secret' + FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] }) + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots') + .mockRejectedValueOnce(new Error(secret)) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call('session_search', { query: 'needle' }) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED') + expect(text(result)).toBe('Error: session query operation failed') + expect(JSON.stringify(result)).not.toContain(secret) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret)) + }) + it('does not downgrade cancellation during title enrichment', async () => { const mounted = await mount() const hit = createSession(mounted.ctx, 'abort-title', '/work') @@ -1237,6 +1753,8 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const result = await mounted.call('session_search', { query: 'needle' }) expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + expect(text(result)).toBe('Error: session target is outside the caller workspace') + expect(JSON.stringify(result)).not.toContain('title observation became unauthorized') expect(text(result)).not.toContain('title unavailable') }) @@ -1360,6 +1878,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => it('passes the exact execution signal to every FTS page and stops on cancellation', async () => { const mounted = await mount() const controller = new AbortController() + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) let started!: () => void const bodyStarted = new Promise((resolve) => { started = resolve }) FakeQuery.sessionSearch = (_request, exec) => new Promise((_resolve, reject) => { @@ -1368,13 +1887,15 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => reject(new SessionQueryError('aborted', 'SESSION_QUERY_ABORTED')) }, { once: true }) }) + const cancellation = new SessionQueryError('aborted', 'SESSION_QUERY_ABORTED') const pending = mounted.call('session_search', { query: 'needle' }, { signal: controller.signal }) await bodyStarted - controller.abort() + controller.abort(cancellation) const result = await pending expect(result.isError).toBe(true) expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED') expect(FakeQuery.searchSignals).toEqual([controller.signal]) + expect(warn).not.toHaveBeenCalled() }) })