fix: harden session query authorization
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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<SessionQueryErrorCode, ModelSafeServiceFailure>
|
||||
|
||||
/** 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<Value>(
|
||||
ctx: Context,
|
||||
signal: AbortSignal,
|
||||
operation: string,
|
||||
call: () => Promise<Value>,
|
||||
): Promise<Value> {
|
||||
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<SessionIdValue>()
|
||||
: await authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal)
|
||||
const parentValues: Array<SessionIdValue | null> = 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<SessionEventSearchPage> => {
|
||||
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<SessionIdValue | null> = []
|
||||
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<T>(
|
||||
let cursor: SessionSearchCursor | undefined
|
||||
while (true) {
|
||||
signal.throwIfAborted()
|
||||
let page: Awaited<ReturnType<typeof request>>
|
||||
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<CompleteTitleMap> {
|
||||
const result = new Map<SessionIdValue, TitleView>()
|
||||
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<Error>()
|
||||
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<SessionIdValue>,
|
||||
): 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,
|
||||
|
||||
@@ -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<undefined>()
|
||||
const abortObserved = Promise.withResolvers<undefined>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
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<void>((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<undefined>()
|
||||
const abortObserved = Promise.withResolvers<undefined>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
let observedSignal: AbortSignal | undefined
|
||||
let active = false
|
||||
const holdExactRead = async (signal?: AbortSignal): Promise<never> => {
|
||||
@@ -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<void>((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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user