From b9e05c30e673f744743d4daf64d37eef3a950a8e Mon Sep 17 00:00:00 2001 From: nv-kasikritc Date: Sun, 8 Mar 2026 21:41:24 +0000 Subject: [PATCH 1/2] First draft of design and implementation --- sandboxes/nemoclaw/Dockerfile | 10 +- sandboxes/nemoclaw/nemoclaw-start.sh | 31 +- .../nemoclaw-ui-extension/extension/icons.ts | 22 + .../extension/nav-group.ts | 9 +- .../extension/package.json | 6 + .../extension/policy-page.ts | 1283 ++++++++++++++ .../extension/styles.css | 1493 +++++++++++++++++ sandboxes/nemoclaw/policy-proxy.js | 159 ++ sandboxes/nemoclaw/policy.yaml | 6 +- 9 files changed, 3000 insertions(+), 19 deletions(-) create mode 100644 sandboxes/nemoclaw/nemoclaw-ui-extension/extension/package.json create mode 100644 sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts create mode 100644 sandboxes/nemoclaw/policy-proxy.js diff --git a/sandboxes/nemoclaw/Dockerfile b/sandboxes/nemoclaw/Dockerfile index 74eb63a..b7a3fb0 100644 --- a/sandboxes/nemoclaw/Dockerfile +++ b/sandboxes/nemoclaw/Dockerfile @@ -20,6 +20,10 @@ USER root COPY nemoclaw-start.sh /usr/local/bin/nemoclaw-start RUN chmod +x /usr/local/bin/nemoclaw-start +# Install the policy reverse proxy (sits in front of the OpenClaw gateway, +# intercepts /api/policy to read/write /etc/navigator/policy.yaml) +COPY policy-proxy.js /usr/local/lib/policy-proxy.js + # Stage the NeMoClaw DevX extension source COPY nemoclaw-ui-extension/extension/ /opt/nemoclaw-devx/ @@ -29,8 +33,11 @@ COPY nemoclaw-ui-extension/extension/ /opt/nemoclaw-devx/ # add \n|" "$UI_DIR/index.html"; \ - npm uninstall -g esbuild + npm uninstall -g esbuild; \ + rm -rf /opt/nemoclaw-devx/node_modules ENTRYPOINT ["/bin/bash"] diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index 74ad006..58f6acb 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -40,6 +40,14 @@ set -euo pipefail # that is blocked, we skip gracefully — users can still enter keys via # the API Keys page in the OpenClaw UI. # -------------------------------------------------------------------------- +if [ -z "${CHAT_UI_URL:-}" ]; then + echo "Error: CHAT_UI_URL environment variable is required." >&2 + echo "Set it to the URL where the chat UI will be accessed, e.g.:" >&2 + echo " Local: CHAT_UI_URL=http://127.0.0.1:18789" >&2 + echo " Brev: CHAT_UI_URL=https://187890-.brevlab.com" >&2 + exit 1 +fi + BUNDLE="$(npm root -g)/openclaw/dist/control-ui/assets/nemoclaw-devx.js" if [ -f "$BUNDLE" ]; then @@ -74,26 +82,21 @@ openclaw onboard \ --custom-api-key "not-used" \ --secret-input-mode plaintext \ --custom-compatibility openai \ - --gateway-port 18789 \ + --gateway-port 18788 \ --gateway-bind loopback export NVIDIA_API_KEY=" " -GATEWAY_PORT=18789 - -if [ -z "${CHAT_UI_URL:-}" ]; then - echo "Error: CHAT_UI_URL environment variable is required." >&2 - echo "Set it to the URL where the chat UI will be accessed, e.g.:" >&2 - echo " Local: CHAT_UI_URL=http://127.0.0.1:18789" >&2 - echo " Brev: CHAT_UI_URL=https://187890-.brevlab.com" >&2 - exit 1 -fi +INTERNAL_GATEWAY_PORT=18788 +PUBLIC_PORT=18789 +# allowedOrigins must reference the PUBLIC port (18789) since that is the +# origin the browser sends. The proxy on 18789 forwards to 18788 internally. python3 -c " import json, os from urllib.parse import urlparse cfg = json.load(open(os.environ['HOME'] + '/.openclaw/openclaw.json')) -local = 'http://127.0.0.1:${GATEWAY_PORT}' +local = 'http://127.0.0.1:${PUBLIC_PORT}' parsed = urlparse(os.environ['CHAT_UI_URL']) chat_origin = f'{parsed.scheme}://{parsed.netloc}' origins = [local] @@ -108,6 +111,12 @@ json.dump(cfg, open(os.environ['HOME'] + '/.openclaw/openclaw.json', 'w'), inden nohup openclaw gateway > /tmp/gateway.log 2>&1 & +# Start the policy reverse proxy on the public-facing port. It forwards all +# traffic to the OpenClaw gateway on the internal port and intercepts +# /api/policy requests to read/write /etc/navigator/policy.yaml. +UPSTREAM_PORT=${INTERNAL_GATEWAY_PORT} LISTEN_PORT=${PUBLIC_PORT} \ + nohup node /usr/local/lib/policy-proxy.js >> /tmp/gateway.log 2>&1 & + # Auto-approve pending device pairing requests so the browser is paired # before the user notices the "pairing required" prompt in the Control UI. ( diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/icons.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/icons.ts index 4f5e3a3..d0523cb 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/icons.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/icons.ts @@ -30,6 +30,28 @@ export const ICON_EYE = ``; +export const ICON_LOCK = ``; + +export const ICON_PLUS = ``; + +export const ICON_TRASH = ``; + +export const ICON_EDIT = ``; + +export const ICON_INFO = ``; + +export const ICON_GLOBE = ``; + +export const ICON_TERMINAL = ``; + +export const ICON_FOLDER = ``; + +export const ICON_USER = ``; + +export const ICON_CHEVRON_RIGHT = ``; + +export const ICON_SEARCH = ``; + export const TARGET_ICONS: Record = { "dgx-spark": ICON_CHIP, "dgx-station": ICON_SERVER, diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts index a94d1cb..7b82564 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts @@ -7,6 +7,7 @@ import { ICON_SHIELD, ICON_ROUTE, ICON_KEY } from "./icons.ts"; import { renderApiKeysPage, areAllKeysConfigured, updateStatusDots } from "./api-keys-page.ts"; +import { renderPolicyPage } from "./policy-page.ts"; // --------------------------------------------------------------------------- // Page definitions @@ -28,10 +29,10 @@ const NEMOCLAW_PAGES: NemoClawPage[] = [ id: "nemoclaw-policy", label: "Policy", icon: ICON_SHIELD, - title: "Policy", - subtitle: "Manage deployment policies and guardrails", - emptyMessage: - "Policy configuration is coming soon. You'll be able to define safety policies, rate limits, and access controls for your NeMoClaw deployments here.", + title: "Sandbox Policy", + subtitle: "View and manage sandbox security guardrails", + emptyMessage: "", + customRender: renderPolicyPage, }, { id: "nemoclaw-inference-routes", diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/package.json b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/package.json new file mode 100644 index 0000000..4267179 --- /dev/null +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "dependencies": { + "js-yaml": "^4.1.0" + } +} diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts new file mode 100644 index 0000000..a82de3d --- /dev/null +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts @@ -0,0 +1,1283 @@ +/** + * NeMoClaw DevX — Policy Page + * + * Interactive policy viewer and editor. Fetches the sandbox policy YAML from + * the policy-proxy API, renders educational sections for immutable fields and + * a full CRUD editor for network policies, and saves changes back via POST. + */ + +import * as yaml from "js-yaml"; +import { + ICON_LOCK, + ICON_GLOBE, + ICON_INFO, + ICON_PLUS, + ICON_TRASH, + ICON_EDIT, + ICON_CHECK, + ICON_CHEVRON_RIGHT, + ICON_CHEVRON_DOWN, + ICON_LOADER, + ICON_TERMINAL, + ICON_CLOSE, +} from "./icons.ts"; + +// --------------------------------------------------------------------------- +// Types — mirrors the YAML schema +// --------------------------------------------------------------------------- + +interface PolicyEndpoint { + host?: string; + port: number; + protocol?: string; + tls?: string; + enforcement?: string; + access?: string; + rules?: { allow: { method: string; path: string } }[]; + allowed_ips?: string[]; +} + +interface PolicyBinary { + path: string; +} + +interface NetworkPolicy { + name: string; + endpoints: PolicyEndpoint[]; + binaries: PolicyBinary[]; +} + +interface SandboxPolicy { + version: number; + filesystem_policy?: { + include_workdir?: boolean; + read_only?: string[]; + read_write?: string[]; + }; + landlock?: { compatibility?: string }; + process?: { run_as_user?: string; run_as_group?: string }; + network_policies?: Record; + inference?: Record; +} + +interface SelectOption { + value: string; + label: string; +} + +// --------------------------------------------------------------------------- +// Policy templates +// --------------------------------------------------------------------------- + +const POLICY_TEMPLATES: { label: string; key: string; policy: NetworkPolicy }[] = [ + { + label: "GitHub (git + API)", + key: "github_custom", + policy: { + name: "github_custom", + endpoints: [ + { host: "github.com", port: 443 }, + { host: "api.github.com", port: 443 }, + ], + binaries: [{ path: "/usr/bin/git" }, { path: "/usr/bin/gh" }], + }, + }, + { + label: "npm Registry", + key: "npm", + policy: { + name: "npm", + endpoints: [{ host: "registry.npmjs.org", port: 443 }], + binaries: [{ path: "/usr/bin/npm" }, { path: "/usr/bin/node" }], + }, + }, + { + label: "PyPI", + key: "pypi", + policy: { + name: "pypi", + endpoints: [ + { host: "pypi.org", port: 443 }, + { host: "files.pythonhosted.org", port: 443 }, + ], + binaries: [{ path: "/usr/bin/pip" }, { path: "/usr/bin/python3" }], + }, + }, + { + label: "Docker Hub", + key: "docker_hub", + policy: { + name: "docker_hub", + endpoints: [ + { host: "registry-1.docker.io", port: 443 }, + { host: "auth.docker.io", port: 443 }, + { host: "production.cloudflare.docker.com", port: 443 }, + ], + binaries: [{ path: "/usr/bin/docker" }], + }, + }, +]; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let currentPolicy: SandboxPolicy | null = null; +let rawYaml = ""; +let isDirty = false; +const changeTracker = { + modified: new Set(), + added: new Set(), + deleted: new Set(), +}; +let pageContainer: HTMLElement | null = null; +let saveBarEl: HTMLElement | null = null; + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- + +async function fetchPolicy(): Promise { + const res = await fetch("/api/policy"); + if (!res.ok) throw new Error(`Failed to load policy: ${res.status}`); + return res.text(); +} + +async function savePolicy(yamlText: string): Promise { + const res = await fetch("/api/policy", { + method: "POST", + headers: { "Content-Type": "text/yaml" }, + body: yamlText, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error || `Save failed: ${res.status}`); + } +} + +// --------------------------------------------------------------------------- +// Render entry point +// --------------------------------------------------------------------------- + +export function renderPolicyPage(container: HTMLElement): void { + container.innerHTML = ` +
+
+
Sandbox Policy
+
Security guardrails that control what your sandbox can do
+
+
+
+
+ ${ICON_LOADER} + Loading policy… +
+
`; + + pageContainer = container; + loadAndRender(container); +} + +async function loadAndRender(container: HTMLElement): Promise { + const page = container.querySelector(".nemoclaw-policy-page")!; + try { + rawYaml = await fetchPolicy(); + currentPolicy = yaml.load(rawYaml) as SandboxPolicy; + isDirty = false; + changeTracker.modified.clear(); + changeTracker.added.clear(); + changeTracker.deleted.clear(); + renderPageContent(page); + } catch (err) { + page.innerHTML = ` +
+

Could not load the sandbox policy.

+

${escapeHtml(String(err))}

+ +
`; + page.querySelector(".nemoclaw-policy-retry-btn")?.addEventListener("click", () => { + page.innerHTML = ` +
+ ${ICON_LOADER} + Loading policy… +
`; + loadAndRender(container); + }); + } +} + +// --------------------------------------------------------------------------- +// Main page layout +// --------------------------------------------------------------------------- + +function renderPageContent(page: HTMLElement): void { + if (!currentPolicy) return; + + page.innerHTML = ""; + + page.appendChild(buildStatusBar()); + + page.appendChild(buildImmutableDisclosure()); + + page.appendChild(buildNetworkPoliciesSection()); + + saveBarEl = buildSaveBar(); + page.appendChild(saveBarEl); +} + +// --------------------------------------------------------------------------- +// Status bar (replaces intro section) +// --------------------------------------------------------------------------- + +function buildStatusBar(): HTMLElement { + const el = document.createElement("div"); + el.className = "nemoclaw-policy-statusbar"; + + const policies = currentPolicy?.network_policies || {}; + const policyCount = Object.keys(policies).length; + let totalEndpoints = 0; + let totalBinaries = 0; + for (const p of Object.values(policies)) { + totalEndpoints += p.endpoints?.length || 0; + totalBinaries += p.binaries?.length || 0; + } + + const stats = document.createElement("div"); + stats.className = "nemoclaw-policy-stats"; + + const statData: { value: number; label: string; scrollTo: string }[] = [ + { value: 3, label: "Immutable", scrollTo: "immutable" }, + { value: policyCount, label: "Net Rules", scrollTo: "network" }, + { value: totalEndpoints, label: "Endpoints", scrollTo: "network" }, + { value: totalBinaries, label: "Binaries", scrollTo: "network" }, + ]; + + for (const s of statData) { + const stat = document.createElement("button"); + stat.type = "button"; + stat.className = "nemoclaw-policy-stat"; + stat.innerHTML = ` + ${s.value} + ${s.label}`; + stat.addEventListener("click", () => { + const target = document.querySelector(`[data-section="${s.scrollTo}"]`); + target?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + stats.appendChild(stat); + } + + el.appendChild(stats); + + const oneliner = document.createElement("div"); + oneliner.className = "nemoclaw-policy-oneliner"; + oneliner.innerHTML = ` + Policies are kernel-enforced guardrails. + ${ICON_LOCK} Immutable at runtime + ${ICON_EDIT} Editable while running`; + + el.appendChild(oneliner); + return el; +} + +// --------------------------------------------------------------------------- +// Immutable disclosure (replaces three separate cards) +// --------------------------------------------------------------------------- + +function buildImmutableDisclosure(): HTMLElement { + const section = document.createElement("div"); + section.className = "nemoclaw-policy-disclosure"; + section.dataset.section = "immutable"; + + const fs = currentPolicy?.filesystem_policy; + const ll = currentPolicy?.landlock; + const proc = currentPolicy?.process; + + const roCount = fs?.read_only?.length || 0; + const rwCount = fs?.read_write?.length || 0; + const user = proc?.run_as_user || "not set"; + const compat = ll?.compatibility || "not set"; + + const header = document.createElement("button"); + header.type = "button"; + header.className = "nemoclaw-policy-disclosure__header"; + header.innerHTML = ` + ${ICON_CHEVRON_RIGHT} + ${ICON_LOCK} + Immutable Configuration + Set at sandbox creation`; + + const summary = document.createElement("div"); + summary.className = "nemoclaw-policy-disclosure__summary"; + summary.innerHTML = ` + ${escapeHtml(user)} user · + ${roCount} read-only paths · + ${rwCount} read-write paths · + Landlock: ${escapeHtml(compat)}`; + + const body = document.createElement("div"); + body.className = "nemoclaw-policy-disclosure__body"; + body.style.display = "none"; + + const note = document.createElement("p"); + note.className = "nemoclaw-policy-disclosure__note"; + note.innerHTML = `To modify these, update policy.yaml and recreate the sandbox.`; + body.appendChild(note); + + const tabs = document.createElement("div"); + tabs.className = "nemoclaw-policy-tabs"; + const tabDefs = [ + { id: "filesystem", label: "Filesystem" }, + { id: "landlock", label: "Landlock" }, + { id: "process", label: "Process Identity" }, + ]; + const panels: Record = {}; + + for (const t of tabDefs) { + const tab = document.createElement("button"); + tab.type = "button"; + tab.className = "nemoclaw-policy-tab" + (t.id === "filesystem" ? " nemoclaw-policy-tab--active" : ""); + tab.textContent = t.label; + tab.dataset.tab = t.id; + tab.addEventListener("click", () => { + tabs.querySelectorAll(".nemoclaw-policy-tab").forEach((el) => el.classList.remove("nemoclaw-policy-tab--active")); + tab.classList.add("nemoclaw-policy-tab--active"); + for (const [id, panel] of Object.entries(panels)) { + panel.style.display = id === t.id ? "" : "none"; + } + }); + tabs.appendChild(tab); + } + body.appendChild(tabs); + + const fsPanel = document.createElement("div"); + fsPanel.className = "nemoclaw-policy-tab-panel"; + fsPanel.appendChild(buildFilesystemContent()); + panels["filesystem"] = fsPanel; + body.appendChild(fsPanel); + + const llPanel = document.createElement("div"); + llPanel.className = "nemoclaw-policy-tab-panel"; + llPanel.style.display = "none"; + llPanel.appendChild(buildLandlockContent()); + panels["landlock"] = llPanel; + body.appendChild(llPanel); + + const procPanel = document.createElement("div"); + procPanel.className = "nemoclaw-policy-tab-panel"; + procPanel.style.display = "none"; + procPanel.appendChild(buildProcessContent()); + panels["process"] = procPanel; + body.appendChild(procPanel); + + let expanded = false; + header.addEventListener("click", () => { + expanded = !expanded; + body.style.display = expanded ? "" : "none"; + summary.style.display = expanded ? "none" : ""; + section.classList.toggle("nemoclaw-policy-disclosure--expanded", expanded); + }); + + section.appendChild(header); + section.appendChild(summary); + section.appendChild(body); + return section; +} + +function buildFilesystemContent(): HTMLElement { + const el = document.createElement("div"); + el.className = "nemoclaw-policy-card__content"; + const fs = currentPolicy?.filesystem_policy; + if (!fs) { + el.innerHTML = `No filesystem policy defined`; + return el; + } + + let html = ""; + if (fs.include_workdir !== undefined) { + html += `
Include workdir: ${fs.include_workdir ? "Yes" : "No"}
`; + } + if (fs.read_only?.length) { + html += `
Read-only paths:
`; + html += `
${fs.read_only.map((p) => `${escapeHtml(p)}`).join("")}
`; + } + if (fs.read_write?.length) { + html += `
Read-write paths:
`; + html += `
${fs.read_write.map((p) => `${escapeHtml(p)}`).join("")}
`; + } + + el.innerHTML = html; + return el; +} + +function buildLandlockContent(): HTMLElement { + const el = document.createElement("div"); + el.className = "nemoclaw-policy-card__content"; + const ll = currentPolicy?.landlock; + el.innerHTML = `
+ Compatibility: + ${escapeHtml(ll?.compatibility || "not set")} +
`; + return el; +} + +function buildProcessContent(): HTMLElement { + const el = document.createElement("div"); + el.className = "nemoclaw-policy-card__content"; + const p = currentPolicy?.process; + el.innerHTML = ` +
+ Run as user: + ${escapeHtml(p?.run_as_user || "not set")} +
+
+ Run as group: + ${escapeHtml(p?.run_as_group || "not set")} +
`; + return el; +} + +// --------------------------------------------------------------------------- +// Network policies (editable) +// --------------------------------------------------------------------------- + +function buildNetworkPoliciesSection(): HTMLElement { + const section = document.createElement("div"); + section.className = "nemoclaw-policy-section"; + section.dataset.section = "network"; + + const policies = currentPolicy?.network_policies || {}; + const policyCount = Object.keys(policies).length; + + const headerRow = document.createElement("div"); + headerRow.className = "nemoclaw-policy-section__header"; + headerRow.innerHTML = ` + ${ICON_GLOBE} +

Network Policies

+ ${policyCount} + ${ICON_EDIT} Editable`; + + const searchInput = document.createElement("input"); + searchInput.type = "search"; + searchInput.className = "nemoclaw-policy-search"; + searchInput.placeholder = "Filter policies..."; + searchInput.addEventListener("input", () => { + const q = searchInput.value.toLowerCase().trim(); + section.querySelectorAll(".nemoclaw-policy-netcard").forEach((card) => { + if (!q) { + card.style.display = ""; + return; + } + const key = card.dataset.policyKey || ""; + const policy = currentPolicy?.network_policies?.[key]; + const hosts = (policy?.endpoints || []).map((ep) => ep.host || "").join(" "); + const bins = (policy?.binaries || []).map((b) => b.path).join(" "); + const haystack = `${key} ${policy?.name || ""} ${hosts} ${bins}`.toLowerCase(); + card.style.display = haystack.includes(q) ? "" : "none"; + }); + }); + headerRow.appendChild(searchInput); + section.appendChild(headerRow); + + const desc = document.createElement("p"); + desc.className = "nemoclaw-policy-section__desc"; + desc.innerHTML = `Controls which external hosts your sandbox can connect to. Each rule binds endpoints to specific binaries.`; + section.appendChild(desc); + + const list = document.createElement("div"); + list.className = "nemoclaw-policy-netpolicies"; + + for (const [key, policy] of Object.entries(policies)) { + list.appendChild(buildNetworkPolicyCard(key, policy, list)); + } + + section.appendChild(list); + + // Add policy button with template dropdown + const addWrap = document.createElement("div"); + addWrap.className = "nemoclaw-policy-add-wrap"; + + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "nemoclaw-policy-add-btn"; + addBtn.innerHTML = `${ICON_PLUS} Add Network Policy ${ICON_CHEVRON_DOWN}`; + + let dropdownOpen = false; + let dropdownEl: HTMLElement | null = null; + + function closeDropdown() { + dropdownOpen = false; + dropdownEl?.remove(); + dropdownEl = null; + } + + addBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (dropdownOpen) { + closeDropdown(); + return; + } + dropdownOpen = true; + dropdownEl = document.createElement("div"); + dropdownEl.className = "nemoclaw-policy-templates"; + + for (const tmpl of POLICY_TEMPLATES) { + const opt = document.createElement("button"); + opt.type = "button"; + opt.className = "nemoclaw-policy-template-option"; + opt.textContent = tmpl.label; + opt.addEventListener("click", (ev) => { + ev.stopPropagation(); + closeDropdown(); + showInlineNewPolicyForm(list, tmpl); + }); + dropdownEl.appendChild(opt); + } + + const customOpt = document.createElement("button"); + customOpt.type = "button"; + customOpt.className = "nemoclaw-policy-template-option nemoclaw-policy-template-option--custom"; + customOpt.textContent = "Custom (blank)"; + customOpt.addEventListener("click", (ev) => { + ev.stopPropagation(); + closeDropdown(); + showInlineNewPolicyForm(list); + }); + dropdownEl.appendChild(customOpt); + + addWrap.appendChild(dropdownEl); + }); + + document.addEventListener("click", () => { if (dropdownOpen) closeDropdown(); }); + + addWrap.appendChild(addBtn); + section.appendChild(addWrap); + + return section; +} + +function buildNetworkPolicyCard(key: string, policy: NetworkPolicy, list: HTMLElement): HTMLElement { + const card = document.createElement("div"); + card.className = "nemoclaw-policy-netcard"; + card.dataset.policyKey = key; + + const header = document.createElement("div"); + header.className = "nemoclaw-policy-netcard__header"; + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "nemoclaw-policy-netcard__toggle"; + toggle.innerHTML = `${ICON_CHEVRON_RIGHT} + ${escapeHtml(policy.name || key)} + ${policy.endpoints?.length || 0} endpoint${(policy.endpoints?.length || 0) !== 1 ? "s" : ""}, ${policy.binaries?.length || 0} binar${(policy.binaries?.length || 0) !== 1 ? "ies" : "y"}`; + + const actions = document.createElement("div"); + actions.className = "nemoclaw-policy-netcard__actions"; + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger"; + deleteBtn.title = "Delete policy"; + deleteBtn.innerHTML = ICON_TRASH; + deleteBtn.addEventListener("click", (e) => { + e.stopPropagation(); + showDeleteConfirmation(actions, deleteBtn, key, card); + }); + actions.appendChild(deleteBtn); + + header.appendChild(toggle); + header.appendChild(actions); + + // Host preview chips (visible when collapsed) + const preview = document.createElement("div"); + preview.className = "nemoclaw-policy-netcard__preview"; + const hosts = (policy.endpoints || []).map((ep) => ep.host).filter(Boolean) as string[]; + const maxChips = 3; + for (let i = 0; i < Math.min(hosts.length, maxChips); i++) { + const chip = document.createElement("code"); + chip.className = "nemoclaw-policy-host-chip"; + chip.textContent = hosts[i]; + preview.appendChild(chip); + } + if (hosts.length > maxChips) { + const more = document.createElement("span"); + more.className = "nemoclaw-policy-host-chip nemoclaw-policy-host-chip--more"; + more.textContent = `+${hosts.length - maxChips} more`; + preview.appendChild(more); + } + + const body = document.createElement("div"); + body.className = "nemoclaw-policy-netcard__body"; + body.style.display = "none"; + renderNetworkPolicyBody(body, key, policy); + + let expanded = false; + toggle.addEventListener("click", () => { + expanded = !expanded; + body.style.display = expanded ? "" : "none"; + card.classList.toggle("nemoclaw-policy-netcard--expanded", expanded); + }); + + card.appendChild(header); + card.appendChild(preview); + card.appendChild(body); + return card; +} + +// --------------------------------------------------------------------------- +// Delete confirmation +// --------------------------------------------------------------------------- + +function showDeleteConfirmation(actions: HTMLElement, deleteBtn: HTMLElement, key: string, card: HTMLElement): void { + deleteBtn.style.display = "none"; + + const confirmWrap = document.createElement("div"); + confirmWrap.className = "nemoclaw-policy-confirm-actions"; + + const confirmBtn = document.createElement("button"); + confirmBtn.type = "button"; + confirmBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--delete"; + confirmBtn.textContent = "Delete"; + + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--cancel"; + cancelBtn.textContent = "Cancel"; + + confirmWrap.appendChild(confirmBtn); + confirmWrap.appendChild(cancelBtn); + actions.appendChild(confirmWrap); + card.classList.add("nemoclaw-policy-netcard--confirming"); + + const revert = () => { + confirmWrap.remove(); + deleteBtn.style.display = ""; + card.classList.remove("nemoclaw-policy-netcard--confirming"); + }; + + const timeout = setTimeout(revert, 3000); + + cancelBtn.addEventListener("click", (e) => { + e.stopPropagation(); + clearTimeout(timeout); + revert(); + }); + + confirmBtn.addEventListener("click", (e) => { + e.stopPropagation(); + clearTimeout(timeout); + if (currentPolicy?.network_policies) { + delete currentPolicy.network_policies[key]; + markDirty(key, "deleted"); + card.remove(); + updateNetworkCount(); + } + }); +} + +// --------------------------------------------------------------------------- +// Inline new-policy form (replaces prompt/alert) +// --------------------------------------------------------------------------- + +function showInlineNewPolicyForm(list: HTMLElement, template?: { key: string; label: string; policy: NetworkPolicy }): void { + const existing = list.querySelector(".nemoclaw-policy-newcard"); + if (existing) existing.remove(); + + const form = document.createElement("div"); + form.className = "nemoclaw-policy-newcard"; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "nemoclaw-policy-input"; + input.placeholder = "e.g. my_custom_api"; + input.value = template ? template.key : ""; + + const createBtn = document.createElement("button"); + createBtn.type = "button"; + createBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--create"; + createBtn.textContent = "Create"; + + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--cancel"; + cancelBtn.textContent = "Cancel"; + + const hint = document.createElement("div"); + hint.className = "nemoclaw-policy-newcard__hint"; + hint.textContent = "Use snake_case. Only letters, numbers, _ and - allowed."; + + const error = document.createElement("div"); + error.className = "nemoclaw-policy-newcard__error"; + + form.appendChild(input); + form.appendChild(createBtn); + form.appendChild(cancelBtn); + form.appendChild(hint); + form.appendChild(error); + list.prepend(form); + + requestAnimationFrame(() => input.focus()); + + const cancel = () => form.remove(); + + cancelBtn.addEventListener("click", cancel); + input.addEventListener("keydown", (e) => { + if (e.key === "Escape") cancel(); + if (e.key === "Enter") doCreate(); + }); + + function doCreate() { + const raw = input.value.trim(); + if (!raw) { + error.textContent = "Name is required."; + return; + } + const key = raw.replace(/[^a-zA-Z0-9_-]/g, "_"); + if (!currentPolicy) return; + if (!currentPolicy.network_policies) currentPolicy.network_policies = {}; + if (currentPolicy.network_policies[key]) { + error.textContent = `A policy named "${key}" already exists.`; + input.classList.add("nemoclaw-policy-input--error"); + return; + } + + const newPolicy: NetworkPolicy = template + ? JSON.parse(JSON.stringify(template.policy)) + : { name: key, endpoints: [{ host: "", port: 443 }], binaries: [{ path: "" }] }; + newPolicy.name = key; + + currentPolicy.network_policies[key] = newPolicy; + markDirty(key, "added"); + + form.remove(); + + const card = buildNetworkPolicyCard(key, newPolicy, list); + card.classList.add("nemoclaw-policy-netcard--expanded"); + const cardBody = card.querySelector(".nemoclaw-policy-netcard__body"); + if (cardBody) cardBody.style.display = ""; + const cardPreview = card.querySelector(".nemoclaw-policy-netcard__preview"); + if (cardPreview) cardPreview.style.display = "none"; + list.appendChild(card); + updateNetworkCount(); + } + + createBtn.addEventListener("click", doCreate); +} + +// --------------------------------------------------------------------------- +// Network policy body +// --------------------------------------------------------------------------- + +function renderNetworkPolicyBody(body: HTMLElement, key: string, policy: NetworkPolicy): void { + body.innerHTML = ""; + + // Endpoints section + const epSection = document.createElement("div"); + epSection.className = "nemoclaw-policy-subsection"; + epSection.innerHTML = `
+ Endpoints + ${ICON_INFO} +
`; + + const epList = document.createElement("div"); + epList.className = "nemoclaw-policy-ep-list"; + + (policy.endpoints || []).forEach((ep, idx) => { + epList.appendChild(buildEndpointRow(key, ep, idx)); + }); + epSection.appendChild(epList); + + const addEpBtn = document.createElement("button"); + addEpBtn.type = "button"; + addEpBtn.className = "nemoclaw-policy-add-small-btn"; + addEpBtn.innerHTML = `${ICON_PLUS} Add Endpoint`; + addEpBtn.addEventListener("click", () => { + const newEp: PolicyEndpoint = { host: "", port: 443 }; + policy.endpoints = policy.endpoints || []; + policy.endpoints.push(newEp); + markDirty(key, "modified"); + epList.appendChild(buildEndpointRow(key, newEp, policy.endpoints.length - 1)); + }); + epSection.appendChild(addEpBtn); + body.appendChild(epSection); + + // Binaries section + const binSection = document.createElement("div"); + binSection.className = "nemoclaw-policy-subsection"; + binSection.innerHTML = `
+ Allowed Binaries + ${ICON_INFO} +
`; + + const binList = document.createElement("div"); + binList.className = "nemoclaw-policy-bin-list"; + + (policy.binaries || []).forEach((bin, idx) => { + binList.appendChild(buildBinaryRow(key, policy, bin, idx)); + }); + binSection.appendChild(binList); + + const addBinBtn = document.createElement("button"); + addBinBtn.type = "button"; + addBinBtn.className = "nemoclaw-policy-add-small-btn"; + addBinBtn.innerHTML = `${ICON_PLUS} Add Binary`; + addBinBtn.addEventListener("click", () => { + const newBin: PolicyBinary = { path: "" }; + policy.binaries = policy.binaries || []; + policy.binaries.push(newBin); + markDirty(key, "modified"); + binList.appendChild(buildBinaryRow(key, policy, newBin, policy.binaries.length - 1)); + }); + binSection.appendChild(addBinBtn); + body.appendChild(binSection); +} + +// --------------------------------------------------------------------------- +// Endpoint row +// --------------------------------------------------------------------------- + +function buildEndpointRow(policyKey: string, ep: PolicyEndpoint, idx: number): HTMLElement { + const row = document.createElement("div"); + row.className = "nemoclaw-policy-ep-row"; + + const mainLine = document.createElement("div"); + mainLine.className = "nemoclaw-policy-ep-row__main"; + + const hostInput = createInput("Host", ep.host || "", (v) => { ep.host = v || undefined; markDirty(policyKey, "modified"); }, "Domain or IP. Supports wildcards like *.example.com"); + hostInput.className += " nemoclaw-policy-input--host"; + + const portInput = createInput("Port", String(ep.port || ""), (v) => { ep.port = parseInt(v, 10) || 0; markDirty(policyKey, "modified"); }, "TCP port (e.g. 443 for HTTPS)"); + portInput.className += " nemoclaw-policy-input--port"; + + mainLine.appendChild(hostInput); + mainLine.appendChild(portInput); + + const optsLine = document.createElement("div"); + optsLine.className = "nemoclaw-policy-ep-row__opts"; + + const protoSelect = createSelect("Protocol", [ + { value: "", label: "(none)" }, + { value: "rest", label: "REST (L7 inspection)" }, + ], ep.protocol || "", (v) => { ep.protocol = v || undefined; markDirty(policyKey, "modified"); }, "REST enables HTTP method/path inspection"); + + const tlsSelect = createSelect("TLS", [ + { value: "", label: "(none)" }, + { value: "terminate", label: "Terminate (inspect)" }, + { value: "passthrough", label: "Passthrough (encrypted)" }, + ], ep.tls || "", (v) => { ep.tls = v || undefined; markDirty(policyKey, "modified"); }, "Terminate: proxy decrypts for inspection. Passthrough: end-to-end encrypted"); + + const enfSelect = createSelect("Enforcement", [ + { value: "", label: "(none)" }, + { value: "enforce", label: "Enforce (block)" }, + { value: "audit", label: "Audit (log only)" }, + ], ep.enforcement || "", (v) => { ep.enforcement = v || undefined; markDirty(policyKey, "modified"); }, "Enforce: block violations. Audit: log only"); + + const accessSelect = createSelect("Access", [ + { value: "", label: "(none)" }, + { value: "read-only", label: "Read-only" }, + { value: "read-write", label: "Read-write" }, + { value: "full", label: "Full access" }, + ], ep.access || "", (v) => { ep.access = v || undefined; markDirty(policyKey, "modified"); }, "Scope of allowed operations on this endpoint"); + + optsLine.appendChild(protoSelect); + optsLine.appendChild(tlsSelect); + optsLine.appendChild(enfSelect); + optsLine.appendChild(accessSelect); + + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger nemoclaw-policy-ep-row__del"; + delBtn.title = "Remove endpoint"; + delBtn.innerHTML = ICON_TRASH; + delBtn.addEventListener("click", () => { + const policy = currentPolicy?.network_policies?.[policyKey]; + if (policy?.endpoints) { + policy.endpoints.splice(idx, 1); + markDirty(policyKey, "modified"); + row.remove(); + } + }); + mainLine.appendChild(delBtn); + + row.appendChild(mainLine); + row.appendChild(optsLine); + + // L7 Rules — editable rows + if (ep.rules?.length || ep.protocol === "rest") { + row.appendChild(buildL7RulesEditor(policyKey, ep)); + } + + // Allowed IPs — editable rows + if (ep.allowed_ips?.length) { + row.appendChild(buildAllowedIpsEditor(policyKey, ep)); + } + + return row; +} + +// --------------------------------------------------------------------------- +// L7 Rules editor (replaces YAML preview) +// --------------------------------------------------------------------------- + +function buildL7RulesEditor(policyKey: string, ep: PolicyEndpoint): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "nemoclaw-policy-ep-rules"; + + const header = document.createElement("div"); + header.className = "nemoclaw-policy-subsection__header"; + header.innerHTML = ` + L7 Rules (${ep.rules?.length || 0}) + ${ICON_INFO}`; + wrapper.appendChild(header); + + const ruleList = document.createElement("div"); + ruleList.className = "nemoclaw-policy-rule-list"; + + (ep.rules || []).forEach((rule, idx) => { + ruleList.appendChild(buildL7RuleRow(policyKey, ep, rule, idx, ruleList)); + }); + wrapper.appendChild(ruleList); + + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "nemoclaw-policy-add-small-btn"; + addBtn.innerHTML = `${ICON_PLUS} Add Rule`; + addBtn.addEventListener("click", () => { + if (!ep.rules) ep.rules = []; + const newRule = { allow: { method: "GET", path: "" } }; + ep.rules.push(newRule); + markDirty(policyKey, "modified"); + ruleList.appendChild(buildL7RuleRow(policyKey, ep, newRule, ep.rules.length - 1, ruleList)); + }); + wrapper.appendChild(addBtn); + + return wrapper; +} + +function buildL7RuleRow(policyKey: string, ep: PolicyEndpoint, rule: { allow: { method: string; path: string } }, idx: number, ruleList: HTMLElement): HTMLElement { + const row = document.createElement("div"); + row.className = "nemoclaw-policy-rule-row"; + + const methodSelect = document.createElement("select"); + methodSelect.className = "nemoclaw-policy-select nemoclaw-policy-rule-method"; + for (const m of ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "*"]) { + const o = document.createElement("option"); + o.value = m; + o.textContent = m; + if (m === rule.allow.method) o.selected = true; + methodSelect.appendChild(o); + } + methodSelect.addEventListener("change", () => { rule.allow.method = methodSelect.value; markDirty(policyKey, "modified"); }); + + const pathInput = document.createElement("input"); + pathInput.type = "text"; + pathInput.className = "nemoclaw-policy-input nemoclaw-policy-rule-path"; + pathInput.placeholder = "/**/path"; + pathInput.value = rule.allow.path; + pathInput.addEventListener("input", () => { rule.allow.path = pathInput.value; markDirty(policyKey, "modified"); }); + + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger"; + delBtn.title = "Remove rule"; + delBtn.innerHTML = ICON_TRASH; + delBtn.addEventListener("click", () => { + if (ep.rules) { + ep.rules.splice(idx, 1); + markDirty(policyKey, "modified"); + row.remove(); + } + }); + + row.appendChild(methodSelect); + row.appendChild(pathInput); + row.appendChild(delBtn); + return row; +} + +// --------------------------------------------------------------------------- +// Allowed IPs editor +// --------------------------------------------------------------------------- + +function buildAllowedIpsEditor(policyKey: string, ep: PolicyEndpoint): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "nemoclaw-policy-ep-rules"; + + const header = document.createElement("div"); + header.className = "nemoclaw-policy-subsection__header"; + header.innerHTML = ` + Allowed IPs + ${ICON_INFO}`; + wrapper.appendChild(header); + + const ipList = document.createElement("div"); + ipList.className = "nemoclaw-policy-bin-list"; + + (ep.allowed_ips || []).forEach((ip, idx) => { + ipList.appendChild(buildIpRow(policyKey, ep, ip, idx)); + }); + wrapper.appendChild(ipList); + + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "nemoclaw-policy-add-small-btn"; + addBtn.innerHTML = `${ICON_PLUS} Add IP`; + addBtn.addEventListener("click", () => { + if (!ep.allowed_ips) ep.allowed_ips = []; + ep.allowed_ips.push(""); + markDirty(policyKey, "modified"); + ipList.appendChild(buildIpRow(policyKey, ep, "", ep.allowed_ips.length - 1)); + }); + wrapper.appendChild(addBtn); + + return wrapper; +} + +function buildIpRow(policyKey: string, ep: PolicyEndpoint, ip: string, idx: number): HTMLElement { + const row = document.createElement("div"); + row.className = "nemoclaw-policy-ip-row"; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "nemoclaw-policy-input"; + input.placeholder = "10.0.0.0/8"; + input.value = ip; + input.addEventListener("input", () => { + if (ep.allowed_ips) { + ep.allowed_ips[idx] = input.value; + markDirty(policyKey, "modified"); + } + }); + + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger"; + delBtn.title = "Remove IP"; + delBtn.innerHTML = ICON_TRASH; + delBtn.addEventListener("click", () => { + if (ep.allowed_ips) { + ep.allowed_ips.splice(idx, 1); + markDirty(policyKey, "modified"); + row.remove(); + } + }); + + row.appendChild(input); + row.appendChild(delBtn); + return row; +} + +// --------------------------------------------------------------------------- +// Binary row +// --------------------------------------------------------------------------- + +function buildBinaryRow(policyKey: string, policy: NetworkPolicy, bin: PolicyBinary, idx: number): HTMLElement { + const row = document.createElement("div"); + row.className = "nemoclaw-policy-bin-row"; + + const icon = document.createElement("span"); + icon.className = "nemoclaw-policy-bin-row__icon"; + icon.innerHTML = ICON_TERMINAL; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "nemoclaw-policy-input"; + input.placeholder = "/usr/bin/example"; + input.value = bin.path; + input.addEventListener("input", () => { bin.path = input.value; markDirty(policyKey, "modified"); }); + + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger"; + delBtn.title = "Remove binary"; + delBtn.innerHTML = ICON_TRASH; + delBtn.addEventListener("click", () => { + policy.binaries.splice(idx, 1); + markDirty(policyKey, "modified"); + row.remove(); + }); + + row.appendChild(icon); + row.appendChild(input); + row.appendChild(delBtn); + return row; +} + +// --------------------------------------------------------------------------- +// Save bar (conditional visibility) +// --------------------------------------------------------------------------- + +function buildSaveBar(): HTMLElement { + const bar = document.createElement("div"); + bar.className = "nemoclaw-policy-savebar nemoclaw-policy-savebar--hidden"; + + const info = document.createElement("div"); + info.className = "nemoclaw-policy-savebar__info"; + info.innerHTML = ` + ${ICON_INFO} + Unsaved changes`; + + const actions = document.createElement("div"); + actions.className = "nemoclaw-policy-savebar__actions"; + + const feedback = document.createElement("div"); + feedback.className = "nemoclaw-policy-savebar__feedback"; + feedback.setAttribute("role", "status"); + + const discardBtn = document.createElement("button"); + discardBtn.type = "button"; + discardBtn.className = "nemoclaw-policy-discard-btn"; + discardBtn.textContent = "Discard"; + discardBtn.addEventListener("click", () => handleDiscard(bar)); + + const saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.className = "nemoclaw-policy-save-btn"; + saveBtn.textContent = "Save Policy"; + saveBtn.addEventListener("click", () => handleSave(saveBtn, feedback, bar)); + + actions.appendChild(feedback); + actions.appendChild(discardBtn); + actions.appendChild(saveBtn); + + bar.appendChild(info); + bar.appendChild(actions); + return bar; +} + +function updateSaveBarSummary(): void { + if (!saveBarEl) return; + const summaryEl = saveBarEl.querySelector(".nemoclaw-policy-savebar__summary"); + if (!summaryEl) return; + + const parts: string[] = []; + if (changeTracker.modified.size > 0) parts.push(`${changeTracker.modified.size} modified`); + if (changeTracker.added.size > 0) parts.push(`${changeTracker.added.size} added`); + if (changeTracker.deleted.size > 0) parts.push(`${changeTracker.deleted.size} deleted`); + + summaryEl.textContent = parts.length > 0 ? `Unsaved: ${parts.join(", ")}` : "Unsaved changes"; +} + +function handleDiscard(bar: HTMLElement): void { + if (!pageContainer) return; + bar.classList.remove("nemoclaw-policy-savebar--visible"); + bar.classList.add("nemoclaw-policy-savebar--hidden"); + loadAndRender(pageContainer); +} + +async function handleSave(btn: HTMLButtonElement, feedback: HTMLElement, bar: HTMLElement): Promise { + if (!currentPolicy) return; + + btn.disabled = true; + feedback.className = "nemoclaw-policy-savebar__feedback nemoclaw-policy-savebar__feedback--saving"; + feedback.innerHTML = `${ICON_LOADER} Saving…`; + + try { + const yamlText = yaml.dump(currentPolicy, { + lineWidth: -1, + noRefs: true, + quotingType: '"', + forceQuotes: false, + }); + + await savePolicy(yamlText); + + rawYaml = yamlText; + isDirty = false; + changeTracker.modified.clear(); + changeTracker.added.clear(); + changeTracker.deleted.clear(); + + feedback.className = "nemoclaw-policy-savebar__feedback nemoclaw-policy-savebar__feedback--success"; + feedback.innerHTML = `${ICON_CHECK} Policy saved`; + setTimeout(() => { + feedback.className = "nemoclaw-policy-savebar__feedback"; + feedback.textContent = ""; + bar.classList.remove("nemoclaw-policy-savebar--visible"); + bar.classList.add("nemoclaw-policy-savebar--hidden"); + }, 3000); + } catch (err) { + feedback.className = "nemoclaw-policy-savebar__feedback nemoclaw-policy-savebar__feedback--error"; + feedback.innerHTML = `${ICON_CLOSE} ${escapeHtml(String(err))}`; + } finally { + btn.disabled = false; + } +} + +// --------------------------------------------------------------------------- +// Shared UI helpers +// --------------------------------------------------------------------------- + +function createInput(label: string, value: string, onChange: (v: string) => void, tooltip?: string): HTMLElement { + const wrapper = document.createElement("label"); + wrapper.className = "nemoclaw-policy-field"; + let labelHtml = `${label}`; + if (tooltip) { + labelHtml += ` ${ICON_INFO}`; + } + labelHtml += ``; + wrapper.innerHTML = labelHtml; + const input = document.createElement("input"); + input.type = "text"; + input.className = "nemoclaw-policy-input"; + input.value = value; + input.placeholder = label; + input.addEventListener("input", () => onChange(input.value)); + wrapper.appendChild(input); + return wrapper; +} + +function createSelect(label: string, options: SelectOption[], value: string, onChange: (v: string) => void, tooltip?: string): HTMLElement { + const wrapper = document.createElement("label"); + wrapper.className = "nemoclaw-policy-field"; + let labelHtml = `${label}`; + if (tooltip) { + labelHtml += ` ${ICON_INFO}`; + } + labelHtml += ``; + wrapper.innerHTML = labelHtml; + const select = document.createElement("select"); + select.className = "nemoclaw-policy-select"; + for (const opt of options) { + const o = document.createElement("option"); + o.value = opt.value; + o.textContent = opt.label; + if (opt.value === value) o.selected = true; + select.appendChild(o); + } + select.addEventListener("change", () => onChange(select.value)); + wrapper.appendChild(select); + return wrapper; +} + +function markDirty(policyKey?: string, changeType?: "modified" | "added" | "deleted"): void { + isDirty = true; + if (policyKey && changeType) { + if (changeType === "deleted") { + changeTracker.added.delete(policyKey); + changeTracker.modified.delete(policyKey); + changeTracker.deleted.add(policyKey); + } else if (changeType === "added") { + changeTracker.added.add(policyKey); + } else { + if (!changeTracker.added.has(policyKey)) { + changeTracker.modified.add(policyKey); + } + } + } + if (saveBarEl) { + saveBarEl.classList.remove("nemoclaw-policy-savebar--hidden"); + saveBarEl.classList.add("nemoclaw-policy-savebar--visible"); + updateSaveBarSummary(); + } +} + +function updateNetworkCount(): void { + const countEl = document.querySelector(".nemoclaw-policy-section__count"); + if (countEl && currentPolicy?.network_policies) { + countEl.textContent = String(Object.keys(currentPolicy.network_policies).length); + } +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css index 415a7da..911dd84 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css @@ -1020,3 +1020,1496 @@ body.nemoclaw-switching openclaw-app { width: 100%; } } + +/* =========================================== + Policy Page + =========================================== */ + +.nemoclaw-policy-page { + max-width: 840px; + margin: 0 auto; + padding: 8px 0 100px; + animation: nemoclaw-fade-in 250ms ease; +} + +/* Loading / error */ + +.nemoclaw-policy-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 80px 24px; + color: var(--muted, #71717a); + font-size: 14px; +} + +.nemoclaw-policy-loading__spinner { + display: flex; + width: 18px; + height: 18px; + color: #76B900; +} + +.nemoclaw-policy-loading__spinner svg { + width: 18px; + height: 18px; + animation: nemoclaw-spin 1s linear infinite; +} + +.nemoclaw-policy-error { + text-align: center; + padding: 60px 24px; + color: var(--muted, #71717a); + font-size: 14px; +} + +.nemoclaw-policy-error__detail { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + font-size: 12px; + color: var(--danger, #ef4444); + margin: 8px 0 16px; +} + +.nemoclaw-policy-retry-btn { + padding: 8px 20px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--bg-elevated, #1a1d25); + color: var(--text, #e4e4e7); + font-size: 13px; + cursor: pointer; + transition: border-color 150ms ease, background 150ms ease; +} + +.nemoclaw-policy-retry-btn:hover { + border-color: #76B900; + background: rgba(118, 185, 0, 0.06); +} + +/* Intro */ + +.nemoclaw-policy-intro { + display: flex; + gap: 16px; + align-items: flex-start; + padding: 20px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-lg, 12px); + background: var(--bg-elevated, #1a1d25); + margin-bottom: 28px; +} + +:root[data-theme="light"] .nemoclaw-policy-intro { + background: #fff; +} + +.nemoclaw-policy-intro__icon { + width: 44px; + height: 44px; + flex-shrink: 0; + border-radius: var(--radius-md, 8px); + background: rgba(118, 185, 0, 0.10); + display: grid; + place-items: center; + color: #76B900; +} + +.nemoclaw-policy-intro__icon svg { + width: 22px; + height: 22px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-intro__title { + font-size: 15px; + font-weight: 700; + color: var(--text-strong, #fafafa); + margin: 0 0 6px; +} + +.nemoclaw-policy-intro__text { + font-size: 13px; + line-height: 1.6; + color: var(--muted, #71717a); + margin: 0 0 10px; +} + +.nemoclaw-policy-intro__badges { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +/* Badges */ + +.nemoclaw-policy-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: var(--radius-full, 9999px); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.nemoclaw-policy-badge svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-badge--locked { + border: 1px solid rgba(161, 161, 170, 0.25); + background: rgba(161, 161, 170, 0.08); + color: var(--muted, #a1a1aa); +} + +.nemoclaw-policy-badge--editable { + border: 1px solid rgba(118, 185, 0, 0.3); + background: rgba(118, 185, 0, 0.08); + color: #76B900; +} + +/* Section */ + +.nemoclaw-policy-section { + margin-bottom: 28px; +} + +.nemoclaw-policy-section__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.nemoclaw-policy-section__icon { + display: flex; + width: 20px; + height: 20px; + color: var(--muted, #71717a); +} + +.nemoclaw-policy-section__icon svg { + width: 20px; + height: 20px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-section__title { + font-size: 16px; + font-weight: 700; + color: var(--text-strong, #fafafa); + margin: 0; +} + +.nemoclaw-policy-section__desc { + font-size: 13px; + line-height: 1.55; + color: var(--muted, #71717a); + margin: 0 0 16px; +} + +.nemoclaw-policy-section__desc code { + font-size: 12px; + padding: 1px 5px; + border-radius: 4px; + background: rgba(118, 185, 0, 0.08); + color: #76B900; +} + +/* Immutable cards */ + +.nemoclaw-policy-immutable-cards { + display: grid; + gap: 12px; +} + +.nemoclaw-policy-card { + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + padding: 16px; + background: var(--bg-elevated, #1a1d25); +} + +:root[data-theme="light"] .nemoclaw-policy-card { + background: #fff; +} + +.nemoclaw-policy-card--locked { + opacity: 0.85; +} + +.nemoclaw-policy-card__header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.nemoclaw-policy-card__icon { + display: flex; + width: 18px; + height: 18px; + color: var(--muted, #71717a); +} + +.nemoclaw-policy-card__icon svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-card__title { + font-size: 14px; + font-weight: 600; + color: var(--text-strong, #fafafa); + flex: 1; +} + +.nemoclaw-policy-card__lock { + display: flex; + width: 14px; + height: 14px; + color: var(--muted, #71717a); + opacity: 0.5; +} + +.nemoclaw-policy-card__lock svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-card__desc { + font-size: 12px; + line-height: 1.5; + color: var(--muted, #71717a); + margin: 0 0 12px; +} + +.nemoclaw-policy-card__content { + border-top: 1px solid var(--border, #27272a); + padding-top: 12px; +} + +/* Property rows inside cards */ + +.nemoclaw-policy-prop { + display: flex; + align-items: baseline; + gap: 8px; + padding: 3px 0; +} + +.nemoclaw-policy-prop__label { + font-size: 12px; + font-weight: 600; + color: var(--muted, #a1a1aa); + white-space: nowrap; +} + +.nemoclaw-policy-prop__value { + font-size: 13px; + color: var(--text, #e4e4e7); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; +} + +.nemoclaw-policy-muted { + font-size: 12px; + color: var(--muted, #71717a); + font-style: italic; +} + +/* Path list (read-only / read-write) */ + +.nemoclaw-policy-pathlist { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 4px 0; +} + +.nemoclaw-policy-path { + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + padding: 3px 8px; + border-radius: 4px; + background: rgba(161, 161, 170, 0.08); + color: var(--text, #e4e4e7); + border: 1px solid var(--border, #27272a); +} + +.nemoclaw-policy-path--rw { + background: rgba(118, 185, 0, 0.06); + border-color: rgba(118, 185, 0, 0.2); + color: #76B900; +} + +/* Network policy cards */ + +.nemoclaw-policy-netpolicies { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +.nemoclaw-policy-netcard { + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--bg-elevated, #1a1d25); + overflow: hidden; + transition: border-color 150ms ease; +} + +:root[data-theme="light"] .nemoclaw-policy-netcard { + background: #fff; +} + +.nemoclaw-policy-netcard--expanded { + border-color: rgba(118, 185, 0, 0.3); +} + +.nemoclaw-policy-netcard__header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; +} + +.nemoclaw-policy-netcard__toggle { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + font: inherit; + text-align: left; +} + +.nemoclaw-policy-netcard__chevron { + display: flex; + width: 16px; + height: 16px; + color: var(--muted, #71717a); + transition: transform 200ms ease; +} + +.nemoclaw-policy-netcard__chevron svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-netcard--expanded .nemoclaw-policy-netcard__chevron { + transform: rotate(90deg); + color: #76B900; +} + +.nemoclaw-policy-netcard__name { + font-size: 14px; + font-weight: 600; + color: var(--text-strong, #fafafa); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; +} + +.nemoclaw-policy-netcard__summary { + font-size: 12px; + color: var(--muted, #71717a); + margin-left: auto; + white-space: nowrap; +} + +.nemoclaw-policy-netcard__actions { + display: flex; + gap: 4px; +} + +.nemoclaw-policy-netcard__body { + border-top: 1px solid var(--border, #27272a); + padding: 14px; +} + +/* Icon buttons (shared) */ + +.nemoclaw-policy-icon-btn { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: var(--radius-sm, 6px); + background: transparent; + color: var(--muted, #71717a); + cursor: pointer; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.nemoclaw-policy-icon-btn:hover { + background: var(--bg-hover, #262a35); + color: var(--text, #e4e4e7); +} + +.nemoclaw-policy-icon-btn--danger:hover { + background: rgba(239, 68, 68, 0.1); + color: var(--danger, #ef4444); + border-color: rgba(239, 68, 68, 0.2); +} + +.nemoclaw-policy-icon-btn svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Subsections inside network policy body */ + +.nemoclaw-policy-subsection { + margin-bottom: 16px; +} + +.nemoclaw-policy-subsection:last-child { + margin-bottom: 0; +} + +.nemoclaw-policy-subsection__header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.nemoclaw-policy-subsection__title { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted, #a1a1aa); +} + +.nemoclaw-policy-info-tip { + display: inline-flex; + width: 14px; + height: 14px; + color: var(--muted, #71717a); + cursor: help; +} + +.nemoclaw-policy-info-tip svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Endpoint rows */ + +.nemoclaw-policy-ep-list { + display: grid; + gap: 10px; +} + +.nemoclaw-policy-ep-row { + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-sm, 6px); + padding: 10px; + background: rgba(0, 0, 0, 0.12); +} + +:root[data-theme="light"] .nemoclaw-policy-ep-row { + background: rgba(0, 0, 0, 0.02); +} + +.nemoclaw-policy-ep-row__main { + display: flex; + gap: 8px; + align-items: flex-end; + margin-bottom: 8px; +} + +.nemoclaw-policy-ep-row__opts { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.nemoclaw-policy-ep-row__del { + flex-shrink: 0; + align-self: flex-end; +} + +/* YAML preview for L7 rules */ + +.nemoclaw-policy-yaml-preview { + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + line-height: 1.5; + padding: 8px 10px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.2); + color: var(--text, #e4e4e7); + overflow-x: auto; + margin: 4px 0 0; + border: 1px solid var(--border, #27272a); + white-space: pre; +} + +:root[data-theme="light"] .nemoclaw-policy-yaml-preview { + background: rgba(0, 0, 0, 0.04); +} + +.nemoclaw-policy-ep-rules { + margin-top: 6px; +} + +/* Binary rows */ + +.nemoclaw-policy-bin-list { + display: grid; + gap: 6px; +} + +.nemoclaw-policy-bin-row { + display: flex; + align-items: center; + gap: 8px; +} + +.nemoclaw-policy-bin-row__icon { + display: flex; + width: 16px; + height: 16px; + color: var(--muted, #71717a); + flex-shrink: 0; +} + +.nemoclaw-policy-bin-row__icon svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Shared inputs and selects */ + +.nemoclaw-policy-field { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.nemoclaw-policy-field__label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted, #71717a); +} + +.nemoclaw-policy-input, +.nemoclaw-policy-select { + padding: 7px 10px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-sm, 6px); + background: var(--bg-elevated, #1a1d25); + color: var(--text, #e4e4e7); + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + outline: none; + transition: border-color 150ms ease, box-shadow 150ms ease; + min-width: 0; +} + +:root[data-theme="light"] .nemoclaw-policy-input, +:root[data-theme="light"] .nemoclaw-policy-select { + background: #fff; +} + +.nemoclaw-policy-input:focus, +.nemoclaw-policy-select:focus { + border-color: #76B900; + box-shadow: 0 0 0 2px rgba(118, 185, 0, 0.15); +} + +.nemoclaw-policy-input::placeholder { + color: var(--muted, #71717a); + opacity: 0.5; +} + +.nemoclaw-policy-input--host { + flex: 1; +} + +.nemoclaw-policy-input--port { + width: 80px; +} + +.nemoclaw-policy-bin-row .nemoclaw-policy-input { + flex: 1; +} + +/* Add buttons */ + +.nemoclaw-policy-add-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: 1px dashed var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: transparent; + color: var(--muted, #71717a); + font-size: 13px; + font-weight: 500; + cursor: pointer; + position: relative; + transition: border-color 150ms ease, color 150ms ease, background 150ms ease; +} + +.nemoclaw-policy-add-btn:hover { + border-color: #76B900; + color: #76B900; + background: rgba(118, 185, 0, 0.04); +} + +.nemoclaw-policy-add-btn svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-add-small-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 12px; + margin-top: 8px; + border: 1px dashed var(--border, #27272a); + border-radius: var(--radius-sm, 6px); + background: transparent; + color: var(--muted, #71717a); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: border-color 150ms ease, color 150ms ease; +} + +.nemoclaw-policy-add-small-btn:hover { + border-color: #76B900; + color: #76B900; +} + +.nemoclaw-policy-add-small-btn svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Save bar */ + +.nemoclaw-policy-savebar { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 18px; + margin-top: 24px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-lg, 12px); + background: var(--card, #181b22); + box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.25); +} + +:root[data-theme="light"] .nemoclaw-policy-savebar { + background: #fff; + box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08); +} + +.nemoclaw-policy-savebar__info { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 11px; + line-height: 1.55; + color: var(--muted, #71717a); + flex: 1; + min-width: 0; +} + +.nemoclaw-policy-savebar__info code { + font-size: 10px; + padding: 1px 4px; + border-radius: 3px; + background: rgba(118, 185, 0, 0.08); + color: #76B900; +} + +.nemoclaw-policy-savebar__info-icon { + display: flex; + flex-shrink: 0; + width: 14px; + height: 14px; + margin-top: 1px; + color: var(--muted, #71717a); +} + +.nemoclaw-policy-savebar__info-icon svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-savebar__actions { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.nemoclaw-policy-save-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 22px; + border: 1px solid #76B900; + border-radius: var(--radius-md, 8px); + background: #76B900; + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 180ms ease, border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.nemoclaw-policy-save-btn:hover { + background: #6aa300; + border-color: #6aa300; + box-shadow: 0 4px 12px rgba(118, 185, 0, 0.35); + transform: translateY(-1px); +} + +.nemoclaw-policy-save-btn:active { + background: #5a8500; + border-color: #5a8500; + transform: translateY(0); +} + +.nemoclaw-policy-save-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.nemoclaw-policy-save-btn:focus-visible { + outline: 2px solid #76B900; + outline-offset: 2px; +} + +/* Save feedback */ + +.nemoclaw-policy-savebar__feedback { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + min-height: 18px; + white-space: nowrap; +} + +.nemoclaw-policy-savebar__feedback--saving { + color: #76B900; +} + +.nemoclaw-policy-savebar__feedback--success { + color: #76B900; + animation: nemoclaw-fade-in 200ms ease; +} + +.nemoclaw-policy-savebar__feedback--error { + color: var(--danger, #ef4444); + animation: nemoclaw-fade-in 200ms ease; +} + +.nemoclaw-policy-savebar__feedback svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; +} + +.nemoclaw-policy-savebar__spinner { + display: flex; + width: 14px; + height: 14px; +} + +.nemoclaw-policy-savebar__spinner svg { + width: 14px; + height: 14px; + animation: nemoclaw-spin 1s linear infinite; +} + +/* Responsive */ + +@media (max-width: 640px) { + .nemoclaw-policy-stats { + grid-template-columns: repeat(2, 1fr); + } + + .nemoclaw-policy-oneliner { + flex-direction: column; + align-items: flex-start; + } + + .nemoclaw-policy-ep-row__main { + flex-wrap: wrap; + } + + .nemoclaw-policy-ep-row__opts { + grid-template-columns: repeat(2, 1fr); + } + + .nemoclaw-policy-savebar { + flex-direction: column; + align-items: stretch; + } + + .nemoclaw-policy-savebar__actions { + justify-content: flex-end; + } + + .nemoclaw-policy-section__header { + flex-wrap: wrap; + } + + .nemoclaw-policy-search { + width: 100%; + margin-left: 0; + margin-top: 8px; + } + + .nemoclaw-policy-disclosure__header { + flex-wrap: wrap; + } +} + +/* =========================================== + Policy Page — Status Bar + =========================================== */ + +.nemoclaw-policy-statusbar { + margin-bottom: 24px; +} + +.nemoclaw-policy-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-bottom: 12px; +} + +.nemoclaw-policy-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 14px 12px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--bg-elevated, #1a1d25); + cursor: pointer; + transition: border-color 150ms ease, background 150ms ease; + font: inherit; + color: inherit; +} + +.nemoclaw-policy-stat:hover { + border-color: rgba(118, 185, 0, 0.3); + background: rgba(118, 185, 0, 0.04); +} + +:root[data-theme="light"] .nemoclaw-policy-stat { + background: #fff; +} + +.nemoclaw-policy-stat__value { + font-size: 22px; + font-weight: 700; + color: var(--text-strong, #fafafa); + letter-spacing: -0.03em; + line-height: 1; +} + +.nemoclaw-policy-stat__label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted, #71717a); +} + +.nemoclaw-policy-oneliner { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + font-size: 13px; + color: var(--muted, #71717a); +} + +/* =========================================== + Policy Page — Immutable Disclosure + =========================================== */ + +.nemoclaw-policy-disclosure { + margin-bottom: 24px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--bg-elevated, #1a1d25); + overflow: hidden; + transition: border-color 150ms ease; +} + +:root[data-theme="light"] .nemoclaw-policy-disclosure { + background: #fff; +} + +.nemoclaw-policy-disclosure--expanded { + border-color: rgba(161, 161, 170, 0.35); +} + +.nemoclaw-policy-disclosure__header { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + background: none; + border: none; + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; + width: 100%; + transition: background 120ms ease; +} + +.nemoclaw-policy-disclosure__header:hover { + background: rgba(161, 161, 170, 0.04); +} + +.nemoclaw-policy-disclosure__chevron { + display: flex; + width: 16px; + height: 16px; + color: var(--muted, #71717a); + transition: transform 200ms ease; + flex-shrink: 0; +} + +.nemoclaw-policy-disclosure__chevron svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-disclosure--expanded .nemoclaw-policy-disclosure__chevron { + transform: rotate(90deg); +} + +.nemoclaw-policy-disclosure__icon { + display: flex; + width: 18px; + height: 18px; + color: var(--muted, #71717a); + flex-shrink: 0; +} + +.nemoclaw-policy-disclosure__icon svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-disclosure__title { + font-size: 14px; + font-weight: 600; + color: var(--text-strong, #fafafa); + flex: 1; +} + +.nemoclaw-policy-disclosure__summary { + padding: 0 16px 14px 60px; + font-size: 12px; + color: var(--muted, #71717a); + line-height: 1.6; +} + +.nemoclaw-policy-disclosure__summary code { + font-size: 11px; + padding: 1px 5px; + border-radius: 3px; + background: rgba(161, 161, 170, 0.08); + color: var(--text, #e4e4e7); +} + +.nemoclaw-policy-disclosure__body { + border-top: 1px solid var(--border, #27272a); + padding: 16px; +} + +.nemoclaw-policy-disclosure__note { + font-size: 12px; + color: var(--muted, #71717a); + margin: 0 0 14px; +} + +.nemoclaw-policy-disclosure__note code { + font-size: 11px; + padding: 1px 5px; + border-radius: 3px; + background: rgba(118, 185, 0, 0.08); + color: #76B900; +} + +/* =========================================== + Policy Page — Internal Tab Strip + =========================================== */ + +.nemoclaw-policy-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border, #27272a); + margin-bottom: 14px; +} + +.nemoclaw-policy-tab { + padding: 8px 16px; + font-size: 12px; + font-weight: 600; + color: var(--muted, #71717a); + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: color 150ms ease, border-color 150ms ease; + font-family: inherit; +} + +.nemoclaw-policy-tab:hover { + color: var(--text, #e4e4e7); +} + +.nemoclaw-policy-tab--active { + color: #76B900; + border-bottom-color: #76B900; +} + +.nemoclaw-policy-tab-panel { + min-height: 40px; +} + +/* =========================================== + Policy Page — Network Card Host Preview + =========================================== */ + +.nemoclaw-policy-netcard__preview { + padding: 0 14px 10px 38px; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.nemoclaw-policy-netcard--expanded .nemoclaw-policy-netcard__preview { + display: none; +} + +.nemoclaw-policy-host-chip { + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + padding: 2px 7px; + border-radius: 3px; + background: rgba(118, 185, 0, 0.06); + border: 1px solid rgba(118, 185, 0, 0.15); + color: var(--text, #e4e4e7); +} + +.nemoclaw-policy-host-chip--more { + color: var(--muted, #71717a); + background: transparent; + border-color: transparent; + font-family: inherit; + font-style: italic; +} + +/* =========================================== + Policy Page — Inline New-Policy Form + =========================================== */ + +.nemoclaw-policy-newcard { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 12px 14px; + border: 1px dashed rgba(118, 185, 0, 0.3); + border-radius: var(--radius-md, 8px); + background: rgba(118, 185, 0, 0.03); + margin-bottom: 8px; + animation: nemoclaw-fade-in 150ms ease; +} + +.nemoclaw-policy-newcard .nemoclaw-policy-input { + flex: 1; + min-width: 160px; +} + +.nemoclaw-policy-newcard__hint { + width: 100%; + font-size: 11px; + color: var(--muted, #71717a); +} + +.nemoclaw-policy-newcard__error { + width: 100%; + font-size: 11px; + color: var(--danger, #ef4444); + min-height: 0; +} + +.nemoclaw-policy-newcard__error:empty { + display: none; +} + +.nemoclaw-policy-input--error { + border-color: var(--danger, #ef4444) !important; +} + +/* =========================================== + Policy Page — Delete Confirmation + =========================================== */ + +.nemoclaw-policy-confirm-actions { + display: flex; + gap: 6px; + align-items: center; +} + +.nemoclaw-policy-confirm-btn { + padding: 4px 12px; + border: none; + border-radius: var(--radius-sm, 6px); + font-size: 11px; + font-weight: 600; + cursor: pointer; + font-family: inherit; + transition: background 120ms ease; +} + +.nemoclaw-policy-confirm-btn--delete { + background: rgba(239, 68, 68, 0.12); + color: var(--danger, #ef4444); +} + +.nemoclaw-policy-confirm-btn--delete:hover { + background: rgba(239, 68, 68, 0.22); +} + +.nemoclaw-policy-confirm-btn--create { + background: rgba(118, 185, 0, 0.12); + color: #76B900; +} + +.nemoclaw-policy-confirm-btn--create:hover { + background: rgba(118, 185, 0, 0.22); +} + +.nemoclaw-policy-confirm-btn--cancel { + background: transparent; + color: var(--muted, #71717a); +} + +.nemoclaw-policy-confirm-btn--cancel:hover { + color: var(--text, #e4e4e7); +} + +.nemoclaw-policy-netcard--confirming { + border-left: 3px solid var(--danger, #ef4444); +} + +/* =========================================== + Policy Page — L7 Rule Editor Rows + =========================================== */ + +.nemoclaw-policy-rule-list { + display: grid; + gap: 6px; +} + +.nemoclaw-policy-rule-row { + display: flex; + gap: 8px; + align-items: center; +} + +.nemoclaw-policy-rule-method { + width: 100px; + flex-shrink: 0; +} + +.nemoclaw-policy-rule-path { + flex: 1; +} + +/* =========================================== + Policy Page — IP Row + =========================================== */ + +.nemoclaw-policy-ip-row { + display: flex; + gap: 8px; + align-items: center; +} + +.nemoclaw-policy-ip-row .nemoclaw-policy-input { + flex: 1; +} + +/* =========================================== + Policy Page — Search Filter + =========================================== */ + +.nemoclaw-policy-search { + padding: 6px 10px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-sm, 6px); + background: var(--bg-elevated, #1a1d25); + color: var(--text, #e4e4e7); + font-size: 12px; + font-family: inherit; + outline: none; + width: 180px; + margin-left: auto; + transition: border-color 150ms ease, width 200ms ease; +} + +.nemoclaw-policy-search:focus { + border-color: #76B900; + width: 240px; +} + +.nemoclaw-policy-search::placeholder { + color: var(--muted, #71717a); + opacity: 0.6; +} + +:root[data-theme="light"] .nemoclaw-policy-search { + background: #fff; +} + +/* =========================================== + Policy Page — Section Count Badge + =========================================== */ + +.nemoclaw-policy-section__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 20px; + padding: 0 6px; + border-radius: var(--radius-full, 9999px); + background: rgba(118, 185, 0, 0.12); + color: #76B900; + font-size: 11px; + font-weight: 700; +} + +/* =========================================== + Policy Page — Template Dropdown + =========================================== */ + +.nemoclaw-policy-add-wrap { + position: relative; + display: inline-flex; +} + +.nemoclaw-policy-add-btn__chevron { + display: flex; + width: 12px; + height: 12px; + margin-left: 2px; +} + +.nemoclaw-policy-add-btn__chevron svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-templates { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + min-width: 220px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--card, #181b22); + padding: 5px; + box-shadow: + 0 8px 24px rgba(0, 0, 0, 0.35), + 0 0 0 1px rgba(255, 255, 255, 0.04); + animation: nemoclaw-scale-in 120ms cubic-bezier(0.16, 1, 0.3, 1); + z-index: 50; +} + +:root[data-theme="light"] .nemoclaw-policy-templates { + background: var(--bg, #fff); + box-shadow: + 0 8px 24px rgba(0, 0, 0, 0.12), + 0 0 0 1px rgba(0, 0, 0, 0.06); +} + +.nemoclaw-policy-template-option { + display: block; + width: 100%; + padding: 8px 12px; + border: none; + border-radius: var(--radius-sm, 6px); + background: transparent; + color: var(--text, #e4e4e7); + font-size: 13px; + font-family: inherit; + text-align: left; + cursor: pointer; + transition: background 100ms ease; +} + +.nemoclaw-policy-template-option:hover { + background: var(--bg-hover, #262a35); +} + +.nemoclaw-policy-template-option--custom { + border-top: 1px solid var(--border, #27272a); + margin-top: 4px; + padding-top: 12px; + color: var(--muted, #71717a); + font-style: italic; +} + +/* =========================================== + Policy Page — Conditional Save Bar + =========================================== */ + +.nemoclaw-policy-savebar--hidden { + display: none !important; +} + +.nemoclaw-policy-savebar--visible { + display: flex !important; + animation: nemoclaw-savebar-slide-up 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes nemoclaw-savebar-slide-up { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +.nemoclaw-policy-savebar__summary { + font-size: 13px; + font-weight: 500; + color: var(--text, #e4e4e7); +} + +.nemoclaw-policy-discard-btn { + padding: 9px 18px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: transparent; + color: var(--text, #e4e4e7); + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: border-color 150ms ease, background 150ms ease, color 150ms ease; +} + +.nemoclaw-policy-discard-btn:hover { + border-color: var(--danger, #ef4444); + color: var(--danger, #ef4444); + background: rgba(239, 68, 68, 0.06); +} diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/nemoclaw/policy-proxy.js new file mode 100644 index 0000000..dc42ace --- /dev/null +++ b/sandboxes/nemoclaw/policy-proxy.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// policy-proxy.js — Lightweight reverse proxy that sits in front of the +// OpenClaw gateway. Intercepts /api/policy requests to read/write the +// sandbox policy YAML file; everything else (including WebSocket upgrades) +// is transparently forwarded to the upstream OpenClaw gateway. + +const http = require("http"); +const fs = require("fs"); +const net = require("net"); + +const POLICY_PATH = process.env.POLICY_PATH || "/etc/navigator/policy.yaml"; +const UPSTREAM_PORT = parseInt(process.env.UPSTREAM_PORT || "18788", 10); +const LISTEN_PORT = parseInt(process.env.LISTEN_PORT || "18789", 10); +const UPSTREAM_HOST = "127.0.0.1"; + +function proxyRequest(clientReq, clientRes) { + const opts = { + hostname: UPSTREAM_HOST, + port: UPSTREAM_PORT, + path: clientReq.url, + method: clientReq.method, + headers: clientReq.headers, + }; + + const upstream = http.request(opts, (upstreamRes) => { + clientRes.writeHead(upstreamRes.statusCode, upstreamRes.headers); + upstreamRes.pipe(clientRes, { end: true }); + }); + + upstream.on("error", (err) => { + console.error("[proxy] upstream error:", err.message); + if (!clientRes.headersSent) { + clientRes.writeHead(502, { "Content-Type": "application/json" }); + } + clientRes.end(JSON.stringify({ error: "upstream unavailable" })); + }); + + clientReq.pipe(upstream, { end: true }); +} + +function handlePolicyGet(req, res) { + fs.readFile(POLICY_PATH, "utf8", (err, data) => { + if (err) { + res.writeHead(err.code === "ENOENT" ? 404 : 500, { + "Content-Type": "application/json", + }); + res.end(JSON.stringify({ error: err.code === "ENOENT" ? "policy file not found" : err.message })); + return; + } + res.writeHead(200, { "Content-Type": "text/yaml; charset=utf-8" }); + res.end(data); + }); +} + +function handlePolicyPost(req, res) { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + + if (!body.trim()) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "empty body" })); + return; + } + + // Minimal validation: must contain "version:" and "network_policies:" + if (!body.includes("version:")) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid policy: missing version field" })); + return; + } + + // Write to a temp file then rename for atomicity + const tmp = POLICY_PATH + ".tmp." + process.pid; + fs.writeFile(tmp, body, "utf8", (writeErr) => { + if (writeErr) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "write failed: " + writeErr.message })); + return; + } + fs.rename(tmp, POLICY_PATH, (renameErr) => { + if (renameErr) { + // rename can fail across filesystems; fall back to direct write + fs.writeFile(POLICY_PATH, body, "utf8", (fallbackErr) => { + fs.unlink(tmp, () => {}); + if (fallbackErr) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "write failed: " + fallbackErr.message })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + }); + }); +} + +const server = http.createServer((req, res) => { + if (req.url === "/api/policy") { + // CORS for same-origin should work, but add headers for safety + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + } else if (req.method === "GET") { + handlePolicyGet(req, res); + } else if (req.method === "POST") { + handlePolicyPost(req, res); + } else { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "method not allowed" })); + } + return; + } + + proxyRequest(req, res); +}); + +// WebSocket upgrade — pipe raw TCP to upstream +server.on("upgrade", (req, socket, head) => { + const upstream = net.createConnection({ host: UPSTREAM_HOST, port: UPSTREAM_PORT }, () => { + const reqLine = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`; + let headers = ""; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + headers += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`; + } + upstream.write(reqLine + headers + "\r\n"); + if (head && head.length) upstream.write(head); + socket.pipe(upstream); + upstream.pipe(socket); + }); + + upstream.on("error", (err) => { + console.error("[proxy] websocket upstream error:", err.message); + socket.destroy(); + }); + + socket.on("error", (err) => { + console.error("[proxy] websocket client error:", err.message); + upstream.destroy(); + }); +}); + +server.listen(LISTEN_PORT, "127.0.0.1", () => { + console.log(`[policy-proxy] Listening on 127.0.0.1:${LISTEN_PORT}, upstream 127.0.0.1:${UPSTREAM_PORT}`); +}); diff --git a/sandboxes/nemoclaw/policy.yaml b/sandboxes/nemoclaw/policy.yaml index 308e077..397971a 100644 --- a/sandboxes/nemoclaw/policy.yaml +++ b/sandboxes/nemoclaw/policy.yaml @@ -7,8 +7,8 @@ version: 1 filesystem_policy: include_workdir: true - # read_only: - read_write: + read_only: + # read_write: - /usr - /lib - /proc @@ -16,7 +16,7 @@ filesystem_policy: - /app - /etc - /var/log - # read_write: + read_write: - /sandbox - /tmp - /dev/null From e41c8e20f1eb5071f4ce2bb90fb3333600294d20 Mon Sep 17 00:00:00 2001 From: nv-kasikritc Date: Mon, 9 Mar 2026 03:58:36 +0000 Subject: [PATCH 2/2] feat(welcome ui): policy tab first pass --- .../__pycache__/server.cpython-310.pyc | Bin 0 -> 17719 bytes brev/welcome-ui/server.py | 314 +++++++- sandboxes/nemoclaw/Dockerfile | 9 +- sandboxes/nemoclaw/nemoclaw-start.sh | 16 +- .../nemoclaw-ui-extension/extension/icons.ts | 2 + .../extension/nav-group.ts | 2 +- .../extension/policy-page.ts | 725 +++++++++++------- .../extension/styles.css | 688 ++++++++++------- sandboxes/nemoclaw/policy-proxy.js | 341 +++++++- sandboxes/nemoclaw/proto/datamodel.proto | 87 +++ sandboxes/nemoclaw/proto/navigator.proto | 533 +++++++++++++ sandboxes/nemoclaw/proto/sandbox.proto | 117 +++ 12 files changed, 2274 insertions(+), 560 deletions(-) create mode 100644 brev/welcome-ui/__pycache__/server.cpython-310.pyc create mode 100644 sandboxes/nemoclaw/proto/datamodel.proto create mode 100644 sandboxes/nemoclaw/proto/navigator.proto create mode 100644 sandboxes/nemoclaw/proto/sandbox.proto diff --git a/brev/welcome-ui/__pycache__/server.cpython-310.pyc b/brev/welcome-ui/__pycache__/server.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c9f8e1f776eab0552280b99925f62dafac3f2a6 GIT binary patch literal 17719 zcmaKUeQX?Oe%{RN?Ck7v`Atz0_2IE(SzO7xlzjHt=E;`zL0P)U6f4qpOxw3w?K>ow zTJBQs%!=Y=f1c5*z|FXSHNepfL)hD3Bs){z!of+Fp^OO;hxbOcS)dw7IAs zXxgA@PO+sv&pWfrT}oHV`_9b!HSfpoeSV+gdT(z=!{47g`>T~Re?`;&h!35AX*|4+ zpZ5(z(}Xs!30)Xfecn*lM0_>pP4%3dPpYdmZ>ei)KBcbqysfV3`Lw!b<}>QrGvA}G z+4*b`wdkGidq=DFn3~(aep=V~PZ;xk?`k!DO>+m_=L8M z8IN*=ft=l94{~y1FX|X)wYc7x*oWL>;#F1ifIA=#y2n+ows=Z7A0_9XV@64RKgJgQr4A`4RCfawf!4G5(P@e?sKMF0#|ifgTGZHRm0-TCT6S&Rdt9AAa~<=i=PljN`fTmMfjLiofJ|rJA^2zwJ~j z3vPM6Ty>o@Gnc%=ee|JChvv+ct8%S;`t9PS^PzU%y@Vl}6;X9_W@veS$!~aJVznZ| zL_=1?q$_1zhL&{whOCu2xz0Zu59jdn{uUBn`}CZy`MS`tr^XFK4yCnsaB5BbyOY34 zO3b!O&0Ctx`^FtjzUU`J(l_rI@>OB5?J4c?R$aYVv)#Qu;rXQIU0lN+^WVGNW7Nr| zrX|59Ovy&AR;ew{c z7jaoDt)qCchy@nwVWzlRmwvIn>ej;S?3wBFXRrKpk)-gsCU@gqDtYkA#1bfLqH(=Z z^BWT_ip!sC@AY-*-kMm82r%EMOhj~7SY7WJgnHow`t-(;SbA1B^nr)j#DH$|?tziW zd}*ePFEd}X(t#hFDY=(3Hkaz2U)FKk`UAXTm*UMlh#Ozq#AYaw?~ZvuF> zbQ~6JW6lg_rf&PPRQ4Tz$#u?Rz>W*zmG#<+Tl1Yu=bZ&vUvWy#MZC&c!s_7QR4X@J z=jh8PUOssufBW|B0_RyRT`z#jj~0U6?~CL4@xt-bx%XQo@3ZJW2P#uYa*5E8ZkSvt z`Q@cBxhU(6RqWyz%MUPVud&>N$8ezNMuRN!O~s0sM6SnqW{n|T;&f_{tpZmA!`$72k8K3(h#tm_sVcIcrOm@)CLlEPHN#Rn~7;Tu*ft_YxoBg5%6BRXpd0>#pJr z*pKV7zJ_z<)N4-3@o@Svrn;;wR`6UCPU%*?BBGwolDNyV?s@sR!gT_I=UjLFHP@{< zYCK-!x~Q*|DmAa5-XHWW`TnXmIWZB@d(hh=YSQ#XFvwz*wjpQofj$wWPOF`HHJ6YU z_CPvJNRr4n%=@RAa8q~Ez;N+87hGWvc}fxR4Q$uEJ|uR^)cf?T!PL~{5oG-cKm7ga z>Coi(!<1iHaqA7gOd@Fg@xG1mb9e}z^0j3gh^D@PjTZV{&Ja>xY98(<+whwHl5@rCtkDS7xWkkFXf( z%pXM?)o{mR$XzIy$4^b(&_{rXh8|gRjrY zbrO7xQ;IT64Tu+t3?d9AwZ>$8)N{yNxW5k8Wuda@+^UouFd(lm9qf*1XZ*c0`T0^l zIFWy)_$`AxzsRl|{g@l4~0^#gt2Ut8^nI!;H%} zR96wYE>zIHgVA|)f@K7s3w60xlIU;I@gYxPf}BOR zPHsZy^AizU+k&)6GzwL4rP01y2Uug5*}~HLpTZfg=)b)x|*qwV@jOj=AhJ?`_Ko zA3%cGo=~msN8L|4p`Mw3kc>2>{+3P@jUxZK0q|=qEL3iX7S0i``dVo!~@QvkN7R6UVpiEXEz|Xg}%X`a+}bfdymvLaX9&W?|3F zmCKjTy&Hja!dv&Km_dG)VRB7YfFkO|@UVmmmUE@4)6G zU~{<(9TI0J8A}nn+mMbNP`4FKohOlix@+rB)m`xGRMz9|c50;+?8VU*&>wB-C)o2_ zm3jjJ=X0U_0uYHC8m8iwp`~cgRk?Hb$sr-B_mxYlP>J0lM5fh-ucVM^NG}=4fe=H> zfLW|;qb4t)e#bH@L{rMi-Xe?!rCMVZN=n5eZMPK6UqTaa0Eq!^X<<3D53{C{UC0f) zmaP{VJ@_T?^L`B}l<*NinGRLjIEBNHs~KODryhr%y* zknQzF!Q8!AK(zLyIPJ#qeet zB?&R|*I@G5(#5Xb8Xyv&xtSHC3g&j-12p6P%& zq(k@(L-^b)#_lG6UjJD87{aL7C-#42ZzTPZv8Z2>u|4g9X`=F_pnvdT29jpc9-C*%YPE2{N(0dNO=mxYiU67*!pj^$dD#NykVbs z`bGikCSWW>8>wb$bN`km4v7TUBHE$Hn8F`-jtEA{LA>q2`j^q$ z#9_XP8duaB2FlD@*Y2`EC4076O_XyItJ*EQM*051&4U=zGuRb7x^LPyP*?s{|EW8? z-trv*1K%AQ*KYYwgEG?1v`92qcGW*5p1Px-*FG59$e{IMaa8T(b}b#XW^}EF);CZ4 ziDnusvj;0QgcTZ(R;by7>kb<27;~+AJvck{rkr|{8htck57N8pH(q&h?C}-u==YZ& z@0Tn69ew@x!DO>wvd6po^Pg#E@9=!#T(O_FXfUAG_WRxaDOQ~QW;WW5&L=Qt`0X=b!)L+kOU58>6(^A2c;}R zo;u8X0-L;yB($P_0xO>(s14e4=`@vYVdT_i5LTRy%VzjM=Q_42%y;kV%f$xH@vIzCs5+zul zYm(gKzM_j9?j%>Wa^f!@Jqq^DL~IPYDP#^2S-m+S=l*Th9Av_cR_dkf$K8Fhq2Pr% zSQ3PT&2-jE6(zJ$f_qFQfLN(4CyLB+N8}Lu;%-W^Ic20!a)SIC9>Y{@UJT80orciq zsW-2jyL{%I;-xpwya8Qy8OEdSHC8(yud$zP^ znF9|FtvPl4!la@;wLGE8d4v{qrP>Yo9J|TH7ICacq_&XHhbE0EN=;E_2Bq7ENm;5b zf(*PWj89<#Tbk<)dte6hi;W7+G+}JQFH#DOXg^V25va2wilX9y;MV0L`=^)&dH~^3 zJqm4sBTpJqr#sY(k!9%XD2u2_@J^A^@Dr$H3#HyaLt>dW=n4AymsT=kSO$JD7^#0> zm=-O|{71X8^~Gpv1T{?vco6ec9hO;t*=P6Jwr&{|_UuF!H4G*Cq1K~p!cwg>`Y1}x z53>d=GLMW8GkyK(x?%7C&Awf=4z4M~*G?&`Jz_X(cjx-~YA- zm^1!x08){Sw@f~SQ29uH3sdQW2pZ1XmV{|M1Y;B`2@8u9DIrvf*|NcdudKYI z&Dd@OZVw1h;-Usga+S#W;#t^q^X>u&+@}#o**P7W*NLi1bJ@0EL5cC95_pBs zZq)#$F3iar7~1Cvc?B1c5cT_U+yYEUu@a6^O~DFj62g8YQXXUF6i)KOO;LkD3Kxyn zsQU6_*6!Au;mJU~V(Xp#IODNVhQ=7{Q6@n#(|BYnqYSqBp}gDbB}~(=ROFz-tg<}B zN=Bi~)ouN8l?fsQ07X(lU($U@O?ULOkQ~Y38?f>DP{83+&^Hp00TRmXGu|`++V+Oo zOl&5bu%c}lx^@$C+OnnOMJ4#pa)(k%xz;z5Pzn+Tq&rAbNwgh@Y-oS1E$Ea_KeE~1 z9lTdgiR4D==8MggeESoPax0}R(Q`}l-U7I%A?;-0q#O-HOxjhLtI-M;KGn&w&gDPmjJt)! z0{R+16zdy@p37akmZQ3<-h-in%iysdsmfux{Z^P=jBrS}3nfrYaiLlVhxON?d8R$O zulUxvoF3Y=mb*3KbvX*S?J4}e!mTD1{v0;vg8?WCbQwX^#uh_FhCM>R2QIp0ybUgT z6V4+nXX1hWjR*Q96eVSLRE9+GQ4h+Liy;|>6QO=X(rVbj1=FXfblzT-ujNU(bl(^s zj%&fuC+WW1oUh$V@wCVWYRGrc`hlK~+(}uWF<4=;NE)YScEN=+uOz}u1s(zJW!Vi) zQ7QXyiPl$f+kh&a0U%d=!Y))i=$i7cqtDPPt#Ym8O+4gm`E6X}r>a0I0tyqNUJesj z+b|io1&0hx?*>k^IT7vc<{nC@FOw?9BzUunJc)kUVTJJcnCHbi;of(FWy@a4hzk8cFDEX)anD7TyLOc+)11v_cO2 zG*M?GMGd!UA#4MI8)5|f$Bs&xsXfrD&~jJPUe02aqtLK+f6o+q?j<=|B_F=w9n*Df_$+>cMntjcF@$6?Nfa7RzW(GpEQ)(Fx z-#zo@WkMazfh`ZZ)52u>L|8zMm-pagb?XgkB+UH$72|6 zA^mueajviXK=(>52Q*B@<rcTgY-b$L11oAIzsfqkp|BeuX21P|qj@M2C$Idfz)f4zGPK7>4&^Y4UyV*!X*;`~o~rEw_^b+6Ikg+r7@db?)5M?5yfG?&#IP zqQ_;%B_}GJq{j=S_CSG7B&H9!Z3~5z**1mg$ySW-OFERAHTswaRQYnv3+-uNZT^+ z`t_FC^;PVh_Zk|EK;RzHhhkxS|Dyqj*0w$bJ~0NqKv5l{_J?~Q($n?|(Hix$;2%TC z2grUOU6k?b`Xh(spGOFGwRv#$c|!?U5aT|w=OH-Ho9Y?jGoF*`-V!O?TQm`V1TV#W z3O^e^n91fd`1Qq~kh?;vp<{QTV6igTk^koeji zXac}%aKZ|>!r#@ut;fo~F&(A_d|qYPR@PzCO?r)0SB7@6Sb@EzSPV@DbD_XwI6`Pb zpN5702Hatb9+diY5r$nzbj9Mf3?r#!aX_Adn=w5YhzD6{b$X#|xTvXy%VB$@YKXs@ zw-2sQrMyr?R`+0S?R$8h+V_kwL>)$0MfMD3#Yj&Fy((G-p+=zYdNnv)tLMv%UwIB9 zEX+4`iMy2sP!m@1Qne!B^a%EyDbt+YYLq`yg}|7re}Xbi;GZZ0V@x(939-I`MGsSg=W}a1oGZqhkseQgy0{6vr`nK zm?HU4u{vSTb_TXU{s4LMcbK$KlKeM#T;dKec!J?l(Z4=D7;f!FM<-po!98m$HxKau z#RkNKfGC^?MuKuTlsE+gwlMcnjf~2ZEZ>AMB`l~KhkXkXZ-=pSFsCb(#uh2$e+4;d z?3n6jGt&~iK%8lmzK*_Zk%{g$dsItZ^x*l;b_wn_vvCQPnt4^y8{N^Ej<@Ne56_G3 z65Nr+!;H#N)$r7>>hW#;h|jz24}f|GqdYM90gg}6(IDa*FM_oyY=5AiR*WP}y|ub1 zv4dfH&AlGSaHQfLzyB|>YQgYCX%)e2IGzZcq_I%N8iGE8-Ku0;Q_jbla#lI0|@5MbrX;9hz|D-%2Vm9>REi z4Jf^r?FM2>>zDvybyhIouvgJsiv$%lg`>qK1vPEsTRf$)R{r*c7@9@ZA0D*0O+2S| zz(hFQDv2hffFA*25$yx{<8_M&-V*g977n@yiB_?0OkUAFop(EkKeUR7CxCH+^iP#- zNFUJm8_+9Y#$jyO5blQ*--xbOT-Ijdou>!S49r|99&*n_QYLt?A&C%#JlH}?78nfwnXPkKcL3ltOIckmMV zKT$ClYRzT4FlUFE?QIG&RC)72zFTfEq?SI<$O{H>l!=AI+j)ou3zTyleBXsUAnEOu zkiBqM^B`2_L9BH~NQ9|IbTqnVl);F4@Z;inzlkvh|1S70%~P~n!wE;rbz%c}!XUe4 z69Gj~&*)y02k;D+oNqUw#30Y8z?G)zK)rxLq#yAINjSff#1e%(6E~k}T1~SF_sY#@ z1R@+L(8Jhbp8_sG8PS%r`1LlKm%eKP&vYgJ(^Unm+5rAEwPHWEcc7Vq8!ojxqNigG4F~TZ`$(SO{g#t zBI14@F%e^e+l)ToT0uF(ep`pS1P9F_|KRddn`0u|Ohbw2jogTVjrSpTUi7nPw4uB1 z!1{0K=z&qebl0g!33DU;&2+5~>x?ViF>vY(aJFjBPYb9YkLPsOfFW=&n7j~qP2iY` zV#QnWp0SP+pI(IOfR!=mP#Nf{pcEoYdKF|KX@Z}A(AQ#sm)+VTBuvUHL1sHpP|^_q zbBTGqF4pBA;9)N40}(5x35ghOg0U{2OY~L+6S3dr{-1;59t_V$U<~6`=@#gh0U#H0 zhY%o);6(U{Ac5EXu#ds-7*iaECr9x{^;-FdxPyJ0p$3r^{5Mqw`~@qX{13c`zeL5o z%KyYh#HO%^5FGPz`LCHX)CI1^dab%1#cIQ-S@ID0mqC*jg7*|JUYRMLeU}&cyX^Iw zO#U$wGI<0^sPUX(#x*7-CKs4&nW| zd`v(rV&I&&8m5^R)gFAEop*zr{1Nhg8H=oZQzil-Oo-klfS32Qsl$@3j{<06;Wh@4 z--kTr(+X}wP^TT7_lV}k9qs5N9~xg6k1PZ}86U!I3)8Kw2YXwf_86<^f*aaO2+;~| z5DupW@W0X)?i=u)D>oUV9BTtOCW-ZZ(J{dhGHD~8Xgc% zgFq*SuzK;)zCr5X(U$+0$zLFu4vgMRbg%~~N=-W3$NPaX>2#U85@^7!o5j!j29jVf ztynFjOQ3(#@G><{2Wku#?xqn>$ygujoKk>DwM`h^mz9|hn1&9q*~WUIRf{=5v&8gV zMZ?PEEK+*;7D8blWgs+59U@rymKv}stkvW(j3rFXUVLk=_|8=*LU7W@5ggq?*=@0G+sLbWgf1pAU`_rD zCd)`F&VD8Pae(qT6NOpN@$Np7uqTEkx+zD#(yvQJQ?f>WIvtmEa_P&+k|q`5#f?2PpR@ zal^nLNM~@I!9N3kOx7f13@NzigOklRM*0X6fC>B~Y(xN&F@ilmY0N5clP4!(?_O48b5;?qvCBrFww_6?N52Az)ZC3+QNELT@qWXr3#^Z zrPFo_Ur;3Az(*6QQ@*tf@rGv~mRo4)w3|}N!iaG8MNb9d0+jv!=#w55@=*DkSiB2? z)8c51eXU|A#5$sCX-JDa<3e(_%3B?bJ)v%Fc2g`?={Iw72@UT5fURhm0*e^pJss8f z9A_EqsjZnRU1OUwcF@hY*o4FrOX+aKD>w5_D)=v`@?%l~NtIeEb|3$&hxoL|YTy6Z zu=YO~%F@NkGTR(50;j$2OOaBtQ^Dxke+%8yiKDo|AL9l)&^9j6I{=>m?d15zgX%4w zl?7Vu6zjp*!<{1dC>0hTa|wuXYL6g7($FKaqy(kr(xMJ%f(MVg*N;8L7bIdJ5@7L` z!RRfp_$F@Yau#L7U44zT`a-ZR#sgv3#29vM>GB(56gK4Di2E|c9u)+;*U!LvXDX|^ zvLus-fp5T20*V=%4z1X7k%|qUjRL0s!|#5G|CE`+*{M~+msfV75C~^H`u^k92YN0z zsO*_1m8lj~rcAZ*Cq7Cf_&?}1valX-T7OrkzCLwsZtDD`!$?CACgisY)sqWBW;-XM z(D&H>eI_3u35HsIY>!6%Ll)SveRMJyZ&%rV&q@V>Ga&xBQe@i>j#O&Ox)U4IC!Hr& zQ{*lo2}FFWL9&n6DH7kJNoP?Zm+hc>S`8%yLAjr8zu?^jlOhu(r+puH2-%=$m!wb4 zg9nKx40h7Utke=`i;+01L@{MsRdV6ZQ$qqC0;EO$GKfo&7Rz*02~!A6fBLB=|CG9Hg86s{XKU+?T4F-+*6wQ)sl5IW#DdM6>1 z`l7m~V3o2zOQ-{ojvx~-L*dN7RlpbPQ}Y)6P{HG4}m!<`MuqLR}Pt zeSvvLn5eC%dH{pI+F8cazyfiYg;^#$#N|Ii-Z=M=Je$EE_*f)CfxtElgnn=b=?C5a z(B{1g3IYH7GB~(%Cu3O*&SBb@x;B$sk26x6$@kC-Ok*9*s#rM1?}&T%u>8ch&!8f2 zFB})9@|~w}=tdyOz~O>dRIBX}ye*bzhIV9{y_ zH|T9wLCY1`JyY=u)OR=RChUzG#cV4~_>Bto=wnO-N)=WYK?XDYmMhDavY|ej^03t^ znu9yivJtV#&JFm_$UDsq7{QGdGadu_37L%f1(y2I&ZUm02S-rLz7-J+6{m6nN$ZcN zh%84f{0wgBh$c%=A6J3>Z-Ry+p&tr|vZ}_c1gFJ^JGR3@0DA<>mTd+UvyHMzn$z-M zq2FLY`Hf(!!JsAxFp@wFru{Ur=n#`pCO<^(le9+(<45EMPgqWl|5mscuyWcm9O^ctpMYNN3h@rJlzs2#57r!G&;O(A>@S!d?X zT~xJbvz33!Hfcnn2Kbl=W`YEF<44F;^0B0NB#HKta3M*u5vNgzq%emuA_#bjd@yCV zT5b7Z)FS*2Tc{X|-^Sf_)K#j8twDZ<0+P+_KLAuQZObm$C+)rVsJ(lrf54Wk&4u6x zp2cD#hsUg>w*>^;_%Bx8VQA$_j%T(LWBNYjnKf{(LLs{f!s*_9jwpjX8{9-a%r5fuJl+`Dt zku@d-R~I)C9@p!h`V0lrqyYu%_Rm>WX*&tkVe5xM1|W*l$b{U2A+q(7za29f@s*!q`Bf(0VnP(dm&=V+7@N*ALkSvc z=fBS)e8b59Jwv!>wz7gcG+JGaB(nGL-|_)qK}3WBal5{{((`)M5v>Q6ueEz u@C>Z%vxj bool: + with _sandbox_lock: + if _sandbox_state["status"] == "running": + return True + if _sandbox_state["status"] in ("idle", "creating"): + if _gateway_log_ready() and _port_open("127.0.0.1", SANDBOX_PORT): + _sandbox_state["status"] = "running" + return True + return False + + def _extract_brev_id(host: str) -> str: """Extract the Brev environment ID from a Host header like '80810-xxx.brevlab.com'.""" match = re.match(r"\d+-(.+?)\.brevlab\.com", host) @@ -53,15 +73,15 @@ def _maybe_detect_brev_id(host: str) -> None: def _build_openclaw_url(token: str | None) -> str: """Build the externally reachable OpenClaw URL. - Uses the Cloudflare tunnel pattern from nemoclaw-start.sh when - BREV_ENV_ID is available (or detected from the request Host header), - otherwise falls back to localhost. + Points to the welcome-ui server itself (port 8081) which reverse-proxies + to the sandbox. This keeps the browser on a single origin and avoids + Brev cross-origin blocks between port subdomains. """ brev_id = BREV_ENV_ID or _detected_brev_id if brev_id: - url = f"https://187890-{brev_id}.brevlab.com/" + url = f"https://80810-{brev_id}.brevlab.com/" else: - url = "http://127.0.0.1:18789/" + url = f"http://127.0.0.1:{PORT}/" if token: url += f"?token={token}" return url @@ -103,6 +123,34 @@ def _gateway_log_ready() -> bool: return False +def _generate_gateway_policy() -> str | None: + """Create a temp policy file suitable for gateway creation. + + Strips ``inference`` (not in the proto schema) and ``process`` (immutable + after creation — including it at creation locks you into it and makes + subsequent updates impossible). + + Returns the path to the temp file, or None if no source policy was found. + The caller is responsible for deleting the file. + """ + if not os.path.isfile(POLICY_FILE): + sys.stderr.write(f"[welcome-ui] Policy file not found: {POLICY_FILE}\n") + return None + + try: + with open(POLICY_FILE) as f: + raw = f.read() + stripped = _strip_policy_fields(raw, extra_fields=("process",)) + fd, path = tempfile.mkstemp(suffix=".yaml", prefix="sandbox-policy-") + with os.fdopen(fd, "w") as f: + f.write(stripped) + sys.stderr.write(f"[welcome-ui] Generated gateway policy from {POLICY_FILE} → {path}\n") + return path + except Exception as exc: + sys.stderr.write(f"[welcome-ui] Failed to generate gateway policy: {exc}\n") + return None + + def _cleanup_existing_sandbox(): """Delete any leftover sandbox named 'nemoclaw' from a previous attempt.""" try: @@ -127,6 +175,8 @@ def _run_sandbox_create(): chat_ui_url = _build_openclaw_url(token=None) + policy_path = _generate_gateway_policy() + env = os.environ.copy() # Use `env` to inject vars into the sandbox command. Avoids the # nemoclaw -e flag which has a quoting bug that causes SSH to @@ -138,6 +188,10 @@ def _run_sandbox_create(): "--name", "nemoclaw", "--from", NEMOCLAW_IMAGE, "--forward", "18789", + ] + if policy_path: + cmd += ["--policy", policy_path] + cmd += [ "--", "env", f"CHAT_UI_URL={chat_ui_url}", @@ -175,6 +229,12 @@ def _stream_output(): proc.wait() streamer.join(timeout=5) + if policy_path: + try: + os.unlink(policy_path) + except OSError: + pass + if proc.returncode != 0: with _sandbox_lock: _sandbox_state["status"] = "error" @@ -226,29 +286,223 @@ def _get_hostname() -> str: return socket.getfqdn() +def _strip_policy_fields(yaml_text: str, extra_fields: tuple[str, ...] = ()) -> str: + """Remove fields that the gateway does not understand or rejects. + + Always strips ``inference``. Pass additional top-level keys via + *extra_fields* (e.g. ``("process",)``) to strip those too. + """ + remove = {"inference"} | set(extra_fields) + if _yaml is not None: + doc = _yaml.safe_load(yaml_text) + if isinstance(doc, dict): + for key in remove: + doc.pop(key, None) + return _yaml.dump(doc, default_flow_style=False, sort_keys=False) + lines = yaml_text.splitlines(keepends=True) + out, skip = [], False + for line in lines: + if any(re.match(rf"^{re.escape(k)}:", line) for k in remove): + skip = True + continue + if skip and (line[0:1] in (" ", "\t") or line.strip() == ""): + continue + skip = False + out.append(line) + return "".join(out) + + +def _log(msg: str) -> None: + ts = time.strftime("%H:%M:%S") + sys.stderr.write(f"[policy-sync {ts}] {msg}\n") + sys.stderr.flush() + + +def _sync_policy_to_gateway(yaml_text: str, sandbox_name: str = "nemoclaw") -> dict: + """Push a policy YAML to the NemoClaw gateway via the host-side CLI.""" + _log(f"step 2/4: stripping inference+process fields ({len(yaml_text)} bytes in)") + stripped = _strip_policy_fields(yaml_text, extra_fields=("process",)) + _log(f" stripped to {len(stripped)} bytes") + + fd, tmp_path = tempfile.mkstemp(suffix=".yaml", prefix="policy-sync-") + try: + with os.fdopen(fd, "w") as f: + f.write(stripped) + cmd = ["nemoclaw", "policy", "set", sandbox_name, "--policy", tmp_path] + _log(f"step 3/4: running {' '.join(cmd)}") + t0 = time.time() + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + elapsed = time.time() - t0 + _log(f" CLI exited {result.returncode} in {elapsed:.1f}s") + if result.stdout.strip(): + _log(f" stdout: {result.stdout.strip()}") + if result.stderr.strip(): + _log(f" stderr: {result.stderr.strip()}") + finally: + os.unlink(tmp_path) + + if result.returncode != 0: + err_msg = (result.stderr or result.stdout or "unknown error").strip() + _log(f"step 4/4: FAILED — {err_msg}") + return {"ok": False, "error": err_msg} + + output = result.stdout + result.stderr + ver_match = re.search(r"version\s+(\d+)", output) + hash_match = re.search(r"hash:\s*([a-f0-9]+)", output) + version = int(ver_match.group(1)) if ver_match else 0 + policy_hash = hash_match.group(1) if hash_match else "" + _log(f"step 4/4: SUCCESS — version={version} hash={policy_hash}") + return {"ok": True, "applied": True, "version": version, "policy_hash": policy_hash} + + class Handler(http.server.SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=ROOT, **kwargs) + _proxy_response = False + def end_headers(self): - self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + if not self._proxy_response: + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") super().end_headers() - # -- Routing -------------------------------------------------------- + # -- Unified routing ------------------------------------------------ - def do_POST(self): + def _route(self): _maybe_detect_brev_id(self.headers.get("Host", "")) - if self.path == "/api/install-openclaw": - return self._handle_install_openclaw() - self.send_error(404) + path = self.path.split("?")[0] - def do_GET(self): - _maybe_detect_brev_id(self.headers.get("Host", "")) - if self.path == "/api/sandbox-status": + if self.headers.get("Upgrade", "").lower() == "websocket" and _sandbox_ready(): + return self._proxy_websocket() + + if self.command == "OPTIONS": + self.send_response(204) + self.end_headers() + return + + if path == "/api/sandbox-status" and self.command == "GET": return self._handle_sandbox_status() - if self.path == "/api/connection-details": + if path == "/api/connection-details" and self.command == "GET": return self._handle_connection_details() - return super().do_GET() + if path == "/api/install-openclaw" and self.command == "POST": + return self._handle_install_openclaw() + if path == "/api/policy-sync" and self.command == "POST": + return self._handle_policy_sync() + + if _sandbox_ready(): + return self._proxy_to_sandbox() + + if self.command in ("GET", "HEAD"): + return super().do_GET() + + self.send_error(404) + + do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = lambda self: self._route() + def do_OPTIONS(self): return self._route() + + # -- Reverse proxy to sandbox -------------------------------------- + + _HOP_BY_HOP = frozenset(( + "connection", "keep-alive", "proxy-authenticate", + "proxy-authorization", "te", "trailers", + "transfer-encoding", "upgrade", + )) + + def _proxy_to_sandbox(self): + """Forward an HTTP request to the sandbox proxy on localhost.""" + try: + conn = http.client.HTTPConnection("127.0.0.1", SANDBOX_PORT, timeout=120) + + body = None + cl = self.headers.get("Content-Length") + if cl: + body = self.rfile.read(int(cl)) + + hdrs = {} + for key, val in self.headers.items(): + if key.lower() == "host": + continue + hdrs[key] = val + hdrs["Host"] = f"127.0.0.1:{SANDBOX_PORT}" + + conn.request(self.command, self.path, body=body, headers=hdrs) + resp = conn.getresponse() + + resp_body = resp.read() + + self._proxy_response = True + self.send_response_only(resp.status, resp.reason) + for key, val in resp.getheaders(): + if key.lower() in self._HOP_BY_HOP: + continue + if key.lower() == "content-length": + continue + self.send_header(key, val) + self.send_header("Content-Length", str(len(resp_body))) + self.end_headers() + + self.wfile.write(resp_body) + self.wfile.flush() + conn.close() + except Exception as exc: + sys.stderr.write(f"[welcome-ui] proxy error: {exc}\n") + try: + self.send_error(502, "Sandbox unavailable") + except Exception: + pass + finally: + self._proxy_response = False + self.close_connection = True + + def _proxy_websocket(self): + """Pipe a WebSocket upgrade to the sandbox via raw sockets.""" + try: + upstream = socket.create_connection( + ("127.0.0.1", SANDBOX_PORT), timeout=5, + ) + except OSError: + self.send_error(502, "Sandbox unavailable") + return + + req = f"{self.requestline}\r\n" + for key, val in self.headers.items(): + if key.lower() == "host": + req += f"Host: 127.0.0.1:{SANDBOX_PORT}\r\n" + else: + req += f"{key}: {val}\r\n" + req += "\r\n" + upstream.sendall(req.encode()) + + client = self.connection + + def _pipe(src, dst): + try: + while True: + data = src.recv(65536) + if not data: + break + dst.sendall(data) + except Exception: + pass + try: + dst.shutdown(socket.SHUT_WR) + except Exception: + pass + + t1 = threading.Thread(target=_pipe, args=(client, upstream), daemon=True) + t2 = threading.Thread(target=_pipe, args=(upstream, client), daemon=True) + t1.start() + t2.start() + t1.join(timeout=7200) + t2.join(timeout=7200) + try: + upstream.close() + except Exception: + pass + self.close_connection = True # -- POST /api/install-openclaw ------------------------------------ @@ -275,15 +529,37 @@ def _handle_install_openclaw(self): return self._json_response(200, {"ok": True}) + # -- POST /api/policy-sync ------------------------------------------ + + def _handle_policy_sync(self): + origin = self.headers.get("Origin", "unknown") + _log(f"── POST /api/policy-sync received (origin={origin})") + _log("step 1/4: reading request body") + content_length = int(self.headers.get("Content-Length", 0)) + if content_length == 0: + _log(" REJECTED: empty body") + return self._json_response(400, {"ok": False, "error": "empty body"}) + body = self.rfile.read(content_length).decode("utf-8", errors="replace") + _log(f" received {len(body)} bytes") + if "version:" not in body: + _log(" REJECTED: missing version field") + return self._json_response(400, { + "ok": False, "error": "invalid policy: missing version field", + }) + result = _sync_policy_to_gateway(body) + status = 200 if result.get("ok") else 502 + _log(f"── responding {status}: {json.dumps(result)}") + return self._json_response(status, result) + # -- GET /api/sandbox-status ---------------------------------------- def _handle_sandbox_status(self): with _sandbox_lock: state = dict(_sandbox_state) - if (state["status"] == "creating" + if (state["status"] in ("creating", "idle") and _gateway_log_ready() - and _port_open("127.0.0.1", 18789)): + and _port_open("127.0.0.1", SANDBOX_PORT)): token = _read_openclaw_token() url = _build_openclaw_url(token) with _sandbox_lock: diff --git a/sandboxes/nemoclaw/Dockerfile b/sandboxes/nemoclaw/Dockerfile index b7a3fb0..3fd648c 100644 --- a/sandboxes/nemoclaw/Dockerfile +++ b/sandboxes/nemoclaw/Dockerfile @@ -21,8 +21,15 @@ COPY nemoclaw-start.sh /usr/local/bin/nemoclaw-start RUN chmod +x /usr/local/bin/nemoclaw-start # Install the policy reverse proxy (sits in front of the OpenClaw gateway, -# intercepts /api/policy to read/write /etc/navigator/policy.yaml) +# intercepts /api/policy to read/write the sandbox policy file) and its +# runtime dependencies for gRPC gateway sync. COPY policy-proxy.js /usr/local/lib/policy-proxy.js +COPY proto/ /usr/local/lib/nemoclaw-proto/ +RUN npm install -g @grpc/grpc-js @grpc/proto-loader js-yaml + +# Allow the sandbox user to read the default policy (the startup script +# copies it to a writable location; this chown covers non-Landlock envs) +RUN chown -R sandbox:sandbox /etc/navigator # Stage the NeMoClaw DevX extension source COPY nemoclaw-ui-extension/extension/ /opt/nemoclaw-devx/ diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index 58f6acb..4ed94c4 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -111,10 +111,22 @@ json.dump(cfg, open(os.environ['HOME'] + '/.openclaw/openclaw.json', 'w'), inden nohup openclaw gateway > /tmp/gateway.log 2>&1 & +# Copy the default policy to a writable location so that policy-proxy can +# update it at runtime. /etc is read-only under Landlock, but /sandbox is +# read-write, so we use /sandbox/.openclaw/ which is already owned by the +# sandbox user. +_POLICY_SRC="/etc/navigator/policy.yaml" +_POLICY_DST="/sandbox/.openclaw/policy.yaml" +if [ ! -f "$_POLICY_DST" ] && [ -f "$_POLICY_SRC" ]; then + cp "$_POLICY_SRC" "$_POLICY_DST" 2>/dev/null || true +fi +_POLICY_PATH="${_POLICY_DST}" +[ -f "$_POLICY_PATH" ] || _POLICY_PATH="$_POLICY_SRC" + # Start the policy reverse proxy on the public-facing port. It forwards all # traffic to the OpenClaw gateway on the internal port and intercepts -# /api/policy requests to read/write /etc/navigator/policy.yaml. -UPSTREAM_PORT=${INTERNAL_GATEWAY_PORT} LISTEN_PORT=${PUBLIC_PORT} \ +# /api/policy requests to read/write the sandbox policy file. +NODE_PATH=$(npm root -g) POLICY_PATH=${_POLICY_PATH} UPSTREAM_PORT=${INTERNAL_GATEWAY_PORT} LISTEN_PORT=${PUBLIC_PORT} \ nohup node /usr/local/lib/policy-proxy.js >> /tmp/gateway.log 2>&1 & # Auto-approve pending device pairing requests so the browser is paired diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/icons.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/icons.ts index d0523cb..fbf1628 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/icons.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/icons.ts @@ -52,6 +52,8 @@ export const ICON_CHEVRON_RIGHT = ``; +export const ICON_WARNING = ``; + export const TARGET_ICONS: Record = { "dgx-spark": ICON_CHIP, "dgx-station": ICON_SERVER, diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts index 7b82564..c8a7c16 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts @@ -27,7 +27,7 @@ interface NemoClawPage { const NEMOCLAW_PAGES: NemoClawPage[] = [ { id: "nemoclaw-policy", - label: "Policy", + label: "Sandbox Policy", icon: ICON_SHIELD, title: "Sandbox Policy", subtitle: "View and manage sandbox security guardrails", diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts index a82de3d..c81ade9 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts @@ -13,13 +13,16 @@ import { ICON_INFO, ICON_PLUS, ICON_TRASH, - ICON_EDIT, ICON_CHECK, ICON_CHEVRON_RIGHT, ICON_CHEVRON_DOWN, ICON_LOADER, ICON_TERMINAL, ICON_CLOSE, + ICON_SHIELD, + ICON_FOLDER, + ICON_USER, + ICON_WARNING, } from "./icons.ts"; // --------------------------------------------------------------------------- @@ -143,16 +146,42 @@ async function fetchPolicy(): Promise { return res.text(); } -async function savePolicy(yamlText: string): Promise { +interface SavePolicyResult { + ok: boolean; + applied?: boolean; + version?: number; + policy_hash?: string; + reason?: string; +} + +async function savePolicy(yamlText: string): Promise { + console.log("[policy-save] step 1/2: POST /api/policy →", yamlText.length, "bytes"); const res = await fetch("/api/policy", { method: "POST", headers: { "Content-Type": "text/yaml" }, body: yamlText, }); + const body = await res.json().catch(() => ({})) as SavePolicyResult; + console.log("[policy-save] step 1/2: proxy responded", JSON.stringify(body)); if (!res.ok) { - const body = await res.json().catch(() => ({})); throw new Error((body as { error?: string }).error || `Save failed: ${res.status}`); } + return body; +} + +async function syncPolicyViaHost(yamlText: string): Promise { + console.log("[policy-save] step 2/2: POST /api/policy-sync →", yamlText.length, "bytes"); + const res = await fetch("/api/policy-sync", { + method: "POST", + headers: { "Content-Type": "text/yaml" }, + body: yamlText, + }); + const body = await res.json().catch(() => ({})) as SavePolicyResult; + console.log("[policy-save] step 2/2: host relay responded", JSON.stringify(body)); + if (!res.ok) { + throw new Error((body as { error?: string }).error || `Host sync failed: ${res.status}`); + } + return body; } // --------------------------------------------------------------------------- @@ -164,7 +193,7 @@ export function renderPolicyPage(container: HTMLElement): void {
Sandbox Policy
-
Security guardrails that control what your sandbox can do
+
Controls what code in your sandbox can access
@@ -189,10 +218,12 @@ async function loadAndRender(container: HTMLElement): Promise { changeTracker.deleted.clear(); renderPageContent(page); } catch (err) { + const errStr = String(err); + const is404 = errStr.includes("404"); page.innerHTML = `
-

Could not load the sandbox policy.

-

${escapeHtml(String(err))}

+

${is404 ? "Policy file not found. The sandbox may still be starting." : "Could not load the sandbox policy."}

+

${escapeHtml(errStr)}

`; page.querySelector(".nemoclaw-policy-retry-btn")?.addEventListener("click", () => { @@ -215,225 +246,196 @@ function renderPageContent(page: HTMLElement): void { page.innerHTML = ""; - page.appendChild(buildStatusBar()); - - page.appendChild(buildImmutableDisclosure()); - - page.appendChild(buildNetworkPoliciesSection()); + page.appendChild(buildTabLayout()); saveBarEl = buildSaveBar(); page.appendChild(saveBarEl); } // --------------------------------------------------------------------------- -// Status bar (replaces intro section) +// Tab layout (Editable default, Locked for inspection) // --------------------------------------------------------------------------- -function buildStatusBar(): HTMLElement { - const el = document.createElement("div"); - el.className = "nemoclaw-policy-statusbar"; +function buildTabLayout(): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "nemoclaw-policy-tabs-wrapper"; const policies = currentPolicy?.network_policies || {}; const policyCount = Object.keys(policies).length; - let totalEndpoints = 0; - let totalBinaries = 0; - for (const p of Object.values(policies)) { - totalEndpoints += p.endpoints?.length || 0; - totalBinaries += p.binaries?.length || 0; - } - const stats = document.createElement("div"); - stats.className = "nemoclaw-policy-stats"; - - const statData: { value: number; label: string; scrollTo: string }[] = [ - { value: 3, label: "Immutable", scrollTo: "immutable" }, - { value: policyCount, label: "Net Rules", scrollTo: "network" }, - { value: totalEndpoints, label: "Endpoints", scrollTo: "network" }, - { value: totalBinaries, label: "Binaries", scrollTo: "network" }, - ]; - - for (const s of statData) { - const stat = document.createElement("button"); - stat.type = "button"; - stat.className = "nemoclaw-policy-stat"; - stat.innerHTML = ` - ${s.value} - ${s.label}`; - stat.addEventListener("click", () => { - const target = document.querySelector(`[data-section="${s.scrollTo}"]`); - target?.scrollIntoView({ behavior: "smooth", block: "start" }); - }); - stats.appendChild(stat); - } + const tabbar = document.createElement("div"); + tabbar.className = "nemoclaw-policy-tabbar"; - el.appendChild(stats); + const editableTab = document.createElement("button"); + editableTab.type = "button"; + editableTab.className = "nemoclaw-policy-tabbar__tab nemoclaw-policy-tabbar__tab--active"; + editableTab.innerHTML = `Editable ${policyCount}`; - const oneliner = document.createElement("div"); - oneliner.className = "nemoclaw-policy-oneliner"; - oneliner.innerHTML = ` - Policies are kernel-enforced guardrails. - ${ICON_LOCK} Immutable at runtime - ${ICON_EDIT} Editable while running`; + const lockedTab = document.createElement("button"); + lockedTab.type = "button"; + lockedTab.className = "nemoclaw-policy-tabbar__tab"; + lockedTab.innerHTML = `${ICON_LOCK} Locked`; - el.appendChild(oneliner); - return el; + tabbar.appendChild(editableTab); + tabbar.appendChild(lockedTab); + wrapper.appendChild(tabbar); + + const editablePanel = document.createElement("div"); + editablePanel.className = "nemoclaw-policy-tab-panel"; + editablePanel.appendChild(buildNetworkPoliciesSection()); + + const lockedPanel = document.createElement("div"); + lockedPanel.className = "nemoclaw-policy-tab-panel"; + lockedPanel.style.display = "none"; + lockedPanel.appendChild(buildImmutableGrid()); + + wrapper.appendChild(editablePanel); + wrapper.appendChild(lockedPanel); + + editableTab.addEventListener("click", () => { + editableTab.classList.add("nemoclaw-policy-tabbar__tab--active"); + lockedTab.classList.remove("nemoclaw-policy-tabbar__tab--active"); + editablePanel.style.display = ""; + lockedPanel.style.display = "none"; + }); + + lockedTab.addEventListener("click", () => { + lockedTab.classList.add("nemoclaw-policy-tabbar__tab--active"); + editableTab.classList.remove("nemoclaw-policy-tabbar__tab--active"); + lockedPanel.style.display = ""; + editablePanel.style.display = "none"; + }); + + return wrapper; } // --------------------------------------------------------------------------- -// Immutable disclosure (replaces three separate cards) +// Immutable grid (3 flat read-only cards) // --------------------------------------------------------------------------- -function buildImmutableDisclosure(): HTMLElement { +function buildImmutableGrid(): HTMLElement { const section = document.createElement("div"); - section.className = "nemoclaw-policy-disclosure"; + section.className = "nemoclaw-policy-immutable-section"; section.dataset.section = "immutable"; - const fs = currentPolicy?.filesystem_policy; - const ll = currentPolicy?.landlock; - const proc = currentPolicy?.process; + const intro = document.createElement("p"); + intro.className = "nemoclaw-policy-immutable-intro"; + intro.textContent = "These policies are set when the sandbox is created and cannot be changed at runtime. They define the security boundary that all code inside the sandbox must operate within."; + section.appendChild(intro); - const roCount = fs?.read_only?.length || 0; - const rwCount = fs?.read_write?.length || 0; - const user = proc?.run_as_user || "not set"; - const compat = ll?.compatibility || "not set"; + const grid = document.createElement("div"); + grid.className = "nemoclaw-policy-immutable-grid"; - const header = document.createElement("button"); - header.type = "button"; - header.className = "nemoclaw-policy-disclosure__header"; - header.innerHTML = ` - ${ICON_CHEVRON_RIGHT} - ${ICON_LOCK} - Immutable Configuration - Set at sandbox creation`; - - const summary = document.createElement("div"); - summary.className = "nemoclaw-policy-disclosure__summary"; - summary.innerHTML = ` - ${escapeHtml(user)} user · - ${roCount} read-only paths · - ${rwCount} read-write paths · - Landlock: ${escapeHtml(compat)}`; + grid.appendChild(buildFilesystemCard()); + grid.appendChild(buildProcessCard()); + grid.appendChild(buildKernelCard()); - const body = document.createElement("div"); - body.className = "nemoclaw-policy-disclosure__body"; - body.style.display = "none"; + section.appendChild(grid); - const note = document.createElement("p"); - note.className = "nemoclaw-policy-disclosure__note"; - note.innerHTML = `To modify these, update policy.yaml and recreate the sandbox.`; - body.appendChild(note); - - const tabs = document.createElement("div"); - tabs.className = "nemoclaw-policy-tabs"; - const tabDefs = [ - { id: "filesystem", label: "Filesystem" }, - { id: "landlock", label: "Landlock" }, - { id: "process", label: "Process Identity" }, - ]; - const panels: Record = {}; - - for (const t of tabDefs) { - const tab = document.createElement("button"); - tab.type = "button"; - tab.className = "nemoclaw-policy-tab" + (t.id === "filesystem" ? " nemoclaw-policy-tab--active" : ""); - tab.textContent = t.label; - tab.dataset.tab = t.id; - tab.addEventListener("click", () => { - tabs.querySelectorAll(".nemoclaw-policy-tab").forEach((el) => el.classList.remove("nemoclaw-policy-tab--active")); - tab.classList.add("nemoclaw-policy-tab--active"); - for (const [id, panel] of Object.entries(panels)) { - panel.style.display = id === t.id ? "" : "none"; - } - }); - tabs.appendChild(tab); - } - body.appendChild(tabs); - - const fsPanel = document.createElement("div"); - fsPanel.className = "nemoclaw-policy-tab-panel"; - fsPanel.appendChild(buildFilesystemContent()); - panels["filesystem"] = fsPanel; - body.appendChild(fsPanel); - - const llPanel = document.createElement("div"); - llPanel.className = "nemoclaw-policy-tab-panel"; - llPanel.style.display = "none"; - llPanel.appendChild(buildLandlockContent()); - panels["landlock"] = llPanel; - body.appendChild(llPanel); - - const procPanel = document.createElement("div"); - procPanel.className = "nemoclaw-policy-tab-panel"; - procPanel.style.display = "none"; - procPanel.appendChild(buildProcessContent()); - panels["process"] = procPanel; - body.appendChild(procPanel); + const footer = document.createElement("p"); + footer.className = "nemoclaw-policy-immutable-footer"; + footer.innerHTML = `To modify these settings, update policy.yaml and recreate the sandbox.`; + section.appendChild(footer); - let expanded = false; - header.addEventListener("click", () => { - expanded = !expanded; - body.style.display = expanded ? "" : "none"; - summary.style.display = expanded ? "none" : ""; - section.classList.toggle("nemoclaw-policy-disclosure--expanded", expanded); - }); - - section.appendChild(header); - section.appendChild(summary); - section.appendChild(body); return section; } -function buildFilesystemContent(): HTMLElement { - const el = document.createElement("div"); - el.className = "nemoclaw-policy-card__content"; +function buildFilesystemCard(): HTMLElement { + const card = document.createElement("div"); + card.className = "nemoclaw-policy-imm-card"; + const fs = currentPolicy?.filesystem_policy; - if (!fs) { - el.innerHTML = `No filesystem policy defined`; - return el; - } - let html = ""; - if (fs.include_workdir !== undefined) { - html += `
Include workdir: ${fs.include_workdir ? "Yes" : "No"}
`; - } - if (fs.read_only?.length) { - html += `
Read-only paths:
`; - html += `
${fs.read_only.map((p) => `${escapeHtml(p)}`).join("")}
`; - } - if (fs.read_write?.length) { - html += `
Read-write paths:
`; - html += `
${fs.read_write.map((p) => `${escapeHtml(p)}`).join("")}
`; + card.innerHTML = ` +
+ ${ICON_FOLDER} + Filesystem Access + ${ICON_LOCK} +
+
Paths the sandbox can read or write
`; + + const content = document.createElement("div"); + content.className = "nemoclaw-policy-imm-card__content"; + + if (!fs) { + content.innerHTML = `No filesystem policy defined`; + } else { + let html = ""; + if (fs.read_only?.length) { + html += `
Read-only
`; + html += `
${fs.read_only.map((p) => `${escapeHtml(p)}`).join("")}
`; + } + if (fs.read_write?.length) { + html += `
Read-write
`; + html += `
${fs.read_write.map((p) => `${escapeHtml(p)}`).join("")}
`; + } + if (fs.include_workdir) { + html += `
Working directory included
`; + } + content.innerHTML = html; } - el.innerHTML = html; - return el; + card.appendChild(content); + return card; } -function buildLandlockContent(): HTMLElement { - const el = document.createElement("div"); - el.className = "nemoclaw-policy-card__content"; - const ll = currentPolicy?.landlock; - el.innerHTML = `
- Compatibility: - ${escapeHtml(ll?.compatibility || "not set")} -
`; - return el; -} +function buildProcessCard(): HTMLElement { + const card = document.createElement("div"); + card.className = "nemoclaw-policy-imm-card"; -function buildProcessContent(): HTMLElement { - const el = document.createElement("div"); - el.className = "nemoclaw-policy-card__content"; const p = currentPolicy?.process; - el.innerHTML = ` + const user = p?.run_as_user || "not set"; + const group = p?.run_as_group || "not set"; + + card.innerHTML = ` +
+ ${ICON_USER} + Process Identity + ${ICON_LOCK} +
+
All code runs as this OS user
`; + + const content = document.createElement("div"); + content.className = "nemoclaw-policy-imm-card__content"; + content.innerHTML = `
- Run as user: - ${escapeHtml(p?.run_as_user || "not set")} + User + ${escapeHtml(user)}
- Run as group: - ${escapeHtml(p?.run_as_group || "not set")} + Group + ${escapeHtml(group)}
`; - return el; + + card.appendChild(content); + return card; +} + +function buildKernelCard(): HTMLElement { + const card = document.createElement("div"); + card.className = "nemoclaw-policy-imm-card"; + + const ll = currentPolicy?.landlock; + const compat = ll?.compatibility || "not set"; + + card.innerHTML = ` +
+ ${ICON_SHIELD} + Kernel Enforcement + ${ICON_LOCK} +
+
Linux kernel restricts filesystem and network access
`; + + const content = document.createElement("div"); + content.className = "nemoclaw-policy-imm-card__content"; + content.innerHTML = ` +
+ Mode + ${escapeHtml(compat)} +
`; + + card.appendChild(content); + return card; } // --------------------------------------------------------------------------- @@ -453,8 +455,7 @@ function buildNetworkPoliciesSection(): HTMLElement { headerRow.innerHTML = ` ${ICON_GLOBE}

Network Policies

- ${policyCount} - ${ICON_EDIT} Editable`; + ${policyCount}`; const searchInput = document.createElement("input"); searchInput.type = "search"; @@ -480,19 +481,22 @@ function buildNetworkPoliciesSection(): HTMLElement { const desc = document.createElement("p"); desc.className = "nemoclaw-policy-section__desc"; - desc.innerHTML = `Controls which external hosts your sandbox can connect to. Each rule binds endpoints to specific binaries.`; + desc.textContent = "Each rule controls which binaries can reach which hosts. All outbound access is denied by default \u2014 add permissions below to allow specific connections."; section.appendChild(desc); const list = document.createElement("div"); list.className = "nemoclaw-policy-netpolicies"; - for (const [key, policy] of Object.entries(policies)) { - list.appendChild(buildNetworkPolicyCard(key, policy, list)); + if (policyCount === 0) { + list.appendChild(buildNetworkEmptyState()); + } else { + for (const [key, policy] of Object.entries(policies)) { + list.appendChild(buildNetworkPolicyCard(key, policy, list)); + } } section.appendChild(list); - // Add policy button with template dropdown const addWrap = document.createElement("div"); addWrap.className = "nemoclaw-policy-add-wrap"; @@ -520,11 +524,28 @@ function buildNetworkPoliciesSection(): HTMLElement { dropdownEl = document.createElement("div"); dropdownEl.className = "nemoclaw-policy-templates"; + // Blank option at the top + const blankOpt = document.createElement("button"); + blankOpt.type = "button"; + blankOpt.className = "nemoclaw-policy-template-option nemoclaw-policy-template-option--blank"; + blankOpt.innerHTML = `Blank + Start from scratch`; + blankOpt.addEventListener("click", (ev) => { + ev.stopPropagation(); + closeDropdown(); + showInlineNewPolicyForm(list); + }); + dropdownEl.appendChild(blankOpt); + for (const tmpl of POLICY_TEMPLATES) { + const hosts = tmpl.policy.endpoints.map((ep) => ep.host).filter(Boolean).slice(0, 2).join(", "); + const bins = tmpl.policy.binaries.map((b) => b.path.split("/").pop()).join(", "); + const opt = document.createElement("button"); opt.type = "button"; opt.className = "nemoclaw-policy-template-option"; - opt.textContent = tmpl.label; + opt.innerHTML = `${escapeHtml(tmpl.label)} + ${escapeHtml(hosts)} — ${escapeHtml(bins)}`; opt.addEventListener("click", (ev) => { ev.stopPropagation(); closeDropdown(); @@ -533,17 +554,6 @@ function buildNetworkPoliciesSection(): HTMLElement { dropdownEl.appendChild(opt); } - const customOpt = document.createElement("button"); - customOpt.type = "button"; - customOpt.className = "nemoclaw-policy-template-option nemoclaw-policy-template-option--custom"; - customOpt.textContent = "Custom (blank)"; - customOpt.addEventListener("click", (ev) => { - ev.stopPropagation(); - closeDropdown(); - showInlineNewPolicyForm(list); - }); - dropdownEl.appendChild(customOpt); - addWrap.appendChild(dropdownEl); }); @@ -555,6 +565,45 @@ function buildNetworkPoliciesSection(): HTMLElement { return section; } +// --------------------------------------------------------------------------- +// Network empty state +// --------------------------------------------------------------------------- + +function buildNetworkEmptyState(): HTMLElement { + const el = document.createElement("div"); + el.className = "nemoclaw-policy-net-empty"; + el.innerHTML = ` + ${ICON_GLOBE} + No network policies + Your sandbox cannot make outbound connections.`; + return el; +} + +// --------------------------------------------------------------------------- +// Network policy card +// --------------------------------------------------------------------------- + +function hasEnforcement(policy: NetworkPolicy): boolean { + return (policy.endpoints || []).some((ep) => ep.enforcement === "enforce"); +} + +function hasAudit(policy: NetworkPolicy): boolean { + return (policy.endpoints || []).some((ep) => ep.enforcement === "audit"); +} + +function generatePolicyTooltip(policy: NetworkPolicy): string { + const bins = (policy.binaries || []).map((b) => b.path.split("/").pop()).filter(Boolean); + const hosts = (policy.endpoints || []).map((ep) => ep.host).filter(Boolean) as string[]; + if (!bins.length && !hosts.length) return ""; + + const binStr = bins.length <= 2 ? bins.join(" and ") : `${bins[0]} and ${bins.length - 1} others`; + const hostStr = hosts.length <= 2 ? hosts.join(" and ") : `${hosts[0]} and ${hosts.length - 1} other hosts`; + + if (bins.length && hosts.length) return `Allows ${binStr} to reach ${hostStr}`; + if (hosts.length) return `Allows connections to ${hostStr}`; + return ""; +} + function buildNetworkPolicyCard(key: string, policy: NetworkPolicy, list: HTMLElement): HTMLElement { const card = document.createElement("div"); card.className = "nemoclaw-policy-netcard"; @@ -563,12 +612,24 @@ function buildNetworkPolicyCard(key: string, policy: NetworkPolicy, list: HTMLEl const header = document.createElement("div"); header.className = "nemoclaw-policy-netcard__header"; + const enforcing = hasEnforcement(policy); + const auditing = hasAudit(policy); + const enfIndicator = enforcing + ? `L7 Enforced` + : auditing + ? `L7 Audit` + : `L4 Default`; + const toggle = document.createElement("button"); toggle.type = "button"; toggle.className = "nemoclaw-policy-netcard__toggle"; toggle.innerHTML = `${ICON_CHEVRON_RIGHT} ${escapeHtml(policy.name || key)} - ${policy.endpoints?.length || 0} endpoint${(policy.endpoints?.length || 0) !== 1 ? "s" : ""}, ${policy.binaries?.length || 0} binar${(policy.binaries?.length || 0) !== 1 ? "ies" : "y"}`; + ${enfIndicator} + ${policy.endpoints?.length || 0} endpoint${(policy.endpoints?.length || 0) !== 1 ? "s" : ""}, ${policy.binaries?.length || 0} ${(policy.binaries?.length || 0) !== 1 ? "binaries" : "binary"}`; + + const tooltip = generatePolicyTooltip(policy); + if (tooltip) toggle.title = tooltip; const actions = document.createElement("div"); actions.className = "nemoclaw-policy-netcard__actions"; @@ -587,7 +648,6 @@ function buildNetworkPolicyCard(key: string, policy: NetworkPolicy, list: HTMLEl header.appendChild(toggle); header.appendChild(actions); - // Host preview chips (visible when collapsed) const preview = document.createElement("div"); preview.className = "nemoclaw-policy-netcard__preview"; const hosts = (policy.endpoints || []).map((ep) => ep.host).filter(Boolean) as string[]; @@ -654,7 +714,7 @@ function showDeleteConfirmation(actions: HTMLElement, deleteBtn: HTMLElement, ke card.classList.remove("nemoclaw-policy-netcard--confirming"); }; - const timeout = setTimeout(revert, 3000); + const timeout = setTimeout(revert, 5000); cancelBtn.addEventListener("click", (e) => { e.stopPropagation(); @@ -670,17 +730,23 @@ function showDeleteConfirmation(actions: HTMLElement, deleteBtn: HTMLElement, ke markDirty(key, "deleted"); card.remove(); updateNetworkCount(); + if (Object.keys(currentPolicy.network_policies).length === 0) { + const list = document.querySelector(".nemoclaw-policy-netpolicies"); + if (list) list.appendChild(buildNetworkEmptyState()); + } } }); } // --------------------------------------------------------------------------- -// Inline new-policy form (replaces prompt/alert) +// Inline new-policy form // --------------------------------------------------------------------------- function showInlineNewPolicyForm(list: HTMLElement, template?: { key: string; label: string; policy: NetworkPolicy }): void { const existing = list.querySelector(".nemoclaw-policy-newcard"); if (existing) existing.remove(); + const emptyState = list.querySelector(".nemoclaw-policy-net-empty"); + if (emptyState) emptyState.remove(); const form = document.createElement("div"); form.className = "nemoclaw-policy-newcard"; @@ -717,7 +783,12 @@ function showInlineNewPolicyForm(list: HTMLElement, template?: { key: string; la requestAnimationFrame(() => input.focus()); - const cancel = () => form.remove(); + const cancel = () => { + form.remove(); + if (currentPolicy && Object.keys(currentPolicy.network_policies || {}).length === 0) { + list.appendChild(buildNetworkEmptyState()); + } + }; cancelBtn.addEventListener("click", cancel); input.addEventListener("keydown", (e) => { @@ -770,12 +841,11 @@ function showInlineNewPolicyForm(list: HTMLElement, template?: { key: string; la function renderNetworkPolicyBody(body: HTMLElement, key: string, policy: NetworkPolicy): void { body.innerHTML = ""; - // Endpoints section const epSection = document.createElement("div"); epSection.className = "nemoclaw-policy-subsection"; epSection.innerHTML = `
- Endpoints - ${ICON_INFO} + Allowed Endpoints + ${ICON_INFO}
`; const epList = document.createElement("div"); @@ -798,14 +868,12 @@ function renderNetworkPolicyBody(body: HTMLElement, key: string, policy: Network epList.appendChild(buildEndpointRow(key, newEp, policy.endpoints.length - 1)); }); epSection.appendChild(addEpBtn); - body.appendChild(epSection); - // Binaries section const binSection = document.createElement("div"); binSection.className = "nemoclaw-policy-subsection"; binSection.innerHTML = `
Allowed Binaries - ${ICON_INFO} + ${ICON_INFO}
`; const binList = document.createElement("div"); @@ -828,13 +896,19 @@ function renderNetworkPolicyBody(body: HTMLElement, key: string, policy: Network binList.appendChild(buildBinaryRow(key, policy, newBin, policy.binaries.length - 1)); }); binSection.appendChild(addBinBtn); + body.appendChild(binSection); + body.appendChild(epSection); } // --------------------------------------------------------------------------- -// Endpoint row +// Endpoint row (progressive: Host+Port primary, advanced toggle) // --------------------------------------------------------------------------- +function hasAdvancedFields(ep: PolicyEndpoint): boolean { + return !!(ep.protocol || ep.tls || ep.enforcement || ep.access); +} + function buildEndpointRow(policyKey: string, ep: PolicyEndpoint, idx: number): HTMLElement { const row = document.createElement("div"); row.className = "nemoclaw-policy-ep-row"; @@ -851,13 +925,51 @@ function buildEndpointRow(policyKey: string, ep: PolicyEndpoint, idx: number): H mainLine.appendChild(hostInput); mainLine.appendChild(portInput); + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger nemoclaw-policy-ep-row__del"; + delBtn.title = "Remove endpoint"; + delBtn.innerHTML = ICON_TRASH; + delBtn.addEventListener("click", () => { + const policy = currentPolicy?.network_policies?.[policyKey]; + if (policy?.endpoints) { + policy.endpoints.splice(idx, 1); + markDirty(policyKey, "modified"); + row.remove(); + } + }); + mainLine.appendChild(delBtn); + row.appendChild(mainLine); + + // Advanced options (progressive disclosure) + const advancedExpanded = hasAdvancedFields(ep); + + const advToggle = document.createElement("button"); + advToggle.type = "button"; + advToggle.className = "nemoclaw-policy-ep-advanced-toggle"; + advToggle.innerHTML = `${ICON_CHEVRON_RIGHT} Advanced Settings ${ICON_INFO}`; + if (advancedExpanded) advToggle.classList.add("nemoclaw-policy-ep-advanced-toggle--open"); + const optsLine = document.createElement("div"); optsLine.className = "nemoclaw-policy-ep-row__opts"; + optsLine.style.display = advancedExpanded ? "" : "none"; const protoSelect = createSelect("Protocol", [ { value: "", label: "(none)" }, - { value: "rest", label: "REST (L7 inspection)" }, - ], ep.protocol || "", (v) => { ep.protocol = v || undefined; markDirty(policyKey, "modified"); }, "REST enables HTTP method/path inspection"); + { value: "rest", label: "REST (HTTP inspection)" }, + ], ep.protocol || "", (v) => { + ep.protocol = v || undefined; + markDirty(policyKey, "modified"); + if (v === "rest") { + let rulesEl = row.querySelector(".nemoclaw-policy-ep-rules"); + if (!rulesEl) { + const sibling = row.querySelector(".nemoclaw-policy-ep-ips") || null; + const newRulesEl = buildHttpRulesEditor(policyKey, ep); + if (sibling) row.insertBefore(newRulesEl, sibling); + else row.appendChild(newRulesEl); + } + } + }, "REST enables HTTP method/path inspection"); const tlsSelect = createSelect("TLS", [ { value: "", label: "(none)" }, @@ -883,30 +995,19 @@ function buildEndpointRow(policyKey: string, ep: PolicyEndpoint, idx: number): H optsLine.appendChild(enfSelect); optsLine.appendChild(accessSelect); - const delBtn = document.createElement("button"); - delBtn.type = "button"; - delBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger nemoclaw-policy-ep-row__del"; - delBtn.title = "Remove endpoint"; - delBtn.innerHTML = ICON_TRASH; - delBtn.addEventListener("click", () => { - const policy = currentPolicy?.network_policies?.[policyKey]; - if (policy?.endpoints) { - policy.endpoints.splice(idx, 1); - markDirty(policyKey, "modified"); - row.remove(); - } + advToggle.addEventListener("click", () => { + const isOpen = optsLine.style.display !== "none"; + optsLine.style.display = isOpen ? "none" : ""; + advToggle.classList.toggle("nemoclaw-policy-ep-advanced-toggle--open", !isOpen); }); - mainLine.appendChild(delBtn); - row.appendChild(mainLine); + row.appendChild(advToggle); row.appendChild(optsLine); - // L7 Rules — editable rows if (ep.rules?.length || ep.protocol === "rest") { - row.appendChild(buildL7RulesEditor(policyKey, ep)); + row.appendChild(buildHttpRulesEditor(policyKey, ep)); } - // Allowed IPs — editable rows if (ep.allowed_ips?.length) { row.appendChild(buildAllowedIpsEditor(policyKey, ep)); } @@ -915,25 +1016,30 @@ function buildEndpointRow(policyKey: string, ep: PolicyEndpoint, idx: number): H } // --------------------------------------------------------------------------- -// L7 Rules editor (replaces YAML preview) +// HTTP Rules editor (renamed from L7) // --------------------------------------------------------------------------- -function buildL7RulesEditor(policyKey: string, ep: PolicyEndpoint): HTMLElement { +function buildHttpRulesEditor(policyKey: string, ep: PolicyEndpoint): HTMLElement { const wrapper = document.createElement("div"); wrapper.className = "nemoclaw-policy-ep-rules"; const header = document.createElement("div"); header.className = "nemoclaw-policy-subsection__header"; header.innerHTML = ` - L7 Rules (${ep.rules?.length || 0}) - ${ICON_INFO}`; + HTTP Rules (${ep.rules?.length || 0}) + ${ICON_INFO}`; wrapper.appendChild(header); + const microLabel = document.createElement("div"); + microLabel.className = "nemoclaw-policy-micro-label"; + microLabel.textContent = "Only matching HTTP requests are allowed"; + wrapper.appendChild(microLabel); + const ruleList = document.createElement("div"); ruleList.className = "nemoclaw-policy-rule-list"; (ep.rules || []).forEach((rule, idx) => { - ruleList.appendChild(buildL7RuleRow(policyKey, ep, rule, idx, ruleList)); + ruleList.appendChild(buildHttpRuleRow(policyKey, ep, rule, idx, ruleList)); }); wrapper.appendChild(ruleList); @@ -946,14 +1052,14 @@ function buildL7RulesEditor(policyKey: string, ep: PolicyEndpoint): HTMLElement const newRule = { allow: { method: "GET", path: "" } }; ep.rules.push(newRule); markDirty(policyKey, "modified"); - ruleList.appendChild(buildL7RuleRow(policyKey, ep, newRule, ep.rules.length - 1, ruleList)); + ruleList.appendChild(buildHttpRuleRow(policyKey, ep, newRule, ep.rules.length - 1, ruleList)); }); wrapper.appendChild(addBtn); return wrapper; } -function buildL7RuleRow(policyKey: string, ep: PolicyEndpoint, rule: { allow: { method: string; path: string } }, idx: number, ruleList: HTMLElement): HTMLElement { +function buildHttpRuleRow(policyKey: string, ep: PolicyEndpoint, rule: { allow: { method: string; path: string } }, idx: number, ruleList: HTMLElement): HTMLElement { const row = document.createElement("div"); row.className = "nemoclaw-policy-rule-row"; @@ -971,7 +1077,7 @@ function buildL7RuleRow(policyKey: string, ep: PolicyEndpoint, rule: { allow: { const pathInput = document.createElement("input"); pathInput.type = "text"; pathInput.className = "nemoclaw-policy-input nemoclaw-policy-rule-path"; - pathInput.placeholder = "/**/path"; + pathInput.placeholder = "/**/info/refs*"; pathInput.value = rule.allow.path; pathInput.addEventListener("input", () => { rule.allow.path = pathInput.value; markDirty(policyKey, "modified"); }); @@ -1000,15 +1106,20 @@ function buildL7RuleRow(policyKey: string, ep: PolicyEndpoint, rule: { allow: { function buildAllowedIpsEditor(policyKey: string, ep: PolicyEndpoint): HTMLElement { const wrapper = document.createElement("div"); - wrapper.className = "nemoclaw-policy-ep-rules"; + wrapper.className = "nemoclaw-policy-ep-rules nemoclaw-policy-ep-ips"; const header = document.createElement("div"); header.className = "nemoclaw-policy-subsection__header"; header.innerHTML = ` Allowed IPs - ${ICON_INFO}`; + ${ICON_INFO}`; wrapper.appendChild(header); + const microLabel = document.createElement("div"); + microLabel.className = "nemoclaw-policy-micro-label"; + microLabel.textContent = "Bypasses private IP protection for these ranges"; + wrapper.appendChild(microLabel); + const ipList = document.createElement("div"); ipList.className = "nemoclaw-policy-bin-list"; @@ -1032,6 +1143,12 @@ function buildAllowedIpsEditor(policyKey: string, ep: PolicyEndpoint): HTMLEleme return wrapper; } +function isValidCidr(s: string): boolean { + if (!s.trim()) return true; + const match = s.match(/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/); + return !!match; +} + function buildIpRow(policyKey: string, ep: PolicyEndpoint, ip: string, idx: number): HTMLElement { const row = document.createElement("div"); row.className = "nemoclaw-policy-ip-row"; @@ -1041,11 +1158,22 @@ function buildIpRow(policyKey: string, ep: PolicyEndpoint, ip: string, idx: numb input.className = "nemoclaw-policy-input"; input.placeholder = "10.0.0.0/8"; input.value = ip; + + const errorEl = document.createElement("span"); + errorEl.className = "nemoclaw-policy-ip-error"; + input.addEventListener("input", () => { if (ep.allowed_ips) { ep.allowed_ips[idx] = input.value; markDirty(policyKey, "modified"); } + if (input.value.trim() && !isValidCidr(input.value.trim())) { + errorEl.textContent = "Expected CIDR (e.g. 10.0.0.0/8)"; + input.classList.add("nemoclaw-policy-input--error"); + } else { + errorEl.textContent = ""; + input.classList.remove("nemoclaw-policy-input--error"); + } }); const delBtn = document.createElement("button"); @@ -1063,13 +1191,18 @@ function buildIpRow(policyKey: string, ep: PolicyEndpoint, ip: string, idx: numb row.appendChild(input); row.appendChild(delBtn); + row.appendChild(errorEl); return row; } // --------------------------------------------------------------------------- -// Binary row +// Binary row (with wildcard warning) // --------------------------------------------------------------------------- +function isWildcardBinary(path: string): boolean { + return path === "/**" || path === "/*" || path === "*"; +} + function buildBinaryRow(policyKey: string, policy: NetworkPolicy, bin: PolicyBinary, idx: number): HTMLElement { const row = document.createElement("div"); row.className = "nemoclaw-policy-bin-row"; @@ -1083,7 +1216,18 @@ function buildBinaryRow(policyKey: string, policy: NetworkPolicy, bin: PolicyBin input.className = "nemoclaw-policy-input"; input.placeholder = "/usr/bin/example"; input.value = bin.path; - input.addEventListener("input", () => { bin.path = input.value; markDirty(policyKey, "modified"); }); + + const warningChip = document.createElement("span"); + warningChip.className = "nemoclaw-policy-wildcard-chip"; + warningChip.innerHTML = `${ICON_WARNING} All binaries`; + warningChip.title = "This wildcard allows any binary to use these endpoints"; + warningChip.style.display = isWildcardBinary(bin.path) ? "" : "none"; + + input.addEventListener("input", () => { + bin.path = input.value; + markDirty(policyKey, "modified"); + warningChip.style.display = isWildcardBinary(input.value) ? "" : "none"; + }); const delBtn = document.createElement("button"); delBtn.type = "button"; @@ -1098,12 +1242,13 @@ function buildBinaryRow(policyKey: string, policy: NetworkPolicy, bin: PolicyBin row.appendChild(icon); row.appendChild(input); + row.appendChild(warningChip); row.appendChild(delBtn); return row; } // --------------------------------------------------------------------------- -// Save bar (conditional visibility) +// Save bar // --------------------------------------------------------------------------- function buildSaveBar(): HTMLElement { @@ -1113,8 +1258,10 @@ function buildSaveBar(): HTMLElement { const info = document.createElement("div"); info.className = "nemoclaw-policy-savebar__info"; info.innerHTML = ` - ${ICON_INFO} - Unsaved changes`; +
+ Unsaved changes + Network policies take effect on new connections. +
`; const actions = document.createElement("div"); actions.className = "nemoclaw-policy-savebar__actions"; @@ -1127,7 +1274,7 @@ function buildSaveBar(): HTMLElement { discardBtn.type = "button"; discardBtn.className = "nemoclaw-policy-discard-btn"; discardBtn.textContent = "Discard"; - discardBtn.addEventListener("click", () => handleDiscard(bar)); + discardBtn.addEventListener("click", () => handleDiscard(bar, discardBtn)); const saveBtn = document.createElement("button"); saveBtn.type = "button"; @@ -1157,11 +1304,29 @@ function updateSaveBarSummary(): void { summaryEl.textContent = parts.length > 0 ? `Unsaved: ${parts.join(", ")}` : "Unsaved changes"; } -function handleDiscard(bar: HTMLElement): void { - if (!pageContainer) return; - bar.classList.remove("nemoclaw-policy-savebar--visible"); - bar.classList.add("nemoclaw-policy-savebar--hidden"); - loadAndRender(pageContainer); +function handleDiscard(bar: HTMLElement, discardBtn: HTMLButtonElement): void { + if (discardBtn.dataset.confirming === "true") return; + + discardBtn.dataset.confirming = "true"; + const origText = discardBtn.textContent; + discardBtn.textContent = "Discard all changes?"; + discardBtn.classList.add("nemoclaw-policy-discard-btn--confirming"); + + const timer = setTimeout(() => { + discardBtn.textContent = origText; + discardBtn.classList.remove("nemoclaw-policy-discard-btn--confirming"); + delete discardBtn.dataset.confirming; + }, 3000); + + discardBtn.addEventListener("click", function onConfirm() { + discardBtn.removeEventListener("click", onConfirm); + clearTimeout(timer); + delete discardBtn.dataset.confirming; + if (!pageContainer) return; + bar.classList.remove("nemoclaw-policy-savebar--visible"); + bar.classList.add("nemoclaw-policy-savebar--hidden"); + loadAndRender(pageContainer); + }, { once: true }); } async function handleSave(btn: HTMLButtonElement, feedback: HTMLElement, bar: HTMLElement): Promise { @@ -1179,7 +1344,8 @@ async function handleSave(btn: HTMLButtonElement, feedback: HTMLElement, bar: HT forceQuotes: false, }); - await savePolicy(yamlText); + console.log("[policy-save] ── Save Policy clicked"); + let result = await savePolicy(yamlText); rawYaml = yamlText; isDirty = false; @@ -1187,14 +1353,41 @@ async function handleSave(btn: HTMLButtonElement, feedback: HTMLElement, bar: HT changeTracker.added.clear(); changeTracker.deleted.clear(); + // When the in-sandbox gRPC is blocked by network enforcement, relay + // through the host-side welcome-ui server which can reach the gateway. + if (result.applied === false) { + console.log("[policy-save] proxy gRPC unavailable — falling back to host relay"); + feedback.innerHTML = `${ICON_LOADER} Applying…`; + try { + const hostResult = await syncPolicyViaHost(yamlText); + if (hostResult.ok && hostResult.applied) { + console.log("[policy-save] host relay succeeded — policy applied live"); + result = hostResult; + } else { + console.warn("[policy-save] host relay returned applied=false", hostResult); + } + } catch (relayErr) { + console.warn("[policy-save] host relay failed:", relayErr); + } + } + feedback.className = "nemoclaw-policy-savebar__feedback nemoclaw-policy-savebar__feedback--success"; - feedback.innerHTML = `${ICON_CHECK} Policy saved`; + if (result.applied && result.version) { + console.log(`[policy-save] ── done: applied v${result.version}`); + feedback.innerHTML = `${ICON_CHECK} Policy applied (v${result.version}). New connections will use updated rules.`; + } else if (result.applied === false) { + console.log("[policy-save] ── done: saved to disk only (live apply failed)"); + feedback.innerHTML = `${ICON_CHECK} Policy saved. To apply live, run: nemoclaw policy set nemoclaw`; + } else { + console.log("[policy-save] ── done: saved"); + feedback.innerHTML = `${ICON_CHECK} Saved. New connections will use updated rules.`; + } setTimeout(() => { feedback.className = "nemoclaw-policy-savebar__feedback"; feedback.textContent = ""; bar.classList.remove("nemoclaw-policy-savebar--visible"); bar.classList.add("nemoclaw-policy-savebar--hidden"); - }, 3000); + }, 5000); } catch (err) { feedback.className = "nemoclaw-policy-savebar__feedback nemoclaw-policy-savebar__feedback--error"; feedback.innerHTML = `${ICON_CLOSE} ${escapeHtml(String(err))}`; @@ -1207,15 +1400,10 @@ async function handleSave(btn: HTMLButtonElement, feedback: HTMLElement, bar: HT // Shared UI helpers // --------------------------------------------------------------------------- -function createInput(label: string, value: string, onChange: (v: string) => void, tooltip?: string): HTMLElement { +function createInput(label: string, value: string, onChange: (v: string) => void, _tooltip?: string): HTMLElement { const wrapper = document.createElement("label"); wrapper.className = "nemoclaw-policy-field"; - let labelHtml = `${label}`; - if (tooltip) { - labelHtml += ` ${ICON_INFO}`; - } - labelHtml += ``; - wrapper.innerHTML = labelHtml; + wrapper.innerHTML = `${label}`; const input = document.createElement("input"); input.type = "text"; input.className = "nemoclaw-policy-input"; @@ -1226,15 +1414,10 @@ function createInput(label: string, value: string, onChange: (v: string) => void return wrapper; } -function createSelect(label: string, options: SelectOption[], value: string, onChange: (v: string) => void, tooltip?: string): HTMLElement { +function createSelect(label: string, options: SelectOption[], value: string, onChange: (v: string) => void, _tooltip?: string): HTMLElement { const wrapper = document.createElement("label"); wrapper.className = "nemoclaw-policy-field"; - let labelHtml = `${label}`; - if (tooltip) { - labelHtml += ` ${ICON_INFO}`; - } - labelHtml += ``; - wrapper.innerHTML = labelHtml; + wrapper.innerHTML = `${label}`; const select = document.createElement("select"); select.className = "nemoclaw-policy-select"; for (const opt of options) { @@ -1276,6 +1459,10 @@ function updateNetworkCount(): void { if (countEl && currentPolicy?.network_policies) { countEl.textContent = String(Object.keys(currentPolicy.network_policies).length); } + const tabCount = document.querySelector(".nemoclaw-policy-tabbar__count"); + if (tabCount && currentPolicy?.network_policies) { + tabCount.textContent = String(Object.keys(currentPolicy.network_policies).length); + } } function escapeHtml(s: string): string { diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css index 911dd84..1f212d3 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css @@ -1026,9 +1026,7 @@ body.nemoclaw-switching openclaw-app { =========================================== */ .nemoclaw-policy-page { - max-width: 840px; - margin: 0 auto; - padding: 8px 0 100px; + padding: 8px 24px 100px; animation: nemoclaw-fade-in 250ms ease; } @@ -1087,37 +1085,119 @@ body.nemoclaw-switching openclaw-app { background: rgba(118, 185, 0, 0.06); } -/* Intro */ +/* Summary Strip (two-panel: locked vs editable) */ + +/* Tab bar */ -.nemoclaw-policy-intro { +.nemoclaw-policy-tabbar { display: flex; - gap: 16px; - align-items: flex-start; - padding: 20px; + gap: 0; + border-bottom: 1px solid rgba(161, 161, 170, 0.18); + margin-bottom: 20px; +} + +.nemoclaw-policy-tabbar__tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 18px; + font: inherit; + font-size: 13px; + font-weight: 600; + color: var(--muted, #a1a1aa); + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: color 150ms ease, border-color 150ms ease; +} + +.nemoclaw-policy-tabbar__tab svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; +} + +.nemoclaw-policy-tabbar__tab:hover { + color: var(--text, #e4e4e7); +} + +.nemoclaw-policy-tabbar__tab--active { + color: #76B900; + border-bottom-color: #76B900; +} + +.nemoclaw-policy-tabbar__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + font-size: 11px; + font-weight: 700; + border-radius: 9999px; + background: rgba(118, 185, 0, 0.12); + color: #76B900; +} + +.nemoclaw-policy-tab-panel { + min-height: 0; +} + +/* Immutable Section */ + +.nemoclaw-policy-immutable-section { + margin-bottom: 28px; +} + +.nemoclaw-policy-immutable-section[data-section="immutable"] { + scroll-margin-top: 16px; +} + +.nemoclaw-policy-immutable-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 10px; +} + +.nemoclaw-policy-imm-card { border: 1px solid var(--border, #27272a); - border-radius: var(--radius-lg, 12px); + border-radius: var(--radius-md, 8px); background: var(--bg-elevated, #1a1d25); - margin-bottom: 28px; + padding: 14px; + position: relative; + overflow: hidden; } -:root[data-theme="light"] .nemoclaw-policy-intro { +:root[data-theme="light"] .nemoclaw-policy-imm-card { background: #fff; } -.nemoclaw-policy-intro__icon { - width: 44px; - height: 44px; +.nemoclaw-policy-imm-card__header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.nemoclaw-policy-imm-card__icon { + display: flex; + width: 16px; + height: 16px; + color: var(--muted, #71717a); flex-shrink: 0; - border-radius: var(--radius-md, 8px); - background: rgba(118, 185, 0, 0.10); - display: grid; - place-items: center; - color: #76B900; } -.nemoclaw-policy-intro__icon svg { - width: 22px; - height: 22px; +.nemoclaw-policy-imm-card__icon svg { + width: 16px; + height: 16px; stroke: currentColor; fill: none; stroke-width: 1.5px; @@ -1125,24 +1205,70 @@ body.nemoclaw-switching openclaw-app { stroke-linejoin: round; } -.nemoclaw-policy-intro__title { - font-size: 15px; - font-weight: 700; +.nemoclaw-policy-imm-card__title { + font-size: 13px; + font-weight: 600; color: var(--text-strong, #fafafa); - margin: 0 0 6px; + flex: 1; +} + +.nemoclaw-policy-imm-card__lock { + display: flex; + width: 12px; + height: 12px; + color: var(--muted, #71717a); + opacity: 0.4; +} + +.nemoclaw-policy-imm-card__lock svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-imm-card__desc { + font-size: 11px; + color: var(--muted, #71717a); + margin-bottom: 10px; + line-height: 1.4; } -.nemoclaw-policy-intro__text { +.nemoclaw-policy-imm-card__content { + border-top: 1px solid var(--border, #27272a); + padding-top: 10px; +} + +.nemoclaw-policy-imm-card__note { + font-size: 11px; + color: var(--muted, #71717a); + margin-top: 6px; + font-style: italic; +} + +.nemoclaw-policy-immutable-intro { font-size: 13px; - line-height: 1.6; color: var(--muted, #71717a); - margin: 0 0 10px; + line-height: 1.55; + margin: 0 0 16px; } -.nemoclaw-policy-intro__badges { - display: flex; - gap: 10px; - flex-wrap: wrap; +.nemoclaw-policy-immutable-footer { + font-size: 13px; + color: var(--muted, #71717a); + line-height: 1.55; + margin: 0; +} + +.nemoclaw-policy-immutable-footer code { + font-size: 11px; + padding: 1px 5px; + border-radius: 3px; + background: rgba(118, 185, 0, 0.08); + color: #76B900; } /* Badges */ @@ -1527,6 +1653,7 @@ body.nemoclaw-switching openclaw-app { height: 14px; color: var(--muted, #71717a); cursor: help; + position: relative; } .nemoclaw-policy-info-tip svg { @@ -1539,6 +1666,70 @@ body.nemoclaw-switching openclaw-app { stroke-linejoin: round; } +.nemoclaw-policy-info-tip[data-tip]::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 6px 10px; + border-radius: 6px; + background: var(--card, #181b22); + border: 1px solid var(--border, #27272a); + color: var(--text, #e4e4e7); + font-size: 11px; + font-weight: 400; + line-height: 1.4; + text-transform: none; + letter-spacing: normal; + white-space: normal; + max-width: 240px; + width: max-content; + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +.nemoclaw-policy-info-tip[data-tip]:hover::after { + opacity: 1; +} + +.nemoclaw-policy-prop__value[data-tip] { + position: relative; + cursor: help; + border-bottom: 1px dotted var(--muted, #71717a); +} + +.nemoclaw-policy-prop__value[data-tip]::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 6px 10px; + border-radius: 6px; + background: var(--card, #181b22); + border: 1px solid var(--border, #27272a); + color: var(--text, #e4e4e7); + font-size: 11px; + font-weight: 400; + line-height: 1.4; + white-space: normal; + max-width: 240px; + width: max-content; + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +.nemoclaw-policy-prop__value[data-tip]:hover::after { + opacity: 1; +} + /* Endpoint rows */ .nemoclaw-policy-ep-list { @@ -1915,13 +2106,8 @@ body.nemoclaw-switching openclaw-app { /* Responsive */ @media (max-width: 640px) { - .nemoclaw-policy-stats { - grid-template-columns: repeat(2, 1fr); - } - - .nemoclaw-policy-oneliner { - flex-direction: column; - align-items: flex-start; + .nemoclaw-policy-immutable-grid { + grid-template-columns: 1fr; } .nemoclaw-policy-ep-row__main { @@ -1950,150 +2136,108 @@ body.nemoclaw-switching openclaw-app { margin-left: 0; margin-top: 8px; } +} - .nemoclaw-policy-disclosure__header { - flex-wrap: wrap; +@media (min-width: 641px) and (max-width: 800px) { + .nemoclaw-policy-immutable-grid { + grid-template-columns: repeat(2, 1fr); } } /* =========================================== - Policy Page — Status Bar + Policy Page — Network Card Host Preview =========================================== */ -.nemoclaw-policy-statusbar { - margin-bottom: 24px; -} - -.nemoclaw-policy-stats { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 10px; - margin-bottom: 12px; -} - -.nemoclaw-policy-stat { +.nemoclaw-policy-netcard__preview { + padding: 0 14px 10px 38px; display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - padding: 14px 12px; - border: 1px solid var(--border, #27272a); - border-radius: var(--radius-md, 8px); - background: var(--bg-elevated, #1a1d25); - cursor: pointer; - transition: border-color 150ms ease, background 150ms ease; - font: inherit; - color: inherit; -} - -.nemoclaw-policy-stat:hover { - border-color: rgba(118, 185, 0, 0.3); - background: rgba(118, 185, 0, 0.04); -} - -:root[data-theme="light"] .nemoclaw-policy-stat { - background: #fff; + flex-wrap: wrap; + gap: 4px; } -.nemoclaw-policy-stat__value { - font-size: 22px; - font-weight: 700; - color: var(--text-strong, #fafafa); - letter-spacing: -0.03em; - line-height: 1; +.nemoclaw-policy-netcard--expanded .nemoclaw-policy-netcard__preview { + display: none; } -.nemoclaw-policy-stat__label { +.nemoclaw-policy-host-chip { font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--muted, #71717a); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + padding: 2px 7px; + border-radius: 3px; + background: rgba(118, 185, 0, 0.06); + border: 1px solid rgba(118, 185, 0, 0.15); + color: var(--text, #e4e4e7); } -.nemoclaw-policy-oneliner { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - font-size: 13px; +.nemoclaw-policy-host-chip--more { color: var(--muted, #71717a); + background: transparent; + border-color: transparent; + font-family: inherit; + font-style: italic; } /* =========================================== - Policy Page — Immutable Disclosure + Policy Page — Enforcement Indicator =========================================== */ -.nemoclaw-policy-disclosure { - margin-bottom: 24px; - border: 1px solid var(--border, #27272a); - border-radius: var(--radius-md, 8px); - background: var(--bg-elevated, #1a1d25); - overflow: hidden; - transition: border-color 150ms ease; -} - -:root[data-theme="light"] .nemoclaw-policy-disclosure { - background: #fff; -} - -.nemoclaw-policy-disclosure--expanded { - border-color: rgba(161, 161, 170, 0.35); +.nemoclaw-policy-enf-pill { + display: inline-flex; + align-items: center; + font-size: 10px; + font-weight: 600; + padding: 1px 8px; + border-radius: 9999px; + margin-left: 4px; + letter-spacing: 0.02em; + line-height: 1.6; } -.nemoclaw-policy-disclosure__header { - display: flex; - align-items: center; - gap: 10px; - padding: 14px 16px; - background: none; - border: none; - color: inherit; - cursor: pointer; - font: inherit; - text-align: left; - width: 100%; - transition: background 120ms ease; +.nemoclaw-policy-enf-pill--enforce { + color: #76B900; + background: rgba(118, 185, 0, 0.1); + border: 1px solid rgba(118, 185, 0, 0.25); } -.nemoclaw-policy-disclosure__header:hover { - background: rgba(161, 161, 170, 0.04); +.nemoclaw-policy-enf-pill--audit { + color: var(--muted, #a1a1aa); + background: rgba(161, 161, 170, 0.08); + border: 1px solid rgba(161, 161, 170, 0.2); } -.nemoclaw-policy-disclosure__chevron { - display: flex; - width: 16px; - height: 16px; +.nemoclaw-policy-enf-pill--default { color: var(--muted, #71717a); - transition: transform 200ms ease; - flex-shrink: 0; + background: rgba(161, 161, 170, 0.05); + border: 1px solid rgba(161, 161, 170, 0.15); } -.nemoclaw-policy-disclosure__chevron svg { - width: 16px; - height: 16px; - stroke: currentColor; - fill: none; - stroke-width: 2px; - stroke-linecap: round; - stroke-linejoin: round; -} +/* =========================================== + Policy Page — Network Empty State + =========================================== */ -.nemoclaw-policy-disclosure--expanded .nemoclaw-policy-disclosure__chevron { - transform: rotate(90deg); +.nemoclaw-policy-net-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 40px 24px; + text-align: center; + border: 1px dashed var(--border, #27272a); + border-radius: var(--radius-md, 8px); + animation: nemoclaw-fade-in 200ms ease; } -.nemoclaw-policy-disclosure__icon { +.nemoclaw-policy-net-empty__icon { display: flex; - width: 18px; - height: 18px; + width: 32px; + height: 32px; color: var(--muted, #71717a); - flex-shrink: 0; + opacity: 0.5; } -.nemoclaw-policy-disclosure__icon svg { - width: 18px; - height: 18px; +.nemoclaw-policy-net-empty__icon svg { + width: 32px; + height: 32px; stroke: currentColor; fill: none; stroke-width: 1.5px; @@ -2101,115 +2245,15 @@ body.nemoclaw-switching openclaw-app { stroke-linejoin: round; } -.nemoclaw-policy-disclosure__title { +.nemoclaw-policy-net-empty__title { font-size: 14px; font-weight: 600; color: var(--text-strong, #fafafa); - flex: 1; } -.nemoclaw-policy-disclosure__summary { - padding: 0 16px 14px 60px; +.nemoclaw-policy-net-empty__desc { font-size: 12px; color: var(--muted, #71717a); - line-height: 1.6; -} - -.nemoclaw-policy-disclosure__summary code { - font-size: 11px; - padding: 1px 5px; - border-radius: 3px; - background: rgba(161, 161, 170, 0.08); - color: var(--text, #e4e4e7); -} - -.nemoclaw-policy-disclosure__body { - border-top: 1px solid var(--border, #27272a); - padding: 16px; -} - -.nemoclaw-policy-disclosure__note { - font-size: 12px; - color: var(--muted, #71717a); - margin: 0 0 14px; -} - -.nemoclaw-policy-disclosure__note code { - font-size: 11px; - padding: 1px 5px; - border-radius: 3px; - background: rgba(118, 185, 0, 0.08); - color: #76B900; -} - -/* =========================================== - Policy Page — Internal Tab Strip - =========================================== */ - -.nemoclaw-policy-tabs { - display: flex; - gap: 0; - border-bottom: 1px solid var(--border, #27272a); - margin-bottom: 14px; -} - -.nemoclaw-policy-tab { - padding: 8px 16px; - font-size: 12px; - font-weight: 600; - color: var(--muted, #71717a); - background: none; - border: none; - border-bottom: 2px solid transparent; - cursor: pointer; - transition: color 150ms ease, border-color 150ms ease; - font-family: inherit; -} - -.nemoclaw-policy-tab:hover { - color: var(--text, #e4e4e7); -} - -.nemoclaw-policy-tab--active { - color: #76B900; - border-bottom-color: #76B900; -} - -.nemoclaw-policy-tab-panel { - min-height: 40px; -} - -/* =========================================== - Policy Page — Network Card Host Preview - =========================================== */ - -.nemoclaw-policy-netcard__preview { - padding: 0 14px 10px 38px; - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.nemoclaw-policy-netcard--expanded .nemoclaw-policy-netcard__preview { - display: none; -} - -.nemoclaw-policy-host-chip { - font-size: 11px; - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; - padding: 2px 7px; - border-radius: 3px; - background: rgba(118, 185, 0, 0.06); - border: 1px solid rgba(118, 185, 0, 0.15); - color: var(--text, #e4e4e7); -} - -.nemoclaw-policy-host-chip--more { - color: var(--muted, #71717a); - background: transparent; - border-color: transparent; - font-family: inherit; - font-style: italic; } /* =========================================== @@ -2308,7 +2352,7 @@ body.nemoclaw-switching openclaw-app { } /* =========================================== - Policy Page — L7 Rule Editor Rows + Policy Page — HTTP Rule Editor Rows =========================================== */ .nemoclaw-policy-rule-list { @@ -2339,12 +2383,112 @@ body.nemoclaw-switching openclaw-app { display: flex; gap: 8px; align-items: center; + flex-wrap: wrap; } .nemoclaw-policy-ip-row .nemoclaw-policy-input { flex: 1; } +.nemoclaw-policy-ip-error { + width: 100%; + font-size: 10px; + color: var(--danger, #ef4444); + min-height: 0; +} + +.nemoclaw-policy-ip-error:empty { + display: none; +} + +/* =========================================== + Policy Page — Micro Labels + =========================================== */ + +.nemoclaw-policy-micro-label { + font-size: 11px; + color: var(--muted, #71717a); + margin-bottom: 8px; + font-style: italic; +} + +/* =========================================== + Policy Page — Progressive Endpoint Advanced Toggle + =========================================== */ + +.nemoclaw-policy-ep-advanced-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + margin: 6px 0 2px; + padding: 0; + border: none; + background: none; + color: var(--muted, #71717a); + font-size: 11px; + font-family: inherit; + cursor: pointer; + transition: color 150ms ease; +} + +.nemoclaw-policy-ep-advanced-toggle:hover { + color: #76B900; +} + +.nemoclaw-policy-ep-advanced-toggle__chevron { + display: flex; + width: 12px; + height: 12px; + transition: transform 200ms ease; +} + +.nemoclaw-policy-ep-advanced-toggle__chevron svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-ep-advanced-toggle--open .nemoclaw-policy-ep-advanced-toggle__chevron { + transform: rotate(90deg); +} + +.nemoclaw-policy-ep-advanced-toggle--open { + color: #76B900; +} + +/* =========================================== + Policy Page — Wildcard Binary Warning + =========================================== */ + +.nemoclaw-policy-wildcard-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: var(--radius-full, 9999px); + font-size: 10px; + font-weight: 600; + border: 1px solid rgba(234, 179, 8, 0.3); + background: rgba(234, 179, 8, 0.08); + color: #eab308; + white-space: nowrap; + flex-shrink: 0; +} + +.nemoclaw-policy-wildcard-chip svg { + width: 11px; + height: 11px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + /* =========================================== Policy Page — Search Filter =========================================== */ @@ -2396,7 +2540,7 @@ body.nemoclaw-switching openclaw-app { } /* =========================================== - Policy Page — Template Dropdown + Policy Page — Template Dropdown (enriched) =========================================== */ .nemoclaw-policy-add-wrap { @@ -2425,7 +2569,7 @@ body.nemoclaw-switching openclaw-app { position: absolute; bottom: calc(100% + 6px); left: 0; - min-width: 220px; + min-width: 280px; border: 1px solid var(--border, #27272a); border-radius: var(--radius-md, 8px); background: var(--card, #181b22); @@ -2445,7 +2589,9 @@ body.nemoclaw-switching openclaw-app { } .nemoclaw-policy-template-option { - display: block; + display: flex; + flex-direction: column; + gap: 2px; width: 100%; padding: 8px 12px; border: none; @@ -2463,11 +2609,24 @@ body.nemoclaw-switching openclaw-app { background: var(--bg-hover, #262a35); } -.nemoclaw-policy-template-option--custom { - border-top: 1px solid var(--border, #27272a); - margin-top: 4px; - padding-top: 12px; +.nemoclaw-policy-template-option__label { + font-weight: 500; +} + +.nemoclaw-policy-template-option__meta { + font-size: 11px; color: var(--muted, #71717a); + font-weight: 400; +} + +.nemoclaw-policy-template-option--blank { + border-bottom: 1px solid var(--border, #27272a); + margin-bottom: 4px; + padding-bottom: 10px; + border-radius: var(--radius-sm, 6px) var(--radius-sm, 6px) 0 0; +} + +.nemoclaw-policy-template-option--blank .nemoclaw-policy-template-option__meta { font-style: italic; } @@ -2493,6 +2652,14 @@ body.nemoclaw-switching openclaw-app { font-size: 13px; font-weight: 500; color: var(--text, #e4e4e7); + display: block; +} + +.nemoclaw-policy-savebar__consequence { + display: block; + font-size: 11px; + color: var(--muted, #71717a); + margin-top: 2px; } .nemoclaw-policy-discard-btn { @@ -2506,6 +2673,7 @@ body.nemoclaw-switching openclaw-app { font-family: inherit; cursor: pointer; transition: border-color 150ms ease, background 150ms ease, color 150ms ease; + white-space: nowrap; } .nemoclaw-policy-discard-btn:hover { @@ -2513,3 +2681,9 @@ body.nemoclaw-switching openclaw-app { color: var(--danger, #ef4444); background: rgba(239, 68, 68, 0.06); } + +.nemoclaw-policy-discard-btn--confirming { + border-color: var(--danger, #ef4444); + color: var(--danger, #ef4444); + background: rgba(239, 68, 68, 0.08); +} diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/nemoclaw/policy-proxy.js index dc42ace..53321c9 100644 --- a/sandboxes/nemoclaw/policy-proxy.js +++ b/sandboxes/nemoclaw/policy-proxy.js @@ -5,11 +5,14 @@ // policy-proxy.js — Lightweight reverse proxy that sits in front of the // OpenClaw gateway. Intercepts /api/policy requests to read/write the -// sandbox policy YAML file; everything else (including WebSocket upgrades) -// is transparently forwarded to the upstream OpenClaw gateway. +// sandbox policy YAML file and push updates to the NemoClaw gateway via +// gRPC so changes take effect on the running sandbox. Everything else +// (including WebSocket upgrades) is transparently forwarded to the +// upstream OpenClaw gateway. const http = require("http"); const fs = require("fs"); +const os = require("os"); const net = require("net"); const POLICY_PATH = process.env.POLICY_PATH || "/etc/navigator/policy.yaml"; @@ -17,6 +20,297 @@ const UPSTREAM_PORT = parseInt(process.env.UPSTREAM_PORT || "18788", 10); const LISTEN_PORT = parseInt(process.env.LISTEN_PORT || "18789", 10); const UPSTREAM_HOST = "127.0.0.1"; +const PROTO_DIR = "/usr/local/lib/nemoclaw-proto"; + +// Well-known paths for TLS credentials (volume-mounted by the NemoClaw +// platform). When the proxy runs inside an SSH session the env vars are +// cleared, but the files on disk remain accessible. +const TLS_WELL_KNOWN = { + ca: "/etc/navigator-tls/client/ca.crt", + cert: "/etc/navigator-tls/client/tls.crt", + key: "/etc/navigator-tls/client/tls.key", +}; + +const WELL_KNOWN_ENDPOINT = "https://navigator.navigator.svc.cluster.local:8080"; + +// Resolved at init time. +let gatewayEndpoint = ""; +let sandboxName = ""; + +// --------------------------------------------------------------------------- +// Discovery helpers +// --------------------------------------------------------------------------- + +function discoverFromSupervisor() { + try { + const raw = fs.readFileSync("/proc/1/cmdline"); + const args = raw.toString("utf8").split("\0").filter(Boolean); + const result = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--navigator-endpoint" && i + 1 < args.length) { + result.endpoint = args[i + 1]; + } else if (args[i] === "--sandbox-id" && i + 1 < args.length) { + result.sandboxId = args[i + 1]; + } else if (args[i] === "--sandbox" && i + 1 < args.length) { + result.sandbox = args[i + 1]; + } + } + return result; + } catch (e) { + return {}; + } +} + +function resolveTlsPaths() { + const ca = process.env.NEMOCLAW_TLS_CA || (fileExists(TLS_WELL_KNOWN.ca) ? TLS_WELL_KNOWN.ca : ""); + const cert = process.env.NEMOCLAW_TLS_CERT || (fileExists(TLS_WELL_KNOWN.cert) ? TLS_WELL_KNOWN.cert : ""); + const key = process.env.NEMOCLAW_TLS_KEY || (fileExists(TLS_WELL_KNOWN.key) ? TLS_WELL_KNOWN.key : ""); + return { ca, cert, key }; +} + +function fileExists(p) { + try { fs.accessSync(p, fs.constants.R_OK); return true; } catch { return false; } +} + +// --------------------------------------------------------------------------- +// gRPC client (lazy-initialized) +// --------------------------------------------------------------------------- + +let grpcClient = null; +let grpcEnabled = false; +let grpcPermanentlyDisabled = false; + +function initGrpcClient() { + // 1. Resolve gateway endpoint. + gatewayEndpoint = process.env.NEMOCLAW_ENDPOINT || ""; + + // 2. Resolve sandbox name. NEMOCLAW_SANDBOX is overridden to "1" by + // the supervisor for all child processes, so prefer NEMOCLAW_SANDBOX_ID. + sandboxName = process.env.NEMOCLAW_SANDBOX_ID || ""; + + // 3. Cmdline fallback (useful when env vars were passed as CLI args). + if (!gatewayEndpoint || !sandboxName) { + const discovered = discoverFromSupervisor(); + if (!gatewayEndpoint && discovered.endpoint) { + gatewayEndpoint = discovered.endpoint; + console.log(`[policy-proxy] Discovered endpoint from supervisor cmdline: ${gatewayEndpoint}`); + } + if (!sandboxName) { + sandboxName = discovered.sandboxId || discovered.sandbox || ""; + } + } + + // 4. Well-known fallbacks for SSH sessions where env_clear() stripped + // the container env vars. + if (!gatewayEndpoint && fileExists(TLS_WELL_KNOWN.ca)) { + gatewayEndpoint = WELL_KNOWN_ENDPOINT; + console.log(`[policy-proxy] Using well-known gateway endpoint: ${gatewayEndpoint}`); + } + if (!sandboxName) { + sandboxName = os.hostname() || ""; + if (sandboxName) { + console.log(`[policy-proxy] Using hostname as sandbox name: ${sandboxName}`); + } + } + + if (!gatewayEndpoint || !sandboxName) { + console.log( + `[policy-proxy] Gateway sync disabled — endpoint=${gatewayEndpoint || "(unset)"}, ` + + `sandbox=${sandboxName || "(unset)"}.` + ); + return; + } + + let grpc, protoLoader; + try { + grpc = require("@grpc/grpc-js"); + protoLoader = require("@grpc/proto-loader"); + } catch (e) { + console.error("[policy-proxy] gRPC packages not available; gateway sync disabled:", e.message); + return; + } + + let packageDef; + try { + packageDef = protoLoader.loadSync("navigator.proto", { + keepCase: true, + longs: Number, + enums: String, + defaults: true, + oneofs: true, + includeDirs: [PROTO_DIR], + }); + } catch (e) { + console.error("[policy-proxy] Failed to load proto definitions:", e.message); + return; + } + + const proto = grpc.loadPackageDefinition(packageDef); + + // Build channel credentials: mTLS when certs exist, TLS-only with CA + // when only the CA is available, insecure as last resort. + const tls = resolveTlsPaths(); + let creds; + try { + if (tls.ca && tls.cert && tls.key) { + const rootCerts = fs.readFileSync(tls.ca); + const privateKey = fs.readFileSync(tls.key); + const certChain = fs.readFileSync(tls.cert); + creds = grpc.credentials.createSsl(rootCerts, privateKey, certChain); + } else if (tls.ca) { + const rootCerts = fs.readFileSync(tls.ca); + creds = grpc.credentials.createSsl(rootCerts); + } else { + creds = grpc.credentials.createInsecure(); + } + } catch (e) { + console.error("[policy-proxy] Failed to load TLS credentials:", e.message); + creds = grpc.credentials.createInsecure(); + } + + // Strip scheme prefix — grpc-js expects "host:port". + const target = gatewayEndpoint.replace(/^https?:\/\//, ""); + + grpcClient = new proto.navigator.v1.Navigator(target, creds); + grpcEnabled = true; + console.log(`[policy-proxy] gRPC client initialized → ${target} (sandbox: ${sandboxName})`); + + // Proactive connectivity probe: try to establish a connection within 3s. + // If the network enforcement proxy blocks us, fail fast here instead of + // making every Save wait for a 5s RPC timeout. + const probeDeadline = new Date(Date.now() + 3000); + grpcClient.waitForReady(probeDeadline, (err) => { + if (err) { + console.warn(`[policy-proxy] gRPC connectivity probe failed — disabling gateway sync: ${err.message}`); + grpcEnabled = false; + grpcPermanentlyDisabled = true; + } else { + console.log("[policy-proxy] gRPC connectivity probe succeeded."); + } + }); +} + +// --------------------------------------------------------------------------- +// YAML → proto conversion +// --------------------------------------------------------------------------- + +function yamlToProto(parsed) { + const fp = parsed.filesystem_policy; + return { + version: parsed.version || 1, + filesystem: fp ? { + include_workdir: !!fp.include_workdir, + read_only: fp.read_only || [], + read_write: fp.read_write || [], + } : undefined, + landlock: parsed.landlock ? { + compatibility: parsed.landlock.compatibility || "", + } : undefined, + process: parsed.process ? { + run_as_user: parsed.process.run_as_user || "", + run_as_group: parsed.process.run_as_group || "", + } : undefined, + network_policies: convertNetworkPolicies(parsed.network_policies || {}), + }; +} + +function convertNetworkPolicies(policies) { + const result = {}; + for (const [key, rule] of Object.entries(policies)) { + result[key] = { + name: rule.name || key, + endpoints: (rule.endpoints || []).map(convertEndpoint), + binaries: (rule.binaries || []).map((b) => ({ path: b.path || "" })), + }; + } + return result; +} + +function convertEndpoint(ep) { + return { + host: ep.host || "", + port: ep.port || 0, + protocol: ep.protocol || "", + tls: ep.tls || "", + enforcement: ep.enforcement || "", + access: ep.access || "", + rules: (ep.rules || []).map((r) => ({ + allow: { + method: (r.allow && r.allow.method) || "", + path: (r.allow && r.allow.path) || "", + command: (r.allow && r.allow.command) || "", + }, + })), + allowed_ips: ep.allowed_ips || [], + }; +} + +// --------------------------------------------------------------------------- +// Push policy to gateway via gRPC +// --------------------------------------------------------------------------- + +function pushPolicyToGateway(yamlBody) { + return new Promise((resolve) => { + if (!grpcEnabled || !grpcClient || grpcPermanentlyDisabled) { + resolve({ applied: false, reason: "network_enforcement" }); + return; + } + + let yaml; + try { + yaml = require("js-yaml"); + } catch (e) { + resolve({ applied: false, reason: "js-yaml not available: " + e.message }); + return; + } + + let parsed; + try { + parsed = yaml.load(yamlBody); + } catch (e) { + resolve({ applied: false, reason: "YAML parse error: " + e.message }); + return; + } + + let policyProto; + try { + policyProto = yamlToProto(parsed); + } catch (e) { + resolve({ applied: false, reason: "proto conversion error: " + e.message }); + return; + } + + const request = { + name: sandboxName, + policy: policyProto, + }; + + const deadline = new Date(Date.now() + 5000); + grpcClient.UpdateSandboxPolicy(request, { deadline }, (err, response) => { + if (err) { + console.error("[policy-proxy] gRPC UpdateSandboxPolicy failed:", err.message); + grpcEnabled = false; + grpcPermanentlyDisabled = true; + console.warn("[policy-proxy] Circuit-breaker tripped — disabling gateway sync for future requests."); + resolve({ applied: false, reason: "network_enforcement" }); + return; + } + console.log( + `[policy-proxy] Policy pushed to gateway: version=${response.version}, hash=${response.policy_hash}` + ); + resolve({ + applied: true, + version: response.version, + policy_hash: response.policy_hash, + }); + }); + }); +} + +// --------------------------------------------------------------------------- +// HTTP proxy helpers +// --------------------------------------------------------------------------- + function proxyRequest(clientReq, clientRes) { const opts = { hostname: UPSTREAM_HOST, @@ -42,6 +336,10 @@ function proxyRequest(clientReq, clientRes) { clientReq.pipe(upstream, { end: true }); } +// --------------------------------------------------------------------------- +// /api/policy handlers +// --------------------------------------------------------------------------- + function handlePolicyGet(req, res) { fs.readFile(POLICY_PATH, "utf8", (err, data) => { if (err) { @@ -57,57 +355,75 @@ function handlePolicyGet(req, res) { } function handlePolicyPost(req, res) { + const t0 = Date.now(); + console.log(`[policy-proxy] ── POST /api/policy received`); const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { const body = Buffer.concat(chunks).toString("utf8"); + console.log(`[policy-proxy] body: ${body.length} bytes`); if (!body.trim()) { + console.log(`[policy-proxy] REJECTED: empty body`); res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "empty body" })); return; } - // Minimal validation: must contain "version:" and "network_policies:" if (!body.includes("version:")) { + console.log(`[policy-proxy] REJECTED: missing version field`); res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid policy: missing version field" })); return; } - // Write to a temp file then rename for atomicity - const tmp = POLICY_PATH + ".tmp." + process.pid; + console.log(`[policy-proxy] step 1/3: writing to disk → ${POLICY_PATH}`); + const tmp = os.tmpdir() + "/policy.yaml.tmp." + process.pid; fs.writeFile(tmp, body, "utf8", (writeErr) => { if (writeErr) { + console.error(`[policy-proxy] step 1/3: FAILED — ${writeErr.message}`); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "write failed: " + writeErr.message })); return; } fs.rename(tmp, POLICY_PATH, (renameErr) => { if (renameErr) { - // rename can fail across filesystems; fall back to direct write fs.writeFile(POLICY_PATH, body, "utf8", (fallbackErr) => { fs.unlink(tmp, () => {}); if (fallbackErr) { + console.error(`[policy-proxy] step 1/3: FAILED (fallback) — ${fallbackErr.message}`); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "write failed: " + fallbackErr.message })); return; } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true })); + console.log(`[policy-proxy] step 1/3: saved to disk (fallback write) [${Date.now() - t0}ms]`); + syncAndRespond(body, res, t0); }); return; } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true })); + console.log(`[policy-proxy] step 1/3: saved to disk (atomic rename) [${Date.now() - t0}ms]`); + syncAndRespond(body, res, t0); }); }); }); } +function syncAndRespond(yamlBody, res, t0) { + console.log(`[policy-proxy] step 2/3: attempting gRPC gateway sync (enabled=${grpcEnabled}, disabled=${grpcPermanentlyDisabled})`); + pushPolicyToGateway(yamlBody).then((result) => { + const payload = { ok: true, ...result }; + console.log(`[policy-proxy] step 3/3: responding — applied=${result.applied}, reason=${result.reason || "n/a"} [${Date.now() - t0}ms total]`); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(payload)); + }); +} + +// --------------------------------------------------------------------------- +// HTTP server +// --------------------------------------------------------------------------- + const server = http.createServer((req, res) => { if (req.url === "/api/policy") { - // CORS for same-origin should work, but add headers for safety res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); @@ -154,6 +470,9 @@ server.on("upgrade", (req, socket, head) => { }); }); +// Initialize gRPC client before starting the HTTP server. +initGrpcClient(); + server.listen(LISTEN_PORT, "127.0.0.1", () => { console.log(`[policy-proxy] Listening on 127.0.0.1:${LISTEN_PORT}, upstream 127.0.0.1:${UPSTREAM_PORT}`); }); diff --git a/sandboxes/nemoclaw/proto/datamodel.proto b/sandboxes/nemoclaw/proto/datamodel.proto new file mode 100644 index 0000000..137d3fc --- /dev/null +++ b/sandboxes/nemoclaw/proto/datamodel.proto @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package navigator.datamodel.v1; + +import "google/protobuf/struct.proto"; +import "sandbox.proto"; + +// Sandbox model stored by Navigator. +message Sandbox { + string id = 1; + string name = 2; + string namespace = 3; + SandboxSpec spec = 4; + SandboxStatus status = 5; + SandboxPhase phase = 6; + // Milliseconds since Unix epoch when the sandbox was created. + int64 created_at_ms = 7; + // Currently active policy version (updated when sandbox reports loaded). + uint32 current_policy_version = 8; +} + +// Navigator-level sandbox spec. +message SandboxSpec { + string log_level = 1; + map environment = 5; + SandboxTemplate template = 6; + // Required sandbox policy configuration. + navigator.sandbox.v1.SandboxPolicy policy = 7; + // Provider names to attach to this sandbox. + repeated string providers = 8; +} + +// Sandbox template mapped onto Kubernetes pod template inputs. +message SandboxTemplate { + string image = 1; + string runtime_class_name = 2; + string agent_socket = 3; + map labels = 4; + map annotations = 5; + map environment = 6; + google.protobuf.Struct resources = 7; + google.protobuf.Struct pod_template = 8; + google.protobuf.Struct volume_claim_templates = 9; +} + +// Sandbox status captured from Kubernetes. +message SandboxStatus { + string sandbox_name = 1; + string agent_pod = 2; + string agent_fd = 3; + string sandbox_fd = 4; + repeated SandboxCondition conditions = 5; +} + +// Sandbox condition mirrors Kubernetes conditions. +message SandboxCondition { + string type = 1; + string status = 2; + string reason = 3; + string message = 4; + string last_transition_time = 5; +} + +// High-level sandbox lifecycle phase. +enum SandboxPhase { + SANDBOX_PHASE_UNSPECIFIED = 0; + SANDBOX_PHASE_PROVISIONING = 1; + SANDBOX_PHASE_READY = 2; + SANDBOX_PHASE_ERROR = 3; + SANDBOX_PHASE_DELETING = 4; + SANDBOX_PHASE_UNKNOWN = 5; +} + +// Provider model stored by Navigator. +message Provider { + string id = 1; + string name = 2; + // Canonical provider type slug (for example: "claude", "gitlab"). + string type = 3; + // Secret values used for authentication. + map credentials = 4; + // Non-secret provider configuration. + map config = 5; +} diff --git a/sandboxes/nemoclaw/proto/navigator.proto b/sandboxes/nemoclaw/proto/navigator.proto new file mode 100644 index 0000000..b6513fb --- /dev/null +++ b/sandboxes/nemoclaw/proto/navigator.proto @@ -0,0 +1,533 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package navigator.v1; + +import "datamodel.proto"; +import "sandbox.proto"; + +option java_multiple_files = true; +option java_package = "com.anthropic.navigator.v1"; + +// Navigator service provides sandbox, provider, and runtime management capabilities. +service Navigator { + // Check the health of the service. + rpc Health(HealthRequest) returns (HealthResponse); + + // Create a new sandbox. + rpc CreateSandbox(CreateSandboxRequest) returns (SandboxResponse); + + // Fetch a sandbox by name. + rpc GetSandbox(GetSandboxRequest) returns (SandboxResponse); + + // List sandboxes. + rpc ListSandboxes(ListSandboxesRequest) returns (ListSandboxesResponse); + + // Delete a sandbox by name. + rpc DeleteSandbox(DeleteSandboxRequest) returns (DeleteSandboxResponse); + + // Create a short-lived SSH session for a sandbox. + rpc CreateSshSession(CreateSshSessionRequest) returns (CreateSshSessionResponse); + + // Revoke a previously issued SSH session. + rpc RevokeSshSession(RevokeSshSessionRequest) returns (RevokeSshSessionResponse); + + // Execute a command in a ready sandbox and stream output. + rpc ExecSandbox(ExecSandboxRequest) returns (stream ExecSandboxEvent); + + // Create a provider. + rpc CreateProvider(CreateProviderRequest) returns (ProviderResponse); + + // Fetch a provider by name. + rpc GetProvider(GetProviderRequest) returns (ProviderResponse); + + // List providers. + rpc ListProviders(ListProvidersRequest) returns (ListProvidersResponse); + + // Update an existing provider by name. + rpc UpdateProvider(UpdateProviderRequest) returns (ProviderResponse); + + // Delete a provider by name. + rpc DeleteProvider(DeleteProviderRequest) returns (DeleteProviderResponse); + + // Get sandbox policy by id (called by sandbox entrypoint and poll loop). + rpc GetSandboxPolicy(navigator.sandbox.v1.GetSandboxPolicyRequest) + returns (navigator.sandbox.v1.GetSandboxPolicyResponse); + + // Update sandbox policy on a live sandbox. + rpc UpdateSandboxPolicy(UpdateSandboxPolicyRequest) + returns (UpdateSandboxPolicyResponse); + + // Get the load status of a specific policy version. + rpc GetSandboxPolicyStatus(GetSandboxPolicyStatusRequest) + returns (GetSandboxPolicyStatusResponse); + + // List policy history for a sandbox. + rpc ListSandboxPolicies(ListSandboxPoliciesRequest) + returns (ListSandboxPoliciesResponse); + + // Report policy load result (called by sandbox after reload attempt). + rpc ReportPolicyStatus(ReportPolicyStatusRequest) + returns (ReportPolicyStatusResponse); + + // Get provider environment for a sandbox (called by sandbox supervisor at startup). + rpc GetSandboxProviderEnvironment(GetSandboxProviderEnvironmentRequest) + returns (GetSandboxProviderEnvironmentResponse); + + // Fetch recent sandbox logs (one-shot). + rpc GetSandboxLogs(GetSandboxLogsRequest) returns (GetSandboxLogsResponse); + + // Push sandbox supervisor logs to the server (client-streaming). + rpc PushSandboxLogs(stream PushSandboxLogsRequest) returns (PushSandboxLogsResponse); + + // Watch a sandbox and stream updates. + // + // This stream can include: + // - Sandbox status snapshots (phase/status) + // - Navigator server process logs correlated by sandbox_id + // - Platform events correlated to the sandbox + rpc WatchSandbox(WatchSandboxRequest) returns (stream SandboxStreamEvent); +} + +// Health check request. +message HealthRequest {} + +// Health check response. +message HealthResponse { + // Service status. + ServiceStatus status = 1; + + // Service version. + string version = 2; +} + +// Create sandbox request. +message CreateSandboxRequest { + navigator.datamodel.v1.SandboxSpec spec = 1; + // Optional user-supplied sandbox name. When empty the server generates one. + string name = 2; +} + +// Get sandbox request. +message GetSandboxRequest { + // Sandbox name (canonical lookup key). + string name = 1; +} + +// List sandboxes request. +message ListSandboxesRequest { + uint32 limit = 1; + uint32 offset = 2; +} + +// Delete sandbox request. +message DeleteSandboxRequest { + // Sandbox name (canonical lookup key). + string name = 1; +} + +// Sandbox response. +message SandboxResponse { + navigator.datamodel.v1.Sandbox sandbox = 1; +} + +// List sandboxes response. +message ListSandboxesResponse { + repeated navigator.datamodel.v1.Sandbox sandboxes = 1; +} + +// Delete sandbox response. +message DeleteSandboxResponse { + bool deleted = 1; +} + +// Create SSH session request. +message CreateSshSessionRequest { + // Sandbox id. + string sandbox_id = 1; +} + +// Create SSH session response. +message CreateSshSessionResponse { + // Sandbox id. + string sandbox_id = 1; + + // Session token for the gateway tunnel. + string token = 2; + + // Gateway host for SSH proxy connection. + string gateway_host = 3; + + // Gateway port for SSH proxy connection. + uint32 gateway_port = 4; + + // Gateway scheme (http or https). + string gateway_scheme = 5; + + // HTTP path for the CONNECT/upgrade endpoint. + string connect_path = 6; + + // Optional host key fingerprint. + string host_key_fingerprint = 7; +} + +// Revoke SSH session request. +message RevokeSshSessionRequest { + // Session token to revoke. + string token = 1; +} + +// Revoke SSH session response. +message RevokeSshSessionResponse { + // True when a session was revoked. + bool revoked = 1; +} + +// Execute command request. +message ExecSandboxRequest { + // Sandbox id. + string sandbox_id = 1; + + // Command and arguments. + repeated string command = 2; + + // Optional working directory. + string workdir = 3; + + // Optional environment overrides. + map environment = 4; + + // Optional timeout in seconds. 0 means no timeout. + uint32 timeout_seconds = 5; + + // Optional stdin payload passed to the command. + bytes stdin = 6; +} + +// One stdout chunk from a sandbox exec. +message ExecSandboxStdout { + bytes data = 1; +} + +// One stderr chunk from a sandbox exec. +message ExecSandboxStderr { + bytes data = 1; +} + +// Final exit status for a sandbox exec. +message ExecSandboxExit { + int32 exit_code = 1; +} + +// One event in a sandbox exec stream. +message ExecSandboxEvent { + oneof payload { + ExecSandboxStdout stdout = 1; + ExecSandboxStderr stderr = 2; + ExecSandboxExit exit = 3; + } +} + +// SSH session record stored in persistence. +message SshSession { + // Unique id (token). + string id = 1; + + // Sandbox id. + string sandbox_id = 2; + + // Session token. + string token = 3; + + // Creation timestamp in milliseconds since epoch. + int64 created_at_ms = 4; + + // Revoked flag. + bool revoked = 5; + + // Human-friendly name (auto-generated if not provided). + string name = 6; +} + +// Watch sandbox request. +message WatchSandboxRequest { + // Sandbox id. + string id = 1; + + // Stream sandbox status snapshots. + bool follow_status = 2; + + // Stream navigator-server process logs correlated to this sandbox. + bool follow_logs = 3; + + // Stream platform events correlated to this sandbox. + bool follow_events = 4; + + // Replay the last N log lines (best-effort) before following. + uint32 log_tail_lines = 5; + + // Replay the last N platform events (best-effort) before following. + uint32 event_tail = 6; + + // Stop streaming once the sandbox reaches a terminal phase (READY or ERROR). + bool stop_on_terminal = 7; + + // Only include log lines with timestamp >= this value (milliseconds since epoch). + // 0 means no time filter. Applies to both tail replay and live streaming. + int64 log_since_ms = 8; + + // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. + repeated string log_sources = 9; + + // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. + string log_min_level = 10; +} + +// One event in a sandbox watch stream. +message SandboxStreamEvent { + oneof payload { + // Latest sandbox snapshot. + navigator.datamodel.v1.Sandbox sandbox = 1; + // One server log line/event. + SandboxLogLine log = 2; + // One platform event. + PlatformEvent event = 3; + // Warning from the server (e.g. missed messages due to lag). + SandboxStreamWarning warning = 4; + } +} + +// Log line correlated to a sandbox. +message SandboxLogLine { + string sandbox_id = 1; + int64 timestamp_ms = 2; + string level = 3; + string target = 4; + string message = 5; + // Log source: "gateway" (server-side) or "sandbox" (supervisor). + // Empty is treated as "gateway" for backward compatibility. + string source = 6; + // Structured key-value fields from the tracing event (e.g. dst_host, action). + map fields = 7; +} + +// Platform event correlated to a sandbox. +message PlatformEvent { + // Event timestamp in milliseconds since epoch. + int64 timestamp_ms = 1; + // Event source (e.g. "kubernetes", "docker", "process"). + string source = 2; + // Event type/severity (e.g. "Normal", "Warning"). + string type = 3; + // Short reason code (e.g. "Started", "Pulled", "Failed"). + string reason = 4; + // Human-readable event message. + string message = 5; + // Optional metadata as key-value pairs. + map metadata = 6; +} + +message SandboxStreamWarning { + string message = 1; +} + +// Create provider request. +message CreateProviderRequest { + navigator.datamodel.v1.Provider provider = 1; +} + +// Get provider request. +message GetProviderRequest { + string name = 1; +} + +// List providers request. +message ListProvidersRequest { + uint32 limit = 1; + uint32 offset = 2; +} + +// Update provider request. +message UpdateProviderRequest { + navigator.datamodel.v1.Provider provider = 1; +} + +// Delete provider request. +message DeleteProviderRequest { + string name = 1; +} + +// Provider response. +message ProviderResponse { + navigator.datamodel.v1.Provider provider = 1; +} + +// List providers response. +message ListProvidersResponse { + repeated navigator.datamodel.v1.Provider providers = 1; +} + +// Delete provider response. +message DeleteProviderResponse { + bool deleted = 1; +} + +// Get sandbox provider environment request. +message GetSandboxProviderEnvironmentRequest { + // The sandbox ID. + string sandbox_id = 1; +} + +// Get sandbox provider environment response. +message GetSandboxProviderEnvironmentResponse { + // Provider credential environment variables. + map environment = 1; +} + +// --------------------------------------------------------------------------- +// Policy update messages +// --------------------------------------------------------------------------- + +// Update sandbox policy request. +message UpdateSandboxPolicyRequest { + // Sandbox name (canonical lookup key). + string name = 1; + // The new policy to apply. Only network_policies and inference fields may + // differ from the create-time policy; static fields (filesystem, landlock, + // process) must match version 1 or the request is rejected. + navigator.sandbox.v1.SandboxPolicy policy = 2; +} + +// Update sandbox policy response. +message UpdateSandboxPolicyResponse { + // Assigned policy version (monotonically increasing per sandbox). + uint32 version = 1; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 2; +} + +// Get sandbox policy status request. +message GetSandboxPolicyStatusRequest { + // Sandbox name (canonical lookup key). + string name = 1; + // The specific policy version to query. 0 means latest. + uint32 version = 2; +} + +// Get sandbox policy status response. +message GetSandboxPolicyStatusResponse { + // The queried policy revision. + SandboxPolicyRevision revision = 1; + // The currently active (loaded) policy version for this sandbox. + uint32 active_version = 2; +} + +// List sandbox policies request. +message ListSandboxPoliciesRequest { + // Sandbox name (canonical lookup key). + string name = 1; + uint32 limit = 2; + uint32 offset = 3; +} + +// List sandbox policies response. +message ListSandboxPoliciesResponse { + repeated SandboxPolicyRevision revisions = 1; +} + +// Report policy load status (called by sandbox runtime after reload attempt). +message ReportPolicyStatusRequest { + // Sandbox id. + string sandbox_id = 1; + // The policy version that was attempted. + uint32 version = 2; + // Load result status. + PolicyStatus status = 3; + // Error message if status is FAILED. + string load_error = 4; +} + +// Report policy status response. +message ReportPolicyStatusResponse {} + +// A versioned policy revision with metadata. +message SandboxPolicyRevision { + // Policy version (monotonically increasing per sandbox). + uint32 version = 1; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 2; + // Load status of this revision. + PolicyStatus status = 3; + // Error message if status is FAILED. + string load_error = 4; + // Milliseconds since epoch when this revision was created. + int64 created_at_ms = 5; + // Milliseconds since epoch when this revision was loaded by the sandbox. + int64 loaded_at_ms = 6; + // The full policy (only populated when explicitly requested). + navigator.sandbox.v1.SandboxPolicy policy = 7; +} + +// Policy load status. +enum PolicyStatus { + POLICY_STATUS_UNSPECIFIED = 0; + // Server received the update; sandbox has not yet loaded it. + POLICY_STATUS_PENDING = 1; + // Sandbox successfully applied this policy version. + POLICY_STATUS_LOADED = 2; + // Sandbox attempted to apply but failed; LKG policy remains active. + POLICY_STATUS_FAILED = 3; + // A newer version was persisted before the sandbox loaded this one. + POLICY_STATUS_SUPERSEDED = 4; +} + +// --------------------------------------------------------------------------- +// Sandbox logs messages +// --------------------------------------------------------------------------- + +// Get sandbox logs request (one-shot fetch). +message GetSandboxLogsRequest { + // Sandbox id. + string sandbox_id = 1; + // Maximum number of log lines to return. 0 means use default (2000). + uint32 lines = 2; + // Only include logs with timestamp >= this value (ms since epoch). 0 means no filter. + int64 since_ms = 3; + // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. + repeated string sources = 4; + // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. + string min_level = 5; +} + +// Batch of log lines pushed from sandbox to server. +message PushSandboxLogsRequest { + // The sandbox ID. + string sandbox_id = 1; + // Log lines to ingest. + repeated SandboxLogLine logs = 2; +} + +// Push sandbox logs response. +message PushSandboxLogsResponse {} + +// Get sandbox logs response. +message GetSandboxLogsResponse { + // Log lines in chronological order. + repeated SandboxLogLine logs = 1; + // Total number of lines in the server's buffer for this sandbox. + uint32 buffer_total = 2; +} + +// --------------------------------------------------------------------------- +// Service status +// --------------------------------------------------------------------------- + +// Service status enum. +enum ServiceStatus { + SERVICE_STATUS_UNSPECIFIED = 0; + SERVICE_STATUS_HEALTHY = 1; + SERVICE_STATUS_DEGRADED = 2; + SERVICE_STATUS_UNHEALTHY = 3; +} diff --git a/sandboxes/nemoclaw/proto/sandbox.proto b/sandboxes/nemoclaw/proto/sandbox.proto new file mode 100644 index 0000000..062026b --- /dev/null +++ b/sandboxes/nemoclaw/proto/sandbox.proto @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package navigator.sandbox.v1; + +// Sandbox security policy configuration. +message SandboxPolicy { + // Policy version. + uint32 version = 1; + // Filesystem access policy. + FilesystemPolicy filesystem = 2; + // Landlock configuration. + LandlockPolicy landlock = 3; + // Process execution policy. + ProcessPolicy process = 4; + // Network access policies keyed by name (e.g. "claude_code", "gitlab"). + map network_policies = 5; +} + +// Filesystem access policy. +message FilesystemPolicy { + // Automatically include the workdir as read-write. + bool include_workdir = 1; + // Read-only directory allow list. + repeated string read_only = 2; + // Read-write directory allow list. + repeated string read_write = 3; +} + +// Landlock policy configuration. +message LandlockPolicy { + // Compatibility mode (e.g. "best_effort", "hard_requirement"). + string compatibility = 1; +} + +// Process execution policy. +message ProcessPolicy { + // User name to run the sandboxed process as. + string run_as_user = 1; + // Group name to run the sandboxed process as. + string run_as_group = 2; +} + +// A named network access policy rule. +message NetworkPolicyRule { + // Human-readable name for this policy rule. + string name = 1; + // Allowed endpoint (host:port) pairs. + repeated NetworkEndpoint endpoints = 2; + // Allowed binary identities. + repeated NetworkBinary binaries = 3; +} + +// A network endpoint (host + port) with optional L7 inspection config. +message NetworkEndpoint { + string host = 1; + uint32 port = 2; + // Application protocol for L7 inspection: "rest", "sql", or "" (L4-only). + string protocol = 3; + // TLS handling: "terminate" or "passthrough" (default). + string tls = 4; + // Enforcement mode: "enforce" or "audit" (default). + string enforcement = 5; + // Access preset shorthand: "read-only", "read-write", "full". + // Mutually exclusive with rules. + string access = 6; + // Explicit L7 rules (mutually exclusive with access). + repeated L7Rule rules = 7; + // Allowed resolved IP addresses or CIDR ranges for this endpoint. + // When non-empty, the SSRF internal-IP check is replaced by an allowlist check: + // - If host is also set: domain must resolve to an IP in this list. + // - If host is empty: any domain is allowed as long as it resolves to an IP in this list. + // Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24"). + // Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked + // regardless of this field. + repeated string allowed_ips = 8; +} + +// An L7 policy rule (allow-only). +message L7Rule { + L7Allow allow = 1; +} + +// Allowed action definition for L7 rules. +message L7Allow { + // HTTP method (REST): GET, POST, etc. or "*" for any. + string method = 1; + // URL path glob pattern (REST): "/repos/**", "**" for any. + string path = 2; + // SQL command (SQL): SELECT, INSERT, etc. or "*" for any. + string command = 3; +} + +// A binary identity for network policy matching. +message NetworkBinary { + string path = 1; + // Deprecated: the harness concept has been removed. This field is ignored. + bool harness = 2 [deprecated = true]; +} + +// Request to get sandbox policy by sandbox ID. +message GetSandboxPolicyRequest { + // The sandbox ID. + string sandbox_id = 1; +} + +// Response containing sandbox policy. +message GetSandboxPolicyResponse { + // The sandbox policy configuration. + SandboxPolicy policy = 1; + // Current policy version (monotonically increasing per sandbox). + uint32 version = 2; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 3; +}