|
|
@@ -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()
|
|
|
})
|
|
|
})
|
|
|
|