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
package/lib/devProxyGateway.js
CHANGED
|
@@ -391,6 +391,107 @@ export function createHttpResolver({
|
|
|
391
391
|
}
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Build a single-shot TCP readiness probe from node's `net` module (HAP-1215).
|
|
396
|
+
*
|
|
397
|
+
* Returns `probe(port) -> Promise<boolean>` that resolves `true` iff a TCP
|
|
398
|
+
* connection to 127.0.0.1:<port> succeeds within `connectTimeoutMs`, else
|
|
399
|
+
* `false` (never rejects). A per-branch `mango dev` binds its port only once
|
|
400
|
+
* Vite has finished booting, so "can I open a socket" is the cheapest accurate
|
|
401
|
+
* signal that the workspace is ready to be proxied to. `net` is injected so the
|
|
402
|
+
* module stays import-free and unit-testable with a plain fake.
|
|
403
|
+
*/
|
|
404
|
+
export function createTcpReadinessProbe(net, { connectTimeoutMs = 1000 } = {}) {
|
|
405
|
+
if (!net || typeof net.connect !== 'function') {
|
|
406
|
+
throw new Error('createTcpReadinessProbe: net module with connect() is required')
|
|
407
|
+
}
|
|
408
|
+
return function probe(port) {
|
|
409
|
+
return new Promise((resolve) => {
|
|
410
|
+
let done = false
|
|
411
|
+
const finish = (ready, sock) => {
|
|
412
|
+
if (done) return
|
|
413
|
+
done = true
|
|
414
|
+
try { sock && sock.destroy() } catch { /* noop */ }
|
|
415
|
+
resolve(ready)
|
|
416
|
+
}
|
|
417
|
+
let sock
|
|
418
|
+
try {
|
|
419
|
+
sock = net.connect({ host: '127.0.0.1', port })
|
|
420
|
+
} catch {
|
|
421
|
+
return resolve(false)
|
|
422
|
+
}
|
|
423
|
+
sock.setTimeout(connectTimeoutMs, () => finish(false, sock))
|
|
424
|
+
sock.once('connect', () => finish(true, sock))
|
|
425
|
+
sock.once('error', () => finish(false, sock))
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Branded "your branch preview is starting" interstitial (HAP-1215).
|
|
432
|
+
*
|
|
433
|
+
* Served (HTTP 503 + Retry-After) when a request resolved to a real per-branch
|
|
434
|
+
* workspace but that workspace's dev server has not bound its port within the
|
|
435
|
+
* bounded readiness window — a genuinely cold `mango dev` start on the 1vcpu
|
|
436
|
+
* droplet. This REPLACES the raw "Bad gateway: ECONNREFUSED" 502 the proxy would
|
|
437
|
+
* otherwise emit, so the owner sees a clear, self-refreshing status instead of an
|
|
438
|
+
* unbounded white/coffee screen. The page auto-reloads, and each reload re-probes
|
|
439
|
+
* with the same bounded budget — so the user converges to the live preview the
|
|
440
|
+
* instant the server is up, or keeps seeing a clear, actionable error if it never
|
|
441
|
+
* comes up. Never an indefinite hang.
|
|
442
|
+
*/
|
|
443
|
+
export function renderBootingPage({ branch = '', retryAfterSeconds = 5 } = {}) {
|
|
444
|
+
const safeBranch = String(branch).replace(/[<>&"]/g, (c) => (
|
|
445
|
+
{ '<': '<', '>': '>', '&': '&', '"': '"' }[c]
|
|
446
|
+
))
|
|
447
|
+
const branchLine = safeBranch
|
|
448
|
+
? `<p class="branch">branch <code>${safeBranch}</code></p>`
|
|
449
|
+
: ''
|
|
450
|
+
return `<!doctype html>
|
|
451
|
+
<html lang="en">
|
|
452
|
+
<head>
|
|
453
|
+
<meta charset="utf-8">
|
|
454
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
455
|
+
<meta http-equiv="refresh" content="${retryAfterSeconds}">
|
|
456
|
+
<title>Starting your preview…</title>
|
|
457
|
+
<style>
|
|
458
|
+
:root { color-scheme: light dark; }
|
|
459
|
+
html, body { height: 100%; margin: 0; }
|
|
460
|
+
body {
|
|
461
|
+
display: flex; align-items: center; justify-content: center;
|
|
462
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
463
|
+
background: #0f1115; color: #e8eaed;
|
|
464
|
+
}
|
|
465
|
+
.card { max-width: 28rem; padding: 2.5rem 2rem; text-align: center; }
|
|
466
|
+
.spinner {
|
|
467
|
+
width: 38px; height: 38px; margin: 0 auto 1.5rem;
|
|
468
|
+
border: 3px solid rgba(255,255,255,0.18); border-top-color: #7aa2f7;
|
|
469
|
+
border-radius: 50%; animation: spin 0.9s linear infinite;
|
|
470
|
+
}
|
|
471
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
472
|
+
h1 { font-size: 1.25rem; font-weight: 600; margin: 0 0 0.5rem; }
|
|
473
|
+
p { margin: 0.35rem 0; color: #9aa0ac; font-size: 0.95rem; line-height: 1.5; }
|
|
474
|
+
.branch code { color: #c0caf5; background: rgba(122,162,247,0.12); padding: 0.1rem 0.4rem; border-radius: 4px; }
|
|
475
|
+
button {
|
|
476
|
+
margin-top: 1.5rem; padding: 0.6rem 1.4rem; font-size: 0.95rem; cursor: pointer;
|
|
477
|
+
color: #0f1115; background: #7aa2f7; border: 0; border-radius: 8px; font-weight: 600;
|
|
478
|
+
}
|
|
479
|
+
button:hover { background: #93b4ff; }
|
|
480
|
+
</style>
|
|
481
|
+
</head>
|
|
482
|
+
<body>
|
|
483
|
+
<main class="card">
|
|
484
|
+
<div class="spinner" role="status" aria-label="Loading"></div>
|
|
485
|
+
<h1>Your preview is starting…</h1>
|
|
486
|
+
<p>The branch server is booting. This usually takes a few seconds on first load.</p>
|
|
487
|
+
${branchLine}
|
|
488
|
+
<p>This page refreshes automatically. If it doesn't load shortly, the branch may have failed to start — retry below.</p>
|
|
489
|
+
<button type="button" onclick="location.reload()">Retry now</button>
|
|
490
|
+
</main>
|
|
491
|
+
</body>
|
|
492
|
+
</html>`
|
|
493
|
+
}
|
|
494
|
+
|
|
394
495
|
/**
|
|
395
496
|
* Standalone staging-gateway server factory (HAP-1165).
|
|
396
497
|
*
|
|
@@ -409,6 +510,9 @@ export function createHttpResolver({
|
|
|
409
510
|
* - Non-staging host → 404 with a clear message (no misroute).
|
|
410
511
|
* - Resolver errors (403/404/502) → surfaced as the resolver's HTTP status.
|
|
411
512
|
* - Upgrade-time resolver failure → socket destroyed (Vite retries the WS).
|
|
513
|
+
* - Cold workspace not yet listening (HAP-1215) → bounded readiness poll, then a
|
|
514
|
+
* branded self-refreshing "starting" page (503) instead of a raw ECONNREFUSED
|
|
515
|
+
* 502. Opt-in via `readinessProbe` (off → proxy immediately, legacy behavior).
|
|
412
516
|
*
|
|
413
517
|
* Page-load auth (HAP-1175, supersedes HAP-1173): a top-level browser navigation
|
|
414
518
|
* MUST never 401, regardless of what cookies it carries. Real browsers attach
|
|
@@ -435,6 +539,13 @@ export function createStagingGatewayServer({
|
|
|
435
539
|
timeoutMs,
|
|
436
540
|
fallbackPort,
|
|
437
541
|
onError,
|
|
542
|
+
// HAP-1215 cold-boot bounded-readiness (all optional — gating is OFF unless a
|
|
543
|
+
// probe is supplied, so existing callers/tests are unaffected):
|
|
544
|
+
readinessProbe, // (port) => Promise<boolean readyNow>; e.g. createTcpReadinessProbe(net)
|
|
545
|
+
readinessTimeoutMs = 25000, // total bounded wait for a cold workspace to bind its port
|
|
546
|
+
readinessIntervalMs = 500, // delay between probe attempts
|
|
547
|
+
renderBootPage = renderBootingPage, // injectable for tests
|
|
548
|
+
setTimeoutFn = setTimeout, // injectable for deterministic tests
|
|
438
549
|
} = {}) {
|
|
439
550
|
if (!http || typeof http.createServer !== 'function') {
|
|
440
551
|
throw new Error('createStagingGatewayServer: http module with createServer() is required')
|
|
@@ -448,6 +559,9 @@ export function createStagingGatewayServer({
|
|
|
448
559
|
if (fallbackPort != null && (!Number.isInteger(fallbackPort) || fallbackPort <= 0 || fallbackPort > 65535)) {
|
|
449
560
|
throw new Error('createStagingGatewayServer: fallbackPort, if set, must be an integer in 1..65535')
|
|
450
561
|
}
|
|
562
|
+
if (readinessProbe != null && typeof readinessProbe !== 'function') {
|
|
563
|
+
throw new Error('createStagingGatewayServer: readinessProbe, if set, must be a function')
|
|
564
|
+
}
|
|
451
565
|
|
|
452
566
|
const resolve = createHttpResolver({ backendPort, resolverPath, timeoutMs, http })
|
|
453
567
|
const proxy = httpProxy.createProxyServer({ ws: true, xfwd: true })
|
|
@@ -457,6 +571,54 @@ export function createStagingGatewayServer({
|
|
|
457
571
|
if (typeof onError === 'function') onError(where, err, req)
|
|
458
572
|
}
|
|
459
573
|
|
|
574
|
+
// HAP-1215: a genuinely cold per-branch `mango dev` can take ~10–60s to bind
|
|
575
|
+
// its port on the 1vcpu droplet. Without this, the first request after resolve
|
|
576
|
+
// hits a not-yet-listening port and the proxy emits a raw ECONNREFUSED 502 (an
|
|
577
|
+
// unbounded "coffee screen" as the browser/owner keep retrying into 502s). This
|
|
578
|
+
// polls the resolved port for a bounded window; the caller serves the live
|
|
579
|
+
// proxy the instant it's up, or a clear branded error if the budget elapses.
|
|
580
|
+
// Returns true if the port became connectable within the window, false on timeout.
|
|
581
|
+
async function waitForReady(port) {
|
|
582
|
+
if (typeof readinessProbe !== 'function') return true // gating disabled
|
|
583
|
+
const maxAttempts = Math.max(1, Math.ceil(readinessTimeoutMs / Math.max(1, readinessIntervalMs)))
|
|
584
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
585
|
+
let ready = false
|
|
586
|
+
try {
|
|
587
|
+
ready = await readinessProbe(port)
|
|
588
|
+
} catch {
|
|
589
|
+
ready = false
|
|
590
|
+
}
|
|
591
|
+
if (ready) return true
|
|
592
|
+
if (attempt < maxAttempts - 1) {
|
|
593
|
+
await new Promise((r) => setTimeoutFn(r, readinessIntervalMs))
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return false
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function sendBootingPage(req, res, branch) {
|
|
600
|
+
if (!res || res.headersSent) return
|
|
601
|
+
const wantsHtml = /\btext\/html\b/.test(String(req?.headers?.accept || ''))
|
|
602
|
+
const retryAfter = Math.max(1, Math.round(readinessIntervalMs / 1000) || 3)
|
|
603
|
+
if (wantsHtml) {
|
|
604
|
+
const html = renderBootPage({ branch, retryAfterSeconds: retryAfter })
|
|
605
|
+
res.writeHead(503, {
|
|
606
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
607
|
+
'Retry-After': String(retryAfter),
|
|
608
|
+
'Cache-Control': 'no-store',
|
|
609
|
+
})
|
|
610
|
+
res.end(html)
|
|
611
|
+
} else {
|
|
612
|
+
// Non-navigation (asset/XHR): a short, bounded 503 the client can retry.
|
|
613
|
+
res.writeHead(503, {
|
|
614
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
615
|
+
'Retry-After': String(retryAfter),
|
|
616
|
+
'Cache-Control': 'no-store',
|
|
617
|
+
})
|
|
618
|
+
res.end('Vibe staging: branch preview is starting — retry shortly')
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
460
622
|
proxy.on('error', (err, req, res) => {
|
|
461
623
|
log('proxy', err, req)
|
|
462
624
|
if (res && typeof res.writeHead === 'function' && !res.headersSent) {
|
|
@@ -513,6 +675,17 @@ export function createStagingGatewayServer({
|
|
|
513
675
|
sendError(res, outcome.status || 502, outcome.message || outcome.error)
|
|
514
676
|
return
|
|
515
677
|
}
|
|
678
|
+
// HAP-1215: the resolve succeeded, but a cold per-branch dev server may not
|
|
679
|
+
// have bound its port yet. Briefly poll (bounded) before proxying; on
|
|
680
|
+
// timeout serve a branded self-refreshing "starting" page instead of the
|
|
681
|
+
// raw ECONNREFUSED 502 the proxy would otherwise emit. Gating is a no-op
|
|
682
|
+
// (returns true immediately) when no readinessProbe is configured.
|
|
683
|
+
const ready = await waitForReady(outcome.port)
|
|
684
|
+
if (!ready) {
|
|
685
|
+
log('readiness', new Error(`workspace port ${outcome.port} not ready within ${readinessTimeoutMs}ms`), req)
|
|
686
|
+
sendBootingPage(req, res, hostBranch || parseBranchFromReq(req))
|
|
687
|
+
return
|
|
688
|
+
}
|
|
516
689
|
proxy.web(req, res, { target: outcome.target })
|
|
517
690
|
}
|
|
518
691
|
|
package/lib/staging-gateway.js
CHANGED
|
@@ -25,6 +25,13 @@
|
|
|
25
25
|
* request whose resolution fails. The
|
|
26
26
|
* shared front (HAP-1175). Set to 0 to
|
|
27
27
|
* disable and 401 those requests instead.
|
|
28
|
+
* READINESS_TIMEOUT_MS (default 25000, int) HAP-1215: after a successful
|
|
29
|
+
* per-branch resolve, poll the workspace
|
|
30
|
+
* port for up to this long before proxying,
|
|
31
|
+
* so a cold `mango dev` boot shows a branded
|
|
32
|
+
* "starting" page instead of a raw 502.
|
|
33
|
+
* Set to 0 to disable readiness gating.
|
|
34
|
+
* READINESS_INTERVAL_MS (default 500, int) Delay between readiness probes.
|
|
28
35
|
*
|
|
29
36
|
* Usage (typical droplet pm2):
|
|
30
37
|
* BACKEND_PORT=7122 LISTEN_PORT=7123 \
|
|
@@ -33,8 +40,9 @@
|
|
|
33
40
|
*/
|
|
34
41
|
|
|
35
42
|
import http from 'http'
|
|
43
|
+
import net from 'net'
|
|
36
44
|
import httpProxy from 'http-proxy'
|
|
37
|
-
import { createStagingGatewayServer } from './devProxyGateway.js'
|
|
45
|
+
import { createStagingGatewayServer, createTcpReadinessProbe } from './devProxyGateway.js'
|
|
38
46
|
|
|
39
47
|
function intEnv(name, fallback) {
|
|
40
48
|
const raw = process.env[name]
|
|
@@ -73,6 +81,11 @@ const STAGING_HOST_PREFIX = process.env.STAGING_HOST_PREFIX || undefined
|
|
|
73
81
|
const RESOLVER_PATH = process.env.RESOLVER_PATH || undefined
|
|
74
82
|
const RESOLVER_TIMEOUT_MS = intEnv('RESOLVER_TIMEOUT_MS', 2000)
|
|
75
83
|
const FALLBACK_PORT = fallbackPortEnv()
|
|
84
|
+
// HAP-1215: READINESS_TIMEOUT_MS=0 (or empty) disables cold-boot readiness gating.
|
|
85
|
+
const READINESS_TIMEOUT_MS = (process.env.READINESS_TIMEOUT_MS === '0' || process.env.READINESS_TIMEOUT_MS === '')
|
|
86
|
+
? 0
|
|
87
|
+
: intEnv('READINESS_TIMEOUT_MS', 25000)
|
|
88
|
+
const READINESS_INTERVAL_MS = intEnv('READINESS_INTERVAL_MS', 500)
|
|
76
89
|
|
|
77
90
|
const { server, close } = createStagingGatewayServer({
|
|
78
91
|
http,
|
|
@@ -83,6 +96,10 @@ const { server, close } = createStagingGatewayServer({
|
|
|
83
96
|
resolverPath: RESOLVER_PATH,
|
|
84
97
|
timeoutMs: RESOLVER_TIMEOUT_MS,
|
|
85
98
|
fallbackPort: FALLBACK_PORT,
|
|
99
|
+
// HAP-1215: only enable readiness gating when the window is > 0.
|
|
100
|
+
readinessProbe: READINESS_TIMEOUT_MS > 0 ? createTcpReadinessProbe(net) : undefined,
|
|
101
|
+
readinessTimeoutMs: READINESS_TIMEOUT_MS,
|
|
102
|
+
readinessIntervalMs: READINESS_INTERVAL_MS,
|
|
86
103
|
onError: (where, err) => {
|
|
87
104
|
console.error(`[staging-gateway] ${where} error: ${err?.message || err}`)
|
|
88
105
|
},
|
|
@@ -97,6 +114,11 @@ server.listen(LISTEN_PORT, LISTEN_HOST, () => {
|
|
|
97
114
|
} else {
|
|
98
115
|
console.log('[staging-gateway] fallback disabled (no branch signal → 401, failed resolve → resolver status)')
|
|
99
116
|
}
|
|
117
|
+
if (READINESS_TIMEOUT_MS > 0) {
|
|
118
|
+
console.log(`[staging-gateway] cold-boot readiness: poll workspace port ≤${READINESS_TIMEOUT_MS}ms (every ${READINESS_INTERVAL_MS}ms) → branded "starting" page on timeout`)
|
|
119
|
+
} else {
|
|
120
|
+
console.log('[staging-gateway] cold-boot readiness gating disabled (proxy immediately; cold boots may 502)')
|
|
121
|
+
}
|
|
100
122
|
})
|
|
101
123
|
|
|
102
124
|
function shutdown(signal) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Vibe Orchestrator (bundled with Mango · HAP-1253)
|
|
2
|
+
|
|
3
|
+
The **vibe-orchestrator** ships inside the `mango-cms` package so a Mango project
|
|
4
|
+
can run it **straight from the installed dependency — no copied source**. It turns
|
|
5
|
+
a ⌘K chat prompt into live edits on a branch worktree by driving **Claude Code
|
|
6
|
+
headless** (no Anthropic API key), streams normalized progress back to the drawer
|
|
7
|
+
over SSE, and verifies the owner token that Mango mints.
|
|
8
|
+
|
|
9
|
+
It is dependency-free (`node:http` only), so it runs in-process from the Mango CLI.
|
|
10
|
+
|
|
11
|
+
## Running it
|
|
12
|
+
|
|
13
|
+
From a Mango project root (preferred — picks up `siteName` from `mango/config/settings.json`):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
mango vibe-orchestrator # listens on 127.0.0.1:7130
|
|
17
|
+
mango vibe-orchestrator --port 7130 # explicit port
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or directly from the installed package (e.g. under a process manager / nginx vhost):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
node node_modules/mango-cms/lib/vibe-orchestrator/server.js
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The orchestrator listens on **loopback only**; front it with the staging/site
|
|
27
|
+
nginx vhost (or the Mango staging gateway) for browser access.
|
|
28
|
+
|
|
29
|
+
## Environment contract
|
|
30
|
+
|
|
31
|
+
| Var | Required | Default | Purpose |
|
|
32
|
+
| --- | --- | --- | --- |
|
|
33
|
+
| `VIBE_ORCH_TOKEN_SECRET` | **yes** | – | HMAC secret **shared with Mango**, which mints per-owner tokens from an authenticated admin session. **Unset ⇒ fails closed**: every gated endpoint returns `503`. |
|
|
34
|
+
| `VIBE_STAGING_ROOT` | recommended | `/root/Staging` | Root dir under which each `<site>` is a clone the orchestrator edits. |
|
|
35
|
+
| `VIBE_ALLOWED_SITES` | recommended | project `siteName` (via `mango vibe-orchestrator`), else `generations-vibe` | Comma-separated allow-list of site slugs. |
|
|
36
|
+
| `CLAUDE_CODE_OAUTH_TOKEN` | one of these | – | Claude OAuth token. If unset, the orchestrator falls back to the Claude CLI's own login at… |
|
|
37
|
+
| `VIBE_CLAUDE_CREDENTIALS` | – | `~/.claude/.credentials.json` | …this auto-refreshing credentials file. One of the two must be present for **live** runs (`/health` reports `canRunLive`). |
|
|
38
|
+
| `VIBE_CLAUDE_BIN` | – | `claude` | Path to the `claude` CLI binary. Point at `scripts/fake-claude.mjs` for a token-free pipeline smoke test. |
|
|
39
|
+
| `VIBE_ORCH_PORT` | – | `7130` | Loopback listen port (`--port` overrides). |
|
|
40
|
+
| `VIBE_MODEL` | – | CLI default | Optional model override (e.g. `claude-opus-4-8`). |
|
|
41
|
+
| `VIBE_TOKEN_CAP` | – | `2000000` | Per-session cumulative token safety cap (`0` = unlimited). |
|
|
42
|
+
| `VIBE_PERMISSION_MODE` | – | `acceptEdits` | Headless permission mode (deliberately **not** `bypassPermissions`). |
|
|
43
|
+
| `VIBE_ALLOWED_ORIGINS` | – | staging/prod site + `localhost:7121` | Browser origins allowed to call cross-origin (CORS). |
|
|
44
|
+
| `VIBE_PUBLISH_*`, `VIBE_PATH_GUARD*`, `VIBE_AUTO_COMMIT`, `VIBE_COST_*`, `VIBE_SESSION_IDLE_MS` | – | see `src/config.js` | Publish, self-protection, cost-mirror and reaper tuning. |
|
|
45
|
+
|
|
46
|
+
The owner token is HMAC-verified against `VIBE_ORCH_TOKEN_SECRET` — Mango mints it
|
|
47
|
+
(`POST /endpoints/vibe/token`) and the orchestrator verifies it (`src/ownerToken.js`).
|
|
48
|
+
Client-side role flags are never trusted; the Claude credentials are never exposed
|
|
49
|
+
by any endpoint.
|
|
50
|
+
|
|
51
|
+
## Endpoints
|
|
52
|
+
|
|
53
|
+
`GET /health` (public liveness, leaks no config) · `GET /health/details` ·
|
|
54
|
+
`GET /sessions` · `POST /prompt` (SSE) · `POST /abort` · `POST /reset` ·
|
|
55
|
+
`GET /publish/diff` · `POST /publish` · `GET /costs` · `GET /costs/rollup` ·
|
|
56
|
+
`GET /recovery/status` · `POST /recovery/revert-last` · `POST /recovery/reset-baseline`.
|
|
57
|
+
|
|
58
|
+
Every route except `GET /health` requires `Authorization: Bearer <owner-token>`.
|
|
59
|
+
|
|
60
|
+
## Smoke test (no Claude token)
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
VIBE_ORCH_TOKEN_SECRET=dev-secret \
|
|
64
|
+
VIBE_STAGING_ROOT=/path/to/clones VIBE_ALLOWED_SITES=mysite \
|
|
65
|
+
VIBE_CLAUDE_BIN="$PWD/node_modules/mango-cms/lib/vibe-orchestrator/scripts/fake-claude.mjs" \
|
|
66
|
+
CLAUDE_CODE_OAUTH_TOKEN=dummy \
|
|
67
|
+
mango vibe-orchestrator --port 7130
|
|
68
|
+
# then POST /prompt with a minted bearer token and watch accepted → progress* → end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Source of truth
|
|
72
|
+
|
|
73
|
+
The runtime here (`server.js`, `src/`, `scripts/`) is the distributed copy. The
|
|
74
|
+
canonical development tree (with the full test suite) lives in the
|
|
75
|
+
`generations-vibe` repo under `orchestrator/`; this bundle is kept byte-identical
|
|
76
|
+
on release. See `docs/RELEASING.md`.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// fake-claude.mjs
|
|
3
|
+
//
|
|
4
|
+
// A stand-in for the real `claude` binary that emits a realistic stream-json
|
|
5
|
+
// sequence. Use it to validate the orchestrator's spawn → parse → SSE pipeline
|
|
6
|
+
// end-to-end WITHOUT a Claude OAuth token (so the live-token dependency does not
|
|
7
|
+
// block integration testing):
|
|
8
|
+
//
|
|
9
|
+
// VIBE_CLAUDE_BIN="$PWD/scripts/fake-claude.mjs" \
|
|
10
|
+
// CLAUDE_CODE_OAUTH_TOKEN=dummy node server.js
|
|
11
|
+
//
|
|
12
|
+
// then POST /prompt and watch the SSE events stream back.
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2)
|
|
15
|
+
const pIdx = args.indexOf('-p')
|
|
16
|
+
const prompt = pIdx >= 0 ? args[pIdx + 1] : '(no prompt)'
|
|
17
|
+
const resumeIdx = args.indexOf('--resume')
|
|
18
|
+
const sessionId = resumeIdx >= 0 ? args[resumeIdx + 1] : 'fake-sess-0001'
|
|
19
|
+
|
|
20
|
+
const lines = [
|
|
21
|
+
{ type: 'system', subtype: 'init', session_id: sessionId, model: 'fake-model', tools: ['Read', 'Edit', 'Bash'], cwd: process.cwd() },
|
|
22
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: `Working on: ${prompt}` }], usage: { input_tokens: 1200, output_tokens: 40 } } },
|
|
23
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tu1', name: 'Edit', input: { file_path: 'src/App.vue' } }], usage: { input_tokens: 1300, output_tokens: 90 } } },
|
|
24
|
+
{ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tu1', is_error: false, content: 'edited' }] } },
|
|
25
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Done. The hero heading is now blue.' }], usage: { input_tokens: 1400, output_tokens: 130 } } },
|
|
26
|
+
{ type: 'result', subtype: 'success', session_id: sessionId, total_cost_usd: 0.018, duration_ms: 5200, num_turns: 3, usage: { input_tokens: 1400, output_tokens: 130 }, result: 'Done. The hero heading is now blue.' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
let i = 0
|
|
30
|
+
const tick = () => {
|
|
31
|
+
if (i >= lines.length) return process.exit(0)
|
|
32
|
+
process.stdout.write(JSON.stringify(lines[i++]) + '\n')
|
|
33
|
+
setTimeout(tick, 250)
|
|
34
|
+
}
|
|
35
|
+
tick()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// path-guard-hook.mjs
|
|
3
|
+
//
|
|
4
|
+
// PreToolUse hook (HAP-1124, protection #1). Wired into every headless claude
|
|
5
|
+
// run via `--settings` (see claudeRunner.js) so it is enforced SERVER-SIDE,
|
|
6
|
+
// independent of the prompt. Claude invokes this before each Edit/Write/
|
|
7
|
+
// MultiEdit/NotebookEdit with a JSON payload on stdin:
|
|
8
|
+
//
|
|
9
|
+
// { cwd, tool_name, tool_input: { file_path | notebook_path, ... } }
|
|
10
|
+
//
|
|
11
|
+
// We resolve the target to a repo-root-relative path and, if it hits the
|
|
12
|
+
// protected denylist (or escapes the staging clone), block the tool by exiting
|
|
13
|
+
// with code 2 — the documented PreToolUse "deny" signal: the tool call is
|
|
14
|
+
// stopped and stderr is fed back to Claude as the reason, which it relays to the
|
|
15
|
+
// owner in the drawer. Anything else exits 0 (allow). The hook is fail-safe:
|
|
16
|
+
// any internal error allows the edit rather than wedging the agent, because the
|
|
17
|
+
// post-run git auto-commit + rollback (protection #2) is the backstop.
|
|
18
|
+
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { isProtectedPath, PROTECTED_MESSAGE } from '../src/pathGuard.js'
|
|
21
|
+
|
|
22
|
+
function readStdin() {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
let raw = ''
|
|
25
|
+
process.stdin.setEncoding('utf8')
|
|
26
|
+
process.stdin.on('data', (c) => { raw += c })
|
|
27
|
+
process.stdin.on('end', () => resolve(raw))
|
|
28
|
+
process.stdin.on('error', () => resolve(raw))
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Collect every filesystem path a tool_input may target. */
|
|
33
|
+
function targetPaths(toolInput) {
|
|
34
|
+
if (!toolInput || typeof toolInput !== 'object') return []
|
|
35
|
+
const out = []
|
|
36
|
+
for (const key of ['file_path', 'notebook_path', 'path']) {
|
|
37
|
+
if (typeof toolInput[key] === 'string' && toolInput[key]) out.push(toolInput[key])
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Map an absolute-or-relative tool path to a clone-root-relative POSIX path. */
|
|
43
|
+
function toRel(cwd, p) {
|
|
44
|
+
const abs = path.isAbsolute(p) ? p : path.resolve(cwd || process.cwd(), p)
|
|
45
|
+
return path.relative(cwd || process.cwd(), abs)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function main() {
|
|
49
|
+
let payload = {}
|
|
50
|
+
try { payload = JSON.parse((await readStdin()) || '{}') } catch { payload = {} }
|
|
51
|
+
|
|
52
|
+
const cwd = payload.cwd || process.cwd()
|
|
53
|
+
const toolInput = payload.tool_input || payload.toolInput || {}
|
|
54
|
+
|
|
55
|
+
for (const raw of targetPaths(toolInput)) {
|
|
56
|
+
const rel = toRel(cwd, raw)
|
|
57
|
+
// An edit that resolves outside the staging clone is never legitimate.
|
|
58
|
+
const escapes = rel.startsWith('..') || path.isAbsolute(rel)
|
|
59
|
+
if (escapes || isProtectedPath(rel)) {
|
|
60
|
+
process.stderr.write(
|
|
61
|
+
`${PROTECTED_MESSAGE} (${escapes ? 'outside the staging clone' : rel})`,
|
|
62
|
+
)
|
|
63
|
+
process.exit(2) // PreToolUse: block the tool, reason → Claude
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
process.exit(0) // allow
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
main().catch(() => process.exit(0)) // fail-open: rollback is the backstop
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# vibe-recover.sh — one-command rollback for the Vibe staging clone (HAP-1124).
|
|
3
|
+
#
|
|
4
|
+
# A recovery path usable WITHOUT an engineer. Operates directly on the staging
|
|
5
|
+
# clone's git, so it works even if the orchestrator process is down.
|
|
6
|
+
#
|
|
7
|
+
# vibe-recover.sh status # show baseline + recent vibe edits
|
|
8
|
+
# vibe-recover.sh revert-last # undo the most recent vibe edit (git revert)
|
|
9
|
+
# vibe-recover.sh reset-baseline # hard-reset to the known-good baseline
|
|
10
|
+
#
|
|
11
|
+
# Env (override as needed):
|
|
12
|
+
# VIBE_STAGING_ROOT (default /root/Staging)
|
|
13
|
+
# VIBE_SITE (default generations-vibe)
|
|
14
|
+
# VIBE_BASELINE_TAG (default vibe-baseline)
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
ROOT="${VIBE_STAGING_ROOT:-/root/Staging}"
|
|
18
|
+
SITE="${VIBE_SITE:-generations-vibe}"
|
|
19
|
+
TAG="${VIBE_BASELINE_TAG:-vibe-baseline}"
|
|
20
|
+
SETTINGS="mango/config/settings.json"
|
|
21
|
+
DIR="$ROOT/$SITE"
|
|
22
|
+
CMD="${1:-status}"
|
|
23
|
+
|
|
24
|
+
cd "$DIR" || { echo "staging clone not found: $DIR" >&2; exit 1; }
|
|
25
|
+
|
|
26
|
+
case "$CMD" in
|
|
27
|
+
status)
|
|
28
|
+
echo "site: $SITE ($DIR)"
|
|
29
|
+
echo "HEAD: $(git rev-parse --short HEAD 2>/dev/null || echo '(none)')"
|
|
30
|
+
if git rev-parse --verify --quiet "${TAG}^{commit}" >/dev/null; then
|
|
31
|
+
echo "baseline: $TAG -> $(git rev-parse --short "$TAG")"
|
|
32
|
+
else
|
|
33
|
+
echo "baseline: (none yet — created on first vibe edit)"
|
|
34
|
+
fi
|
|
35
|
+
echo "recent vibe edits:"
|
|
36
|
+
git log -n 10 --pretty=format:' %h %cs %s' | grep -E ' vibe:' || echo " (none)"
|
|
37
|
+
echo
|
|
38
|
+
;;
|
|
39
|
+
|
|
40
|
+
revert-last)
|
|
41
|
+
last_msg="$(git log -1 --pretty=format:'%s')"
|
|
42
|
+
case "$last_msg" in
|
|
43
|
+
vibe:*) ;;
|
|
44
|
+
*) echo "the most recent commit is not a vibe edit — nothing to revert" >&2; exit 1 ;;
|
|
45
|
+
esac
|
|
46
|
+
git revert --no-edit HEAD
|
|
47
|
+
echo "reverted last vibe edit; HEAD is now $(git rev-parse --short HEAD)"
|
|
48
|
+
;;
|
|
49
|
+
|
|
50
|
+
reset-baseline)
|
|
51
|
+
if ! git rev-parse --verify --quiet "${TAG}^{commit}" >/dev/null; then
|
|
52
|
+
echo "no baseline tag ($TAG) to reset to" >&2; exit 1
|
|
53
|
+
fi
|
|
54
|
+
git reset --hard "$TAG"
|
|
55
|
+
git clean -fd -e "$SETTINGS"
|
|
56
|
+
echo "reset clone to baseline $TAG ($(git rev-parse --short "$TAG"))"
|
|
57
|
+
;;
|
|
58
|
+
|
|
59
|
+
*)
|
|
60
|
+
echo "usage: vibe-recover.sh {status|revert-last|reset-baseline}" >&2
|
|
61
|
+
exit 2
|
|
62
|
+
;;
|
|
63
|
+
esac
|