mango-cms 0.3.33 → 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 (34) 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 +35 -1
  11. package/default/vite.config.js +46 -0
  12. package/lib/devProxyGateway.js +173 -0
  13. package/lib/staging-gateway.js +23 -1
  14. package/lib/vibe-orchestrator/README.md +76 -0
  15. package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
  16. package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
  17. package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
  18. package/lib/vibe-orchestrator/server.js +344 -0
  19. package/lib/vibe-orchestrator/src/attachments.js +98 -0
  20. package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
  21. package/lib/vibe-orchestrator/src/config.js +227 -0
  22. package/lib/vibe-orchestrator/src/costMirror.js +64 -0
  23. package/lib/vibe-orchestrator/src/costStore.js +209 -0
  24. package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
  25. package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
  26. package/lib/vibe-orchestrator/src/preamble.js +139 -0
  27. package/lib/vibe-orchestrator/src/publisher.js +376 -0
  28. package/lib/vibe-orchestrator/src/recovery.js +199 -0
  29. package/lib/vibe-orchestrator/src/screenshot.js +38 -0
  30. package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
  31. package/lib/vibe-orchestrator/src/streamParser.js +188 -0
  32. package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
  33. package/package.json +1 -1
  34. package/readme.md +6 -0
@@ -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 }
@@ -0,0 +1,233 @@
1
+ // claudeRunner.js
2
+ //
3
+ // Spawns Claude Code headless and turns its stream-json output into a live
4
+ // EventEmitter of normalized progress events.
5
+ //
6
+ // claude -p "<prompt>" --output-format stream-json --verbose \
7
+ // --permission-mode <mode> [--resume <sessionId>] [--model <m>]
8
+ //
9
+ // cwd is scoped to the resolved staging site dir; the OAuth token is passed via
10
+ // the child env only (never on argv, never logged). The token meter watches
11
+ // usage as it streams and the run is aborted if the per-session cap trips.
12
+
13
+ import { spawn } from 'node:child_process'
14
+ import { EventEmitter } from 'node:events'
15
+ import { StreamParser, KINDS } from './streamParser.js'
16
+ import { TokenMeter } from './tokenMeter.js'
17
+ import { applyMarkup } from './costStore.js'
18
+
19
+ /**
20
+ * Build the inline `--settings` JSON that installs the PreToolUse path-guard hook
21
+ * (HAP-1124). Enforced server-side for every run: the hook blocks edits to the
22
+ * Vibe system regardless of the prompt. Returns '' when no hook is configured.
23
+ * @param {string} [pathGuardHook] absolute path to the hook script
24
+ * @returns {string}
25
+ */
26
+ function buildSettings(pathGuardHook) {
27
+ if (!pathGuardHook) return ''
28
+ // The `command` is run via shell by the CLI, so quote the path. spawn passes
29
+ // the whole JSON as one argv element, so the JSON itself needs no extra quoting.
30
+ return JSON.stringify({
31
+ hooks: {
32
+ PreToolUse: [
33
+ {
34
+ matcher: 'Edit|Write|MultiEdit|NotebookEdit',
35
+ hooks: [{ type: 'command', command: `node '${pathGuardHook}'` }],
36
+ },
37
+ ],
38
+ },
39
+ })
40
+ }
41
+
42
+ /**
43
+ * Build the claude argv for a run.
44
+ * @returns {string[]}
45
+ */
46
+ function buildArgs({ prompt, permissionMode, model, resumeSessionId, pathGuardHook, allowedTools = [], addDirs = [] }) {
47
+ const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose']
48
+ if (permissionMode) args.push('--permission-mode', permissionMode)
49
+ if (model) args.push('--model', model)
50
+ if (resumeSessionId) args.push('--resume', resumeSessionId)
51
+ // Pre-approve read-only web tools (WebFetch/WebSearch) so they aren't auto-denied
52
+ // in headless, and grant read access to out-of-cwd dirs (the persisted screenshot
53
+ // + attachments). Both are <tool...>/<dir...> variadic flags (HAP-1096).
54
+ if (allowedTools.length) args.push('--allowedTools', ...allowedTools)
55
+ if (addDirs.length) args.push('--add-dir', ...addDirs)
56
+ const settings = buildSettings(pathGuardHook)
57
+ if (settings) args.push('--settings', settings)
58
+ return args
59
+ }
60
+
61
+ /**
62
+ * Build the child env for a run.
63
+ *
64
+ * The claude CLI authenticates one of two ways: an explicit
65
+ * `CLAUDE_CODE_OAUTH_TOKEN`, or its own auto-refreshing login credentials at
66
+ * `~/.claude/.credentials.json`. HAP-1116's outage was a *stale/expired* token
67
+ * (`401 authentication_failed`); the stopgap was to blank the token so the CLI
68
+ * falls back to the login credentials. Relying on the CLI treating an empty
69
+ * string as "no token" is brittle, so when no token is supplied we strip the
70
+ * var entirely rather than passing `''`. This makes the login-credentials path
71
+ * (the Phase-1 decision — "Claude Code CLI not API key") explicit and durable.
72
+ *
73
+ * @param {string} oauthToken
74
+ * @returns {NodeJS.ProcessEnv}
75
+ */
76
+ function buildEnv(oauthToken) {
77
+ const env = { ...process.env }
78
+ if (oauthToken) {
79
+ env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken
80
+ } else {
81
+ delete env.CLAUDE_CODE_OAUTH_TOKEN
82
+ }
83
+ return env
84
+ }
85
+
86
+ /**
87
+ * Run a single headless prompt.
88
+ *
89
+ * @param {Object} opts
90
+ * @param {string} opts.prompt
91
+ * @param {string} opts.cwd - absolute staging site dir
92
+ * @param {string} opts.oauthToken
93
+ * @param {string} [opts.claudeBin]
94
+ * @param {string} [opts.permissionMode]
95
+ * @param {string} [opts.model]
96
+ * @param {string} [opts.resumeSessionId]
97
+ * @param {number} [opts.tokenCap]
98
+ * @param {number} [opts.timeoutMs]
99
+ * @param {function} [opts.spawnFn] - injectable for tests (defaults to child_process.spawn)
100
+ * @returns {{ emitter: EventEmitter, abort: () => void, done: Promise<object> }}
101
+ *
102
+ * Emitter events: 'event' (ProgressEvent), 'error' (Error), 'end' (summary).
103
+ */
104
+ function runPrompt(opts) {
105
+ const {
106
+ prompt,
107
+ cwd,
108
+ oauthToken,
109
+ claudeBin = 'claude',
110
+ permissionMode = 'acceptEdits',
111
+ model = '',
112
+ resumeSessionId = null,
113
+ tokenCap = 0,
114
+ timeoutMs = 0,
115
+ markupPct = 0, // client-facing markup % stamped onto the result event (HAP-1123)
116
+ pathGuardHook = '',
117
+ allowedTools = [], // tools pre-approved for headless (e.g. WebFetch/WebSearch) — HAP-1096
118
+ addDirs = [], // extra readable dirs (screenshot + attachments tmp) — HAP-1096
119
+ spawnFn = spawn,
120
+ } = opts
121
+
122
+ const emitter = new EventEmitter()
123
+ const parser = new StreamParser()
124
+ const meter = new TokenMeter(tokenCap)
125
+
126
+ let sessionId = resumeSessionId || null
127
+ let runModel = model || null // resolved from the CLI's session_start init
128
+ let lastCostUsd = null // this turn's total_cost_usd (CLI source of truth)
129
+ let capTripped = false
130
+ let settled = false
131
+ let stderrTail = ''
132
+
133
+ const args = buildArgs({ prompt, permissionMode, model, resumeSessionId, pathGuardHook, allowedTools, addDirs })
134
+ const child = spawnFn(claudeBin, args, {
135
+ cwd,
136
+ env: buildEnv(oauthToken),
137
+ stdio: ['ignore', 'pipe', 'pipe'],
138
+ })
139
+
140
+ let timer = null
141
+ if (timeoutMs > 0) {
142
+ timer = setTimeout(() => {
143
+ emitter.emit('event', { kind: 'aborted', reason: 'timeout', timeoutMs })
144
+ kill()
145
+ }, timeoutMs)
146
+ if (timer.unref) timer.unref()
147
+ }
148
+
149
+ const done = new Promise((resolve) => {
150
+ const finish = (summary) => {
151
+ if (settled) return
152
+ settled = true
153
+ if (timer) clearTimeout(timer)
154
+ emitter.emit('end', summary)
155
+ resolve(summary)
156
+ }
157
+
158
+ function handleEvents(events) {
159
+ for (const ev of events) {
160
+ if (ev.kind === KINDS.SESSION_START && ev.sessionId) sessionId = ev.sessionId
161
+ if (ev.kind === KINDS.SESSION_START && ev.model) runModel = ev.model
162
+ if (ev.kind === KINDS.RESULT && ev.sessionId) sessionId = ev.sessionId
163
+ // Only assistant turns add to the meter. The final `result` message
164
+ // repeats a usage summary — recording it would double-count, so we just
165
+ // stamp the running snapshot onto it.
166
+ if (ev.usage && ev.kind !== KINDS.RESULT) meter.record(ev.usage)
167
+ if (ev.usage || ev.kind === KINDS.RESULT) ev.tokens = meter.snapshot()
168
+ // HAP-1123: the `result` message carries the CLI's authoritative
169
+ // total_cost_usd for this turn. Stamp the resolved model onto it (the
170
+ // result message itself omits model) so the drawer can show "$cost ·
171
+ // model" inline, and remember the cost for the run summary.
172
+ if (ev.kind === KINDS.RESULT) {
173
+ ev.model = runModel
174
+ if (typeof ev.costUsd === 'number') {
175
+ lastCostUsd = ev.costUsd
176
+ // Client-facing number (raw cost + markup) so the drawer shows what the
177
+ // client is billed without needing to know the markup rate itself.
178
+ ev.costMarkupPct = markupPct
179
+ ev.billedUsd = applyMarkup(ev.costUsd, markupPct)
180
+ }
181
+ }
182
+ emitter.emit('event', ev)
183
+ if (!capTripped && meter.exceeded) {
184
+ capTripped = true
185
+ emitter.emit('event', {
186
+ kind: 'aborted',
187
+ reason: 'token_cap',
188
+ tokens: meter.snapshot(),
189
+ })
190
+ kill()
191
+ }
192
+ }
193
+ }
194
+
195
+ child.stdout.on('data', (chunk) => handleEvents(parser.push(chunk)))
196
+ child.stderr.on('data', (chunk) => {
197
+ stderrTail = (stderrTail + chunk.toString()).slice(-4000)
198
+ })
199
+
200
+ child.on('error', (err) => {
201
+ emitter.emit('error', err)
202
+ finish({ ok: false, error: err.message, sessionId, model: runModel, costUsd: lastCostUsd, tokens: meter.snapshot() })
203
+ })
204
+
205
+ child.on('close', (code, signal) => {
206
+ handleEvents(parser.flush())
207
+ const ok = !capTripped && code === 0
208
+ finish({
209
+ ok,
210
+ code,
211
+ signal,
212
+ aborted: capTripped,
213
+ sessionId,
214
+ model: runModel,
215
+ costUsd: lastCostUsd,
216
+ tokens: meter.snapshot(),
217
+ stderr: ok ? undefined : stderrTail.trim() || undefined,
218
+ })
219
+ })
220
+ })
221
+
222
+ function kill() {
223
+ if (!child.killed) {
224
+ child.kill('SIGTERM')
225
+ // Hard-stop if it ignores SIGTERM.
226
+ setTimeout(() => { if (!child.killed) child.kill('SIGKILL') }, 3000).unref?.()
227
+ }
228
+ }
229
+
230
+ return { emitter, abort: kill, done, getSessionId: () => sessionId }
231
+ }
232
+
233
+ export { runPrompt, buildArgs, buildEnv, buildSettings }