workspace-access.ts 7.3 KB

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