1
0
Эх сурвалжийг харах

fix: redact lineage errors and parse precise times

Hypatia May 2 сар өмнө
parent
commit
415948dd7b

+ 86 - 9
packages/session-query/tool-session-query/src/index.ts

@@ -428,7 +428,19 @@ async function executeSessionTrace(
   const caller = callerOf(exec)
   const sessionId = targetId(args, caller)
   await authorizeTarget(ctx, caller, sessionId, exec.signal)
-  const trace = await ctx.sessionQuery.traceSession(sessionId)
+  let trace: SessionLineageTrace
+  try {
+    trace = await ctx.sessionQuery.traceSession(sessionId)
+  } 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()
   assertObservedTargetAuthorized(caller, sessionId, trace.target.header)
 
@@ -579,21 +591,31 @@ function timestampRange(
   to: string | undefined,
 ): { from?: number; to?: number } | undefined {
   if (from === undefined && to === undefined) return undefined
-  const fromMs = from === undefined ? undefined : parseIsoTimestamp(`${name}_from`, from)
-  const toMs = to === undefined ? undefined : parseIsoTimestamp(`${name}_to`, to)
-  if (fromMs !== undefined && toMs !== undefined && fromMs > toMs) {
+  const fromTimestamp = from === undefined ? undefined : parseIsoTimestamp(`${name}_from`, from)
+  const toTimestamp = to === undefined ? undefined : parseIsoTimestamp(`${name}_to`, to)
+  if (
+    fromTimestamp !== undefined
+    && toTimestamp !== undefined
+    && compareTimestamps(fromTimestamp, toTimestamp) > 0
+  ) {
     throw invalidRange(name, 'from must be less than or equal to to')
   }
   return {
-    ...fromMs === undefined ? {} : { from: fromMs },
-    ...toMs === undefined ? {} : { to: toMs },
+    ...fromTimestamp === undefined ? {} : { from: timestampLowerBound(fromTimestamp) },
+    ...toTimestamp === undefined ? {} : { to: timestampUpperBound(toTimestamp) },
   }
 }
 
 const ISO_TIMESTAMP =
   /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|([+-])(\d{2}):(\d{2}))$/
 
-function parseIsoTimestamp(name: string, value: string): number {
+interface ExactTimestamp {
+  readonly millisecond: number
+  /** Canonical decimal digits strictly below one millisecond; no trailing zeroes. */
+  readonly remainder: string
+}
+
+function parseIsoTimestamp(name: string, value: string): ExactTimestamp {
   const match = ISO_TIMESTAMP.exec(value)
   if (match === null) {
     throw invalidRange(name, 'must be an ISO 8601 timestamp with Z or a numeric offset')
@@ -614,8 +636,63 @@ function parseIsoTimestamp(name: string, value: string): number {
   ) {
     throw invalidRange(name, 'must be a valid ISO 8601 timestamp')
   }
-  const timestamp = Date.parse(value)
-  return timestamp
+  const fraction = match[7] ?? ''
+  const millisecondDigits = fraction.slice(0, 3).padEnd(3, '0')
+  const normalized = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`
+    + `:${match[6] ?? '00'}.${millisecondDigits}${match[8]}`
+  const timestamp = Date.parse(normalized)
+  if (!Number.isSafeInteger(timestamp)) {
+    throw invalidRange(name, 'must be a valid ISO 8601 timestamp')
+  }
+  return {
+    millisecond: timestamp,
+    remainder: fraction.slice(3).replace(/0+$/u, ''),
+  }
+}
+
+function compareTimestamps(left: ExactTimestamp, right: ExactTimestamp): number {
+  if (left.millisecond !== right.millisecond) {
+    return left.millisecond < right.millisecond ? -1 : 1
+  }
+  const length = Math.max(left.remainder.length, right.remainder.length)
+  for (let index = 0; index < length; index += 1) {
+    const leftDigit = left.remainder[index] ?? '0'
+    const rightDigit = right.remainder[index] ?? '0'
+    if (leftDigit !== rightDigit) return leftDigit < rightDigit ? -1 : 1
+  }
+  return 0
+}
+
+function timestampLowerBound(timestamp: ExactTimestamp): number {
+  return timestamp.remainder.length === 0
+    ? timestamp.millisecond
+    : nextUpFinite(timestamp.millisecond)
+}
+
+function timestampUpperBound(timestamp: ExactTimestamp): number {
+  return timestamp.remainder.length === 0
+    ? timestamp.millisecond
+    : nextDownFinite(timestamp.millisecond + 1)
+}
+
+/** Return the adjacent IEEE-754 value toward positive infinity for a finite input. */
+function nextUpFinite(value: number): number {
+  if (value === 0) return Number.MIN_VALUE
+  const view = new DataView(new ArrayBuffer(8))
+  view.setFloat64(0, value)
+  const bits = view.getBigUint64(0)
+  view.setBigUint64(0, value > 0 ? bits + 1n : bits - 1n)
+  return view.getFloat64(0)
+}
+
+/** Return the adjacent IEEE-754 value toward negative infinity for a finite input. */
+function nextDownFinite(value: number): number {
+  if (value === 0) return -Number.MIN_VALUE
+  const view = new DataView(new ArrayBuffer(8))
+  view.setFloat64(0, value)
+  const bits = view.getBigUint64(0)
+  view.setBigUint64(0, value > 0 ? bits - 1n : bits + 1n)
+  return view.getFloat64(0)
 }
 
 function daysInMonth(year: number, month: number): number {

+ 128 - 0
packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts

@@ -97,4 +97,132 @@ describe('tool-session-query with the real SQLite provider', () => {
     expect(liveEvents.content.map(block => block.type === 'text' ? block.text : '').join('\n'))
       .toContain('seq 1')
   })
+
+  it('passes finite fractional epoch-millisecond bounds through SQLite comparisons', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-tool-session-query-fractional-'))
+    temporaryDirectories.push(root)
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
+    await ctx.plugin(SessionQuerySqlite, { path: join(root, 'session-query.db') })
+    await ctx.plugin(ToolSessionQuery)
+
+    const base = Date.parse('2026-07-24T00:00:00.000Z')
+    const persisted = SessionId('fractional-persisted')
+    await ctx.sessionPersistence.create({
+      version: SESSION_FORMAT_VERSION,
+      id: persisted,
+      createdAt: base,
+      cwd: '/work',
+    })
+    await ctx.sessionPersistence.append(persisted, [
+      {
+        type: 'user/message',
+        seq: 0,
+        time: base + 123,
+        data: {
+          content: [{ type: 'text', text: 'fractional integration needle' }],
+          source: { kind: 'user' },
+        },
+        surfaceOp: 'append',
+      },
+      {
+        type: 'user/message',
+        seq: 1,
+        time: base + 124,
+        data: {
+          content: [{ type: 'text', text: 'fractional integration needle' }],
+          source: { kind: 'user' },
+        },
+        surfaceOp: 'append',
+      },
+      {
+        type: 'user/message',
+        seq: 2,
+        time: -124,
+        data: {
+          content: [{ type: 'text', text: 'pre-epoch fractional needle' }],
+          source: { kind: 'user' },
+        },
+        surfaceOp: 'append',
+      },
+      {
+        type: 'user/message',
+        seq: 3,
+        time: -123,
+        data: {
+          content: [{ type: 'text', text: 'pre-epoch fractional needle' }],
+          source: { kind: 'user' },
+        },
+        surfaceOp: 'append',
+      },
+    ])
+
+    const caller = ctx.sessions.create(SessionId('fractional-caller'), {
+      meta: { createdAt: base + 1_000, cwd: '/work' },
+    })
+    let call = 0
+    const execute = (args: unknown) => ctx.tools.execute({
+      name: 'session_event_search',
+      arguments: args,
+      callId: CallId(`fractional-integration-${++call}`),
+      signal: new AbortController().signal,
+      agent: fakeAgent(caller),
+    })
+
+    const lowerBound = await execute({
+      session_id: persisted,
+      query: 'fractional integration needle',
+      time_from: '2026-07-24T00:00:00.12300001Z',
+    })
+    expect(lowerBound.isError).toBe(false)
+    const lowerText = lowerBound.content.map(block => block.type === 'text' ? block.text : '').join('\n')
+    expect(lowerText).toContain('seq 1')
+    expect(lowerText).not.toContain('seq 0')
+
+    const upperBound = await execute({
+      session_id: persisted,
+      query: 'fractional integration needle',
+      time_to: '2026-07-24T08:00:00.1239999+08:00',
+    })
+    expect(upperBound.isError).toBe(false)
+    const upperText = upperBound.content.map(block => block.type === 'text' ? block.text : '').join('\n')
+    expect(upperText).toContain('seq 0')
+    expect(upperText).not.toContain('seq 1')
+
+    const emptySameMillisecond = await execute({
+      session_id: persisted,
+      query: 'fractional integration needle',
+      time_from: '2026-07-24T00:00:00.12300001Z',
+      time_to: '2026-07-24T08:00:00.1239999+08:00',
+    })
+    expect(emptySameMillisecond.isError).toBe(false)
+    expect(emptySameMillisecond.content.map(block => block.type === 'text' ? block.text : '').join('\n'))
+      .toContain('No prior event matches found.')
+
+    const preEpochLower = await execute({
+      session_id: persisted,
+      query: 'pre-epoch fractional needle',
+      time_from: '1969-12-31T23:59:59.87600001Z',
+    })
+    expect(preEpochLower.isError).toBe(false)
+    const preEpochLowerText = preEpochLower.content
+      .map(block => block.type === 'text' ? block.text : '').join('\n')
+    expect(preEpochLowerText).toContain('seq 3')
+    expect(preEpochLowerText).not.toContain('seq 2')
+
+    const preEpochUpper = await execute({
+      session_id: persisted,
+      query: 'pre-epoch fractional needle',
+      time_to: '1969-12-31T19:59:59.8769999-04:00',
+    })
+    expect(preEpochUpper.isError).toBe(false)
+    const preEpochUpperText = preEpochUpper.content
+      .map(block => block.type === 'text' ? block.text : '').join('\n')
+    expect(preEpochUpperText).toContain('seq 2')
+    expect(preEpochUpperText).not.toContain('seq 3')
+  })
 })

+ 234 - 0
packages/session-query/tool-session-query/tests/tool-session-query.spec.ts

@@ -382,6 +382,137 @@ describe('input validation and translation', () => {
     })
   })
 
+  it.each([
+    ['one fractional digit', '2026-07-24T00:00:00.1Z', 100],
+    ['two fractional digits', '2026-07-24T00:00:00.12Z', 120],
+    ['three fractional digits', '2026-07-24T00:00:00.123Z', 123],
+  ])('normalizes %s into an exact integer epoch-millisecond filter', async (_case, value, offset) => {
+    const mounted = await mount()
+    await mounted.call('session_search', {
+      query: 'q',
+      created_at_from: value,
+    })
+    const expected = Date.parse('2026-07-24T00:00:00.000Z') + offset
+    expect(Number.isFinite(expected)).toBe(true)
+    expect(FakeQuery.sessionRequests[0]?.sessionFilters).toContainEqual({
+      kind: 'created-at',
+      from: expected,
+    })
+  })
+
+  it('maps exact same-millisecond decimal bounds to adjacent numeric values without collapsing the interval', async () => {
+    const mounted = await mount()
+    const base = Date.parse('2026-07-24T00:00:00.000Z')
+    const result = await mounted.call('session_search', {
+      query: 'q',
+      created_at_from: '2026-07-24T00:00:00.12300001Z',
+      created_at_to: '2026-07-24T08:00:00.1239999+08:00',
+    })
+
+    expect(result.isError).toBe(false)
+    expect(text(result)).toContain('No prior session matches found.')
+    const range = FakeQuery.sessionRequests[0]?.sessionFilters
+      ?.find(filter => filter.kind === 'created-at')
+    expect(range).toBeDefined()
+    if (range?.kind !== 'created-at' || range.from === undefined || range.to === undefined) {
+      throw new Error('expected complete created-at range')
+    }
+    expect(Number.isFinite(range.from)).toBe(true)
+    expect(Number.isFinite(range.to)).toBe(true)
+    expect(range.from).toBeGreaterThan(base + 123)
+    expect(range.from).toBeLessThan(base + 124)
+    expect(range.to).toBeGreaterThan(base + 123)
+    expect(range.to).toBeLessThan(base + 124)
+    expect(range.from).toBeLessThan(range.to)
+  })
+
+  it('rejects exact bounds reversed only below one millisecond before calling the provider', async () => {
+    const mounted = await mount()
+    const result = await mounted.call('session_search', {
+      query: 'q',
+      created_at_from: '2026-07-24T00:00:00.12300002Z',
+      created_at_to: '2026-07-24T00:00:00.12300001Z',
+    })
+
+    expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER')
+    expect(FakeQuery.sessionRequests).toEqual([])
+  })
+
+  it('compares unequal-length exact remainders with implicit trailing decimal zeroes', async () => {
+    const mounted = await mount()
+    const ordered = await mounted.call('session_search', {
+      query: 'q',
+      created_at_from: '2026-07-24T00:00:00.1231Z',
+      created_at_to: '2026-07-24T00:00:00.12311Z',
+    })
+    expect(ordered.isError).toBe(false)
+
+    const reversed = await mounted.call('session_search', {
+      query: 'q',
+      created_at_from: '2026-07-24T00:00:00.12311Z',
+      created_at_to: '2026-07-24T00:00:00.1231Z',
+    })
+    expect(errorCode(reversed)).toBe('SESSION_QUERY_INVALID_FILTER')
+  })
+
+  it('treats trailing-zero fractional spellings as the same exact instant', async () => {
+    const mounted = await mount()
+    const result = await mounted.call('session_search', {
+      query: 'q',
+      created_at_from: '2026-07-24T00:00:00.1230000100Z',
+      created_at_to: '2026-07-24T00:00:00.12300001Z',
+    })
+
+    expect(result.isError).toBe(false)
+    expect(FakeQuery.sessionRequests).toHaveLength(1)
+  })
+
+  it('maps fractional bounds correctly across zero and for negative pre-epoch milliseconds', async () => {
+    const mounted = await mount()
+    await mounted.call('session_search', {
+      query: 'q',
+      created_at_from: '1970-01-01T00:00:00.0000001Z',
+      event_time_to: '1969-12-31T23:59:59.9999999Z',
+    })
+    expect(FakeQuery.sessionRequests[0]?.sessionFilters).toContainEqual({
+      kind: 'created-at',
+      from: Number.MIN_VALUE,
+    })
+    expect(FakeQuery.sessionRequests[0]?.eventFilters).toContainEqual({
+      kind: 'time',
+      to: -Number.MIN_VALUE,
+    })
+
+    await mounted.call('session_event_search', {
+      query: 'q',
+      time_from: '1969-12-31T23:59:59.87600001Z',
+      time_to: '1969-12-31T19:59:59.8769999-04:00',
+    })
+    const range = FakeQuery.eventRequests[0]?.filters?.find(filter => filter.kind === 'time')
+    expect(range).toBeDefined()
+    if (range?.kind !== 'time' || range.from === undefined || range.to === undefined) {
+      throw new Error('expected complete event time range')
+    }
+    expect(range.from).toBeGreaterThan(-124)
+    expect(range.from).toBeLessThan(-123)
+    expect(range.to).toBeGreaterThan(-124)
+    expect(range.to).toBeLessThan(-123)
+    expect(range.from).toBeLessThan(range.to)
+  })
+
+  it('rejects a normalized timestamp when the platform parser cannot produce a finite value', async () => {
+    const mounted = await mount()
+    vi.spyOn(Date, 'parse').mockReturnValueOnce(Number.NaN)
+
+    const result = await mounted.call('session_search', {
+      query: 'q',
+      created_at_from: '2026-07-24T00:00:00.123456Z',
+    })
+
+    expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER')
+    expect(FakeQuery.sessionRequests).toEqual([])
+  })
+
   it('compiles one-sided timestamps and independent root/parent clauses', async () => {
     const mounted = await mount()
     await mounted.call('session_search', {
@@ -463,6 +594,109 @@ describe('workspace authority and lineage redaction', () => {
     expect(output).not.toContain('hidden-grandchild-secret')
   })
 
+  it('sanitizes a real outside-workspace ancestor cycle before the lineage error reaches the model', async () => {
+    const mounted = await mount()
+    const hiddenA = SessionId('hidden-cycle-a-secret')
+    const hiddenB = SessionId('hidden-cycle-b-secret')
+    createSession(mounted.ctx, hiddenA, '/outside', 2, hiddenB)
+    createSession(mounted.ctx, hiddenB, '/outside', 3, hiddenA)
+    const target = createSession(mounted.ctx, 'visible-cycle-target', '/work', 4, hiddenA)
+
+    const result = await mounted.call('session_trace', { session_id: target.id })
+
+    expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_LINEAGE')
+    expect(text(result)).toBe('Error: session lineage is invalid')
+    const presentation = JSON.stringify(result)
+    expect(presentation).not.toContain(hiddenA)
+    expect(presentation).not.toContain(hiddenB)
+  })
+
+  it.each([
+    {
+      name: 'typed query error',
+      makeError: () => new SessionQueryError(
+        'unrelated persistence failure',
+        'SESSION_QUERY_PERSISTENCE_FAILED',
+      ),
+      code: 'SESSION_QUERY_PERSISTENCE_FAILED',
+      message: 'unrelated persistence failure',
+    },
+    {
+      name: 'plain error',
+      makeError: () => new Error('unrelated plain trace failure'),
+      code: undefined,
+      message: 'unrelated plain trace failure',
+    },
+  ])('preserves an unrelated $name from lineage tracing', async ({ makeError, code, message }) => {
+    const mounted = await mount()
+    const target = createSession(mounted.ctx, 'trace-failure-target', '/work')
+    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}`)
+  })
+
+  it('preserves caller cancellation while a lineage trace is pending', async () => {
+    const mounted = await mount()
+    const target = createSession(mounted.ctx, 'cancelled-trace-target', '/work')
+    const trace = await mounted.ctx.sessionQuery.traceSession(target.id)
+    let started!: () => void
+    const traceStarted = new Promise<void>((resolve) => { started = resolve })
+    let finish!: (value: typeof trace) => void
+    vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockImplementation(() => {
+      started()
+      return new Promise<typeof trace>((resolve) => { finish = resolve })
+    })
+    const controller = new AbortController()
+    const cancellation = new SessionQueryError('lineage trace cancelled', 'SESSION_QUERY_ABORTED')
+
+    const pending = mounted.call(
+      'session_trace',
+      { session_id: target.id },
+      { signal: controller.signal },
+    )
+    await traceStarted
+    controller.abort(cancellation)
+    finish(trace)
+    const result = await pending
+
+    expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED')
+    expect(text(result)).toBe('Error: lineage trace cancelled')
+  })
+
+  it('gives caller cancellation precedence when a pending trace rejects with invalid lineage', async () => {
+    const mounted = await mount()
+    const target = createSession(mounted.ctx, 'cancelled-invalid-lineage-target', '/work')
+    let started!: () => void
+    const traceStarted = new Promise<void>((resolve) => { started = resolve })
+    let fail!: (error: SessionQueryError) => void
+    vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockImplementation(() => {
+      started()
+      return new Promise((_resolve, reject) => { fail = reject })
+    })
+    const controller = new AbortController()
+    const cancellation = new SessionQueryError('lineage trace cancelled first', 'SESSION_QUERY_ABORTED')
+
+    const pending = mounted.call(
+      'session_trace',
+      { session_id: target.id },
+      { signal: controller.signal },
+    )
+    await traceStarted
+    controller.abort(cancellation)
+    fail(new SessionQueryError(
+      'session lineage contains a cycle at "hidden-race-secret"',
+      'SESSION_QUERY_INVALID_LINEAGE',
+    ))
+    const result = await pending
+
+    expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED')
+    expect(text(result)).toBe('Error: lineage trace cancelled first')
+    expect(JSON.stringify(result)).not.toContain('hidden-race-secret')
+  })
+
   it('renders branching descendants in source preorder with one indented marker per pruned subtree', async () => {
     const mounted = await mount()
     const target = createSession(mounted.ctx, 'branch-target', '/work', 20)