@namzu/sdk 60.0.0 → 60.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/agents/QueryAgent.d.ts.map +1 -1
  3. package/dist/agents/QueryAgent.js +1 -0
  4. package/dist/agents/QueryAgent.js.map +1 -1
  5. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  6. package/dist/bridge/a2a/mapper.js +19 -0
  7. package/dist/bridge/a2a/mapper.js.map +1 -1
  8. package/dist/bridge/acp/update.d.ts.map +1 -1
  9. package/dist/bridge/acp/update.js +1 -0
  10. package/dist/bridge/acp/update.js.map +1 -1
  11. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  12. package/dist/bridge/sse/mapper.js +28 -0
  13. package/dist/bridge/sse/mapper.js.map +1 -1
  14. package/dist/constants/continuation.d.ts +9 -0
  15. package/dist/constants/continuation.d.ts.map +1 -1
  16. package/dist/constants/continuation.js +9 -0
  17. package/dist/constants/continuation.js.map +1 -1
  18. package/dist/contracts/session/api.d.ts +8 -2
  19. package/dist/contracts/session/api.d.ts.map +1 -1
  20. package/dist/manager/session/turn-recorder.d.ts +7 -1
  21. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  22. package/dist/manager/session/turn-recorder.js +20 -0
  23. package/dist/manager/session/turn-recorder.js.map +1 -1
  24. package/dist/peers/dir.d.ts.map +1 -1
  25. package/dist/peers/dir.js +3 -0
  26. package/dist/peers/dir.js.map +1 -1
  27. package/dist/provider/collect-chat-completion.d.ts.map +1 -1
  28. package/dist/provider/collect-chat-completion.js +4 -0
  29. package/dist/provider/collect-chat-completion.js.map +1 -1
  30. package/dist/provider/fallback.d.ts.map +1 -1
  31. package/dist/provider/fallback.js +11 -0
  32. package/dist/provider/fallback.js.map +1 -1
  33. package/dist/provider/idle-timeout.d.ts.map +1 -1
  34. package/dist/provider/idle-timeout.js +5 -0
  35. package/dist/provider/idle-timeout.js.map +1 -1
  36. package/dist/provider/mock.d.ts +2 -0
  37. package/dist/provider/mock.d.ts.map +1 -1
  38. package/dist/provider/mock.js +16 -8
  39. package/dist/provider/mock.js.map +1 -1
  40. package/dist/provider/model-window-suffix.d.ts.map +1 -1
  41. package/dist/provider/model-window-suffix.js +1 -0
  42. package/dist/provider/model-window-suffix.js.map +1 -1
  43. package/dist/provider/retry.d.ts.map +1 -1
  44. package/dist/provider/retry.js +5 -0
  45. package/dist/provider/retry.js.map +1 -1
  46. package/dist/public-runtime.d.ts +1 -0
  47. package/dist/public-runtime.d.ts.map +1 -1
  48. package/dist/public-runtime.js +1 -0
  49. package/dist/public-runtime.js.map +1 -1
  50. package/dist/runtime/query/events.d.ts.map +1 -1
  51. package/dist/runtime/query/events.js +17 -0
  52. package/dist/runtime/query/events.js.map +1 -1
  53. package/dist/runtime/query/executor.d.ts +23 -0
  54. package/dist/runtime/query/executor.d.ts.map +1 -1
  55. package/dist/runtime/query/executor.js +88 -9
  56. package/dist/runtime/query/executor.js.map +1 -1
  57. package/dist/runtime/query/index.d.ts +14 -0
  58. package/dist/runtime/query/index.d.ts.map +1 -1
  59. package/dist/runtime/query/index.js +14 -0
  60. package/dist/runtime/query/index.js.map +1 -1
  61. package/dist/runtime/query/iteration/effort-clamp.d.ts +13 -0
  62. package/dist/runtime/query/iteration/effort-clamp.d.ts.map +1 -0
  63. package/dist/runtime/query/iteration/effort-clamp.js +42 -0
  64. package/dist/runtime/query/iteration/effort-clamp.js.map +1 -0
  65. package/dist/runtime/query/iteration/index.d.ts +34 -0
  66. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  67. package/dist/runtime/query/iteration/index.js +199 -6
  68. package/dist/runtime/query/iteration/index.js.map +1 -1
  69. package/dist/runtime/query/iteration/phases/context.d.ts +2 -0
  70. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  71. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  72. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  73. package/dist/runtime/query/iteration/phases/tool-review.js +6 -0
  74. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  75. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  76. package/dist/runtime/query/iteration/stream-turn.js +6 -0
  77. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  78. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  79. package/dist/runtime/query/review-policy.js +12 -2
  80. package/dist/runtime/query/review-policy.js.map +1 -1
  81. package/dist/runtime/query/tooling.d.ts +2 -0
  82. package/dist/runtime/query/tooling.d.ts.map +1 -1
  83. package/dist/runtime/query/tooling.js +1 -0
  84. package/dist/runtime/query/tooling.js.map +1 -1
  85. package/dist/sandbox/provider/local.d.ts.map +1 -1
  86. package/dist/sandbox/provider/local.js +6 -5
  87. package/dist/sandbox/provider/local.js.map +1 -1
  88. package/dist/session/paths.d.ts +15 -22
  89. package/dist/session/paths.d.ts.map +1 -1
  90. package/dist/session/paths.js +19 -46
  91. package/dist/session/paths.js.map +1 -1
  92. package/dist/store/memory/disk.d.ts.map +1 -1
  93. package/dist/store/memory/disk.js +1 -1
  94. package/dist/store/memory/disk.js.map +1 -1
  95. package/dist/store/memory/markdown.d.ts.map +1 -1
  96. package/dist/store/memory/markdown.js +1 -1
  97. package/dist/store/memory/markdown.js.map +1 -1
  98. package/dist/store/memory/operation-lock.d.ts +20 -4
  99. package/dist/store/memory/operation-lock.d.ts.map +1 -1
  100. package/dist/store/memory/operation-lock.js +235 -58
  101. package/dist/store/memory/operation-lock.js.map +1 -1
  102. package/dist/store/session-log/core.d.ts +43 -0
  103. package/dist/store/session-log/core.d.ts.map +1 -1
  104. package/dist/store/session-log/core.js +190 -3
  105. package/dist/store/session-log/core.js.map +1 -1
  106. package/dist/store/session-log/disk.d.ts +1 -1
  107. package/dist/store/session-log/disk.d.ts.map +1 -1
  108. package/dist/store/session-log/disk.js +1 -0
  109. package/dist/store/session-log/disk.js.map +1 -1
  110. package/dist/store/session-log/index.d.ts +1 -1
  111. package/dist/store/session-log/index.d.ts.map +1 -1
  112. package/dist/store/session-log/index.js +1 -1
  113. package/dist/store/session-log/index.js.map +1 -1
  114. package/dist/tools/builtins/bash-cwd.d.ts.map +1 -1
  115. package/dist/tools/builtins/bash-cwd.js +2 -3
  116. package/dist/tools/builtins/bash-cwd.js.map +1 -1
  117. package/dist/tools/builtins/read-media.js +3 -3
  118. package/dist/tools/builtins/read-media.js.map +1 -1
  119. package/dist/turn/reporter.d.ts.map +1 -1
  120. package/dist/turn/reporter.js +13 -0
  121. package/dist/turn/reporter.js.map +1 -1
  122. package/dist/types/agent/query.d.ts +2 -0
  123. package/dist/types/agent/query.d.ts.map +1 -1
  124. package/dist/types/hitl/index.d.ts +8 -0
  125. package/dist/types/hitl/index.d.ts.map +1 -1
  126. package/dist/types/hitl/index.js.map +1 -1
  127. package/dist/types/message/index.d.ts +1 -1
  128. package/dist/types/message/index.d.ts.map +1 -1
  129. package/dist/types/message/index.js +1 -0
  130. package/dist/types/message/index.js.map +1 -1
  131. package/dist/types/provider/chat.d.ts +19 -0
  132. package/dist/types/provider/chat.d.ts.map +1 -1
  133. package/dist/types/provider/config.d.ts +7 -0
  134. package/dist/types/provider/config.d.ts.map +1 -1
  135. package/dist/types/provider/index.d.ts +1 -1
  136. package/dist/types/provider/index.d.ts.map +1 -1
  137. package/dist/types/provider/interface.d.ts +15 -0
  138. package/dist/types/provider/interface.d.ts.map +1 -1
  139. package/dist/types/provider/stream.d.ts +6 -0
  140. package/dist/types/provider/stream.d.ts.map +1 -1
  141. package/dist/types/session/events.d.ts +72 -2
  142. package/dist/types/session/events.d.ts.map +1 -1
  143. package/dist/types/session/events.js +4 -0
  144. package/dist/types/session/events.js.map +1 -1
  145. package/dist/types/session/records.d.ts +34 -34
  146. package/dist/types/session/records.d.ts.map +1 -1
  147. package/dist/types/session/records.js +5 -0
  148. package/dist/types/session/records.js.map +1 -1
  149. package/dist/types/session/stop-reason.d.ts +11 -0
  150. package/dist/types/session/stop-reason.d.ts.map +1 -1
  151. package/dist/utils/temp.d.ts +82 -0
  152. package/dist/utils/temp.d.ts.map +1 -0
  153. package/dist/utils/temp.js +196 -0
  154. package/dist/utils/temp.js.map +1 -0
  155. package/package.json +1 -1
  156. package/src/agents/QueryAgent.ts +1 -0
  157. package/src/bridge/a2a/mapper.ts +22 -0
  158. package/src/bridge/acp/update.ts +6 -1
  159. package/src/bridge/sse/mapper.ts +29 -0
  160. package/src/constants/continuation.ts +13 -0
  161. package/src/contracts/session/api.ts +8 -0
  162. package/src/manager/session/turn-recorder.ts +22 -0
  163. package/src/peers/dir.ts +3 -0
  164. package/src/provider/collect-chat-completion.ts +3 -0
  165. package/src/provider/fallback.ts +17 -0
  166. package/src/provider/idle-timeout.ts +9 -0
  167. package/src/provider/mock.ts +19 -9
  168. package/src/provider/model-window-suffix.ts +1 -0
  169. package/src/provider/retry.ts +9 -0
  170. package/src/public-runtime.ts +13 -0
  171. package/src/runtime/query/events.ts +19 -0
  172. package/src/runtime/query/executor.ts +92 -8
  173. package/src/runtime/query/index.ts +27 -0
  174. package/src/runtime/query/iteration/effort-clamp.ts +46 -0
  175. package/src/runtime/query/iteration/index.ts +260 -6
  176. package/src/runtime/query/iteration/phases/context.ts +2 -0
  177. package/src/runtime/query/iteration/phases/tool-review.ts +6 -0
  178. package/src/runtime/query/iteration/stream-turn.ts +5 -0
  179. package/src/runtime/query/review-policy.ts +13 -2
  180. package/src/runtime/query/tooling.ts +3 -0
  181. package/src/sandbox/provider/local.ts +8 -5
  182. package/src/session/paths.ts +32 -57
  183. package/src/store/memory/disk.ts +1 -0
  184. package/src/store/memory/markdown.ts +1 -0
  185. package/src/store/memory/operation-lock.ts +288 -57
  186. package/src/store/session-log/core.ts +235 -3
  187. package/src/store/session-log/disk.ts +7 -1
  188. package/src/store/session-log/index.ts +4 -0
  189. package/src/tools/builtins/bash-cwd.ts +2 -3
  190. package/src/tools/builtins/read-media.ts +3 -3
  191. package/src/turn/reporter.ts +14 -0
  192. package/src/types/agent/query.ts +2 -0
  193. package/src/types/hitl/index.ts +8 -0
  194. package/src/types/message/index.ts +5 -3
  195. package/src/types/provider/chat.ts +20 -0
  196. package/src/types/provider/config.ts +7 -0
  197. package/src/types/provider/index.ts +1 -0
  198. package/src/types/provider/interface.ts +20 -0
  199. package/src/types/provider/stream.ts +6 -0
  200. package/src/types/session/events.ts +106 -6
  201. package/src/types/session/records.ts +5 -0
  202. package/src/types/session/stop-reason.ts +11 -0
  203. package/src/utils/temp.ts +250 -0
@@ -102,7 +102,12 @@ type CoreSessionEvent =
102
102
  /** The token ledger this turn spends from, keyed by the root turn. */
103
103
  budget?: TurnBudgetBinding
104
104
  }
105
- | { type: 'iteration_started'; sessionId: SessionId; turnId: TurnId; iteration: number }
105
+ | {
106
+ type: 'iteration_started'
107
+ sessionId: SessionId
108
+ turnId: TurnId
109
+ iteration: number
110
+ }
106
111
  /**
107
112
  * Who answers when this turn asks a human, changed mid-turn.
108
113
  *
@@ -206,6 +211,40 @@ type CoreSessionEvent =
206
211
  /** The window the trigger measured against. */
207
212
  contextWindowTokens: number
208
213
  }
214
+ /**
215
+ * The session log can no longer write: the volume holding it is full, over
216
+ * its quota or read-only. The session goes on in memory and the records not
217
+ * yet written wait in a bounded buffer, so a reply is not lost, but a
218
+ * restart before the log recovers would resume from an older point. Emitted
219
+ * once on entering that state, and once more with `dropping` when the
220
+ * buffer first overflowed and its oldest records were discarded.
221
+ *
222
+ * Live only, never recorded: the log is what cannot be written.
223
+ */
224
+ | {
225
+ type: 'session_log_degraded'
226
+ sessionId: SessionId
227
+ turnId?: TurnId
228
+ cause: 'disk_full' | 'quota_exceeded' | 'read_only'
229
+ /** Records waiting to be written. */
230
+ bufferedRecords: number
231
+ bufferedBytes: number
232
+ /** The buffer overflowed and the oldest waiting records were discarded. */
233
+ dropping?: boolean
234
+ }
235
+ /**
236
+ * The session log writes again after `session_log_degraded`: the waiting
237
+ * records were written, in order. `droppedRecords` is how many were
238
+ * discarded while the buffer was full; the log has a gap where they were.
239
+ * Live only, never recorded.
240
+ */
241
+ | {
242
+ type: 'session_log_recovered'
243
+ sessionId: SessionId
244
+ turnId?: TurnId
245
+ flushedRecords: number
246
+ droppedRecords: number
247
+ }
209
248
  | {
210
249
  type: 'compaction_completed'
211
250
  sessionId: SessionId
@@ -454,6 +493,40 @@ type CoreSessionEvent =
454
493
  */
455
494
  restart?: boolean
456
495
  }
496
+ /**
497
+ * The model declined to answer, and what the turn did about it.
498
+ *
499
+ * The provider finished a reply as a refusal or a safety block. The refused
500
+ * reply is kept out of the history sent next. `outcome` says what followed:
501
+ *
502
+ * - `'fallback'`: the request is being re-run on `fallbackModel`, which the
503
+ * rest of the turn keeps. A host that owns the session's model is expected
504
+ * to keep it for later turns too and to say so.
505
+ * - `'retry'`: no other model was available and the refused reply had
506
+ * partial output, so the request is being re-run once on the same model
507
+ * with a note that the previous reply was stopped.
508
+ * - `'declined'`: nothing recovered it. The turn settles with stop reason
509
+ * `'refused'`.
510
+ *
511
+ * Like `provider_fallback`, this is the feature's honesty: a swapped model
512
+ * must be shown, not logged. It is emitted before the re-run starts.
513
+ */
514
+ | {
515
+ type: 'model_refusal'
516
+ sessionId: SessionId
517
+ turnId: TurnId
518
+ iteration: number
519
+ /** The model that declined. */
520
+ model: string
521
+ providerId: string
522
+ outcome: 'fallback' | 'retry' | 'declined'
523
+ /** The model the request moves to. Set only with `outcome: 'fallback'`. */
524
+ fallbackModel?: string
525
+ /** The provider's own classification, when it gave one. */
526
+ category?: string
527
+ /** The provider's own sentence, when it gave one. Show it; do not parse it. */
528
+ explanation?: string
529
+ }
457
530
  | {
458
531
  type: 'tool_completed'
459
532
  /** Bounded result view; omitted when output was overridden or truncated. */
@@ -892,7 +965,12 @@ type CoreSessionEvent =
892
965
  steps: PlanStep[]
893
966
  summary?: string
894
967
  }
895
- | { type: 'plan_approved'; sessionId: SessionId; turnId: TurnId; planId: PlanId }
968
+ | {
969
+ type: 'plan_approved'
970
+ sessionId: SessionId
971
+ turnId: TurnId
972
+ planId: PlanId
973
+ }
896
974
  | {
897
975
  type: 'plan_rejected'
898
976
  sessionId: SessionId
@@ -923,7 +1001,12 @@ type CoreSessionEvent =
923
1001
  * through `onContextCreated`, so they proved the plan settled without ever
924
1002
  * asking whether a consumer of the EVENT STREAM could see it.
925
1003
  */
926
- | { type: 'plan_completed'; sessionId: SessionId; turnId: TurnId; planId: PlanId }
1004
+ | {
1005
+ type: 'plan_completed'
1006
+ sessionId: SessionId
1007
+ turnId: TurnId
1008
+ planId: PlanId
1009
+ }
927
1010
  /**
928
1011
  * The plan is over and it did not finish.
929
1012
  *
@@ -931,7 +1014,13 @@ type CoreSessionEvent =
931
1014
  * an event that says "failed" without saying why puts the reader back
932
1015
  * where the missing event did.
933
1016
  */
934
- | { type: 'plan_failed'; sessionId: SessionId; turnId: TurnId; planId: PlanId; reason?: string }
1017
+ | {
1018
+ type: 'plan_failed'
1019
+ sessionId: SessionId
1020
+ turnId: TurnId
1021
+ planId: PlanId
1022
+ reason?: string
1023
+ }
935
1024
  | {
936
1025
  type: 'agent_pending'
937
1026
  sessionId: SessionId
@@ -1103,7 +1192,12 @@ type CoreSessionEvent =
1103
1192
  exitCode: number
1104
1193
  durationMs: number
1105
1194
  }
1106
- | { type: 'sandbox_destroyed'; sessionId: SessionId; turnId?: TurnId; sandboxId: SandboxId }
1195
+ | {
1196
+ type: 'sandbox_destroyed'
1197
+ sessionId: SessionId
1198
+ turnId?: TurnId
1199
+ sandboxId: SandboxId
1200
+ }
1107
1201
  // ─────────────────────────────────────────────────────────────────────
1108
1202
  // v3 message + tool-input lifecycle (additive 2026-05; see
1109
1203
  // ses_001-tool-stream-events). These are not yet emitted by the
@@ -1310,6 +1404,8 @@ export type SessionEventListener = (event: SessionEvent) => void | Promise<void>
1310
1404
  * runtime-context message or tool result that carried it, and a noisy watch
1311
1405
  * would write tens of megabytes an hour of raw process output a second time.
1312
1406
  * `background_job_exited.monitor` is the durable record of how a watch ended.
1407
+ * `session_log_degraded` and `session_log_recovered` are ephemeral because the
1408
+ * log is what cannot be written while they matter.
1313
1409
  * `compaction_started` is ephemeral too: it only says a pass is about to run,
1314
1410
  * and the `compaction` record or `compaction_failed` that ends it is the
1315
1411
  * durable fact.
@@ -1321,13 +1417,15 @@ export const EPHEMERAL_EVENT_TYPES: ReadonlySet<SessionEventType> = new Set<Sess
1321
1417
  'tool_progress',
1322
1418
  'monitor_event',
1323
1419
  'compaction_started',
1420
+ 'session_log_degraded',
1421
+ 'session_log_recovered',
1324
1422
  ])
1325
1423
 
1326
1424
  export function isEphemeralEvent(event: { readonly type: string }): boolean {
1327
1425
  return EPHEMERAL_EVENT_TYPES.has(event.type as SessionEventType)
1328
1426
  }
1329
1427
 
1330
- /** The event types a session log records: every literal except the ephemeral six. */
1428
+ /** The event types a session log records: every literal except the ephemeral eight. */
1331
1429
  export type PersistedSessionEventType = Exclude<
1332
1430
  SessionEventType,
1333
1431
  | 'text_delta'
@@ -1336,4 +1434,6 @@ export type PersistedSessionEventType = Exclude<
1336
1434
  | 'tool_progress'
1337
1435
  | 'monitor_event'
1338
1436
  | 'compaction_started'
1437
+ | 'session_log_degraded'
1438
+ | 'session_log_recovered'
1339
1439
  >
@@ -236,6 +236,7 @@ const stopReason = z.enum([
236
236
  'step_refused',
237
237
  'structured_output_failed',
238
238
  'output_limit',
239
+ 'refused',
239
240
  'answer_rejected',
240
241
  'input_guardrail',
241
242
  'output_guardrail',
@@ -646,6 +647,8 @@ const SESSION_EVENT_TYPE_TABLE: Record<SessionEventType, true> = {
646
647
  request_envelope: true,
647
648
  iteration_completed: true,
648
649
  compaction_started: true,
650
+ session_log_degraded: true,
651
+ session_log_recovered: true,
649
652
  compaction_shed: true,
650
653
  compaction_completed: true,
651
654
  compaction_tool_results_cleared: true,
@@ -655,6 +658,7 @@ const SESSION_EVENT_TYPE_TABLE: Record<SessionEventType, true> = {
655
658
  hosted_tool: true,
656
659
  provider_retry: true,
657
660
  provider_fallback: true,
661
+ model_refusal: true,
658
662
  tool_completed: true,
659
663
  user_question_asked: true,
660
664
  user_question_answered: true,
@@ -749,6 +753,7 @@ const TURN_BOUND_OTHER_TYPE_LIST = [
749
753
  'hosted_tool',
750
754
  'provider_retry',
751
755
  'provider_fallback',
756
+ 'model_refusal',
752
757
  'tool_completed',
753
758
  'user_question_asked',
754
759
  'user_question_answered',
@@ -53,6 +53,17 @@ export type StopReason =
53
53
  * looking for a loop instead of at the reviewer.
54
54
  */
55
55
  | 'answer_rejected'
56
+ /**
57
+ * The model declined to answer and nothing recovered it.
58
+ *
59
+ * Its provider finished the reply as a refusal or a safety block, and the
60
+ * one automatic retry this turn allows was not available or was refused
61
+ * too. Distinct from `end_turn` so a host never reads a declined request
62
+ * as a finished one, and from `error` because nothing failed: the model
63
+ * answered, and the answer was no. The refused reply is not in the
64
+ * history; a `model_refusal` event carries what the provider said.
65
+ */
66
+ | 'refused'
56
67
  /** An input guardrail refused the turn before it started. */
57
68
  | 'input_guardrail'
58
69
  /** An output guardrail refused the produced result. */
@@ -0,0 +1,250 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { lstatSync, mkdirSync, mkdtempSync } from 'node:fs'
3
+ import { mkdtemp } from 'node:fs/promises'
4
+ import { tmpdir, userInfo } from 'node:os'
5
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
6
+
7
+ /**
8
+ * Namzu's own scratch space: the one place that decides where temporary files
9
+ * go, who may enter, and what a failure to create it says.
10
+ *
11
+ * The base is `NAMZU_TMPDIR` when it is set, otherwise the operating system's
12
+ * temporary directory. Under it, `namzu-<user>` is private to the user (mode
13
+ * 0700, refused when it is a symbolic link, not a directory, owned by someone
14
+ * else, or open to group and others). A failure is never answered by quietly
15
+ * using another directory: it is a {@link TempDirectoryError} that names the
16
+ * directory, the cause and `NAMZU_TMPDIR`.
17
+ */
18
+
19
+ /** The environment variable that moves Namzu's scratch space to another folder or disk. */
20
+ export const NAMZU_TMPDIR_ENV = 'NAMZU_TMPDIR'
21
+
22
+ export type TempDirectoryFailure =
23
+ | 'volume_full'
24
+ | 'quota_exceeded'
25
+ | 'not_writable'
26
+ | 'missing'
27
+ | 'not_absolute'
28
+ | 'refused'
29
+
30
+ /** Namzu's scratch directory cannot be used. `message` is a sentence for a person. */
31
+ export class TempDirectoryError extends Error {
32
+ override readonly name = 'TempDirectoryError'
33
+ constructor(
34
+ message: string,
35
+ readonly reason: TempDirectoryFailure,
36
+ readonly directory: string,
37
+ ) {
38
+ super(message)
39
+ }
40
+ }
41
+
42
+ export interface PrivateTempRootOptions {
43
+ /** Base directory. Default `NAMZU_TMPDIR`, else `os.tmpdir()`. */
44
+ readonly tmpdir?: string
45
+ /** Default `process.env`. */
46
+ readonly env?: NodeJS.ProcessEnv
47
+ /** Default `process.platform`. */
48
+ readonly platform?: NodeJS.Platform
49
+ /**
50
+ * Default `process.getuid`. Pass `undefined` explicitly to model a
51
+ * platform without it (win32).
52
+ */
53
+ readonly getuid?: (() => number) | undefined
54
+ /** Default `os.userInfo().username`. */
55
+ readonly username?: () => string
56
+ }
57
+
58
+ /**
59
+ * The directory under which Namzu keeps its temporary files: `NAMZU_TMPDIR`
60
+ * when set (it must be an absolute path), otherwise `os.tmpdir()`. Nothing is
61
+ * created or checked.
62
+ */
63
+ export function resolveTempBase(env: NodeJS.ProcessEnv = process.env): string {
64
+ const configured = env[NAMZU_TMPDIR_ENV]?.trim()
65
+ if (!configured) return tmpdir()
66
+ if (!isAbsolute(configured)) {
67
+ throw new TempDirectoryError(
68
+ `${NAMZU_TMPDIR_ENV} must be a full path, but it is "${configured}". Set it to a folder on a disk with free space.`,
69
+ 'not_absolute',
70
+ configured,
71
+ )
72
+ }
73
+ return configured
74
+ }
75
+
76
+ /** The sentence for a directory that could not be created or written. */
77
+ function explain(error: unknown, directory: string): TempDirectoryError | undefined {
78
+ const code = (error as NodeJS.ErrnoException | undefined)?.code
79
+ const elsewhere = `Free some space and start again, or set ${NAMZU_TMPDIR_ENV} to a folder on another disk.`
80
+ switch (code) {
81
+ case 'ENOSPC':
82
+ return new TempDirectoryError(
83
+ `The disk is full, so Namzu could not create ${directory}. ${elsewhere}`,
84
+ 'volume_full',
85
+ directory,
86
+ )
87
+ case 'EDQUOT':
88
+ return new TempDirectoryError(
89
+ `Your disk quota is used up, so Namzu could not create ${directory}. ${elsewhere}`,
90
+ 'quota_exceeded',
91
+ directory,
92
+ )
93
+ case 'EACCES':
94
+ case 'EPERM':
95
+ case 'EROFS':
96
+ return new TempDirectoryError(
97
+ `Namzu could not create ${directory} because the folder cannot be written to. Set ${NAMZU_TMPDIR_ENV} to a folder you can write to.`,
98
+ 'not_writable',
99
+ directory,
100
+ )
101
+ case 'ENOENT':
102
+ return new TempDirectoryError(
103
+ `Namzu could not create ${directory} because its parent folder does not exist. Create that folder, or set ${NAMZU_TMPDIR_ENV} to one that does.`,
104
+ 'missing',
105
+ directory,
106
+ )
107
+ default:
108
+ return undefined
109
+ }
110
+ }
111
+
112
+ function refuse(root: string, why: string): TempDirectoryError {
113
+ return new TempDirectoryError(
114
+ `Refusing temporary root ${root}: ${why} Set ${NAMZU_TMPDIR_ENV} to a folder only you can write to.`,
115
+ 'refused',
116
+ root,
117
+ )
118
+ }
119
+
120
+ function sha256Hex(value: string): string {
121
+ return createHash('sha256').update(value).digest('hex')
122
+ }
123
+
124
+ /**
125
+ * The per-user private root, `<base>/namzu-<user>`, created if missing.
126
+ *
127
+ * `<user>` is the numeric uid where the platform has one; otherwise the first
128
+ * 12 hex digits of the SHA-256 of the user name; otherwise `user`. On POSIX
129
+ * the directory is created with mode 0700 and refused if it is a symlink, not
130
+ * a directory, owned by another uid, or open to group or others. On win32
131
+ * `%TEMP%` is already per user: a symlink or junction is refused and the
132
+ * owner check is skipped.
133
+ *
134
+ * @throws {TempDirectoryError} when it cannot be created or is not safe to use.
135
+ */
136
+ /**
137
+ * Where {@link privateTempRoot} lives, without creating or checking anything.
138
+ * For a caller that needs to know a path before it is worth making it.
139
+ */
140
+ export function privateTempRootPath(options: PrivateTempRootOptions = {}): string {
141
+ const getuid = 'getuid' in options ? options.getuid : process.getuid?.bind(process)
142
+ let user = 'user'
143
+ if (typeof getuid === 'function') {
144
+ user = String(getuid())
145
+ } else {
146
+ try {
147
+ const name = (options.username ?? (() => userInfo().username))()
148
+ if (name.length > 0) user = sha256Hex(name).slice(0, 12)
149
+ } catch {
150
+ // No user database entry: fall back to the fixed segment.
151
+ }
152
+ }
153
+ return join(options.tmpdir ?? resolveTempBase(options.env), `namzu-${user}`)
154
+ }
155
+
156
+ export function privateTempRoot(options: PrivateTempRootOptions = {}): string {
157
+ const platform = options.platform ?? process.platform
158
+ const getuid = 'getuid' in options ? options.getuid : process.getuid?.bind(process)
159
+ const root = privateTempRootPath(options)
160
+ try {
161
+ mkdirSync(root, { mode: 0o700 })
162
+ } catch (error) {
163
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw explain(error, root) ?? error
164
+ }
165
+ const entry = lstatSync(root)
166
+ if (entry.isSymbolicLink()) throw refuse(root, 'it is a symbolic link.')
167
+ if (!entry.isDirectory()) throw refuse(root, 'it is not a directory.')
168
+ if (platform !== 'win32' && typeof getuid === 'function') {
169
+ if (entry.uid !== getuid()) throw refuse(root, `it belongs to uid ${entry.uid}.`)
170
+ if ((entry.mode & 0o077) !== 0) {
171
+ throw refuse(
172
+ root,
173
+ `mode ${(entry.mode & 0o777).toString(8)} lets other users in; it must be 700.`,
174
+ )
175
+ }
176
+ }
177
+ return root
178
+ }
179
+
180
+ /** A new empty directory named `<prefix>XXXXXX` inside the private root. */
181
+ export function createTempDirSync(prefix: string, options: PrivateTempRootOptions = {}): string {
182
+ const root = privateTempRoot(options)
183
+ try {
184
+ return mkdtempSync(join(root, prefix))
185
+ } catch (error) {
186
+ throw explain(error, join(root, prefix)) ?? error
187
+ }
188
+ }
189
+
190
+ /** As {@link createTempDirSync}, without blocking the event loop. */
191
+ export async function createTempDir(
192
+ prefix: string,
193
+ options: PrivateTempRootOptions = {},
194
+ ): Promise<string> {
195
+ const root = privateTempRoot(options)
196
+ try {
197
+ return await mkdtemp(join(root, prefix))
198
+ } catch (error) {
199
+ throw explain(error, join(root, prefix)) ?? error
200
+ }
201
+ }
202
+
203
+ /**
204
+ * The path of a file named `name` inside the private root. The file itself is
205
+ * not created; the caller opens it, and a write that fails because the disk is
206
+ * full is the caller's to report.
207
+ */
208
+ export function tempFilePath(name: string, options: PrivateTempRootOptions = {}): string {
209
+ return join(privateTempRoot(options), name)
210
+ }
211
+
212
+ /**
213
+ * Make a session's scratch folder (and the folders above it, mode 0700) and
214
+ * confirm it is safe to treat as a place the session may write freely: it lies
215
+ * inside the private root, and neither it nor any folder between it and the
216
+ * root is a symbolic link, a non-folder or owned by someone else. Made on
217
+ * first use, not before, so a session that never needs it leaves nothing behind.
218
+ *
219
+ * @throws {TempDirectoryError} when it cannot be made or is not safe; the
220
+ * caller then leaves the folder outside the session's roots.
221
+ */
222
+ export function ensureScratchpadDirectory(
223
+ directory: string,
224
+ options: PrivateTempRootOptions = {},
225
+ ): string {
226
+ const root = privateTempRoot(options)
227
+ const target = resolve(directory)
228
+ const inside = relative(root, target)
229
+ if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) {
230
+ throw refuse(target, 'it is not inside the private temporary folder.')
231
+ }
232
+ const getuid = 'getuid' in options ? options.getuid : process.getuid?.bind(process)
233
+ const platform = options.platform ?? process.platform
234
+ let current = root
235
+ for (const part of inside.split(sep)) {
236
+ current = join(current, part)
237
+ try {
238
+ mkdirSync(current, { mode: 0o700 })
239
+ } catch (error) {
240
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw explain(error, current) ?? error
241
+ }
242
+ const entry = lstatSync(current)
243
+ if (entry.isSymbolicLink()) throw refuse(current, 'it is a symbolic link.')
244
+ if (!entry.isDirectory()) throw refuse(current, 'it is not a directory.')
245
+ if (platform !== 'win32' && typeof getuid === 'function' && entry.uid !== getuid()) {
246
+ throw refuse(current, `it belongs to uid ${entry.uid}.`)
247
+ }
248
+ }
249
+ return target
250
+ }