mango-cms 0.3.33 → 0.3.35

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