@jini-ai/http-kit 0.2.1

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 (227) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +107 -0
  3. package/dist/active-context.d.ts +99 -0
  4. package/dist/active-context.d.ts.map +1 -0
  5. package/dist/active-context.js +77 -0
  6. package/dist/active-context.js.map +1 -0
  7. package/dist/adapter.d.ts +36 -0
  8. package/dist/adapter.d.ts.map +1 -0
  9. package/dist/adapter.js +92 -0
  10. package/dist/adapter.js.map +1 -0
  11. package/dist/agents.d.ts +55 -0
  12. package/dist/agents.d.ts.map +1 -0
  13. package/dist/agents.js +23 -0
  14. package/dist/agents.js.map +1 -0
  15. package/dist/api-security-middleware.d.ts +146 -0
  16. package/dist/api-security-middleware.d.ts.map +1 -0
  17. package/dist/api-security-middleware.js +276 -0
  18. package/dist/api-security-middleware.js.map +1 -0
  19. package/dist/attachments.d.ts +317 -0
  20. package/dist/attachments.d.ts.map +1 -0
  21. package/dist/attachments.js +664 -0
  22. package/dist/attachments.js.map +1 -0
  23. package/dist/cancel-owned-runs.d.ts +44 -0
  24. package/dist/cancel-owned-runs.d.ts.map +1 -0
  25. package/dist/cancel-owned-runs.js +35 -0
  26. package/dist/cancel-owned-runs.js.map +1 -0
  27. package/dist/compat.d.ts +22 -0
  28. package/dist/compat.d.ts.map +1 -0
  29. package/dist/compat.js +17 -0
  30. package/dist/compat.js.map +1 -0
  31. package/dist/component-catalog.d.ts +74 -0
  32. package/dist/component-catalog.d.ts.map +1 -0
  33. package/dist/component-catalog.js +74 -0
  34. package/dist/component-catalog.js.map +1 -0
  35. package/dist/connectors.d.ts +165 -0
  36. package/dist/connectors.d.ts.map +1 -0
  37. package/dist/connectors.js +474 -0
  38. package/dist/connectors.js.map +1 -0
  39. package/dist/daemon-status.d.ts +72 -0
  40. package/dist/daemon-status.d.ts.map +1 -0
  41. package/dist/daemon-status.js +60 -0
  42. package/dist/daemon-status.js.map +1 -0
  43. package/dist/db-ops.d.ts +116 -0
  44. package/dist/db-ops.d.ts.map +1 -0
  45. package/dist/db-ops.js +191 -0
  46. package/dist/db-ops.js.map +1 -0
  47. package/dist/delegated-tools.d.ts +46 -0
  48. package/dist/delegated-tools.d.ts.map +1 -0
  49. package/dist/delegated-tools.js +155 -0
  50. package/dist/delegated-tools.js.map +1 -0
  51. package/dist/express/run-stream.d.ts +15 -0
  52. package/dist/express/run-stream.d.ts.map +1 -0
  53. package/dist/express/run-stream.js +11 -0
  54. package/dist/express/run-stream.js.map +1 -0
  55. package/dist/express-index.d.ts +32 -0
  56. package/dist/express-index.d.ts.map +1 -0
  57. package/dist/express-index.js +14 -0
  58. package/dist/express-index.js.map +1 -0
  59. package/dist/fastify/adapter.d.ts +31 -0
  60. package/dist/fastify/adapter.d.ts.map +1 -0
  61. package/dist/fastify/adapter.js +64 -0
  62. package/dist/fastify/adapter.js.map +1 -0
  63. package/dist/fastify/agents.d.ts +13 -0
  64. package/dist/fastify/agents.d.ts.map +1 -0
  65. package/dist/fastify/agents.js +7 -0
  66. package/dist/fastify/agents.js.map +1 -0
  67. package/dist/fastify/api-security-middleware.d.ts +64 -0
  68. package/dist/fastify/api-security-middleware.d.ts.map +1 -0
  69. package/dist/fastify/api-security-middleware.js +139 -0
  70. package/dist/fastify/api-security-middleware.js.map +1 -0
  71. package/dist/fastify/compat.d.ts +22 -0
  72. package/dist/fastify/compat.d.ts.map +1 -0
  73. package/dist/fastify/compat.js +16 -0
  74. package/dist/fastify/compat.js.map +1 -0
  75. package/dist/fastify/daemon-status.d.ts +22 -0
  76. package/dist/fastify/daemon-status.d.ts.map +1 -0
  77. package/dist/fastify/daemon-status.js +9 -0
  78. package/dist/fastify/daemon-status.js.map +1 -0
  79. package/dist/fastify/host-tools.d.ts +13 -0
  80. package/dist/fastify/host-tools.d.ts.map +1 -0
  81. package/dist/fastify/host-tools.js +8 -0
  82. package/dist/fastify/host-tools.js.map +1 -0
  83. package/dist/fastify/index.d.ts +36 -0
  84. package/dist/fastify/index.d.ts.map +1 -0
  85. package/dist/fastify/index.js +18 -0
  86. package/dist/fastify/index.js.map +1 -0
  87. package/dist/fastify/local-daemon-request.d.ts +43 -0
  88. package/dist/fastify/local-daemon-request.d.ts.map +1 -0
  89. package/dist/fastify/local-daemon-request.js +155 -0
  90. package/dist/fastify/local-daemon-request.js.map +1 -0
  91. package/dist/fastify/origin.d.ts +21 -0
  92. package/dist/fastify/origin.d.ts.map +1 -0
  93. package/dist/fastify/origin.js +14 -0
  94. package/dist/fastify/origin.js.map +1 -0
  95. package/dist/fastify/request.d.ts +20 -0
  96. package/dist/fastify/request.d.ts.map +1 -0
  97. package/dist/fastify/request.js +25 -0
  98. package/dist/fastify/request.js.map +1 -0
  99. package/dist/fastify/response.d.ts +20 -0
  100. package/dist/fastify/response.d.ts.map +1 -0
  101. package/dist/fastify/response.js +41 -0
  102. package/dist/fastify/response.js.map +1 -0
  103. package/dist/fastify/route-registration-guard.d.ts +70 -0
  104. package/dist/fastify/route-registration-guard.d.ts.map +1 -0
  105. package/dist/fastify/route-registration-guard.js +69 -0
  106. package/dist/fastify/route-registration-guard.js.map +1 -0
  107. package/dist/fastify/run-stream.d.ts +18 -0
  108. package/dist/fastify/run-stream.d.ts.map +1 -0
  109. package/dist/fastify/run-stream.js +10 -0
  110. package/dist/fastify/run-stream.js.map +1 -0
  111. package/dist/fastify/runs.d.ts +17 -0
  112. package/dist/fastify/runs.d.ts.map +1 -0
  113. package/dist/fastify/runs.js +33 -0
  114. package/dist/fastify/runs.js.map +1 -0
  115. package/dist/frontend-control.d.ts +131 -0
  116. package/dist/frontend-control.d.ts.map +1 -0
  117. package/dist/frontend-control.js +77 -0
  118. package/dist/frontend-control.js.map +1 -0
  119. package/dist/frontend-sessions.d.ts +100 -0
  120. package/dist/frontend-sessions.d.ts.map +1 -0
  121. package/dist/frontend-sessions.js +180 -0
  122. package/dist/frontend-sessions.js.map +1 -0
  123. package/dist/health.d.ts +91 -0
  124. package/dist/health.d.ts.map +1 -0
  125. package/dist/health.js +53 -0
  126. package/dist/health.js.map +1 -0
  127. package/dist/host-tools.d.ts +174 -0
  128. package/dist/host-tools.d.ts.map +1 -0
  129. package/dist/host-tools.js +338 -0
  130. package/dist/host-tools.js.map +1 -0
  131. package/dist/index.d.ts +86 -0
  132. package/dist/index.d.ts.map +1 -0
  133. package/dist/index.js +40 -0
  134. package/dist/index.js.map +1 -0
  135. package/dist/local-daemon-request.d.ts +44 -0
  136. package/dist/local-daemon-request.d.ts.map +1 -0
  137. package/dist/local-daemon-request.js +145 -0
  138. package/dist/local-daemon-request.js.map +1 -0
  139. package/dist/media.d.ts +131 -0
  140. package/dist/media.d.ts.map +1 -0
  141. package/dist/media.js +352 -0
  142. package/dist/media.js.map +1 -0
  143. package/dist/memory.d.ts +230 -0
  144. package/dist/memory.d.ts.map +1 -0
  145. package/dist/memory.js +331 -0
  146. package/dist/memory.js.map +1 -0
  147. package/dist/model-proxy.d.ts +45 -0
  148. package/dist/model-proxy.d.ts.map +1 -0
  149. package/dist/model-proxy.js +619 -0
  150. package/dist/model-proxy.js.map +1 -0
  151. package/dist/origin-validation.d.ts +64 -0
  152. package/dist/origin-validation.d.ts.map +1 -0
  153. package/dist/origin-validation.js +226 -0
  154. package/dist/origin-validation.js.map +1 -0
  155. package/dist/origin.d.ts +32 -0
  156. package/dist/origin.d.ts.map +1 -0
  157. package/dist/origin.js +14 -0
  158. package/dist/origin.js.map +1 -0
  159. package/dist/pack-http.d.ts +30 -0
  160. package/dist/pack-http.d.ts.map +1 -0
  161. package/dist/pack-http.js +12 -0
  162. package/dist/pack-http.js.map +1 -0
  163. package/dist/raw-sse.d.ts +44 -0
  164. package/dist/raw-sse.d.ts.map +1 -0
  165. package/dist/raw-sse.js +93 -0
  166. package/dist/raw-sse.js.map +1 -0
  167. package/dist/remote-run-events.d.ts +66 -0
  168. package/dist/remote-run-events.d.ts.map +1 -0
  169. package/dist/remote-run-events.js +230 -0
  170. package/dist/remote-run-events.js.map +1 -0
  171. package/dist/request.d.ts +19 -0
  172. package/dist/request.d.ts.map +1 -0
  173. package/dist/request.js +25 -0
  174. package/dist/request.js.map +1 -0
  175. package/dist/research.d.ts +42 -0
  176. package/dist/research.d.ts.map +1 -0
  177. package/dist/research.js +316 -0
  178. package/dist/research.js.map +1 -0
  179. package/dist/response.d.ts +14 -0
  180. package/dist/response.d.ts.map +1 -0
  181. package/dist/response.js +45 -0
  182. package/dist/response.js.map +1 -0
  183. package/dist/route-manifest.d.ts +31 -0
  184. package/dist/route-manifest.d.ts.map +1 -0
  185. package/dist/route-manifest.js +103 -0
  186. package/dist/route-manifest.js.map +1 -0
  187. package/dist/route-registration-guard.d.ts +65 -0
  188. package/dist/route-registration-guard.d.ts.map +1 -0
  189. package/dist/route-registration-guard.js +71 -0
  190. package/dist/route-registration-guard.js.map +1 -0
  191. package/dist/routines.d.ts +114 -0
  192. package/dist/routines.d.ts.map +1 -0
  193. package/dist/routines.js +320 -0
  194. package/dist/routines.js.map +1 -0
  195. package/dist/run-stream.d.ts +60 -0
  196. package/dist/run-stream.d.ts.map +1 -0
  197. package/dist/run-stream.js +108 -0
  198. package/dist/run-stream.js.map +1 -0
  199. package/dist/runs.d.ts +79 -0
  200. package/dist/runs.d.ts.map +1 -0
  201. package/dist/runs.js +232 -0
  202. package/dist/runs.js.map +1 -0
  203. package/dist/sse.d.ts +139 -0
  204. package/dist/sse.d.ts.map +1 -0
  205. package/dist/sse.js +157 -0
  206. package/dist/sse.js.map +1 -0
  207. package/dist/terminals.d.ts +125 -0
  208. package/dist/terminals.d.ts.map +1 -0
  209. package/dist/terminals.js +349 -0
  210. package/dist/terminals.js.map +1 -0
  211. package/dist/tool-catalog.d.ts +68 -0
  212. package/dist/tool-catalog.d.ts.map +1 -0
  213. package/dist/tool-catalog.js +68 -0
  214. package/dist/tool-catalog.js.map +1 -0
  215. package/dist/types.d.ts +58 -0
  216. package/dist/types.d.ts.map +1 -0
  217. package/dist/types.js +5 -0
  218. package/dist/types.js.map +1 -0
  219. package/dist/workspace-root.d.ts +69 -0
  220. package/dist/workspace-root.d.ts.map +1 -0
  221. package/dist/workspace-root.js +63 -0
  222. package/dist/workspace-root.js.map +1 -0
  223. package/dist/xai.d.ts +86 -0
  224. package/dist/xai.d.ts.map +1 -0
  225. package/dist/xai.js +668 -0
  226. package/dist/xai.js.map +1 -0
  227. package/package.json +58 -0
@@ -0,0 +1,664 @@
1
+ /**
2
+ * @module attachments
3
+ *
4
+ * `POST /api/attachments`, `DELETE /api/attachments` — an HTTP route pack for user file/image
5
+ * uploads staged for one agent run, over a narrow, transport-owned `AttachmentStore` port plus a
6
+ * batteries-included disk implementation (`createDiskAttachmentStore`).
7
+ *
8
+ * **The capability this closes.** A chat composer that accepts drag-and-drop needs somewhere to put
9
+ * the bytes, a way to hand the agent a path it is actually allowed to read, and a guarantee that
10
+ * those bytes disappear when the run ends. That is three separate concerns (an upload endpoint, a
11
+ * trusted-metadata registry, and a run-scoped lifetime) which every host would otherwise hand-roll.
12
+ * This module owns all three; `@jini-ai/chat-react`'s `createDaemonAttachmentUploader` is the
13
+ * matching client half.
14
+ *
15
+ * **Trust model.** A renderer supplies bytes, a filename, and a batch id. None of that is trusted:
16
+ * - the filename is reduced to a basename over a conservative character allowlist
17
+ * (`sanitizeAttachmentName`) and is only ever used as *display* text plus a short extension
18
+ * suffix — never as the stored filename, which is a fresh `randomUUID()`;
19
+ * - the `kind` (`'image' | 'file'`) is sniffed from the leading bytes (`detectAttachmentKind`),
20
+ * never taken from a renderer-controlled MIME type or file extension;
21
+ * - the byte count is measured while streaming, never read from a client-supplied length;
22
+ * - the returned `path` is an opaque `attachment:<uuid>` capability id, not a filesystem path, so a
23
+ * renderer never learns where the upload root is and cannot name a file it did not upload.
24
+ * `claim()` is what exchanges those ids for real paths, server-side.
25
+ *
26
+ * **What `claim()` guarantees.** Exactly once per registered attachment, and only if the file is
27
+ * still the same file: `lstat` must report a regular non-symlink whose `dev`/`ino`/`size` match
28
+ * what was recorded at registration, and `realpath` must equal the recorded path (so neither the
29
+ * file nor any parent directory was swapped for a symlink between upload and run start). Every
30
+ * claimed attachment must belong to one batch, so the single `batchDirectory` a host grants the
31
+ * agent read access to cannot be widened by mixing batches.
32
+ *
33
+ * **Storage lifetime is daemon-lifetime, not persistent.** `createDiskAttachmentStore` empties its
34
+ * upload directory on construction: files left behind by an interrupted previous process cannot be
35
+ * authenticated against an in-memory registry that no longer exists, so they are removed rather
36
+ * than adopted. Unclaimed uploads also expire by TTL (`pruneExpired`), and a run's claimed files are
37
+ * deleted by `cleanupRun`.
38
+ *
39
+ * **This pack does not auto-wire itself into a run's lifecycle**, because no generic hook for that
40
+ * exists — the same deliberate choice `@jini-ai/daemon`'s `createRunScopedContextStore` makes. A
41
+ * host claims in its own `onRunStarted` and cleans up in a `finally`, roughly:
42
+ *
43
+ * ```ts
44
+ * const store = await createDiskAttachmentStore({ uploadDirectory });
45
+ * // ... httpExtensions: [(app, { adapter }) => registerAttachmentRoutes(app, { store }, adapter)]
46
+ * onRunStarted: (context) => {
47
+ * void (async () => {
48
+ * try {
49
+ * const claimed = await store.claim(attachmentRefsFrom(context.request), context.run.id);
50
+ * await executor.run({
51
+ * runId: context.run.id,
52
+ * // ... prompt, cwd, agentId
53
+ * ...(claimed.batchDirectory === undefined ? {} : {
54
+ * imagePaths: claimed.attachments.filter((a) => a.kind === 'image').map((a) => a.path),
55
+ * extraAllowedDirs: [claimed.batchDirectory],
56
+ * uploadRoot: claimed.batchDirectory,
57
+ * }),
58
+ * });
59
+ * } finally {
60
+ * await store.cleanupRun(context.run.id);
61
+ * }
62
+ * })();
63
+ * }
64
+ * ```
65
+ *
66
+ * `imagePaths`/`extraAllowedDirs`/`uploadRoot` are pre-existing `AgentExecutor.run()` options; this
67
+ * module only produces real values for them.
68
+ *
69
+ * **Body-parser ordering (`POST` reads the raw request stream).** The upload route streams
70
+ * `request` straight to disk, so any body-parsing middleware that has already consumed the stream
71
+ * leaves nothing to write. The classic way to hit this is a JSON body parser mounted app-wide: a
72
+ * user drops a `.json` file, the browser sets `content-type: application/json`, and
73
+ * `express.json()` eats the body — which without a guard shows up as the deeply unhelpful
74
+ * "attachment is empty". `registerAttachmentRoutes` detects an already-consumed stream up front and
75
+ * reports it as a host misconfiguration (`'attachment-body-consumed'`) instead of failing silently.
76
+ * Mount this pack before any global body parser, or scope the parser so it skips this path.
77
+ *
78
+ * **SEC-005 redaction**: a store failure can carry filesystem paths a caller must not see, so
79
+ * anything that is not an explicitly-classified `AttachmentRejectedError` becomes a
80
+ * correlation-id-bearing generic `INTERNAL_ERROR`, with the real error reaching a host-owned sink —
81
+ * the same `reportInternalError` shape `media.ts`/`delegated-tools.ts` use.
82
+ */
83
+ import { chmod, lstat, mkdir, open, readdir, realpath, rm, rmdir } from 'node:fs/promises';
84
+ import { randomUUID } from 'node:crypto';
85
+ import { basename, dirname, extname, resolve } from 'node:path';
86
+ import { createApiError } from '@jini-ai/protocol';
87
+ import { guardSameOrigin } from './origin.js';
88
+ import { sendApiError, sendJson } from './response.js';
89
+ /**
90
+ * A refusal this module classified itself, as opposed to an unexpected filesystem/programming
91
+ * error. The route pack turns the `reason` into a status code and lets the `message` through to the
92
+ * caller; anything that is *not* one of these is redacted to a generic `INTERNAL_ERROR`.
93
+ */
94
+ export class AttachmentRejectedError extends Error {
95
+ reason;
96
+ constructor(reason, message) {
97
+ super(message);
98
+ this.name = 'AttachmentRejectedError';
99
+ this.reason = reason;
100
+ }
101
+ }
102
+ /** The subset of `AttachmentRejectionReason` a caller is allowed to see a real message for. */
103
+ const REJECTION_STATUS = {
104
+ 'invalid-batch': 400,
105
+ 'attachment-too-large': 413,
106
+ 'batch-count-exceeded': 413,
107
+ 'batch-too-large': 413,
108
+ 'storage-full': 413,
109
+ 'empty-attachment': 400,
110
+ 'too-many-concurrent-uploads': 429,
111
+ 'invalid-cleanup-request': 400,
112
+ // The remaining reasons describe a broken or hostile server-side state. They are never given a
113
+ // real message over HTTP (see `respondToUploadFailure`); the entries exist so a host catching a
114
+ // rejection from `claim()` outside HTTP can still classify it.
115
+ 'attachment-body-consumed': 500,
116
+ 'attachment-integrity': 500,
117
+ 'too-many-attachments': 400,
118
+ 'duplicate-attachment': 400,
119
+ 'attachment-unknown-or-claimed': 400,
120
+ 'mixed-batch': 400,
121
+ };
122
+ /** `ApiError` code per refusal, so `sendApiError` produces the standard envelope. */
123
+ function apiErrorForRejection(error) {
124
+ const status = REJECTION_STATUS[error.reason];
125
+ if (status === 413)
126
+ return createApiError('PAYLOAD_TOO_LARGE', error.message);
127
+ if (status === 429)
128
+ return createApiError('RATE_LIMITED', error.message);
129
+ return createApiError('BAD_REQUEST', error.message);
130
+ }
131
+ /**
132
+ * Accepted batch id shape. Deliberately narrow — long enough for a UUID, and admitting no `.`,
133
+ * `/`, or `\`, which is what makes `resolve(uploadRoot, batchId)` provably a single
134
+ * non-traversing path segment.
135
+ */
136
+ const BATCH_ID_PATTERN = /^[a-zA-Z0-9-]{8,80}$/u;
137
+ /** Bytes of leading signature `detectAttachmentKind` needs (WEBP's marker ends at byte 12). */
138
+ const SIGNATURE_BYTES = 12;
139
+ /**
140
+ * `true` when the file on disk is still the same file registration accepted.
141
+ *
142
+ * Every condition is a distinct real attack: a regular file swapped for a symlink or directory, a
143
+ * parent directory swapped for a symlink pointing elsewhere, the file replaced by a different file
144
+ * at the same path (new inode), the same inode truncated or appended to (new size), or the path
145
+ * now resolving onto a different device.
146
+ *
147
+ * Pure and exported on purpose. Staging a *device* change for a file that keeps its path is not
148
+ * something a test can do on a real filesystem, and a check that can only be exercised in
149
+ * production is a check nobody knows works — a fake identity states each case directly.
150
+ */
151
+ export function isUnchangedAttachment(recorded, observed) {
152
+ return observed.isRegularFile
153
+ && observed.canonicalPath === recorded.filePath
154
+ && observed.dev === recorded.dev
155
+ && observed.ino === recorded.ino
156
+ && observed.size === recorded.size;
157
+ }
158
+ /**
159
+ * Phase 1 of `claim()`: synchronously reserves `attachments` for `runId`, or throws (releasing
160
+ * whatever it already reserved this call) the first time a requested attachment turns out to be
161
+ * unknown or already claimed.
162
+ *
163
+ * The reservation loop deliberately has no `await`: this store's exactly-once guarantee is what
164
+ * stops two runs being handed the same real path on disk, and `claim` is reachable concurrently
165
+ * (two run starts, one shared attachment). Nothing between `records.get` and the assignment of
166
+ * `record.claimedRunId` may ever become asynchronous — that window is exactly where a concurrent
167
+ * call would get its turn and could observe the same record as still unclaimed.
168
+ *
169
+ * Exported so this invariant can be exercised directly against a plain `Map` of fabricated
170
+ * records, without going through a disk-backed store.
171
+ */
172
+ export function reserveAttachmentRecords(attachments, records, runId, maxAttachments) {
173
+ if (attachments.length > maxAttachments) {
174
+ throw new AttachmentRejectedError('too-many-attachments', 'Too many attachments');
175
+ }
176
+ const requestedPaths = new Set(attachments.map((attachment) => attachment.path));
177
+ if (requestedPaths.size !== attachments.length) {
178
+ throw new AttachmentRejectedError('duplicate-attachment', 'Duplicate attachment');
179
+ }
180
+ const claimed = [];
181
+ for (const requested of attachments) {
182
+ const record = records.get(requested.path);
183
+ if (!record || record.claimedRunId !== undefined) {
184
+ for (const reserved of claimed)
185
+ delete reserved.claimedRunId;
186
+ throw new AttachmentRejectedError('attachment-unknown-or-claimed', 'Attachment is unknown or already claimed');
187
+ }
188
+ record.claimedRunId = runId;
189
+ claimed.push(record);
190
+ }
191
+ return claimed;
192
+ }
193
+ /**
194
+ * Phase 2 of `claim()`: re-verifies every already-reserved record against the filesystem — not
195
+ * merely re-read, but re-checked against what registration recorded, so someone able to write into
196
+ * the batch directory between upload and run start cannot get the agent to read a file of their
197
+ * choosing — and checks every claimed attachment shares one batch. Returns the shared batch
198
+ * directory on success (or `''` for an empty `claimed`, which `claim()` never passes in — it
199
+ * returns before calling this — but which a direct caller can still treat as "no batch").
200
+ *
201
+ * Throws without releasing `claimed`'s reservations; releasing is the caller's job (`claim`'s own
202
+ * `catch`), so a rejected claim still leaves nothing half-claimed and the caller can retry with a
203
+ * corrected set.
204
+ *
205
+ * Exported so the integrity and mixed-batch checks can be exercised directly against fabricated
206
+ * records over real files on disk, without a full `createDiskAttachmentStore` around them.
207
+ */
208
+ export async function verifyClaimedAttachments(claimed) {
209
+ let batchDirectory = '';
210
+ for (const [index, record] of claimed.entries()) {
211
+ const info = await lstat(record.filePath);
212
+ const canonicalPath = await realpath(record.filePath);
213
+ if (!isUnchangedAttachment(record, {
214
+ isRegularFile: info.isFile(),
215
+ dev: info.dev,
216
+ ino: info.ino,
217
+ size: info.size,
218
+ canonicalPath,
219
+ })) {
220
+ throw new AttachmentRejectedError('attachment-integrity', 'Attachment changed after upload');
221
+ }
222
+ if (index > 0 && record.batchDirectory !== batchDirectory) {
223
+ throw new AttachmentRejectedError('mixed-batch', 'Attachments must belong to one batch');
224
+ }
225
+ batchDirectory = record.batchDirectory;
226
+ }
227
+ return batchDirectory;
228
+ }
229
+ /** Renders a byte cap the way a person would read it, for a message a user actually sees. */
230
+ function formatByteLimit(bytes) {
231
+ const megabytes = bytes / (1024 * 1024);
232
+ return Number.isInteger(megabytes) ? `${megabytes} MB` : `${bytes} bytes`;
233
+ }
234
+ /**
235
+ * Reduces an untrusted upload filename to a display-only basename over a conservative allowlist.
236
+ * Never used as the name of the stored file — see this module's trust-model doc.
237
+ */
238
+ export function sanitizeAttachmentName(requestedName) {
239
+ if (typeof requestedName !== 'string')
240
+ return 'attachment';
241
+ return basename(requestedName).replaceAll(/[^a-zA-Z0-9._ -]/gu, '_') || 'attachment';
242
+ }
243
+ /** `true` when `body`'s first 8 bytes are the PNG signature. */
244
+ export function hasPngSignature(body) {
245
+ return body.length >= 8
246
+ && body[0] === 0x89
247
+ && body[1] === 0x50
248
+ && body[2] === 0x4e
249
+ && body[3] === 0x47;
250
+ }
251
+ /** `true` when `body`'s first 3 bytes are the JPEG start-of-image marker. */
252
+ export function hasJpegSignature(body) {
253
+ return body.length >= 3
254
+ && body[0] === 0xff
255
+ && body[1] === 0xd8
256
+ && body[2] === 0xff;
257
+ }
258
+ /** `true` when `body`'s first 6 bytes spell either GIF version tag. */
259
+ export function hasGifSignature(body) {
260
+ const signature = new TextDecoder().decode(body.slice(0, 6));
261
+ return signature === 'GIF87a' || signature === 'GIF89a';
262
+ }
263
+ /** `true` when `body` opens with a RIFF container whose form type is WEBP. */
264
+ export function hasWebpSignature(body) {
265
+ return body.length >= 12
266
+ && new TextDecoder().decode(body.slice(0, 4)) === 'RIFF'
267
+ && new TextDecoder().decode(body.slice(8, 12)) === 'WEBP';
268
+ }
269
+ /** Every recognized image signature, checked in this order until one matches. */
270
+ const IMAGE_SIGNATURE_MATCHERS = [
271
+ hasPngSignature,
272
+ hasJpegSignature,
273
+ hasGifSignature,
274
+ hasWebpSignature,
275
+ ];
276
+ /**
277
+ * Infers `'image'` from the leading bytes rather than from a renderer-controlled MIME type or file
278
+ * extension. PNG, JPEG, GIF87a/89a, and WEBP are recognized; everything else is `'file'`.
279
+ *
280
+ * `kind` decides whether a path is later passed to `AgentExecutor.run()`'s `imagePaths`, so letting
281
+ * a renderer assert it would let a renderer choose how the agent runtime parses the bytes.
282
+ */
283
+ export function detectAttachmentKind(body) {
284
+ return IMAGE_SIGNATURE_MATCHERS.some((matchesSignature) => matchesSignature(body)) ? 'image' : 'file';
285
+ }
286
+ /**
287
+ * Streams a request body straight to a private file under a hard byte cap, keeping only the
288
+ * leading signature bytes in memory so an upload never costs memory proportional to its size.
289
+ *
290
+ * Opened `wx`, so this can never overwrite an existing file. A partial write is removed before the
291
+ * rejection propagates: the cap is enforced *during* the stream, which necessarily means some bytes
292
+ * already reached disk by the time it trips.
293
+ */
294
+ export async function writeBoundedAttachmentBody({ request, filePath, maxBytes, mode = 0o600, }) {
295
+ const handle = await open(filePath, 'wx', mode);
296
+ let total = 0;
297
+ let signature = Buffer.alloc(0);
298
+ try {
299
+ for await (const chunk of request) {
300
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
301
+ total += buffer.byteLength;
302
+ if (total > maxBytes) {
303
+ throw new AttachmentRejectedError('attachment-too-large', `Each attachment must be ${formatByteLimit(maxBytes)} or smaller`);
304
+ }
305
+ if (signature.byteLength < SIGNATURE_BYTES) {
306
+ signature = Buffer.concat([signature, buffer.subarray(0, SIGNATURE_BYTES - signature.byteLength)], Math.min(SIGNATURE_BYTES, signature.byteLength + buffer.byteLength));
307
+ }
308
+ await handle.write(buffer);
309
+ }
310
+ await handle.close();
311
+ await chmod(filePath, mode);
312
+ return { size: total, signature };
313
+ }
314
+ catch (error) {
315
+ // Reached both while the handle is still open (the byte cap tripped, or the request stream
316
+ // errored) and after it was already closed (`chmod` failed). No `.catch` is needed to tell those
317
+ // apart: Node's `FileHandle.close()` is idempotent — closing an already-closed handle resolves
318
+ // — so this can only reject for a handle that is genuinely still open and unclosable, which is
319
+ // a failure worth surfacing rather than swallowing.
320
+ await handle.close();
321
+ await rm(filePath, { force: true });
322
+ throw error;
323
+ }
324
+ }
325
+ /**
326
+ * The disk-backed `AttachmentStore` this package ships. Every default matches what a chat composer
327
+ * needs out of the box; a host that wants different quotas passes them rather than reimplementing
328
+ * the port.
329
+ *
330
+ * @complexity `register`/`claim` are O(n) in the number of tracked records (a small bounded number
331
+ * — `maxStoredAttachments`), which is what keeps the quota decision synchronous; see `register`.
332
+ */
333
+ export async function createDiskAttachmentStore({ uploadDirectory, maxAttachments = 10, maxBatchBytes = 50 * 1024 * 1024, maxStoredAttachments = 100, maxStoredBytes = 200 * 1024 * 1024, retentionMs = 60 * 60 * 1_000, }) {
334
+ await mkdir(uploadDirectory, { recursive: true, mode: 0o700 });
335
+ await chmod(uploadDirectory, 0o700);
336
+ const canonicalUploadDirectory = await realpath(uploadDirectory);
337
+ // Uploads live only as long as this store does. A file left by an interrupted previous process
338
+ // has no record to authenticate it against, so it is removed rather than adopted.
339
+ for (const entry of await readdir(canonicalUploadDirectory)) {
340
+ await rm(resolve(canonicalUploadDirectory, entry), { recursive: true, force: true });
341
+ }
342
+ const records = new Map();
343
+ const resolveBatchDirectory = (batchId) => {
344
+ if (!BATCH_ID_PATTERN.test(batchId)) {
345
+ throw new AttachmentRejectedError('invalid-batch', 'Invalid attachment batch');
346
+ }
347
+ // No containment re-check follows, and two things together are why — **both** are load-bearing:
348
+ // `BATCH_ID_PATTERN` admits only `[a-zA-Z0-9-]`, so `batchId` carries no `.`, `/`, or `\`; and
349
+ // `resolve` normalizes, so the result is exactly `<canonicalUploadDirectory><sep><batchId>`, one
350
+ // non-traversing segment deeper. Weakening the pattern (a `.` would be enough) or dropping the
351
+ // `resolve` reintroduces traversal here. Verified by sweeping 5,040,504 `(uploadRoot, batchId)`
352
+ // pairs on both posix and win32: zero escape the upload root.
353
+ return resolve(canonicalUploadDirectory, batchId);
354
+ };
355
+ const removeEmptyBatch = async (batchDirectory) => {
356
+ try {
357
+ await rmdir(batchDirectory);
358
+ }
359
+ catch {
360
+ // Non-empty (another unclaimed upload still occupies it) or already gone. Both are fine:
361
+ // this is opportunistic tidying, never the thing that makes a delete correct.
362
+ }
363
+ };
364
+ const deleteRecord = async (record) => {
365
+ records.delete(record.id);
366
+ await rm(record.filePath, { force: true });
367
+ await removeEmptyBatch(record.batchDirectory);
368
+ };
369
+ const totalBytes = (candidates) => candidates.reduce((total, record) => total + record.size, 0);
370
+ return {
371
+ async createBatchDirectory(batchId) {
372
+ const directory = resolveBatchDirectory(batchId);
373
+ await mkdir(directory, { recursive: true, mode: 0o700 });
374
+ await chmod(directory, 0o700);
375
+ return directory;
376
+ },
377
+ async register(input) {
378
+ const batchDirectory = resolveBatchDirectory(input.batchId);
379
+ // `resolve` is NOT redundant with the route's own `resolve`, and this line is the precondition
380
+ // the containment argument below depends on. Do not remove it or take a pre-resolved path from
381
+ // the caller: `register` is a public port method, so `input.path` is untrusted.
382
+ const filePath = resolve(input.path);
383
+ // Containment: the file's parent directory must be *exactly* the batch directory — stronger
384
+ // than a `path.relative`-based "is inside" test, which also admits nested subdirectories and
385
+ // the batch directory itself.
386
+ //
387
+ // Why parent-equality is sufficient: `resolve` above guarantees `filePath` is absolute and
388
+ // normalized with no `..` left in the path body, so `dirname(filePath) === batchDirectory`
389
+ // implies `filePath` is `batchDirectory + sep + basename` with a real, non-traversing
390
+ // basename. This is *not* an unconditional path-algebra identity — without the `resolve`,
391
+ // `dirname('/a/..') === '/a'` is true while `/a/..` escapes `/a`. The normalization is what
392
+ // makes the argument hold; a differential fuzz of 6,000,000 `input.path` values against the
393
+ // previous two-part check found zero inputs this accepts that containment rejected.
394
+ //
395
+ // Deliberately OUTSIDE the `try` below, whose `catch` unlinks `filePath`. A path that failed
396
+ // containment must never reach that cleanup, or this port becomes an arbitrary-file-delete
397
+ // primitive for anything the daemon can unlink.
398
+ if (dirname(filePath) !== batchDirectory) {
399
+ throw new AttachmentRejectedError('attachment-integrity', 'Attachment path is outside its batch');
400
+ }
401
+ try {
402
+ const info = await lstat(filePath);
403
+ // `lstat` (not `stat`): a symlink must be seen as a symlink, not followed. `isFile()` is
404
+ // false for a symlink and for a directory, which is the whole check.
405
+ if (!info.isFile()) {
406
+ throw new AttachmentRejectedError('attachment-integrity', 'Attachment is not a regular file');
407
+ }
408
+ const canonical = await realpath(filePath);
409
+ if (canonical !== filePath) {
410
+ throw new AttachmentRejectedError('attachment-integrity', 'Attachment path is not canonical');
411
+ }
412
+ // Quotas are decided here, immediately before `records.set`, with no `await` in between —
413
+ // so two concurrent registrations cannot both observe the last free slot (or the last free
414
+ // bytes) and both commit. Moving any of this above the `await`s would reintroduce that
415
+ // race; adding an `await` below would too.
416
+ const batchRecords = [...records.values()]
417
+ .filter((record) => record.batchId === input.batchId);
418
+ if (batchRecords.length >= maxAttachments) {
419
+ throw new AttachmentRejectedError('batch-count-exceeded', `You can attach at most ${maxAttachments} files to one message`);
420
+ }
421
+ if (totalBytes(batchRecords) + info.size > maxBatchBytes) {
422
+ throw new AttachmentRejectedError('batch-too-large', `Attachments for one message must total ${formatByteLimit(maxBatchBytes)} or less`);
423
+ }
424
+ if (records.size >= maxStoredAttachments
425
+ || totalBytes([...records.values()]) + info.size > maxStoredBytes) {
426
+ throw new AttachmentRejectedError('storage-full', 'Attachment storage is full');
427
+ }
428
+ const id = `attachment:${randomUUID()}`;
429
+ const record = {
430
+ id,
431
+ filePath,
432
+ name: input.name,
433
+ kind: input.kind,
434
+ size: info.size,
435
+ batchId: input.batchId,
436
+ batchDirectory,
437
+ dev: info.dev,
438
+ ino: info.ino,
439
+ createdAt: Date.now(),
440
+ };
441
+ records.set(id, record);
442
+ return { path: id, name: record.name, kind: record.kind, size: record.size };
443
+ }
444
+ catch (error) {
445
+ // A file this store refused to take ownership of must not be left behind. `rm` is
446
+ // deliberately non-recursive (never recurse over an attacker-influenced path) and its own
447
+ // failure is swallowed, so the real rejection above always propagates instead of being
448
+ // masked by e.g. EISDIR from a directory sitting at `filePath`.
449
+ await rm(filePath, { force: true }).catch(() => undefined);
450
+ await removeEmptyBatch(batchDirectory);
451
+ throw error;
452
+ }
453
+ },
454
+ async claim(attachments, runId) {
455
+ if (attachments.length === 0)
456
+ return { attachments: [] };
457
+ // Phase 1 (`reserveAttachmentRecords`) reserves synchronously; Phase 2
458
+ // (`verifyClaimedAttachments`) re-validates what is now held exclusively. Any Phase 2 failure
459
+ // releases the whole reservation here, so a rejected claim still leaves nothing half-claimed
460
+ // and the caller can retry with a corrected set. Only reservations made by *this* call are
461
+ // released, so a concurrent winner's claim is never revoked by a loser's rollback.
462
+ const claimed = reserveAttachmentRecords(attachments, records, runId, maxAttachments);
463
+ try {
464
+ const batchDirectory = await verifyClaimedAttachments(claimed);
465
+ return {
466
+ attachments: claimed.map((record) => ({
467
+ path: record.filePath,
468
+ name: record.name,
469
+ kind: record.kind,
470
+ size: record.size,
471
+ })),
472
+ batchDirectory,
473
+ };
474
+ }
475
+ catch (error) {
476
+ for (const record of claimed)
477
+ delete record.claimedRunId;
478
+ throw error;
479
+ }
480
+ },
481
+ async deleteUnclaimed(batchId, paths) {
482
+ const batchDirectory = resolveBatchDirectory(batchId);
483
+ for (const attachmentId of new Set(paths)) {
484
+ const record = records.get(attachmentId);
485
+ if (record && record.batchId === batchId && record.claimedRunId === undefined) {
486
+ await deleteRecord(record);
487
+ }
488
+ }
489
+ await removeEmptyBatch(batchDirectory);
490
+ },
491
+ async cleanupRun(runId) {
492
+ for (const record of [...records.values()]) {
493
+ if (record.claimedRunId === runId)
494
+ await deleteRecord(record);
495
+ }
496
+ },
497
+ async pruneExpired(now = Date.now()) {
498
+ for (const record of [...records.values()]) {
499
+ if (record.claimedRunId === undefined && now - record.createdAt >= retentionMs) {
500
+ await deleteRecord(record);
501
+ }
502
+ }
503
+ },
504
+ async dispose() {
505
+ for (const record of [...records.values()])
506
+ await deleteRecord(record);
507
+ },
508
+ };
509
+ }
510
+ export const ATTACHMENTS_ROUTE_PATH = '/api/attachments';
511
+ function defaultInternalErrorSink(context) {
512
+ // eslint-disable-next-line no-console
513
+ console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, context.error);
514
+ }
515
+ function reportInternalError(deps, source, error, batchId) {
516
+ const correlationId = randomUUID();
517
+ const sink = deps.onInternalError ?? defaultInternalErrorSink;
518
+ sink({ source, batchId, correlationId, error });
519
+ return createApiError('INTERNAL_ERROR', 'an internal error occurred', { requestId: correlationId });
520
+ }
521
+ /**
522
+ * Sends the right status for an upload failure: an explicitly-classified rejection keeps its real
523
+ * message (the composer shows it to the user, so "you can attach at most 10 files" has to survive),
524
+ * while an integrity failure or any unexpected error is redacted to a generic `INTERNAL_ERROR`.
525
+ */
526
+ function respondToUploadFailure(res, deps, error, batchId, source) {
527
+ if (error instanceof AttachmentRejectedError && REJECTION_STATUS[error.reason] !== 500) {
528
+ sendApiError(res, REJECTION_STATUS[error.reason], apiErrorForRejection(error));
529
+ return;
530
+ }
531
+ sendApiError(res, 500, reportInternalError(deps, source, error, batchId));
532
+ }
533
+ /** `false` when the same-origin guard rejected the request (and already answered it). */
534
+ function passesOriginGuard(req, res, deps, adapter) {
535
+ if (deps.requireSameOrigin === false)
536
+ return true;
537
+ const origin = guardSameOrigin(req, adapter);
538
+ if (origin.ok)
539
+ return true;
540
+ sendApiError(res, 403, origin.error);
541
+ return false;
542
+ }
543
+ /**
544
+ * Handles one upload: rate-limit, batch directory, bounded stream to disk, signature sniff,
545
+ * register. Exported so a host mounting its own path (or its own framework) can reuse the whole
546
+ * body without re-deriving the ordering, which is load-bearing — see the `finally`.
547
+ */
548
+ export async function handleAttachmentUpload(req, res, deps, state) {
549
+ const maxConcurrentUploads = deps.maxConcurrentUploads ?? 4;
550
+ const maxAttachmentBytes = deps.maxAttachmentBytes ?? 20 * 1024 * 1024;
551
+ const name = sanitizeAttachmentName(req.query.name);
552
+ const batchId = typeof req.query.batch === 'string' ? req.query.batch : '';
553
+ // Every refusal below goes through `respondToUploadFailure`, so the reason -> status/code mapping
554
+ // lives in exactly one place rather than being partly inlined here.
555
+ if (state.activeUploads >= maxConcurrentUploads) {
556
+ respondToUploadFailure(res, deps, new AttachmentRejectedError('too-many-concurrent-uploads', 'Too many attachment uploads are in progress'), batchId, 'attachment-upload');
557
+ return;
558
+ }
559
+ // Checked before a single byte is written: a drained stream would otherwise produce a zero-byte
560
+ // file and the misleading "attachment is empty". See this module's body-parser doc.
561
+ if (req.readableEnded) {
562
+ respondToUploadFailure(res, deps, new AttachmentRejectedError('attachment-body-consumed', `the request body was already consumed before ${ATTACHMENTS_ROUTE_PATH} received it — mount this route pack before any global body parser, or scope that parser to skip this path`), batchId, 'attachment-upload');
563
+ return;
564
+ }
565
+ state.activeUploads += 1;
566
+ try {
567
+ await deps.store.pruneExpired();
568
+ const batchDirectory = await deps.store.createBatchDirectory(batchId);
569
+ // A fresh UUID, never the client's filename. The extension is carried over (bounded) only
570
+ // because some agent runtimes decide how to read a file from its suffix.
571
+ const suffix = extname(name).slice(0, 12);
572
+ const path = resolve(batchDirectory, `${randomUUID()}${suffix}`);
573
+ const upload = await writeBoundedAttachmentBody({ request: req, filePath: path, maxBytes: maxAttachmentBytes });
574
+ if (upload.size === 0) {
575
+ await rm(path, { force: true });
576
+ sendApiError(res, 400, createApiError('BAD_REQUEST', 'Attachment is empty'));
577
+ return;
578
+ }
579
+ const attachment = await deps.store.register({
580
+ batchId,
581
+ path,
582
+ name,
583
+ kind: detectAttachmentKind(upload.signature),
584
+ size: upload.size,
585
+ });
586
+ sendJson(res, 201, { attachment });
587
+ }
588
+ catch (error) {
589
+ respondToUploadFailure(res, deps, error, batchId, 'attachment-upload');
590
+ }
591
+ finally {
592
+ state.activeUploads -= 1;
593
+ // Removes the batch directory when this failed upload left it empty. Passing no paths is
594
+ // deliberate: a *successful* upload must not be deleted here, and `deleteUnclaimed` with an
595
+ // empty list does exactly the directory tidying and nothing else.
596
+ await deps.store.deleteUnclaimed(batchId, []).catch(() => undefined);
597
+ }
598
+ }
599
+ /**
600
+ * Handles a client abandoning a batch (a failed multi-file upload, a cleared composer). Only
601
+ * *unclaimed* uploads can be deleted this way, so a caller cannot use it to pull files out from
602
+ * under a run that already claimed them.
603
+ */
604
+ export async function handleAttachmentCleanup(req, res, deps) {
605
+ const maxCleanupPaths = deps.maxCleanupPaths ?? 10;
606
+ const body = req.body;
607
+ if (typeof body?.batchId !== 'string'
608
+ || !Array.isArray(body.paths)
609
+ || body.paths.length > maxCleanupPaths
610
+ || !body.paths.every((path) => typeof path === 'string')) {
611
+ sendApiError(res, 400, createApiError('BAD_REQUEST', 'Invalid attachment cleanup request'));
612
+ return;
613
+ }
614
+ try {
615
+ await deps.store.deleteUnclaimed(body.batchId, body.paths);
616
+ res.status(204).end();
617
+ }
618
+ catch (error) {
619
+ respondToUploadFailure(res, deps, error, body.batchId, 'attachment-cleanup');
620
+ }
621
+ }
622
+ /**
623
+ * Mounts `POST`/`DELETE /api/attachments` on `app`. A pack's `http(app, services)` calls this
624
+ * directly.
625
+ *
626
+ * Hand-mounted rather than built from `defineJsonRoute`/`mountJsonRoute` for two concrete reasons:
627
+ * the upload reads the raw request stream (a JSON-parsed `req.body` is exactly what must not have
628
+ * happened), and the cleanup answers `204` with no body, which a JSON responder cannot express.
629
+ */
630
+ export function registerAttachmentRoutes(app, deps, adapter) {
631
+ // Per-registration, not module-level: two daemons in one process (a test harness, an embedded
632
+ // second host) must not share one upload budget.
633
+ const state = { activeUploads: 0 };
634
+ app.post(ATTACHMENTS_ROUTE_PATH, async (req, res) => {
635
+ try {
636
+ if (!passesOriginGuard(req, res, deps, adapter))
637
+ return;
638
+ await handleAttachmentUpload(req, res, deps, state);
639
+ }
640
+ catch (error) {
641
+ // `handleAttachmentUpload` already catches everything inside its own body — this only ever
642
+ // fires for `passesOriginGuard`/`guardSameOrigin` throwing, which is Result-returning by
643
+ // contract but not guaranteed never to throw (`isLocalSameOrigin` really does throw on a
644
+ // malformed `JINI_ALLOWED_ORIGINS` entry — see the paired regression test). Mounting this
645
+ // route bypasses `mountJsonRoute`'s adapter (see this function's own doc), so nothing else
646
+ // stood between that throw and an unhandled rejection with no process-level guard anywhere
647
+ // in this package's path.
648
+ if (!res.headersSent)
649
+ respondToUploadFailure(res, deps, error, null, 'attachment-upload');
650
+ }
651
+ });
652
+ app.delete(ATTACHMENTS_ROUTE_PATH, async (req, res) => {
653
+ try {
654
+ if (!passesOriginGuard(req, res, deps, adapter))
655
+ return;
656
+ await handleAttachmentCleanup(req, res, deps);
657
+ }
658
+ catch (error) {
659
+ if (!res.headersSent)
660
+ sendApiError(res, 500, reportInternalError(deps, 'attachment-cleanup', error, null));
661
+ }
662
+ });
663
+ }
664
+ //# sourceMappingURL=attachments.js.map