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
package/default/vite.config.js
CHANGED
|
@@ -25,9 +25,50 @@ const settingsPath = path.resolve(configPath, 'config/settings.json')
|
|
|
25
25
|
|
|
26
26
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
|
|
27
27
|
|
|
28
|
+
/*
|
|
29
|
+
* Race-safe loader for the regenerated config (Vibe / HAP-1096, HAP-1203).
|
|
30
|
+
*
|
|
31
|
+
* `@collections` / `@endpoints` resolve to `mango/config/.collections.json` and
|
|
32
|
+
* `.endpoints.json`, which mango REWRITES on every rebuild. For a brief window
|
|
33
|
+
* during that rewrite the file is empty/truncated. The front imports them
|
|
34
|
+
* (`src/helpers/mango.js`) and the watch plugin below full-reloads on change —
|
|
35
|
+
* so a reload that lands mid-rewrite makes Vite's `vite:json` throw
|
|
36
|
+
* "Failed to parse JSON file ... position -1", breaking the module graph and
|
|
37
|
+
* blanking the app. This `enforce: 'pre'` loader intercepts those two ids before
|
|
38
|
+
* `vite:json`, caches the last good parse, and serves it whenever the file is
|
|
39
|
+
* transiently unreadable, so a mid-write read can never break the page. Once the
|
|
40
|
+
* file settles, the watch -> full-reload re-runs this loader and the fresh
|
|
41
|
+
* content is picked up. It returns RAW JSON text (not `export default …`) so
|
|
42
|
+
* vite's built-in `vite:json` transform still converts it to ESM normally —
|
|
43
|
+
* returning JS here causes a 500 (HAP-1203). Dev/serve only; prod serves a
|
|
44
|
+
* static `vite build` and is immune.
|
|
45
|
+
*/
|
|
46
|
+
function resilientConfigJson(targets) {
|
|
47
|
+
const lastGood = new Map() // absolute path -> last successfully parsed value
|
|
48
|
+
const fallback = (p) => (p === collectionsPath ? [] : {})
|
|
49
|
+
return {
|
|
50
|
+
name: 'vibe-resilient-config-json',
|
|
51
|
+
enforce: 'pre',
|
|
52
|
+
load(id) {
|
|
53
|
+
const clean = id.split('?')[0]
|
|
54
|
+
if (!targets.includes(clean)) return null
|
|
55
|
+
let value
|
|
56
|
+
try {
|
|
57
|
+
value = JSON.parse(fs.readFileSync(clean, 'utf8'))
|
|
58
|
+
lastGood.set(clean, value)
|
|
59
|
+
} catch (e) {
|
|
60
|
+
value = lastGood.has(clean) ? lastGood.get(clean) : fallback(clean)
|
|
61
|
+
this.warn(`[vibe] ${path.basename(clean)} mid-rewrite/unparseable; served ${lastGood.has(clean) ? 'last-good' : 'empty'} config (${e.message})`)
|
|
62
|
+
}
|
|
63
|
+
return JSON.stringify(value)
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
28
68
|
// https://vitejs.dev/config/
|
|
29
69
|
export default defineConfig({
|
|
30
70
|
plugins: [
|
|
71
|
+
resilientConfigJson([collectionsPath, endpointsPath]),
|
|
31
72
|
vue(),
|
|
32
73
|
// VitePWA({
|
|
33
74
|
// registerType: 'autoUpdate',
|
|
@@ -50,6 +91,11 @@ export default defineConfig({
|
|
|
50
91
|
server.watcher.add(endpointsPath)
|
|
51
92
|
server.watcher.on('change', (file) => {
|
|
52
93
|
if (file === collectionsPath || file === endpointsPath) {
|
|
94
|
+
// Only reload once the rewrite has SETTLED (file parses). The
|
|
95
|
+
// 'change' event also fires for the transient empty/truncated
|
|
96
|
+
// state mid-rewrite; reloading then would just bounce off the
|
|
97
|
+
// resilient loader's last-good and reload again. Skip those.
|
|
98
|
+
try { JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return }
|
|
53
99
|
server.ws.send({
|
|
54
100
|
type: 'full-reload',
|
|
55
101
|
})
|
|
@@ -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
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// server.js
|
|
2
|
+
//
|
|
3
|
+
// Orchestrator HTTP entrypoint. Dependency-free (node:http). Exposes:
|
|
4
|
+
//
|
|
5
|
+
// GET /health -> { ok, canRunLive, config summary }
|
|
6
|
+
// GET /sessions -> [ { site, sessionId, cumulativeTokens, status } ]
|
|
7
|
+
// POST /prompt -> SSE stream of progress events body: { site, prompt, pageContext?, attachments? }
|
|
8
|
+
// POST /abort -> { ok } body: { site }
|
|
9
|
+
// POST /reset -> { ok } body: { site }
|
|
10
|
+
// POST /publish -> { ... } body: { site, confirm }
|
|
11
|
+
//
|
|
12
|
+
// Owner auth (HAP-1109): every endpoint except `GET /health` requires a real
|
|
13
|
+
// per-owner token in `Authorization: Bearer <token>`. The token is minted by
|
|
14
|
+
// Mango from an authenticated admin session and verified here against the shared
|
|
15
|
+
// VIBE_ORCH_TOKEN_SECRET (see src/ownerToken.js). Without that secret the server
|
|
16
|
+
// fails CLOSED — gated endpoints return 503, never open access. Client-side role
|
|
17
|
+
// flags are never trusted. The Claude OAuth token is never exposed by any
|
|
18
|
+
// endpoint. Intended to listen on localhost only and be fronted by the staging
|
|
19
|
+
// nginx vhost (see README).
|
|
20
|
+
|
|
21
|
+
import http from 'node:http'
|
|
22
|
+
import { SessionManager } from './src/sessionManager.js'
|
|
23
|
+
import { config, canRunLive } from './src/config.js'
|
|
24
|
+
import { verifyOwnerToken } from './src/ownerToken.js'
|
|
25
|
+
import * as publisher from './src/publisher.js'
|
|
26
|
+
import * as recovery from './src/recovery.js'
|
|
27
|
+
import { readCosts, rollup } from './src/costStore.js'
|
|
28
|
+
|
|
29
|
+
const manager = new SessionManager()
|
|
30
|
+
|
|
31
|
+
/** Extract the bearer token from the Authorization header. */
|
|
32
|
+
function bearer(req) {
|
|
33
|
+
const h = req.headers['authorization'] || ''
|
|
34
|
+
const m = /^Bearer\s+(.+)$/i.exec(h.trim())
|
|
35
|
+
return m ? m[1].trim() : ''
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve owner auth for a request. Returns the verifyOwnerToken result:
|
|
40
|
+
* { ok:true, payload } or { ok:false, reason, status }. Fails closed when no
|
|
41
|
+
* token secret is configured (status 503).
|
|
42
|
+
*/
|
|
43
|
+
function requireOwner(req) {
|
|
44
|
+
return verifyOwnerToken(bearer(req), {
|
|
45
|
+
secret: config.tokenSecret,
|
|
46
|
+
ownerRoles: config.ownerRoles,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sendJson(res, status, body) {
|
|
51
|
+
const data = JSON.stringify(body)
|
|
52
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
53
|
+
res.end(data)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Apply CORS headers for an allowed browser origin (HAP-1121). The ⌘K drawer is
|
|
58
|
+
* served from the site origin (e.g. https://staging-vibe.generations.org) and
|
|
59
|
+
* calls this orchestrator on the -api origin cross-origin. Without an allow-list
|
|
60
|
+
* + preflight handling the browser blocks every call and the drawer shows
|
|
61
|
+
* "Failed to fetch" (a transport failure with no HTTP status). Headers are set
|
|
62
|
+
* with setHeader so they merge into later writeHead() calls (JSON + SSE alike).
|
|
63
|
+
* Returns true when the request Origin is allowed.
|
|
64
|
+
*/
|
|
65
|
+
function applyCors(req, res) {
|
|
66
|
+
const origin = req.headers['origin']
|
|
67
|
+
if (!origin) return false
|
|
68
|
+
const list = config.allowedOrigins
|
|
69
|
+
const allowed = list.length === 0 || list.includes(origin)
|
|
70
|
+
if (!allowed) return false
|
|
71
|
+
res.setHeader('Access-Control-Allow-Origin', origin)
|
|
72
|
+
res.setHeader('Vary', 'Origin')
|
|
73
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
74
|
+
res.setHeader('Access-Control-Allow-Headers', 'authorization, content-type')
|
|
75
|
+
res.setHeader('Access-Control-Max-Age', '86400')
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a { from, to } ms window from query params (HAP-1123). Each of `from`
|
|
81
|
+
* and `to` may be epoch-ms or an ISO date (YYYY-MM-DD). With neither present we
|
|
82
|
+
* default to the current calendar month-to-date so a bare `/costs/rollup` answers
|
|
83
|
+
* "this month's spend". An ISO date with no time is treated as UTC midnight.
|
|
84
|
+
*/
|
|
85
|
+
function parseRange(params) {
|
|
86
|
+
const parse = (v) => {
|
|
87
|
+
if (!v) return undefined
|
|
88
|
+
if (/^\d+$/.test(v)) return Number.parseInt(v, 10)
|
|
89
|
+
const t = Date.parse(v)
|
|
90
|
+
return Number.isNaN(t) ? undefined : t
|
|
91
|
+
}
|
|
92
|
+
let from = parse(params.get('from'))
|
|
93
|
+
let to = parse(params.get('to'))
|
|
94
|
+
if (from === undefined && to === undefined) {
|
|
95
|
+
const now = new Date()
|
|
96
|
+
from = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)
|
|
97
|
+
}
|
|
98
|
+
return { from, to }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readBody(req) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
let raw = ''
|
|
104
|
+
req.on('data', (c) => {
|
|
105
|
+
raw += c
|
|
106
|
+
// 80MB ceiling: prompt bodies may carry a base64 viewport screenshot
|
|
107
|
+
// (HAP-1111) plus up to 5 user attachments of ≤10MB each (HAP-1126). The
|
|
108
|
+
// per-file size/mime/count guard runs again server-side in attachments.js;
|
|
109
|
+
// this is just the gross transport cap.
|
|
110
|
+
if (raw.length > 80_000_000) reject(new Error('body too large'))
|
|
111
|
+
})
|
|
112
|
+
req.on('end', () => {
|
|
113
|
+
if (!raw) return resolve({})
|
|
114
|
+
try { resolve(JSON.parse(raw)) } catch { reject(new Error('invalid JSON body')) }
|
|
115
|
+
})
|
|
116
|
+
req.on('error', reject)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function startSse(res, { onPing } = {}) {
|
|
121
|
+
res.writeHead(200, {
|
|
122
|
+
'content-type': 'text/event-stream',
|
|
123
|
+
'cache-control': 'no-cache',
|
|
124
|
+
connection: 'keep-alive',
|
|
125
|
+
'x-accel-buffering': 'no', // disable nginx proxy buffering
|
|
126
|
+
})
|
|
127
|
+
const write = (event, data) => {
|
|
128
|
+
res.write(`event: ${event}\n`)
|
|
129
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`)
|
|
130
|
+
}
|
|
131
|
+
// keepalive comment ping so idle proxies don't drop the connection. The ping
|
|
132
|
+
// also counts as session activity (HAP-1110) so an actively-read but idle
|
|
133
|
+
// session is kept warm rather than reaped mid-read.
|
|
134
|
+
const ping = setInterval(() => {
|
|
135
|
+
res.write(': ping\n\n')
|
|
136
|
+
onPing?.()
|
|
137
|
+
}, 15000)
|
|
138
|
+
if (ping.unref) ping.unref()
|
|
139
|
+
return { write, close: () => clearInterval(ping) }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function handlePrompt(req, res) {
|
|
143
|
+
let body
|
|
144
|
+
try { body = await readBody(req) } catch (e) { return sendJson(res, 400, { error: e.message }) }
|
|
145
|
+
const { site, prompt, pageContext, attachments } = body
|
|
146
|
+
|
|
147
|
+
let handle
|
|
148
|
+
try {
|
|
149
|
+
handle = manager.submit(site, prompt, { pageContext, attachments })
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return sendJson(res, 400, { error: e.message })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Track this connection as an active reader and keep the session warm on ping.
|
|
155
|
+
manager.addReader(site)
|
|
156
|
+
const sse = startSse(res, { onPing: () => manager.touch(site) })
|
|
157
|
+
sse.write('accepted', { site })
|
|
158
|
+
|
|
159
|
+
handle.emitter.on('event', (ev) => sse.write('progress', ev))
|
|
160
|
+
handle.emitter.on('error', (err) => sse.write('error', { message: err.message }))
|
|
161
|
+
handle.emitter.on('end', (summary) => {
|
|
162
|
+
sse.write('end', summary)
|
|
163
|
+
sse.close()
|
|
164
|
+
manager.removeReader(site)
|
|
165
|
+
res.end()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// If the client disconnects, abort the run to free the slot.
|
|
169
|
+
req.on('close', () => {
|
|
170
|
+
if (!res.writableEnded) {
|
|
171
|
+
handle.abort()
|
|
172
|
+
manager.removeReader(site)
|
|
173
|
+
}
|
|
174
|
+
sse.close()
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const server = http.createServer(async (req, res) => {
|
|
179
|
+
const url = new URL(req.url, 'http://localhost')
|
|
180
|
+
const route = `${req.method} ${url.pathname}`
|
|
181
|
+
|
|
182
|
+
// CORS (HAP-1121): set allow-origin headers for an allowed browser origin and
|
|
183
|
+
// answer the preflight BEFORE the auth gate. Browsers never send the
|
|
184
|
+
// Authorization header on an OPTIONS preflight, so gating it on auth returned
|
|
185
|
+
// 401 and the whole cross-origin call failed in-browser as "Failed to fetch".
|
|
186
|
+
applyCors(req, res)
|
|
187
|
+
if (req.method === 'OPTIONS') {
|
|
188
|
+
res.writeHead(204)
|
|
189
|
+
return res.end()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// `GET /health` is the only unauthenticated route: a minimal liveness probe
|
|
193
|
+
// for ops/nginx that leaks no config. Everything else requires an owner token.
|
|
194
|
+
if (route === 'GET /health') {
|
|
195
|
+
return sendJson(res, 200, { ok: true, ownerAuth: !!config.tokenSecret })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Real per-owner gate. 401 = no/invalid token, 403 = valid but not an owner,
|
|
199
|
+
// 503 = server not configured for owner auth (fails closed).
|
|
200
|
+
const auth = requireOwner(req)
|
|
201
|
+
if (!auth.ok) return sendJson(res, auth.status || 401, { error: auth.reason || 'unauthorized' })
|
|
202
|
+
req.owner = auth.payload
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
switch (route) {
|
|
206
|
+
case 'GET /health/details':
|
|
207
|
+
return sendJson(res, 200, {
|
|
208
|
+
ok: true,
|
|
209
|
+
canRunLive: canRunLive(),
|
|
210
|
+
stagingRoot: config.stagingRoot,
|
|
211
|
+
allowedSites: config.allowedSites,
|
|
212
|
+
tokenCap: config.tokenCap,
|
|
213
|
+
permissionMode: config.permissionMode,
|
|
214
|
+
publishEnabled: config.publishEnabled,
|
|
215
|
+
publishRemote: config.publishRemote,
|
|
216
|
+
settingsPath: config.settingsPath,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
case 'GET /sessions':
|
|
220
|
+
return sendJson(res, 200, { sessions: manager.list() })
|
|
221
|
+
|
|
222
|
+
// ---- Cost tracking (HAP-1123) ----
|
|
223
|
+
// GET /costs — raw per-turn records in a window (for export/audit)
|
|
224
|
+
// GET /costs/rollup — summed totals (by day/session/model) to invoice from
|
|
225
|
+
// Both default to the current calendar month when no range is given, and
|
|
226
|
+
// accept ?from / ?to as epoch-ms or YYYY-MM-DD, plus an optional ?site.
|
|
227
|
+
case 'GET /costs': {
|
|
228
|
+
const range = parseRange(url.searchParams)
|
|
229
|
+
const site = url.searchParams.get('site') || undefined
|
|
230
|
+
return sendJson(res, 200, { ...range, site: site || null, records: readCosts({ ...range, site }) })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
case 'GET /costs/rollup': {
|
|
234
|
+
const range = parseRange(url.searchParams)
|
|
235
|
+
const site = url.searchParams.get('site') || undefined
|
|
236
|
+
return sendJson(res, 200, rollup({ ...range, site }))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
case 'POST /prompt':
|
|
240
|
+
return await handlePrompt(req, res)
|
|
241
|
+
|
|
242
|
+
case 'GET /publish/diff': {
|
|
243
|
+
const site = url.searchParams.get('site') || config.allowedSites[0]
|
|
244
|
+
try {
|
|
245
|
+
const d = await publisher.diff(site)
|
|
246
|
+
return sendJson(res, 200, d)
|
|
247
|
+
} catch (e) {
|
|
248
|
+
return sendJson(res, e.statusCode || 400, { error: e.message })
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case 'POST /publish': {
|
|
253
|
+
let body
|
|
254
|
+
try { body = await readBody(req) } catch (e) { return sendJson(res, 400, { error: e.message }) }
|
|
255
|
+
const { site, message, confirm } = body
|
|
256
|
+
try {
|
|
257
|
+
const result = await publisher.publish(site || config.allowedSites[0], { message, confirm })
|
|
258
|
+
return sendJson(res, 200, result)
|
|
259
|
+
} catch (e) {
|
|
260
|
+
// 409 ⇒ merge conflict publishing the branch into the deploy branch;
|
|
261
|
+
// surface the conflicting files so the owner can resolve them.
|
|
262
|
+
return sendJson(res, e.statusCode || 500, {
|
|
263
|
+
error: e.message,
|
|
264
|
+
steps: e.steps,
|
|
265
|
+
conflicts: e.conflicts,
|
|
266
|
+
deployBranch: e.deployBranch,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---- Recovery / rollback (HAP-1124) ----
|
|
272
|
+
// GET /recovery/status — baseline + recent vibe commits + what's available
|
|
273
|
+
// POST /recovery/revert-last — undo the most recent vibe edit (git revert)
|
|
274
|
+
// POST /recovery/reset-baseline— hard-reset to the known-good baseline (confirm:true)
|
|
275
|
+
// A recovery path usable without an engineer; also wrapped by scripts/vibe-recover.sh.
|
|
276
|
+
case 'GET /recovery/status': {
|
|
277
|
+
const site = url.searchParams.get('site') || config.allowedSites[0]
|
|
278
|
+
try {
|
|
279
|
+
return sendJson(res, 200, await recovery.status(site))
|
|
280
|
+
} catch (e) {
|
|
281
|
+
return sendJson(res, e.statusCode || 400, { error: e.message })
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
case 'POST /recovery/revert-last': {
|
|
286
|
+
const { site } = await readBody(req)
|
|
287
|
+
const target = site || config.allowedSites[0]
|
|
288
|
+
if (manager.get(target)?.status === 'running') {
|
|
289
|
+
return sendJson(res, 409, { error: 'cannot recover while a run is in progress' })
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
return sendJson(res, 200, await recovery.revertLast(target))
|
|
293
|
+
} catch (e) {
|
|
294
|
+
return sendJson(res, e.statusCode || 400, { error: e.message })
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
case 'POST /recovery/reset-baseline': {
|
|
299
|
+
const { site, confirm } = await readBody(req)
|
|
300
|
+
const target = site || config.allowedSites[0]
|
|
301
|
+
if (confirm !== true) {
|
|
302
|
+
return sendJson(res, 400, { error: 'reset to baseline requires explicit confirm:true' })
|
|
303
|
+
}
|
|
304
|
+
if (manager.get(target)?.status === 'running') {
|
|
305
|
+
return sendJson(res, 409, { error: 'cannot recover while a run is in progress' })
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
return sendJson(res, 200, await recovery.resetToBaseline(target))
|
|
309
|
+
} catch (e) {
|
|
310
|
+
return sendJson(res, e.statusCode || 400, { error: e.message })
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
case 'POST /abort': {
|
|
315
|
+
const { site } = await readBody(req)
|
|
316
|
+
manager.abort(site)
|
|
317
|
+
return sendJson(res, 200, { ok: true })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
case 'POST /reset': {
|
|
321
|
+
const { site } = await readBody(req)
|
|
322
|
+
try { manager.reset(site) } catch (e) { return sendJson(res, 409, { error: e.message }) }
|
|
323
|
+
return sendJson(res, 200, { ok: true })
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
default:
|
|
327
|
+
return sendJson(res, 404, { error: 'not found' })
|
|
328
|
+
}
|
|
329
|
+
} catch (e) {
|
|
330
|
+
if (!res.headersSent) sendJson(res, 500, { error: e.message })
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// Listen on loopback only; nginx terminates TLS and proxies.
|
|
335
|
+
server.listen(config.port, '127.0.0.1', () => {
|
|
336
|
+
// eslint-disable-next-line no-console
|
|
337
|
+
console.log(`[vibe-orchestrator] listening on 127.0.0.1:${config.port} (live=${canRunLive()}, ownerAuth=${!!config.tokenSecret})`)
|
|
338
|
+
if (!config.tokenSecret) {
|
|
339
|
+
// eslint-disable-next-line no-console
|
|
340
|
+
console.warn('[vibe-orchestrator] WARNING: VIBE_ORCH_TOKEN_SECRET is unset — owner auth is NOT configured; all gated endpoints will return 503 (fail closed).')
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
export { server }
|