mango-cms 0.3.34 → 0.3.35

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 (32) hide show
  1. package/cli.js +57 -0
  2. package/default/infra/vibe/README.md +43 -0
  3. package/default/infra/vibe/cloudflare.ini.template +26 -0
  4. package/default/infra/vibe/ecosystem.vibe.config.cjs +44 -0
  5. package/default/infra/vibe/nginx-vibe-orchestrator.conf.template +50 -0
  6. package/default/infra/vibe/nginx-vibe-staging.conf.template +73 -0
  7. package/default/infra/vibe/vibe-gateway.service +38 -0
  8. package/default/infra/vibe/vibe-orchestrator.service +44 -0
  9. package/default/infra/vibe/vibe.env.template +24 -0
  10. package/default/mango/config/settings.json +40 -1
  11. package/default/vite.config.js +46 -0
  12. package/lib/vibe-orchestrator/README.md +76 -0
  13. package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
  14. package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
  15. package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
  16. package/lib/vibe-orchestrator/server.js +344 -0
  17. package/lib/vibe-orchestrator/src/attachments.js +98 -0
  18. package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
  19. package/lib/vibe-orchestrator/src/config.js +227 -0
  20. package/lib/vibe-orchestrator/src/costMirror.js +64 -0
  21. package/lib/vibe-orchestrator/src/costStore.js +209 -0
  22. package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
  23. package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
  24. package/lib/vibe-orchestrator/src/preamble.js +139 -0
  25. package/lib/vibe-orchestrator/src/publisher.js +376 -0
  26. package/lib/vibe-orchestrator/src/recovery.js +199 -0
  27. package/lib/vibe-orchestrator/src/screenshot.js +38 -0
  28. package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
  29. package/lib/vibe-orchestrator/src/streamParser.js +188 -0
  30. package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
  31. package/package.json +1 -1
  32. package/readme.md +6 -0
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ // path-guard-hook.mjs
3
+ //
4
+ // PreToolUse hook (HAP-1124, protection #1). Wired into every headless claude
5
+ // run via `--settings` (see claudeRunner.js) so it is enforced SERVER-SIDE,
6
+ // independent of the prompt. Claude invokes this before each Edit/Write/
7
+ // MultiEdit/NotebookEdit with a JSON payload on stdin:
8
+ //
9
+ // { cwd, tool_name, tool_input: { file_path | notebook_path, ... } }
10
+ //
11
+ // We resolve the target to a repo-root-relative path and, if it hits the
12
+ // protected denylist (or escapes the staging clone), block the tool by exiting
13
+ // with code 2 — the documented PreToolUse "deny" signal: the tool call is
14
+ // stopped and stderr is fed back to Claude as the reason, which it relays to the
15
+ // owner in the drawer. Anything else exits 0 (allow). The hook is fail-safe:
16
+ // any internal error allows the edit rather than wedging the agent, because the
17
+ // post-run git auto-commit + rollback (protection #2) is the backstop.
18
+
19
+ import path from 'node:path'
20
+ import { isProtectedPath, PROTECTED_MESSAGE } from '../src/pathGuard.js'
21
+
22
+ function readStdin() {
23
+ return new Promise((resolve) => {
24
+ let raw = ''
25
+ process.stdin.setEncoding('utf8')
26
+ process.stdin.on('data', (c) => { raw += c })
27
+ process.stdin.on('end', () => resolve(raw))
28
+ process.stdin.on('error', () => resolve(raw))
29
+ })
30
+ }
31
+
32
+ /** Collect every filesystem path a tool_input may target. */
33
+ function targetPaths(toolInput) {
34
+ if (!toolInput || typeof toolInput !== 'object') return []
35
+ const out = []
36
+ for (const key of ['file_path', 'notebook_path', 'path']) {
37
+ if (typeof toolInput[key] === 'string' && toolInput[key]) out.push(toolInput[key])
38
+ }
39
+ return out
40
+ }
41
+
42
+ /** Map an absolute-or-relative tool path to a clone-root-relative POSIX path. */
43
+ function toRel(cwd, p) {
44
+ const abs = path.isAbsolute(p) ? p : path.resolve(cwd || process.cwd(), p)
45
+ return path.relative(cwd || process.cwd(), abs)
46
+ }
47
+
48
+ async function main() {
49
+ let payload = {}
50
+ try { payload = JSON.parse((await readStdin()) || '{}') } catch { payload = {} }
51
+
52
+ const cwd = payload.cwd || process.cwd()
53
+ const toolInput = payload.tool_input || payload.toolInput || {}
54
+
55
+ for (const raw of targetPaths(toolInput)) {
56
+ const rel = toRel(cwd, raw)
57
+ // An edit that resolves outside the staging clone is never legitimate.
58
+ const escapes = rel.startsWith('..') || path.isAbsolute(rel)
59
+ if (escapes || isProtectedPath(rel)) {
60
+ process.stderr.write(
61
+ `${PROTECTED_MESSAGE} (${escapes ? 'outside the staging clone' : rel})`,
62
+ )
63
+ process.exit(2) // PreToolUse: block the tool, reason → Claude
64
+ }
65
+ }
66
+
67
+ process.exit(0) // allow
68
+ }
69
+
70
+ main().catch(() => process.exit(0)) // fail-open: rollback is the backstop
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ # vibe-recover.sh — one-command rollback for the Vibe staging clone (HAP-1124).
3
+ #
4
+ # A recovery path usable WITHOUT an engineer. Operates directly on the staging
5
+ # clone's git, so it works even if the orchestrator process is down.
6
+ #
7
+ # vibe-recover.sh status # show baseline + recent vibe edits
8
+ # vibe-recover.sh revert-last # undo the most recent vibe edit (git revert)
9
+ # vibe-recover.sh reset-baseline # hard-reset to the known-good baseline
10
+ #
11
+ # Env (override as needed):
12
+ # VIBE_STAGING_ROOT (default /root/Staging)
13
+ # VIBE_SITE (default generations-vibe)
14
+ # VIBE_BASELINE_TAG (default vibe-baseline)
15
+ set -euo pipefail
16
+
17
+ ROOT="${VIBE_STAGING_ROOT:-/root/Staging}"
18
+ SITE="${VIBE_SITE:-generations-vibe}"
19
+ TAG="${VIBE_BASELINE_TAG:-vibe-baseline}"
20
+ SETTINGS="mango/config/settings.json"
21
+ DIR="$ROOT/$SITE"
22
+ CMD="${1:-status}"
23
+
24
+ cd "$DIR" || { echo "staging clone not found: $DIR" >&2; exit 1; }
25
+
26
+ case "$CMD" in
27
+ status)
28
+ echo "site: $SITE ($DIR)"
29
+ echo "HEAD: $(git rev-parse --short HEAD 2>/dev/null || echo '(none)')"
30
+ if git rev-parse --verify --quiet "${TAG}^{commit}" >/dev/null; then
31
+ echo "baseline: $TAG -> $(git rev-parse --short "$TAG")"
32
+ else
33
+ echo "baseline: (none yet — created on first vibe edit)"
34
+ fi
35
+ echo "recent vibe edits:"
36
+ git log -n 10 --pretty=format:' %h %cs %s' | grep -E ' vibe:' || echo " (none)"
37
+ echo
38
+ ;;
39
+
40
+ revert-last)
41
+ last_msg="$(git log -1 --pretty=format:'%s')"
42
+ case "$last_msg" in
43
+ vibe:*) ;;
44
+ *) echo "the most recent commit is not a vibe edit — nothing to revert" >&2; exit 1 ;;
45
+ esac
46
+ git revert --no-edit HEAD
47
+ echo "reverted last vibe edit; HEAD is now $(git rev-parse --short HEAD)"
48
+ ;;
49
+
50
+ reset-baseline)
51
+ if ! git rev-parse --verify --quiet "${TAG}^{commit}" >/dev/null; then
52
+ echo "no baseline tag ($TAG) to reset to" >&2; exit 1
53
+ fi
54
+ git reset --hard "$TAG"
55
+ git clean -fd -e "$SETTINGS"
56
+ echo "reset clone to baseline $TAG ($(git rev-parse --short "$TAG"))"
57
+ ;;
58
+
59
+ *)
60
+ echo "usage: vibe-recover.sh {status|revert-last|reset-baseline}" >&2
61
+ exit 2
62
+ ;;
63
+ esac
@@ -0,0 +1,344 @@
1
+ // server.js
2
+ //
3
+ // Orchestrator HTTP entrypoint. Dependency-free (node:http). Exposes:
4
+ //
5
+ // GET /health -> { ok, canRunLive, config summary }
6
+ // GET /sessions -> [ { site, sessionId, cumulativeTokens, status } ]
7
+ // POST /prompt -> SSE stream of progress events body: { site, prompt, pageContext?, attachments? }
8
+ // POST /abort -> { ok } body: { site }
9
+ // POST /reset -> { ok } body: { site }
10
+ // POST /publish -> { ... } body: { site, confirm }
11
+ //
12
+ // Owner auth (HAP-1109): every endpoint except `GET /health` requires a real
13
+ // per-owner token in `Authorization: Bearer <token>`. The token is minted by
14
+ // Mango from an authenticated admin session and verified here against the shared
15
+ // VIBE_ORCH_TOKEN_SECRET (see src/ownerToken.js). Without that secret the server
16
+ // fails CLOSED — gated endpoints return 503, never open access. Client-side role
17
+ // flags are never trusted. The Claude OAuth token is never exposed by any
18
+ // endpoint. Intended to listen on localhost only and be fronted by the staging
19
+ // nginx vhost (see README).
20
+
21
+ import http from 'node:http'
22
+ import { SessionManager } from './src/sessionManager.js'
23
+ import { config, canRunLive } from './src/config.js'
24
+ import { verifyOwnerToken } from './src/ownerToken.js'
25
+ import * as publisher from './src/publisher.js'
26
+ import * as recovery from './src/recovery.js'
27
+ import { readCosts, rollup } from './src/costStore.js'
28
+
29
+ const manager = new SessionManager()
30
+
31
+ /** Extract the bearer token from the Authorization header. */
32
+ function bearer(req) {
33
+ const h = req.headers['authorization'] || ''
34
+ const m = /^Bearer\s+(.+)$/i.exec(h.trim())
35
+ return m ? m[1].trim() : ''
36
+ }
37
+
38
+ /**
39
+ * Resolve owner auth for a request. Returns the verifyOwnerToken result:
40
+ * { ok:true, payload } or { ok:false, reason, status }. Fails closed when no
41
+ * token secret is configured (status 503).
42
+ */
43
+ function requireOwner(req) {
44
+ return verifyOwnerToken(bearer(req), {
45
+ secret: config.tokenSecret,
46
+ ownerRoles: config.ownerRoles,
47
+ })
48
+ }
49
+
50
+ function sendJson(res, status, body) {
51
+ const data = JSON.stringify(body)
52
+ res.writeHead(status, { 'content-type': 'application/json' })
53
+ res.end(data)
54
+ }
55
+
56
+ /**
57
+ * Apply CORS headers for an allowed browser origin (HAP-1121). The ⌘K drawer is
58
+ * served from the site origin (e.g. https://staging-vibe.generations.org) and
59
+ * calls this orchestrator on the -api origin cross-origin. Without an allow-list
60
+ * + preflight handling the browser blocks every call and the drawer shows
61
+ * "Failed to fetch" (a transport failure with no HTTP status). Headers are set
62
+ * with setHeader so they merge into later writeHead() calls (JSON + SSE alike).
63
+ * Returns true when the request Origin is allowed.
64
+ */
65
+ function applyCors(req, res) {
66
+ const origin = req.headers['origin']
67
+ if (!origin) return false
68
+ const list = config.allowedOrigins
69
+ const allowed = list.length === 0 || list.includes(origin)
70
+ if (!allowed) return false
71
+ res.setHeader('Access-Control-Allow-Origin', origin)
72
+ res.setHeader('Vary', 'Origin')
73
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
74
+ res.setHeader('Access-Control-Allow-Headers', 'authorization, content-type')
75
+ res.setHeader('Access-Control-Max-Age', '86400')
76
+ return true
77
+ }
78
+
79
+ /**
80
+ * Resolve a { from, to } ms window from query params (HAP-1123). Each of `from`
81
+ * and `to` may be epoch-ms or an ISO date (YYYY-MM-DD). With neither present we
82
+ * default to the current calendar month-to-date so a bare `/costs/rollup` answers
83
+ * "this month's spend". An ISO date with no time is treated as UTC midnight.
84
+ */
85
+ function parseRange(params) {
86
+ const parse = (v) => {
87
+ if (!v) return undefined
88
+ if (/^\d+$/.test(v)) return Number.parseInt(v, 10)
89
+ const t = Date.parse(v)
90
+ return Number.isNaN(t) ? undefined : t
91
+ }
92
+ let from = parse(params.get('from'))
93
+ let to = parse(params.get('to'))
94
+ if (from === undefined && to === undefined) {
95
+ const now = new Date()
96
+ from = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)
97
+ }
98
+ return { from, to }
99
+ }
100
+
101
+ function readBody(req) {
102
+ return new Promise((resolve, reject) => {
103
+ let raw = ''
104
+ req.on('data', (c) => {
105
+ raw += c
106
+ // 80MB ceiling: prompt bodies may carry a base64 viewport screenshot
107
+ // (HAP-1111) plus up to 5 user attachments of ≤10MB each (HAP-1126). The
108
+ // per-file size/mime/count guard runs again server-side in attachments.js;
109
+ // this is just the gross transport cap.
110
+ if (raw.length > 80_000_000) reject(new Error('body too large'))
111
+ })
112
+ req.on('end', () => {
113
+ if (!raw) return resolve({})
114
+ try { resolve(JSON.parse(raw)) } catch { reject(new Error('invalid JSON body')) }
115
+ })
116
+ req.on('error', reject)
117
+ })
118
+ }
119
+
120
+ function startSse(res, { onPing } = {}) {
121
+ res.writeHead(200, {
122
+ 'content-type': 'text/event-stream',
123
+ 'cache-control': 'no-cache',
124
+ connection: 'keep-alive',
125
+ 'x-accel-buffering': 'no', // disable nginx proxy buffering
126
+ })
127
+ const write = (event, data) => {
128
+ res.write(`event: ${event}\n`)
129
+ res.write(`data: ${JSON.stringify(data)}\n\n`)
130
+ }
131
+ // keepalive comment ping so idle proxies don't drop the connection. The ping
132
+ // also counts as session activity (HAP-1110) so an actively-read but idle
133
+ // session is kept warm rather than reaped mid-read.
134
+ const ping = setInterval(() => {
135
+ res.write(': ping\n\n')
136
+ onPing?.()
137
+ }, 15000)
138
+ if (ping.unref) ping.unref()
139
+ return { write, close: () => clearInterval(ping) }
140
+ }
141
+
142
+ async function handlePrompt(req, res) {
143
+ let body
144
+ try { body = await readBody(req) } catch (e) { return sendJson(res, 400, { error: e.message }) }
145
+ const { site, prompt, pageContext, attachments } = body
146
+
147
+ let handle
148
+ try {
149
+ handle = manager.submit(site, prompt, { pageContext, attachments })
150
+ } catch (e) {
151
+ return sendJson(res, 400, { error: e.message })
152
+ }
153
+
154
+ // Track this connection as an active reader and keep the session warm on ping.
155
+ manager.addReader(site)
156
+ const sse = startSse(res, { onPing: () => manager.touch(site) })
157
+ sse.write('accepted', { site })
158
+
159
+ handle.emitter.on('event', (ev) => sse.write('progress', ev))
160
+ handle.emitter.on('error', (err) => sse.write('error', { message: err.message }))
161
+ handle.emitter.on('end', (summary) => {
162
+ sse.write('end', summary)
163
+ sse.close()
164
+ manager.removeReader(site)
165
+ res.end()
166
+ })
167
+
168
+ // If the client disconnects, abort the run to free the slot.
169
+ req.on('close', () => {
170
+ if (!res.writableEnded) {
171
+ handle.abort()
172
+ manager.removeReader(site)
173
+ }
174
+ sse.close()
175
+ })
176
+ }
177
+
178
+ const server = http.createServer(async (req, res) => {
179
+ const url = new URL(req.url, 'http://localhost')
180
+ const route = `${req.method} ${url.pathname}`
181
+
182
+ // CORS (HAP-1121): set allow-origin headers for an allowed browser origin and
183
+ // answer the preflight BEFORE the auth gate. Browsers never send the
184
+ // Authorization header on an OPTIONS preflight, so gating it on auth returned
185
+ // 401 and the whole cross-origin call failed in-browser as "Failed to fetch".
186
+ applyCors(req, res)
187
+ if (req.method === 'OPTIONS') {
188
+ res.writeHead(204)
189
+ return res.end()
190
+ }
191
+
192
+ // `GET /health` is the only unauthenticated route: a minimal liveness probe
193
+ // for ops/nginx that leaks no config. Everything else requires an owner token.
194
+ if (route === 'GET /health') {
195
+ return sendJson(res, 200, { ok: true, ownerAuth: !!config.tokenSecret })
196
+ }
197
+
198
+ // Real per-owner gate. 401 = no/invalid token, 403 = valid but not an owner,
199
+ // 503 = server not configured for owner auth (fails closed).
200
+ const auth = requireOwner(req)
201
+ if (!auth.ok) return sendJson(res, auth.status || 401, { error: auth.reason || 'unauthorized' })
202
+ req.owner = auth.payload
203
+
204
+ try {
205
+ switch (route) {
206
+ case 'GET /health/details':
207
+ return sendJson(res, 200, {
208
+ ok: true,
209
+ canRunLive: canRunLive(),
210
+ stagingRoot: config.stagingRoot,
211
+ allowedSites: config.allowedSites,
212
+ tokenCap: config.tokenCap,
213
+ permissionMode: config.permissionMode,
214
+ publishEnabled: config.publishEnabled,
215
+ publishRemote: config.publishRemote,
216
+ settingsPath: config.settingsPath,
217
+ })
218
+
219
+ case 'GET /sessions':
220
+ return sendJson(res, 200, { sessions: manager.list() })
221
+
222
+ // ---- Cost tracking (HAP-1123) ----
223
+ // GET /costs — raw per-turn records in a window (for export/audit)
224
+ // GET /costs/rollup — summed totals (by day/session/model) to invoice from
225
+ // Both default to the current calendar month when no range is given, and
226
+ // accept ?from / ?to as epoch-ms or YYYY-MM-DD, plus an optional ?site.
227
+ case 'GET /costs': {
228
+ const range = parseRange(url.searchParams)
229
+ const site = url.searchParams.get('site') || undefined
230
+ return sendJson(res, 200, { ...range, site: site || null, records: readCosts({ ...range, site }) })
231
+ }
232
+
233
+ case 'GET /costs/rollup': {
234
+ const range = parseRange(url.searchParams)
235
+ const site = url.searchParams.get('site') || undefined
236
+ return sendJson(res, 200, rollup({ ...range, site }))
237
+ }
238
+
239
+ case 'POST /prompt':
240
+ return await handlePrompt(req, res)
241
+
242
+ case 'GET /publish/diff': {
243
+ const site = url.searchParams.get('site') || config.allowedSites[0]
244
+ try {
245
+ const d = await publisher.diff(site)
246
+ return sendJson(res, 200, d)
247
+ } catch (e) {
248
+ return sendJson(res, e.statusCode || 400, { error: e.message })
249
+ }
250
+ }
251
+
252
+ case 'POST /publish': {
253
+ let body
254
+ try { body = await readBody(req) } catch (e) { return sendJson(res, 400, { error: e.message }) }
255
+ const { site, message, confirm } = body
256
+ try {
257
+ const result = await publisher.publish(site || config.allowedSites[0], { message, confirm })
258
+ return sendJson(res, 200, result)
259
+ } catch (e) {
260
+ // 409 ⇒ merge conflict publishing the branch into the deploy branch;
261
+ // surface the conflicting files so the owner can resolve them.
262
+ return sendJson(res, e.statusCode || 500, {
263
+ error: e.message,
264
+ steps: e.steps,
265
+ conflicts: e.conflicts,
266
+ deployBranch: e.deployBranch,
267
+ })
268
+ }
269
+ }
270
+
271
+ // ---- Recovery / rollback (HAP-1124) ----
272
+ // GET /recovery/status — baseline + recent vibe commits + what's available
273
+ // POST /recovery/revert-last — undo the most recent vibe edit (git revert)
274
+ // POST /recovery/reset-baseline— hard-reset to the known-good baseline (confirm:true)
275
+ // A recovery path usable without an engineer; also wrapped by scripts/vibe-recover.sh.
276
+ case 'GET /recovery/status': {
277
+ const site = url.searchParams.get('site') || config.allowedSites[0]
278
+ try {
279
+ return sendJson(res, 200, await recovery.status(site))
280
+ } catch (e) {
281
+ return sendJson(res, e.statusCode || 400, { error: e.message })
282
+ }
283
+ }
284
+
285
+ case 'POST /recovery/revert-last': {
286
+ const { site } = await readBody(req)
287
+ const target = site || config.allowedSites[0]
288
+ if (manager.get(target)?.status === 'running') {
289
+ return sendJson(res, 409, { error: 'cannot recover while a run is in progress' })
290
+ }
291
+ try {
292
+ return sendJson(res, 200, await recovery.revertLast(target))
293
+ } catch (e) {
294
+ return sendJson(res, e.statusCode || 400, { error: e.message })
295
+ }
296
+ }
297
+
298
+ case 'POST /recovery/reset-baseline': {
299
+ const { site, confirm } = await readBody(req)
300
+ const target = site || config.allowedSites[0]
301
+ if (confirm !== true) {
302
+ return sendJson(res, 400, { error: 'reset to baseline requires explicit confirm:true' })
303
+ }
304
+ if (manager.get(target)?.status === 'running') {
305
+ return sendJson(res, 409, { error: 'cannot recover while a run is in progress' })
306
+ }
307
+ try {
308
+ return sendJson(res, 200, await recovery.resetToBaseline(target))
309
+ } catch (e) {
310
+ return sendJson(res, e.statusCode || 400, { error: e.message })
311
+ }
312
+ }
313
+
314
+ case 'POST /abort': {
315
+ const { site } = await readBody(req)
316
+ manager.abort(site)
317
+ return sendJson(res, 200, { ok: true })
318
+ }
319
+
320
+ case 'POST /reset': {
321
+ const { site } = await readBody(req)
322
+ try { manager.reset(site) } catch (e) { return sendJson(res, 409, { error: e.message }) }
323
+ return sendJson(res, 200, { ok: true })
324
+ }
325
+
326
+ default:
327
+ return sendJson(res, 404, { error: 'not found' })
328
+ }
329
+ } catch (e) {
330
+ if (!res.headersSent) sendJson(res, 500, { error: e.message })
331
+ }
332
+ })
333
+
334
+ // Listen on loopback only; nginx terminates TLS and proxies.
335
+ server.listen(config.port, '127.0.0.1', () => {
336
+ // eslint-disable-next-line no-console
337
+ console.log(`[vibe-orchestrator] listening on 127.0.0.1:${config.port} (live=${canRunLive()}, ownerAuth=${!!config.tokenSecret})`)
338
+ if (!config.tokenSecret) {
339
+ // eslint-disable-next-line no-console
340
+ console.warn('[vibe-orchestrator] WARNING: VIBE_ORCH_TOKEN_SECRET is unset — owner auth is NOT configured; all gated endpoints will return 503 (fail closed).')
341
+ }
342
+ })
343
+
344
+ export { server }
@@ -0,0 +1,98 @@
1
+ // attachments.js
2
+ //
3
+ // Persist client-uploaded chat attachments (images + PDFs) to temp files the
4
+ // agent can Read — Claude Code natively reads images AND PDFs. Generalizes
5
+ // screenshot.js: each file arrives as a base64 data URL and is written to the OS
6
+ // temp dir, NEVER into the staging repo, so nothing is ever committed or
7
+ // published. Each turn we first clear the site's previous attachments so temp
8
+ // growth stays bounded (the screenshot reuses one fixed file; attachments are a
9
+ // variable-length set, so we sweep the whole `<site>-att-*` prefix). Best-effort
10
+ // — never throws. A server-side size + mime + count guard runs here too so we
11
+ // don't trust the client (HAP-1126).
12
+
13
+ import { writeFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
14
+ import { tmpdir } from 'node:os'
15
+ import { join } from 'node:path'
16
+
17
+ const DIR = join(tmpdir(), 'vibe-orchestrator')
18
+
19
+ // Allowed upload types → file extension. Mirrors the client allow-list. The
20
+ // extension is taken from the data URL's own mime (what the bytes actually are),
21
+ // not the caller-supplied name, exactly like screenshot.js.
22
+ const MIME_EXT = {
23
+ 'image/png': 'png',
24
+ 'image/jpeg': 'jpg',
25
+ 'image/webp': 'webp',
26
+ 'image/gif': 'gif',
27
+ 'application/pdf': 'pdf',
28
+ }
29
+
30
+ const MAX_FILES = 5
31
+ const MAX_BYTES = 10 * 1024 * 1024 // 10MB per file, decoded (server-side guard)
32
+
33
+ const DATA_URL = /^data:([a-z0-9.+-]+\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)$/i
34
+
35
+ function safeSite(site) {
36
+ return String(site || 'site').replace(/[^a-z0-9_-]/gi, '_')
37
+ }
38
+
39
+ /** Remove any previously-persisted attachments for a site (best-effort). */
40
+ function clearAttachments(site) {
41
+ const prefix = `${safeSite(site)}-att-`
42
+ try {
43
+ for (const f of readdirSync(DIR)) {
44
+ if (f.startsWith(prefix)) {
45
+ try { rmSync(join(DIR, f)) } catch { /* already gone */ }
46
+ }
47
+ }
48
+ } catch { /* dir missing — nothing to clear */ }
49
+ }
50
+
51
+ /**
52
+ * Persist a list of `{ name, mime, dataUrl }` attachments to disk. Returns an
53
+ * array of `{ name, path, mime }` for the files that were saved. Invalid entries
54
+ * (disallowed mime, malformed data URL, empty, or oversize) are skipped, and the
55
+ * count is capped at MAX_FILES. Passing an array (even empty) clears the site's
56
+ * previous attachments first, so each turn starts clean. Best-effort — never
57
+ * throws.
58
+ *
59
+ * @param {string} site
60
+ * @param {Array<{name?:string, mime?:string, dataUrl?:string}>} attachments
61
+ * @returns {Array<{name:string, path:string, mime:string}>}
62
+ */
63
+ function persistAttachments(site, attachments) {
64
+ if (!Array.isArray(attachments)) return []
65
+ // Per-turn clean: drop the previous turn's files so temp never grows unbounded.
66
+ clearAttachments(site)
67
+ if (!attachments.length) return []
68
+
69
+ const saved = []
70
+ try { mkdirSync(DIR, { recursive: true }) } catch { return saved }
71
+ const safe = safeSite(site)
72
+ let n = 0
73
+ for (const att of attachments) {
74
+ if (n >= MAX_FILES) break
75
+ if (!att || typeof att !== 'object') continue
76
+ const dataUrl = typeof att.dataUrl === 'string' ? att.dataUrl.trim() : ''
77
+ const m = DATA_URL.exec(dataUrl)
78
+ if (!m) continue
79
+ const mime = m[1].toLowerCase()
80
+ const ext = MIME_EXT[mime]
81
+ if (!ext) continue // disallowed type — skip silently (client already warned)
82
+ let buf
83
+ try { buf = Buffer.from(m[2].replace(/\s+/g, ''), 'base64') } catch { continue }
84
+ if (!buf.length || buf.length > MAX_BYTES) continue // empty or oversize — skip
85
+ n += 1
86
+ const path = join(DIR, `${safe}-att-${n}.${ext}`)
87
+ const name = typeof att.name === 'string' && att.name.trim()
88
+ ? att.name.trim().slice(0, 200)
89
+ : `attachment-${n}.${ext}`
90
+ try {
91
+ writeFileSync(path, buf)
92
+ saved.push({ name, path, mime })
93
+ } catch { /* write failed — skip this one */ }
94
+ }
95
+ return saved
96
+ }
97
+
98
+ export { persistAttachments, clearAttachments, MIME_EXT, MAX_FILES, MAX_BYTES }