mango-cms 0.3.34 → 0.3.36
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 +113 -23
- 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 +40 -1
- package/default/package.json +1 -1
- package/default/vite.config.js +46 -0
- 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,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 }
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// config.js
|
|
2
|
+
//
|
|
3
|
+
// Centralized env reading + validation for the orchestrator. The OAuth token is
|
|
4
|
+
// never read from git — it lives in /root/.vibe-orchestrator.env on the droplet
|
|
5
|
+
// (chmod 600) and is sourced into the process env before launch (see README).
|
|
6
|
+
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
|
|
14
|
+
function envInt(name, fallback) {
|
|
15
|
+
const v = process.env[name]
|
|
16
|
+
if (v == null || v === '') return fallback
|
|
17
|
+
const n = Number.parseInt(v, 10)
|
|
18
|
+
return Number.isFinite(n) ? n : fallback
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const config = {
|
|
22
|
+
// HTTP/SSE listen port for the orchestrator service.
|
|
23
|
+
port: envInt('VIBE_ORCH_PORT', 7130),
|
|
24
|
+
|
|
25
|
+
// Root under which each <site> is a clone, e.g. /root/Staging/<site>.
|
|
26
|
+
stagingRoot: process.env.VIBE_STAGING_ROOT || '/root/Staging',
|
|
27
|
+
|
|
28
|
+
// Whitelist of site slugs the orchestrator is allowed to operate on. Empty =>
|
|
29
|
+
// any directory directly under stagingRoot is permitted (still path-guarded).
|
|
30
|
+
allowedSites: (process.env.VIBE_ALLOWED_SITES || 'generations-vibe')
|
|
31
|
+
.split(',')
|
|
32
|
+
.map((s) => s.trim())
|
|
33
|
+
.filter(Boolean),
|
|
34
|
+
|
|
35
|
+
// claude CLI binary (v2.1.157 installed at /usr/local/bin/claude on droplet).
|
|
36
|
+
claudeBin: process.env.VIBE_CLAUDE_BIN || 'claude',
|
|
37
|
+
|
|
38
|
+
// Per-session cumulative token cap. 0 = unlimited. Raised from 200k → 2M
|
|
39
|
+
// (HAP-1096): a real vibe session reads files + viewport screenshots (images are
|
|
40
|
+
// token-heavy) and 200k was tripping mid-demo. Cost is still tracked + billed per
|
|
41
|
+
// turn, so this is a runaway-safety rail, not a budget — tune via VIBE_TOKEN_CAP.
|
|
42
|
+
tokenCap: envInt('VIBE_TOKEN_CAP', 2_000_000),
|
|
43
|
+
|
|
44
|
+
// Headless permission mode. Deliberately NOT bypassPermissions: this droplet is
|
|
45
|
+
// shared (it hosts ~40 other production sites) and the orchestrator runs as root,
|
|
46
|
+
// so blanket-allowing Bash would let an errant run `rm -rf` a sibling tenant — a
|
|
47
|
+
// blast radius the HAP-1124 path guard does NOT cover (it only matches Edit/Write,
|
|
48
|
+
// not Bash). acceptEdits keeps that destructive/network Bash gated while still
|
|
49
|
+
// auto-accepting file edits on the throwaway clone. The HAP-1096 web-fetch +
|
|
50
|
+
// screenshot-read failures are fixed surgically instead via `allowedTools` +
|
|
51
|
+
// `toolExtraDirs` below — read-only capabilities with no shared-droplet risk.
|
|
52
|
+
permissionMode: process.env.VIBE_PERMISSION_MODE || 'acceptEdits',
|
|
53
|
+
|
|
54
|
+
// Tools pre-approved for headless runs (no interactive approver exists, so
|
|
55
|
+
// otherwise-gated tools are auto-DENIED). WebFetch/WebSearch are read-only network
|
|
56
|
+
// reads with no filesystem/destructive risk — enabling them lets the owner say
|
|
57
|
+
// "use our real logo + brand blue from generations.org" and have it work (HAP-1096,
|
|
58
|
+
// where WebFetch showed "isn't permitted here"). Space/comma separated override.
|
|
59
|
+
allowedTools: (process.env.VIBE_ALLOWED_TOOLS || 'WebFetch WebSearch')
|
|
60
|
+
.split(/[\s,]+/)
|
|
61
|
+
.map((s) => s.trim())
|
|
62
|
+
.filter(Boolean),
|
|
63
|
+
|
|
64
|
+
// Extra directories the run's tools may access (--add-dir). The viewport
|
|
65
|
+
// screenshot and any pasted/uploaded attachments are persisted OUTSIDE the cwd
|
|
66
|
+
// (os.tmpdir()/vibe-orchestrator, so they never land in the editable clone); under
|
|
67
|
+
// acceptEdits the agent otherwise can't Read them — that is the HAP-1096 "screenshot
|
|
68
|
+
// file gated behind approval" failure. Allowing this one temp dir restores the read.
|
|
69
|
+
toolExtraDirs: (
|
|
70
|
+
process.env.VIBE_TOOL_EXTRA_DIRS
|
|
71
|
+
|| path.join(os.tmpdir(), 'vibe-orchestrator')
|
|
72
|
+
)
|
|
73
|
+
.split(/[\s,]+/)
|
|
74
|
+
.map((s) => s.trim())
|
|
75
|
+
.filter(Boolean),
|
|
76
|
+
|
|
77
|
+
// Optional model override (e.g. claude-opus-4-8). Empty => CLI default.
|
|
78
|
+
model: process.env.VIBE_MODEL || '',
|
|
79
|
+
|
|
80
|
+
// The OAuth token. An EXPLICIT token is optional: when empty, live runs use
|
|
81
|
+
// the claude CLI's own auto-refreshing login credentials instead (the Phase-1
|
|
82
|
+
// decision — "Claude Code CLI not API key"). Never required for the server to
|
|
83
|
+
// boot — scaffold/stream-parse/session work runs token-free.
|
|
84
|
+
oauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN || '',
|
|
85
|
+
|
|
86
|
+
// Path to the claude CLI's auto-refreshing login credentials. When present,
|
|
87
|
+
// live runs work without an explicit oauthToken. Override only in tests.
|
|
88
|
+
claudeCredentialsPath:
|
|
89
|
+
process.env.VIBE_CLAUDE_CREDENTIALS
|
|
90
|
+
|| path.join(os.homedir(), '.claude', '.credentials.json'),
|
|
91
|
+
|
|
92
|
+
// Wall-clock ceiling for a single claude invocation (ms). 0 = no timeout.
|
|
93
|
+
runTimeoutMs: envInt('VIBE_RUN_TIMEOUT_MS', 10 * 60 * 1000),
|
|
94
|
+
|
|
95
|
+
// ---- Publish (staging → prod via the existing pipeline; HAP-1103) ----
|
|
96
|
+
|
|
97
|
+
// Master switch for the publish endpoints. The actual `git push` is also
|
|
98
|
+
// gated by an explicit per-call confirm; this is the operator-level kill switch.
|
|
99
|
+
publishEnabled: String(process.env.VIBE_PUBLISH_ENABLED ?? 'true').toLowerCase() !== 'false',
|
|
100
|
+
|
|
101
|
+
// Remote the publish push targets (push to its tracked branch → ssh-deploy).
|
|
102
|
+
publishRemote: process.env.VIBE_PUBLISH_REMOTE || 'origin',
|
|
103
|
+
|
|
104
|
+
// Deploy branch each workspace branch merges INTO on publish. ssh-deploy only
|
|
105
|
+
// triggers on a push to this branch (`.github/workflows/ssh-deploy.yml` →
|
|
106
|
+
// update.sh on prod), so a per-branch workspace must merge here to reach prod.
|
|
107
|
+
// When a workspace is already on this branch, publish pushes it directly.
|
|
108
|
+
publishBranch: process.env.VIBE_PUBLISH_BRANCH || 'main',
|
|
109
|
+
|
|
110
|
+
// Env-specific file that must NEVER be pushed. The staging overlay materializes
|
|
111
|
+
// the staging block into it; publish restores it (`git checkout`) before commit.
|
|
112
|
+
settingsPath: process.env.VIBE_SETTINGS_PATH || 'mango/config/settings.json',
|
|
113
|
+
|
|
114
|
+
// ---- Self-protection: path guard + rollback (HAP-1124) ----
|
|
115
|
+
|
|
116
|
+
// Server-side denylist enforcement. When true, every live run is launched with
|
|
117
|
+
// a PreToolUse hook (scripts/path-guard-hook.mjs) that blocks edits to the Vibe
|
|
118
|
+
// system itself (drawer, orchestrator, staging spine, settings, deploy/CI). This
|
|
119
|
+
// is enforced regardless of the prompt. Disable only for tests/debugging.
|
|
120
|
+
pathGuardEnabled: String(process.env.VIBE_PATH_GUARD ?? 'true').toLowerCase() !== 'false',
|
|
121
|
+
|
|
122
|
+
// Absolute path to the PreToolUse hook script. Lives in the orchestrator tree
|
|
123
|
+
// (itself protected), never inside the editable site clone.
|
|
124
|
+
pathGuardHook:
|
|
125
|
+
process.env.VIBE_PATH_GUARD_HOOK
|
|
126
|
+
|| path.resolve(__dirname, '..', 'scripts', 'path-guard-hook.mjs'),
|
|
127
|
+
|
|
128
|
+
// Auto-commit every applied vibe edit to the staging clone's git history and
|
|
129
|
+
// keep a known-good baseline tag, so there is always a way back (revert last /
|
|
130
|
+
// reset to baseline). Disable only for tests that drive a fake runner.
|
|
131
|
+
autoCommitEnabled: String(process.env.VIBE_AUTO_COMMIT ?? 'true').toLowerCase() !== 'false',
|
|
132
|
+
|
|
133
|
+
// Annotated tag marking the known-good baseline a "reset to baseline" returns to.
|
|
134
|
+
baselineTag: process.env.VIBE_BASELINE_TAG || 'vibe-baseline',
|
|
135
|
+
|
|
136
|
+
// ---- Owner auth (HAP-1109) ----
|
|
137
|
+
|
|
138
|
+
// HMAC secret shared ONLY between Mango (which mints owner tokens from an
|
|
139
|
+
// authenticated admin session) and this orchestrator (which verifies them).
|
|
140
|
+
// Lives in /root/.vibe-orchestrator.env on the droplet (chmod 600), never in
|
|
141
|
+
// git. UNSET ⇒ the orchestrator fails CLOSED: every gated endpoint returns 503
|
|
142
|
+
// until it is configured. This is the real per-owner gate that replaces the old
|
|
143
|
+
// optional shared secret.
|
|
144
|
+
tokenSecret: process.env.VIBE_ORCH_TOKEN_SECRET || '',
|
|
145
|
+
|
|
146
|
+
// Roles that count as a site owner (any match grants access).
|
|
147
|
+
ownerRoles: (process.env.VIBE_OWNER_ROLES || 'admin')
|
|
148
|
+
.split(',')
|
|
149
|
+
.map((s) => s.trim())
|
|
150
|
+
.filter(Boolean),
|
|
151
|
+
|
|
152
|
+
// ---- Cost tracking (HAP-1123) ----
|
|
153
|
+
|
|
154
|
+
// Client-facing markup on the RAW API cost, as a percentage (board request on
|
|
155
|
+
// HAP-1123). 100 ⇒ a 100% markup ⇒ the client is shown/billed 2× the raw cost.
|
|
156
|
+
// The raw CLI cost stays the persisted source of truth; the markup is applied
|
|
157
|
+
// only at display + rollup time so the rate can change without rewriting history.
|
|
158
|
+
markupPct: envInt('VIBE_COST_MARKUP_PCT', 100),
|
|
159
|
+
|
|
160
|
+
// Mango ingest endpoint that mirrors each per-turn cost record into the
|
|
161
|
+
// `vibeCosts` collection (queryable in the CMS for invoicing). UNSET ⇒ the
|
|
162
|
+
// mirror is a no-op and the orchestrator's local NDJSON log is the only store
|
|
163
|
+
// (the rollup endpoint reads from it either way). Authenticated with the shared
|
|
164
|
+
// VIBE_ORCH_TOKEN_SECRET as a bearer; intended to point at localhost Mango.
|
|
165
|
+
costMirrorUrl: process.env.VIBE_COST_MIRROR_URL || '',
|
|
166
|
+
|
|
167
|
+
// ---- CORS (HAP-1121) ----
|
|
168
|
+
|
|
169
|
+
// Browser origins allowed to call the orchestrator cross-origin. The ⌘K drawer
|
|
170
|
+
// is served from the SITE origin (e.g. https://staging-vibe.generations.org)
|
|
171
|
+
// and calls the orchestrator on the -api origin, so the browser issues a CORS
|
|
172
|
+
// preflight. Without an allow-list + preflight handling every call surfaces as
|
|
173
|
+
// "Failed to fetch". Empty list ⇒ reflect any origin (dev only). Prod + staging
|
|
174
|
+
// site origins are the defaults.
|
|
175
|
+
allowedOrigins: (
|
|
176
|
+
process.env.VIBE_ALLOWED_ORIGINS
|
|
177
|
+
|| 'https://staging-vibe.generations.org,https://vibe.generations.org,http://localhost:7121'
|
|
178
|
+
)
|
|
179
|
+
.split(',')
|
|
180
|
+
.map((s) => s.trim())
|
|
181
|
+
.filter(Boolean),
|
|
182
|
+
|
|
183
|
+
// ---- Idle session reaping (HAP-1110) ----
|
|
184
|
+
|
|
185
|
+
// How long a session may sit idle (no prompts, no keepalive touches, no open
|
|
186
|
+
// readers) before it is reaped so the next prompt starts a fresh conversation.
|
|
187
|
+
// A running run or an open SSE reader always keeps it warm. 0 = never reap.
|
|
188
|
+
sessionIdleMs: envInt('VIBE_SESSION_IDLE_MS', 30 * 60 * 1000),
|
|
189
|
+
|
|
190
|
+
// How often the reaper sweeps. Kept well below sessionIdleMs.
|
|
191
|
+
reaperIntervalMs: envInt('VIBE_REAPER_INTERVAL_MS', 60 * 1000),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Resolve + validate a site slug to an absolute, in-root working directory. */
|
|
195
|
+
function resolveSiteDir(site) {
|
|
196
|
+
if (!site || typeof site !== 'string' || !/^[a-zA-Z0-9._-]+$/.test(site)) {
|
|
197
|
+
throw new Error(`invalid site slug: ${JSON.stringify(site)}`)
|
|
198
|
+
}
|
|
199
|
+
if (config.allowedSites.length && !config.allowedSites.includes(site)) {
|
|
200
|
+
throw new Error(`site not in allowlist: ${site}`)
|
|
201
|
+
}
|
|
202
|
+
const dir = path.resolve(config.stagingRoot, site)
|
|
203
|
+
// Guard against traversal escaping the staging root.
|
|
204
|
+
if (dir !== path.join(config.stagingRoot, site)) {
|
|
205
|
+
throw new Error(`resolved path escapes staging root: ${site}`)
|
|
206
|
+
}
|
|
207
|
+
return dir
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** True when the claude CLI's auto-refreshing login credentials are present. */
|
|
211
|
+
function hasCliCredentials() {
|
|
212
|
+
try {
|
|
213
|
+
return fs.existsSync(config.claudeCredentialsPath)
|
|
214
|
+
} catch {
|
|
215
|
+
return false
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* True when a live claude run is possible: either an explicit OAuth token is
|
|
221
|
+
* configured, OR the claude CLI has its own login credentials to fall back on.
|
|
222
|
+
*/
|
|
223
|
+
function canRunLive() {
|
|
224
|
+
return !!config.oauthToken || hasCliCredentials()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export { config, resolveSiteDir, canRunLive, hasCliCredentials }
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// costMirror.js
|
|
2
|
+
//
|
|
3
|
+
// Best-effort mirror of a per-turn cost record into the Mango `vibeCosts`
|
|
4
|
+
// collection (HAP-1123). The orchestrator's NDJSON log (costStore) is the durable
|
|
5
|
+
// system of record; this mirror makes the same record queryable in the CMS so the
|
|
6
|
+
// board can invoice from it. A mango outage NEVER loses data — the local log
|
|
7
|
+
// already has every record and the orchestrator rollup endpoint reads from it.
|
|
8
|
+
//
|
|
9
|
+
// Transport: a plain POST to the Mango ingest endpoint on localhost, authenticated
|
|
10
|
+
// with the shared VIBE_ORCH_TOKEN_SECRET as a bearer. Disabled (no-op) unless
|
|
11
|
+
// VIBE_COST_MIRROR_URL is configured, so it is fail-safe by default.
|
|
12
|
+
|
|
13
|
+
import http from 'node:http'
|
|
14
|
+
import https from 'node:https'
|
|
15
|
+
import { config } from './config.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* POST one normalized cost record to the Mango ingest endpoint. Resolves to
|
|
19
|
+
* `{ ok }`; never rejects (callers treat it as fire-and-forget). No-op when the
|
|
20
|
+
* mirror URL or shared secret is unset.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} entry normalized cost record (from costStore)
|
|
23
|
+
* @param {object} [opts]
|
|
24
|
+
* @param {string} [opts.url] override the mirror URL (tests)
|
|
25
|
+
* @param {string} [opts.secret] override the bearer secret (tests)
|
|
26
|
+
* @param {function} [opts.requestFn] injectable transport (tests)
|
|
27
|
+
*/
|
|
28
|
+
function mirrorCost(entry, opts = {}) {
|
|
29
|
+
const url = opts.url || config.costMirrorUrl
|
|
30
|
+
const secret = opts.secret || config.tokenSecret
|
|
31
|
+
if (!url || !secret || !entry) return Promise.resolve({ ok: false, skipped: true })
|
|
32
|
+
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
let target
|
|
35
|
+
try { target = new URL(url) } catch { return resolve({ ok: false, error: 'bad mirror url' }) }
|
|
36
|
+
const lib = target.protocol === 'https:' ? https : http
|
|
37
|
+
const requestFn = opts.requestFn || lib.request
|
|
38
|
+
const body = JSON.stringify(entry)
|
|
39
|
+
const req = requestFn(
|
|
40
|
+
{
|
|
41
|
+
hostname: target.hostname,
|
|
42
|
+
port: target.port,
|
|
43
|
+
path: target.pathname + target.search,
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
'content-type': 'application/json',
|
|
47
|
+
'content-length': Buffer.byteLength(body),
|
|
48
|
+
authorization: `Bearer ${secret}`,
|
|
49
|
+
},
|
|
50
|
+
timeout: 5000,
|
|
51
|
+
},
|
|
52
|
+
(res) => {
|
|
53
|
+
res.resume() // drain
|
|
54
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode })
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
req.on('error', () => resolve({ ok: false }))
|
|
58
|
+
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'timeout' }) })
|
|
59
|
+
req.write(body)
|
|
60
|
+
req.end()
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { mirrorCost }
|