workspace-access.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. /**
  2. * Caller identity, workspace authorization, and visible lineage projection.
  3. *
  4. * @module @deepseek-ai/dsh-tool-session-query/workspace-access
  5. */
  6. import type { Context } from '@deepseek-ai/cordis'
  7. import { brandString } from '@deepseek-ai/dsh-brand'
  8. import { HarnessError } from '@deepseek-ai/dsh-llm'
  9. import {
  10. type SessionEvent,
  11. type SessionHeader,
  12. type SessionId as SessionIdValue,
  13. } from '@deepseek-ai/dsh-session'
  14. import type { TurnBoundaryProjection } from '@deepseek-ai/dsh-agent'
  15. import type {
  16. SessionLineageNode,
  17. SessionRecord,
  18. } from '@deepseek-ai/dsh-session-query'
  19. import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
  20. import type {} from '@deepseek-ai/dsh-session-projection'
  21. import { serviceBoundary } from './service-boundary.ts'
  22. interface Caller {
  23. readonly id: SessionIdValue
  24. readonly header: SessionHeader
  25. readonly events: readonly SessionEvent[]
  26. /** The caller's own-session boundary fold (the `turnBoundary` projection). */
  27. readonly boundary: TurnBoundaryProjection | undefined
  28. }
  29. interface TitleView {
  30. readonly text: string
  31. readonly unavailableCode?: string
  32. }
  33. interface CompleteTitleMap extends ReadonlyMap<SessionIdValue, TitleView> {
  34. get(id: SessionIdValue): TitleView
  35. }
  36. interface AuthorizedDescendant {
  37. readonly record: SessionRecord
  38. readonly descendants: Array<AuthorizedDescendant | null>
  39. }
  40. interface DescendantProjectionFrame {
  41. readonly node: SessionLineageNode
  42. readonly target: Array<AuthorizedDescendant | null>
  43. readonly next: DescendantProjectionFrame | undefined
  44. }
  45. interface DescendantVisit {
  46. readonly node: AuthorizedDescendant | null
  47. readonly depth: number
  48. readonly next: DescendantVisit | undefined
  49. }
  50. function callerOf(exec: ToolRunContext, ctx: Context): Caller {
  51. const agent = exec.agent
  52. if (agent === undefined) {
  53. throw new HarnessError(
  54. 'session query tools require an agent-bound caller',
  55. 'SESSION_QUERY_TOOL_MISSING_AGENT',
  56. )
  57. }
  58. return {
  59. id: agent.session.id,
  60. header: agent.session.header,
  61. events: agent.session.events,
  62. boundary: ctx.sessionProjections.stateOf(agent.session, 'turnBoundary'),
  63. }
  64. }
  65. function targetId(args: { readonly session_id?: string }, caller: Caller): SessionIdValue {
  66. return args.session_id === undefined ? caller.id : brandString<SessionIdValue>(args.session_id)
  67. }
  68. async function authorizeTarget(
  69. ctx: Context,
  70. caller: Caller,
  71. target: SessionIdValue,
  72. signal: AbortSignal,
  73. ): Promise<void> {
  74. if (target === caller.id) return
  75. const cwd = caller.header.cwd
  76. if (cwd === undefined) throw serviceBoundary.unauthorizedTarget()
  77. const records = await serviceBoundary.call(ctx, signal, 'target authorization', () =>
  78. ctx.sessionQuery.filterSessions([
  79. { kind: 'id', values: [target] },
  80. { kind: 'cwd', values: [cwd] },
  81. ], signal))
  82. if (records.length !== 1) throw serviceBoundary.unauthorizedTarget()
  83. }
  84. function recordAuthorized(record: SessionRecord, caller: Caller): boolean {
  85. return headerAuthorized(record.header, caller)
  86. }
  87. function headerAuthorized(header: SessionHeader, caller: Caller): boolean {
  88. if (header.id === caller.id) return header.cwd === caller.header.cwd
  89. return caller.header.cwd !== undefined && header.cwd === caller.header.cwd
  90. }
  91. function assertObservedTargetAuthorized(
  92. caller: Caller,
  93. target: SessionIdValue,
  94. observed: SessionHeader,
  95. ): void {
  96. if (observed.id !== target || !headerAuthorized(observed, caller)) {
  97. throw serviceBoundary.unauthorizedTarget()
  98. }
  99. }
  100. async function authorizeSessionIds(
  101. ctx: Context,
  102. caller: Caller,
  103. ids: readonly SessionIdValue[],
  104. signal: AbortSignal,
  105. ): Promise<ReadonlySet<SessionIdValue>> {
  106. const unique = [...new Set(ids)]
  107. const authorized = new Set<SessionIdValue>()
  108. if (unique.includes(caller.id)) authorized.add(caller.id)
  109. const cwd = caller.header.cwd
  110. const other = unique.filter(id => id !== caller.id)
  111. if (cwd === undefined || other.length === 0) return authorized
  112. const records = await serviceBoundary.call(ctx, signal, 'session-id authorization', () =>
  113. ctx.sessionQuery.filterSessions([
  114. { kind: 'id', values: other },
  115. { kind: 'cwd', values: [cwd] },
  116. ], signal))
  117. const requested = new Set(other)
  118. for (const record of records) {
  119. if (requested.has(record.header.id) && recordAuthorized(record, caller)) {
  120. authorized.add(record.header.id)
  121. }
  122. }
  123. return authorized
  124. }
  125. async function readTitles(
  126. ctx: Context,
  127. caller: Caller,
  128. ids: readonly SessionIdValue[],
  129. signal: AbortSignal,
  130. ): Promise<CompleteTitleMap> {
  131. const result = new Map<SessionIdValue, TitleView>()
  132. const observations = await serviceBoundary.call(ctx, signal, 'title observation', () =>
  133. ctx.sessionQuery.readTitleSnapshots(ids, signal))
  134. for (const observation of observations) {
  135. if (observation.status === 'rejected') {
  136. result.set(observation.sessionId, unavailableTitle(ctx, observation.reason))
  137. continue
  138. }
  139. assertObservedTargetAuthorized(caller, observation.sessionId, observation.value.session)
  140. result.set(observation.sessionId, { text: observation.value.title?.title ?? 'untitled' })
  141. }
  142. return result as CompleteTitleMap
  143. }
  144. async function readTitle(
  145. ctx: Context,
  146. caller: Caller,
  147. id: SessionIdValue,
  148. signal: AbortSignal,
  149. ): Promise<TitleView> {
  150. return (await readTitles(ctx, caller, [id], signal)).get(id)
  151. }
  152. function unavailableTitle(
  153. ctx: Context,
  154. error: unknown,
  155. ): TitleView {
  156. const sanitized = serviceBoundary.sanitizeError(ctx, 'title observation item', error)
  157. if (sanitized.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw sanitized
  158. return { text: 'untitled', unavailableCode: sanitized.code }
  159. }
  160. function authorizeDescendants(
  161. nodes: readonly SessionLineageNode[],
  162. caller: Caller,
  163. ): Array<AuthorizedDescendant | null> {
  164. const result: Array<AuthorizedDescendant | null> = []
  165. let pending: DescendantProjectionFrame | undefined
  166. for (const node of [...nodes].reverse()) {
  167. pending = { node, target: result, next: pending }
  168. }
  169. while (pending !== undefined) {
  170. const current = pending
  171. pending = current.next
  172. if (!recordAuthorized(current.node.session, caller)) {
  173. current.target.push(null)
  174. continue
  175. }
  176. const projected: AuthorizedDescendant = {
  177. record: current.node.session,
  178. descendants: [],
  179. }
  180. current.target.push(projected)
  181. for (const child of [...current.node.descendants].reverse()) {
  182. pending = {
  183. node: child,
  184. target: projected.descendants,
  185. next: pending,
  186. }
  187. }
  188. }
  189. return result
  190. }
  191. function * visitDescendants(
  192. nodes: readonly (AuthorizedDescendant | null)[],
  193. ): Generator<DescendantVisit> {
  194. let pending: DescendantVisit | undefined
  195. for (const node of [...nodes].reverse()) {
  196. pending = { node, depth: 0, next: pending }
  197. }
  198. while (pending !== undefined) {
  199. const current = pending
  200. pending = current.next
  201. yield current
  202. if (current.node === null) continue
  203. for (const child of [...current.node.descendants].reverse()) {
  204. pending = {
  205. node: child,
  206. depth: current.depth + 1,
  207. next: pending,
  208. }
  209. }
  210. }
  211. }
  212. function descendantIds(nodes: readonly (AuthorizedDescendant | null)[]): SessionIdValue[] {
  213. const ids: SessionIdValue[] = []
  214. for (const { node } of visitDescendants(nodes)) {
  215. if (node !== null) ids.push(node.record.header.id)
  216. }
  217. return ids
  218. }
  219. function titleText(view: TitleView): string {
  220. return view.unavailableCode === undefined
  221. ? view.text
  222. : `${view.text} (title unavailable: ${view.unavailableCode})`
  223. }
  224. /** Workspace-scoped caller authorization, title access, and lineage projection. */
  225. export const workspaceAccess = {
  226. callerOf,
  227. targetId,
  228. authorizeTarget,
  229. recordAuthorized,
  230. assertObservedTargetAuthorized,
  231. authorizeSessionIds,
  232. readTitles,
  233. readTitle,
  234. authorizeDescendants,
  235. visitDescendants,
  236. descendantIds,
  237. titleText,
  238. }