fix: bind session authorization to observations
This commit is contained in:
@@ -25,6 +25,7 @@ import type {
|
||||
Config as SessionQueryConfig,
|
||||
SessionEventSearchDocument,
|
||||
SessionEventSearchHit,
|
||||
SessionEventSearchPage,
|
||||
SessionEventSearchRequest,
|
||||
SessionSearchExecContext,
|
||||
SessionSearchHit,
|
||||
@@ -133,7 +134,7 @@ interface IndexedLiveRow {
|
||||
generation: number
|
||||
}
|
||||
|
||||
interface SearchRow {
|
||||
interface SessionHeaderRow {
|
||||
session_id: string
|
||||
version: number
|
||||
created_at: number
|
||||
@@ -141,6 +142,9 @@ interface SearchRow {
|
||||
parent_session: string | null
|
||||
seed_length: number | null
|
||||
delegation_depth: number | null
|
||||
}
|
||||
|
||||
interface SearchRow extends SessionHeaderRow {
|
||||
live: number
|
||||
persisted: number
|
||||
seq: number
|
||||
@@ -247,27 +251,30 @@ export class SessionQuerySqlite extends SessionQueryService {
|
||||
override async searchEvents(
|
||||
request: SessionEventSearchRequest,
|
||||
exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionEventSearchHit>> {
|
||||
): Promise<SessionEventSearchPage> {
|
||||
const normalized = normalizeEventRequest(request, this.config)
|
||||
const signal = exec?.signal
|
||||
return this._serialized(signal, async () => {
|
||||
await this._ensureReady(signal)
|
||||
const persistenceBinding = await this._reconcile(signal)
|
||||
assertNotAborted(signal)
|
||||
const generation = this._targetGeneration(normalized.sessionId, persistenceBinding)
|
||||
const target = this._targetObservation(normalized.sessionId, persistenceBinding)
|
||||
const fingerprint = requestFingerprint(normalized)
|
||||
const offset = normalized.cursor === undefined
|
||||
? 0
|
||||
: decodeCursor(normalized.cursor, this._instance, 'events', fingerprint, generation)
|
||||
: decodeCursor(normalized.cursor, this._instance, 'events', fingerprint, target.generation)
|
||||
const rows = this._queryEvents(normalized, offset, persistenceBinding)
|
||||
return page(rows, normalized.limit, row => this._eventHit(row), cursorOffset => encodeCursor({
|
||||
version: 1,
|
||||
instance: this._instance,
|
||||
scope: 'events',
|
||||
fingerprint,
|
||||
generation,
|
||||
offset: cursorOffset,
|
||||
}), offset)
|
||||
return {
|
||||
session: target.header,
|
||||
...page(rows, normalized.limit, row => this._eventHit(row), cursorOffset => encodeCursor({
|
||||
version: 1,
|
||||
instance: this._instance,
|
||||
scope: 'events',
|
||||
fingerprint,
|
||||
generation: target.generation,
|
||||
offset: cursorOffset,
|
||||
}), offset),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -643,17 +650,33 @@ export class SessionQuerySqlite extends SessionQueryService {
|
||||
`).all(...bindings) as unknown as SearchRow[]
|
||||
}
|
||||
|
||||
private _targetGeneration(sessionId: SessionId, persistenceBinding: PersistenceBinding): string {
|
||||
private _targetObservation(
|
||||
sessionId: SessionId,
|
||||
persistenceBinding: PersistenceBinding,
|
||||
): { header: SessionHeader; generation: string } {
|
||||
const db = this._requireDb()
|
||||
const live = db.prepare(
|
||||
'SELECT generation FROM temp.live_sessions WHERE id = ?',
|
||||
).get(sessionId) as { generation: number } | undefined
|
||||
if (live !== undefined) return `live:${live.generation}`
|
||||
`SELECT
|
||||
id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation
|
||||
FROM temp.live_sessions
|
||||
WHERE id = ?`,
|
||||
).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined
|
||||
if (live !== undefined) {
|
||||
return { header: rowHeader(live), generation: `live:${live.generation}` }
|
||||
}
|
||||
if (persistenceBinding.service !== undefined) {
|
||||
const persisted = db.prepare(
|
||||
'SELECT generation FROM persisted_sessions WHERE id = ?',
|
||||
).get(sessionId) as { generation: number } | undefined
|
||||
if (persisted !== undefined) return `persisted:${this._persistenceEpoch}:${persisted.generation}`
|
||||
`SELECT
|
||||
id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation
|
||||
FROM persisted_sessions
|
||||
WHERE id = ?`,
|
||||
).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined
|
||||
if (persisted !== undefined) {
|
||||
return {
|
||||
header: rowHeader(persisted),
|
||||
generation: `persisted:${this._persistenceEpoch}:${persisted.generation}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new SessionQueryError(
|
||||
`session "${sessionId}" not found`,
|
||||
@@ -835,7 +858,7 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean {
|
||||
&& (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0)
|
||||
}
|
||||
|
||||
function rowHeader(row: SearchRow): SessionHeader {
|
||||
function rowHeader(row: SessionHeaderRow): SessionHeader {
|
||||
return {
|
||||
version: row.version,
|
||||
id: row.session_id as SessionId,
|
||||
|
||||
@@ -179,7 +179,10 @@ describe('SQLite session search', () => {
|
||||
)
|
||||
|
||||
await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'AI' }))
|
||||
.resolves.toMatchObject({ items: [{ sessionId: session.id, seq: 0, snippet: 'An AI helper' }] })
|
||||
.resolves.toMatchObject({
|
||||
session: { ...session.header, seedLength: 1 },
|
||||
items: [{ sessionId: session.id, seq: 0, snippet: 'An AI helper' }],
|
||||
})
|
||||
await expect(ctx.sessionQuery.searchSessions({ query: 'AI' }))
|
||||
.resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] })
|
||||
})
|
||||
@@ -1307,7 +1310,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
|
||||
await expect(ctx.sessionQuery.searchSessions({ query: 'SQLite needle' }))
|
||||
.resolves.toMatchObject({ items: [{ header: meta, persisted: true, live: false }] })
|
||||
await expect(ctx.sessionQuery.searchEvents({ sessionId: meta.id, query: 'SQLite needle' }))
|
||||
.resolves.toMatchObject({ items: [{ sessionId: meta.id, seq: 0 }] })
|
||||
.resolves.toMatchObject({ session: meta, items: [{ sessionId: meta.id, seq: 0 }] })
|
||||
await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('absent'), query: 'needle' }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
|
||||
await search.dispose()
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
|
||||
- `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus.
|
||||
- `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order.
|
||||
- `readTitle(sessionId)` loads one live-preferred or persisted log and folds its latest `session/title` event into a `SessionTitleSnapshot`; it returns `undefined` when the known session has no title.
|
||||
- `readTitleSnapshot(sessionId)` loads one live-preferred or persisted log and returns the cloned source header with its latest folded `session/title` event. `readTitle(sessionId)` is the title-only convenience view; it returns `undefined` when the known session has no title.
|
||||
- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold.
|
||||
- `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture.
|
||||
- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
|
||||
- `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
|
||||
- `traceEvent(request)` loads the logical log once and returns direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive.
|
||||
- `traceEvent(request)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive.
|
||||
|
||||
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. `listSessions()` remains lightweight and does not load logs or index titles.
|
||||
|
||||
@@ -24,7 +24,7 @@ The text clause is deliberately independent of FTS providers: caller text is esc
|
||||
|
||||
## Full-text methods
|
||||
|
||||
`SessionQueryService.searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. These are the service's only abstract methods. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above.
|
||||
`SessionQueryService.searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. These are the service's only abstract methods. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. An event-search page also carries the cloned target header from the same indexed generation as its hits, allowing authorization consumers to bind policy to the payload observation. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above.
|
||||
|
||||
The package has no provider coordinator, fallback implementation, or standalone concrete plugin. A concrete service backend inherits the implemented reads, filters, and traces while owning full-text observation, reconciliation, ranking, cursor generations, and query execution; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md).
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
SessionEventResultFilter,
|
||||
SessionEventSearchPage,
|
||||
SessionEventReadRequest,
|
||||
SessionEventRecord,
|
||||
SessionEventSearchHit,
|
||||
SessionEventSearchDocument,
|
||||
SessionEventSearchRequest,
|
||||
SessionEventTrace,
|
||||
SessionEventTraceObservation,
|
||||
SessionEventTraceRequest,
|
||||
SessionEventWindow,
|
||||
SessionLineageTrace,
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
SessionSearchPage,
|
||||
SessionSearchRequest,
|
||||
SessionSurfaceSnapshot,
|
||||
SessionTitleObservation,
|
||||
} from './types.ts'
|
||||
import {
|
||||
SESSION_QUERY_READ_WINDOW_MAX,
|
||||
@@ -103,12 +104,12 @@ export abstract class SessionQueryService extends Service {
|
||||
* Search events within one live-preferred logical session.
|
||||
* @param request - target session, query text, filters, page size, and cursor.
|
||||
* @param exec - optional cancellation control.
|
||||
* @returns matching event hits in deterministic relevance order.
|
||||
* @returns matching event hits and their target header from one indexed generation.
|
||||
*/
|
||||
abstract searchEvents(
|
||||
request: SessionEventSearchRequest,
|
||||
exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionEventSearchHit>>
|
||||
): Promise<SessionEventSearchPage>
|
||||
|
||||
/**
|
||||
* List the complete logical corpus using live-preferred records.
|
||||
@@ -134,8 +135,21 @@ export abstract class SessionQueryService extends Service {
|
||||
* @returns latest title snapshot, or `undefined` when the log has no title event.
|
||||
*/
|
||||
async readTitle(sessionId: SessionId): Promise<SessionTitleSnapshot | undefined> {
|
||||
return (await this.readTitleSnapshot(sessionId)).title
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the latest title and return its source header from one corpus observation.
|
||||
* @param sessionId - live or persisted session id to read.
|
||||
* @returns cloned source header and optional latest title snapshot.
|
||||
*/
|
||||
async readTitleSnapshot(sessionId: SessionId): Promise<SessionTitleObservation> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
return foldSessionTitle(loaded.events)
|
||||
const title = foldSessionTitle(loaded.events)
|
||||
return {
|
||||
session: loaded.header,
|
||||
...title === undefined ? {} : { title },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,12 +218,15 @@ export abstract class SessionQueryService extends Service {
|
||||
/**
|
||||
* Trace one event's direct positional and provenance relationships.
|
||||
* @param request - target session id and event seq.
|
||||
* @returns direct links plus the target's positional replacement chain.
|
||||
* @returns source header, direct links, and the target's positional replacement chain.
|
||||
* @throws when source resolution fails, the target is absent, or surface/provenance validation fails.
|
||||
*/
|
||||
async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTrace> {
|
||||
async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTraceObservation> {
|
||||
const loaded = await this._corpus.load(request.sessionId)
|
||||
return tracing.traceEvent(request.sessionId, loaded.events, request.seq)
|
||||
return {
|
||||
session: loaded.header,
|
||||
...tracing.traceEvent(request.sessionId, loaded.events, request.seq),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
SessionId,
|
||||
SurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title'
|
||||
import type { SessionSearchCursor } from './cursor.ts'
|
||||
|
||||
export type { SessionSearchCursor } from './cursor.ts'
|
||||
@@ -108,6 +109,12 @@ export interface SessionEventTrace {
|
||||
derivedEventSeqs: number[]
|
||||
}
|
||||
|
||||
/** Event relationships bound to the same session-header observation. */
|
||||
export interface SessionEventTraceObservation extends SessionEventTrace {
|
||||
/** Cloned header selected with the event log used for the trace. */
|
||||
session: SessionHeader
|
||||
}
|
||||
|
||||
/** Request for one event plus raw neighboring log context. */
|
||||
export interface SessionEventReadRequest {
|
||||
/** Session that owns the target event. */
|
||||
@@ -134,6 +141,14 @@ export interface SessionEventWindow {
|
||||
endSeq: number
|
||||
}
|
||||
|
||||
/** Latest folded title bound to the same session-header observation. */
|
||||
export interface SessionTitleObservation {
|
||||
/** Cloned header selected with the event log used for the title fold. */
|
||||
session: SessionHeader
|
||||
/** Latest title snapshot, absent when the observed log has no title. */
|
||||
title?: SessionTitleSnapshot
|
||||
}
|
||||
|
||||
/** Inclusive numeric interval used by time and sequence filters. */
|
||||
export interface SessionResultRange {
|
||||
/** Inclusive lower bound. */
|
||||
@@ -184,6 +199,12 @@ export interface SessionSearchPage<T> {
|
||||
nextCursor?: SessionSearchCursor
|
||||
}
|
||||
|
||||
/** Event-search results bound to the indexed target-session observation. */
|
||||
export interface SessionEventSearchPage extends SessionSearchPage<SessionEventSearchHit> {
|
||||
/** Cloned target header from the same indexed generation as `items`. */
|
||||
session: SessionHeader
|
||||
}
|
||||
|
||||
/** Controls shared by cross-session and within-session search calls. */
|
||||
export interface SessionSearchExecContext {
|
||||
/** Abort caller waiting and interrupt provider work where supported. */
|
||||
|
||||
@@ -213,8 +213,10 @@ it('registers exact and abstract search behavior under one ctx key', async () =>
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(TestSessionQueryService)
|
||||
const session = ctx.sessions.create(id)
|
||||
await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] })
|
||||
await expect(ctx.sessionQuery.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] })
|
||||
await expect(ctx.sessionQuery.searchEvents({ sessionId: id, query: 'AI' }))
|
||||
.resolves.toEqual({ session: session.header, items: [] })
|
||||
await fiber.dispose()
|
||||
expect(ctx.sessionQuery).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SessionQueryService from '@deepseek-ai/dsh-session-query'
|
||||
import type {
|
||||
SessionEventSearchHit,
|
||||
SessionEventSearchPage,
|
||||
SessionEventSearchRequest,
|
||||
SessionSearchExecContext,
|
||||
SessionSearchHit,
|
||||
@@ -17,10 +17,13 @@ export class TestSessionQueryService extends SessionQueryService {
|
||||
return Promise.resolve({ items: [] })
|
||||
}
|
||||
|
||||
override searchEvents(
|
||||
_request: SessionEventSearchRequest,
|
||||
override async searchEvents(
|
||||
request: SessionEventSearchRequest,
|
||||
_exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionEventSearchHit>> {
|
||||
return Promise.resolve({ items: [] })
|
||||
): Promise<SessionEventSearchPage> {
|
||||
return {
|
||||
session: (await this.readSurface(request.sessionId)).session,
|
||||
items: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@ import {
|
||||
extractSessionEventText,
|
||||
type SessionAvailability,
|
||||
type SessionEventMetadataFilter,
|
||||
type SessionEventSearchPage,
|
||||
type SessionEventSearchHit,
|
||||
type SessionEventSurface,
|
||||
type SessionEventTrace,
|
||||
type SessionEventTraceObservation,
|
||||
type SessionEventWindow,
|
||||
type SessionLineageNode,
|
||||
type SessionLineageTrace,
|
||||
@@ -352,7 +353,7 @@ async function executeSessionSearch(
|
||||
.map(hit => hit.header.parentSession)
|
||||
.filter((id): id is SessionIdValue => id !== undefined)
|
||||
const authorizedParents = await authorizeSessionIds(ctx, caller, parentIds, exec.signal)
|
||||
const titles = await readTitles(ctx, collected.items.map(hit => hit.header.id), exec.signal)
|
||||
const titles = await readTitles(ctx, caller, collected.items.map(hit => hit.header.id), exec.signal)
|
||||
return formatSessionSearch(collected, titles, authorizedParents)
|
||||
}
|
||||
|
||||
@@ -377,7 +378,7 @@ async function executeEventSearch(
|
||||
}
|
||||
range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, stepStart.seq - 1)
|
||||
}
|
||||
const title = await readTitle(ctx, sessionId, exec.signal)
|
||||
const title = await readTitle(ctx, caller, sessionId, exec.signal)
|
||||
if (range.from !== undefined && range.to !== undefined && range.from > range.to) {
|
||||
return formatEventSearch(sessionId, title, { items: [], capped: false })
|
||||
}
|
||||
@@ -392,12 +393,16 @@ async function executeEventSearch(
|
||||
const collected = await collectPages(
|
||||
maxResults,
|
||||
exec.signal,
|
||||
cursor => ctx.sessionQuery.searchEvents({
|
||||
sessionId,
|
||||
query,
|
||||
filters,
|
||||
...cursor === undefined ? {} : { cursor },
|
||||
}, { signal: exec.signal }),
|
||||
async (cursor): Promise<SessionEventSearchPage> => {
|
||||
const page = await ctx.sessionQuery.searchEvents({
|
||||
sessionId,
|
||||
query,
|
||||
filters,
|
||||
...cursor === undefined ? {} : { cursor },
|
||||
}, { signal: exec.signal })
|
||||
assertObservedTargetAuthorized(caller, sessionId, page.session)
|
||||
return page
|
||||
},
|
||||
() => true,
|
||||
)
|
||||
return formatEventSearch(sessionId, title, collected)
|
||||
@@ -413,6 +418,7 @@ async function executeSessionTrace(
|
||||
await authorizeTarget(ctx, caller, sessionId, exec.signal)
|
||||
const trace = await ctx.sessionQuery.traceSession(sessionId)
|
||||
exec.signal.throwIfAborted()
|
||||
assertObservedTargetAuthorized(caller, sessionId, trace.target.header)
|
||||
|
||||
const ancestors: SessionRecord[] = []
|
||||
let ancestorBoundary = false
|
||||
@@ -430,7 +436,7 @@ async function executeSessionTrace(
|
||||
...ancestors.map(record => record.header.id),
|
||||
...descendantIds(descendants),
|
||||
]
|
||||
const titles = await readTitles(ctx, visibleIds, exec.signal)
|
||||
const titles = await readTitles(ctx, caller, visibleIds, exec.signal)
|
||||
return formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles)
|
||||
}
|
||||
|
||||
@@ -445,7 +451,8 @@ async function executeEventTrace(
|
||||
await authorizeTarget(ctx, caller, sessionId, exec.signal)
|
||||
const trace = await ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq })
|
||||
exec.signal.throwIfAborted()
|
||||
const title = await readTitle(ctx, sessionId, exec.signal)
|
||||
assertObservedTargetAuthorized(caller, sessionId, trace.session)
|
||||
const title = await readTitle(ctx, caller, sessionId, exec.signal)
|
||||
return formatEventTrace(sessionId, title, trace)
|
||||
}
|
||||
|
||||
@@ -467,7 +474,8 @@ async function executeEventRead(
|
||||
...args.after === undefined ? {} : { after: args.after },
|
||||
})
|
||||
exec.signal.throwIfAborted()
|
||||
const title = await readTitle(ctx, sessionId, exec.signal)
|
||||
assertObservedTargetAuthorized(caller, sessionId, window.session)
|
||||
const title = await readTitle(ctx, caller, sessionId, exec.signal)
|
||||
return formatEventRead(sessionId, title, window)
|
||||
}
|
||||
|
||||
@@ -676,8 +684,20 @@ async function collectPages<T>(
|
||||
}
|
||||
|
||||
function recordAuthorized(record: SessionRecord, caller: Caller): boolean {
|
||||
if (record.header.id === caller.id) return true
|
||||
return caller.header.cwd !== undefined && record.header.cwd === caller.header.cwd
|
||||
return headerAuthorized(record.header, caller)
|
||||
}
|
||||
|
||||
function headerAuthorized(header: SessionHeader, caller: Caller): boolean {
|
||||
if (header.id === caller.id) return true
|
||||
return caller.header.cwd !== undefined && header.cwd === caller.header.cwd
|
||||
}
|
||||
|
||||
function assertObservedTargetAuthorized(
|
||||
caller: Caller,
|
||||
target: SessionIdValue,
|
||||
observed: SessionHeader,
|
||||
): void {
|
||||
if (observed.id !== target || !headerAuthorized(observed, caller)) throw unauthorizedTarget()
|
||||
}
|
||||
|
||||
async function authorizeSessionIds(
|
||||
@@ -704,28 +724,32 @@ async function authorizeSessionIds(
|
||||
|
||||
async function readTitles(
|
||||
ctx: Context,
|
||||
caller: Caller,
|
||||
ids: readonly SessionIdValue[],
|
||||
signal: AbortSignal,
|
||||
): Promise<CompleteTitleMap> {
|
||||
const result = new Map<SessionIdValue, TitleView>()
|
||||
for (const id of new Set(ids)) {
|
||||
result.set(id, await readTitle(ctx, id, signal))
|
||||
result.set(id, await readTitle(ctx, caller, id, signal))
|
||||
}
|
||||
return result as CompleteTitleMap
|
||||
}
|
||||
|
||||
async function readTitle(
|
||||
ctx: Context,
|
||||
caller: Caller,
|
||||
id: SessionIdValue,
|
||||
signal: AbortSignal,
|
||||
): Promise<TitleView> {
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
const title = await ctx.sessionQuery.readTitle(id)
|
||||
const observation = await ctx.sessionQuery.readTitleSnapshot(id)
|
||||
signal.throwIfAborted()
|
||||
return { text: title?.title ?? 'untitled' }
|
||||
assertObservedTargetAuthorized(caller, id, observation.session)
|
||||
return { text: observation.title?.title ?? 'untitled' }
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) signal.throwIfAborted()
|
||||
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 }
|
||||
@@ -866,7 +890,7 @@ function renderDescendants(
|
||||
function formatEventTrace(
|
||||
sessionId: SessionIdValue,
|
||||
title: TitleView,
|
||||
trace: SessionEventTrace,
|
||||
trace: SessionEventTraceObservation,
|
||||
): string {
|
||||
return [
|
||||
`Session ${sessionId} — ${titleText(title)}`,
|
||||
|
||||
@@ -14,6 +14,7 @@ import SessionQueryService, {
|
||||
SessionQueryError,
|
||||
SessionSearchCursor,
|
||||
type SessionEventSearchHit,
|
||||
type SessionEventSearchPage,
|
||||
type SessionEventSearchRequest,
|
||||
type SessionSearchExecContext,
|
||||
type SessionSearchHit,
|
||||
@@ -113,7 +114,10 @@ class FakeQuery extends SessionQueryService {
|
||||
static eventSearch: (
|
||||
request: SessionEventSearchRequest,
|
||||
exec?: SessionSearchExecContext,
|
||||
) => Promise<SessionSearchPage<SessionEventSearchHit>> = () => Promise.resolve({ items: [] })
|
||||
) => Promise<SessionEventSearchPage> = request => Promise.resolve({
|
||||
session: header(request.sessionId, '/work'),
|
||||
items: [],
|
||||
})
|
||||
|
||||
static sessionRequests: SessionSearchRequest[] = []
|
||||
static eventRequests: SessionEventSearchRequest[] = []
|
||||
@@ -122,7 +126,10 @@ class FakeQuery extends SessionQueryService {
|
||||
|
||||
static reset(): void {
|
||||
this.sessionSearch = () => Promise.resolve({ items: [] })
|
||||
this.eventSearch = () => Promise.resolve({ items: [] })
|
||||
this.eventSearch = request => Promise.resolve({
|
||||
session: header(request.sessionId, '/work'),
|
||||
items: [],
|
||||
})
|
||||
this.sessionRequests = []
|
||||
this.eventRequests = []
|
||||
this.searchSignals = []
|
||||
@@ -141,22 +148,25 @@ class FakeQuery extends SessionQueryService {
|
||||
override searchEvents(
|
||||
request: SessionEventSearchRequest,
|
||||
exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionEventSearchHit>> {
|
||||
): Promise<SessionEventSearchPage> {
|
||||
FakeQuery.eventRequests.push(request)
|
||||
FakeQuery.searchSignals.push(exec?.signal)
|
||||
return FakeQuery.eventSearch(request, exec)
|
||||
}
|
||||
|
||||
override async readTitle(sessionId: SessionIdValue) {
|
||||
override async readTitleSnapshot(sessionId: SessionIdValue) {
|
||||
const value = FakeQuery.titles.get(sessionId)
|
||||
if (value instanceof Error) throw value
|
||||
if (value === undefined) return super.readTitle(sessionId)
|
||||
if (value === undefined) return super.readTitleSnapshot(sessionId)
|
||||
return {
|
||||
title: value,
|
||||
messageSeqs: [],
|
||||
source: { kind: 'fallback' as const },
|
||||
eventSeq: 0,
|
||||
updatedAt: 1,
|
||||
session: (await this.readSurface(sessionId)).session,
|
||||
title: {
|
||||
title: value,
|
||||
messageSeqs: [],
|
||||
source: { kind: 'fallback' as const },
|
||||
eventSeq: 0,
|
||||
updatedAt: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -477,6 +487,69 @@ describe('workspace authority and lineage redaction', () => {
|
||||
expect(output).toContain(mounted.caller.id)
|
||||
expect(output).toContain('persisted')
|
||||
})
|
||||
|
||||
it('rejects every payload observation whose target moved after pre-authorization', async () => {
|
||||
const mounted = await mount()
|
||||
const target = createSession(mounted.ctx, 'moving-target', '/work')
|
||||
target.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'authorized payload' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
const movedHeader = header(target.id, '/outside')
|
||||
|
||||
FakeQuery.eventSearch = () => Promise.resolve({
|
||||
session: movedHeader,
|
||||
items: [eventHit(target.id, 0, 'secret event hit')],
|
||||
})
|
||||
const search = await mounted.call('session_event_search', {
|
||||
session_id: target.id,
|
||||
query: 'secret',
|
||||
})
|
||||
expect(errorCode(search)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
|
||||
expect(text(search)).not.toContain('secret event hit')
|
||||
|
||||
const lineage = await mounted.ctx.sessionQuery.traceSession(target.id)
|
||||
vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockResolvedValueOnce({
|
||||
...lineage,
|
||||
target: { ...lineage.target, header: movedHeader },
|
||||
})
|
||||
expect(errorCode(await mounted.call('session_trace', { session_id: target.id })))
|
||||
.toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
|
||||
|
||||
const eventTrace = await mounted.ctx.sessionQuery.traceEvent({ sessionId: target.id, seq: 0 })
|
||||
vi.spyOn(mounted.ctx.sessionQuery, 'traceEvent').mockResolvedValueOnce({
|
||||
...eventTrace,
|
||||
session: movedHeader,
|
||||
})
|
||||
expect(errorCode(await mounted.call('session_event_trace', { session_id: target.id, seq: 0 })))
|
||||
.toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
|
||||
|
||||
const eventWindow = await mounted.ctx.sessionQuery.readEvent({ sessionId: target.id, seq: 0 })
|
||||
vi.spyOn(mounted.ctx.sessionQuery, 'readEvent').mockResolvedValueOnce({
|
||||
...eventWindow,
|
||||
session: movedHeader,
|
||||
})
|
||||
expect(errorCode(await mounted.call('session_event_read', { session_id: target.id, seq: 0 })))
|
||||
.toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
|
||||
|
||||
FakeQuery.sessionSearch = () => Promise.resolve({
|
||||
items: [sessionHit(target.id, '/work', 'safe hit')],
|
||||
})
|
||||
vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockResolvedValueOnce({
|
||||
session: movedHeader,
|
||||
title: {
|
||||
title: 'secret moved title',
|
||||
messageSeqs: [],
|
||||
source: { kind: 'fallback' },
|
||||
eventSeq: 0,
|
||||
updatedAt: 1,
|
||||
},
|
||||
})
|
||||
const titled = await mounted.call('session_search', { query: 'safe' })
|
||||
expect(errorCode(titled)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
|
||||
expect(text(titled)).not.toContain('secret moved title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('search paging, prior-history bounds, titles, and cancellation', () => {
|
||||
@@ -590,6 +663,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () =>
|
||||
it('intersects current-session search with the event before the latest step and leaves other targets unchanged', async () => {
|
||||
const mounted = await mount()
|
||||
FakeQuery.eventSearch = request => Promise.resolve({
|
||||
session: header(request.sessionId, '/work'),
|
||||
items: [eventHit(request.sessionId, 1)],
|
||||
})
|
||||
await mounted.call('session_event_search', {
|
||||
@@ -633,8 +707,15 @@ describe('search paging, prior-history bounds, titles, and cancellation', () =>
|
||||
const other = createSession(mounted.ctx, 'paged-events', '/work')
|
||||
const cursor = SessionSearchCursor('events-next')
|
||||
FakeQuery.eventSearch = request => request.cursor === undefined
|
||||
? Promise.resolve({ items: [eventHit(other.id, 1)], nextCursor: cursor })
|
||||
: Promise.resolve({ items: [eventHit(other.id, 2), eventHit(other.id, 3)] })
|
||||
? Promise.resolve({
|
||||
session: header(other.id, '/work'),
|
||||
items: [eventHit(other.id, 1)],
|
||||
nextCursor: cursor,
|
||||
})
|
||||
: Promise.resolve({
|
||||
session: header(other.id, '/work'),
|
||||
items: [eventHit(other.id, 2), eventHit(other.id, 3)],
|
||||
})
|
||||
const result = await mounted.call('session_event_search', {
|
||||
session_id: other.id,
|
||||
query: 'q',
|
||||
@@ -663,7 +744,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () =>
|
||||
const second = createSession(mounted.ctx, 'stackless-title', '/work')
|
||||
const stackless = new Error('stackless')
|
||||
Object.defineProperty(stackless, 'stack', { value: undefined })
|
||||
const readTitle = vi.spyOn(mounted.ctx.sessionQuery, 'readTitle')
|
||||
const readTitle = vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot')
|
||||
.mockRejectedValueOnce('string failure')
|
||||
.mockRejectedValueOnce(stackless)
|
||||
FakeQuery.sessionSearch = () => Promise.resolve({
|
||||
@@ -685,7 +766,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () =>
|
||||
const hit = createSession(mounted.ctx, 'abort-title', '/work')
|
||||
const controller = new AbortController()
|
||||
FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] })
|
||||
vi.spyOn(mounted.ctx.sessionQuery, 'readTitle').mockImplementation(() => {
|
||||
vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockImplementation(() => {
|
||||
controller.abort()
|
||||
return Promise.reject(new Error('cancelled title'))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user