realm.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. /**
  2. * Materializes values leaving the script vm into plain JSON before they cross the worker
  3. * boundary, and renders thrown script values without rejecting the run. The walk rejects
  4. * values that JSON cannot preserve but trusts model-written workflow scripts: getters and proxy traps may
  5. * run, and the vm is not a security boundary. The worker provides host-loop isolation and
  6. * forced termination, not hostile-value containment. See
  7. * .agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md for the isolation rationale.
  8. * @module @deepseek-ai/dsh-workflow-worker-thread/realm
  9. */
  10. /** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */
  11. export class MaterializeError extends Error {
  12. constructor(public readonly path: string, public readonly reason: string) {
  13. super(`${path}: ${reason}`)
  14. this.name = 'MaterializeError'
  15. }
  16. }
  17. /**
  18. * Render a thrown value to failure text without ever throwing: prefer the
  19. * `stack` (host or realm — a realm error's `stack` is a plain string read),
  20. * fall back to `message`, then `String()`. Reading those properties MAY run
  21. * script code (a getter, `toString`) — accepted under the module's trust
  22. * premise; if that code itself throws, a fixed label is returned instead.
  23. * @param error - any value thrown in the host or worker realm.
  24. * @returns human-readable text for the failure report; prefers the stack.
  25. */
  26. export function renderThrown(error: unknown): string {
  27. try {
  28. const stack = (error as { stack?: unknown } | null | undefined)?.stack
  29. if (typeof stack === 'string' && stack.length > 0) return stack
  30. const message = (error as { message?: unknown } | null | undefined)?.message
  31. if (typeof message === 'string' && message.length > 0) return message
  32. return String(error)
  33. } catch {
  34. // A throwing accessor/toString on the thrown value — rendering must be
  35. // total (drive()'s never-reject contract), so fall back to a fixed label.
  36. return '[unrenderable thrown value]'
  37. }
  38. }
  39. /**
  40. * Whether an object's prototype chain represents a plain data object: `null`, or a prototype
  41. * whose own prototype is `null` (the realm's `Object.prototype` — which we
  42. * cannot compare by identity across realms). A `Date`/`Map`/class instance
  43. * has a longer chain and is rejected.
  44. */
  45. function hasPlainPrototype(value: object): boolean {
  46. const proto: unknown = Object.getPrototypeOf(value)
  47. if (proto === null) return true
  48. return Object.getPrototypeOf(proto) === null
  49. }
  50. /**
  51. * Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is
  52. * returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail
  53. * with the offending path. Property accessors run normally, and a throwing read is wrapped
  54. * with its rendered failure.
  55. *
  56. * @param value - the realm value to materialize.
  57. * @param root - the path label for the root value (error messages).
  58. * @returns the host-realm copy (plain objects/arrays/scalars only).
  59. * @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic
  60. * prototypes, or property reads that throw.
  61. */
  62. export function materializeFromRealm(value: unknown, root = 'value'): unknown {
  63. if (value === undefined) return undefined
  64. try {
  65. return materialize(value, root, new Set())
  66. } catch (error: unknown) {
  67. if (error instanceof MaterializeError) throw error
  68. // A property read ran script code that threw; total-ize it so callers can
  69. // keep the narrow MaterializeError contract.
  70. throw new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`)
  71. }
  72. }
  73. function materialize(value: unknown, path: string, seen: Set<object>): unknown {
  74. switch (typeof value) {
  75. case 'boolean':
  76. case 'string':
  77. return value
  78. case 'number': {
  79. if (!Number.isFinite(value)) throw new MaterializeError(path, 'non-finite numbers are not JSON data')
  80. return value
  81. }
  82. case 'bigint':
  83. throw new MaterializeError(path, 'bigints are not JSON data')
  84. case 'function':
  85. throw new MaterializeError(path, 'functions are not plain JSON data')
  86. case 'symbol':
  87. throw new MaterializeError(path, 'symbols are not plain JSON data')
  88. case 'undefined':
  89. throw new MaterializeError(path, 'undefined is not JSON data')
  90. case 'object':
  91. break
  92. }
  93. if (value === null) return null
  94. const objectValue: object = value
  95. if (seen.has(objectValue)) throw new MaterializeError(path, 'circular references are not JSON data')
  96. seen.add(objectValue)
  97. try {
  98. if (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen)
  99. return materializeObject(objectValue, path, seen)
  100. } finally {
  101. seen.delete(objectValue)
  102. }
  103. }
  104. function materializeArray(value: unknown[], path: string, seen: Set<object>): unknown[] {
  105. const out: unknown[] = []
  106. for (let index = 0; index < value.length; index++) {
  107. if (!(index in value)) throw new MaterializeError(`${path}[${index}]`, 'sparse arrays are not JSON data')
  108. out.push(materialize(value[index], `${path}[${index}]`, seen))
  109. }
  110. // Own enumerable props beyond the indices (e.g. `arr.total = 3`) would be
  111. // silently dropped by JSON — reject them instead.
  112. for (const key of Object.keys(value)) {
  113. const index = Number(key)
  114. if (!Number.isInteger(index) || index < 0 || index >= value.length) {
  115. throw new MaterializeError(`${path}.${key}`, 'arrays with non-index properties are not JSON data')
  116. }
  117. }
  118. if (Object.getOwnPropertySymbols(value).length > 0) {
  119. throw new MaterializeError(path, 'symbol-keyed properties are not plain JSON data')
  120. }
  121. return out
  122. }
  123. function materializeObject(value: object, path: string, seen: Set<object>): Record<string, unknown> {
  124. if (!hasPlainPrototype(value)) {
  125. throw new MaterializeError(path, 'only plain objects and arrays are JSON data (exotic prototype)')
  126. }
  127. if (Object.getOwnPropertySymbols(value).length > 0) {
  128. throw new MaterializeError(path, 'symbol-keyed properties are not plain JSON data')
  129. }
  130. const out: Record<string, unknown> = {}
  131. // Object.keys = own enumerable string keys, matching JSON.stringify's
  132. // property selection exactly (non-enumerable props never reach JSON output).
  133. for (const key of Object.keys(value)) {
  134. // defineProperty, never assignment: a "__proto__" key must become an OWN
  135. // data property of the copy, not a prototype mutation.
  136. Object.defineProperty(out, key, {
  137. value: materialize((value as Record<string, unknown>)[key], `${path}.${key}`, seen),
  138. enumerable: true,
  139. writable: true,
  140. configurable: true,
  141. })
  142. }
  143. return out
  144. }