diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..3631a852c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-push: + runs-on: [self-hosted, Linux, X64] + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.ref == 'refs/heads/main' }} + tags: | + ghcr.io/${{ github.repository }}:latest + ghcr.io/${{ github.repository }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml new file mode 100644 index 000000000..be8e6bd24 --- /dev/null +++ b/.github/workflows/upstream-sync.yml @@ -0,0 +1,101 @@ +name: Upstream Sync + +on: + schedule: + # Daily at 06:30 UTC (offset from paperclip's 06:00) + - cron: "30 6 * * *" + workflow_dispatch: + inputs: + mode: + description: "Sync mode" + required: true + default: "pr" + type: choice + options: + - pr + - push + - dry-run + +jobs: + sync: + runs-on: self-hosted + timeout-minutes: 20 + + steps: + - name: Checkout (full history for rebase) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GH_PAT }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + # Nuke stale credential helpers on self-hosted runner + git config --local --unset-all credential.helper 2>/dev/null || true + git config --global --unset-all credential.helper 2>/dev/null || true + + - name: Clean working tree (self-hosted runner may have leftovers) + run: | + git checkout main 2>/dev/null || true + git rebase --abort 2>/dev/null || true + git reset --hard HEAD + git clean -fd + + - name: Add upstream remote + run: | + git remote get-url upstream 2>/dev/null || \ + git remote add upstream https://github.com/NVIDIA/NemoClaw.git + git fetch upstream + + - name: Install Agent SDK + run: | + rm -rf "$(npm root -g)/@anthropic-ai/.claude-code-"* "$(npm root -g)/@anthropic-ai/.claude-agent-sdk-"* 2>/dev/null || true + npm install -g @anthropic-ai/claude-code || npm install -g @anthropic-ai/claude-code + npm install -g @anthropic-ai/claude-agent-sdk || npm install -g @anthropic-ai/claude-agent-sdk + node -e "require('@anthropic-ai/claude-agent-sdk')" 2>/dev/null || echo "SDK import check: will resolve at runtime via global root" + + - name: Write Claude credentials + run: | + mkdir -p ~/.claude + echo '${{ secrets.CLAUDE_CREDENTIALS_JSON }}' > ~/.claude/.credentials.json + + - name: Check for upstream changes + id: check + run: | + BEHIND=$(git rev-list HEAD..upstream/main --count) + echo "behind=$BEHIND" >> "$GITHUB_OUTPUT" + if [ "$BEHIND" -eq 0 ]; then + echo "Up to date with upstream. Skipping sync." + else + echo "Behind upstream by $BEHIND commits." + fi + + - name: Run upstream sync + if: steps.check.outputs.behind != '0' + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + GH_TOKEN: ${{ secrets.GH_PAT }} + run: | + MODE="${{ github.event.inputs.mode || 'pr' }}" + case "$MODE" in + push) FLAG="--push" ;; + pr) FLAG="--pr" ;; + dry-run) FLAG="--dry-run" ;; + esac + node scripts/upstream-sync.mjs $FLAG + + - name: Upload agent event log + if: always() + uses: actions/upload-artifact@v4 + with: + name: agent-events-log + path: agent-events.log + if-no-files-found: ignore + retention-days: 14 diff --git a/Dockerfile b/Dockerfile index 309ba1d8f..28a194dde 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,9 +69,18 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \ NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} +# WOPR sidecar — provision + health endpoints for nemoclaw-platform +COPY wopr/ /opt/wopr/ + WORKDIR /sandbox USER sandbox +# Pre-create OpenClaw directories and write default config. +# These are saved to /opt/nemoclaw-defaults/ (read-only at runtime). +# The startup script copies them to $HOME/.openclaw/ (writable volume). +RUN mkdir -p /sandbox/.openclaw/agents/main/agent \ + && chmod 700 /sandbox/.openclaw + # Write the COMPLETE openclaw.json including gateway config and auth token. # This file is immutable at runtime (Landlock read-only on /sandbox/.openclaw). # No runtime writes to openclaw.json are needed or possible. @@ -118,7 +127,6 @@ path = os.path.expanduser('~/.openclaw/openclaw.json'); \ json.dump(config, open(path, 'w'), indent=2); \ os.chmod(path, 0o600)" -# Install NemoClaw plugin into OpenClaw RUN openclaw doctor --fix > /dev/null 2>&1 || true \ && openclaw plugins install /opt/nemoclaw > /dev/null 2>&1 || true @@ -146,6 +154,18 @@ RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash && chmod 444 /sandbox/.openclaw/.config-hash \ && chown root:root /sandbox/.openclaw/.config-hash +# Save build-time config as defaults — startup script copies to writable HOME +RUN cp -a /sandbox/.openclaw /opt/nemoclaw-defaults \ + && cp -a /sandbox/.nemoclaw /opt/nemoclaw-defaults/.nemoclaw +USER sandbox + +# At runtime, HOME=/data (writable volume mount from FleetManager). +# ReadonlyRootfs makes /sandbox read-only, so all writes go to /data. +ENV HOME=/data + +# Expose WOPR sidecar port +EXPOSE 3100 + # Entrypoint runs as root to start the gateway as the gateway user, # then drops to sandbox for agent commands. See nemoclaw-start.sh. ENTRYPOINT ["/usr/local/bin/nemoclaw-start"] diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 6472459c6..7a7661441 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -207,6 +207,20 @@ PYAUTOPAIR # ── Main ───────────────────────────────────────────────────────── +# Copy default config to writable HOME if not already present. +# FleetManager mounts a volume at /data and sets HOME=/data. +# The rootfs is read-only, so all writes must go to /data. +if [ -d /opt/nemoclaw-defaults ] && [ ! -f "${HOME}/.openclaw/openclaw.json" ]; then + echo "[init] Copying default config to ${HOME}/.openclaw/" + mkdir -p "${HOME}/.openclaw" "${HOME}/.nemoclaw" + cp -a /opt/nemoclaw-defaults/. "${HOME}/.openclaw/" + [ -d /opt/nemoclaw-defaults/.nemoclaw ] && cp -a /opt/nemoclaw-defaults/.nemoclaw/. "${HOME}/.nemoclaw/" +fi + +# Ensure writable dirs exist +mkdir -p "${HOME}/.openclaw/agents/main/agent" 2>/dev/null || true +touch /tmp/gateway.log 2>/dev/null || true + echo 'Setting up NemoClaw...' [ -f .env ] && chmod 600 .env @@ -293,6 +307,13 @@ echo "[gateway] openclaw gateway launched as 'gateway' user (pid $GATEWAY_PID)" start_auto_pair print_dashboard_urls +# Start WOPR sidecar in foreground — keeps the container alive. +# Serves /internal/health + /internal/provision for nemoclaw-platform. +if [ -f /opt/wopr/sidecar.js ]; then + echo "[wopr-sidecar] starting in foreground (port ${PORT:-3100})" + exec node /opt/wopr/sidecar.js +fi + # Keep container running by waiting on the gateway process. # This script is PID 1 (ENTRYPOINT); if it exits, Docker kills all children. wait "$GATEWAY_PID" diff --git a/scripts/upstream-sync.mjs b/scripts/upstream-sync.mjs new file mode 100644 index 000000000..76d05441f --- /dev/null +++ b/scripts/upstream-sync.mjs @@ -0,0 +1,382 @@ +#!/usr/bin/env node +/** + * upstream-sync.mjs + * + * Keeps the wopr-network/nemoclaw fork rebased on NVIDIA/NemoClaw upstream. + * + * Our fork adds a WOPR sidecar (wopr/ directory) + Dockerfile/entrypoint + * tweaks for managed hosting. Upstream changes are always taken; our + * additions are rebased on top. + * + * 1. Fetches upstream and checks for new commits + * 2. Rebases our sidecar commits on top + * 3. Resolves any rebase conflicts (via Agent SDK) + * 4. Runs a build check + * 5. Pushes or creates a PR + * + * Usage: + * node scripts/upstream-sync.mjs [options] + * + * Options: + * --dry-run Report status but don't push + * --push Force-push main after sync + * --pr Create a PR instead of pushing (default for cron) + * + * Requires: + * - CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY env var + * - @anthropic-ai/claude-agent-sdk (npm install -g) + * - git remotes: origin (wopr-network), upstream (NVIDIA) + */ + +import { execSync } from "node:child_process"; +import { existsSync, appendFileSync, writeFileSync, copyFileSync } from "node:fs"; +import { join } from "node:path"; + +const CWD = process.cwd(); +const DRY_RUN = process.argv.includes("--dry-run"); +const AUTO_PUSH = process.argv.includes("--push"); +const CREATE_PR = process.argv.includes("--pr"); + +// Agent event log — saved as CI artifact +const AGENT_LOG_TMP = join("/tmp", `agent-events-${Date.now()}.log`); +const AGENT_LOG_PATH = join(CWD, "agent-events.log"); +writeFileSync(AGENT_LOG_TMP, `=== upstream-sync agent log — ${new Date().toISOString()} ===\n`); + +function logEvent(phase, event) { + const ts = new Date().toISOString(); + appendFileSync(AGENT_LOG_TMP, `[${ts}] [${phase}] ${JSON.stringify(event)}\n`); +} + +function flushLog() { + try { copyFileSync(AGENT_LOG_TMP, AGENT_LOG_PATH); } catch { /* best-effort */ } +} + +// --------------------------------------------------------------------------- +// Shell helpers +// --------------------------------------------------------------------------- + +function run(cmd) { + return execSync(cmd, { cwd: CWD, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +function tryRun(cmd) { + try { + return { ok: true, output: run(cmd) }; + } catch (e) { + return { ok: false, output: (e.stderr || e.message || "").trim() }; + } +} + +function log(msg) { console.log(`[upstream-sync] ${msg}`); } + +function die(msg) { + flushLog(); + console.error(`[upstream-sync] FATAL: ${msg}`); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Agent SDK wrapper +// --------------------------------------------------------------------------- + +let _query; + +async function loadSdk() { + if (_query) return; + const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim(); + const candidates = [ + "@anthropic-ai/claude-agent-sdk", + `${globalRoot}/@anthropic-ai/claude-agent-sdk/sdk.mjs`, + ]; + for (const candidate of candidates) { + try { + const sdk = await import(candidate); + _query = sdk.query; + return; + } catch { /* try next */ } + } + die("@anthropic-ai/claude-agent-sdk not installed.\n npm install -g @anthropic-ai/claude-agent-sdk\n npm install -g @anthropic-ai/claude-code"); +} + +async function runAgent(prompt, opts = {}) { + await loadSdk(); + const phase = opts.phase ?? "unknown"; + const tools = opts.tools ?? ["Read", "Edit", "Write", "Bash", "Glob", "Grep"]; + let result = ""; + let turnCount = 0; + + log(`Agent [${phase}] starting (model: ${opts.model ?? "claude-sonnet-4-6"}, maxTurns: ${opts.maxTurns ?? 60})`); + logEvent(phase, { type: "agent_start", model: opts.model, maxTurns: opts.maxTurns ?? 60 }); + + for await (const message of _query({ + prompt, + options: { + cwd: CWD, + allowedTools: tools, + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + maxTurns: opts.maxTurns ?? 60, + model: opts.model ?? "claude-sonnet-4-6", + }, + })) { + if (message.type === "tool_use") { + turnCount++; + logEvent(phase, { type: "tool_use", turn: turnCount, tool: message.tool, input_preview: JSON.stringify(message.input).slice(0, 200) }); + } else if (message.type === "text") { + logEvent(phase, { type: "text", preview: (message.text || "").slice(0, 300) }); + } else if ("result" in message) { + result = message.result; + logEvent(phase, { type: "result", preview: result.slice(0, 500) }); + } else { + logEvent(phase, { type: message.type || "unknown", keys: Object.keys(message) }); + } + } + + log(`Agent [${phase}] finished after ${turnCount} tool calls`); + logEvent(phase, { type: "agent_done", turnCount }); + return result; +} + +// --------------------------------------------------------------------------- +// Fork context (shared across agent prompts) +// --------------------------------------------------------------------------- + +const FORK_CONTEXT = ` +## Context: WOPR NemoClaw Fork + +This is a fork of NVIDIA/NemoClaw maintained by wopr-network. +The fork adds a WOPR sidecar for managed hosting. Our additions: + +### Files we added (preserve these): +- \`wopr/sidecar.js\` — HTTP sidecar exposing /internal/health and /internal/provision +- \`wopr/package.json\` — sidecar dependencies +- Dockerfile modifications — adds sidecar setup and entrypoint changes +- Entrypoint tweaks — foreground sidecar, correct port, writable HOME + +### Conflict Resolution Rules: +1. TAKE all of upstream's changes (new features, bug fixes, security hardening) +2. REAPPLY our wopr/ additions on top +3. If upstream changed Dockerfile or entrypoint, adapt our additions to the new structure +4. Never drop upstream functionality — only add our sidecar layer +5. Keep wopr/ directory intact +`; + +// --------------------------------------------------------------------------- +// Rebase +// --------------------------------------------------------------------------- + +async function rebase() { + log("Fetching upstream..."); + run("git fetch upstream"); + + const behind = parseInt(run("git rev-list HEAD..upstream/main --count"), 10); + const ahead = parseInt(run("git rev-list upstream/main..HEAD --count"), 10); + + if (behind === 0) { + log("Already up to date with upstream."); + return { rebased: false, behind: 0, ahead }; + } + + log(`Behind upstream by ${behind} commits, ahead by ${ahead} commits.`); + + // Backup + const datestamp = new Date().toISOString().slice(0, 10); + const backupBranch = `backup/pre-sync-${datestamp}`; + tryRun(`git branch -D ${backupBranch}`); + run(`git branch ${backupBranch}`); + log(`Backup: ${backupBranch}`); + + // Attempt rebase + log("Rebasing onto upstream/main..."); + const rebaseResult = tryRun("git rebase upstream/main"); + + if (rebaseResult.ok) { + log("Rebase succeeded cleanly."); + return { rebased: true, behind, ahead }; + } + + // Conflicts — invoke agent + log("Rebase has conflicts. Invoking agent to resolve..."); + const conflicting = tryRun("git diff --name-only --diff-filter=U"); + const conflictFiles = conflicting.ok ? conflicting.output : "unknown"; + + await runAgent( + `You are resolving git rebase conflicts in a NemoClaw fork. + +${FORK_CONTEXT} + +## Current Conflicts + +These files have conflicts: +${conflictFiles} + +## Steps + +1. For each conflicting file, read it and find the conflict markers (<<<<<<< / ======= / >>>>>>>) +2. Resolve each conflict following the rules above +3. Run: git add +4. After ALL conflicts are resolved, run: git rebase --continue +5. If new conflicts appear, repeat +6. Continue until the rebase completes + +IMPORTANT: Do NOT use git rebase --abort. Resolve all conflicts.`, + { model: "claude-sonnet-4-6", maxTurns: 80, phase: "rebase-conflicts" }, + ); + + // Verify rebase completed + const status = tryRun("git rebase --show-current-patch"); + if (status.ok) { + die("Rebase still in progress after agent intervention. Manual resolution needed."); + } + + log("Rebase completed after conflict resolution."); + return { rebased: true, behind, ahead }; +} + +// --------------------------------------------------------------------------- +// Build check +// --------------------------------------------------------------------------- + +async function buildCheck() { + log("Running build check..."); + + // Check sidecar syntax + const sidecarCheck = tryRun("node --check wopr/sidecar.js"); + if (!sidecarCheck.ok) { + log("Sidecar syntax check failed. Invoking agent to fix..."); + await runAgent( + `The WOPR sidecar has a syntax error after upstream sync: + +\`\`\` +${sidecarCheck.output.slice(0, 2000)} +\`\`\` + +Fix the syntax error in wopr/sidecar.js. Do NOT remove sidecar functionality.`, + { model: "claude-sonnet-4-6", phase: "sidecar-fix" }, + ); + } + + // Check Dockerfile builds (syntax only — full build is too slow for CI sync) + if (existsSync(`${CWD}/Dockerfile`)) { + // docker build --check isn't universally available, so just verify Dockerfile parses + const hasFrom = tryRun("grep -q '^FROM' Dockerfile"); + if (!hasFrom.ok) { + log("Dockerfile appears broken (no FROM instruction)."); + return false; + } + } + + log("Build check passed."); + return true; +} + +// --------------------------------------------------------------------------- +// Push / PR +// --------------------------------------------------------------------------- + +function pushOrPr() { + if (DRY_RUN) { + log("Dry run — skipping push."); + return; + } + + // Push using GH_TOKEN embedded directly in the remote URL + // Use a fresh remote to avoid any cached credential interference + const ghToken = process.env.GH_TOKEN; + + function gitPush(args) { + if (!ghToken) return run(`git ${args}`); + // gh on the runner picks up GH_TOKEN env for auth — just pass it through + const result = execSync(`git ${args}`, { + cwd: CWD, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, GH_TOKEN: ghToken, GITHUB_TOKEN: ghToken, GIT_TERMINAL_PROMPT: "0" }, + }).trim(); + return result; + } + + if (AUTO_PUSH) { + log("Force-pushing to origin/main..."); + gitPush("push --force-with-lease origin main"); + log("Pushed successfully."); + } else if (CREATE_PR) { + const datestamp = new Date().toISOString().slice(0, 10); + const branch = `sync/upstream-${datestamp}`; + tryRun(`git branch -D ${branch}`); + run(`git checkout -b ${branch}`); + gitPush(`push -u origin ${branch} --force-with-lease`); + + const prBody = [ + "## Automated upstream sync", + "", + "Rebased our WOPR sidecar commits onto upstream/main (NVIDIA/NemoClaw).", + "", + "### What this does", + "- Pulls in latest upstream changes (security fixes, features, CI improvements)", + "- Resolves any rebase conflicts (preserving wopr/ sidecar)", + "- Verifies sidecar + Dockerfile integrity", + "", + "### Verify", + "- [ ] Build passes", + "- [ ] wopr/sidecar.js intact", + "- [ ] Dockerfile includes sidecar setup", + ].join("\n"); + + const pr = tryRun( + `gh pr create --repo wopr-network/nemoclaw --title "sync: rebase on upstream (${datestamp})" --body "${prBody.replace(/"/g, '\\"')}" --base main`, + ); + if (pr.ok) { + log(`PR created: ${pr.output}`); + } else { + log(`PR creation failed: ${pr.output}`); + } + + run("git checkout main"); + } else { + log("Sync complete. Use --push to force-push or --pr to create a PR."); + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const remotes = tryRun("git remote -v"); + if (!remotes.output.includes("nemoclaw")) { + die("Not in a nemoclaw repo."); + } + + if (!tryRun("git remote get-url upstream").ok) { + die("No 'upstream' remote. Add with: git remote add upstream https://github.com/NVIDIA/NemoClaw.git"); + } + + const status = run("git status --porcelain"); + if (status) { + die("Working tree is dirty. Commit or stash changes first."); + } + + const { rebased, behind } = await rebase(); + + if (!rebased && behind === 0) { + log("Up to date. Nothing to do."); + flushLog(); + return; + } + + const buildOk = await buildCheck(); + if (!buildOk) { + die("Build failed. Not pushing."); + } + + pushOrPr(); + flushLog(); + log("Done."); +} + +main().catch((err) => { + flushLog(); + console.error(err); + process.exit(1); +}); diff --git a/wopr/package.json b/wopr/package.json new file mode 100644 index 000000000..e539c40b8 --- /dev/null +++ b/wopr/package.json @@ -0,0 +1,7 @@ +{ + "name": "@wopr-network/nemoclaw-sidecar", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "WOPR provision sidecar for NemoClaw managed hosting" +} diff --git a/wopr/sidecar.js b/wopr/sidecar.js new file mode 100644 index 000000000..52f4d001a --- /dev/null +++ b/wopr/sidecar.js @@ -0,0 +1,149 @@ +#!/usr/bin/env node +// WOPR NemoClaw sidecar — exposes /internal/health and /internal/provision +// so nemoclaw-platform can use the same provision contract as paperclip-platform. +// +// Env vars: +// WOPR_PROVISION_SECRET — shared secret for auth +// WOPR_GATEWAY_URL — WOPR inference gateway base URL (e.g. https://gateway.wopr.bot/v1) +// PORT — sidecar port (default: 3001) + +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +const SECRET = process.env.WOPR_PROVISION_SECRET ?? ""; +const GATEWAY_URL = process.env.WOPR_GATEWAY_URL ?? ""; +const PORT = parseInt(process.env.PORT ?? process.env.WOPR_SIDECAR_PORT ?? "3100", 10); +const OPENCLAW_CONFIG_PATH = path.join(os.homedir(), ".openclaw", "openclaw.json"); + +function assertSecret(req) { + const auth = req.headers["authorization"] ?? ""; + if (!auth.startsWith("Bearer ")) return false; + return auth.slice("Bearer ".length).trim() === SECRET; +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return {}; + } +} + +function writeJson(filePath, obj) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), { mode: 0o600 }); +} + +function isGatewayUp() { + try { + const logPath = "/tmp/gateway.log"; + if (!fs.existsSync(logPath)) return false; + const tail = fs.readFileSync(logPath, "utf8").slice(-4096); + // openclaw gateway prints "Listening" or "Gateway running" when ready + return /listening|gateway running|started/i.test(tail); + } catch { + return false; + } +} + +function provision(body) { + const { tenantId, tenantName, gatewayUrl, apiKey, budgetCents } = body; + + if (!tenantId || !tenantName) { + throw new Error("Missing required fields: tenantId, tenantName"); + } + + const effectiveGateway = gatewayUrl || GATEWAY_URL; + if (!effectiveGateway) { + throw new Error("No gateway URL provided and WOPR_GATEWAY_URL not set"); + } + + // Point NemoClaw at WOPR's inference gateway instead of NVIDIA's + const cfg = readJson(OPENCLAW_CONFIG_PATH); + + cfg.agents ??= {}; + cfg.agents.defaults ??= {}; + cfg.agents.defaults.model ??= {}; + cfg.agents.defaults.model.primary = "nvidia/nemotron-3-super-120b-a12b"; + + cfg.models ??= {}; + cfg.models.mode = "merge"; + cfg.models.providers ??= {}; + cfg.models.providers["wopr-gateway"] = { + baseUrl: effectiveGateway, + apiKey: apiKey ?? "wopr-managed", + api: "openai-completions", + models: [ + { + id: "nvidia/nemotron-3-super-120b-a12b", + name: "Nemotron 3 Super 120B (via WOPR)", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 4096, + }, + ], + }; + + // Set WOPR gateway as the active provider + cfg.agents.defaults.model.primary = "nvidia/nemotron-3-super-120b-a12b"; + cfg.gateway ??= {}; + cfg.gateway.inferenceProvider = "wopr-gateway"; + + // Store tenant metadata for reference + cfg._wopr = { tenantId, tenantName, budgetCents: budgetCents ?? 0, provisionedAt: new Date().toISOString() }; + + writeJson(OPENCLAW_CONFIG_PATH, cfg); + + // Derive a stable tenantEntityId and slug from tenantId + const tenantSlug = tenantName.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").slice(0, 32); + const tenantEntityId = `e:${tenantId}`; + + return { tenantEntityId, tenantSlug }; +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + + // Health check — no auth required (provision-client checks /internal/provision/health) + if (req.method === "GET" && (url.pathname === "/internal/health" || url.pathname === "/internal/provision/health")) { + const up = isGatewayUp(); + res.writeHead(up ? 200 : 503, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: up, provisioning: up, gateway: up ? "running" : "starting" })); + return; + } + + // Provision — auth required + if (req.method === "POST" && url.pathname === "/internal/provision") { + if (!assertSecret(req)) { + res.writeHead(401, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "Unauthorized" })); + return; + } + + let body = ""; + req.on("data", (chunk) => { body += chunk; }); + req.on("end", () => { + try { + const parsed = JSON.parse(body); + const result = provision(parsed); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, ...result })); + } catch (err) { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + } + }); + return; + } + + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); +}); + +server.listen(PORT, "0.0.0.0", () => { + console.log(`[wopr-sidecar] listening on :${PORT}`); +});