realm.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. /**
  2. * The engine's value boundary: copy script-realm values into plain JSON data
  3. * — loud about everything JSON cannot carry — and render thrown script
  4. * values to failure text. The script runs in a vm context INSIDE the worker
  5. * thread, so "host" here means the worker-side JavaScript around that
  6. * context; everything that later crosses the thread boundary is JSON by this
  7. * walk, which is what makes the postMessage hop total.
  8. *
  9. * TRUST PREMISE (everything in this module hangs on it): workflow scripts are
  10. * MODEL-WRITTEN, the same trust level as the model's existing bash access, so
  11. * this boundary guards against BUGGY scripts, not hostile ones. It rejects
  12. * loud what JSON would silently mangle — functions, symbols, bigints,
  13. * non-finite numbers, nested `undefined`, cycles, sparse arrays, exotic
  14. * prototypes — because accepted-then-ignored is this repo's banned failure
  15. * mode. It does NOT defend against adversarial values: the walk reads
  16. * properties ordinarily (a getter runs, and whatever it returns is what
  17. * crosses), {@link renderThrown} reads `stack`/`message`/`String()` directly,
  18. * and a proxy is walked through its traps. A hostile script gains nothing
  19. * worth defending here — the vm context inside the worker is escapable by
  20. * construction, so hostile-value containment would be cost without a threat
  21. * model (what the worker thread DOES buy is that a spin occupies the
  22. * worker's loop, not the host's, and termination is real).
  23. *
  24. * The host→realm direction needs no machinery at all: hooks hand the script
  25. * plain values of the worker realm, prototypes included — the script is
  26. * trusted. One consequence is documented in the engine README: an error
  27. * thrown by a hook is built OUTSIDE the script's vm context, so an in-script
  28. * `instanceof Error` check is false; read `name`/`code`/`message` instead.
  29. *
  30. * @module @deepseek-ai/dsh-workflow-workerthread/realm
  31. */
  32. /** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */
  33. export class MaterializeError extends Error {
  34. constructor(public readonly path: string, public readonly reason: string) {
  35. super(`${path}: ${reason}`)
  36. this.name = 'MaterializeError'
  37. }
  38. }
  39. /**
  40. * Render a thrown value to failure text without ever throwing: prefer the
  41. * `stack` (host or realm — a realm error's `stack` is a plain string read),
  42. * fall back to `message`, then `String()`. Reading those properties MAY run
  43. * script code (a getter, `toString`) — accepted under the module's trust
  44. * premise; if that code itself throws, a fixed label is returned instead.
  45. * @param error - the thrown value, of any shape and any realm.
  46. * @returns human-readable text for the failure report; prefers the stack.
  47. */
  48. export function renderThrown(error: unknown): string {
  49. try {
  50. const stack = (error as { stack?: unknown } | null | undefined)?.stack
  51. if (typeof stack === 'string' && stack.length > 0) return stack
  52. const message = (error as { message?: unknown } | null | undefined)?.message
  53. if (typeof message === 'string' && message.length > 0) return message
  54. return String(error)
  55. } catch {
  56. // A throwing accessor/toString on the thrown value — rendering must be
  57. // total (drive()'s never-reject contract), so fall back to a fixed label.
  58. return '[unrenderable thrown value]'
  59. }
  60. }
  61. /**
  62. * Whether an object's prototype chain is data-shaped: `null`, or a prototype
  63. * whose own prototype is `null` (the realm's `Object.prototype` — which we
  64. * cannot compare by identity across realms). A `Date`/`Map`/class instance
  65. * has a longer chain and is rejected.
  66. */
  67. function hasPlainPrototype(value: object): boolean {
  68. const proto: unknown = Object.getPrototypeOf(value)
  69. if (proto === null) return true
  70. return Object.getPrototypeOf(proto) === null
  71. }
  72. /**
  73. * Copy `value` (typically from the vm realm) into plain host JSON data.
  74. * Throws {@link MaterializeError} naming the offending path for anything JSON
  75. * cannot carry losslessly. Properties are read ordinarily — a getter runs and
  76. * its RESULT is materialized; a read that throws surfaces as a
  77. * {@link MaterializeError} carrying the rendered failure. `undefined` is
  78. * accepted only at the ROOT (a script with no `return` value) — the caller
  79. * decides what it means; an `undefined` nested INSIDE a container is a
  80. * violation.
  81. * @param value - the realm value to materialize.
  82. * @param root - the path label for the root value (error messages).
  83. * @returns the host-realm copy (plain objects/arrays/scalars only).
  84. */
  85. export function materializeFromRealm(value: unknown, root = 'value'): unknown {
  86. if (value === undefined) return undefined
  87. try {
  88. return materialize(value, root, new Set())
  89. } catch (error: unknown) {
  90. if (error instanceof MaterializeError) throw error
  91. // A property read ran script code that threw; total-ize it so callers can
  92. // keep the narrow MaterializeError contract.
  93. throw new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`)
  94. }
  95. }
  96. function materialize(value: unknown, path: string, seen: Set<object>): unknown {
  97. switch (typeof value) {
  98. case 'boolean':
  99. case 'string':
  100. return value
  101. case 'number': {
  102. if (!Number.isFinite(value)) throw new MaterializeError(path, 'non-finite numbers are not JSON data')
  103. return value
  104. }
  105. case 'bigint':
  106. throw new MaterializeError(path, 'bigints are not JSON data')
  107. case 'function':
  108. throw new MaterializeError(path, 'functions cannot cross the workflow value boundary')
  109. case 'symbol':
  110. throw new MaterializeError(path, 'symbols cannot cross the workflow value boundary')
  111. case 'undefined':
  112. throw new MaterializeError(path, 'undefined is not JSON data')
  113. case 'object':
  114. break
  115. }
  116. if (value === null) return null
  117. const objectValue: object = value
  118. if (seen.has(objectValue)) throw new MaterializeError(path, 'circular references are not JSON data')
  119. seen.add(objectValue)
  120. try {
  121. if (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen)
  122. return materializeObject(objectValue, path, seen)
  123. } finally {
  124. seen.delete(objectValue)
  125. }
  126. }
  127. function materializeArray(value: unknown[], path: string, seen: Set<object>): unknown[] {
  128. const out: unknown[] = []
  129. for (let index = 0; index < value.length; index++) {
  130. if (!(index in value)) throw new MaterializeError(`${path}[${index}]`, 'sparse arrays are not JSON data')
  131. out.push(materialize(value[index], `${path}[${index}]`, seen))
  132. }
  133. // Own enumerable props beyond the indices (e.g. `arr.total = 3`) would be
  134. // silently dropped by JSON — reject them instead.
  135. for (const key of Object.keys(value)) {
  136. const index = Number(key)
  137. if (!Number.isInteger(index) || index < 0 || index >= value.length) {
  138. throw new MaterializeError(`${path}.${key}`, 'arrays with non-index properties are not JSON data')
  139. }
  140. }
  141. if (Object.getOwnPropertySymbols(value).length > 0) {
  142. throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow value boundary')
  143. }
  144. return out
  145. }
  146. function materializeObject(value: object, path: string, seen: Set<object>): Record<string, unknown> {
  147. if (!hasPlainPrototype(value)) {
  148. throw new MaterializeError(path, 'only plain objects and arrays are JSON data (exotic prototype)')
  149. }
  150. if (Object.getOwnPropertySymbols(value).length > 0) {
  151. throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow value boundary')
  152. }
  153. const out: Record<string, unknown> = {}
  154. // Object.keys = own enumerable string keys, matching JSON.stringify's
  155. // property selection exactly (non-enumerable props never reach JSON output).
  156. for (const key of Object.keys(value)) {
  157. // defineProperty, never assignment: a "__proto__" key must become an OWN
  158. // data property of the copy, not a prototype mutation.
  159. Object.defineProperty(out, key, {
  160. value: materialize((value as Record<string, unknown>)[key], `${path}.${key}`, seen),
  161. enumerable: true,
  162. writable: true,
  163. configurable: true,
  164. })
  165. }
  166. return out
  167. }