mango-cms 0.3.34 → 0.3.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/cli.js +57 -0
  2. package/default/infra/vibe/README.md +43 -0
  3. package/default/infra/vibe/cloudflare.ini.template +26 -0
  4. package/default/infra/vibe/ecosystem.vibe.config.cjs +44 -0
  5. package/default/infra/vibe/nginx-vibe-orchestrator.conf.template +50 -0
  6. package/default/infra/vibe/nginx-vibe-staging.conf.template +73 -0
  7. package/default/infra/vibe/vibe-gateway.service +38 -0
  8. package/default/infra/vibe/vibe-orchestrator.service +44 -0
  9. package/default/infra/vibe/vibe.env.template +24 -0
  10. package/default/mango/config/settings.json +40 -1
  11. package/default/vite.config.js +46 -0
  12. package/lib/vibe-orchestrator/README.md +76 -0
  13. package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
  14. package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
  15. package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
  16. package/lib/vibe-orchestrator/server.js +344 -0
  17. package/lib/vibe-orchestrator/src/attachments.js +98 -0
  18. package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
  19. package/lib/vibe-orchestrator/src/config.js +227 -0
  20. package/lib/vibe-orchestrator/src/costMirror.js +64 -0
  21. package/lib/vibe-orchestrator/src/costStore.js +209 -0
  22. package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
  23. package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
  24. package/lib/vibe-orchestrator/src/preamble.js +139 -0
  25. package/lib/vibe-orchestrator/src/publisher.js +376 -0
  26. package/lib/vibe-orchestrator/src/recovery.js +199 -0
  27. package/lib/vibe-orchestrator/src/screenshot.js +38 -0
  28. package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
  29. package/lib/vibe-orchestrator/src/streamParser.js +188 -0
  30. package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
  31. package/package.json +1 -1
  32. package/readme.md +6 -0
@@ -0,0 +1,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 }