workspace-access.ts 7.6 KB

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