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.
- package/cli.js +57 -0
- package/default/infra/vibe/README.md +43 -0
- package/default/infra/vibe/cloudflare.ini.template +26 -0
- package/default/infra/vibe/ecosystem.vibe.config.cjs +44 -0
- package/default/infra/vibe/nginx-vibe-orchestrator.conf.template +50 -0
- package/default/infra/vibe/nginx-vibe-staging.conf.template +73 -0
- package/default/infra/vibe/vibe-gateway.service +38 -0
- package/default/infra/vibe/vibe-orchestrator.service +44 -0
- package/default/infra/vibe/vibe.env.template +24 -0
- package/default/mango/config/settings.json +35 -1
- package/default/vite.config.js +46 -0
- package/lib/devProxyGateway.js +173 -0
- package/lib/staging-gateway.js +23 -1
- package/lib/vibe-orchestrator/README.md +76 -0
- package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
- package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
- package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
- package/lib/vibe-orchestrator/server.js +344 -0
- package/lib/vibe-orchestrator/src/attachments.js +98 -0
- package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
- package/lib/vibe-orchestrator/src/config.js +227 -0
- package/lib/vibe-orchestrator/src/costMirror.js +64 -0
- package/lib/vibe-orchestrator/src/costStore.js +209 -0
- package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
- package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
- package/lib/vibe-orchestrator/src/preamble.js +139 -0
- package/lib/vibe-orchestrator/src/publisher.js +376 -0
- package/lib/vibe-orchestrator/src/recovery.js +199 -0
- package/lib/vibe-orchestrator/src/screenshot.js +38 -0
- package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
- package/lib/vibe-orchestrator/src/streamParser.js +188 -0
- package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
- package/package.json +1 -1
- 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 }
|