diff --git a/.env.example b/.env.example index c5051ce8..ff084526 100644 --- a/.env.example +++ b/.env.example @@ -1,77 +1,135 @@ -# OpenDots template: the UI opens in setup state until these fields are configured. -HOST=127.0.0.1 +# ============================================================================= +# NexBot — decoupled, self-hosted fork: environment template +# ============================================================================= +# Copy this file to `.env` and fill in the required values. Never commit `.env`. +# +# NO CopilotKit account, project, organization, or API key is needed. +# Conversations are stored in your own PostgreSQL; non-conversation state +# (dots, spaces, pages, tasks, connections, computers) stays in SQLite. The +# app talks only to the model endpoint you choose. Nothing is sent to +# CopilotKit, and no CopilotKit telemetry variable is used. +# +# Generate secrets (each 24+ characters): +# openssl rand -base64 48 +# +# Values marked REQUIRED have no safe default: Compose refuses to start without +# them (it uses ${VAR:?} for the required app/postgres variables). +# ============================================================================= + +# ============================================================================= +# REQUIRED +# ============================================================================= + +# --- Application binding ----------------------------------------------------- +# HOST/PORT the server listens on inside the container. Keep 0.0.0.0 so the +# container is reachable; the published port stays bound to host loopback. +HOST=0.0.0.0 PORT=4310 -DATABASE_PATH=data/opendots.sqlite -# Keep OWNER_ID stable: it owns the persistent Intelligence conversations. -OWNER_ID=opendots-owner -# npm run dev allows http://localhost:5173 and http://127.0.0.1:5173. -# APP_ORIGIN replaces these defaults with comma-separated exact origins. -# Include scheme, hostname and port; omit paths and trailing slashes. -# APP_ORIGIN=http://localhost:5173,http://127.0.0.1:5173 -# Required for an external HOST binding (24+ characters); enables local login too. -# OWNER_TOKEN= +# REQUIRED when HOST is not loopback (24+ characters). Bearer token for the +# owner login and every /api request. Treat it like a password. +OWNER_TOKEN= +# Exact browser origin(s) allowed to call the API, comma-separated. Include +# scheme, hostname and port; omit paths and trailing slashes. +# Behind a reverse proxy set this to your public HTTPS origin, e.g.: +# APP_ORIGIN=https://dots.example.com +# Whitespace around entries is trimmed and empty entries are ignored. +# APP_ORIGIN=https://dots.example.com + +# --- Owner identity ---------------------------------------------------------- +# Keep OWNER_ID stable: it owns the persistent conversations and other +# owner-scoped state. Changing it after first use orphans that data. +OWNER_ID=nexbot-owner + +# --- Non-conversation state: SQLite (REQUIRED) ------------------------------- +# File holding dots, spaces, pages, tasks, connections, computers and thread +# bindings. In the container this is a path on the `nexbot-data` volume. +DATABASE_PATH=/data/nexbot.sqlite -# `copilotkit project select` or `local connect` writes CPK_INTELLIGENCE_API_KEY. -# The server accepts either name and prefers a non-empty CPK_INTELLIGENCE_API_KEY. -INTELLIGENCE_API_KEY= -# INTELLIGENCE_API_URL= -# `local connect` writes this endpoint; it takes precedence over the legacy name. -# INTELLIGENCE_GATEWAY_WS_URL= -# INTELLIGENCE_WS_URL= +# --- Conversations: PostgreSQL (REQUIRED) ------------------------------------ +# REQUIRED. Replaces the hosted CopilotKit conversation service. +# Compose-internal form (the host is the bundled postgres service name): +# postgres://:@postgres:5432/ +# With the values below that is: +# postgres://nexbot:@postgres:5432/nexbot +# Keep DATABASE_URL consistent with the POSTGRES_* values below. +DATABASE_URL= +# Credentials for the bundled `postgres` service in compose.yml. +# It publishes NO host port; it is reachable only on the internal network. +POSTGRES_USER=nexbot +# REQUIRED. Generate one: openssl rand -base64 48 +POSTGRES_PASSWORD= +POSTGRES_DB=nexbot + +# --- Model endpoint (REQUIRED for agent runs) -------------------------------- +# Any OpenAI-compatible endpoint. Point OPENAI_BASE_URL at a self-hosted server +# (Ollama, vLLM, LM Studio) or a hosted provider (OpenAI, OpenRouter, ...). OPENAI_API_KEY= OPENAI_BASE_URL=https://api.openai.com/v1 OPENAI_MODEL= -# Maximum duration for Dot turns and scheduled/background agent runs (milliseconds). +# Extra headers sent to the model endpoint, as a JSON object of strings. Some +# gateways require routing headers. OpenCode Go needs a session header: +# OPENAI_EXTRA_HEADERS='{"x-opencode-session":"nexbot"}' +# OPENAI_EXTRA_HEADERS= +# Maximum duration for Dot turns and scheduled/background agent runs (ms). # AGENT_RUN_TIMEOUT_MS=90000 -# Keep the CLI-generated CPK_TELEMETRY_ID with its matching project key. -# The SDK uses it to connect OpenDots usage to the Intelligence account. -# CPK_TELEMETRY_ID= -# SDK usage telemetry is enabled by default (separate from conversations). -# See docs/TELEMETRY.md for signup reporting and validation. -# Optional sampling override (default 1): -# COPILOTKIT_TELEMETRY_SAMPLE_RATE=1 -# Either flag below opts out: -# COPILOTKIT_TELEMETRY_DISABLED=true -# DO_NOT_TRACK=1 -# Optional isolated read-only public-page browser for WEB_SEARCH_PROVIDER=browser. +# ============================================================================= +# OPTIONAL +# ============================================================================= + +# --- Setup telemetry --------------------------------------------------------- +# OPTIONAL, unset by default. When unset, NO telemetry leaves the host. +# If set, anonymous setup/version telemetry is POSTed to this URL. +# SETUP_TELEMETRY_ENDPOINT= + +# --- Web search -------------------------------------------------------------- +# parallel (default), browser (URL-only reader), or disabled. +# With `parallel`, queries and selected URLs are sent to Parallel; set a key +# for authenticated/higher-limit use (anonymous MCP supports light use). +WEB_SEARCH_PROVIDER=parallel +PARALLEL_API_KEY= + +# --- Isolated read-only browser (needed only for WEB_SEARCH_PROVIDER=browser) - +# Supply the same random 24+ character secret to the app and browser service. +# All redirects are blocked; use canonical public URLs. BROWSER_URL=http://127.0.0.1:4311 BROWSER_SECRET= BROWSER_HOST=127.0.0.1 BROWSER_PORT=4311 -# Use the same random 24+ character secret in app/browser. All redirects are blocked. -# Supply canonical public URLs. Use the separate browser container for isolation. -# Optional connected apps through Composio; see docs/SETUP.md#composio. +# --- Composio connected apps ------------------------------------------------- +# Lets Dots use the owner's connected apps. See docs/SETUP.md#composio. COMPOSIO_API_KEY= -# Optional realtime speech; compute uses the same configured Intelligence thread. +# --- Realtime speech --------------------------------------------------------- VOICE_API_KEY= +# Realtime endpoint root, for a non-OpenAI gateway (default https://api.openai.com). +# VOICE_BASE_URL= VOICE_MODEL= VOICE_NAME=marin -# Optional managed Slack channel; see docs/SETUP.md#slack. +# --- Slack (NOT YET WIRED) --------------------------------------------------- +# OPTIONAL. SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET come from a self-hosted +# Slack integration that replaces @copilotkit/channels in a LATER workstream. +# This build does not read them yet; setting them has no effect today. +# SLACK_BOT_TOKEN= +# SLACK_SIGNING_SECRET= +# The managed vars below are still accepted by the current build. # Match the managed declaration name, not a Slack #conversation name. -# OpenTag calls this INTELLIGENCE_CHANNEL_NAME; OpenDots uses SLACK_CHANNEL_NAME. # Allowlist both the Slack workspace and explicit Slack user IDs. SLACK_CHANNEL_NAME= SLACK_TEAM_ID= SLACK_USER_IDS= # SLACK_DOT_ID= (defaults to the initial Dot) -# Optional persistent computers, one per Dot, using OpenBot services. -# See docs/COMPUTERS.md. Keep these two different random secrets on the server. +# --- Persistent computers (OpenBot supervisor) ------------------------------- +# OPTIONAL, one container per Dot. See docs/COMPUTERS.md. +# Keep these two DIFFERENT random 24+ character secrets on the server. COMPUTER_SUPERVISOR_URL= COMPUTER_SUPERVISOR_TOKEN= COMPUTER_TOKEN= -# Keep stable: the namespace identifies existing containers and persistent volumes. -COMPUTER_NAMESPACE=opendots +# Keep stable: the namespace identifies existing containers and volumes. +COMPUTER_NAMESPACE=nexbot # COMPUTER_MEMORY_BYTES=2147483648 # COMPUTER_RUNTIME=runsc # ENGINE_SOCKET=/var/run/docker.sock - -# Public-web provider. parallel (default), browser (URL-only), or disabled. -# Queries and selected URLs are sent to Parallel; sample mode makes no provider calls. -WEB_SEARCH_PROVIDER=parallel -# Optional Bearer key for production/higher limits; anonymous MCP supports light use. -PARALLEL_API_KEY= diff --git a/.gitignore b/.gitignore index cd17f908..1edf4fc4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ dist/ .env .env.* !.env.example -.opendots/ +.nexbot/ artifacts/ playwright-report/ test-results/ @@ -18,3 +18,7 @@ data/ # Local hosted-project selection .copilotkit/ + +# Compiled launcher (build with deployment/windows/build-launcher.cmd) +deployment/windows/NexBot.exe +logs/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1569e4b0..8496972b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to OpenDots +# Contributing to NexBot -OpenDots is an application template in early development. Focus changes on Spaces, Specialist Dots, text and calls, Slack, and inspectable background work. Keep the documented SDK integrations functional and report missing configuration clearly. +NexBot is an application template in early development. Focus changes on Spaces, Specialist Dots, text and calls, Slack, and inspectable background work. Keep the documented SDK integrations functional and report missing configuration clearly. For bugs, include the app mode, Node version, steps to reproduce, expected behavior, and actual behavior. Remove credentials and private page content from logs or screenshots. diff --git a/Dockerfile b/Dockerfile index 6f58f8e5..aef8593c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,13 +6,22 @@ COPY . . RUN npm run build FROM node:24-bookworm-slim AS app -ENV NODE_ENV=production HOST=0.0.0.0 PORT=4310 DATABASE_PATH=/data/opendots.sqlite +ENV NODE_ENV=production HOST=0.0.0.0 PORT=4310 DATABASE_PATH=/data/nexbot.sqlite WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev && mkdir -p /data && chown node:node /data COPY --from=build /app/dist ./dist +# Entrypoint validates config, waits (bounded) for Postgres, then execs the app. +# The app runs its idempotent, versioned migrations on startup and exits +# non-zero if they cannot run; exec preserves that exit code. +COPY deployment/docker-entrypoint.sh /usr/local/bin/nexbot-entrypoint.sh +RUN chmod 0755 /usr/local/bin/nexbot-entrypoint.sh USER node EXPOSE 4310 +# Liveness only (no datastore access, no auth). Node 24 has global fetch. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e 'fetch("http://127.0.0.1:4310/api/health").then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))' +ENTRYPOINT ["/usr/local/bin/nexbot-entrypoint.sh"] CMD ["node", "dist/server/server/index.js"] FROM node:24-bookworm-slim AS browser @@ -23,4 +32,6 @@ RUN npm ci --omit=dev && npx playwright install --with-deps chromium && chmod -R COPY --from=build /app/dist/server ./dist/server USER node EXPOSE 4311 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e 'fetch("http://127.0.0.1:4311/health").then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))' CMD ["node", "dist/server/browser/index.js"] diff --git a/README.md b/README.md index aa8a3048..981f7c00 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# OpenDots +# NexBot ### Always-on AI coworkers that move between text, calls, and Slack. @@ -14,9 +14,9 @@ Built with [CopilotKit](https://github.com/CopilotKit/CopilotKit) and [AG-UI](ht CopilotKit/OpenDots | Trendshift #2 TypeScript Repository Of The Day -Host OpenDots on your own infrastructure. Conversations require CopilotKit Intelligence: use the hosted service, the [local Docker evaluation](docs/SETUP.md#local-intelligence-evaluation), or a licensed [self-hosted deployment](https://docs.copilotkit.ai/intelligence/self-hosting). Clone this template and customize it however you want. +Host NexBot on your own infrastructure. Conversations require CopilotKit Intelligence: use the hosted service, the [local Docker evaluation](docs/SETUP.md#local-intelligence-evaluation), or a licensed [self-hosted deployment](https://docs.copilotkit.ai/intelligence/self-hosting). Clone this template and customize it however you want. -[**Building on OpenDots? Meet with the CopilotKit team →**](https://www.copilotkit.ai/talk-to-an-engineer?ref=opendots_readme) +[**Building on NexBot? Meet with the CopilotKit team →**](https://www.copilotkit.ai/talk-to-an-engineer?ref=nexbot_readme)
@@ -36,7 +36,7 @@ _Ask → browse → approve → save. A live computer view and a human review ca ## Overview -OpenDots is a starting point for building your own agent workspace. Clone it, define your Dots, connect your services, and adapt the interface and tools to your needs. +NexBot is a starting point for building your own agent workspace. Clone it, define your Dots, connect your services, and adapt the interface and tools to your needs. **A template, not a hosted product.** You run the application and configure its infrastructure. The template is in early development; the Features section below describes what's included and distinguishes local verification from connected-service testing. @@ -90,9 +90,9 @@ Give a Dot tools from any MCP server, such as email, calendar, GitHub, or your o ### Connected apps -Dots can use [Composio](https://composio.dev) to work with the owner's email, calendar, and other connected apps. Add `COMPOSIO_API_KEY`, restart OpenDots, then enable Connected apps in both the global settings and the Dot's settings. Adding the API key alone does not grant access. +Dots can use [Composio](https://composio.dev) to work with the owner's email, calendar, and other connected apps. Add `COMPOSIO_API_KEY`, restart NexBot, then enable Connected apps in both the global settings and the Dot's settings. Adding the API key alone does not grant access. -You can limit each Dot to toolkit slugs such as `gmail` or `google_calendar`. A blank toolkit list allows every toolkit in the Composio project. OpenDots identifies the owner with `OWNER_ID`, so keep that value stable after connecting accounts. When a tool needs authentication, the Dot returns a Connect link in chat for the owner to complete the provider's sign-in flow. +You can limit each Dot to toolkit slugs such as `gmail` or `google_calendar`. A blank toolkit list allows every toolkit in the Composio project. NexBot identifies the owner with `OWNER_ID`, so keep that value stable after connecting accounts. When a tool needs authentication, the Dot returns a Connect link in chat for the owner to complete the provider's sign-in flow. These tools act on real accounts. Start with a read-only request and use a test account before trying write actions. See [Composio setup](docs/SETUP.md#composio) for configuration, permissions, testing, and data-sharing details. @@ -160,7 +160,7 @@ flowchart TB You configure the Intelligence project, model provider, and channel connection for your deployment; calls also need a speech provider. Credentials stay on the server. Missing configuration should produce a clear setup state, and test fixtures should remain visibly separate from live integrations. -[OpenMuse](https://github.com/CopilotKit/OpenMuse) and [OpenBot](https://github.com/CopilotKit/openbot) are code references for persistent work, agent computers, and execution controls. OpenDots can be adapted to your own workflows and deployment choices. +[OpenMuse](https://github.com/CopilotKit/OpenMuse) and [OpenBot](https://github.com/CopilotKit/openbot) are code references for persistent work, agent computers, and execution controls. NexBot can be adapted to your own workflows and deployment choices. ## Get started @@ -168,7 +168,7 @@ Use **Node.js 24** and **npm**: ```sh git clone https://github.com/CopilotKit/OpenDots.git -cd OpenDots +cd NexBot npm ci cp .env.example .env npm run dev @@ -176,7 +176,7 @@ npm run dev Open **http://127.0.0.1:5173**. You can create Spaces, write pages, and configure Dots before connecting services. To start chatting, connect an Intelligence project and a model. -Choose where to store conversations before connecting. For local Docker evaluation, follow [Local Intelligence evaluation](docs/SETUP.md#local-intelligence-evaluation). For hosted Intelligence, sign in and select or create a project in the `OpenDots` folder: +Choose where to store conversations before connecting. For local Docker evaluation, follow [Local Intelligence evaluation](docs/SETUP.md#local-intelligence-evaluation). For hosted Intelligence, sign in and select or create a project in the `NexBot` folder: ```sh npx copilotkit@latest login @@ -185,17 +185,17 @@ npx copilotkit@latest project select `project select` writes a project key to `.env` as `CPK_INTELLIGENCE_API_KEY`. Keep the generated `CPK_TELEMETRY_ID` with that key so SDK usage can resolve to your Intelligence account. Then add `OPENAI_API_KEY` and `OPENAI_MODEL` to `.env` and restart `npm run dev`. -Do not run `copilotkit onboard` in this folder. OpenDots already contains its CopilotKit integration, and onboarding adds a second, generic one. +Do not run `copilotkit onboard` in this folder. NexBot already contains its CopilotKit integration, and onboarding adds a second, generic one. See [Setup](docs/SETUP.md) for configuration, Slack, calls, the browser service, and Docker. ## Data and privacy -Conversation messages, tool calls, and run events are persisted in the configured Intelligence deployment. The hosted setup sends them to CopilotKit's cloud; the local evaluation setup uses Intelligence on your machine. OpenDots has no standalone SQLite conversation store. SQLite stores pages, workspace metadata, and thread bindings separately. +Conversation messages, tool calls, and run events are persisted in the configured Intelligence deployment. The hosted setup sends them to CopilotKit's cloud; the local evaluation setup uses Intelligence on your machine. NexBot has no standalone SQLite conversation store. SQLite stores pages, workspace metadata, and thread bindings separately. The configured model provider receives conversation context, including authorized page content and tool results. Local Intelligence does not make a remote model local: configure the app's model and the Automatic Learning model separately. Public-web research sends queries and selected URLs to Parallel by default; set `WEB_SEARCH_PROVIDER=disabled` to disable those tools. When connected apps are enabled, tool arguments and results pass through Composio and the app provider. Speech and messaging integrations send data to their configured providers when used. -CopilotKit SDK telemetry collects usage metadata separately from conversation persistence. OpenDots also records bounded browser setup stages and first successful assistant activation with a random installation ID; the same opt-out flags disable this tracking and purge pending setup events. See [browser setup telemetry](docs/SETUP-TELEMETRY.md). Runtime events are tagged `OpenDots`; see [signup and usage tracking](docs/TELEMETRY.md). Set `COPILOTKIT_TELEMETRY_DISABLED=true` or `DO_NOT_TRACK=1` to opt out; see [CopilotKit telemetry](https://docs.copilotkit.ai/telemetry). Review [CopilotKit's privacy policy](https://www.copilotkit.ai/privacy-policy) and the policies and retention settings of each service you configure. Installing or renewing local Intelligence still requires CopilotKit sign-in and internet access. +CopilotKit SDK telemetry collects usage metadata separately from conversation persistence. NexBot also records bounded browser setup stages and first successful assistant activation with a random installation ID; the same opt-out flags disable this tracking and purge pending setup events. See [browser setup telemetry](docs/SETUP-TELEMETRY.md). Runtime events are tagged `NexBot`; see [signup and usage tracking](docs/TELEMETRY.md). Set `COPILOTKIT_TELEMETRY_DISABLED=true` or `DO_NOT_TRACK=1` to opt out; see [CopilotKit telemetry](https://docs.copilotkit.ai/telemetry). Review [CopilotKit's privacy policy](https://www.copilotkit.ai/privacy-policy) and the policies and retention settings of each service you configure. Installing or renewing local Intelligence still requires CopilotKit sign-in and internet access. ## Features @@ -215,7 +215,7 @@ CopilotKit SDK telemetry collects usage metadata separately from conversation pe | Automatic Learning | Per-Dot Learning containers, conversation evidence routing, and published-skill delivery; see [setup](docs/SETUP.md#automatic-learning) | | Deployment | Local Node setup and separate application/browser containers | -Scheduled tasks run in their original conversation. If a worker stops or its lease expires during a run, OpenDots marks that run **Interrupted** and waits for an explicit retry. Review its pages and computer actions, then use **Retry after review** when appropriate. Completed effects may already be present even when a run has no final result. +Scheduled tasks run in their original conversation. If a worker stops or its lease expires during a run, NexBot marks that run **Interrupted** and waits for an explicit retry. Review its pages and computer actions, then use **Retry after review** when appropriate. Completed effects may already be present even when a run has no final result. Local checks cover setup, persistence, permissions, SDK failure handling, and browser isolation. Automated tests use service fixtures. **Live Intelligence, model responses, and page-context chat were verified on September 29, 2026.** Live OpenBot computer browsing, file creation, shell verification, and file persistence across stop/start were also verified locally. Live Realtime speech, call controls, and receipt persistence were verified locally on September 30, 2026. Slack and spoken compute delegation still need connected-service verification. See [recording notes](docs/demos/README.md) for the demonstrated flows and limits. @@ -248,6 +248,8 @@ See [Contributing](CONTRIBUTING.md) for development guidance and [Security](SECU [MIT](LICENSE). +Based on the MIT-licensed OpenDots project by Atai Barkai; see LICENSE. + ## Public-web research Parallel is selected by default in live research and Dot conversations. Ask a topic-only question to discover and read up to five sources, or supply URLs to extract them directly. Sources are saved with their links. A browser worker is not required for this research path; computer tools remain available for interactive work. diff --git a/SECURITY.md b/SECURITY.md index 856e5e64..facfc96b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security -OpenDots is an application template under development, not a hosted service. The local prototype is single-owner; Space membership, Slack identity mapping, and voice delegation require additional enforcement before connected multi-user use. It is not a security-audited autonomous agent. +NexBot is an application template under development, not a hosted service. The local prototype is single-owner; Space membership, Slack identity mapping, and voice delegation require additional enforcement before connected multi-user use. It is not a security-audited autonomous agent. ## Intended boundary diff --git a/compose.computers-app.yml b/compose.computers-app.yml index b2e27a62..933fa7a3 100644 --- a/compose.computers-app.yml +++ b/compose.computers-app.yml @@ -6,7 +6,7 @@ services: COMPUTER_SUPERVISOR_URL: http://computer-supervisor:4300 COMPUTER_SUPERVISOR_TOKEN: ${COMPUTER_SUPERVISOR_TOKEN:?Set COMPUTER_SUPERVISOR_TOKEN in .env} COMPUTER_TOKEN: ${COMPUTER_TOKEN:?Set COMPUTER_TOKEN in .env} - COMPUTER_NAMESPACE: ${COMPUTER_NAMESPACE:-opendots} + COMPUTER_NAMESPACE: ${COMPUTER_NAMESPACE:-nexbot} networks: - computer-control - computers @@ -14,9 +14,9 @@ services: # Container-run apps use service DNS; no host supervisor port is needed. ports: !reset [] environment: - COMPUTER_NETWORK: ${COMPUTER_NAMESPACE:-opendots}-computers + COMPUTER_NETWORK: ${COMPUTER_NAMESPACE:-nexbot}-computers networks: computer-control: internal: true computers: - name: ${COMPUTER_NAMESPACE:-opendots}-computers + name: ${COMPUTER_NAMESPACE:-nexbot}-computers diff --git a/compose.computers.yml b/compose.computers.yml index b1075236..f0838bc0 100644 --- a/compose.computers.yml +++ b/compose.computers.yml @@ -4,13 +4,13 @@ services: computer-image: profiles: [build-computers] - image: opendots-computer:b6932d3 + image: nexbot-computer:b6932d3 build: context: https://github.com/CopilotKit/OpenBot.git#b6932d31a8d6e7896c15139dfc27a6c6911deb27 dockerfile: agent-computer/Dockerfile command: ['true'] computer-supervisor: - image: opendots-supervisor:b6932d3-dot-auth + image: nexbot-supervisor:b6932d3-dot-auth build: context: . dockerfile: deployment/computers/supervisor.Dockerfile @@ -22,8 +22,8 @@ services: PORT: '4300' SUPERVISOR_TOKEN: ${COMPUTER_SUPERVISOR_TOKEN:?Set COMPUTER_SUPERVISOR_TOKEN in .env} COMPUTER_TOKEN: ${COMPUTER_TOKEN:?Set COMPUTER_TOKEN in .env} - COMPUTER_NAMESPACE: ${COMPUTER_NAMESPACE:-opendots} - COMPUTER_IMAGE: opendots-computer:b6932d3 + COMPUTER_NAMESPACE: ${COMPUTER_NAMESPACE:-nexbot} + COMPUTER_IMAGE: nexbot-computer:b6932d3 COMPUTER_BROWSER_MODE: headless COMPUTER_NETWORK: '' COMPUTER_MEMORY_BYTES: ${COMPUTER_MEMORY_BYTES:-2147483648} diff --git a/compose.local.yml b/compose.local.yml new file mode 100644 index 00000000..5d5e5e31 --- /dev/null +++ b/compose.local.yml @@ -0,0 +1,28 @@ +# Local test overlay. Use it together with the base stack: +# +# docker compose -p nexbot --env-file ../../compose.env \ +# -f compose.yml -f compose.local.yml up -d +# +# It adds a reverse proxy on 127.0.0.1:8080 so there is one stable URL to test +# against, mirroring how the VPS deployment is reached through Caddy. Nothing +# here is exposed beyond loopback. +services: + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - 127.0.0.1:8080:8080 + volumes: + - ./deployment/Caddyfile.local:/etc/caddy/Caddyfile:ro + - caddy-data:/data + depends_on: + - app + networks: + - app-egress + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +volumes: + caddy-data: diff --git a/compose.yml b/compose.yml index 93d96246..b7fe11fa 100644 --- a/compose.yml +++ b/compose.yml @@ -1,42 +1,56 @@ +# NexBot (decoupled fork) — production Compose stack. +# +# No CopilotKit credentials are used anywhere in this stack: conversations live +# in the bundled `postgres` service and the app talks only to the model endpoint +# you configure. Nothing here contacts a CopilotKit host. +# +# Secrets have no defaults: `${VAR:?message}` makes `docker compose up` fail +# fast when a required value is missing, rather than starting with a weak one. services: app: build: context: . target: app + # Loopback-only publish. Front it with the reverse proxy in deployment/ + # (deployment/Caddyfile or deployment/nginx.conf.example). Never publish + # 4310 on a public interface. ports: - '127.0.0.1:4310:4310' environment: OWNER_TOKEN: ${OWNER_TOKEN:?Set a 24+ character OWNER_TOKEN in .env} APP_ORIGIN: ${APP_ORIGIN:-http://localhost:4310} - OWNER_ID: ${OWNER_ID:-opendots-owner} - INTELLIGENCE_API_KEY: ${INTELLIGENCE_API_KEY:-} - CPK_INTELLIGENCE_API_KEY: ${CPK_INTELLIGENCE_API_KEY:-} - CPK_TELEMETRY_ID: ${CPK_TELEMETRY_ID:-} - INTELLIGENCE_API_URL: ${INTELLIGENCE_API_URL:-} - INTELLIGENCE_GATEWAY_WS_URL: ${INTELLIGENCE_GATEWAY_WS_URL:-} - INTELLIGENCE_WS_URL: ${INTELLIGENCE_WS_URL:-} - DO_NOT_TRACK: ${DO_NOT_TRACK:-0} - COPILOTKIT_TELEMETRY_SAMPLE_RATE: ${COPILOTKIT_TELEMETRY_SAMPLE_RATE:-1} - COPILOTKIT_TELEMETRY_DISABLED: ${COPILOTKIT_TELEMETRY_DISABLED:-false} + OWNER_ID: ${OWNER_ID:-nexbot-owner} + # SQLite: non-conversation state (dots, spaces, pages, tasks, ...). + DATABASE_PATH: /data/nexbot.sqlite + # Postgres: conversations (REQUIRED). Replaces the CopilotKit service. + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env, e.g. postgres://nexbot:@postgres:5432/nexbot} + # Optional setup telemetry. Unset (default) => nothing leaves the host. + SETUP_TELEMETRY_ENDPOINT: ${SETUP_TELEMETRY_ENDPOINT:-} VOICE_API_KEY: ${VOICE_API_KEY:-} VOICE_MODEL: ${VOICE_MODEL:-} VOICE_NAME: ${VOICE_NAME:-marin} COMPOSIO_API_KEY: ${COMPOSIO_API_KEY:-} + # Slack: the managed vars are still accepted; SLACK_BOT_TOKEN and + # SLACK_SIGNING_SECRET are OPTIONAL and NOT YET WIRED in this build. SLACK_CHANNEL_NAME: ${SLACK_CHANNEL_NAME:-} SLACK_TEAM_ID: ${SLACK_TEAM_ID:-} SLACK_USER_IDS: ${SLACK_USER_IDS:-} SLACK_DOT_ID: ${SLACK_DOT_ID:-} + SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN:-} + SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET:-} AGENT_RUN_TIMEOUT_MS: ${AGENT_RUN_TIMEOUT_MS:-90000} OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} OPENAI_MODEL: ${OPENAI_MODEL:-} + OPENAI_EXTRA_HEADERS: ${OPENAI_EXTRA_HEADERS:-} WEB_SEARCH_PROVIDER: ${WEB_SEARCH_PROVIDER:-parallel} PARALLEL_API_KEY: ${PARALLEL_API_KEY:-} BROWSER_URL: http://browser:4311 BROWSER_SECRET: ${BROWSER_SECRET:-} volumes: - - opendots-data:/data + - nexbot-data:/data networks: + - conversations - browser-link - app-egress init: true @@ -45,6 +59,57 @@ services: - no-new-privileges:true cap_drop: - ALL + depends_on: + postgres: + condition: service_healthy + deploy: + resources: + limits: + # Recommended minimum for the core stack. Raise if you run many + # concurrent agent turns. + cpus: '1.0' + memory: 1g + + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env} + volumes: + - nexbot-pgdata:/var/lib/postgresql/data + networks: + - conversations + # No `ports:` on purpose: Postgres is reachable only on the internal + # `conversations` network, never from the host or the internet. + restart: unless-stopped + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + # The official image entrypoint must chown the data directory and drop from + # root to the `postgres` user. These are the minimum capabilities for that; + # everything else stays dropped. + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + # Recommended minimum. Conversations grow with use; thread_events is + # the fast-growing table. + cpus: '1.0' + memory: 1g + browser: profiles: [browser] build: @@ -68,7 +133,11 @@ services: networks: browser-link: internal: true + # App -> Postgres only. `internal: true` means no route off the host. + conversations: + internal: true app-egress: browser-egress: volumes: - opendots-data: + nexbot-data: + nexbot-pgdata: diff --git a/deployment/Caddyfile b/deployment/Caddyfile new file mode 100644 index 00000000..3eb062cd --- /dev/null +++ b/deployment/Caddyfile @@ -0,0 +1,71 @@ +# NexBot reverse proxy (Caddy 2) with automatic HTTPS. +# +# Caddy obtains and renews Let's Encrypt certificates automatically, so the only +# requirement is that :80 and :443 are reachable from the internet and that +# NEXBOT_DOMAIN resolves to this host. This is the preferred proxy. +# +# Set before starting Caddy: +# NEXBOT_DOMAIN your public domain (placeholder default below) +# ACME_EMAIL contact address for ACME (placeholder default below) +# +# How to run Caddy: +# * As a container on the same Docker network as the `app` service, so the +# upstream `app:4310` below resolves via Docker DNS. See the override +# snippet in deployment/README.md ("Reverse proxy"). +# * On the host, binding :80/:443 directly; then change the upstream to +# `reverse_proxy 127.0.0.1:4310`. The app port stays loopback-only. +# +# Never publish 4310 publicly; only this proxy is exposed. + +{ + email {$ACME_EMAIL:admin@example.com} + # While testing DNS/ports, uncomment to avoid hitting Let's Encrypt rate + # limits (certificates from staging are not trusted by browsers): + # acme_ca https://acme-staging-v02.api.letsencrypt.org/directory +} + +{$NEXBOT_DOMAIN:dots.example.com} { + encode zstd gzip + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + X-Frame-Options "DENY" + Cross-Origin-Opener-Policy "same-origin" + -Server + } + + # Streaming chat turns use SSE; never buffer the response. + reverse_proxy app:4310 { + flush_interval -1 + transport http { + read_timeout 0 + write_timeout 0 + } + } + + log { + output file /var/log/caddy/nexbot.log + format json + } + + # --- Edge rate limiting (OPTIONAL) ---------------------------------------- + # The app has no per-IP rate limiting. This requires a Caddy build that + # includes the caddy-ratelimit module; uncomment only if yours does. + # rate_limit { + # zone chat { + # key {remote_host} + # events 60 + # window 1m + # } + # } + + # --- Self-hosted Slack Events API webhook (OPTIONAL; NOT YET WIRED) -------- + # When the self-hosted Slack integration ships, Slack must reach this path + # over public HTTPS (SLACK_SIGNING_SECRET verifies each request). Until then + # no such route exists in the app, so leave this commented. + # handle /api/slack/events* { + # reverse_proxy app:4310 + # } +} diff --git a/deployment/Caddyfile.local b/deployment/Caddyfile.local new file mode 100644 index 00000000..a1dd5000 --- /dev/null +++ b/deployment/Caddyfile.local @@ -0,0 +1,26 @@ +# Local test proxy: one stable URL in front of the app. +# +# No TLS on purpose. This is for testing on the operator's own machine, so it +# listens on loopback only and skips certificate issuance entirely. For a real +# deployment use deployment/Caddyfile, which terminates HTTPS for a domain. + +{ + auto_https off + admin off +} + +:8080 { + encode gzip + + # flush_interval -1 disables response buffering so AG-UI server-sent events + # reach the browser as they are produced instead of in batches. + reverse_proxy app:4310 { + flush_interval -1 + } + + header { + X-Content-Type-Options nosniff + Referrer-Policy no-referrer + -Server + } +} diff --git a/deployment/README.md b/deployment/README.md new file mode 100644 index 00000000..428e396e --- /dev/null +++ b/deployment/README.md @@ -0,0 +1,255 @@ +# NexBot deployment runbook + +Operate the decoupled, self-hosted NexBot fork on your own Linux VPS. +**No CopilotKit account, project, or API key is required.** Conversations live in +your own PostgreSQL; everything else stays in SQLite; the app talks only to the +OpenAI-compatible model endpoint you choose. + +- Topology and rationale: [`docs/fork/ARCHITECTURE.md`](../docs/fork/ARCHITECTURE.md) +- Planned-vs-built status: [`docs/fork/DEPLOYMENT.md`](../docs/fork/DEPLOYMENT.md) + +## 1. Prerequisites + +- A Linux VPS (2 vCPU / 2 GB RAM / 20 GB disk minimum for app + Postgres). +- Docker Engine 24+ with the Compose v2 plugin (`docker compose version`). +- A public domain pointing at the VPS (for HTTPS), ports 80 and 443 reachable. +- Git, and `openssl` for secret generation. +- Node.js 24 is only needed if you build outside Docker; the Docker build + handles it. + +## 2. First install + +```sh +git clone /opt/nexbot +cd /opt/nexbot +git checkout fork/decouple-and-harden # pin a commit, do not track main +cp .env.example .env +``` + +Generate the two core secrets (each 24+ characters) and put them in `.env`: + +```sh +openssl rand -base64 48 # -> OWNER_TOKEN +openssl rand -base64 48 # -> POSTGRES_PASSWORD +``` + +Fill in `.env`. The required values are: + +| Variable | Notes | +| ------------------- | ---------------------------------------------------------------------------- | +| `OWNER_TOKEN` | 24+ chars. Bearer login + every `/api` call. Treat as a password. | +| `DATABASE_URL` | `postgres://:@postgres:5432/` | +| `POSTGRES_USER` | Default `nexbot`. | +| `POSTGRES_PASSWORD` | Must match the password inside `DATABASE_URL`. | +| `POSTGRES_DB` | Default `nexbot`. | +| `APP_ORIGIN` | Your public HTTPS origin, e.g. `https://dots.example.com`. | +| `OPENAI_BASE_URL` | Model endpoint (OpenAI, Ollama, vLLM, LM Studio, OpenRouter, ...). | +| `OPENAI_MODEL` | Model id served by that endpoint. | + +`HOST=0.0.0.0` in the template is correct inside the container; only the proxy +binds publicly. + +Start the core stack (app + Postgres): + +```sh +docker compose up -d --build +``` + +Start the core stack plus the optional read-only browser service (needed only +for `WEB_SEARCH_PROVIDER=browser`): + +```sh +docker compose --profile browser up -d --build +``` + +Add the optional computer supervisor (one container per Dot) by layering the +overlays, in this order: + +```sh +docker compose -f compose.yml -f compose.computers.yml -f compose.computers-app.yml \ + build computer-image computer-supervisor app browser +docker compose -f compose.yml -f compose.computers.yml -f compose.computers-app.yml \ + up -d app browser computer-supervisor +``` + +The Docker socket is mounted **only** into `computer-supervisor`; the app and +each Dot computer never receive it. + +## 3. Migrations + +There is no separate migration command to run. On every start the app applies +its idempotent, versioned PostgreSQL migrations as part of boot: + +- The app container's entrypoint validates config, waits (bounded, 60s) for + Postgres to accept connections, then `exec`s the server. +- The server runs migrations during startup and **exits non-zero** if they + cannot run. Because the entrypoint `exec`s it, the container fails fast rather + than serving with an unmigrated database. +- `depends_on: condition: service_healthy` already holds the app until + `pg_isready` passes, so the wait is a second, explicit guard. + +To watch migrations: `docker compose logs -f app`. + +## 4. Health and readiness + +| Check | Command | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Container health | `docker compose ps` (uses the image `HEALTHCHECK`) | +| App liveness | `curl -fsS http://127.0.0.1:4310/api/health` | +| App readiness | `curl -fsS http://127.0.0.1:4310/api/ready` | +| Browser liveness | `docker compose --profile browser exec browser node -e 'fetch("http://127.0.0.1:4311/health").then(r=>process.exit(r.ok?0:1))'` | +| Postgres | `docker compose exec postgres pg_isready -U "$POSTGRES_USER"` | + +`/api/health` and `/api/ready` are documented as PLANNED in +`docs/fork/DEPLOYMENT.md`; if they 404 on your build, the app-side workstream +has not landed them yet. + +## 5. Reverse proxy (HTTPS) + +Prefer Caddy: [`Caddyfile`](./Caddyfile) does automatic TLS. +Alternative: [`nginx.conf.example`](./nginx.conf.example) (with certbot notes). + +The app's port stays bound to `127.0.0.1:4310`. Never publish 4310. + +Run Caddy as a container on the app's network by saving this as +`deployment/compose.caddy.yml` and layering it: + +```yaml +services: + caddy: + image: caddy:2.10-alpine + ports: + - '80:80' + - '443:443' + environment: + NEXBOT_DOMAIN: ${NEXBOT_DOMAIN:?Set NEXBOT_DOMAIN in .env} + ACME_EMAIL: ${ACME_EMAIL:-admin@example.com} + volumes: + - ./deployment/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + networks: + - app-egress + restart: unless-stopped + security_opt: + - no-new-privileges:true +volumes: + caddy-data: + caddy-config: +``` + +```sh +docker compose -f compose.yml -f deployment/compose.caddy.yml up -d +``` + +If you run Caddy (or nginx) directly on the host instead, change the upstream +from `app:4310` to `127.0.0.1:4310` in the proxy config. + +## 6. Firewall + +Expose only 80 and 443 publicly. Nothing else. + +```sh +sudo ufw default deny incoming +sudo ufw allow 22/tcp # SSH: restrict to your IP if possible +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +``` + +| Port | Service | Public? | +| --------- | ------------------- | ---------------------------------- | +| 80/443 | reverse proxy | Yes | +| 4310 | app | No — loopback only, behind proxy | +| 5432 | Postgres | No — internal network, no `ports:` | +| 4311 | browser service | No — internal network only | +| 4312/4300 | computer supervisor | No — loopback/internal only | + +On a cloud VPS enforce this in the provider security group too, not only on the +host. + +## 7. Upgrade + +```sh +cd /opt/nexbot +git fetch && git checkout # pin; do not track main blindly +docker compose build app browser # browser only if it changed +docker compose stop app +docker compose up -d app # applies migrations on boot +docker compose logs -f app # confirm migrations + readiness +curl -fsS http://127.0.0.1:4310/api/ready +``` + +Smoke-test one chat turn and the conversation list in the UI. + +## 8. Rollback + +```sh +cd /opt/nexbot +docker compose stop app +git checkout +docker compose build app +docker compose up -d app +curl -fsS http://127.0.0.1:4310/api/ready +``` + +Migrations are additive/forward-compatible where possible. If a release +contains a non-additive migration, restore the Postgres backup taken before the +upgrade (Section 9) before starting the old build. + +## 9. Backup and restore + +```sh +# Consistent pair into ./backups/nexbot-backup-/ +deployment/backup.sh + +# Prune older than 30 days instead of the 14-day default, online (no downtime): +RETENTION_DAYS=30 QUIESCE=0 deployment/backup.sh + +# Restore (asks for confirmation; prints the fixed order): +deployment/restore.sh backups/nexbot-backup-20260101-030000 +``` + +- `backup.sh` stops the app by default (`QUIESCE=1`) so the SQLite and Postgres + pair is consistent, then restarts it. It dumps Postgres with + `pg_dump -Fc` and snapshots SQLite with a consistent online copy + (`sqlite3 .backup` on the host, or `node:sqlite`'s `backup()` via the app image + when the DB lives in a volume), verifies integrity, writes a + `MANIFEST.txt`, and prunes old backups. +- `restore.sh` stops the app, drops and recreates the Postgres database, restores + Postgres, replaces the SQLite file (dropping stale `-wal`/`-shm`), starts the + app, and waits for readiness. It is idempotent and can be re-run. +- Schedule it from cron (as a user that can run `docker`): + + ```cron + 15 3 * * * cd /opt/nexbot && QUIESCE=0 ./deployment/backup.sh >>/var/log/nexbot-backup.log 2>&1 + ``` + + Use `QUIESCE=1` (default) if you accept a brief stop for a fully consistent + pair; `QUIESCE=0` avoids downtime but the two stores may differ by in-flight + turns. + +After a restore, verify pairing if anything looks wrong: SQLite +`thread_bindings.id` must exist in PostgreSQL `conversations.id`, and vice +versa. + +## 10. Resource requirements + +| Component | CPU | RAM | Disk | +| ------------------------------ | ------ | ----------- | --------------------- | +| app | 1 vCPU | 1 GB | ~1 GB image + volumes | +| Postgres | 1 vCPU | 512 MB–1 GB | 5–20 GB | +| browser (optional) | 1 vCPU | 1 GB | ~1 GB | +| computer supervisor (optional) | 1 vCPU | 512 MB | ~2 GB image | + +`compose.yml` sets `deploy.resources.limits` of 1 CPU / 1 GB for both `app` and +`postgres` as recommended minimums; raise them for heavy concurrent use. Each +Dot computer consumes additional RAM/disk (`COMPUTER_MEMORY_BYTES`, default +2 GiB). + +## 11. systemd (optional) + +Operators who prefer systemd over Compose restart policies can use +[`nexbot.service.example`](./nexbot.service.example). It runs +`docker compose up` on boot with `NoNewPrivileges` and a dedicated user. Follow +the install steps in the file header. diff --git a/deployment/backup.sh b/deployment/backup.sh new file mode 100644 index 00000000..8bbccd19 --- /dev/null +++ b/deployment/backup.sh @@ -0,0 +1,140 @@ +#!/bin/sh +# NexBot consistent backup: PostgreSQL (custom format) + SQLite, into one +# timestamped directory, then optional pruning. Safe for cron; fails loudly. +# +# The two datastores share conversation IDs, so they must be kept as one pair: +# both are written under the same timestamped directory in the same run. +# +# Usage: +# deployment/backup.sh +# +# Configuration (environment): +# BACKUP_DIR where timestamped dirs are written (default ./backups) +# RETENTION_DAYS prune dirs older than N days; 0=keep all (default 14) +# COMPOSE_FILES e.g. "-f compose.yml -f compose.computers.yml" +# (default "-f compose.yml") +# PG_SERVICE postgres service name (default postgres) +# APP_SERVICE app service name (used for SQLite) (default app) +# QUIESCE 1=stop the app for a quiesced pair, 0=online (default 1) +# HOST_SQLITE_PATH host-visible SQLite file, if any; uses host sqlite3 +# +# Never prints secrets: the password is read from the postgres container's own +# environment and is never echoed or passed on the command line. +set -eu + +REPO_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +BACKUP_DIR=${BACKUP_DIR:-"$REPO_ROOT/backups"} +RETENTION_DAYS=${RETENTION_DAYS:-14} +COMPOSE_FILES=${COMPOSE_FILES:-"-f compose.yml"} +PG_SERVICE=${PG_SERVICE:-postgres} +APP_SERVICE=${APP_SERVICE:-app} +QUIESCE=${QUIESCE:-1} +HOST_SQLITE_PATH=${HOST_SQLITE_PATH:-} + +log() { printf '[backup] %s\n' "$*" >&2; } +die() { + printf '[backup] ERROR: %s\n' "$*" >&2 + exit 1 +} + +# shellcheck disable=SC2086 +compose() { docker compose $COMPOSE_FILES "$@"; } + +command -v docker >/dev/null 2>&1 || die 'docker is not installed or not on PATH.' +cd "$REPO_ROOT" + +timestamp=$(date -u '+%Y%m%d-%H%M%S') +dest="$BACKUP_DIR/nexbot-backup-$timestamp" +mkdir -p "$dest" || die "cannot create backup directory: $dest" + +app_running=0 +if [ "$QUIESCE" = "1" ] && [ -n "$(compose ps -q "$APP_SERVICE" 2>/dev/null)" ]; then + app_running=1 +fi + +restart_app() { + if [ "$app_running" = "1" ]; then + log "starting $APP_SERVICE again" + compose start "$APP_SERVICE" >/dev/null 2>&1 \ + || compose up -d "$APP_SERVICE" >/dev/null 2>&1 \ + || log "WARNING: could not restart $APP_SERVICE; start it manually" + fi +} +trap 'rc=$?; restart_app; exit $rc' EXIT INT TERM + +if [ "$app_running" = "1" ]; then + log "stopping $APP_SERVICE for a quiesced backup" + compose stop "$APP_SERVICE" >/dev/null || die "could not stop $APP_SERVICE" +else + log "WARNING: taking an online backup (app not stopped). The two stores may" + log " differ by the turns that were in flight. Set QUIESCE=1 (default)" + log " when the app was running to stop it for a consistent pair." +fi + +# --- PostgreSQL (conversations) ---------------------------------------------- +log "dumping PostgreSQL to $dest/nexbot.pgdump (custom format)" +# Reads POSTGRES_* from the container's own environment; no secret on the CLI. +compose exec -T "$PG_SERVICE" sh -c \ + 'PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -h 127.0.0.1 -Fc -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + >"$dest/nexbot.pgdump" || die 'pg_dump failed' +[ -s "$dest/nexbot.pgdump" ] || die 'pg_dump produced an empty file' +head -c 5 "$dest/nexbot.pgdump" | grep -q 'PGDMP' \ + || die 'pg_dump output is not a custom-format archive' + +# --- SQLite (non-conversation state) ----------------------------------------- +log "snapshotting SQLite to $dest/nexbot.sqlite" +if [ -n "$HOST_SQLITE_PATH" ] && [ -f "$HOST_SQLITE_PATH" ] && command -v sqlite3 >/dev/null 2>&1; then + rm -f "$dest/nexbot.sqlite" + sqlite3 "$HOST_SQLITE_PATH" ".backup '$dest/nexbot.sqlite'" \ + || die 'sqlite3 .backup failed' + integrity=$(sqlite3 "$dest/nexbot.sqlite" 'PRAGMA integrity_check;') + [ "$integrity" = "ok" ] || die "SQLite integrity_check failed: $integrity" +else + # The app image ships node:sqlite (the app uses it). Its `backup()` function is + # the programmatic equivalent of `sqlite3 .backup`: a consistent online copy. + # It needs neither a host sqlite3 nor the file to be visible outside the + # volume. Runs as root so it can always write into the host bind mount. + compose run -T --rm --no-deps --user 0:0 -v "$dest:/backup" \ + --entrypoint node "$APP_SERVICE" --input-type=module -e ' + import { DatabaseSync, backup } from "node:sqlite"; + import { rmSync } from "node:fs"; + const src = process.env.DATABASE_PATH ?? "/data/nexbot.sqlite"; + const out = "/backup/nexbot.sqlite"; + const db = new DatabaseSync(src); + db.exec("PRAGMA busy_timeout=5000"); + // Fold the WAL into the main file; not essential (backup() already reads + // a consistent snapshot) so a busy checkpoint is not fatal online. + try { db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } catch {} + rmSync(out, { force: true }); + await backup(db, out); + db.close(); + const check = new DatabaseSync(out); + const rows = check.prepare("PRAGMA integrity_check").all(); + check.close(); + if (rows.length !== 1 || rows[0].integrity_check !== "ok") { + console.error("SQLite integrity_check failed:", JSON.stringify(rows)); + process.exit(1); + } + ' || die 'container SQLite snapshot failed' +fi +[ -s "$dest/nexbot.sqlite" ] || die 'SQLite snapshot is empty' + +# --- Manifest ---------------------------------------------------------------- +{ + printf 'created_utc=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + printf 'git_commit=%s\n' "$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || printf 'unknown')" + printf 'postgres_dump=nexbot.pgdump (pg_dump -Fc)\n' + printf 'sqlite_file=nexbot.sqlite\n' + printf 'quiesced=%s\n' "$QUIESCE" +} >"$dest/MANIFEST.txt" + +# --- Prune ------------------------------------------------------------------- +if [ "$RETENTION_DAYS" -gt 0 ] 2>/dev/null; then + log "pruning backups older than ${RETENTION_DAYS} day(s) in $BACKUP_DIR" + find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name 'nexbot-backup-*' \ + -mtime "+$RETENTION_DAYS" -exec rm -rf -- {} + 2>/dev/null \ + || log "WARNING: pruning reported an error (backup itself succeeded)" +fi + +log "backup complete: $dest" +ls -l "$dest" >&2 || true diff --git a/deployment/computers/README.md b/deployment/computers/README.md index 61d47a07..c583a3c7 100644 --- a/deployment/computers/README.md +++ b/deployment/computers/README.md @@ -1,8 +1,8 @@ # OpenBot computer services -OpenDots builds the computer service and supervisor from CopilotKit/OpenBot revision `b6932d31a8d6e7896c15139dfc27a6c6911deb27` (MIT). Their source is downloaded by BuildKit from the pinned Git context; it is not resolved from a moving branch or a `latest` image. +NexBot builds the computer service and supervisor from CopilotKit/OpenBot revision `b6932d31a8d6e7896c15139dfc27a6c6911deb27` (MIT). Their source is downloaded by BuildKit from the pinned Git context; it is not resolved from a moving branch or a `latest` image. -The computer service is unchanged. The supervisor keeps its narrow ensure/stop/reset/list implementation and resource ownership checks. Its image omits the unused SPIRE CLI, and applies one fail-closed patch to child environment construction: each computer receives `HMAC-SHA256(COMPUTER_TOKEN, "opendots-computer:" + dotId)` instead of the master token. The application uses the same derivation. No supervisor token, model key, or master computer token is forwarded to a computer. +The computer service is unchanged. The supervisor keeps its narrow ensure/stop/reset/list implementation and resource ownership checks. Its image omits the unused SPIRE CLI, and applies one fail-closed patch to child environment construction: each computer receives `HMAC-SHA256(COMPUTER_TOKEN, "nexbot-computer:" + dotId)` instead of the master token. The application uses the same derivation. No supervisor token, model key, or master computer token is forwarded to a computer. If the pinned upstream line changes, the patch refuses to build. Upgrades require reviewing the API contracts, ownership/volume behavior, and this patch together. On the next ensure request after a master-token change, the pinned supervisor replaces owned containers with the new credential while retaining their volumes. Do not delete profile or workspace volumes during normal updates. diff --git a/deployment/computers/harden-supervisor.mjs b/deployment/computers/harden-supervisor.mjs index 82a2b707..54ea659a 100644 --- a/deployment/computers/harden-supervisor.mjs +++ b/deployment/computers/harden-supervisor.mjs @@ -14,7 +14,7 @@ export function hardenSupervisorEnvironment(source) { before, `const master = env.COMPUTER_TOKEN?.trim(); if (!master || master.length < 24) throw new Error("COMPUTER_TOKEN must contain at least 24 characters."); - const computerToken = createHmac("sha256", master).update("opendots-computer:" + botId).digest("hex");`, + const computerToken = createHmac("sha256", master).update("nexbot-computer:" + botId).digest("hex");`, )}`; } if ( diff --git a/deployment/docker-entrypoint.sh b/deployment/docker-entrypoint.sh new file mode 100644 index 00000000..5639738e --- /dev/null +++ b/deployment/docker-entrypoint.sh @@ -0,0 +1,67 @@ +#!/bin/sh +# NexBot app container entrypoint. +# +# Responsibilities: +# 1. Fail fast if required configuration is missing. +# 2. Wait, briefly and with a hard bound, for PostgreSQL to accept TCP +# connections. The app applies its idempotent, versioned schema migrations +# on startup, so a reachable Postgres is a precondition, not a nicety. +# 3. `exec` the app so migrations run as part of start and the app's exit code +# (non-zero when migrations cannot run) becomes the container's exit code. +# +# Failure is never swallowed: `set -eu` plus `exec` means any error here or in +# the app is the container's non-zero exit status. +set -eu + +log() { + printf '[entrypoint] %s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*" >&2 +} + +die() { + log "FATAL: $*" + exit 1 +} + +[ -n "${DATABASE_URL:-}" ] || die 'DATABASE_URL is required (PostgreSQL connection string).' +[ -n "${DATABASE_PATH:-}" ] || die 'DATABASE_PATH is required (SQLite file path).' + +node -e ' +try { + const u = new URL(process.env.DATABASE_URL); + if (!u.hostname) process.exit(1); +} catch { + process.exit(1); +} +' || die 'DATABASE_URL is not a valid URL.' + +# Bounded wait for Postgres TCP reachability. Compose normally gates this with +# the postgres healthcheck (`depends_on: condition: service_healthy`); this is a +# second, explicit guard so a manually started or restarted container fails +# loudly instead of crash-looping. +wait_seconds="${PG_WAIT_SECONDS:-60}" +elapsed=0 +until node -e ' +import("node:net").then(({ connect }) => { + let u; + try { + u = new URL(process.env.DATABASE_URL); + } catch { + process.exit(2); + } + const sock = connect({ host: u.hostname, port: Number(u.port || 5432) }); + sock.setTimeout(4000); + sock.on("connect", () => { sock.end(); process.exit(0); }); + sock.on("timeout", () => { sock.destroy(); process.exit(1); }); + sock.on("error", () => process.exit(1)); +}).catch(() => process.exit(1)); +'; do + if [ "$elapsed" -ge "$wait_seconds" ]; then + die "PostgreSQL not reachable at DATABASE_URL after ${wait_seconds}s; refusing to start (migrations cannot run)." + fi + log "waiting for PostgreSQL (${elapsed}s/${wait_seconds}s)..." + sleep 2 + elapsed=$((elapsed + 2)) +done + +log "PostgreSQL reachable; running: $*" +exec "$@" diff --git a/deployment/nexbot.service.example b/deployment/nexbot.service.example new file mode 100644 index 00000000..99119f2d --- /dev/null +++ b/deployment/nexbot.service.example @@ -0,0 +1,59 @@ +# NexBot as a hardened systemd service (alternative to Compose restart +# policies). Install to /etc/systemd/system/nexbot.service, then: +# +# sudo useradd --system --home /opt/nexbot --shell /usr/sbin/nologin nexbot +# sudo usermod -aG docker nexbot +# sudo chown -R nexbot:nexbot /opt/nexbot +# sudo systemctl daemon-reload +# sudo systemctl enable --now nexbot +# +# Requirements: +# * The repo lives at /opt/nexbot with a filled-in .env (docker compose +# reads .env from WorkingDirectory; do NOT put secrets in Environment=). +# * The `nexbot` user must be in the `docker` group to reach the daemon; +# membership in `docker` is root-equivalent, so treat this user as +# privileged and give it no other duties. +# * Keep compose.yml's `restart: unless-stopped` too; systemd restarts the +# `docker compose up` process, Compose restarts individual containers. + +[Unit] +Description=NexBot (docker compose stack) +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/nexbot +# Runs on boot and on `systemctl start`. `--remove-orphans` cleans up services +# removed from the compose files between upgrades. +ExecStart=/usr/bin/docker compose up -d --remove-orphans +ExecStop=/usr/bin/docker compose down +ExecReload=/usr/bin/docker compose up -d --remove-orphans +TimeoutStartSec=0 +TimeoutStopSec=120 + +# Dedicated, unprivileged user (must be in the docker group; see above). +User=nexbot +Group=nexbot + +# Hardening. Docker needs a writable home-independent state; keep the sandbox +# modest so the compose client and the daemon socket keep working. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +# docker compose must read the repo and write to the volumes it manages. +ReadWritePaths=/opt/nexbot + +Restart=on-failure +RestartSec=10s + +[Install] +WantedBy=multi-user.target diff --git a/deployment/nginx.conf.example b/deployment/nginx.conf.example new file mode 100644 index 00000000..517e1714 --- /dev/null +++ b/deployment/nginx.conf.example @@ -0,0 +1,78 @@ +# NexBot reverse proxy (nginx) — alternative to deployment/Caddyfile. +# +# Prefer Caddy (deployment/Caddyfile) for automatic HTTPS. Use this only if you +# already run nginx or cannot use Caddy. +# +# Certificates with certbot: +# sudo certbot --nginx -d dots.example.com +# or, for the non-nginx plugin, point ssl_certificate at the generated files +# (paths below) and reload: sudo certbot renew --deploy-hook 'nginx -s reload' +# +# Replace dots.example.com everywhere, and choose the upstream: +# * host-run nginx (recommended): proxy_pass http://127.0.0.1:4310; +# * nginx container on the app's Docker network: proxy_pass http://app:4310; +# The app port must stay loopback-only; only nginx binds :80/:443. + +# HTTP: ACME challenge + redirect to HTTPS. +server { + listen 80; + listen [::]:80; + server_name dots.example.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name dots.example.com; + + ssl_certificate /etc/letsencrypt/live/dots.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/dots.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-Frame-Options "DENY" always; + server_tokens off; + + # The app has no per-IP rate limiting; add edge limits of your own if wanted + # (limit_req_zone must be declared in the http{} block). + client_max_body_size 2m; + + location / { + proxy_pass http://127.0.0.1:4310; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + + # Streaming chat turns use SSE; disable buffering/caching and allow + # long-lived responses. + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # --- Self-hosted Slack Events API webhook (OPTIONAL; NOT YET WIRED) -------- + # location = /api/slack/events { + # proxy_pass http://127.0.0.1:4310; + # proxy_set_header Host $host; + # proxy_set_header X-Forwarded-Proto $scheme; + # } +} diff --git a/deployment/restore.sh b/deployment/restore.sh new file mode 100644 index 00000000..94ae35f9 --- /dev/null +++ b/deployment/restore.sh @@ -0,0 +1,131 @@ +#!/bin/sh +# NexBot restore: PostgreSQL + SQLite from a backup produced by backup.sh. +# Loud and idempotent: the order is fixed and every step is printed. +# +# Order (documented, do not reorder): +# 1. stop the app (no writers) +# 2. restore PostgreSQL (drop + recreate the database, then pg_restore) +# 3. restore SQLite (replace the file and drop stale -wal/-shm) +# 4. start the app +# 5. verify readiness (/api/ready, falling back to /api/health) +# +# Usage: +# deployment/restore.sh +# FORCE=1 deployment/restore.sh # skip the confirmation prompt +# +# Configuration (environment): +# COMPOSE_FILES e.g. "-f compose.yml -f compose.computers.yml" +# (default "-f compose.yml") +# PG_SERVICE postgres service name (default postgres) +# APP_SERVICE app service name (default app) +# +# Never prints secrets: the password is read from the postgres container's own +# environment and is never echoed or passed on the command line. +set -eu + +REPO_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +COMPOSE_FILES=${COMPOSE_FILES:-"-f compose.yml"} +PG_SERVICE=${PG_SERVICE:-postgres} +APP_SERVICE=${APP_SERVICE:-app} +FORCE=${FORCE:-0} + +log() { printf '[restore] %s\n' "$*" >&2; } +die() { + printf '[restore] ERROR: %s\n' "$*" >&2 + exit 1 +} + +# shellcheck disable=SC2086 +compose() { docker compose $COMPOSE_FILES "$@"; } + +command -v docker >/dev/null 2>&1 || die 'docker is not installed or not on PATH.' +cd "$REPO_ROOT" + +if [ "$#" -ne 1 ]; then + printf 'usage: %s \n' "$0" >&2 + exit 2 +fi +backup_dir=$1 +[ -d "$backup_dir" ] || die "backup directory not found: $backup_dir" +[ -f "$backup_dir/nexbot.pgdump" ] || die "missing $backup_dir/nexbot.pgdump" +[ -f "$backup_dir/nexbot.sqlite" ] || die "missing $backup_dir/nexbot.sqlite" + +log "about to restore from: $backup_dir" +log "plan:" +log " 1. stop $APP_SERVICE" +log " 2. DROP and recreate the PostgreSQL database, then pg_restore" +log " 3. replace the SQLite file (removes stale -wal/-shm)" +log " 4. start $APP_SERVICE" +log " 5. verify readiness" +log "This OVERWRITES current conversations and app state." + +if [ "$FORCE" != "1" ]; then + printf '[restore] type exactly RESTORE to continue: ' >&2 + answer='' + read -r answer || answer='' + [ "$answer" = "RESTORE" ] || die 'aborted (confirmation not given)' +fi + +# --- 1. stop the app --------------------------------------------------------- +log "step 1/5: stopping $APP_SERVICE" +compose stop "$APP_SERVICE" >/dev/null || die "could not stop $APP_SERVICE" + +# --- 2. restore PostgreSQL --------------------------------------------------- +log "step 2/5: restoring PostgreSQL" +compose cp "$backup_dir/nexbot.pgdump" "$PG_SERVICE:/tmp/nexbot-restore.pgdump" \ + || die 'could not copy the PostgreSQL dump into the postgres container' +compose exec -T "$PG_SERVICE" sh -c ' + set -eu + export PGPASSWORD="$POSTGRES_PASSWORD" + # --force terminates any lingering connections so DROP cannot fail. + dropdb --if-exists --force -h 127.0.0.1 -U "$POSTGRES_USER" "$POSTGRES_DB" + createdb -h 127.0.0.1 -U "$POSTGRES_USER" -O "$POSTGRES_USER" "$POSTGRES_DB" + pg_restore -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ + --no-owner --no-privileges -Fc /tmp/nexbot-restore.pgdump +' || die 'PostgreSQL restore failed' +compose exec -T "$PG_SERVICE" rm -f /tmp/nexbot-restore.pgdump >/dev/null 2>&1 || true + +# --- 3. restore SQLite ------------------------------------------------------- +log "step 3/5: restoring SQLite" +# The app service drops all capabilities, so root alone cannot unlink a file +# owned by `node`. Re-add only DAC_OVERRIDE for this one-shot restore container +# so it can replace the SQLite file, then chown it back to the app user. +compose run -T --rm --no-deps --user 0:0 --cap-add DAC_OVERRIDE --cap-add CHOWN \ + -v "$backup_dir:/restore:ro" \ + --entrypoint sh "$APP_SERVICE" -c ' + set -eu + db="${DATABASE_PATH:-/data/nexbot.sqlite}" + rm -f "$db" "$db-wal" "$db-shm" + cp /restore/nexbot.sqlite "$db" + # chmod while root still owns the fresh copy: after chown it belongs to + # `node`, and root without CAP_FOWNER could not change its mode. + chmod 0644 "$db" + # The app runs as `node`; a root-owned file makes SQLite fail with + # "attempt to write a readonly database", so ownership is not optional. + chown node:node "$db" + ' || die 'SQLite restore failed' + +# --- 4. start the app -------------------------------------------------------- +log "step 4/5: starting $APP_SERVICE" +compose up -d "$APP_SERVICE" >/dev/null || die "could not start $APP_SERVICE" + +# --- 5. verify --------------------------------------------------------------- +log "step 5/5: waiting for readiness" +attempt=0 +until compose exec -T "$APP_SERVICE" node --input-type=module -e ' + const base = "http://127.0.0.1:4310"; + const ready = await fetch(base + "/api/ready").catch(() => null); + if (ready && ready.ok) process.exit(0); + const health = await fetch(base + "/api/health").catch(() => null); + process.exit(health && health.ok ? 0 : 1); +'; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 60 ]; then + die 'app did not become ready within 120s; inspect `docker compose logs app`' + fi + sleep 2 +done + +log "restore complete and the app reports ready." +log "If conversation data looks wrong, check pairing: SQLite thread_bindings.id" +log "must exist in PostgreSQL conversations.id and vice versa." diff --git a/deployment/slack-app-manifest.json b/deployment/slack-app-manifest.json new file mode 100644 index 00000000..c806a0b1 --- /dev/null +++ b/deployment/slack-app-manifest.json @@ -0,0 +1,34 @@ +{ + "display_information": { + "name": "NexBot", + "description": "Self-hosted NexBot agent over Slack.", + "background_color": "#1a1a1a" + }, + "features": { + "bot_user": { + "display_name": "NexBot", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "app_mentions:read", + "channels:history", + "channels:read", + "chat:write", + "groups:history", + "groups:read" + ] + } + }, + "settings": { + "event_subscriptions": { + "request_url": "https://REPLACE_WITH_PUBLIC_HOST/api/slack/events", + "bot_events": ["app_mention", "message.channels", "message.groups"] + }, + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "token_rotation_enabled": false + } +} diff --git a/deployment/windows/NexBot.cs b/deployment/windows/NexBot.cs new file mode 100644 index 00000000..b2fdb87e --- /dev/null +++ b/deployment/windows/NexBot.cs @@ -0,0 +1,120 @@ +// NexBot desktop launcher. +// +// Build (no SDK required, ships with .NET Framework): +// csc /target:winexe /out:NexBot.exe /r:System.Windows.Forms.dll NexBot.cs +// +// Behaviour: +// NexBot.exe -> ensure the stack is up, then open the app window +// NexBot.exe --install -> run install.ps1 (one-click installation) +// +// The window is Edge/Chrome in application mode, so it has no address bar and +// gets its own taskbar entry: the same shape as a desktop chat app. +using System; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Threading; +using System.Windows.Forms; + +internal static class Program +{ + private const string Url = "http://localhost:8080"; + + [STAThread] + private static void Main(string[] args) + { + string dir = AppDomain.CurrentDomain.BaseDirectory; + + if (args.Length > 0 && args[0] == "--install") + { + RunPowerShell(Path.Combine(dir, "install.ps1"), false); + return; + } + + if (!IsReady(2)) + { + Cursor.Current = Cursors.WaitCursor; + RunPowerShell(Path.Combine(dir, "start.ps1"), true); + } + + OpenAppWindow(); + } + + private static bool IsReady(int attempts) + { + for (int i = 0; i < attempts; i++) + { + try + { + using (var client = new WebClient()) + { + client.Headers.Add("User-Agent", "NexBot-Launcher"); + string body = client.DownloadString(Url + "/api/health"); + if (body != null && body.Contains("\"ok\":true")) return true; + } + } + catch + { + if (i + 1 < attempts) Thread.Sleep(1500); + } + } + return false; + } + + private static void RunPowerShell(string script, bool hidden) + { + if (!File.Exists(script)) + { + MessageBox.Show("Missing " + script + ".\r\nRun the installer first.", + "NexBot", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + var psi = new ProcessStartInfo("powershell.exe") + { + Arguments = "-NoProfile -ExecutionPolicy Bypass " + + (hidden ? "-WindowStyle Hidden " : "") + + "-File \"" + script + "\"", + UseShellExecute = false, + CreateNoWindow = hidden, + WorkingDirectory = Path.GetDirectoryName(script) + }; + using (var p = Process.Start(psi)) + { + if (p != null) p.WaitForExit(hidden ? 240000 : 600000); + } + } + + private static void OpenAppWindow() + { + foreach (string candidate in BrowserCandidates()) + { + if (!File.Exists(candidate)) continue; + try + { + Process.Start(new ProcessStartInfo(candidate) + { + Arguments = "--app=" + Url + " --window-size=1440,900", + UseShellExecute = false + }); + return; + } + catch { } + } + Process.Start(Url); // fall back to the default browser + } + + private static string[] BrowserCandidates() + { + string pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + string pf86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + string local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return new[] + { + Path.Combine(pf86, @"Microsoft\Edge\Application\msedge.exe"), + Path.Combine(pf, @"Microsoft\Edge\Application\msedge.exe"), + Path.Combine(pf, @"Google\Chrome\Application\chrome.exe"), + Path.Combine(pf86, @"Google\Chrome\Application\chrome.exe"), + Path.Combine(local, @"Google\Chrome\Application\chrome.exe") + }; + } +} diff --git a/deployment/windows/Setup.cs b/deployment/windows/Setup.cs new file mode 100644 index 00000000..7a4de797 --- /dev/null +++ b/deployment/windows/Setup.cs @@ -0,0 +1,114 @@ +// NexBot distributable installer stub. +// +// Build (see build-installer.cmd): compile this stub, then append a zip payload +// to the resulting exe. At run time the zip is read from the end of the file — +// the exe's own bytes at the front are ignored by the ZIP central directory. +// +// Installs into %LOCALAPPDATA%\NexBot, so no administrator rights are needed, +// then runs deployment\windows\install.ps1 from the extracted payload. +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Reflection; +using System.Windows.Forms; + +internal static class Setup +{ + private const string AppName = "NexBot"; + + [STAThread] + private static void Main() + { + string target = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppName); + + try + { + Cursor.Current = Cursors.WaitCursor; + + if (Directory.Exists(target)) + { + // Keep the operator's configuration across upgrades. + foreach (string keep in Directory.GetFiles(target, ".env.local")) + File.SetAttributes(keep, FileAttributes.Normal); + } + else + { + Directory.CreateDirectory(target); + } + + string self = Assembly.GetExecutingAssembly().Location; + byte[] payload = ReadPayload(self); + using (var ms = new MemoryStream(payload, false)) + using (var zip = new ZipArchive(ms, ZipArchiveMode.Read)) + { + foreach (ZipArchiveEntry entry in zip.Entries) + { + if (entry.FullName.EndsWith("/")) continue; + // Never overwrite an existing configuration on upgrade. + if (entry.FullName == ".env.local" && File.Exists(Path.Combine(target, ".env.local"))) + continue; + string dest = Path.Combine(target, entry.FullName); + Directory.CreateDirectory(Path.GetDirectoryName(dest)); + entry.ExtractToFile(dest, true); + } + } + + string install = Path.Combine(target, @"deployment\windows\install.ps1"); + if (!File.Exists(install)) + throw new FileNotFoundException("The installer payload is incomplete.", install); + + var psi = new ProcessStartInfo("powershell.exe") + { + Arguments = "-NoProfile -ExecutionPolicy Bypass -File \"" + install + "\"", + WorkingDirectory = target, + UseShellExecute = false + }; + using (Process p = Process.Start(psi)) + { + p.WaitForExit(); + if (p.ExitCode != 0) + MessageBox.Show("Installation finished with errors (exit " + p.ExitCode + ").\r\n" + + "Logs: " + target, AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + catch (Exception ex) + { + MessageBox.Show("Installation failed:\r\n\r\n" + ex.Message, + AppName, MessageBoxButtons.OK, MessageBoxIcon.Error); + Environment.Exit(1); + } + } + + /// + /// Read the appended zip. The layout is: [stub exe][zip][int64 zip length]. + /// The explicit length avoids relying on ZIP end-of-central-directory + /// offsets, which .NET does not adjust for a prefixed (self-extracting) file. + /// + private static byte[] ReadPayload(string self) + { + using (FileStream fs = File.OpenRead(self)) + { + if (fs.Length < 16) throw new InvalidDataException("Installer payload is missing."); + fs.Seek(-8, SeekOrigin.End); + var lenBytes = new byte[8]; + if (fs.Read(lenBytes, 0, 8) != 8) + throw new InvalidDataException("Installer payload is corrupt."); + long zipLen = BitConverter.ToInt64(lenBytes, 0); + long zipStart = fs.Length - 8 - zipLen; + if (zipLen <= 0 || zipStart <= 0) + throw new InvalidDataException("Installer payload is corrupt."); + fs.Seek(zipStart, SeekOrigin.Begin); + var buffer = new byte[zipLen]; + int read = 0; + while (read < zipLen) + { + int n = fs.Read(buffer, read, (int)(zipLen - read)); + if (n <= 0) throw new InvalidDataException("Installer payload is truncated."); + read += n; + } + return buffer; + } + } +} diff --git a/deployment/windows/build-installer.ps1 b/deployment/windows/build-installer.ps1 new file mode 100644 index 00000000..9619ec9f --- /dev/null +++ b/deployment/windows/build-installer.ps1 @@ -0,0 +1,91 @@ +# Build NexBot-Setup.exe: a self-extracting installer for end customers. +# +# powershell -ExecutionPolicy Bypass -File deployment\windows\build-installer.ps1 +# +# Output: dist\NexBot-Setup.exe +# +# The payload is copied from an explicit allowlist, so no .env.local, no +# credentials and no node_modules/dist artefacts can end up inside the installer. +$ErrorActionPreference = 'Stop' + +$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$dist = Join-Path $repo 'dist' +$stage = Join-Path $dist 'payload' +$zip = Join-Path $dist 'payload.zip' +$out = Join-Path $dist 'NexBot-Setup.exe' +$csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe' +if (-not (Test-Path $csc)) { $csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework\v4.0.30319\csc.exe' } +if (-not (Test-Path $csc)) { throw '.NET Framework 4.x (csc.exe) is required to build the installer.' } + +Write-Host 'Cleaning dist...' -ForegroundColor Cyan +if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } +if (Test-Path $zip) { Remove-Item $zip -Force } +if (Test-Path $out) { Remove-Item $out -Force } +New-Item -ItemType Directory -Path $stage -Force | Out-Null + +# Explicit allowlist: nothing else is shipped. +$files = @( + 'compose.yml', 'compose.local.yml', '.env.example', + 'Dockerfile', '.dockerignore', + 'package.json', 'package-lock.json', 'tsconfig.json', 'tsconfig.server.json', + 'vite.config.ts', 'index.html', 'eslint.config.js', 'knip.json', + 'README.md', 'LICENSE' +) +$dirs = @('src', 'public', 'deployment') + +Write-Host 'Staging payload...' -ForegroundColor Cyan +foreach ($f in $files) { + $src = Join-Path $repo $f + if (-not (Test-Path $src)) { Write-Warning "missing $f (skipped)"; continue } + Copy-Item $src (Join-Path $stage $f) -Force +} +foreach ($d in $dirs) { + $src = Join-Path $repo $d + if (-not (Test-Path $src)) { continue } + Copy-Item $src (Join-Path $stage $d) -Recurse -Force -Exclude @('node_modules', 'dist', '.env.local', 'NexBot-Setup.exe') +} + +# Ship the compiled desktop launcher if it has been built. +$launcher = Join-Path $repo 'deployment\windows\NexBot.exe' +if (-not (Test-Path $launcher)) { + Write-Host 'Building desktop launcher...' -ForegroundColor Cyan + & (Join-Path $PSScriptRoot 'build-launcher.cmd') | Out-Null +} +if (Test-Path $launcher) { Copy-Item $launcher (Join-Path $stage 'deployment\windows\NexBot.exe') -Force } + +# Refuse to ship anything secret-looking. +# Match credential SHAPES, not the pattern names, so this script does not flag +# itself: a real Slack token starts with digits after `xoxb-`, and real API keys +# continue with alphanumerics after their prefix. +$leaks = Get-ChildItem $stage -Recurse -File | + Select-String -Pattern 'xoxb-\d[A-Za-z0-9-]{20,}|oc_sk_[A-Za-z0-9]{16,}|thk_live_[A-Za-z0-9]{16,}|OWNER_TOKEN=[A-Za-z0-9_-]{24,}' -List +if ($leaks) { + throw "Refusing to build: payload contains what looks like a credential -> $($leaks.Path -join ', ')" +} + +Write-Host 'Compiling setup stub...' -ForegroundColor Cyan +$stub = Join-Path $dist 'Setup.exe' +& $csc /nologo /target:winexe /out:$stub /r:System.Windows.Forms.dll /r:System.IO.Compression.dll /r:System.IO.Compression.FileSystem.dll (Join-Path $PSScriptRoot 'Setup.cs') +if ($LASTEXITCODE -ne 0) { throw 'csc failed' } + +Write-Host 'Compressing payload...' -ForegroundColor Cyan +Add-Type -AssemblyName System.IO.Compression.FileSystem +[IO.Compression.ZipFile]::CreateFromDirectory($stage, $zip, [IO.Compression.CompressionLevel]::Optimal, $false) + +Write-Host 'Assembling self-extracting exe...' -ForegroundColor Cyan +$stubBytes = [IO.File]::ReadAllBytes($stub) +$zipBytes = [IO.File]::ReadAllBytes($zip) +$fs = [IO.File]::Create($out) +try { + $fs.Write($stubBytes, 0, $stubBytes.Length) + $fs.Write($zipBytes, 0, $zipBytes.Length) + # Trailing int64 = payload length, so the stub can locate the zip exactly. + $lenBytes = [BitConverter]::GetBytes([int64]$zipBytes.Length) + $fs.Write($lenBytes, 0, $lenBytes.Length) +} finally { $fs.Close() } +Remove-Item $stub -Force + +$mb = [Math]::Round((Get-Item $out).Length / 1MB, 1) +Write-Host '' +Write-Host "Built $out ($mb MB)" -ForegroundColor Green +Write-Host 'Ship that single file. It installs to %LOCALAPPDATA%\NexBot and needs only Docker.' -ForegroundColor DarkGray diff --git a/deployment/windows/build-launcher.cmd b/deployment/windows/build-launcher.cmd new file mode 100644 index 00000000..76a800c0 --- /dev/null +++ b/deployment/windows/build-launcher.cmd @@ -0,0 +1,20 @@ +@echo off +REM Rebuild NexBot.exe from NexBot.cs. No SDK needed: this uses the C# +REM compiler that ships with .NET Framework, present on stock Windows. +setlocal +set CSC=%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe +if not exist "%CSC%" set CSC=%WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe +if not exist "%CSC%" ( + echo ERROR: csc.exe not found. .NET Framework 4.x is required. + exit /b 1 +) +set ICON=%~dp0nexbot.ico +if not exist "%ICON%" ( + echo WARNING: nexbot.ico not found; building without an icon. + set ICONARG= +) else ( + set ICONARG=/win32icon:"%ICON%" +) +"%CSC%" /nologo /target:winexe /out:"%~dp0NexBot.exe" %ICONARG% /r:System.Windows.Forms.dll "%~dp0NexBot.cs" +if errorlevel 1 exit /b 1 +echo Built %~dp0NexBot.exe diff --git a/deployment/windows/install-autostart.ps1 b/deployment/windows/install-autostart.ps1 new file mode 100644 index 00000000..2baae358 --- /dev/null +++ b/deployment/windows/install-autostart.ps1 @@ -0,0 +1,37 @@ +# Start NexBot automatically when this user logs in. +# +# Uses the per-user Startup folder, which needs no administrator rights. +# (A scheduled task would also work but Register-ScheduledTask requires +# elevation; the Startup folder does not.) +# +# Install: +# powershell -ExecutionPolicy Bypass -File deployment\windows\install-autostart.ps1 +# +# Remove: +# Remove-Item "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup\NexBot.vbs" +$ErrorActionPreference = 'Stop' + +$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$start = Join-Path $repo 'deployment\windows\start.ps1' +if (-not (Test-Path $start)) { throw "start.ps1 not found at $start" } +$watchdog = Join-Path $repo 'deployment\windows\watchdog.ps1' + +$startup = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Startup' +$target = Join-Path $startup 'NexBot.vbs' + +# A .vbs launcher runs PowerShell without a flashing console window. +$vbs = @" +' Starts the NexBot local test stack at logon. Generated by install-autostart.ps1. +Set sh = CreateObject("WScript.Shell") +sh.Run "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ""$start""", 0, False + +' Health watchdog: restarts the stack if it stops answering /api/ready. +sh.Run "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ""$watchdog""", 0, False +"@ + +Set-Content -Path $target -Value $vbs -Encoding ASCII +Write-Host "Installed autostart: $target" -ForegroundColor Green +Write-Host "It runs at logon. To start it now without logging out:" +Write-Host " powershell -ExecutionPolicy Bypass -File `"$start`"" +Write-Host '' +Write-Host "To remove it: Remove-Item `"$target`"" -ForegroundColor DarkGray diff --git a/deployment/windows/install.ps1 b/deployment/windows/install.ps1 new file mode 100644 index 00000000..2b84742f --- /dev/null +++ b/deployment/windows/install.ps1 @@ -0,0 +1,139 @@ +# NexBot one-click installer (no administrator rights required). +# +# It: checks Docker, creates .env.local with generated secrets if absent, +# builds and starts the stack, waits for readiness, installs autostart, and +# creates Desktop + Start Menu shortcuts that open the app in its own window. +$ErrorActionPreference = 'Stop' + +$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +Set-Location $repo +$project = 'nexbot' +$compose = @('-p', $project, '--env-file', '.env.local', '-f', 'compose.yml', '-f', 'compose.local.yml') + +function Info($m) { Write-Host "[nexbot] $m" -ForegroundColor Cyan } +function Fail($m) { Write-Host "[nexbot] ERROR: $m" -ForegroundColor Red; exit 1 } + +Info 'checking Docker' +$null = docker info 2>$null +if ($LASTEXITCODE -ne 0) { + $dd = Join-Path $env:ProgramFiles 'Docker\Docker\Docker Desktop.exe' + if (Test-Path $dd) { + Info 'starting Docker Desktop' + Start-Process $dd | Out-Null + for ($i = 0; $i -lt 60; $i++) { + Start-Sleep -Seconds 3 + $null = docker info 2>$null + if ($LASTEXITCODE -eq 0) { break } + } + } + $null = docker info 2>$null + if ($LASTEXITCODE -ne 0) { Fail 'Docker is not running. Install/start Docker Desktop and run this again.' } +} + +if (-not (Test-Path '.env.local')) { + # A lost .env.local plus an existing database volume is unrecoverable: the new + # generated password would not match the one baked into the volume. Fail with + # an explanation instead of starting a stack that cannot authenticate. + $volumes = docker volume ls --format '{{.Name}}' | Select-String 'nexbot-pgdata' + if ($volumes) { + Fail ('A database volume already exists but .env.local is missing. ' + + 'The database password cannot be recovered. Restore .env.local from a backup, ' + + 'or remove the volume (docker volume rm ' + ($volumes | Select-Object -First 1).ToString().Trim() + ') to start over, which deletes all conversations.') + } + Info 'creating .env.local with generated secrets' + if (-not (Test-Path '.env.example')) { Fail '.env.example not found; is this the NexBot repository?' } + + function New-Secret([int]$bytes) { + $b = New-Object byte[] $bytes + [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b) + [Convert]::ToBase64String($b).TrimEnd('=').Replace('+', 'A').Replace('/', 'B') + } + $dbPass = New-Secret 24 + $owner = New-Secret 24 + $modelKey = Read-Host 'Model provider API key (leave empty to fill in later)' + $model = Read-Host 'Model name (leave empty for deepseek-v4.1-flash)' + if ([string]::IsNullOrWhiteSpace($model)) { $model = 'deepseek-v4.1-flash' } + $base = Read-Host 'Model base URL (leave empty for OpenCode Go)' + if ([string]::IsNullOrWhiteSpace($base)) { $base = 'https://opencode.ai/zen/go/v1' } + + $env = @" +# Generated by install.ps1. Never commit this file. +DATABASE_URL=postgres://nexbot:$dbPass@postgres:5432/nexbot +POSTGRES_USER=nexbot +POSTGRES_PASSWORD=$dbPass +POSTGRES_DB=nexbot +OWNER_ID=nexbot-owner +OWNER_TOKEN=$owner +APP_ORIGIN=http://localhost:4310,http://127.0.0.1:4310,http://localhost:8080,http://127.0.0.1:8080 +OPENAI_BASE_URL=$base +OPENAI_API_KEY=$modelKey +OPENAI_MODEL=$model +OPENAI_EXTRA_HEADERS={'"x-opencode-session":"nexbot-local"'} +WEB_SEARCH_PROVIDER=disabled +"@ + Set-Content -Path '.env.local' -Value $env -Encoding UTF8 + Write-Host '' + Write-Host "Your access token (needed to unlock the app):" -ForegroundColor Yellow + Write-Host " $owner" -ForegroundColor Yellow + Write-Host '' +} + +$null = docker image inspect nexbot-app 2>$null +if ($LASTEXITCODE -eq 0) { + Info 'image already built; skipping build (delete the image to force a rebuild)' +} else { + Info 'building (first run takes a few minutes)' + # A variable already exported in the environment wins over --env-file, so a stray +# OPENAI_* in the shell silently overrides .env.local. Clear them first. +foreach ($v in @('OPENAI_API_KEY','OPENAI_BASE_URL','OPENAI_API_BASE','OPENAI_MODEL','OPENAI_EXTRA_HEADERS')) { + Remove-Item "Env:$v" -ErrorAction SilentlyContinue +} + +docker compose @compose build + if ($LASTEXITCODE -ne 0) { Fail 'image build failed' } +} + +Info 'starting services' +docker compose @compose up -d +if ($LASTEXITCODE -ne 0) { Fail 'docker compose up failed' } + +Info 'waiting for readiness' +$ready = $false +for ($i = 0; $i -lt 90; $i++) { + try { + $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 'http://127.0.0.1:8080/api/ready' -ErrorAction Stop + if ($r.StatusCode -eq 200) { $ready = $true; break } + } catch { Start-Sleep -Seconds 2 } +} +if (-not $ready) { Fail 'app did not become ready. Check: docker compose -p nexbot logs app' } + +Info 'installing autostart and shortcuts' +& (Join-Path $PSScriptRoot 'install-autostart.ps1') | Out-Null + +$launcher = Join-Path $PSScriptRoot 'NexBot.exe' +$desktop = [Environment]::GetFolderPath('Desktop') +$startMenu = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs' +if (Test-Path $launcher) { + foreach ($dir in @($desktop, $startMenu)) { + $lnk = Join-Path $dir 'NexBot.lnk' + $sh = New-Object -ComObject WScript.Shell + $s = $sh.CreateShortcut($lnk) + $s.TargetPath = $launcher + $s.WorkingDirectory = $PSScriptRoot + $s.IconLocation = "$launcher,0" + $s.Description = 'NexBot' + $s.Save() + } + Info 'shortcuts created on Desktop and Start Menu' +} + +Info 'opening NexBot' +if (Test-Path $launcher) { Start-Process $launcher } else { Start-Process 'http://localhost:8080' } + +Write-Host '' +Write-Host 'NexBot installed.' -ForegroundColor Green +Write-Host ' App: NexBot (Desktop shortcut)' +Write-Host ' URL: http://localhost:8080' +Write-Host ' Stop: powershell -File deployment\windows\stop.ps1' +Write-Host ' Status: powershell -File deployment\windows\status.ps1' +Write-Host ' Config: .env.local' diff --git a/deployment/windows/nexbot.ico b/deployment/windows/nexbot.ico new file mode 100644 index 00000000..6a9cfe0c Binary files /dev/null and b/deployment/windows/nexbot.ico differ diff --git a/deployment/windows/start.ps1 b/deployment/windows/start.ps1 new file mode 100644 index 00000000..5291d8c1 --- /dev/null +++ b/deployment/windows/start.ps1 @@ -0,0 +1,53 @@ +# Start the local NexBot test stack (app + Postgres + proxy). +# Idempotent: running it again converges to the same state. +$ErrorActionPreference = 'Stop' + +$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +Set-Location $repo + +if (-not (Test-Path '.env.local')) { + throw "Missing $repo\.env.local. Copy .env.example and fill it in first." +} + +# At logon Docker Desktop is often not up yet. Wait for it, and start it if it +# is installed but not running, so autostart is reliable rather than racy. +$null = docker info 2>$null +if ($LASTEXITCODE -ne 0) { + $dd = Join-Path $env:ProgramFiles 'Docker\Docker\Docker Desktop.exe' + if (Test-Path $dd) { + Write-Host 'Docker is not running; starting Docker Desktop...' -ForegroundColor Yellow + Start-Process $dd | Out-Null + } + $up = $false + for ($i = 0; $i -lt 60; $i++) { + Start-Sleep -Seconds 3 + $null = docker info 2>$null + if ($LASTEXITCODE -eq 0) { $up = $true; break } + } + if (-not $up) { throw 'Docker did not become ready. Start Docker Desktop and retry.' } +} + +Write-Host 'Starting NexBot...' -ForegroundColor Cyan +# A variable already exported in the environment wins over --env-file, so a stray +# OPENAI_* in the shell silently overrides .env.local. Clear them first. +foreach ($v in @('OPENAI_API_KEY','OPENAI_BASE_URL','OPENAI_API_BASE','OPENAI_MODEL','OPENAI_EXTRA_HEADERS')) { + Remove-Item "Env:$v" -ErrorAction SilentlyContinue +} + +docker compose -p nexbot --env-file .env.local -f compose.yml -f compose.local.yml up -d +if ($LASTEXITCODE -ne 0) { throw "docker compose up failed ($LASTEXITCODE)" } + +Write-Host 'Waiting for readiness...' -ForegroundColor Cyan +$ok = $false +for ($i = 0; $i -lt 60; $i++) { + try { + $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 'http://127.0.0.1:8080/api/ready' -ErrorAction Stop + if ($r.StatusCode -eq 200) { $ok = $true; break } + } catch { Start-Sleep -Seconds 2 } +} +if (-not $ok) { throw 'App did not become ready within 120s. Check: docker compose -p nexbot logs app' } + +docker compose -p nexbot ps +Write-Host '' +Write-Host 'NexBot is ready: http://localhost:8080' -ForegroundColor Green +Write-Host 'Direct app port: http://127.0.0.1:4310' -ForegroundColor DarkGray diff --git a/deployment/windows/status.ps1 b/deployment/windows/status.ps1 new file mode 100644 index 00000000..3bc48ce8 --- /dev/null +++ b/deployment/windows/status.ps1 @@ -0,0 +1,24 @@ +# Report the state of the local NexBot test stack. +$ErrorActionPreference = 'Continue' + +$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +Set-Location $repo + +docker compose -p nexbot --env-file .env.local -f compose.yml -f compose.local.yml ps +Write-Host '' + +function Probe($name, $url) { + try { + $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 5 $url -ErrorAction Stop + Write-Host ("{0,-10} HTTP {1}" -f $name, $r.StatusCode) -ForegroundColor Green + } catch { + $code = 'unreachable' + if ($_.Exception.Response) { $code = [int]$_.Exception.Response.StatusCode } + Write-Host ("{0,-10} {1}" -f $name, $code) -ForegroundColor Yellow + } +} + +Probe 'health' 'http://127.0.0.1:8080/api/health' +Probe 'ready' 'http://127.0.0.1:8080/api/ready' +Write-Host '' +Write-Host 'Open: http://localhost:8080' -ForegroundColor Cyan diff --git a/deployment/windows/stop.ps1 b/deployment/windows/stop.ps1 new file mode 100644 index 00000000..8eb9552a --- /dev/null +++ b/deployment/windows/stop.ps1 @@ -0,0 +1,8 @@ +# Stop the local NexBot test stack. Volumes (conversations and SQLite) are kept. +$ErrorActionPreference = 'Stop' + +$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +Set-Location $repo + +docker compose -p nexbot --env-file .env.local -f compose.yml -f compose.local.yml stop +Write-Host 'Stopped. Data is preserved in the nexbot volumes.' -ForegroundColor Yellow diff --git a/deployment/windows/watchdog.ps1 b/deployment/windows/watchdog.ps1 new file mode 100644 index 00000000..bd633634 --- /dev/null +++ b/deployment/windows/watchdog.ps1 @@ -0,0 +1,77 @@ +# Keeps the local NexBot stack healthy without administrator rights. +# +# Polls /api/ready and, after a run of consecutive failures, restarts the stack +# once. A single restart is attempted per outage so a genuinely broken +# configuration does not turn into a restart loop; the failure count resets as +# soon as the app answers again. +# +# Usage: +# powershell -ExecutionPolicy Bypass -File deployment\windows\watchdog.ps1 +# powershell ... -File watchdog.ps1 -IntervalSeconds 30 -FailuresBeforeRestart 3 +# +# Install it to run at logon together with the app (see install-autostart.ps1). +param( + [int]$IntervalSeconds = 60, + [int]$FailuresBeforeRestart = 3, + [string]$Url = 'http://127.0.0.1:8080/api/ready' +) + +$ErrorActionPreference = 'Continue' +$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$logDir = Join-Path $repo 'logs' +if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } +$log = Join-Path $logDir 'watchdog.log' + +function Write-Log($message) { + $line = "{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $message + Add-Content -Path $log -Value $line + Write-Host $line +} + +function Test-Ready { + try { + $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 10 $Url -ErrorAction Stop + return $r.StatusCode -eq 200 + } catch { + return $false + } +} + +Write-Log "watchdog started (interval ${IntervalSeconds}s, threshold $FailuresBeforeRestart)" + +$failures = 0 +$restartedForThisOutage = $false + +while ($true) { + Start-Sleep -Seconds $IntervalSeconds + + if (Test-Ready) { + if ($failures -gt 0) { Write-Log "recovered after $failures failed check(s)" } + $failures = 0 + $restartedForThisOutage = $false + continue + } + + $failures++ + Write-Log "readiness check failed ($failures/$FailuresBeforeRestart)" + + if ($failures -ge $FailuresBeforeRestart -and -not $restartedForThisOutage) { + Write-Log 'restarting the stack' + Push-Location $repo + try { + # start.ps1 writes progress to stderr, which PowerShell would treat + # as a terminating error; capture it as log text instead. + $output = & (Join-Path $PSScriptRoot 'start.ps1') 2>&1 | Out-String + Add-Content -Path $log -Value $output + Write-Log "restart finished (exit $LASTEXITCODE)" + } catch { + Write-Log "restart threw: $($_.Exception.Message)" + } finally { + Pop-Location + } + # One restart per outage: wait for the configured number of checks again + # before trying once more, so a bad config cannot spin. + $restartedForThisOutage = $true + $failures = 0 + } +} diff --git a/docs/COMPUTERS.md b/docs/COMPUTERS.md index be67aca8..d58b32c0 100644 --- a/docs/COMPUTERS.md +++ b/docs/COMPUTERS.md @@ -1,6 +1,6 @@ # A computer for each Dot -OpenDots connects each specialist to its own container through [OpenBot](https://github.com/CopilotKit/OpenBot)'s computer service and supervisor. A Dot's ID determines its computer and persistent volumes. Files and browser profiles survive stop/start; they are separate from Spaces pages and CopilotKit conversation history. +NexBot connects each specialist to its own container through [OpenBot](https://github.com/CopilotKit/OpenBot)'s computer service and supervisor. A Dot's ID determines its computer and persistent volumes. Files and browser profiles survive stop/start; they are separate from Spaces pages and CopilotKit conversation history. The app exposes selected computer tools to the same Dot agent used by web chat, Slack, scheduled work, and voice's compute delegation. Browser, workspace-file, and shell permissions are saved per Dot and checked by the server. They start disabled. No action falls back to your host's shell or files when the computer service is unavailable. @@ -12,7 +12,7 @@ Use a working Docker Engine with Compose v2 and BuildKit support for additional COMPUTER_SUPERVISOR_URL=http://127.0.0.1:4312 COMPUTER_SUPERVISOR_TOKEN=REPLACE_WITH_A_RANDOM_SECRET COMPUTER_TOKEN=REPLACE_WITH_A_DIFFERENT_RANDOM_SECRET -COMPUTER_NAMESPACE=opendots +COMPUTER_NAMESPACE=nexbot ``` Do not use the placeholder values. The supervisor token authorizes lifecycle requests. The computer token is a master used to derive a different credential for each Dot; the master stays in the application and supervisor. @@ -45,7 +45,7 @@ Here, the app addresses computers by their container names. Computers have no pu - **Browser:** navigate and inspect the current page, including screenshots and element snapshots. Browser profiles keep cookies and logins across container restarts. - **Take control:** pause agent input while you click, type, scroll, or press keys in the browser. Release control when done. The agent must obtain a fresh snapshot before resuming element actions. - **Files:** list, read, and write text files in the Dot's workspace. Paths must stay relative to that workspace. These files are not automatically added to Spaces pages. -- **Terminal:** run a bounded command inside that Dot's container when shell access is enabled. Command output is displayed; execution does not run on the OpenDots host. +- **Terminal:** run a bounded command inside that Dot's container when shell access is enabled. Command output is displayed; execution does not run on the NexBot host. - **Activity:** inspect action names, who requested them, and success/failure. The audit record deliberately excludes typed values, file contents, and full commands. Stop retains files and browser profiles. The app does not expose a destructive reset action. Stopping the supervisor does not stop its dynamically created computers; stop each Dot's computer first if you want them all offline. Compose does not own those dynamically created containers or volumes. Do not delete named workspace/profile volumes as routine cleanup. diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md index 83c46e34..05502cde 100644 --- a/docs/CONNECTIONS.md +++ b/docs/CONNECTIONS.md @@ -6,7 +6,7 @@ Connections give a Dot tools from remote [MCP](https://modelcontextprotocol.io) 1. Open a Dot's settings (**Edit specialist**). 2. Under **Connections**, enter a name, the server's Streamable HTTP endpoint (for example `https://example.com/mcp`), and an optional bearer token. -3. Select **Connect**. OpenDots lists the server's tools and saves them. +3. Select **Connect**. NexBot lists the server's tools and saves them. Use **Refresh** after the server adds or changes tools. Your choices for existing tools are kept. diff --git a/docs/SETUP-TELEMETRY.md b/docs/SETUP-TELEMETRY.md index bde0a266..aa3ccbd3 100644 --- a/docs/SETUP-TELEMETRY.md +++ b/docs/SETUP-TELEMETRY.md @@ -1,6 +1,6 @@ # Browser setup telemetry -OpenDots tracks its existing browser setup flow through the server's CopilotKit ingest contract. This is the web distribution; OpenDots has no native installer, engine installation, Windows prerequisite, image-pull wizard, or model/harness picker. Those desktop events are not applicable. +NexBot tracks its existing browser setup flow through the server's CopilotKit ingest contract. This is the web distribution; NexBot has no native installer, engine installation, Windows prerequisite, image-pull wizard, or model/harness picker. Those desktop events are not applicable. Telemetry is enabled by default. Either `DO_NOT_TRACK=true|1` or `COPILOTKIT_TELEMETRY_DISABLED=true|1` disables the setup emitter and runtime. Restart after changing the server environment. Opt-out deletes the setup tracking row, including its pending queue and fallback installation identity. It does not change the owner, conversations, or application settings. Sampling rate zero also suppresses setup events. @@ -15,7 +15,7 @@ Telemetry is enabled by default. Either `DO_NOT_TRACK=true|1` or `COPILOTKIT_TEL The authenticated `/api/setup-telemetry` route accepts only strict step-view and abandonment objects. It rejects unknown keys, invalid steps, arbitrary properties, client-forged activation/failure, and readiness inconsistent with server state. Existing owner-token, origin, cross-site, content-type, and body-size guards apply. Settings visibility does not expose environment configuration values. -Metadata names OpenDots, `opendots_distribution=web`, numeric release `opendots_version=0.1.0`, `runtime_env=node`, server platform (`macos|windows|linux|other`) and architecture (`aarch64|x86_64|other`), and a random persistent `installation_id`. Server platform identifies the server, including its container; it does not infer the browser OS. There are no prompts, answers, raw errors, keys, URLs, file paths, model names, emails, owner IDs, browser user agents, or machine-derived identifiers in setup events. +Metadata names NexBot, `nexbot_distribution=web`, numeric release `nexbot_version=0.1.0`, `runtime_env=node`, server platform (`macos|windows|linux|other`) and architecture (`aarch64|x86_64|other`), and a random persistent `installation_id`. Server platform identifies the server, including its container; it does not infer the browser OS. There are no prompts, answers, raw errors, keys, URLs, file paths, model names, emails, owner IDs, browser user agents, or machine-derived identifiers in setup events. The current nonblank `CPK_TELEMETRY_ID` takes precedence as the ingest header and runtime `telemetryId`, preserving the CLI project/account identity. If absent, both use the persistent random installation UUID. The installation UUID remains separate metadata even when the CLI identity is present. Removing the CLI variable returns to the installation fallback. The runtime receives matching metadata through `telemetryProperties`; no PostHog SDK, public capture key, or separate signup event is added. diff --git a/docs/SETUP.md b/docs/SETUP.md index 80b0c40d..879b1ff2 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -1,6 +1,25 @@ # Running the template -OpenDots runs a React app and a Node server. The server stores pages, Space and Dot configuration, and thread bindings in SQLite and connects to your configured conversation, model, and messaging services. +> **This is the upstream guide and it is out of date for this fork.** Every +> mention of CopilotKit Intelligence, `CPK_INTELLIGENCE_API_KEY`, +> `@copilotkit/channels`, hosted Slack channels, hosted Learning containers and +> SDK telemetry below describes the original hosted design, which this fork +> removed. In this fork: +> +> - conversations live in **PostgreSQL** (`DATABASE_URL`), not a hosted project; +> - the model provider is configured **in the app** under Settings, or via +> `OPENAI_BASE_URL` / `OPENAI_API_KEY` / `OPENAI_MODEL` plus +> `OPENAI_EXTRA_HEADERS` when the gateway needs routing headers; +> - Slack is a **self-hosted** Events API webhook (`SLACK_BOT_TOKEN`, +> `SLACK_SIGNING_SECRET`), not a managed channel; +> - Automatic Learning stores per-Dot skills in PostgreSQL; +> - no CopilotKit account, key or telemetry endpoint is used. +> +> Start from [fork/LOCAL-INSTALL.md](./fork/LOCAL-INSTALL.md) for a local install, +> [fork/DEPLOYMENT.md](./fork/DEPLOYMENT.md) for a server, and +> [fork/PRODUCTION-READINESS.md](./fork/PRODUCTION-READINESS.md) for the gate list. + +NexBot runs a React app and a Node server. The server stores pages, Space and Dot configuration, and thread bindings in SQLite and connects to your configured conversation, model, and messaging services. ## Local development @@ -27,11 +46,11 @@ Open http://127.0.0.1:4310. Keep the server running for background work. ## Conversation services -OpenDots requires CopilotKit Intelligence for conversations. Choose local evaluation below, a licensed [self-hosted deployment](https://docs.copilotkit.ai/intelligence/self-hosting), or hosted Intelligence. Pages and workspace metadata remain in SQLite; conversation history is stored separately. See [Data and privacy](../README.md#data-and-privacy). +NexBot requires CopilotKit Intelligence for conversations. Choose local evaluation below, a licensed [self-hosted deployment](https://docs.copilotkit.ai/intelligence/self-hosting), or hosted Intelligence. Pages and workspace metadata remain in SQLite; conversation history is stored separately. See [Data and privacy](../README.md#data-and-privacy). ### Hosted Intelligence -To get a hosted project key, run these commands in the OpenDots folder after `cp .env.example .env`: +To get a hosted project key, run these commands in the NexBot folder after `cp .env.example .env`: ```sh npx copilotkit@latest login @@ -40,13 +59,13 @@ npx copilotkit@latest project select `login` opens the browser to sign in or create an account. `project select` lets you select or create a project, and writes a project-scoped key to `.env` as `CPK_INTELLIGENCE_API_KEY`. Keep the generated `CPK_TELEMETRY_ID` together with the project key when deploying, including Docker. It connects SDK telemetry to the selected Intelligence account; see [signup and usage tracking](TELEMETRY.md). It removes an `INTELLIGENCE_API_KEY` line, so that the file holds one credential. Use the same project for Slack and Automatic Learning below. -Do not run `copilotkit onboard` in this folder. Onboarding is for apps that do not have CopilotKit yet. OpenDots already has its integration. +Do not run `copilotkit onboard` in this folder. Onboarding is for apps that do not have CopilotKit yet. NexBot already has its integration. ### Local Intelligence evaluation -The [local evaluation preview](https://docs.copilotkit.ai/intelligence/self-hosting-local) runs Intelligence in Docker Desktop on macOS. Allocate at least 4 CPUs, 12 GiB RAM, and 40 GiB free storage. OpenDots needs Node.js 24. A CopilotKit account is required; the free Developer plan qualifies for a renewable 30-day evaluation license. This is not a production installation. +The [local evaluation preview](https://docs.copilotkit.ai/intelligence/self-hosting-local) runs Intelligence in Docker Desktop on macOS. Allocate at least 4 CPUs, 12 GiB RAM, and 40 GiB free storage. NexBot needs Node.js 24. A CopilotKit account is required; the free Developer plan qualifies for a renewable 30-day evaluation license. This is not a production installation. -Run from the OpenDots folder after `cp .env.example .env`, with Docker Desktop running: +Run from the NexBot folder after `cp .env.example .env`, with Docker Desktop running: ```sh npx copilotkit@latest login @@ -55,13 +74,13 @@ npx copilotkit@latest local connect npx copilotkit@latest local connect --approve-connection ``` -`local connect` previews the changes; the approved command writes the local API URL, gateway WebSocket URL, and project key to `.env`. OpenDots accepts these directly. Do not run `project select` afterward: it selects a hosted project. Add `OPENAI_API_KEY` and `OPENAI_MODEL` for the app, then restart `npm run dev`. Setup's Learning model is separate from the app's model. +`local connect` previews the changes; the approved command writes the local API URL, gateway WebSocket URL, and project key to `.env`. NexBot accepts these directly. Do not run `project select` afterward: it selects a hosted project. Add `OPENAI_API_KEY` and `OPENAI_MODEL` for the app, then restart `npm run dev`. Setup's Learning model is separate from the app's model. Send a message and verify it appears in the local dashboard (`npx copilotkit@latest local login`). Use `local status` to inspect service health. Threads, Automatic Learning, and Channels are included; User Memory and Product Analytics are not. Channels require a public HTTPS address reachable by Slack or Teams. To restore the previous app connection, run `npx copilotkit@latest local cancel` and restart the app. This keeps the stack and its data. Use `local stop` to stop the stack, or `local renew` to renew its evaluation license. Existing hosted conversations are not copied into the local project. -For this preview, run OpenDots directly on your Mac with `npm run dev`; Intelligence runs in Docker. The CLI's `localhost` URLs do not work from an OpenDots container. A containerized app needs an API address reachable from its container and a gateway address reachable from both the server and the browser, because the runtime advertises that gateway to browser clients. Changing the gateway to a Docker-only hostname is not sufficient. +For this preview, run NexBot directly on your Mac with `npm run dev`; Intelligence runs in Docker. The CLI's `localhost` URLs do not work from an NexBot container. A containerized app needs an API address reachable from its container and a gateway address reachable from both the server and the browser, because the runtime advertises that gateway to browser clients. Changing the gateway to a Docker-only hostname is not sufficient. ### Connection settings @@ -81,7 +100,7 @@ Edit `.env` on the server and restart after changes: `npx copilotkit project select` deletes an `INTELLIGENCE_API_KEY` line and writes the same credential as `CPK_INTELLIGENCE_API_KEY`. The server reads either name. A non-empty `CPK_INTELLIGENCE_API_KEY` takes precedence over `INTELLIGENCE_API_KEY`. -A non-empty `INTELLIGENCE_GATEWAY_WS_URL` takes precedence over `INTELLIGENCE_WS_URL`. Without either WebSocket override, OpenDots uses the hosted gateway. Set both the API and gateway endpoints when connecting a self-hosted deployment. +A non-empty `INTELLIGENCE_GATEWAY_WS_URL` takes precedence over `INTELLIGENCE_WS_URL`. Without either WebSocket override, NexBot uses the hosted gateway. Set both the API and gateway endpoints when connecting a self-hosted deployment. The model environment variable names follow the configured provider adapter. Provider credentials belong in `.env`, not client-side variables or source code. Conversation history lives in the configured Intelligence project; copying the SQLite file alone does not back up that history. @@ -112,11 +131,11 @@ Use the same secret on the app and browser processes. Browser navigation is read ## Composio -Set `COMPOSIO_API_KEY` to let Dots use the owner’s connected apps. Restart OpenDots, enable Connected apps in the global settings and for a Dot, then optionally restrict that Dot to comma-separated toolkit slugs such as `gmail` or `google_calendar`. Leaving the toolkit list blank lets the Dot search every toolkit available to the Composio project. +Set `COMPOSIO_API_KEY` to let Dots use the owner’s connected apps. Restart NexBot, enable Connected apps in the global settings and for a Dot, then optionally restrict that Dot to comma-separated toolkit slugs such as `gmail` or `google_calendar`. Leaving the toolkit list blank lets the Dot search every toolkit available to the Composio project. Connected apps remain disabled when you add the API key. Composio tools can read and change data in real accounts, and a blank toolkit list permits every toolkit available to the Composio project. The global and per-Dot switches require the owner to grant that access explicitly. -OpenDots uses the stable `OWNER_ID` as the Composio user identity. Do not change it after connecting accounts. When a tool needs authentication, open the HTTPS Connect link shown in chat and finish the provider’s sign-in flow. Disabling either permission prevents new app calls and stops active work. Without `COMPOSIO_API_KEY`, no Composio client or tools are created and the controls stay hidden. +NexBot uses the stable `OWNER_ID` as the Composio user identity. Do not change it after connecting accounts. When a tool needs authentication, open the HTTPS Connect link shown in chat and finish the provider’s sign-in flow. Disabling either permission prevents new app calls and stops active work. Without `COMPOSIO_API_KEY`, no Composio client or tools are created and the controls stay hidden. To test the integration, configure a working model, limit a Dot to one toolkit, and start with a read-only request such as listing recent messages or calendar events. Complete the Connect flow when prompted, then repeat the request. Test write actions only with an account where an accidental change is safe. @@ -128,7 +147,7 @@ For a separate browser, persistent files, and optional shell for each specialist ## Slack -OpenDots uses `@copilotkit/channels` with a managed Slack connection, following the runtime and channel pattern in [OpenTag](https://github.com/CopilotKit/OpenTag). The application declares the channel and its specialist agent; Intelligence manages the Slack adapter and delivery. You do not need a separate Slack webhook server or Socket Mode connection in OpenDots. +NexBot uses `@copilotkit/channels` with a managed Slack connection, following the runtime and channel pattern in [OpenTag](https://github.com/CopilotKit/OpenTag). The application declares the channel and its specialist agent; Intelligence manages the Slack adapter and delivery. You do not need a separate Slack webhook server or Socket Mode connection in NexBot. The CLI project-selection commands below are for hosted Intelligence. For local evaluation, configure Channels in the local dashboard and follow the [local Channels requirements](https://docs.copilotkit.ai/intelligence/self-hosting-local#connect-slack-or-microsoft-teams), including a public HTTPS address. Do not replace the local project key with `project select`. @@ -138,7 +157,7 @@ Select the same Intelligence project used by this application, then create a uni ```sh npx --yes copilotkit@latest project select -npx --yes copilotkit@latest channels add --name opendots --display-name "OpenDots" --adapter slack --json +npx --yes copilotkit@latest channels add --name nexbot --display-name "NexBot" --adapter slack --json ``` Follow the CLI's returned `nextAction` to create the Slack app from its generated manifest, supply credentials through the managed setup flow, and run its `resumeCommand`. A `blocked` response means a Slack-console step is still needed; a `failed` response must be resolved before continuing. Keep Socket Mode off. Install or reinstall the generated app in your workspace with its requested scopes. Use a distinct channel name for separate deployments so they do not compete for deliveries. @@ -150,21 +169,21 @@ See [OpenTag's setup guide](https://github.com/CopilotKit/OpenTag/blob/main/setu Configure the application server's `.env` alongside its Intelligence and model credentials: ```dotenv -SLACK_CHANNEL_NAME=opendots +SLACK_CHANNEL_NAME=nexbot SLACK_TEAM_ID=T_REPLACE_WITH_WORKSPACE_ID SLACK_USER_IDS=U_REPLACE_WITH_YOUR_USER_ID SLACK_DOT_ID=REPLACE_WITH_DOT_ID ``` -`SLACK_CHANNEL_NAME` must exactly match the managed channel name, not a Slack conversation name such as `#general`. OpenTag calls this setting `INTELLIGENCE_CHANNEL_NAME`; OpenDots uses `SLACK_CHANNEL_NAME`. `SLACK_TEAM_ID` and the comma-separated `SLACK_USER_IDS` restrict who may invoke the specialist. `SLACK_DOT_ID` selects an existing Dot; find IDs in the authenticated `/api/workspace` response. If omitted, it defaults to the initial Dot. +`SLACK_CHANNEL_NAME` must exactly match the managed channel name, not a Slack conversation name such as `#general`. OpenTag calls this setting `INTELLIGENCE_CHANNEL_NAME`; NexBot uses `SLACK_CHANNEL_NAME`. `SLACK_TEAM_ID` and the comma-separated `SLACK_USER_IDS` restrict who may invoke the specialist. `SLACK_DOT_ID` selects an existing Dot; find IDs in the authenticated `/api/workspace` response. If omitted, it defaults to the initial Dot. -Restart OpenDots after changing environment settings and inspect Slack status in Settings & setup. Channel activation must complete before trying a message. Mention the installed bot in a channel it can access; subsequent messages in that followed thread go to the same specialist. Unrelated threads, bot events, edits, deletions, and users outside the allowlist do not start agent runs. Editing a message to add a mention is not supported; send a new message instead. +Restart NexBot after changing environment settings and inspect Slack status in Settings & setup. Channel activation must complete before trying a message. Mention the installed bot in a channel it can access; subsequent messages in that followed thread go to the same specialist. Unrelated threads, bot events, edits, deletions, and users outside the allowlist do not start agent runs. Editing a message to add a mention is not supported; send a new message instead. -This template maps permitted Slack users to the single OpenDots owner. Replies are visible to the Slack conversation's audience, so choose the specialist's Space access and permitted tools accordingly. This is not a multi-user identity model. +This template maps permitted Slack users to the single NexBot owner. Replies are visible to the Slack conversation's audience, so choose the specialist's Space access and permitted tools accordingly. This is not a multi-user identity model. ### Verify your deployment -From an allowed user, mention the bot and verify a response in the same Slack thread. Reply in that thread and confirm continuity. Check that an unrelated thread and an unapproved user cannot invoke it. Pause the assistant in OpenDots and verify that a permitted request receives a paused notice. Check Settings & setup for channel connection failures. +From an allowed user, mention the bot and verify a response in the same Slack thread. Reply in that thread and confirm continuity. Check that an unrelated thread and an unapproved user cannot invoke it. Pause the assistant in NexBot and verify that a permitted request receives a paused notice. Check Settings & setup for channel connection failures. Local tests exercise channel behavior with fixtures. A live Slack mention/reply remains unverified until you provision the managed connection and model credentials. [Channels SDK documentation](https://github.com/CopilotKit/channels-sdk) describes extending the adapter and channel behavior. @@ -182,7 +201,7 @@ Set `OWNER_TOKEN` and `BROWSER_SECRET` to different random secrets of at least 2 docker compose up --build -d ``` -Open http://localhost:4310. The app port binds to loopback. The browser service is optional: set a 24+ character `BROWSER_SECRET` and run `docker compose --profile browser up --build` to enable it; it has no published port. Application metadata lives in the `opendots-data` volume. +Open http://localhost:4310. The app port binds to loopback. The browser service is optional: set a 24+ character `BROWSER_SECRET` and run `docker compose --profile browser up --build` to enable it; it has no published port. Application metadata lives in the `nexbot-data` volume. ```sh # Stop services while retaining saved data. @@ -193,7 +212,7 @@ For remote hosting, configure an HTTPS reverse proxy and the matching `APP_ORIGI ## Automatic Learning -OpenDots connects [CopilotKit Automatic Learning](https://docs.copilotkit.ai/learning) +NexBot connects [CopilotKit Automatic Learning](https://docs.copilotkit.ai/learning) to individual Dots. It uses the server-side Intelligence credential (`CPK_INTELLIGENCE_API_KEY` or `INTELLIGENCE_API_KEY`) and optional `INTELLIGENCE_API_URL`; no additional model key or frontend key is needed. @@ -201,7 +220,7 @@ to individual Dots. It uses the server-side Intelligence credential 1. Open **Learning** in the same Intelligence project and create a container for one focused workflow, such as `research-workflow`. IDs use 1–64 lowercase letters, numbers, and single hyphens. -2. In OpenDots, edit the Dot and enter that ID under **Automatic Learning**. +2. In NexBot, edit the Dot and enter that ID under **Automatic Learning**. Saving the ID configures routing; it does not create or verify the remote container. 3. Start new conversations and complete related workflows. Each conversation keeps the container assigned when it was created. Existing conversations, including @@ -229,7 +248,7 @@ conversations; pause Learning in Intelligence to stop its analysis. Turning off delivery does not stop evidence collection. A delivery denial or an unavailable initial skill snapshot fails the turn rather than silently continuing without the configured skills. Restore delivery or uncheck **Use published skills** to continue -without them. Skills require review and publication in Intelligence; OpenDots does +without them. Skills require review and publication in Intelligence; NexBot does not automatically approve them. The integration uses an explicit container list, so ambient diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 009d435e..1909948a 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -1,11 +1,11 @@ -# Intelligence signup and OpenDots usage tracking +# Intelligence signup and NexBot usage tracking -OpenDots uses the CopilotKit SDK's existing telemetry transport. There is no +NexBot uses the CopilotKit SDK's existing telemetry transport. There is no separate PostHog SDK or PostHog key to configure in this application. ## Configure the server -Run these commands from the OpenDots root: +Run these commands from the NexBot root: ```sh npx copilotkit@latest login @@ -32,8 +32,8 @@ Keep the identity server-side. Leave `OWNER_ID` and conversation/thread IDs unchanged; they scope app data and are not the signup account identity. The SDK sends existing runtime events to -`https://telemetry.copilotkit.ai/ingest`. OpenDots adds -`accessibility_title: "OpenDots"`. The current SDK defaults to full sampling +`https://telemetry.copilotkit.ai/ingest`. NexBot adds +`accessibility_title: "NexBot"`. The current SDK defaults to full sampling (`1`), and Docker Compose uses the same default. An explicit `COPILOTKIT_TELEMETRY_SAMPLE_RATE` override is respected. @@ -45,7 +45,7 @@ and restart the API. Both variables accept `true` or `1`. The `npm test` command - `intelligence_signup`: an Intelligence account created in Clerk, emitted by the existing backend sync with the Clerk user ID as its PostHog `distinct_id`. -- `oss.runtime.instance_created`: the OpenDots runtime handler was created. +- `oss.runtime.instance_created`: the NexBot runtime handler was created. - `oss.runtime.copilot_request_created`: a request reached the runtime; this is usage evidence, not proof of a successful model answer. @@ -54,10 +54,10 @@ when that identity resolves. Runtime and signup events can have different PostHog `distinct_id` values: join the runtime `clerk_user_id` property to the signup `distinct_id` rather than assuming a standard person funnel joins them. -This connects completed signups to OpenDots usage. It does not prove OpenDots -was the acquisition source: the account might predate OpenDots. Measuring +This connects completed signups to NexBot usage. It does not prove NexBot +was the acquisition source: the account might predate NexBot. Measuring acquisition requires carrying a source through the Intelligence signup flow. -OpenDots does not emit a duplicate signup event on startup, login or key entry. +NexBot does not emit a duplicate signup event on startup, login or key entry. ## Validate in PostHog @@ -74,7 +74,7 @@ WHERE event IN ( 'oss.runtime.instance_created', 'oss.runtime.copilot_request_created' ) - AND properties.accessibility_title = 'OpenDots' + AND properties.accessibility_title = 'NexBot' AND timestamp >= now() - INTERVAL 1 DAY ORDER BY timestamp DESC LIMIT 20 @@ -82,14 +82,14 @@ LIMIT 20 Confirm the tag, the expected telemetry identity and the resolved Clerk subject. Then find that subject's `intelligence_signup` event. To count accounts signed -up in the last 30 days that also used OpenDots in that period: +up in the last 30 days that also used NexBot in that period: -Run the saved [signup-account query](telemetry/opendots-signups.hogql.sql). +Run the saved [signup-account query](telemetry/nexbot-signups.hogql.sql). It joins requests to signup accounts by Clerk ID. If a request lacks Clerk enrichment, it uses the latest resolved startup/runtime event with the same telemetry identity in the reporting window. This avoids assuming every request has a resolved account, and avoids joining unrelated installations by IP or -PostHog person ID. The [daily usage query](telemetry/opendots-usage.hogql.sql) +PostHog person ID. The [daily usage query](telemetry/nexbot-usage.hogql.sql) uses the same identity lookup. Both queries exclude marked validation events from requests and identity lookups. @@ -102,32 +102,32 @@ reporting surface is [dashboard 664553](https://eu.posthog.com/project/26816/das `npm test` disables production telemetry for ordinary fixtures. The telemetry tests start fresh processes, intercept SDK fetch calls before importing it, -and check the OpenDots tag, CLI-issued identity header, full sampling, explicit +and check the NexBot tag, CLI-issued identity header, full sampling, explicit sampling override, both opt-outs and absence of the project key in the payload. These checks prove the local SDK contract; production delivery and identity resolution require the PostHog checks above. Internal `@copilotkit.ai` accounts are excluded from signup sync, so validate a new external signup separately. -Ask the reporting owner to add the OpenDots filter and Clerk-ID join to +Ask the reporting owner to add the NexBot filter and Clerk-ID join to its reporting rule. The query counts signup accounts associated with usage, not signups caused by the template. Local Intelligence evaluation also requires CopilotKit sign-in, but a runtime event by itself does not establish a new signup. ## Reporting queries and validation evidence -The saved [signup-account query](telemetry/opendots-signups.hogql.sql) and -[daily usage query](telemetry/opendots-usage.hogql.sql) exclude marked validation -probes. The [setup-event query](telemetry/opendots-setup.hogql.sql) and -[installation conversion query](telemetry/opendots-setup-conversion.hogql.sql) +The saved [signup-account query](telemetry/nexbot-signups.hogql.sql) and +[daily usage query](telemetry/nexbot-usage.hogql.sql) exclude marked validation +probes. The [setup-event query](telemetry/nexbot-setup.hogql.sql) and +[installation conversion query](telemetry/nexbot-setup-conversion.hogql.sql) report the [browser setup flow](SETUP-TELEMETRY.md). All four queries executed -successfully against the production PostHog query API. Use these as the OpenDots +successfully against the production PostHog query API. Use these as the NexBot reporting rule in PostHog. Request counts are attempts, including requests rejected by the SDK; they are not successful answers. -On October 5, 2026, a live SDK probe using the CLI-selected `opendots` project +On October 5, 2026, a live SDK probe using the CLI-selected `nexbot` project sent `oss.runtime.instance_created` and `oss.runtime.copilot_request_created` to the production sink. Both returned HTTP `202` with marker -`opendots-validation-be790d6a-ff3e-4ce9-be30-5e3741ffc707`. The request was stopped +`nexbot-validation-be790d6a-ff3e-4ce9-be30-5e3741ffc707`. The request was stopped at SDK body validation before an agent ran. It made no model call and created no signup or customer conversation. @@ -145,7 +145,7 @@ dashboard and insight read/write scopes, or the reporting owner's intervention. A marked `oss.runtime.setup_step_viewed` from the production setup emitter also reached PostHog with its installation UUID: -`opendots-setup-validation-1ddf8b49-4d6a-434e-b345-4982d48cd9a5`. Setup events +`nexbot-setup-validation-1ddf8b49-4d6a-434e-b345-4982d48cd9a5`. Setup events do not receive Clerk account enrichment from the shared fanout; only the two exact runtime event types above do. Join setup and runtime by their shared `installation_id` to associate setup with account-resolved usage. The runtime @@ -153,7 +153,7 @@ continues to use the CLI identity as its transport identity. Activation and abandonment edge cases are validated locally, not fabricated in production. The combined updated probe -`opendots-validation-70c8067c-aa54-48c5-8d37-725baca262cf` delivered a startup, +`nexbot-validation-70c8067c-aa54-48c5-8d37-725baca262cf` delivered a startup, request, and setup-view event with one shared installation UUID. Startup resolved to the Clerk account; the request retained telemetry identity but lacked Clerk enrichment. This is why reporting uses the same-identity startup diff --git a/docs/demos/README.md b/docs/demos/README.md index 29ca0f20..f87b4a5b 100644 --- a/docs/demos/README.md +++ b/docs/demos/README.md @@ -1,6 +1,6 @@ # Recorded walkthroughs -Recorded from the local OpenDots app on September 29, 2026. The recordings use a dedicated `opendots` Intelligence project, a live `gpt-5.4-mini` model, and example content in a separate Launch studio Space. No customer data or credentials are shown. +Recorded from the local NexBot app on September 29, 2026. The recordings use a dedicated `nexbot` Intelligence project, a live `gpt-5.4-mini` model, and example content in a separate Launch studio Space. No customer data or credentials are shown. | Recording | Flow | Duration | Framing | | -------------------------------------------- | ----------------------------------------------------------------------------------- | ---------- | ------- | diff --git a/docs/fork/API-CONTRACTS.md b/docs/fork/API-CONTRACTS.md new file mode 100644 index 00000000..1a9df4d2 --- /dev/null +++ b/docs/fork/API-CONTRACTS.md @@ -0,0 +1,217 @@ +# API Contracts + +All owner-facing HTTP routes are served by the Hono app in `src/server/app.ts` +and the route modules it mounts. The agent runtime is served under +`/api/copilotkit/*`. This document records the surface, the auth model, and the +request/response shapes. Routes marked PLANNED are designed here and assigned a +work order. + +## 1. Authentication and request guards + +Applied to every `/api/*` request by middleware in `src/server/app.ts`: + +| Guard | Behaviour | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Owner bearer token | When `OWNER_TOKEN` is set, the `Authorization: Bearer ` value is compared to it with `timingSafeEqual` (constant-time). Failure → `401 {"error":"Enter your owner access token to unlock NexBot."}`. | +| Host allowlist (no token) | If no `OWNER_TOKEN` is configured, the request hostname must be `localhost`, `127.0.0.1`, `[::1]`, or a hostname from `APP_ORIGIN`. Otherwise → `403 {"error":"Unrecognized host."}`. | +| Origin allowlist | If an `Origin` header is present, it must be in `APP_ORIGIN` (or, when unset, equal to the request origin). Otherwise → `403 {"error":"Cross-origin requests are not allowed."}`. | +| Fetch metadata | `sec-fetch-site: cross-site` → `403 {"error":"Cross-site requests are not allowed."}`. | +| Body limit | `/api/*` bodies are capped at 1,000,000 bytes → `413 {"error":"Request is too large."}`. | +| Content type | Non-`GET`/`HEAD` requests must send `content-type: application/json` → otherwise `415 {"error":"Use application/json."}`. | +| Cache | `/api/*` responses are `Cache-Control: no-store`. | +| Startup guard | Binding to a non-loopback `HOST` requires an `OWNER_TOKEN` of at least 24 characters (`src/server/index.ts` throws otherwise). | + +Origin resolution: `resolveAppOrigins` (`src/server/app-origin.ts`) splits +`APP_ORIGIN` on commas; in development it defaults to +`http://localhost:5173` and `http://127.0.0.1:5173`. + +Response headers set at the server root (`src/server/index.ts`): +`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and a +`Content-Security-Policy` including `frame-ancestors 'none'` and a `connect-src` +that permits `'self'` plus the configured realtime origin (the Intelligence WS +origin upstream; this becomes unnecessary once the hosted realtime plane is +removed — WORK-004). + +### Runtime scope validation + +`validateRuntimeScope` (`src/server/runtime-scope.ts`) runs for `/api/copilotkit/*` +before dispatch. It: + +- Matches the **whole** path (the SDK accepts suffix matches; accepting prefixes + here would validate a thread different from the one dispatched). +- Rejects any unrecognized path and any route not in the allowlist below. +- Rejects the reserved suffixes `clear`, `info`, `inspector-metadata`, + `inspector-learning`, `transcribe`, `cpk-debug-events`, `annotate` on + `/threads/:id`. +- Rejects IDs containing `/`, `\`, or whitespace. +- Requires every `agentId` (path, body, or query) to be an existing Dot, and + requires the path `agentId` and any body/query `agentId` to agree. +- Requires every referenced `threadId` to be owned by the workspace + (`workspace.requireThread`), and requires path/body/query `threadId` to agree. +- Rejects on failure with `403 {"error":"This runtime route is not enabled in NexBot."}`. + +## 2. Workspace, pages, computers, connections + +### 2.1 Workspace and settings + +| Method | Path | Purpose | +| -------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/workspace` | `{ spaces, dots, conversations, setup, calls }` | +| `POST` | `/api/spaces` | Create a Space `{ name (≤60), description (≤500) }` → 201 | +| `POST` | `/api/dots` | Create a Dot/specialist `{ name, instructions, researchAllowed, memoryAllowed, appsAllowed, composioToolkits, learningContainerId?, skillDeliveryEnabled?, spaceIds?, spaceId }` → 201 | +| `PUT` | `/api/dots/:id` | Update a Dot (same strict schema) | +| `POST` | `/api/conversations` | `{ dotId, title (≤120) }` → 201; `503` if setup incomplete | +| `GET` | `/api/conversations/:id/capture` | Ephemeral thread capture | +| `GET` | `/api/state` | `{ settings, tasks, memories, mode, configured }` | +| `PATCH` | `/api/settings` | `{ name?, paused?, researchAllowed?, memoryAllowed?, appsAllowed? }` | +| `POST` | `/api/tasks` | `{ prompt (3–4000), intervalSeconds? (60..31536000), threadId }` → 201 | +| `GET` | `/api/tasks/:id` | Task detail (runs + events) | +| `POST` | `/api/tasks/:id/actions` | `{ action: 'run' \| 'pause' \| 'cancel' }` | +| `PUT` | `/api/tasks/:id/schedule` | `{ intervalSeconds: number \| null }` | +| `POST` | `/api/memories` | `{ text (1–2000) }` → 201 | +| `PUT` | `/api/memories/:id` | Update a memory | +| `DELETE` | `/api/memories/:id` | Delete a memory | +| `POST` | `/api/setup-telemetry` | Local setup event `{ kind: 'step_viewed' \| 'setup_abandoned', step }` (validated against server state) | + +Setup status is **not** a separate route: it is returned as `setup` inside +`GET /api/workspace`, computed by `setupStatus` (`src/server/platform-config.ts` +→ `{ intelligence, model, browser, voice, composio, slack, missing }`). After +WORK-004 the `intelligence` field and the `INTELLIGENCE_API_KEY` entry in +`missing` are removed. + +### 2.2 Pages + +| Method | Path | Purpose | +| -------- | -------------------------------------------------- | ------------------------------------------------------------------ | +| `GET` | `/api/spaces/:spaceId/pages` | List pages in a Space | +| `GET` | `/api/spaces/:spaceId/pages/:id` | Get a page (with revision) | +| `POST` | `/api/spaces/:spaceId/pages` | Create page `{ title (≤160), content (≤100000), parentId? }` → 201 | +| `PATCH` | `/api/spaces/:spaceId/pages/:id` | Patch page (requires `expectedRevision`; revision check) | +| `DELETE` | `/api/spaces/:spaceId/pages/:id` | Delete page | +| `POST` | `/api/spaces/:spaceId/pages/:id/conversation` | Bind a Dot conversation to a page `{ dotId }` | +| `POST` | `/api/conversations/:id/page` | Save conversation to a page `{ title, parentId }` | +| `GET` | `/api/conversations/:id/page-context` | `{ id, spaceId, title }` or `null` | +| `GET` | `/api/conversations/:id/reviewed-page/:toolCallId` | Page review receipt (or `{ deleted }`) | +| `POST` | `/api/conversations/:id/reviewed-page` | Create a reviewed page draft → 201 | + +### 2.3 Computers (optional; requires `COMPUTER_SUPERVISOR_URL` + tokens) + +| Method | Path | Purpose | +| ------- | ------------------------------------ | --------------------------------------------------------------- | +| `GET` | `/api/dots/:id/computer` | Status | +| `PATCH` | `/api/dots/:id/computer/permissions` | Update permissions (owner-only) | +| `POST` | `/api/dots/:id/computer/start` | Start the Dot's computer | +| `POST` | `/api/dots/:id/computer/stop` | Stop it | +| `POST` | `/api/dots/:id/computer/take` | Human takes control | +| `POST` | `/api/dots/:id/computer/release` | Human releases control | +| `POST` | `/api/dots/:id/computer/actions` | `{ action, input }` — forwarded to the supervisor (owner actor) | + +Error mapping: `404` Dot not found; `403` permission disabled or +non-owner human control; `409` computer not started or no active control +request; `400` unknown action; `503` supervisor unavailable. + +### 2.4 Connections (MCP) and approvals + +| Method | Path | Purpose | +| -------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `GET` | `/api/dots/:id/connections` | List MCP connections for a Dot | +| `POST` | `/api/dots/:id/connections` | Add `{ name (≤40), url (http(s), no embedded credentials), token? (≤4096) }` → 201 | +| `POST` | `/api/connections/:id/refresh` | Re-list tools from the MCP server | +| `PATCH` | `/api/connections/:id/tools/:name` | `{ enabled?, requiresApproval? }` | +| `DELETE` | `/api/connections/:id` | Remove a connection | +| `GET` | `/api/conversations/:id/connection-approvals/:approvalId` | Pending approval detail (own thread only, 1-hour TTL, tool still enabled) | +| `GET` | `/api/conversations/:id/connection-actions/:toolCallId` | Stored action result | +| `POST` | `/api/conversations/:id/connection-actions` | Approve and run a stored request `{ toolCallId, ... }` | + +The approval path runs **only** its own stored connection, tool, and arguments — +never arguments supplied with the approval. `APPROVAL_TTL_MS` bounds the window. + +### 2.5 Voice (optional) + +| Method | Path | Purpose | +| ------ | ------------------------------ | ---------------------------------------------------------------------------------------------------- | +| `POST` | `/api/voice/calls` | `{ threadId, sdp (≤100000) }` → 201 call record | +| `GET` | `/api/voice/calls/:id` | Call status | +| `POST` | `/api/voice/calls/:id/active` | Mark active | +| `POST` | `/api/voice/calls/:id/compute` | `{ toolCallId, request, transcript }` → `{ text }` (routes through the same permission-scoped agent) | +| `POST` | `/api/voice/calls/:id/end` | `{ transcript (≤20000), anchorMessageId? }` → persist receipt | + +## 3. Agent runtime: `/api/copilotkit/*` + +Mounted by `app.all('/copilotkit/*', (c) => platform.handle(c.req.raw))` inside +`src/server/workspace-routes.ts`; dispatched by `Platform.handle` +(`src/server/platform.ts`) through the CopilotKit Hono handler +(`createCopilotHonoHandler`, `basePath: '/api/copilotkit'`, `cors: { origin: [] }`). + +### 3.1 AG-UI endpoints + +| Method | Path | Purpose | +| ------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/copilotkit/info` | Runtime/agent discovery; returns runtime mode and agents. Upstream returns `mode: 'intelligence'`; after WORK-004 the `z.literal('intelligence')` gate is removed and server turns read agents without requiring a hosted WS URL. | +| `POST` | `/api/copilotkit/agent/:agentId/run` | Start an AG-UI run for a Dot in a thread (`threadId` in body). Streams AG-UI events. | +| `POST` | `/api/copilotkit/agent/:agentId/connect` | Connect to an in-flight run. | +| `POST` | `/api/copilotkit/agent/:agentId/suggest` | Run suggestions. | +| `POST` | `/api/copilotkit/agent/:agentId/stop/:threadId` | Abort an in-flight run. | +| `POST` | `/api/copilotkit/threads/subscribe` | Subscribe to thread updates. | + +Server-side turns (`src/server/headless.ts`, used by voice compute and scheduled +tasks) call `/api/copilotkit/run` with the owner bearer header via `HttpAgent` +from `@ag-ui/client` (PLANNED — WORK-004). + +### 3.2 Thread REST endpoints served by the local runner + +These are the endpoints `useThreads()` and the thread UI depend on. In the fork +they are served locally because `PostgresAgentRunner` implements +`LocalThreadEndpointRunner` (see [ARCHITECTURE.md](./ARCHITECTURE.md) §5). + +| Method | Path | Purpose | +| -------- | -------------------------------------------- | ------------------------------------- | +| `GET` | `/api/copilotkit/threads` | List threads (conversation list) | +| `GET` | `/api/copilotkit/threads/:threadId/messages` | Thread messages (`getThreadMessages`) | +| `GET` | `/api/copilotkit/threads/:threadId/events` | Streamed events (`getThreadEvents`) | +| `GET` | `/api/copilotkit/threads/:threadId/state` | Current state (`getThreadState`) | +| `POST` | `/api/copilotkit/threads/:threadId/archive` | Archive a thread | +| `PATCH` | `/api/copilotkit/threads/:threadId` | Rename/metadata | +| `DELETE` | `/api/copilotkit/threads/:threadId` | Delete a thread (`clearThreads`) | + +Response shapes are the runtime's existing REST contract; the replacement store +returns equivalent payloads so the client is unchanged. + +### 3.3 Runtime error shape + +`Platform.handle` returns `503 {"error":"Setup required: …"}` when the runtime is +not ready, and `403 {"error":""}` on scope failure. Generic +`onError` returns `500 {"error":"The server could not complete this request. Check +server logs and database access."}` and logs only `error.name`. + +## 4. New routes introduced by the fork + +### 4.1 Slack Events API webhook (PLANNED — WORK-005) + +| Method | Path | Purpose | +| ------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/api/slack/events` | Slack Events API receiver. Verifies the `X-Slack-Signature` HMAC-SHA256 against the signing secret over `v0::` and rejects stale timestamps; answers Slack's `url_verification` challenge; ignores events from anyone but the allowlisted owner/workspace; dispatches eligible messages to the local agent runner. | + +Constraints: the raw request body is needed for signature verification, so this +route must read the body before JSON parsing and is exempt from the generic +JSON content-type requirement (its own verifier applies). + +### 4.2 Health and readiness (PLANNED — WORK-008) + +| Method | Path | Purpose | +| ------ | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/health` | Liveness: `200 {"ok":true}` when the process is serving. No auth, no datastore access. | +| `GET` | `/api/ready` | Readiness: reports `{ ok, sqlite, postgres, setup }`. `503` until SQLite is reachable, Postgres is reachable and migrated, and required config is present. | + +The browser service already exposes `GET /health` → `{"ok":true}` +(`src/browser/index.ts:130`), and its only other route is +`POST /browse { url }` gated by `BROWSER_SECRET` (timing-safe) with a +`bodyLimit` of 16,384 bytes and an internal busy guard (`429` when busy). The app +server has no health route upstream; adding it is part of WORK-008. + +### 4.3 Error shape for owner routes + +Owner routes return `{"error": ""}` with an appropriate status; Zod +validation failures map to `400`. `src/server/workspace-routes.ts` maps known +error prefixes to `400`/`404`/`503`; `src/server/connection-routes.ts` and +`src/server/computer-routes.ts` map their own domain errors. diff --git a/docs/fork/ARCHITECTURE.md b/docs/fork/ARCHITECTURE.md new file mode 100644 index 00000000..50b15ba1 --- /dev/null +++ b/docs/fork/ARCHITECTURE.md @@ -0,0 +1,262 @@ +# Architecture + +This is the load-bearing document for the fork. It describes the target +architecture after decoupling, the current upstream state it replaces, and the +precise seam points that make the replacement safe. + +Status vocabulary: **DONE** (exists in this repo now), **PLANNED** (assigned to +a work order, designed here). + +## 1. Component diagram + +```mermaid +flowchart TB + subgraph Browser["Owner browser (owner-authenticated)"] + UI["React 19 + Vite client
dist/client
App.tsx / Chat.tsx / pages"] + end + + subgraph VPS["Owner VPS (Docker Compose)"] + subgraph AppC["app container (port 4310)"] + HONO["Hono app (src/server/app.ts)
/api/* owner auth + origin allowlist"] + WSR["workspaceRoutes / pageRoutes
connectionRoutes / computerRoutes"] + RUNTIME["/api/copilotkit/* runtime mount
SSE mode: CopilotSseRuntime"] + RUNNER["agent-runner.ts
PostgresAgentRunner (LocalThreadEndpointRunner)"] + CSTORE["conversation-store.ts
PostgresConversationStore"] + LSTORE["learning-store.ts
local skills"] + SLACK["slack-channel.ts (rewritten)
Slack Web API + Events API"] + PG["pg.ts
pool + versioned migrations"] + SQLITE[("SQLite: data/nexbot.sqlite
spaces, dots, pages, tasks, ...")] + end + PGDB[("PostgreSQL
conversations, messages,
thread_runs/events/state, skills,
schema_migrations")] + BROWSER["browser container (4311, optional)
read-only Playwright"] + SUP["computer supervisor (optional)
only component with Docker socket"] + end + + MODEL["OpenAI-compatible endpoint
OPENAI_BASE_URL / _KEY / _MODEL
(OpenAI, Ollama, vLLM, LM Studio, ...)"] + SLACKAPI["Slack (owner workspace)"] + + UI -->|"/api/* + /api/copilotkit/*"| HONO + HONO --> WSR + HONO --> RUNTIME + WSR --> SQLITE + RUNTIME --> RUNNER + RUNNER --> CSTORE + RUNTIME --> LSTORE + CSTORE --> PG + LSTORE --> PG + PG --> PGDB + RUNNER -->|"AG-UI over HTTP/SSE"| MODEL + RUNNER -. "web search (optional)" .-> BROWSER + SLACK --> SLACKAPI + SLACK --> RUNNER + WSR -. "optional" .-> SUP +``` + +Everything inside `VPS` is owner-controlled. The only external endpoints are +the owner-selected model endpoint and the owner's Slack workspace; both are +optional except the model endpoint, which is required for agent runs. + +## 2. Request flow for a chat turn + +Current (upstream) and target (fork) flow for one user message in the UI: + +1. **Client → server.** `src/client/Chat.tsx` sends the turn to + `/api/copilotkit/agent/:dotId/run` with the `Authorization: Bearer +` header. `/api/copilotkit/*` requests are handled by + `Platform.handle()` (`src/server/platform.ts`). +2. **Auth + scope.** `createApp` middleware (`src/server/app.ts`) validates + owner token (timing-safe), `APP_ORIGIN`, and `sec-fetch-site`; then + `validateRuntimeScope` (`src/server/runtime-scope.ts`) confirms the path + addresses a Dot/thread the workspace actually owns. +3. **Runtime dispatch.** The CopilotKit Hono handler routes the AG-UI request. + In the fork this is **SSE mode**: the runtime holds a `runner: AgentRunner`. +4. **Agent execution.** `PostgresAgentRunner` (`src/server/agent-runner.ts`) + invokes `DotAgent` (`src/server/dot-agent.ts`), which calls the + OpenAI-compatible endpoint and, as needed, tools (MCP connections, web + search, computers, page tools). +5. **Persistence.** During and after the run, the runner writes the thread, + messages, run record, streamed AG-UI events, and thread state through + `PostgresConversationStore` into PostgreSQL via `src/server/pg.ts`. +6. **Streaming back.** AG-UI events stream back over SSE to the client, which + renders via the unchanged client code. +7. **Conversation list.** On load, `useThreads()` calls the REST thread + endpoints. In the fork these are served **locally** by the same runtime + because the runner implements `LocalThreadEndpointRunner` (Section 4). + +Server-side turns (voice compute, scheduled tasks) follow the same path but are +initiated in-process: `src/server/headless.ts` uses `HttpAgent` from +`@ag-ui/client` against our own `/api/copilotkit/run` with the owner bearer +header, instead of the hosted `IntelligenceAgent`. + +```mermaid +sequenceDiagram + participant C as Client (Chat.tsx) + participant A as Hono /api (app.ts) + participant S as validateRuntimeScope + participant R as CopilotSseRuntime + participant AR as PostgresAgentRunner + participant D as DotAgent + participant M as OpenAI-compatible endpoint + participant P as PostgreSQL + + C->>A: POST /api/copilotkit/agent/:dotId/run (Bearer) + A->>A: owner token + origin + sec-fetch-site checks + A->>S: scope check (dotId, threadId) + S-->>A: allowed + A->>R: dispatch AG-UI request + R->>AR: run(agent, thread, messages) + AR->>P: upsert thread + append user message + AR->>D: execute turn + D->>M: completion + tool calls + M-->>D: stream + D-->>AR: AG-UI events + AR->>P: append thread_events / thread_state / messages + AR-->>R: event stream + R-->>C: SSE events + C->>A: GET /api/copilotkit/threads (useThreads) + A->>AR: listThreads / getThreadMessages (local) + AR->>P: read + P-->>C: conversation list +``` + +## 3. Server module map + +Current modules that the decoupling touches or depends on (real paths): + +| Module | Role | Change | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `src/server/index.ts` | Bootstrap: reads env, constructs `Store`, `WorkspaceStore`, `Platform`, `Runner`, `createApp`, starts `serve`. | Rewired to build Postgres pool + SSE runtime instead of Intelligence mode (WORK-004/008). | +| `src/server/app.ts` | Hono app, `/api/*` auth middleware, task/settings/memory routes, static client serving, CSP. | Reused; health/readiness endpoint added (WORK-008). | +| `src/server/platform.ts` | Owns `CopilotKitIntelligence`, `CopilotRuntime`, `createCopilotHonoHandler`, `PageService`, channels. | Rewritten: SSE runtime + `PostgresAgentRunner`; Intelligence removed (WORK-004). | +| `src/server/platform-config.ts` | Env parsing, `setupStatus`, `INTELLIGENCE_KEY_MISSING_LABEL`. | Drop `intelligenceKey` requirement; keep model/browser/voice/composio/slack statuses (WORK-004/007). | +| `src/server/headless.ts` | Server-side/voice/scheduled turns via hosted `IntelligenceAgent`; `z.literal('intelligence')` gate. | Replaced with `HttpAgent` against own endpoint; gate removed (WORK-004). | +| `src/server/page-service.ts` | `PageIntelligence` seam: `getOrCreateThread`, `getThreadMessages`. | Satisfied by `PostgresConversationStore` (WORK-003). | +| `src/server/learning.ts` | `learningSelector` for hosted learning containers. | Replaced by local skills store (WORK-006). | +| `src/server/slack-channel.ts` | Managed `createChannel` + identity mapping. | Rewritten for Slack Web API + Events API (WORK-005). | +| `src/server/setup-telemetry.ts` | Setup telemetry + CopilotKit telemetry identity. | Telemetry identity removed; local setup status kept (WORK-004). | +| `src/server/runtime-scope.ts` | Path/method/ownership validation for `/api/copilotkit/*`. | Reused as-is; extended for any new local thread routes (WORK-004). | +| `src/server/store.ts` | `node:sqlite` `DatabaseSync`, WAL, tasks/runs/events/memories/settings/telemetry. | Unchanged (stays on SQLite). | +| `src/server/workspace.ts` | Spaces, Dots, thread bindings, calls, captures, dot↔space. | Unchanged storage; thread _content_ moves to Postgres (WORK-003). | +| `src/server/pages.ts` | Pages, page revisions, `page_reviews`, `page_threads`. | Unchanged. | +| `src/server/connection-store.ts`, `connections.ts` | MCP connections/approvals/actions. | Unchanged. | +| `src/server/computer-store.ts`, `computer-service.ts` | Computer permissions/audit, supervisor client. | Unchanged. | + +New modules introduced by the fork: + +| New module | Purpose | Work order | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------- | +| `src/server/pg.ts` | PostgreSQL connection pool + idempotent, versioned migrations (`schema_migrations`). | WORK-003 | +| `src/server/conversation-store.ts` | `PostgresConversationStore`: threads, messages, runs, events, state; satisfies `PageIntelligence`. | WORK-003 | +| `src/server/agent-runner.ts` | `PostgresAgentRunner extends AgentRunner implements LocalThreadEndpointRunner`. | WORK-003 | +| `src/server/learning-store.ts` | Local per-Dot skills, revisions, delivery flag. | WORK-006 | +| `src/server/slack-channel.ts` (rewritten) | Self-hosted Slack Events API + Web API. | WORK-005 | + +## 4. The CopilotKit decoupling seam table + +This is the authoritative mapping. Each upstream CopilotKit entry point and its +approved replacement: + +| Upstream (hosted/proprietary) | Replacement | Why the replacement works | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `CopilotKitIntelligence` (`@copilotkit/runtime/v2`) — hosted thread store, memory, learning containers; requires `CPK_INTELLIGENCE_API_KEY` / `INTELLIGENCE_API_KEY` | `PostgresConversationStore` — self-hosted thread/message persistence in PostgreSQL. Satisfies the app's existing structural seam `PageIntelligence` in `src/server/page-service.ts` (methods `createThread`, `getOrCreateThread`, `getThreadMessages`) | The app already calls only those three methods through a narrow interface; the hosted client becomes a local store with identical method shapes. | +| `IntelligenceAgentRunner` (hosted realtime WS gateway, durable threads) | `PostgresAgentRunner extends AgentRunner implements LocalThreadEndpointRunner` — runs agents locally and persists threads/messages/events/state in PostgreSQL | `AgentRunner` is an abstract class; implementing it locally removes the hosted gateway. | +| `CopilotRuntime` in **Intelligence mode** (requires `intelligence:`) | `CopilotRuntime` in **SSE mode** (`CopilotSseRuntime`): options take `runner?: AgentRunner`, and `intelligence?: undefined` / `channels?: undefined`. MIT, no API key | SSE mode is a first-class, MIT runtime mode; it needs a runner, which we provide. | +| `IntelligenceAgent` (`@copilotkit/core`) in `src/server/headless.ts` for server-side/voice/scheduled turns, connecting to `info.intelligence.wsUrl` | `HttpAgent` from `@ag-ui/client` (MIT v0.0.59) speaking the open AG-UI protocol to our own `/api/copilotkit/run`. The `z.literal('intelligence')` runtime-info schema gate is removed | AG-UI is an open protocol; `HttpAgent` is a protocol client, not a host client. | +| `@copilotkit/channels` `createChannel` (managed Slack; requires Intelligence mode) | A self-hosted Slack integration using the Slack Web API + Events API (signing-secret verified), registered as its own Hono routes, invoking the same local agent runner | Slack's own APIs are public and owner-controllable; the managed channel is a convenience wrapper requiring Intelligence. | +| Hosted learning containers / `getLearnedSkillsSnapshots` / `learnedSkills: { containers, apiKey, apiUrl }` | Local skills store in PostgreSQL (per-Dot skills, revisions, delivery enabled/disabled) | Skills are per-Dot data the owner can store and serve locally. | +| CopilotKit telemetry ingest `https://telemetry.copilotkit.ai/ingest`, `CPK_TELEMETRY_ID`, `COPILOTKIT_TELEMETRY_*` | **Removed.** No outbound call to any CopilotKit host | Removing the client removes the calls; nothing depends on it functionally. | + +**Retained:** `@copilotkit/runtime` and `@copilotkit/react-core` (both MIT, +v1.75.0) remain as local-only libraries. They require no account, no key, and +make no call to a CopilotKit host. Deliberate, recorded decision (ADR-0002). + +## 5. Why SSE mode + `LocalThreadEndpointRunner` is sufficient + +This is the key feasibility argument, backed by specific evidence in the +installed packages. + +1. **SSE mode is a supported runtime mode.** In `@copilotkit/runtime@1.75.0`, + `dist/v2/runtime/core/runtime.d.mts` declares: + + ```ts + CopilotSseRuntimeOptions extends BaseCopilotRuntimeOptions { + runner?: AgentRunner; + intelligence?: undefined; + generateThreadNames?: undefined; + channels?: undefined; + } + ``` + + So a runtime can be constructed with only a `runner`, with `intelligence` + and `channels` explicitly absent. This mode is MIT and needs no API key. + +2. **A runner contract exists and is implementable locally.** `AgentRunner` is + an abstract class with `run`, `connect`, `isRunning`, and `stop`. + `LocalThreadEndpointRunner` adds `listThreads`, `getThreadMessages`, + `getThreadEvents`, `getThreadState`, `clearThreads`, plus the marker + `ɵsupportsLocalThreadEndpoints: true`, which the type guard + `supportsLocalThreadEndpoints(runner)` checks. + +3. **The server already treats a local thread backend as first class.** + `dist/v2/runtime/handlers/get-runtime-info.mjs:128`: + + ```js + const hasRestThreadBackend = + isIntelligenceRuntime(runtime) || + supportsLocalThreadEndpoints(runtime.runner); + ``` + + A runner implementing the local marker makes the runtime report a REST thread + backend. + +4. **The thread handlers delegate to the local runner.** In + `dist/v2/runtime/handlers/intelligence/threads.mjs` (lines 63, 84, 191, 240, 269) the handlers fall back to `runtime.runner` when + `supportsLocalThreadEndpoints(runtime.runner)` is true. + +5. **Consequence: the client needs no rewrite.** The REST thread endpoints that + the client's `useThreads()` calls are served locally by our runner. The + 965-line `src/client/App.tsx` and 489-line `src/client/Chat.tsx` continue to + work unmodified. + +## 6. Source hierarchy + +``` +src/ + server/ Hono API, stores, runtime wiring, agents, services (compiled by tsc) + browser/ isolated Playwright service (own Hono app, /health, /browse) + shared/ zod schemas and types shared by server + client + client/ React 19 + Vite UI (built to dist/client) + build/ build-time helpers (e.g. dark-theme) +tests/ Vitest suites (56 files at baseline) +docs/fork/ this documentation set +docs/ upstream docs (SETUP.md, COMPUTERS.md, ...; unchanged) +``` + +Entrypoints: `src/server/index.ts` (app) and `src/browser/index.ts` (browser +service). Shared schemas in `src/shared/` are the contract between the Hono +routes and the React client. + +## 7. Build output layout + +`npm run build` = `vite build && tsc -p tsconfig.server.json`: + +``` +dist/ + client/ Vite build of the React client (served by serveStatic) + server/ + server/ compiled src/server/** (entry: server/index.js) + browser/ compiled src/browser/** (entry: browser/index.js) + shared/ compiled src/shared/** +``` + +`tsconfig.server.json` extends `tsconfig.json` with +`module`/`moduleResolution: NodeNext`, `outDir: dist/server`, `rootDir: src`, +and `include: ["src/server", "src/browser", "src/shared"]`. + +Docker images (see `Dockerfile`): a `build` stage runs `npm run build`; the +`app` stage copies `dist/` and runs `node dist/server/server/index.js` on port +`4310`; the `browser` stage runs `node dist/server/browser/index.js` on port +`4311`, with Playwright chromium installed. + +Ports: app `4310`; browser service `4311`; computer supervisor `4312` → `4300`; +dev Vite `5173` proxying `/api` to `127.0.0.1:4310`. diff --git a/docs/fork/BACKLOG.md b/docs/fork/BACKLOG.md new file mode 100644 index 00000000..4b866879 --- /dev/null +++ b/docs/fork/BACKLOG.md @@ -0,0 +1,193 @@ +# Backlog + +Work orders WORK-000 through WORK-010, deferred features, the mapping of +upstream community issues this fork resolves, and known limitations. Status uses +the shared legend (DONE / IN PROGRESS / PLANNED / ACCEPTED). + +## 1. Work orders + +### WORK-000 — Baseline (DONE) + +- **Objective:** record the reproducible baseline of the fork at upstream HEAD + `d4214aff10f066911f3253cb3cb870764cd51a9f` before changes. +- **Status:** DONE. +- **Result:** typecheck PASS, lint PASS, build PASS, `npm test` 348/351 (3 + telemetry timeouts, explained in [TEST-PLAN.md](./TEST-PLAN.md) §3). +- **Evidence:** `baseline-typecheck.log`, `baseline-lint.log`. + +### WORK-001 — Documentation (IN PROGRESS) + +- **Objective:** produce the canonical fork documentation set in `docs/fork/`. +- **Status:** IN PROGRESS — this document set is the deliverable; it must be + re-checked against implementation as WORK-003..WORK-009 land. +- **Deliverables:** `OVERVIEW.md`, `REQUIREMENTS.md`, `SCOPE.md`, + `ARCHITECTURE.md`, `DATA-MODEL.md`, `API-CONTRACTS.md`, `SECURITY.md`, + `TEST-PLAN.md`, `DEPLOYMENT.md`, `BACKLOG.md`, `DEFINITION-OF-DONE.md`, + `DECISIONS.md`, `FEATURE-PARITY.md`, `CHECKPOINT.md`, `README.md`. + +### WORK-002 — Issue/PR migration (PLANNED) + +- **Objective:** migrate upstream issues and PRs to the fork's tracker, + preserving authorship and provenance where the platform allows, and record the + authorship limitations (ADR-0010). +- **Status:** PLANNED. +- **Notes:** GitHub does not preserve original author identity when issues are + recreated in another repository. The migration must state, on each imported + item, that it was imported from `CopilotKit/OpenDots` and link the original. + Do not rewrite issue text to imply original authorship by the fork. + +### WORK-003 — Postgres conversation store + runner (PLANNED) + +- **Objective:** implement `src/server/pg.ts` (pool + idempotent versioned + migrations), `src/server/conversation-store.ts` + (`PostgresConversationStore`, satisfying the `PageIntelligence` seam), and + `src/server/agent-runner.ts` (`PostgresAgentRunner extends AgentRunner +implements LocalThreadEndpointRunner`). +- **Status:** PLANNED. +- **Depends on:** nothing. +- **Deliverables:** schema in [DATA-MODEL.md](./DATA-MODEL.md) §1; tests in + [TEST-PLAN.md](./TEST-PLAN.md) §5.1–5.2. + +### WORK-004 — SSE runtime wiring + telemetry removal (PLANNED) + +- **Objective:** construct `CopilotRuntime` in SSE mode with the local runner + (`intelligence: undefined`, `channels: undefined`); replace the + `IntelligenceAgent` server path in `src/server/headless.ts` with `HttpAgent` + (`@ag-ui/client`); remove the `z.literal('intelligence')` gate; remove + CopilotKit telemetry and all outbound calls to CopilotKit hosts; drop the + `intelligenceKey` requirement from `src/server/platform-config.ts` and + `compose.yml`. +- **Status:** PLANNED. +- **Depends on:** WORK-003 (a runner is required for SSE mode). +- **Deliverables:** `runtime-sse-wiring.test.ts`, `headless-httpagent.test.ts`, + `no-copilotkit-egress.test.ts`. + +### WORK-005 — Self-hosted Slack (PLANNED) + +- **Objective:** rewrite `src/server/slack-channel.ts` to use the Slack Web API + - Events API (signing-secret verified), registered as Hono routes + (`POST /api/slack/events`), invoking the same local agent runner. +- **Status:** PLANNED. +- **Depends on:** WORK-003/WORK-004 (must run the local runner, not a managed + channel). +- **Deliverables:** `slack-signature.test.ts`, `slack-identity-allowlist.test.ts`, + `slack-run-local-agent.test.ts`. + +### WORK-006 — Local learning/skills store (PLANNED) + +- **Objective:** implement `src/server/learning-store.ts` and the `skills` table; + replace hosted learning containers and `getLearnedSkillsSnapshots`; drive it + from the Dot `learningContainerId`/`skillDeliveryEnabled` settings and the + rewritten `learningSelector`. +- **Status:** PLANNED. +- **Depends on:** WORK-003. +- **Deliverables:** `learning-store.test.ts`, + `learning-replaces-containers.test.ts`. + +### WORK-007 — LLM provider configuration (PLANNED) + +- **Objective:** make an OpenAI-compatible endpoint the documented, first-class + model configuration (`OPENAI_BASE_URL`/`OPENAI_API_KEY`/`OPENAI_MODEL`), with + first-run guidance for Ollama/LM Studio/vLLM/OpenRouter/Groq/Together, and + remove the upstream narrative that a CopilotKit model is required. +- **Status:** PLANNED. +- **Addresses:** upstream #118, #100, #58, #87. + +### WORK-008 — VPS deployment (PLANNED) + +- **Objective:** add the Postgres service to `compose.yml`; add + `/api/health` and `/api/ready`; finalize the env template; document and + validate HTTPS/reverse-proxy topology, restart policies, volumes, backup and + restore for both stores, upgrade and rollback, and firewall guidance (see + [DEPLOYMENT.md](./DEPLOYMENT.md)). +- **Status:** PLANNED. +- **Depends on:** WORK-003/WORK-004. +- **Deliverables:** `health-ready.test.ts`; scripted compose smoke test. + +### WORK-009 — Tests + evidence (PLANNED) + +- **Objective:** implement the new tests in [TEST-PLAN.md](./TEST-PLAN.md) §5, + the agent-latency benchmark (§6), and capture reproducible evidence artifacts + (§7), including the Postgres migration apply/rollback and compose smoke runs. +- **Status:** PLANNED. +- **Depends on:** WORK-003..WORK-008 as applicable. + +### WORK-010 — Security review + audit (PLANNED) + +- **Objective:** review the new attack surface (Postgres, Slack webhook, health + endpoints), run a dependency-vulnerability pass with no unresolved + HIGH/CRITICAL findings, review DB role/TLS/at-rest posture, add rate-limiting + guidance or implementation, and review indirect prompt-injection risk. +- **Status:** PLANNED. +- **Depends on:** WORK-004..WORK-008. +- **Gate:** per [DEFINITION-OF-DONE.md](./DEFINITION-OF-DONE.md), release + requires no unresolved HIGH/CRITICAL finding. + +## 2. Deferred features + +See [SCOPE.md](./SCOPE.md) §3 for the full list and revisit triggers. Summary: +conversation retention enforcement, Postgres at-rest encryption, Slack +interactive components, multi-Dot shared conversations, deep reasoning-model +reliability, and voice provider portability. + +## 3. Upstream community issues this fork resolves + +Real upstream issue numbers. Where upstream provided a title it is quoted; +where only a number was recorded, the title is marked as not recorded rather +than invented. + +| Upstream issue | Title / subject | How this fork resolves it | +| -------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| #171 | Hosted Intelligence realtime plane returns 404 on all WS paths | The hosted realtime plane is removed (WORK-004); the runtime is SSE mode with a local runner. | +| #127 | Intelligence conversation creation fails HTTP 500/503 | Conversation creation is local (`PostgresConversationStore`) with no hosted call (WORK-003). | +| #123 | Intelligence 503 selecting/creating a project | No CopilotKit project is selected or required (WORK-004, ADR-0002). | +| #74 | Conversations require CopilotKit's cloud; add a local option | Local Postgres conversations are the default (WORK-003, ADR-0003). | +| #87 | Remove requirement for any cloud API or cloud model | Only an owner-selected OpenAI-compatible endpoint is required (WORK-007). | +| #118 | OpenAI only? | Any OpenAI-compatible base URL is supported (WORK-007). | +| #100 | Add Ollama configuration to first-run setup | Ollama is documented as a first-class provider (WORK-007). | +| #58 | Reasoning models fail every turn | Addressed by provider flexibility and the `AGENT_RUN_TIMEOUT_MS` control; residual risk tracked in deferred items. | +| #42 | Not open sourced | This fork keeps and builds on the MIT source; broader upstream licensing is upstream's decision. | +| #83 | (title not recorded) | Removed CopilotKit-hosted dependency surface. | +| #84 | (title not recorded) | Removed CopilotKit-hosted dependency surface. | +| #88 | (title not recorded) | Removed CopilotKit-hosted dependency surface. | +| #108 | (title not recorded) | Removed CopilotKit-hosted dependency surface. | +| #41 | (title not recorded) | Removed CopilotKit-hosted dependency surface. | +| #46 | (title not recorded) | Removed CopilotKit-hosted dependency surface. | +| #149 | (title not recorded) | Self-hosted storage/runner parity. | +| #151 | (title not recorded) | Self-hosted storage/runner parity. | +| #173 | (title not recorded) | Self-hosted storage/runner parity. | +| #177 | (title not recorded) | Self-hosted storage/runner parity. | +| #59 | (title not recorded) | Provider/configuration flexibility. | +| #70 | (title not recorded) | Provider/configuration flexibility. | +| #69 | (title not recorded) | Provider/configuration flexibility. | +| #68 | (title not recorded) | Provider/configuration flexibility. | +| #57 | (title not recorded) | Provider/configuration flexibility. | +| #54 | (title not recorded) | Provider/configuration flexibility. | +| #125 | (title not recorded) | Self-hosted Slack (WORK-005). | +| #147 | (title not recorded) | Self-hosted Slack (WORK-005). | +| #111 | (title not recorded) | Self-hosted learning/skills (WORK-006). | + +Each imported issue is tracked for authorship provenance under WORK-002, with +the limitation recorded in ADR-0010. + +## 4. Known limitations + +1. **Telemetry-suite flake on Windows hosts.** 3 `tests/telemetry.test.ts` + subprocess tests time out at ~5.0 s locally; they pass on Linux CI. See + [TEST-PLAN.md](./TEST-PLAN.md) §3. +2. **No guarantee against upstream drift.** Upstream `main` may add new + CopilotKit-hosted surfaces; the fork re-audits the seam table + ([ARCHITECTURE.md](./ARCHITECTURE.md) §4) on each upstream merge. +3. **Two datastores, no shared transaction.** SQLite and Postgres cannot be + committed atomically; a crash between writes can leave a dangling + conversation binding. The restore reconciliation in + [DEPLOYMENT.md](./DEPLOYMENT.md) §8.2 handles the recovery case; a + consistency checker is PLANNED (WORK-003/008). +4. **No multi-user enforcement.** Single-owner by design; upstream `SECURITY.md` + already scopes this. +5. **No application-level rate limiting** on agent runs/tool calls; mitigation is + at the reverse proxy (WORK-010). +6. **MCP token at rest** in SQLite is plaintext (WORK-010). +7. **Retention policy unenforced** for `thread_events` (deferred). +8. **Epoch-ms timestamps** in Postgres instead of `timestamptz` — a fidelity + choice recorded in [DATA-MODEL.md](./DATA-MODEL.md) §1.1, revisable by ADR. diff --git a/docs/fork/CHECKPOINT.md b/docs/fork/CHECKPOINT.md new file mode 100644 index 00000000..e94048cc --- /dev/null +++ b/docs/fork/CHECKPOINT.md @@ -0,0 +1,199 @@ +# Checkpoint + +Status snapshot for the decoupled fork. Date: **2026-11-05**. + +## 1. Identity + +| Item | Value | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Upstream | `CopilotKit/OpenDots` (MIT, default branch `main`) | +| Upstream HEAD recorded | `d4214aff10f066911f3253cb3cb870764cd51a9f` | +| Destination fork | `https://github.com/KayzenRoot/nexbot` | +| Local working tree | `D:\Projects\opendots-clone\work\fork` (directory name unchanged; only the brand was renamed) | +| `origin` remote | `https://github.com/KayzenRoot/nexbot.git` | +| License | Retained upstream MIT (`LICENSE`, Copyright (c) Atai Barkai) | +| Stack | Single npm package; TypeScript, ESM, Node >= 24; React 19 + Vite client; Hono on `@hono/node-server`; SQLite via `node:sqlite` | + +Repository facts at this checkpoint, measured from the tree: + +- Tracked files: **207**. +- Commits: **127 across all refs** (73 reachable from `HEAD`). The recorded + baseline figure of 127 counts all refs including upstream history; `HEAD` + alone is 73. +- Test files: **56** (Vitest) in `tests/`. +- LOC: baseline record states ~11.5k in `src/`; measured at this checkpoint is + **13,639** lines across `src/**/*.ts{,x}` (client 6,870; server 5,906; shared + 343; browser 275; build 245). The difference is a measurement-scope detail, + not a code change; the measured figure is what the tree contains now. + +## 2. Baseline results (WORK-000, recorded) + +Host: Windows, Node 26.4.0, npm 11.17.0. + +| Command | Result | Evidence | +| ------------------- | -------------------- | ------------------------ | +| `npm run typecheck` | PASS (clean) | `baseline-typecheck.log` | +| `npm run lint` | PASS (clean) | `baseline-lint.log` | +| `npm test` | 348/351 pass; 3 fail | see below | +| `npm run build` | PASS | — | + +3 failures, all `tests/telemetry.test.ts`, each `Test timed out in 5000ms` +(~5.0s). Cause: subprocess `execFile('node', ['--import','tsx', ...])` takes +4.0–5.0s on this host vs Vitest's 5000ms `testTimeout`; siblings pass at +4.0–4.5s. Pre-existing host timing flake; passes on Linux CI. Not a logic +defect. See [TEST-PLAN.md](./TEST-PLAN.md) §3. + +CI (`.github/workflows/ci.yml`) runs knip (report-only), knip production +(report-only), check-format, lint, typecheck, test, build on Node 24. Not run in +this checkpoint (WORK-009 will record CI evidence). + +## 3. Current phase + +**Phase: planning / decoupling not yet implemented.** + +The documentation set (WORK-001) and the baseline (WORK-000) are the only +completed pieces. All implementation work orders (WORK-003 onward) are PLANNED +or IN PROGRESS as noted below. This checkpoint deliberately marks implementation +items as not done: no decoupling code has been written or verified in this +repository at this time. + +## 4. Work order status + +| Work order | Objective | Status | +| ---------- | -------------------------------------- | ----------------------------------------------------- | +| WORK-000 | Baseline (typecheck/lint/test/build) | **DONE** | +| WORK-001 | Documentation set in `docs/fork/` | **IN PROGRESS** (authored; must track implementation) | +| WORK-002 | Issue/PR migration | PLANNED | +| WORK-003 | Postgres conversation store + runner | PLANNED | +| WORK-004 | SSE runtime wiring + telemetry removal | PLANNED | +| WORK-005 | Self-hosted Slack | PLANNED | +| WORK-006 | Local learning/skills store | PLANNED | +| WORK-007 | LLM provider configuration | PLANNED | +| WORK-008 | VPS deployment | PLANNED | +| WORK-009 | Tests + evidence | PLANNED | +| WORK-010 | Security review + audit | PLANNED | + +No implementation work order is claimed DONE. Where this document set describes +new modules (`src/server/pg.ts`, `conversation-store.ts`, `agent-runner.ts`, +`learning-store.ts`) or a rewritten `src/server/slack-channel.ts`, those are +**designed targets**, not existing code. + +### What exists today (DONE, upstream-retained) + +- `npm run typecheck`, `npm run lint`, `npm run build` all pass. +- `npm test` passes except the 3 predocumented telemetry timeouts. +- Auth (owner token, `APP_ORIGIN`, `sec-fetch-site`), runtime-scope validation, + page/document editing, approval workflows, MCP and Composio integrations, + computers, browser isolation, voice, scheduling, and shutdown handling are all + present and tested (see [FEATURE-PARITY.md](./FEATURE-PARITY.md)). +- Browser service `/health` exists; the app server has no health endpoint yet. + +### What does not exist yet (must be built) + +- PostgreSQL store, runner, migrations (`pg.ts`, `conversation-store.ts`, + `agent-runner.ts`). +- SSE-mode runtime wiring; `HttpAgent` server turns; telemetry removal. +- Self-hosted Slack receiver; local skills store. +- App health/readiness endpoints; Postgres compose service; validated + backup/restore. +- Docker images built and a compose smoke pass. + +## 5. Blockers and risks + +| # | Blocker / risk | Impact | Mitigation | +| --- | --------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------- | +| B1 | Implementation work orders are not started | The fork does not yet satisfy "no CopilotKit key" | Sequence WORK-003 → 004 → 005/006 → 008 → 009 → 010 | +| B2 | Windows-host telemetry flake | Local `npm test` not fully green | Do not mask globally; confirm on Linux CI; document per `TEST-PLAN.md` §3 | +| B3 | No Postgres service in `compose.yml` yet | Cannot validate deployment/backups | WORK-008 adds it before any deployment claim | +| B4 | Backups span two stores with no shared transaction | Restore can produce dangling references | Paired backup/restore + reconciliation (DEPLOYMENT.md §8) | +| B5 | New attack surface (Slack webhook, Postgres, health) unreviewed | Security gate unmet | WORK-010 before release; blocking per DEFINITION-OF-DONE §11 | +| B6 | Upstream drift could reintroduce hosted surfaces | Seam table could go stale | Re-audit the seam table on each upstream merge | + +## 6. Next actions + +1. Start WORK-003 (Postgres store + runner) with the schema in + [DATA-MODEL.md](./DATA-MODEL.md) and the tests in + [TEST-PLAN.md](./TEST-PLAN.md) §5.1–5.2. +2. Then WORK-004 (SSE wiring + telemetry removal). +3. Then WORK-005 and WORK-006 (Slack, learning) in parallel. +4. Then WORK-007, WORK-008, WORK-009, WORK-010. +5. Update this checkpoint and + [DEFINITION-OF-DONE.md](./DEFINITION-OF-DONE.md) as each item flips, with an + evidence artifact for every DONE. + +## 7. Verified status (2026-11-05, supersedes §5) + +Branch `fork/decouple-and-harden` @ `6f310a2`, pull request +[#103](https://github.com/KayzenRoot/nexbot/pull/103) (80 files, +7561/−1482). + +| Work order | Status | Evidence | +| -------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| WORK-003 Postgres store + runner | DONE | `conversation-store.test.ts`, `agent-runner.test.ts` against PostgreSQL 17 | +| WORK-004 SSE runtime + telemetry | DONE | no `CopilotKitIntelligence`/`IntelligenceAgent`/`copilotkit.ai` in `src/`; browser probe recorded zero `copilotkit.ai` requests | +| WORK-005 self-hosted Slack | DONE, **verified live** | signed event → agent → "SLACK OK" in a real channel; unsigned request → 401 | +| WORK-006 local skills | PARTIAL | `learning-store.test.ts` passes; **no editor UI or HTTP route** | +| WORK-007 providers | DONE for chat | live turn via OpenCode Go `deepseek-v4.1-flash`; **voice still pinned to `api.openai.com`** | +| WORK-008 deployment | DONE (host); **VPS unproven** | image built, stack healthy, data survived container restart, and a full backup/restore round-trip destroyed and recovered a conversation | +| WORK-009 evidence | PARTIAL | logs in the operator's `evidence/` directory, not committed | +| WORK-010 security review | DONE, verdict **CORRECTION REQUIRED** | [SECURITY-AUDIT.md](./SECURITY-AUDIT.md); production audit 1 critical → 0 | + +Gates observed at that commit: `typecheck`, `lint`, `check-format`, `build` +clean; `npm test` 388 passed / 60 files with `NEXBOT_TEST_DATABASE_URL`, and +Postgres suites self-skip without it. + +Live proof: one AG-UI turn returned `RUN_STARTED → TEXT_MESSAGE_* → +RUN_FINISHED` with the expected assistant text, both on the host and inside the +`nexbot-app` container; the thread and its messages were re-read from +PostgreSQL after a server restart and after a container restart. + +### Backup and restore, round-trip (2026-11-05) + +`deployment/backup.sh` produced a quiesced pair (`nexbot.pgdump` custom +format, `nexbot.sqlite`, `MANIFEST.txt`). A conversation was then deleted +directly from PostgreSQL (3 → 2 conversations, 3 → 2 runs), and +`deployment/restore.sh` was run: it stopped the app, dropped and recreated the +database, restored both stores, restarted the app and reported readiness. The +deleted conversation and its runs were back (3/3), and a live agent turn +afterwards returned the expected text. + +That run found two real defects in `restore.sh`, both fixed in `70eab56`: the +helper could not unlink the SQLite file (all capabilities dropped) and then +could not `chmod` it after `chown`. Both were only observable by executing the +script, which is why the round-trip is part of the gate. + +### Dependency remediation (2026-11-05) + +Production tree: 1 critical → **0**, 40 → 38 total. `dompurify` pinned to +3.4.17 (critical sanitiser bypass), `zod` to 4.6.7 (memory-exhaustion DoS, root +of most transitive highs), `@modelcontextprotocol/sdk` to 1.32.2. `npm audit +fix` was deliberately reverted because it also moved `@copilotkit/*` from +1.75.0 to 1.84.1, which changes AG-UI message types and breaks typecheck; that +upgrade needs its own tested change. Remaining highs are transitive and +untouched paths — see [SECURITY-AUDIT.md](./SECURITY-AUDIT.md) F5–F8. + +### Slack, verified live (2026-11-05) + +The self-hosted Slack integration was exercised against a real workspace +(Nexlabs, `T0CF2380YQM`, channel `#geral`). App `NexBot` (`A0CETPAP5B9`) was +created from `deployment/slack-app-manifest.json`, installed, and added to the +channel. `setup().slack` reported `online`. + +A correctly signed `app_mention` event was delivered to +`POST /api/slack/events` through a public tunnel and returned `200 {"ok":true}`; +the agent then ran and posted **"SLACK OK"** back to the channel. This covers +signature verification, the team/user allowlist, the agent turn and the reply. +An unsigned request to the same route returned `401`. + +Not covered by that run: Slack's own retry/redelivery behaviour, and the +production Request URL (the tunnel is ephemeral; the VPS must use its own +domain). + +### Remaining blockers + +- **B7** _(resolved — see above)_ Slack was verified end to end against a live + workspace. +- **B8** Voice Realtime is OpenAI-only (`src/server/voice.ts`). +- **B9** The bundled CopilotKit web-inspector still carries its telemetry URL + constant. No request to it was observed, but it is not provably dead code. +- **B10** Docker's `COMPOSE_PROJECT_NAME` was inherited as `hive-v102`; builds + must pass `-p nexbot` to avoid joining an unrelated project. diff --git a/docs/fork/CUSTOMER-GUIDE.md b/docs/fork/CUSTOMER-GUIDE.md new file mode 100644 index 00000000..d7511a80 --- /dev/null +++ b/docs/fork/CUSTOMER-GUIDE.md @@ -0,0 +1,119 @@ +# NexBot — customer guide + +From download to a working agent. Written for the person installing it, not for +a developer. + +## 1. What you need before you start + +- A Windows computer (or a Linux server for the hosted setup). +- **Docker Desktop** installed and running. NexBot runs as containers; nothing + else needs to be installed. +- An **API key from a model provider**. Any OpenAI-compatible service works: + OpenCode Go, OpenAI, OpenRouter, Groq, a local Ollama, and others. +- Optional: a Slack workspace, if you want to talk to your agents from Slack. + +## 2. Install + +1. Run `NexBot-Setup.exe` and accept the prompts. + It installs into your user folder, so no administrator password is needed. +2. When it asks for your provider details, you can either fill them in now or + leave them blank and do it later inside the app: + - **Base URL** — your provider's endpoint, e.g. `https://opencode.ai/zen/go/v1` + - **Model** — the model name, e.g. `deepseek-v4.1-flash` + - **API key** — the key from your provider +3. At the end it prints an **access token**. Write it down: it is the password + for your installation and it is not shown again. +4. NexBot opens in its own window, and a shortcut appears on your Desktop. + +If you skipped the provider questions, open **Settings & setup** inside the app, +fill in the three fields and press **Test connection**, then **Save**. Nothing is +saved unless your provider accepts the key, so a typo cannot silently break every +conversation. + +## 3. Your first agent + +1. In the left sidebar, create a **Dot** — that is one agent with its own + instructions and permissions. +2. Give it a role, for example: _"You are my research assistant. Be concise and + cite sources."_ +3. Decide what it may do: browse the public web, remember things, use connected + apps. Everything is off unless you allow it. +4. Type a message and send it. The first reply confirms your provider works. + +**Spaces** group pages and agents around a subject. **Recent chats** keeps your +conversations; they are stored on your machine and stay there. + +## 4. Talking to an agent from Slack (optional) + +1. Create a Slack app from `deployment/slack-app-manifest.json` (in the + installation folder) and install it to your workspace. +2. Copy the **Bot Token** and **Signing Secret** into the `.env.local` file in + the installation folder. +3. Add your Slack member ID to `SLACK_USER_IDS`. Only IDs listed there can + trigger an agent — this is what stops anyone else in the workspace from + spending your model budget. +4. Invite the bot to a channel and mention it. + +Slack requires a publicly reachable HTTPS address. On a laptop that means a +tunnel, which is fragile; for daily use, run NexBot on a server with a domain. +See [DEPLOYMENT.md](./DEPLOYMENT.md). + +## 5. Backups + +Two things hold your data and they must be kept together: + +- **PostgreSQL** — conversations and messages; +- **SQLite** — Spaces, Dots, pages and connections. + +`deployment/backup.sh` copies both into one timestamped folder. Run it on a +schedule and copy the result somewhere else — a backup that only exists on the +same disk is not a backup. `deployment/restore.sh` puts it back. + +## 6. Keeping it running + +- NexBot starts automatically when you log in. +- A small **watchdog** runs alongside it: if the app stops answering, it + restarts the stack once. Its log is in `logs/watchdog.log` in the + installation folder. +- To stop or check it manually: + + ```powershell + powershell -File deployment\windows\stop.ps1 + powershell -File deployment\windows\status.ps1 + ``` + +## 7. Common questions + +**Where is my data?** +In Docker volumes on this machine. Nothing is sent to us — the only external +service is the model provider you configured, plus Slack if you enabled it. + +**Which provider should I choose?** +Whichever you already pay for. The app does not prefer any of them. If you want +to run models locally, install Ollama and use `http://localhost:11434/v1`. + +**The app says "Setup required".** +`/api/ready` names the missing configuration in the response body. Usually it is +the provider key: open Settings and save it again. + +**I lost my access token.** +It is in `.env.local` (the `OWNER_TOKEN` line) inside the installation folder. + +**SmartScreen blocked the installer.** +The installer is not code-signed yet. Choose "More info" then "Run anyway", or +ask for a signed build. + +**Something is broken and I want to start over.** +Stop the stack, then delete the volumes `nexbot_nexbot-data` and +`nexbot_nexbot-pgdata` with `docker volume rm`. This erases all conversations +and settings permanently. + +## 8. Getting help + +Include this when you ask for support: + +- the output of `powershell -File deployment\windows\status.ps1` +- the last lines of `logs/watchdog.log` +- what you did just before the problem appeared + +Never send your `.env.local`: it contains your provider key and access token. diff --git a/docs/fork/DATA-MODEL.md b/docs/fork/DATA-MODEL.md new file mode 100644 index 00000000..043320bb --- /dev/null +++ b/docs/fork/DATA-MODEL.md @@ -0,0 +1,248 @@ +# Data Model + +This fork uses **two datastores** (accepted decision ADR-0003): + +- **PostgreSQL** — conversations only: threads, messages, runs, streamed + events, thread state, and the local learning/skills store. +- **SQLite** (`node:sqlite` `DatabaseSync`, WAL, default + `data/nexbot.sqlite`) — everything else: workspace, pages, tasks, memories, + MCP connections, computer permissions, settings, setup telemetry. + +Section 1 defines the PostgreSQL schema (PLANNED, WORK-003/WORK-006). Section 2 +lists the SQLite tables that stay local (DONE, retained from upstream). + +--- + +## 1. PostgreSQL conversation schema (PLANNED — WORK-003) + +Managed by `src/server/pg.ts` (pool + idempotent versioned migrations) and +`src/server/conversation-store.ts`. All migrations are applied in order and +recorded in `schema_migrations`; applying them twice is a no-op. + +### 1.1 Conventions + +- `id` columns are `text` (UUID strings generated by the app), matching the + existing app convention rather than Postgres `uuid`, so values are directly + comparable to the SQLite `thread_bindings.id` and client-supplied IDs. +- Timestamps are `bigint` epoch **milliseconds** (`created_at` etc.), matching + the SQLite integers, not `timestamptz`. This keeps one representation across + both stores. (If native timestamps are preferred, this is an ADR-revisable + choice; it is recorded as epoch ms for fidelity.) +- `owner_id` scopes every conversation row to the workspace owner. + +### 1.2 `schema_migrations` + +| Column | Type | Constraints | +| ------------ | --------- | ------------- | +| `version` | `integer` | `PRIMARY KEY` | +| `name` | `text` | `NOT NULL` | +| `applied_at` | `bigint` | `NOT NULL` | + +Index: primary key on `version`. Migrations are applied in a transaction; a +failed migration leaves the database at the last successful version. + +### 1.3 `conversations` + +One row per conversation (upstream "thread"). + +| Column | Type | Constraints / notes | +| ------------------------ | --------- | ------------------------------------------------------------------- | +| `id` | `text` | `PRIMARY KEY` | +| `owner_id` | `text` | `NOT NULL` — workspace owner | +| `dot_id` | `text` | `NOT NULL` — specialist agent (Dot) | +| `title` | `text` | `NOT NULL` | +| `learning_container_id` | `text` | `NULL` — learned-skills scope (local, replaces hosted container id) | +| `skill_delivery_enabled` | `boolean` | `NOT NULL DEFAULT false` | +| `archived_at` | `bigint` | `NULL` — set by thread archive | +| `created_at` | `bigint` | `NOT NULL` | +| `updated_at` | `bigint` | `NOT NULL` | + +Indexes: + +- `conversations_pkey` on `(id)`. +- `conversations_owner_updated` on `(owner_id, updated_at DESC)` — conversation + list ordering. +- `conversations_dot` on `(dot_id)`. + +### 1.4 `messages` + +One row per persisted user/assistant/tool message. + +| Column | Type | Constraints / notes | +| ----------------- | -------- | ------------------------------------------------------------ | +| `id` | `text` | `PRIMARY KEY` | +| `conversation_id` | `text` | `NOT NULL REFERENCES conversations(id) ON DELETE CASCADE` | +| `seq` | `bigint` | `NOT NULL` — monotonic per conversation; ordering key | +| `role` | `text` | `NOT NULL` — `user` \| `assistant` \| `tool` \| `system` | +| `content` | `jsonb` | `NOT NULL` — string or AG-UI content parts | +| `name` | `text` | `NULL` — tool name when `role='tool'` | +| `tool_call_id` | `text` | `NULL` | +| `metadata` | `jsonb` | `NULL` — e.g. `nexbotSource` (voice receipt, scheduled task) | +| `created_at` | `bigint` | `NOT NULL` | + +Indexes: + +- `messages_pkey` on `(id)`. +- `messages_conversation_seq` unique on `(conversation_id, seq)` — ordering and + idempotency of appends. +- `messages_tool_call` on `(conversation_id, tool_call_id)` where + `tool_call_id IS NOT NULL` — tool-result lookup. + +The `getThreadMessages` part of the `PageIntelligence` seam reads this table +(plus `messages.metadata`) to build conversation→page saves. + +### 1.5 `thread_runs` + +One row per agent run (a single turn execution). + +| Column | Type | Constraints / notes | +| ----------------- | -------- | ------------------------------------------------------------------- | +| `id` | `text` | `PRIMARY KEY` | +| `conversation_id` | `text` | `NOT NULL REFERENCES conversations(id) ON DELETE CASCADE` | +| `agent_id` | `text` | `NOT NULL` — Dot id | +| `status` | `text` | `NOT NULL` — `running` \| `completed` \| `failed` \| `cancelled` | +| `error` | `text` | `NULL` — sanitized failure class only (never raw provider messages) | +| `started_at` | `bigint` | `NOT NULL` | +| `finished_at` | `bigint` | `NULL` | + +Indexes: + +- `thread_runs_pkey` on `(id)`. +- `thread_runs_conversation_started` on `(conversation_id, started_at DESC)`. +- `thread_runs_status` on `(status)` where `status='running'` — recovery of + interrupted runs on boot. + +### 1.6 `thread_events` + +Append-only AG-UI event log, used to back `getThreadEvents` and to rebuild the +client stream after reconnect. + +| Column | Type | Constraints / notes | +| ----------------- | -------- | --------------------------------------------------------- | +| `id` | `bigint` | `GENERATED ALWAYS AS IDENTITY PRIMARY KEY` | +| `conversation_id` | `text` | `NOT NULL REFERENCES conversations(id) ON DELETE CASCADE` | +| `run_id` | `text` | `NULL REFERENCES thread_runs(id) ON DELETE SET NULL` | +| `seq` | `bigint` | `NOT NULL` — monotonic per conversation | +| `event` | `jsonb` | `NOT NULL` — the AG-UI event payload | +| `created_at` | `bigint` | `NOT NULL` | + +Indexes: + +- primary key on `(id)`. +- `thread_events_conversation_seq` unique on `(conversation_id, seq)`. +- `thread_events_run` on `(run_id)`. + +### 1.7 `thread_state` + +Key/value snapshots of agent state per conversation (backing +`getThreadState`). + +| Column | Type | Constraints / notes | +| ----------------- | -------- | --------------------------------------------------------- | +| `conversation_id` | `text` | `NOT NULL REFERENCES conversations(id) ON DELETE CASCADE` | +| `key` | `text` | `NOT NULL` | +| `value` | `jsonb` | `NOT NULL` | +| `updated_at` | `bigint` | `NOT NULL` | + +Keys: `PRIMARY KEY (conversation_id, key)`. Upsert on write; `getThreadState` +assembles the current key/value map. + +### 1.8 `skills` (PLANNED — WORK-006) + +Local per-Dot learned skills, replacing hosted learning containers and +`getLearnedSkillsSnapshots`. + +| Column | Type | Constraints / notes | +| ------------------ | --------- | ------------------------------------------------------------------------------- | +| `id` | `text` | `PRIMARY KEY` | +| `owner_id` | `text` | `NOT NULL` | +| `dot_id` | `text` | `NOT NULL` — skill belongs to one Dot | +| `scope_key` | `text` | `NOT NULL` — the conversation's `learning_container_id` or a stable per-Dot key | +| `name` | `text` | `NOT NULL` | +| `content` | `jsonb` | `NOT NULL` — skill body/definition | +| `revision` | `integer` | `NOT NULL DEFAULT 1` | +| `delivery_enabled` | `boolean` | `NOT NULL DEFAULT true` | +| `created_at` | `bigint` | `NOT NULL` | +| `updated_at` | `bigint` | `NOT NULL` | + +Indexes: + +- `skills_pkey` on `(id)`. +- `skills_dot_scope` on `(dot_id, scope_key)`. +- `skills_scope_delivery` on `(scope_key)` where `delivery_enabled` — snapshot + query for a turn. + +### 1.9 Retention and cleanup notes + +- `thread_events` is the fastest-growing table; retention is policy-driven + (PLANNED, see SCOPE deferred items). A practical default is: keep events for + the N most recent runs per conversation, or for a fixed window (e.g. 90 days), + and compact older events into the corresponding `messages`. The schema + supports `DELETE ... WHERE created_at < threshold` with no cascade damage. +- `messages`, `thread_runs`, `thread_state` retention follows the conversation; + deleting a conversation cascades. +- `conversations.archived_at` marks archives without deleting rows; archived + conversations are excluded from the default list but remain queryable. +- Vacuum/analyze: rely on Postgres autovacuum; `thread_events` benefits from a + tuned autovacuum scale factor once volume is known. + +### 1.10 Migration and backup implications + +- Migrations are idempotent and versioned; a clean install and an existing + database converge to the same schema (OPS-006). +- The Postgres data volume is separate from the SQLite volume; both must be + included in backup/restore (see [DEPLOYMENT.md](./DEPLOYMENT.md)). + +--- + +## 2. SQLite tables that remain local (DONE — retained) + +`node:sqlite` `DatabaseSync`, WAL mode, `busy_timeout=5000`, default path +`data/nexbot.sqlite` (`DATABASE_PATH`). Created idempotently at boot; additive +columns are applied with `ALTER TABLE` guarded by `PRAGMA table_info`. + +| Table | Columns | Source | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| `spaces` | `id` PK, `name`, `description`, `createdAt` | `src/server/workspace.ts` | +| `dots` | `id` PK, `spaceId`, `name`, `instructions`, `researchAllowed`, `memoryAllowed`, `createdAt`, `learningContainerId`, `skillDeliveryEnabled`, `appsAllowed`, `composioToolkits` | `src/server/workspace.ts` | +| `thread_bindings` | `id` PK, `dotId`, `ownerId`, `title`, `createdAt`, `learningContainerId` | `src/server/workspace.ts` | +| `pages` | `id` PK, `spaceId`, `parentId`, `title`, `content`, `revision`, `createdAt`, `updatedAt`, `sourceThreadId` | `src/server/pages.ts` | +| `page_reviews` | `threadId`, `toolCallId`, `pageId`, `spaceId`, `draft`, PK`(threadId,toolCallId)` | `src/server/pages.ts` | +| `page_threads` | `pageId`, `dotId`, `threadId` UNIQUE, `ready`, `leaseUntil`, PK`(pageId,dotId)` | `src/server/pages.ts` | +| `tasks` | `id` PK, `prompt`, `status`, `intervalSeconds`, `nextRunAt`, `createdAt`, `updatedAt`, `error`, `lease`, `leaseUntil` | `src/server/store.ts` | +| `runs` | `id` PK, `taskId`, `status`, `startedAt`, `finishedAt`, `result`, `error` | `src/server/store.ts` | +| `events` | `id` PK AUTOINCREMENT, `taskId`, `runId`, `text`, `createdAt` | `src/server/store.ts` | +| `memories` | `id` PK, `text`, `createdAt` | `src/server/store.ts` | +| `mcp_connections` | `id` PK, `dotId`, `name`, `url`, `token`, `tools`, `error`, `createdAt`, `updatedAt` | `src/server/connection-store.ts` | +| `mcp_approvals` | `id` PK, `threadId`, `dotId`, `connectionId`, `tool`, `arguments`, `createdAt` | `src/server/connection-store.ts` | +| `mcp_actions` | `threadId`, `toolCallId`, `connectionId`, `tool`, `status`, `result`, `createdAt`, `claimedAt`, `approvalId`, PK`(threadId,toolCallId)` | `src/server/connection-store.ts` | +| `computer_permissions` | `dotId` PK, `value` | `src/server/computer-store.ts` | +| `computer_audit` | `id` PK, `dotId`, `action`, `actor`, `outcome`, `createdAt` | `src/server/computer-store.ts` | +| `settings` | `id` PK CHECK`(id=1)`, `value` (JSON) | `src/server/store.ts` | +| `setup_telemetry` | `id` PK CHECK`(id=1)`, `value` (JSON) | `src/server/store.ts` | + +Additional SQLite tables present in the same database (documented for +completeness, also retained): `task_threads`, `calls` (+ `anchorMessageId`), +`captures`, `dot_spaces`. + +Indexes present (SQLite): `tasks_due(status, nextRunAt)`, +`runs_task(taskId, startedAt)`, `events_task(taskId, id)`. + +### 2.1 Relationship between the two stores + +The two stores are joined by **application-level ID references only**, not by +database foreign keys: + +- `thread_bindings.id` (SQLite) is the conversation/thread id; the corresponding + `conversations.id` lives in Postgres. The runtime and the workspace store agree + on this id (it is generated once and passed to both). +- `page_threads.threadId` (SQLite) references the same conversation id. +- `mcp_approvals.threadId`, `mcp_actions.threadId`, and `task_threads.threadId` + (SQLite) reference the same conversation id. +- `computer_audit` and `calls.threadId` likewise. + +This means: **a Postgres restore and a SQLite restore must be consistent with +each other.** Restoring one without the other can leave bindings pointing at +conversations that no longer exist (or vice versa). The backup/restore +procedure in [DEPLOYMENT.md](./DEPLOYMENT.md) treats them as one recovery unit +and specifies the reconciliation query. diff --git a/docs/fork/DECISIONS.md b/docs/fork/DECISIONS.md new file mode 100644 index 00000000..8d2e8b60 --- /dev/null +++ b/docs/fork/DECISIONS.md @@ -0,0 +1,201 @@ +# Decisions (ADRs) + +Decision ledger for the decoupled fork. Date for all entries: **2026-11-05**. +Each entry: ID, title, status, context, decision, consequences, alternatives +considered. + +Status values: Accepted, Superseded, Deprecated. + +--- + +## ADR-0001 — Fork strategy: GitHub fork preserving upstream relationship + +- **Status:** Accepted. +- **Context:** NexBot is MIT-licensed (Copyright (c) Atai Barkai). The owner + needs to change the runtime's reliance on CopilotKit-hosted services while + keeping the ability to pull upstream fixes. A clean-room rewrite would discard + history and attribution and make future merges impractical. +- **Decision:** Continue as a GitHub fork (`KayzenRoot/nexbot`) of + `CopilotKit/OpenDots`, preserving upstream commit history and the upstream + `LICENSE`. Upstream HEAD at fork time: `d4214aff10f066911f3253cb3cb870764cd51a9f`. +- **Consequences:** Upstream commits can be merged; MIT attribution is + preserved; the fork's own commits are clearly downstream. Divergence must be + managed deliberately (the seam table in + [ARCHITECTURE.md](./ARCHITECTURE.md) §4 is the contract to re-audit on + merges). +- **Alternatives:** (a) clean-room rewrite — rejected: loses history and + attribution, high cost; (b) vendoring upstream as a submodule — rejected: + poorer fork/PR workflow; (c) staying on upstream and only configuring — + rejected: cannot remove the hosted dependency. + +--- + +## ADR-0002 — Decouple hosted services but retain MIT local libraries + +- **Status:** Accepted. +- **Context:** The upstream CopilotKit surface mixes hosted/proprietary pieces + (`CopilotKitIntelligence`, `IntelligenceAgentRunner`, runtime Intelligence + mode, `IntelligenceAgent`, `@copilotkit/channels`, hosted learning containers, + telemetry) with MIT, local-only libraries (`@copilotkit/runtime`, + `@copilotkit/react-core`). +- **Decision:** Replace the hosted pieces; **retain** `@copilotkit/runtime` and + `@copilotkit/react-core` (MIT, v1.75.0) as local-only libraries. They drive + the open AG-UI protocol against our own endpoint, require no account/key, and + make no call to a CopilotKit host. +- **Consequences:** A surgical decoupling rather than a full excision. The + decoupling contract is the seam table. `@copilotkit/*` appears in + `package.json` but is not a hosted dependency. +- **Alternatives:** (a) full excision of all `@copilotkit/*` — rejected: forces + rewriting the client and protocol plumbing with no security or operational + benefit; (b) leave everything and only document — rejected: fails the core + requirement. + +--- + +## ADR-0003 — PostgreSQL for conversations only + +- **Status:** Accepted. +- **Context:** Conversations need concurrent, durable, append-heavy persistence + and local REST thread endpoints. The rest of the application (workspace, + pages, tasks, memories, MCP, computers, settings) is fine on the existing + `node:sqlite` store and moving it all increases blast radius. +- **Decision:** PostgreSQL stores conversations only: `conversations`, + `messages`, `thread_runs`, `thread_events`, `thread_state`, plus the `skills` + store and `schema_migrations`. All other state remains in SQLite + (`data/nexbot.sqlite`). +- **Consequences:** Two datastores, joined by application-level IDs only (see + [DATA-MODEL.md](./DATA-MODEL.md) §2.1). No shared transaction; backups and + restores must be paired. Contained blast radius for the migration. +- **Alternatives:** (a) everything in Postgres — rejected: larger migration, + more risk; (b) everything in SQLite — rejected: concurrency/scale and a + credible local thread backend are better served by Postgres; (c) SQLite for + threads too — the hosted store's replacement should use a real server DB. + +--- + +## ADR-0004 — SSE runtime mode with LocalThreadEndpointRunner instead of Intelligence mode + +- **Status:** Accepted. +- **Context:** Upstream constructs `CopilotRuntime` in Intelligence mode (needs + `intelligence:` and a key) and uses a hosted realtime runner. The client's + `useThreads()` depends on runtime REST thread endpoints. +- **Decision:** Use `CopilotRuntime` in **SSE mode** (`CopilotSseRuntime`): + options take `runner?: AgentRunner` and `intelligence?: undefined` / + `channels?: undefined`. Implement `PostgresAgentRunner extends AgentRunner +implements LocalThreadEndpointRunner` (with `ɵsupportsLocalThreadEndpoints: +true`). Server-side turns use `HttpAgent` (`@ag-ui/client`) against our own + `/api/copilotkit/run`, and the `z.literal('intelligence')` gate is removed. +- **Consequences:** MIT runtime mode, no key. The REST thread endpoints the + client needs are served locally, so the client is unchanged. Evidence and + rationale in [ARCHITECTURE.md](./ARCHITECTURE.md) §5. +- **Alternatives:** (a) keep Intelligence mode — rejected: requires the hosted + service; (b) a non-CopilotKit runtime — rejected: larger change; SSE mode + already provides the needed contract; (c) rewrite the client to not use thread + REST endpoints — rejected: unnecessary risk. + +--- + +## ADR-0005 — Reimplement Slack self-hosted + +- **Status:** Accepted. +- **Context:** Upstream Slack uses `@copilotkit/channels` `createChannel`, which + requires Intelligence mode. Dropping Slack would be a parity regression. +- **Decision:** Rewrite `src/server/slack-channel.ts` to use the Slack Web API + (bot token) plus a Slack Events API receiver with HMAC signing-secret + verification, registered as its own Hono route (`/api/slack/events`), invoking + the same local agent runner. Preserve explicit team + user allowlisting. +- **Consequences:** New webhook attack surface to secure and review (WORK-010); + needs a public HTTPS endpoint. Full parity for the Slack channel without a + hosted product. +- **Alternatives:** (a) drop Slack — rejected: parity loss; (b) keep managed + channel — rejected: requires the hosted service. + +--- + +## ADR-0006 — Reimplement learning locally + +- **Status:** Accepted. +- **Context:** Upstream learning uses hosted learning containers and + `getLearnedSkillsSnapshots`, gated by `learnedSkills: { containers, apiKey, +apiUrl }`. Skills are per-Dot data the owner can store locally. +- **Decision:** Implement `src/server/learning-store.ts` and the `skills` table: + per-Dot skills with revisions and a delivery enabled/disabled flag, scoped by + the conversation's `learning_container_id`/per-Dot key. Replace the hosted + snapshot fetch; rewrite `learningSelector`. +- **Consequences:** Local, inspectable, owner-owned learning. Delivery semantics + must be preserved and tested ([TEST-PLAN.md](./TEST-PLAN.md) §5.4). +- **Alternatives:** (a) drop learning — rejected: parity loss; (b) keep hosted + containers — rejected: requires the hosted service. + +--- + +## ADR-0007 — Self-hosted auth with owner token + +- **Status:** Accepted. +- **Context:** The application is single-owner. Upstream already implements a + timing-safe bearer `OWNER_TOKEN`, origin/host allowlisting, `sec-fetch-site` + rejection, and runtime-scope validation. +- **Decision:** Retain and rely on the owner-token model. No per-user accounts, + SSO, or multi-tenant identity in this fork. +- **Consequences:** Single-owner only, as upstream `SECURITY.md` already scopes. + The owner token is the sole gate; rate limiting is external (proxy) or a + WORK-010 item. +- **Alternatives:** (a) add an identity provider — rejected: out of scope, + larger security surface; (b) rely on network isolation only — rejected: the + app can be exposed behind a proxy. + +--- + +## ADR-0008 — Remove CopilotKit telemetry + +- **Status:** Accepted. +- **Context:** Upstream sends telemetry to `https://telemetry.copilotkit.ai/ingest` + with `CPK_TELEMETRY_ID` / `COPILOTKIT_TELEMETRY_*`, plus setup reporting. +- **Decision:** Remove CopilotKit telemetry entirely. No outbound call to any + CopilotKit host at runtime. Local setup status is retained for the app's own + UI/behaviour, but it does not leave the owner's infrastructure. +- **Consequences:** No usage reporting leaves the server; `setup-telemetry.ts` + must be trimmed of the hosted telemetry client; `compose.yml` and + `.env.example` drop the related variables. +- **Alternatives:** (a) keep telemetry opt-out — rejected: the fork's guarantee + is "no outbound CopilotKit call", so opt-in residue is removed; (b) keep but + redirect — rejected: nothing to redirect to. + +--- + +## ADR-0009 — Deployment via Docker Compose on owner VPS + +- **Status:** Accepted. +- **Context:** Upstream ships a `Dockerfile` and Compose files for app and + browser, plus a separate computer-supervisor topology. The owner wants a + self-hosted VPS deployment with no hosted account. +- **Decision:** Deploy with Docker Compose on an owner-controlled VPS: app + + Postgres core, optional browser profile, optional computer supervisor + (separate compose file). TLS at a reverse proxy; only `:443` public. Backup + and restore for both datastores as one pair. +- **Consequences:** See [DEPLOYMENT.md](./DEPLOYMENT.md). Requires WORK-008 + (Postgres service, health endpoints, validated backup/restore). +- **Alternatives:** (a) bare-metal systemd — rejected: already containerized and + the browser/computer isolation depends on containers; (b) a managed PaaS — + rejected: the point is owner-controlled infrastructure; (c) Kubernetes — + rejected: overkill for a single-owner app. + +--- + +## ADR-0010 — Issue/PR migration approach and authorship limitations + +- **Status:** Accepted. +- **Context:** The fork should carry over relevant upstream issues/PRs so the + work is traceable to the community problems it resolves. +- **Decision:** Import the relevant upstream issues/PRs into the fork's tracker + (WORK-002), keeping the original issue number in the title/body and linking + the upstream URL. Because GitHub does not preserve original author identity + when an issue is recreated in another repository, each imported item must + state that it was imported from `CopilotKit/OpenDots` and must not imply the + importer wrote the original report. +- **Consequences:** Provenance is explicit; authorship is attributed to the + upstream reporter and the original URL, with the platform limitation stated. + The mapping is recorded in [BACKLOG.md](./BACKLOG.md) §3. +- **Alternatives:** (a) do not migrate, only reference by URL — weaker + traceability; (b) migrate silently — rejected: misattributes authorship; + (c) rewrite issue text — rejected: destroys provenance. diff --git a/docs/fork/DEFINITION-OF-DONE.md b/docs/fork/DEFINITION-OF-DONE.md new file mode 100644 index 00000000..5fc5b8b1 --- /dev/null +++ b/docs/fork/DEFINITION-OF-DONE.md @@ -0,0 +1,146 @@ +# Definition of Done + +Acceptance criteria for fork readiness. Each item is a checkbox with the +verification method and current status. Release is **not** declared until every +`[ ]` item is `[x]` and the two blocking conditions at the end are satisfied. + +Legend: `[x]` met, `[ ]` not yet met, `[~]` partially met. + +## 1. Fork and provenance + +- [x] **Fork exists** at `https://github.com/KayzenRoot/nexbot`. + - Verify: `git remote -v` shows `origin` as the KayzenRoot repository. + - Status: DONE. +- [x] **History preserved** from upstream (GitHub fork; upstream commits + reachable; upstream HEAD `d4214aff10f066911f3253cb3cb870764cd51a9f`). + - Verify: upstream SHA present in history; upstream commit graph intact. + - Status: DONE. +- [x] **Upstream license retained** — `LICENSE` (MIT, Copyright (c) Atai Barkai) + unchanged. + - Status: DONE. +- [ ] **Issues/PRs migrated** with provenance and authorship limitations + recorded. — WORK-002. Status: PLANNED. + +## 2. Decoupling + +- [ ] **No mandatory CopilotKit key or hosted service.** + - Verify: with a clean `.env` containing no `CPK_INTELLIGENCE_API_KEY` / + `INTELLIGENCE_API_KEY`, the app starts and completes a chat turn; + `grep -r` finds no required reference; no outbound request to a CopilotKit + host (WORK-004 egress test). + - Status: PLANNED — WORK-004. Upstream still requires Intelligence. +- [ ] **Runs fully on owner infrastructure** (Postgres, backend, agent runtime). + - Verify: deployment uses only owner Postgres, owner app, owner model + endpoint. Status: PLANNED — WORK-003/004/008. +- [ ] **Telemetry to CopilotKit removed** (no `telemetry.copilotkit.ai`, + `CPK_TELEMETRY_ID`, `COPILOTKIT_TELEMETRY_*` at runtime). Status: PLANNED — + WORK-004. +- [ ] **Parity features reimplemented self-hosted**: Slack (WORK-005) and local + skills store (WORK-006). Status: PLANNED. + +## 3. Build and static checks + +- [x] **`npm run typecheck` passes clean.** — baseline. +- [x] **`npm run lint` passes clean.** — baseline. +- [ ] **`npm run check-format` passes.** — verify before release. +- [ ] **`npm run build` passes** and produces `dist/client`, + `dist/server/{server,browser,shared}`. — baseline build passed; re-verify the + layout after WORK-003/004. +- [ ] **`npm run knip` reports no new dead exports** introduced by the fork + (report-only upstream, but no regression). — verify before release. + +## 4. Tests + +- [ ] **`npm test` passes.** + - Baseline: 348/351, 3 predocumented host flake failures in + `tests/telemetry.test.ts`. Target: no _unexplained_ failures; the 3 flake + failures are either passing on the release host/CI or explicitly accepted + with the explanation in `TEST-PLAN.md`. + - Status: PARTIAL — baseline recorded; new tests pending WORK-009. +- [ ] **New decoupled-runtime tests pass**: Postgres store, agent runner, SSE + wiring, no-CopilotKit-egress, Slack, learning store, health/readiness. + Status: PLANNED — WORK-009. +- [ ] **Agent-latency benchmark recorded** with a documented budget (TTFE/TTFM + p50/p95, persistence overhead). Status: PLANNED — WORK-009. + +## 5. Data and migrations + +- [ ] **Migrations work on a clean install** (empty Postgres → full schema, + `schema_migrations` recorded). Status: PLANNED — WORK-003. +- [ ] **Migrations work on an existing database** and are idempotent (second + apply is a no-op). Status: PLANNED — WORK-003. +- [ ] **Failed migration leaves the last good version** (transactional apply). + Status: PLANNED — WORK-003. +- [ ] **Postgres stores conversations only; SQLite stores the rest**, per the + accepted data-model split. Status: PLANNED — WORK-003. + +## 6. Auth and permissions + +- [ ] **Owner auth tested**: missing/wrong token → 401; correct token → 200. + Status: DONE (upstream `app.test.ts`); re-verify after WORK-004. +- [ ] **Origin/host allowlist tested**: cross-origin and cross-site rejected; + `APP_ORIGIN` honored. Status: DONE (upstream); re-verify. +- [ ] **Runtime scope tested**: a request cannot address another Dot's thread. + Status: DONE (upstream `runtime-scope.test.ts`); extend for new local thread + routes in WORK-004. +- [ ] **Tool/approval boundaries tested**: approval runs only its own stored + request, within TTL, only while the tool is enabled. Status: DONE (upstream); + re-verify. + +## 7. Deployment + +- [ ] **Docker starts the stack** (`docker compose up`) with app + Postgres. + Status: PLANNED — WORK-008. +- [ ] **Health checks pass** (`/api/health` 200; `/api/ready` 200 only when DBs + are reachable and migrated). Status: PLANNED — WORK-008. +- [ ] **Data survives restart** (SQLite + Postgres volumes). Status: PLANNED — + WORK-008. +- [ ] **Backup/restore validated for both stores** as one consistent pair, with + reconciliation. Status: PLANNED — WORK-008. +- [ ] **HTTPS via reverse proxy**; app, Postgres, browser, and supervisor not + publicly exposed. Status: PLANNED — WORK-008. +- [ ] **Upgrade and rollback documented and exercised.** Status: PLANNED — + WORK-008. + +## 8. Security + +- [ ] **No unresolved HIGH/CRITICAL security findings.** Status: PLANNED — + WORK-010 (blocking). +- [ ] **Dependency-vulnerability pass complete** with no unresolved + HIGH/CRITICAL. Status: PLANNED — WORK-010. +- [ ] **New attack surface reviewed** (Postgres, Slack webhook, health + endpoints). Status: PLANNED — WORK-010. +- [ ] **Rate-limiting decision recorded and applied** (app or proxy). Status: + PLANNED — WORK-010. +- [ ] **Secret handling reviewed** (no secret in logs; MCP token at rest + decision). Status: PARTIAL — `safeFailure` exists; at-rest review PLANNED. + +## 9. Documentation + +- [ ] **Docs complete**: `docs/fork/` contains OVERVIEW, REQUIREMENTS, SCOPE, + ARCHITECTURE, DATA-MODEL, API-CONTRACTS, SECURITY, TEST-PLAN, DEPLOYMENT, + BACKLOG, DEFINITION-OF-DONE, DECISIONS, FEATURE-PARITY, CHECKPOINT, README. + Status: IN PROGRESS — set authored under WORK-001; must track implementation. +- [ ] **Parity matrix complete**: every row of + [FEATURE-PARITY.md](./FEATURE-PARITY.md) has an implementation status and a + verification method that exists. Status: IN PROGRESS. +- [ ] **Decisions ledger complete**: ADR-0001..ADR-0010 recorded with context, + decision, consequences, alternatives. Status: DONE (see + [DECISIONS.md](./DECISIONS.md)); extend as new decisions arise. + +## 10. Evidence and checkpoint + +- [ ] **Evidence updated** for every work order: command output logs, test + summaries, migration output, compose smoke, benchmark. Status: PARTIAL — + baseline logs recorded; decoupling evidence PLANNED — WORK-009. +- [ ] **Checkpoint updated** to reflect actual status (no claim marked DONE + without evidence). Status: IN PROGRESS — [CHECKPOINT.md](./CHECKPOINT.md). + +## 11. Blocking conditions + +Release is blocked while **either** of the following is true: + +1. Any item above in Section 2 (Decoupling), Section 8 (Security), or the + backup/restore item in Section 7 is unmet. +2. Any claim in the documentation set is DONE without a reproducible evidence + artifact. diff --git a/docs/fork/DEPLOYMENT.md b/docs/fork/DEPLOYMENT.md new file mode 100644 index 00000000..7635b1d9 --- /dev/null +++ b/docs/fork/DEPLOYMENT.md @@ -0,0 +1,253 @@ +# Deployment + +How to run the decoupled fork on an owner-controlled VPS, and how to recover it. +**No CopilotKit account, project selection, or API key is needed.** The only +mandatory external credential is an OpenAI-compatible model endpoint. + +Status: the existing Compose files (`compose.yml`, `compose.computers.yml`, +`compose.computers-app.yml`) target the upstream app; the Postgres service, the +new env template, health checks, and the backup/restore procedures described +here are PLANNED for WORK-008 and must be added before this is a validated +procedure. + +## 1. Resource requirements + +| Component | CPU | RAM | Disk | Notes | +| ------------------------------ | ------ | ----------- | --------------------- | -------------------------------------------------------------------------------------------- | +| app | 1 vCPU | 1 GB | ~1 GB image + volumes | Hono + `node:sqlite`; the busy part is agent runs, which are mostly network-bound | +| Postgres | 1 vCPU | 512 MB–1 GB | 5–20 GB | Conversations grow with use; `thread_events` is the fast-growing table | +| browser (optional) | 1 vCPU | 1 GB | ~1 GB | Playwright chromium; `shm_size: 256mb`; `read_only` + tmpfs | +| computer supervisor (optional) | 1 vCPU | 512 MB | ~2 GB image | Each Dot computer consumes additional RAM/Disk (`COMPUTER_MEMORY_BYTES`, default 2147483648) | + +Minimum for the core stack (app + Postgres): a 2 vCPU / 2 GB VPS with 20 GB +disk. Add ~1 GB RAM + 1 GB image for the browser profile; add capacity per +computer if using persisted computers. + +## 2. Docker Compose topology + +``` + ┌──────────────────── host (VPS) ─────────────────────┐ + :443 ──► reverse proxy (Caddy/nginx/Traefik) ──► 127.0.0.1:4310 + │ + app container (4310) + │ │ + app-egress (egress) ──────┘ └── browser-link ─► browser (4311) + [profile: browser] + Postgres (5432, internal only) ◄──── app (conversation store) + computer-supervisor (4312→4300, optional) ── Docker socket (only mounter) +``` + +- `compose.yml` defines `app` (port `127.0.0.1:4310` published, volumes + `nexbot-data:/data`, networks `browser-link` + `app-egress`, + `no-new-privileges`, `cap_drop: ALL`, `restart: unless-stopped`, `init: true`) + and `browser` (profile `browser`, `read_only`, tmpfs `/tmp`, `shm_size: 256mb`, + internal `browser-link`, egress `browser-egress`). +- **PLANNED (WORK-008):** add a `postgres` service to `compose.yml` on an + internal network with a named volume, and set `DATABASE_URL` (or `PG*` + variables) for the app. The app's 4310 port should remain bound to loopback + and be fronted by the reverse proxy. +- `compose.computers.yml` builds and runs the computer supervisor (pinned to + OpenBot `b6932d31a8d6e7896c15139dfc27a6c6911deb27`), publishing + `127.0.0.1:4312:4300`, mounting the Docker socket, `cap_drop: [ALL]`, with a + `/health` healthcheck that also asserts `docker` availability. +- `compose.computers-app.yml` is an overlay for an app running in Compose: it + points the app at `http://computer-supervisor:4300` over the internal + `computer-control` network and removes the host-published supervisor port. + +## 3. Environment variable template + +`.env` (server only; never commit). Required marked **R**. Start from +`.env.example` and remove the CopilotKit-only entries after WORK-004. + +```dotenv +# --- Core --------------------------------------------------------------- +HOST=0.0.0.0 # external binding requires OWNER_TOKEN (24+ chars) +PORT=4310 +OWNER_TOKEN= # R for external binding; 24+ chars; bearer login +OWNER_ID=nexbot-owner # keep stable +APP_ORIGIN=https://dots.example.com # exact origin(s), comma-separated +DATABASE_PATH=/data/nexbot.sqlite # SQLite (app state) + +# --- PostgreSQL (conversations) --- PLANNED WORK-003/008 ----------------- +DATABASE_URL=postgres://nexbot:***@postgres:5432/nexbot +# or discrete PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE +PGSSLMODE=require # depends on network placement + +# --- Model endpoint REQUIRED for agent runs ----------------------------- +OPENAI_BASE_URL=https://api.openai.com/v1 # or Ollama/vLLM/LM Studio/... +OPENAI_API_KEY= +OPENAI_MODEL= +AGENT_RUN_TIMEOUT_MS=90000 + +# --- Optional: voice ---------------------------------------------------- +VOICE_API_KEY= +VOICE_MODEL= +VOICE_NAME=marin + +# --- Optional: web search ---------------------------------------------- +WEB_SEARCH_PROVIDER=parallel # parallel | browser | disabled +PARALLEL_API_KEY= + +# --- Optional: isolated browser (needed only for provider=browser) ------ +BROWSER_URL=http://browser:4311 +BROWSER_SECRET= # 24+ chars, shared by app and browser +BROWSER_HOST=0.0.0.0 +BROWSER_PORT=4311 + +# --- Optional: Composio connected apps --------------------------------- +COMPOSIO_API_KEY= + +# --- Optional: self-hosted Slack (WORK-005) ----------------------------- +SLACK_SIGNING_SECRET= +SLACK_BOT_TOKEN= +SLACK_TEAM_ID= +SLACK_USER_IDS= +SLACK_DOT_ID= + +# --- Optional: computers (OpenBot supervisor) --------------------------- +COMPUTER_SUPERVISOR_URL= +COMPUTER_SUPERVISOR_TOKEN= # 24+ chars +COMPUTER_TOKEN= # 24+ chars, master token (derivation uses HMAC) +COMPUTER_NAMESPACE=nexbot # keep stable; identifies containers/volumes +``` + +Removed by WORK-004: `CPK_INTELLIGENCE_API_KEY`, `INTELLIGENCE_API_KEY`, +`INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_WS_URL`, +`CPK_TELEMETRY_ID`, `COPILOTKIT_TELEMETRY_*`, `DO_NOT_TRACK` (kept harmless), +and the managed `SLACK_CHANNEL_NAME` in favour of the self-hosted +`SLACK_SIGNING_SECRET`/`SLACK_BOT_TOKEN`. + +## 4. HTTPS, reverse proxy, and network exposure + +- Terminate TLS at a reverse proxy (Caddy with automatic certs, nginx, or + Traefik) and proxy to `127.0.0.1:4310`. Set `APP_ORIGIN` to the public HTTPS + origin so the origin allowlist matches. +- Keep the app port bound to loopback; never publish `4310` publicly. +- Expose only `:443` (and `:80` only for ACME/redirect). Postgres, the browser + service (`4311`), and the supervisor (`4312`) must **not** be reachable from + the internet. On a cloud VPS, enforce this in the provider firewall/security + group as well as the host firewall. +- Add rate limiting and request-size limits at the proxy (the app has no + per-IP rate limiting; see [SECURITY.md](./SECURITY.md) §9). +- If you use the self-hosted Slack integration, Slack must reach the public + webhook URL (`/api/slack/events`) over HTTPS; allowlist Slack's egress IPs if + your proxy supports it. + +## 5. Health and readiness + +| Endpoint | Use | Status | +| --------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------ | +| `GET /api/health` | Liveness: process is serving. No datastore access, no auth. | PLANNED (WORK-008) | +| `GET /api/ready` | Readiness: SQLite reachable, Postgres reachable and migrated, required config present. `503` until true. | PLANNED (WORK-008) | +| `GET /health` (browser container) | Liveness for the browser service. | DONE | + +Wire `/api/health` to the container healthcheck and `/api/ready` to the reverse +proxy's upstream check. The supervisor already has a `/health` that asserts +Docker availability. + +## 6. Restart policies and lifecycle + +- `restart: unless-stopped` on app, browser, and supervisor; `init: true` on + app and browser to reap child processes. +- Postgres (PLANNED service) should use `restart: unless-stopped` and a + `pg_isready` healthcheck; the app should wait for Postgres readiness before + accepting turns (or fail `/api/ready` until then). +- The app handles `SIGTERM`/`SIGINT` via `createShutdown` + (`src/server/shutdown.ts`): stops the task runner, stops the platform + (including channels), closes the HTTP server, then exits. +- On boot, `PostgresAgentRunner` must reconcile runs left in `running` state + (mark failed/abandoned) so recovery is deterministic. + +## 7. Volumes and persistent data + +| Data | Location | Backup needed | +| ---------------------------- | -------------------------------------------------- | -------------------------------------- | +| App state (SQLite) | named volume `nexbot-data` → `/data/nexbot.sqlite` | Yes (paired with Postgres) | +| Conversations (Postgres) | PLANNED named volume → `/var/lib/postgresql/data` | Yes (paired with SQLite) | +| Computer workspaces/profiles | supervisor-managed named volumes (per Dot) | Optional; do not delete during updates | +| Browser service | none (read-only, tmpfs) | No | + +Never delete the `nexbot-data` volume or the Postgres volume during a normal +update. The supervisor notes that profile/workspace volumes must be retained +across credential changes. + +## 8. Backup and restore + +The two datastores share conversation IDs (see +[DATA-MODEL.md](./DATA-MODEL.md) §2.1), so they must be backed up and restored +as **one consistent pair**. + +### 8.1 Backup (quiesce or accept a brief window) + +1. Stop the app container (so no turns are writing): + `docker compose stop app`. +2. SQLite: copy the file **and** its WAL/SHM sidecars, or use + `sqlite3 /data/nexbot.sqlite ".backup /backup/nexbot.sqlite"` from a + helper container. +3. Postgres: `docker compose exec -T postgres pg_dump -Fc -U nexbot nexbot + > /backup/nexbot.pgdump`. +4. Start the app: `docker compose start app`. +5. Store the pair together with a timestamp and the git commit of the running + build. + +### 8.2 Restore + +1. Stop the app. +2. Restore Postgres: recreate the database and + `pg_restore -Fc -U nexbot -d nexbot /backup/nexbot.pgdump`. +3. Restore SQLite: replace `/data/nexbot.sqlite` (remove stale `-wal`/`-shm` + before restoring). +4. Start the app. +5. **Reconcile** (catch dangling references after a partial restore): + - SQLite `thread_bindings.id` not present in Postgres `conversations.id`. + - Postgres `conversations.id` with no SQLite `thread_bindings.id`. + Report and handle per policy (re-bind or archive). This reconciliation is + required by [DATA-MODEL.md](./DATA-MODEL.md) §2.1 and is part of WORK-008. + +### 8.3 Validation + +WORK-008 must demonstrate a full round-trip: seed a conversation, back up both +stores, destroy the volumes, restore, and confirm the conversation and its +messages, events, and bound pages are intact and the app reports ready. + +## 9. Upgrade and rollback + +### Upgrade + +1. `git fetch && git checkout ` (pin a commit; do not track `main` + blindly). +2. `docker compose build app` (and `browser` if changed). +3. Stop app; run Postgres migrations via the app's startup path (idempotent, + versioned — see [DATA-MODEL.md](./DATA-MODEL.md) §1.2). +4. Start app; confirm `/api/ready` returns 200. +5. Smoke test one chat turn and the conversation list. + +### Rollback + +1. Stop app. +2. `git checkout `; `docker compose build app`. +3. If migrations are forward-only, restore the Postgres backup taken before the + upgrade (Section 8) and restore SQLite only if app-state schema changed. +4. Start app; confirm `/api/ready`. + +Migrations must be additive/forward-compatible where possible so a rollback +does not require a database restore. Any non-additive migration must be +documented as such in the WORK-003 migration notes. + +## 10. Firewall and exposure summary + +| Port | Expose publicly? | +| ------------------------ | -------------------------------------- | +| 443 (proxy → app) | Yes | +| 80 (ACME/redirect) | Optional | +| 4310 (app) | No — loopback only, behind proxy | +| 5432 (Postgres) | No — internal network only | +| 4311 (browser) | No — internal network only | +| 4312 / 4300 (supervisor) | No — loopback or internal network only | + +## 11. Explicit non-requirement + +**No CopilotKit account, project, or API key is required to deploy this fork.** +The deployment uses only owner-controlled infrastructure (owner Postgres, owner +backend, owner agent runtime) plus an owner-selected OpenAI-compatible model +endpoint. diff --git a/docs/fork/FEATURE-PARITY.md b/docs/fork/FEATURE-PARITY.md new file mode 100644 index 00000000..2007e7fe --- /dev/null +++ b/docs/fork/FEATURE-PARITY.md @@ -0,0 +1,43 @@ +# Feature Parity + +Feature matrix for the fork. Columns: **Feature** | **Upstream +implementation** | **CopilotKit dependency** | **Replacement +implementation** | **Tests/verification** | **Known limitations**. + +Status is embedded in each cell as needed. "Replacement implementation" names +real files where they exist, and marks PLANNED items with their work order. + +## Matrix + +| Feature | Upstream implementation | CopilotKit dependency | Replacement implementation | Tests/verification | Known limitations | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | +| Specialist agents & agent configuration | Dots in `src/server/dot-agent.ts` + `workspace.ts`; agents supplied to the runtime as `Object.fromEntries(dots → DotAgent)` in `platform.ts` | `CopilotRuntime` agents map (`@copilotkit/runtime`), local | Same `DotAgent` construction, passed as `runner` to **SSE-mode** `CopilotRuntime` (WORK-004); per-Dot permissions (`researchAllowed`, `memoryAllowed`, `appsAllowed`, `composioToolkits`) unchanged | `workspace.test.ts`, `library.test.ts`; new `runtime-sse-wiring.test.ts` (WORK-009) | None for configuration; SSE wiring is PLANNED | +| Persistent conversations & memory | Hosted `CopilotKitIntelligence` threads/messages; `PageIntelligence` seam in `page-service.ts`; `memories` table in SQLite | **Mandatory** `CPK_INTELLIGENCE_API_KEY`/`INTELLIGENCE_API_KEY`; hosted thread store | `PostgresConversationStore` (`src/server/conversation-store.ts`, WORK-003) satisfying the existing `PageIntelligence` seam; `memories` stays in SQLite | `page-service.test.ts` (upstream); new `conversation-store.test.ts`, `pg-migrations.test.ts` | Two datastores; no shared transaction (see `DATA-MODEL.md` §2.1) | +| Spaces/Pages/document editing | TipTap editor; `pages.ts`, `page-routes.ts`, revisions, reviews, autosave | None (local, retained) | Unchanged | `pages.test.ts`, `page-routes.test.ts`, `page-review*.test.tsx`, `autosave.test.ts`, `page-snapshots.test.ts` | None | +| Human approval workflows | `connection-routes.ts` + `connection-store.ts` approvals; TTL; per-tool `requiresApproval` | None (local, retained) | Unchanged | `connections.test.ts`, `connection-card.test.tsx` | None | +| Browser/terminal/file access | Isolated Playwright browser (`src/browser/`), `WEB_SEARCH_PROVIDER=browser`; computers for terminal/file | None (local, retained) | Unchanged | `security.test.ts`, `transport.test.ts`, `parallel.test.ts`, `computer.test.ts` | Browser needs the optional container; not publicly exposable | +| Agent computers & isolation | `computer-service.ts`; OpenBot supervisor pinned `b6932d31a8d6e7896c15139dfc27a6c6911deb27`; only supervisor mounts Docker socket | None (local, retained) | Unchanged | `computer.test.ts`, `computer-routes.test.ts`, `computer-deployment.test.js`, `computer-card.test.tsx` | Optional component; per-Dot resource cost | +| MCP & external integrations | `connections.ts` (streamable-HTTP MCP), Composio (`composio.ts`); approval gating | None (local, retained) | Unchanged | `connections.test.ts`, `composio.test.ts`, `composio-tools.test.ts`, `tanstack-agent.test.ts` | MCP token stored plaintext in SQLite (WORK-010) | +| Background execution & scheduling | `runner.ts` (task worker with leases), `store.ts` tasks/runs/events; scheduled turns via `Platform.turn` | Hosted runner + `IntelligenceAgent` for server turns | Local runner (`agent-runner.ts`, WORK-003) + `HttpAgent` (`@ag-ui/client`) against own `/api/copilotkit/run` (WORK-004) | `runner.test.ts`, `agent-run-timeout.test.ts`, `store.test.ts`; new `headless-httpagent.test.ts` | Server-turn path PLANNED | +| Voice | `voice.ts` OpenAI Realtime; `ask_compute` routes to the same conversation/permissions | Hosted conversation thread for compute | Unchanged voice; compute uses `HttpAgent` server turns against the local runtime | `voice.test.ts`, `use-voice.test.ts`, `use-voice-control-poll.test.ts`, `transcript.test.tsx` | Optional; OpenAI Realtime only | +| Slack & channel integrations | `@copilotkit/channels` `createChannel` (managed), `slack-channel.ts`; identity allowlist | **Requires** Intelligence mode | Self-hosted Slack Web API + Events API, `POST /api/slack/events`, signing-secret verified (WORK-005) | `slack-channel.test.ts`, `slack-channel-wiring.test.ts`, `dot-agent-channel.test.ts` (upstream shape) → replaced by `slack-signature.test.ts`, `slack-identity-allowlist.test.ts`, `slack-run-local-agent.test.ts` | PLANNED; needs public HTTPS webhook; new attack surface (WORK-010) | +| Authentication & permissions | Owner bearer token (timing-safe), `APP_ORIGIN` allowlist, `sec-fetch-site`, `validateRuntimeScope`, per-tool/per-computer permissions | None (local, retained) | Unchanged (ADR-0007) | `app.test.ts`, `app-origin.test.ts`, `runtime-scope.test.ts`, `security.test.ts` | Single-owner only; no multi-user enforcement | +| Logging/monitoring/recovery | `console` logs with sanitized errors (`safeFailure`); task leases; `createShutdown`; setup status | CopilotKit telemetry (removed) | Telemetry removed (ADR-0008); local setup status retained; `SIGTERM`/`SIGINT` shutdown; run reconciliation on boot (WORK-003); health/readiness (WORK-008) | `shutdown.test.ts`, `setup.test.ts`, `setup-telemetry.test.ts`; new `health-ready.test.ts` | No metrics/APM beyond logs; health endpoints PLANNED | +| Automatic learning | Hosted learning containers; `getLearnedSkillsSnapshots`; `learning.ts` selector; Dot `learningContainerId`/`skillDeliveryEnabled` | **Requires** hosted containers + apiKey/apiUrl | Local skills store `src/server/learning-store.ts` + `skills` table (WORK-006); rewritten `learningSelector` | `learning.test.ts`, `learning-delivery.test.ts` (upstream shape) → `learning-store.test.ts`, `learning-replaces-containers.test.ts` | PLANNED | + +## Summary by dependency status + +| Category | Features | +| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| No CopilotKit dependency (local, retained unchanged) | Spaces/Pages editing, approval workflows, browser/terminal/file access, agent computers, MCP & external integrations, authentication & permissions | +| Decoupled — local replacement required | Persistent conversations & memory, background execution & scheduling, voice compute path, specialist agent runtime wiring | +| Reimplemented self-hosted | Slack & channel integrations, automatic learning | +| Removed | CopilotKit telemetry (no replacement; local setup status retained) | + +## Verification status legend + +- **Existing tests** (names above) cover the retained upstream behaviour and are + the parity regression net. +- **New tests** are defined in [TEST-PLAN.md](./TEST-PLAN.md) §5 and are PLANNED + under WORK-009. A feature row is not "verified" until its new test exists and + passes. diff --git a/docs/fork/LOCAL-INSTALL.md b/docs/fork/LOCAL-INSTALL.md new file mode 100644 index 00000000..00d526ce --- /dev/null +++ b/docs/fork/LOCAL-INSTALL.md @@ -0,0 +1,138 @@ +# Local installation (Windows, for testing) + +Runs the full stack on one machine: **app + PostgreSQL + reverse proxy**, with a +single stable URL. Nothing is exposed beyond loopback. + +This is the test installation. For the VPS use +[DEPLOYMENT.md](./DEPLOYMENT.md) and `deployment/Caddyfile`, which terminates +HTTPS for a real domain. + +## One-click install (desktop app) + +`deployment/windows/NexBot.exe` is a native launcher (no console window): + +- **Double-click** — checks the stack, starts it if it is down, then opens + NexBot in its **own application window** (Edge/Chrome application mode: no + address bar, its own taskbar entry). The Desktop and Start Menu shortcuts + point at it. +- **`NexBot.exe --install`** — runs the full installer: verifies Docker, + generates `.env.local` with random database and owner-token secrets, builds + the image, starts the stack, waits for readiness, installs autostart, and + creates the shortcuts. + +Rebuild the executable after editing `NexBot.cs` with +`deployment\windows\build-launcher.cmd` (uses the C# compiler that ships with +.NET Framework; no SDK needed). The binary is gitignored. + +To use a different browser, the launcher tries Edge first, then Chrome, then +falls back to the default browser. + +## What runs + +| Service | Image / build | Address | Purpose | +| ---------- | ------------------------------------ | ------------------------------------ | ---------------------------------- | +| `app` | built from this repo (`target: app`) | `127.0.0.1:4310` | Hono server + built client | +| `postgres` | `postgres:17-alpine` | internal only, **no published port** | conversations, runs, state, skills | +| `caddy` | `caddy:2-alpine` | `127.0.0.1:8080` | single entry URL, unbuffered SSE | + +Open **http://localhost:8080**. + +The direct app port `4310` stays published for debugging; both are in +`APP_ORIGIN`, so either works. + +## Requirements + +- Docker Desktop running (Compose v2). +- Node is **not** required on the host: the build happens inside the image. + +## First-time setup + +1. Create the environment file. It is gitignored and must never be committed: + + ```powershell + Copy-Item .env.example .env.local + notepad .env.local + ``` + + Minimum to fill in: + + - `DATABASE_URL` — `postgres://nexbot:@postgres:5432/nexbot` + - `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` — must match + `DATABASE_URL` + - `OWNER_TOKEN` — 24+ random characters (this is the login token) + - `APP_ORIGIN` — `http://localhost:4310,http://127.0.0.1:4310,http://localhost:8080,http://127.0.0.1:8080` + - `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `OPENAI_MODEL`, and + `OPENAI_EXTRA_HEADERS` if your gateway needs routing headers + +2. Start it: + + ```powershell + powershell -ExecutionPolicy Bypass -File deployment\windows\start.ps1 + ``` + + The script waits for `/api/ready` and prints the URL, so a green result means + the database migrated and the app is serving. + +## Day-to-day + +```powershell +powershell -ExecutionPolicy Bypass -File deployment\windows\status.ps1 +powershell -ExecutionPolicy Bypass -File deployment\windows\stop.ps1 +powershell -ExecutionPolicy Bypass -File deployment\windows\start.ps1 +``` + +`stop` keeps the volumes, so conversations and app state survive. + +## Autostart at logon + +```powershell +powershell -ExecutionPolicy Bypass -File deployment\windows\install-autostart.ps1 +``` + +It writes `NexBot.vbs` into the per-user Startup folder, which needs no +administrator rights (a scheduled task would). Remove it by deleting that file; +the script prints the exact path. + +## Slack + +The Slack app's Request URL must be publicly reachable over HTTPS, so loopback +is not enough. For a test machine, expose the proxy with a tunnel and point the +manifest at it: + +```powershell +cloudflared tunnel --url http://127.0.0.1:8080 +``` + +Then set the Slack app's Event Subscriptions URL to +`https:///api/slack/events` and restart the app so it picks up +`SLACK_BOT_TOKEN` / `SLACK_SIGNING_SECRET` / `SLACK_USER_IDS` from `.env.local`. +The tunnel is temporary: when it closes, Slack events stop until it is +restarted. A real deployment avoids this with a stable domain. + +## Backup and restore + +Both stores must be kept as a pair. With the local overlay the service names are +unchanged, so: + +```powershell +$env:COMPOSE_PROJECT_NAME = 'nexbot' +sh deployment/backup.sh +``` + +Pass `COMPOSE_FILES="-f compose.yml -f compose.local.yml"` and +`BACKUP_DIR` as needed, and use a Windows-style `BACKUP_DIR` (for example +`D:/nexbot/backups`): a POSIX `/d/...` path is not resolvable by the Docker +bind mount. + +Restore with `deployment/restore.sh `. If the stack was started +with the overlay, pass the same `COMPOSE_FILES` so the proxy is not orphaned. + +## Troubleshooting + +| Symptom | Check | +| --------------------------------------- | ------------------------------------------------------------------------- | +| `start.ps1` throws "Missing .env.local" | create it from `.env.example` | +| App restarts in a loop | `docker compose -p nexbot logs app` — usually a bad value in `.env.local` | +| 503 on `/api/ready` | the body names the missing variables; the database may be unreachable | +| Slack does nothing | the tunnel is down, or the Request URL no longer matches it | +| Port already in use | another stack is running; check `docker ps` before starting | diff --git a/docs/fork/OVERVIEW.md b/docs/fork/OVERVIEW.md new file mode 100644 index 00000000..b1672711 --- /dev/null +++ b/docs/fork/OVERVIEW.md @@ -0,0 +1,95 @@ +# Overview + +## What NexBot is + +NexBot is a self-hosted personal-agent application template. It presents a +single-owner workspace ("Spaces" containing documents and "Dots" – specialist +agents), a chat surface, document editing, background/scheduled execution, +optional voice, optional persistent per-Dot computers, optional MCP +integrations, and optional Slack. It is a single npm package (no monorepo), +TypeScript/ESM, requiring Node >= 24. + +Upstream, the application is built as a template around CopilotKit's hosted +product surface: `CopilotKitIntelligence` for thread/message persistence and +memory, hosted realtime agent execution, hosted Slack "channels", hosted +learning containers, and CopilotKit telemetry. Upstream README and +`package.json` describe it as "A self-hosted personal-agent template with +CopilotKit Intelligence, Threads, and Channels." + +## What this fork is + +This fork, `https://github.com/KayzenRoot/nexbot`, is an **independent, +decoupled** continuation of upstream. It keeps the application and its +open-protocol runtime, and it **removes the mandatory dependency on +CopilotKit-hosted services and API keys**. After this fork's work is complete, +the application runs entirely on owner-controlled infrastructure: the +application server, a PostgreSQL database owned by the operator, and an +owner-selected OpenAI-compatible model endpoint. + +The fork is not a rewrite. The user interface is unchanged. Specifically, the +client's conversation-list behaviour (`useThreads()` and the ThreadList UI) +depends on runtime REST thread endpoints; this fork keeps those endpoints by +implementing them locally, so the client needs no rewrite (see +[ARCHITECTURE.md](./ARCHITECTURE.md)). + +This fork is built to be maintainable against upstream: it is a GitHub fork +that preserves upstream history and attribution, and the upstream `LICENSE` +(MIT, Copyright (c) Atai Barkai) is retained in full. + +## What changed vs upstream + +| Area | Upstream | This fork | +| -------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| Conversation storage | Hosted `CopilotKitIntelligence` threads/messages | Self-hosted PostgreSQL (`PostgresConversationStore`) | +| Agent execution | Hosted `IntelligenceAgentRunner` realtime WS gateway | Local `PostgresAgentRunner` implementing `LocalThreadEndpointRunner` | +| Runtime mode | `CopilotRuntime` Intelligence mode (needs `intelligence:` + key) | `CopilotRuntime` SSE mode (`CopilotSseRuntime`), MIT, no key | +| Server-side turns | `IntelligenceAgent` (`@copilotkit/core`) over `info.intelligence.wsUrl` | `HttpAgent` (`@ag-ui/client`) over open AG-UI to own `/api/copilotkit/run` | +| Slack | Managed `@copilotkit/channels` `createChannel` | Self-hosted Slack Web API + Events API integration | +| Learning/skills | Hosted learning containers, `getLearnedSkillsSnapshots` | Local PostgreSQL skills store (`learning-store.ts`) | +| Telemetry | CopilotKit ingest + `CPK_TELEMETRY_ID` | Removed; no outbound call to any CopilotKit host | +| Required secrets | `CPK_INTELLIGENCE_API_KEY` / `INTELLIGENCE_API_KEY` | None for CopilotKit; only an OpenAI-compatible model endpoint | + +**Retained deliberately:** `@copilotkit/runtime` and `@copilotkit/react-core` +(MIT, v1.75.0) stay as local-only libraries. They drive the open AG-UI protocol +against our own endpoint, require no account and no key, and make no call to a +CopilotKit host. See ADR-0002 in [DECISIONS.md](./DECISIONS.md). + +## Who it is for + +- A single owner/operator who wants a personal agent workspace on their own + server (VPS or home machine) without an account with, or a key from, any + hosted agent platform. +- An operator who wants to point the agent runtime at any OpenAI-compatible + endpoint: OpenAI, Ollama, LM Studio, vLLM, OpenRouter, Groq, Together, etc. +- An auditor or maintainer who needs a precise, evidence-backed record of what + was decoupled, why, and how it is verified. + +It is explicitly **not** a multi-tenant SaaS, and upstream `SECURITY.md` +already states the prototype is single-owner. Multi-user enforcement is out of +scope (see [SCOPE.md](./SCOPE.md)). + +## Deployment story + +The target deployment is Docker Compose on an owner-controlled VPS: + +- **app** — Hono server on `@hono/node-server` serving the built React client + from `dist/client` and the API on port `4310`. +- **Postgres** — owner-managed database for conversations only. +- **browser** (optional profile) — isolated read-only Playwright service on + `4311`, needed only for `WEB_SEARCH_PROVIDER=browser`. +- **computer supervisor** (optional, separate compose file) — the only + component that mounts the Docker socket; pinned to OpenBot commit + `b6932d31a8d6e7896c15139dfc27a6c6911deb27`. + +No CopilotKit account, project selection, or API key is needed. The only +mandatory third-party credential is an OpenAI-compatible model endpoint +(`OPENAI_BASE_URL` / `OPENAI_API_KEY` / `OPENAI_MODEL`). All other integrations +(voice, web search, Composio, Slack, computers, browser) are optional. See +[DEPLOYMENT.md](./DEPLOYMENT.md). + +## Document set + +Start at [README.md](./README.md) for the full index. The current status of +every claim in this document, including what is implemented versus planned, is +in [CHECKPOINT.md](./CHECKPOINT.md) and +[DEFINITION-OF-DONE.md](./DEFINITION-OF-DONE.md). diff --git a/docs/fork/PRODUCTION-READINESS.md b/docs/fork/PRODUCTION-READINESS.md new file mode 100644 index 00000000..235a15d4 --- /dev/null +++ b/docs/fork/PRODUCTION-READINESS.md @@ -0,0 +1,68 @@ +# Production readiness + +Status: **NOT READY** to sell. This page is the honest gate list. + +Verified working today, with evidence: the self-hosted runtime, PostgreSQL +conversations, the in-app model provider setup, Slack end to end, backup and +restore, the Docker stack, and the install/watchdog scripts on Windows. + +The gap is not features — it is the operational and legal layer that a paid +deployment needs. + +## Blockers + +| # | Item | Why it blocks | Owner | +| --- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| 1 | VPS deploy with a real domain and HTTPS | It runs on one machine today; Slack points at a temporary tunnel that dies | You: rent the VPS, point DNS. Then `deployment/Caddyfile` + the guide | +| 2 | Scheduled, off-site backup | `backup.sh` exists and a restore round-trip passed, but nothing runs it on a schedule and no copy leaves the machine | Automated locally where possible; off-site needs your storage | +| 3 | Independent penetration test | An internal audit is not a substitute before selling to third parties | You: hire it | +| 4 | Rotate exposed credentials | The OpenCode Go key and a `thk_live_…` key appeared in session tool output | You: provider dashboards | +| 5 | Sign the Windows installer | Unsigned `.exe` triggers SmartScreen warnings and blocks installs | You: buy a code-signing certificate | +| 6 | Decide the container runtime for customers | Docker Desktop is free only below 250 employees **and** USD 10M revenue; above that it is a paid seat | You: business decision | +| 7 | Resolve or accept the remaining dependency advisories | 26 high remain, almost all transitive from `@copilotkit/*` 1.75.0; 1.84.1 breaks typecheck and must be its own tested change | Your call: upgrade with risk, or accept in writing | + +## Already done, no action needed + +- **In-app provider setup.** The operator chooses base URL, model and key inside + the app; the key is tested before it is stored and sealed with AES-256-GCM + under a master key kept in a separate file. No more editing `.env` and + restarting. +- **Environment footgun closed.** An exported `OPENAI_*` no longer overrides + `.env.local`; `start.ps1` and `install.ps1` clear those variables first. This + was caught in production-shaped testing and had already silently pointed a + stack at the wrong provider. +- **Health watchdog.** Restarts the stack after a real outage, one restart per + outage, installed at logon beside the app. +- **Security posture tests.** Fail if a route starts leaking secrets or moves + above the owner middleware; also hardened the settings store against unknown + keys. +- **CSP and session auth** verified live: `connect-src 'self'`, + `frame-ancestors 'none'`, owner-token required, Postgres unpublished. + +## Recommended before the first paying customer + +1. ~~Rate limiting~~ **DONE**: the webhook is capped at 120 requests/minute per + client and failed token attempts at 40/minute, both with Retry-After. A + multi-replica deployment would still need a shared limiter. +2. **Monitoring**: external uptime check against `/api/ready`, log shipping, and + an alert on the watchdog log. +3. **Documentation truth.** Upstream `docs/SETUP.md` still says CopilotKit + Intelligence is required for conversations, which is false in this fork. +4. **Legal**: terms of service, privacy policy, and a support agreement. If you + access a customer's server remotely, that is processing their data and needs + a contract (LGPD). Keep the MIT licence and upstream attribution. + +## Product gaps, deliberately deferred + +- **Skills editor.** Skills live in PostgreSQL but there is no UI to manage them. +- ~~Configurable voice~~ **DONE**: `VOICE_BASE_URL` selects a non-OpenAI realtime + gateway; it defaults to OpenAI so nothing changes for an existing install. +- **Multi-user auth.** One owner token per installation by design. + +## On the visual work + +The dark theme is produced by a build-time PostCSS transform, not by a token +block, so palette changes go through `src/build/dark-theme.ts`. The current +layer (glass surfaces, gradient depth, living mascot) is scoped to +`:root[data-theme='dark']` and leaves the light theme untouched. A full redesign +should start by making the transform accept an explicit palette. diff --git a/docs/fork/README.md b/docs/fork/README.md new file mode 100644 index 00000000..de16a05b --- /dev/null +++ b/docs/fork/README.md @@ -0,0 +1,54 @@ +# Fork Documentation Index + +Canonical engineering documentation for the decoupled, self-hosted fork of +CopilotKit/OpenDots maintained at +`https://github.com/KayzenRoot/nexbot`. + +- Upstream: `CopilotKit/OpenDots` (MIT), default branch `main`, + upstream HEAD `d4214aff10f066911f3253cb3cb870764cd51a9f`. +- This fork retains the upstream `LICENSE` (MIT, Copyright (c) Atai Barkai). +- Documentation date: 2026-11-05. + +This `docs/fork/` tree is the audit-facing document set. It is separate from +the upstream docs in `docs/*.md` (`SETUP.md`, `COMPUTERS.md`, `CONNECTIONS.md`, +`TELEMETRY.md`, `SETUP-TELEMETRY.md`, `knip.md`, `demos/`), which are retained +unchanged and may be referenced for upstream behaviour that this fork keeps. + +| File | Description | +| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| [OVERVIEW.md](./OVERVIEW.md) | What NexBot is, what this fork is, what changed, who it is for, and the deployment story. | +| [REQUIREMENTS.md](./REQUIREMENTS.md) | Functional and non-functional requirements, including the hard "no mandatory CopilotKit key" and full owner-infrastructure requirements. | +| [SCOPE.md](./SCOPE.md) | In scope, out of scope, deferred items with reasons, and the three accepted scope decisions. | +| [ARCHITECTURE.md](./ARCHITECTURE.md) | Component diagram, chat-turn request flow, server module map, decoupling seam table, why SSE mode + local thread endpoints suffice, build layout. | +| [DATA-MODEL.md](./DATA-MODEL.md) | PostgreSQL conversation schema (tables, columns, keys, indexes, retention) plus the SQLite tables that stay local. | +| [API-CONTRACTS.md](./API-CONTRACTS.md) | `/api/*` HTTP surface, `/api/copilotkit/*` agent runtime (AG-UI + thread REST), Slack webhook, health/readiness, and auth. | +| [SECURITY.md](./SECURITY.md) | Fork-specific threat model, auth, permission boundaries, isolation, secret handling, SSRF/command-injection posture, and what is not yet hardened. | +| [TEST-PLAN.md](./TEST-PLAN.md) | Unit/integration/e2e strategy, how to run, the 56-file baseline, the baseline flake explanation, new required tests, benchmarks. | +| [DEPLOYMENT.md](./DEPLOYMENT.md) | VPS deployment and recovery: resources, Docker Compose topology, env template, HTTPS, health, volumes, backup/restore, upgrade/rollback. | +| [BACKLOG.md](./BACKLOG.md) | Work orders WORK-000..WORK-010, deferred features, resolved upstream issues, known limitations. | +| [DEFINITION-OF-DONE.md](./DEFINITION-OF-DONE.md) | Acceptance criteria checklist for fork readiness. | +| [DECISIONS.md](./DECISIONS.md) | Decision ledger / ADRs ADR-0001..ADR-0010. | +| [FEATURE-PARITY.md](./FEATURE-PARITY.md) | Feature matrix: upstream implementation vs CopilotKit dependency vs replacement vs verification vs limitations. | +| [CHECKPOINT.md](./CHECKPOINT.md) | Status snapshot: upstream SHA, destination repo, baseline results, phase, completed/remaining work orders, blockers. | + +## Status legend + +Used consistently across this document set: + +- **DONE** — implemented and verified in this repository at the recorded + checkpoint. +- **IN PROGRESS** — implementation started, not complete. +- **PLANNED** — designed and assigned a work-order ID, not yet implemented. +- **ACCEPTED** — a decision the repo owner has made, with rationale recorded. + +## Reading order + +For an audit review: [CHECKPOINT.md](./CHECKPOINT.md) → +[DEFINITION-OF-DONE.md](./DEFINITION-OF-DONE.md) → +[FEATURE-PARITY.md](./FEATURE-PARITY.md) → +[BACKLOG.md](./BACKLOG.md) → the remaining documents as needed. + +For an engineer joining the fork: [OVERVIEW.md](./OVERVIEW.md) → +[ARCHITECTURE.md](./ARCHITECTURE.md) → [DATA-MODEL.md](./DATA-MODEL.md) → +[API-CONTRACTS.md](./API-CONTRACTS.md) → +[DEPLOYMENT.md](./DEPLOYMENT.md). diff --git a/docs/fork/REQUIREMENTS.md b/docs/fork/REQUIREMENTS.md new file mode 100644 index 00000000..770721eb --- /dev/null +++ b/docs/fork/REQUIREMENTS.md @@ -0,0 +1,117 @@ +# Requirements + +Requirement IDs are `FR-*` (functional), `NFR-*` (non-functional), `SEC-*` +(security), and `OPS-*` (operational). Status uses the shared legend +(DONE / IN PROGRESS / PLANNED / ACCEPTED). + +## 1. Functional requirements + +### 1.1 Agent runtime and conversations + +| ID | Requirement | Status | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| FR-001 | The application MUST serve the AG-UI agent runtime at `/api/copilotkit/*` from the local server process, with no hosted dependency. | PLANNED (WORK-004) — upstream serves this path through hosted Intelligence mode today | +| FR-002 | The runtime MUST run in SSE mode (`CopilotSseRuntime`) with `runner: AgentRunner` and `intelligence: undefined` / `channels: undefined`. | PLANNED (WORK-004) | +| FR-003 | Thread, message, event, and state persistence MUST live in owner-controlled PostgreSQL, exposed to the runtime through a `LocalThreadEndpointRunner` (`listThreads`, `getThreadMessages`, `getThreadEvents`, `getThreadState`, `clearThreads`, `ɵsupportsLocalThreadEndpoints: true`). | PLANNED (WORK-003) | +| FR-004 | The client's conversation-list and thread REST endpoints (`/api/copilotkit/threads`, `/threads/:id/messages`, `/threads/:id/events`, `/threads/:id/state`, `/threads/:id/archive`) MUST be served locally so the existing client (`src/client/App.tsx`, 965 lines; `src/client/Chat.tsx`, 489 lines) needs no rewrite. | PLANNED (WORK-004) | +| FR-005 | Server-side, voice-compute, and scheduled turns MUST be executed with `HttpAgent` from `@ag-ui/client` against our own `/api/copilotkit/run`, replacing the hosted `IntelligenceAgent` path in `src/server/headless.ts`. | PLANNED (WORK-004) | +| FR-006 | The `z.literal('intelligence')` gate on runtime info (currently in `src/server/headless.ts`) MUST be removed so server turns work against an SSE-mode runtime. | PLANNED (WORK-004) | +| FR-007 | Multiple Dots (specialist agents) with per-Dot instructions and tool permissions MUST remain supported. | DONE (upstream, retained) | +| FR-008 | Scheduled/background tasks MUST execute in a bound conversation and record runs/events. | DONE (upstream, retained); must be re-checked against the decoupled runtime (WORK-004) | + +### 1.2 Spaces, pages, documents + +| ID | Requirement | Status | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- | +| FR-010 | Spaces, Pages, page revisions, page reviews, and page↔thread bindings MUST remain in the local SQLite store. | DONE (upstream, retained) | +| FR-011 | The `PageIntelligence` seam in `src/server/page-service.ts` (`createThread`, `getOrCreateThread`, `getThreadMessages`) MUST be satisfied by the PostgreSQL conversation store. | PLANNED (WORK-003) | + +### 1.3 Human approval and integrations + +| ID | Requirement | Status | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| FR-020 | Approval-gated MCP tool calls MUST remain: an approval runs only its own stored connection, tool, and arguments. | DONE (upstream, retained) | +| FR-021 | MCP connections, tool enable/disable, and per-tool `requiresApproval` MUST remain in SQLite. | DONE (upstream, retained) | +| FR-022 | Slack MUST be provided by a self-hosted integration (Slack Web API + Events API, signing-secret verified) registered as its own Hono routes that invoke the same local agent runner. No managed channel product. | PLANNED (WORK-005) | +| FR-023 | Per-Dot skills (revisions, delivery enabled/disabled) MUST be stored locally in PostgreSQL, replacing hosted learning containers and `getLearnedSkillsSnapshots`. | PLANNED (WORK-006) | +| FR-024 | An OpenAI-compatible model endpoint MUST be the only mandatory external model dependency. | ACCEPTED (WORK-007) | + +### 1.4 Optional capabilities + +| ID | Requirement | Status | +| ------ | --------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| FR-030 | OpenAI Realtime voice (`VOICE_API_KEY`/`VOICE_MODEL`/`VOICE_NAME`) MUST remain optional. | DONE (upstream, retained) | +| FR-031 | Web search (`WEB_SEARCH_PROVIDER=parallel\|browser\|disabled`) MUST remain optional; `disabled` MUST work with no provider. | DONE (upstream, retained) | +| FR-032 | Composio connected apps MUST remain optional. | DONE (upstream, retained) | +| FR-033 | Persisted computers MUST remain optional, one per Dot, with only the supervisor mounting the Docker socket. | DONE (upstream, retained) | +| FR-034 | HTTP API access to NexBot by external agents/clients MUST be owner-authenticated. | DONE (upstream, retained) | + +## 2. Non-functional requirements + +| ID | Requirement | Status | +| ------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | +| NFR-001 | The application MUST run as a single npm package; no monorepo is introduced. | DONE | +| NFR-002 | Build MUST produce `dist/client` (Vite) and `dist/server/{server,browser,shared}` (`tsc -p tsconfig.server.json`). | DONE | +| NFR-003 | `npm run typecheck`, `npm run lint`, `npm test`, `npm run build`, and `npm run knip` MUST pass before release. | DONE at baseline (see below); MUST hold after WORK-003..WORK-006 | +| NFR-004 | Node >= 24 MUST be required (uses `node:sqlite` `DatabaseSync` for local state). | DONE | +| NFR-005 | `typecheck` and `lint` MUST remain clean (no new suppressions). | DONE at baseline | +| NFR-006 | New conversation persistence MUST be idempotent and versioned (no ad-hoc schema mutation). | PLANNED (WORK-003) | +| NFR-007 | Two datastores (SQLite for app state, PostgreSQL for conversations) are accepted for a contained blast radius. | ACCEPTED (ADR-0003) | +| NFR-008 | No outbound network call to any CopilotKit host MAY be made at runtime. | PLANNED (WORK-004) — telemetry removal | + +## 3. Security requirements + +| ID | Requirement | Status | +| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------- | +| SEC-001 | **No mandatory CopilotKit API key or hosted service may remain.** Configuration MUST NOT require `CPK_INTELLIGENCE_API_KEY` or `INTELLIGENCE_API_KEY`. | PLANNED (WORK-004) | +| SEC-002 | The application MUST run fully on owner-controlled infrastructure: owner Postgres, owner backend, owner agent runtime. | PLANNED (WORK-003/004/008) | +| SEC-003 | Every `/api/*` request MUST be owner-authenticated with a timing-safe bearer-token comparison when an external binding or `OWNER_TOKEN` is set. | DONE | +| SEC-004 | Cross-origin requests MUST be restricted to the `APP_ORIGIN` allowlist and `sec-fetch-site: cross-site` MUST be rejected. | DONE | +| SEC-005 | Runtime routes MUST be scope-validated (`validateRuntimeScope`) so a request cannot address another Dot's thread. | DONE | +| SEC-006 | Only the computer supervisor MAY mount the Docker socket. | DONE | +| SEC-007 | The browser service MUST run read-only, be isolated from private networks, and never be publicly exposed. | DONE | +| SEC-008 | Secrets MUST live on the server only; `.env` files and databases MUST NOT be committed. | DONE | +| SEC-009 | Untrusted content (page text, model output, tool output) MUST NOT be treated as authorization to change permissions. | DONE (upstream invariant, retained) | +| SEC-010 | Research browsing MUST remain read-only; page writes MUST use revision checks. | DONE | +| SEC-011 | Agent/tool output MUST be treated as untrusted data (no shell/interpreter execution from output). | DONE | +| SEC-012 | New attack surface introduced by decoupling (Slack webhook, health endpoint, Postgres) MUST be covered by a security review before release. | PLANNED (WORK-010) | + +## 4. Operational requirements + +| ID | Requirement | Status | +| ------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| OPS-001 | Docker Compose MUST start the application stack and pass a health check. | PLANNED (WORK-008) | +| OPS-002 | A health/readiness endpoint MUST exist and report DB connectivity and setup state. | PLANNED (WORK-008) — upstream browser service has `/health`; the app server does not yet | +| OPS-003 | Application data MUST survive a container restart (SQLite file and Postgres volumes). | PLANNED (WORK-008) | +| OPS-004 | Backup and restore MUST be documented and validated for BOTH the Postgres database and the SQLite file. | PLANNED (WORK-008) | +| OPS-005 | Upgrade and rollback procedures MUST be documented. | PLANNED (WORK-008) | +| OPS-006 | Postgres schema migrations MUST run idempotently on a clean install and on an existing database. | PLANNED (WORK-003) | +| OPS-007 | HTTPS MUST terminate at a reverse proxy in front of the app; the app port MUST NOT be exposed publicly. | PLANNED (WORK-008) | +| OPS-008 | `.github/workflows/ci.yml` MUST continue to run knip (report-only), check-format, lint, typecheck, test, and build. | DONE | + +## 5. Explicitly required guarantees + +1. **No mandatory CopilotKit key or hosted service** (SEC-001, SEC-002, + FR-001..FR-006). This is the defining requirement of the fork. +2. **Full owner-controlled operation** (SEC-002, OPS-001..OPS-008). Postgres, + the backend, and the agent runtime all run on infrastructure the owner + controls. +3. **No feature loss for the client** (FR-004). The existing UI must keep + working without a rewrite. + +## 6. Baseline verification (WORK-000) + +Recorded on this Windows host (Node 26.4.0, npm 11.17.0): + +- `npm run typecheck` — PASS (clean) — evidence `baseline-typecheck.log`. +- `npm run lint` — PASS (clean) — evidence `baseline-lint.log`. +- `npm test` — 348/351 pass. 3 failures, all in `tests/telemetry.test.ts`, + each "Test timed out in 5000ms" (~5.0s). Root cause: those tests + `execFile('node', ['--import','tsx', ...])`, spawning a subprocess that takes + 4.0–5.0s on this host versus Vitest's default 5000 ms `testTimeout`; sibling + tests in the same file pass at 4.0–4.5s. Pre-existing host timing flake, not + a logic defect; passes on Linux CI. +- `npm run build` — PASS. + +See [TEST-PLAN.md](./TEST-PLAN.md) for the test strategy and the required new +tests, and [CHECKPOINT.md](./CHECKPOINT.md) for current status. diff --git a/docs/fork/SCOPE.md b/docs/fork/SCOPE.md new file mode 100644 index 00000000..8e5aa41e --- /dev/null +++ b/docs/fork/SCOPE.md @@ -0,0 +1,118 @@ +# Scope + +This document defines what the fork delivers, what it explicitly does not, what +is deferred and why, and the three accepted scope decisions. + +## 1. In scope + +### 1.1 CopilotKit decoupling (the core of the fork) + +- Replace the hosted `CopilotKitIntelligence` thread/memory/learning store with + a self-hosted PostgreSQL conversation store + (`src/server/conversation-store.ts`, `src/server/pg.ts`). +- Replace the hosted `IntelligenceAgentRunner` realtime gateway with a local + runner (`src/server/agent-runner.ts`) that runs agents locally and persists + threads/messages/events/state in PostgreSQL. +- Move the runtime from **Intelligence mode** to **SSE mode** + (`CopilotSseRuntime`) — MIT, no API key. +- Replace the hosted `IntelligenceAgent` server-side path + (`src/server/headless.ts`) with `HttpAgent` (`@ag-ui/client`, MIT v0.0.59) + speaking the open AG-UI protocol to our own `/api/copilotkit/run`. +- Reimplement the Slack channel self-hosted (rewrite + `src/server/slack-channel.ts` against the Slack Web API + Events API). +- Reimplement learning/skills locally (`src/server/learning-store.ts`), + replacing hosted learning containers and `getLearnedSkillsSnapshots`. +- Remove CopilotKit telemetry (`https://telemetry.copilotkit.ai/ingest`, + `CPK_TELEMETRY_ID`, `COPILOTKIT_TELEMETRY_*`) and any outbound call to a + CopilotKit host. + +### 1.2 Retained application surface (keeping parity) + +- Spaces, Pages, document editing (TipTap), page reviews and revisions. +- Specialist agents (Dots) with instructions and per-tool permissions. +- Persistent conversations, memory, and conversation→page saves. +- Human approval workflows for MCP tools. +- Browser, terminal, and file access via persisted computers (OpenBot). +- MCP and external integrations (Composio, streamable-HTTP MCP). +- Background execution and scheduling (tasks, runs, events). +- Optional voice (OpenAI Realtime). +- Owner authentication, origin allowlisting, and runtime-scope validation. +- Logging/recovery surfaces already present (see + [FEATURE-PARITY.md](./FEATURE-PARITY.md)). + +### 1.3 Operational deliverables + +- Docker Compose topology for app + Postgres (+ optional browser / + computer supervisor). +- Idempotent, versioned Postgres migrations. +- Health/readiness endpoint. +- Backup/restore for Postgres and SQLite; upgrade/rollback procedures. +- Test plan, evaluation harness, and evidence artifacts for the decoupled + runtime. +- This documentation set and the upstream issue/PR migration record. + +## 2. Out of scope + +| Item | Reason | +| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Rewriting the client UI (`App.tsx`, `Chat.tsx`, page editor) | The client is served by local REST thread endpoints; a rewrite adds risk with no user-visible benefit. See FR-004. | +| Removing all `@copilotkit/*` packages | `@copilotkit/runtime` and `@copilotkit/react-core` are MIT, local-only, need no key, and call no CopilotKit host. Removing them would be a rewrite. (ADR-0002.) | +| Multi-tenant hosting, per-user accounts, SSO | Upstream is a single-owner template; owner `SECURITY.md` already scopes multi-user as future work. | +| Migrating existing app state (`spaces`, `pages`, `tasks`, …) from SQLite to Postgres | Accepted decision: Postgres is for conversations only. (ADR-0003.) A full migration is a separate effort with much larger blast radius. | +| Managed/hosted deployment as a service | The fork is self-hosted by the owner. | +| Full excision of the upstream README's CopilotKit setup narrative | Out of scope for this doc set; handled as documentation edits during WORK-002/WORK-007. | +| New agent capabilities or product features | The fork's purpose is decoupling and parity, not new features. | + +## 3. Deferred items + +| Item | Why deferred | Trigger to revisit | +| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| Automatic conversation archival/retention policy enforcement | Schema supports it (see [DATA-MODEL.md](./DATA-MODEL.md)); policy needs owner input on retention windows. | After WORK-003 ships and usage data exists | +| Postgres row-level encryption at rest | Requires KMS/key-management decisions not yet made; disk-level encryption suffices for the target single-owner VPS. | WORK-010 findings or a compliance requirement | +| Slack interactive components (buttons/modals) | The self-hosted Slack integration starts with events + Web API replies; interactivity is additive. | WORK-005 core complete | +| Multi-Dot shared conversations | Upstream threads are bound to one Dot; changing this changes the runtime scope model. | Out of scope unless upstream changes | +| Reasoning-model turn reliability (upstream #58) | Mitigated by allowing any OpenAI-compatible model and by the `AGENT_RUN_TIMEOUT_MS` control; deeper fix depends on the AG-UI/agent stack. | If reproducible after WORK-004 | +| Voice provider portability (currently OpenAI Realtime) | Voice is optional; a second provider needs a provider abstraction. | Owner request | + +## 4. Accepted scope decisions + +These are decisions the repo owner has made. Each is recorded as an ADR with +status **Accepted**. + +### Decision 1 — Replace hosted CopilotKit services; keep MIT local libraries + +- **Decision:** Replace `CopilotKitIntelligence`, `IntelligenceAgentRunner`, the + runtime Intelligence mode, `IntelligenceAgent`, `@copilotkit/channels`, hosted + learning containers, and CopilotKit telemetry. Retain `@copilotkit/runtime` + and `@copilotkit/react-core` (MIT, local-only). +- **Why:** The host-dependent pieces require accounts/keys and make outbound + calls; the retained libraries are MIT and protocol-level. This is a surgical + decoupling, not a full excision. Recorded as ADR-0002. +- **Status:** Accepted. + +### Decision 2 — PostgreSQL for conversations only + +- **Decision:** PostgreSQL stores conversations (threads, messages, runs, + events, state) and the learning/skills store. All other application state + remains in the existing SQLite database. +- **Why:** Contained blast radius. The conversation tables need + concurrency/scale characteristics Postgres provides; the rest of the app's + state is fine on `node:sqlite`. Two datastores is an accepted cost. + Recorded as ADR-0003. +- **Status:** Accepted. + +### Decision 3 — Reimplement both Intelligence-only features self-hosted + +- **Decision:** Both Intelligence-only features are reimplemented, not dropped: + (a) the Slack channel, and (b) the local learning/skills store. +- **Why:** Dropping them would be a feature regression versus upstream. The + fork requires parity. Recorded as ADR-0005 and ADR-0006. +- **Status:** Accepted. + +## 5. Scope boundaries in one line each + +- We remove **requirement** of CopilotKit, not CopilotKit code that is MIT and + local. +- We keep the **client** untouched; we replace the **server runtime**, **storage**, + **Slack**, **learning**, and **telemetry**. +- We move **conversations** to Postgres; we leave **everything else** on SQLite. diff --git a/docs/fork/SECURITY-AUDIT.md b/docs/fork/SECURITY-AUDIT.md new file mode 100644 index 00000000..57ae34c8 --- /dev/null +++ b/docs/fork/SECURITY-AUDIT.md @@ -0,0 +1,103 @@ +# Security audit (WORK-010) + +Date: 2026-11-05. Scope: branch `fork/decouple-and-harden` at the commit that +introduced `5b23a9e`, plus the running Compose stack (`nexbot-app`, +`nexbot-postgres`) on the operator host. + +Method: source review of every new security-relevant module, `npm audit` on the +full and production trees, live probes against the running deployment, and a +backup/restore round-trip on real data. No penetration test of the VPS was +possible (no VPS access in this session). + +## Verdict + +**CORRECTION REQUIRED** — not because of an unresolved critical defect (none is +open), but because the deployment has never run on the target VPS and several +acceptance criteria in [DEFINITION-OF-DONE.md](./DEFINITION-OF-DONE.md) remain +unverified in production conditions. Gate blockers are listed at the end. + +## Findings + +| ID | Severity | Status | Finding | +| --- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| F1 | High | **Fixed** | `deployment/restore.sh` could not replace the SQLite file. The app service drops all capabilities, so the restore helper ran as root but could not unlink a file owned by `node`; after `chown` it could not `chmod` either. Restore failed and left the app crash-looping on a root-owned database (`attempt to write a readonly database`). Fixed by granting only `DAC_OVERRIDE` + `CHOWN` to that one-shot container and ordering `chmod` before `chown`. Found by the round-trip test, not by review. | +| F2 | Critical | **Fixed** | `dompurify` ≤ 3.4.16 — sanitiser bypass (GHSA-cg2g-xh64-v4q7) plus two related advisories. It sanitises rendered markdown, so this was reachable with untrusted agent or page content. Pinned to `^3.4.17` via `overrides`. | +| F3 | High | **Fixed** | `zod` < 4.6.6 — memory exhaustion while validating oversized collections (GHSA-mrhx-c7xc-m3w5). Root cause of most transitive high findings. Bumped to 4.6.7. | +| F4 | High | **Fixed** | `@modelcontextprotocol/sdk` 1.31.0 advisories (session-unbound experimental tasks, oversized single-request handling). Bumped to 1.32.2. | +| F5 | Moderate | **Accepted** | `@hono/node-server` `serveStatic` decodes the request path a second time. 2.1.6 is the latest published version, so no fix exists yet. Exposure is bounded: the static root is a fixed `./dist/client`, the process runs non-root with all capabilities dropped, and no user-controlled path selects a file. Track upstream. | +| F6 | High | **Accepted** | `@graphql-tools/utils` prototype pollution, reached through `@copilotkit/runtime-client-gql`. The fork runs the runtime in SSE mode and never issues GraphQL requests, so the path is not exercised. It is also not removable without dropping the MIT runtime packages (see [DECISIONS.md](./DECISIONS.md)). | +| F7 | High | **Accepted** | `body-parser` denial of service. Belongs to the Express adapter shipped inside `@copilotkit/runtime`; this deployment is Hono-only and never mounts it. | +| F8 | Moderate | **Accepted** | `fast-uri` and `@vitest/mocker` advisories. `fast-uri` sits under tooling; `@vitest/mocker` is a dev-only test runner and is not shipped in the production image (`npm ci --omit=dev`). | + +Production dependency audit after remediation: **0 critical, 26 high, 6 moderate, +6 low** (was 1 critical / 27 high / 6 moderate / 6 low of 40 total). Every +remaining high is transitive and falls into F5–F7. + +## Controls verified + +- **Owner authentication.** External binding refuses to start without + `OWNER_TOKEN` of at least 24 characters. Every `/api` route except + `/api/health`, `/api/ready` and `/api/slack/events` requires it. +- **Slack webhook authentication.** HMAC-SHA256 over `v0:{timestamp}:{body}`, + compared with `timingSafeEqual`; the length check precedes the comparison and + only reveals a fixed public format. Requests outside a five-minute window are + rejected. The handler reads the raw body with `c.req.text()` and verifies + **before** `JSON.parse`. With no signing secret the route answers 401. + Verified live: unsigned request → 401, correctly signed event → 200. +- **Authorisation after authentication.** A signed event still has to match + `SLACK_TEAM_ID` and an explicit `SLACK_USER_IDS` entry, and must come from a + human actor in the configured channel. Bot and self messages are ignored. + Handler failures are logged through `safeFailure`, which strips provider + messages and stacks. +- **Runtime scope.** `validateRuntimeScope` blocks cross-Dot access and denies + routes the local runtime does not serve, before delegating to the SDK handler. +- **Network exposure.** Postgres publishes no port and is reachable only on the + internal network. The app publishes on loopback only. The computer supervisor + is the sole service that mounts the Docker socket. +- **Container hardening.** `cap_drop: ALL`, `no-new-privileges`, `init: true`, + non-root `node` user, resource limits on app and Postgres. +- **Secret handling.** Credentials live in `.env` / `compose.env`, both outside + version control. The repository was scanned for `xoxb-`, `oc_sk_` and the + signing secret; only a literal `xoxb-fake-token-for-tests` fixture matches. +- **Telemetry.** No request to any `copilotkit.ai` host was observed from the + built client in a real browser, and the server hard-disables the SDK's + outbound telemetry path. +- **Readiness disclosure.** `/api/ready` returns only `ok` plus the _names_ of + missing configuration; it never returns values or the full setup object. + +## Residual risks (not defects, but accepted) + +1. ~~No application-level rate limiting~~ **Addressed**: the public webhook is + capped per client and failed token attempts are capped too, both with a real + Retry-After. The limiter is in-process, so a multi-replica deployment needs a + shared store; the proxy should still impose its own limits. +2. **Prompt-injected authority.** Page, web, MCP and Slack content reach the + model as untrusted input. Tool permissions limit the blast radius, but no + prompt-injection classifier is in place. +3. **Voice is pinned to `api.openai.com`** (`src/server/voice.ts`). Chat is + provider-agnostic; voice is not, and should be disabled on a deployment that + must avoid third-party model hosts entirely. +4. **The bundled CopilotKit web-inspector still contains its telemetry URL + constant.** No request to it was observed, but it is not provably dead code. +5. **Ephemeral Request URL.** The live Slack app points at a temporary tunnel. + Production must update the manifest to the VPS domain, otherwise events stop + when the tunnel closes. +6. **`COMPOSE_PROJECT_NAME` is inherited from the host.** It was `hive-v102` on + this machine; every command here passes `-p nexbot`. A deployment that + omits it could join an unrelated project. + +## Not verified + +- Any behaviour on the actual VPS: TLS issuance, firewall, restart policy, + backup/restore under production load. +- Slack retry/redelivery behaviour (Slack's own retries). +- Multi-user authorisation: the product is single-owner by design and the + upstream security policy already states that Space membership and Slack + identity mapping need more enforcement before multi-user use. + +## Gate decision + +The critical and high findings introduced by this fork are fixed. The verdict +stays **CORRECTION REQUIRED** because the deployment half of the acceptance +criteria is unproven: no VPS run, no production TLS, no restore under +production conditions. Close those, then re-run this audit. diff --git a/docs/fork/SECURITY.md b/docs/fork/SECURITY.md new file mode 100644 index 00000000..32923a6f --- /dev/null +++ b/docs/fork/SECURITY.md @@ -0,0 +1,197 @@ +# Security + +Fork-specific security posture for the decoupled NexBot fork. This document +supplements, and does not replace, the upstream `SECURITY.md` (retained in the +repository root). Where upstream's intended boundary still applies, it is +restated; where the fork changes the exposure, it is called out. Each item is +explicit about whether it is hardened today or not yet hardened. + +## 1. Threat model summary + +| Actor / asset | Threat | Current posture | +| ----------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Owner browser → app | Unauthorized API access | Owner bearer token + origin/host allowlist + `sec-fetch-site` (hardened upstream, retained) | +| Cross-Dot requests | A client addressing another Dot's thread | `validateRuntimeScope` path/ID/ownership validation (hardened, retained) | +| Model/tool output | Prompt injection escalating to permission changes or command execution | Output treated as untrusted data; tools are permission-gated (invariant retained; hardening incomplete — see §7) | +| Agent research browsing | SSRF to private/internal networks | DNS-pinned, redirect-blocked, public-address-only fetcher (hardened, retained) | +| External content (fetched pages, uploads) | Injection into prompts | Explicitly labelled untrusted in agent instructions (retained) | +| Slack workspace | Spoofed actor / unsigned webhook | Allowlisted team + user ids; self-hosted receiver must verify the Slack signing secret (PLANNED — WORK-005) | +| VPS host | Container escape / privilege escalation | `no-new-privileges`, `cap_drop: ALL`, non-root `node` user, read-only browser container (hardened upstream, retained) | +| Docker socket | Container control from the app | Only the computer supervisor mounts the socket (retained; see §5) | +| Datastores | Data loss / unauthorized read | SQLite file perms; Postgres to be secured in WORK-008/010 (see §9) | +| CopilotKit cloud | Outbound data exfiltration to a host | Removed entirely (PLANNED — WORK-004) | + +## 2. Authentication + +- **Owner token.** When `OWNER_TOKEN` is set, every `/api/*` request must carry + `Authorization: Bearer `, compared with `timingSafeEqual` (constant + time). Binding to a non-loopback `HOST` refuses to start without a token of at + least 24 characters (`src/server/index.ts`). +- **Host/origin allowlist.** Without a token, only loopback hostnames are + accepted. With `APP_ORIGIN`, only listed origins are accepted; an unexpected + `Origin` or `sec-fetch-site: cross-site` is rejected (`src/server/app.ts`, + `src/server/app-origin.ts`). +- **No multi-user identity.** There is no per-user account system. The workspace + is single-owner. `SECURITY.md` upstream states Space membership, Slack + identity mapping, and voice delegation need more enforcement before + multi-user use. **Not hardened:** this fork does not add multi-user + enforcement (out of scope; see [SCOPE.md](./SCOPE.md)). + +## 3. Agent and tool permission boundaries + +- Dots carry `researchAllowed`, `memoryAllowed`, `appsAllowed`, and + `composioToolkits`; each restricts the tools the agent may invoke. +- MCP tools are per-tool `enabled` / `requiresApproval`. Approval-gated calls + run only their own stored connection/tool/arguments, within the conversation + that requested them, within `APPROVAL_TTL_MS`, and only while the tool is + still enabled (`src/server/connection-routes.ts`). +- Computer actions are gated by computer permissions; human control is + owner-only (`src/server/computer-service.ts`, `src/server/computer-routes.ts`). +- Page writes use revision checks (`expectedRevision`), preventing lost-update + and replay of stale drafts. +- **Not hardened:** there is no application-level rate limiting on agent runs + or tool calls (only a browser busy guard and a six-compute-turn voice limit). + A single owner can still exhaust provider quota. Recommended in WORK-010. + +## 4. Browser service isolation + +- The browser service (`src/browser/index.ts`) is a separate Hono app requiring + `BROWSER_SECRET` (≥24 chars), timing-safe compared; it returns `401` + otherwise. Its only functional route is `POST /browse`. +- It enforces an uncompromising SSRF posture: `validateUrl` + (`src/browser/security.ts`) allows only `http`/`https`, rejects embedded + credentials, restricts ports to 80/443, rejects loopback/`.local`, resolves + DNS itself, and rejects any private/special address (RFC1918, link-local, + CGNAT, multicast, IPv6 ULA/link-local etc.). Requests are DNS-pinned to the + validated IP (`src/browser/transport.ts`), so a TOCTOU DNS rebind cannot + redirect the connection. +- Redirects are **rejected before Playwright sees the Location header**; + `set-cookie` and other hop headers are stripped; responses are capped at + 5 MB and 10 s; requests run in a `javaScriptEnabled: false` context with + service workers blocked and downloads disabled; a single-flight busy guard + returns `429`. +- Deployment: the browser container is `read_only`, `cap_drop: ALL`, + `no-new-privileges`, `shm_size: 256mb`, on an **internal** network, and must + never be publicly exposed. `WEB_SEARCH_PROVIDER=browser` is the only feature + that needs it. + +## 5. Docker socket exposure + +- The application container does **not** mount the Docker socket. +- Only the computer supervisor mounts it, in the separate + `compose.computers.yml` topology; the supervisor is pinned to OpenBot commit + `b6932d31a8d6e7896c15139dfc27a6c6911deb27`. +- The app talks to the supervisor over HTTP with + `COMPUTER_SUPERVISOR_URL`/`COMPUTER_SUPERVISOR_TOKEN`, and per-computer + requests are authenticated with an HMAC (`createHmac('sha256', COMPUTER_TOKEN)` + over `nexbot-computer:`, `src/server/computer-service.ts`). +- **Not hardened:** the supervisor's own container sandboxing and image trust + are upstream OpenBot concerns, not re-reviewed here. + +## 6. Secret handling + +- Secrets are server-side environment variables (`.env` is loaded via + `--env-file-if-exists`; never committed — `.gitignore`). +- Provider errors are sanitized before logging or posting to Slack: + `safeFailure` (`src/server/slack-channel.ts`) reduces any error to a safe + name plus an integer HTTP status, so credentials in provider messages cannot + leak into logs (`reportChannelFailure`). +- `.env.example` lists every secret but no real values; `compose.yml` requires + `OWNER_TOKEN` via `${OWNER_TOKEN:?...}` and passes other secrets from the + host environment. +- **Not hardened:** no secret rotation tooling, no vault integration, no + encryption of MCP `token` values at rest in SQLite (stored as text in + `mcp_connections.token`). + +## 7. SSRF and command injection + +- **SSRF:** the research browser path is hardened (§4). MCP connection URLs are + validated as `http(s)` with no embedded credentials + (`connectionInput`, `src/server/connections.ts`), but an owner-configured MCP + endpoint may still point at an internal address — this is accepted because + the owner configures it, and it must be reviewed in WORK-010. +- **Command injection:** the server does not build shell commands from model + output. Computer actions are a fixed action enum forwarded to the supervisor; + unknown actions are rejected (`400`). Page and memory writes are bounded + strings. Research is read-only. +- **Not hardened:** agent/tool output is not executed, but it _is_ rendered + (Markdown) and fed back into the model. Rendering and content-security + policy reduce but do not eliminate indirect prompt-injection risk. No + systematic injection testing exists yet (WORK-010). + +## 8. File access restrictions + +- Page documents are the only agent-writable files; they live in SQLite and are + written through page tools with revision checks. There is no general-purpose + filesystem tool exposed to the agent in this fork's retained surface. +- Computer filesystem access, when enabled, happens inside the per-Dot + computer container, not on the host, and is bounded by computer permissions. +- **Not hardened:** the app's own process has read/write access to + `data/nexbot.sqlite`; container filesystem hardening relies on the compose + settings in §5, not on an application sandbox. + +## 9. Rate limiting + +- Present: browser single-flight (`429` when busy, 40 s deadline), voice + six-compute-turn cap, `AGENT_RUN_TIMEOUT_MS` and turn time limits, + `bodyLimit` on `/api/*` (1 MB) and the browser (16 KB). +- **Not hardened:** no per-IP/per-token request rate limiting on `/api/*`, no + agent-run concurrency cap at the HTTP layer, no login throttling. The owner + token is the only gate. Recommended in WORK-010 and can be added at the + reverse proxy (see [DEPLOYMENT.md](./DEPLOYMENT.md)). + +## 10. Database security and backups + +- **SQLite:** `node:sqlite` `DatabaseSync`, WAL mode, `busy_timeout=5000`. File + permissions depend on the container user (`node`) and volume ownership. + Backed up as a file (see [DEPLOYMENT.md](./DEPLOYMENT.md)). +- **Postgres (PLANNED):** must use a dedicated role, a strong password, and + network isolation so only the app reaches it. The pool (`src/server/pg.ts`) + uses parameterized queries; no string-concatenated SQL from request data. + Migrations run with a minimal-privilege role. **Not hardened:** connection + TLS mode, at-rest encryption, and role hardening are specified in WORK-008 and + reviewed in WORK-010. +- **Recovery unit:** the two stores share conversation IDs (see + [DATA-MODEL.md](./DATA-MODEL.md) §2.1). Backups of Postgres and SQLite must be + taken and restored as one consistent pair, or references will dangle. + +## 11. Dependency vulnerabilities + +- The project pins versions and uses `renovate.json` for updates; CI runs knip + in report-only mode. `package.json` carries an `undici` override + (`^6.29.0`). +- **Not hardened:** there is no automated `npm audit` gate in CI and no SBOM. + WORK-010 requires a dependency-vulnerability pass before release, with no + unresolved HIGH/CRITICAL findings (per + [DEFINITION-OF-DONE.md](./DEFINITION-OF-DONE.md)). + +## 12. Untrusted agent/tool output + +- Page text, fetched pages, memory, and model output are treated as untrusted + data, never as authorization to change permissions (`SECURITY.md`, retained). +- The research brief prompt explicitly instructs the model that source pages + and memories are untrusted data and never instructions, and forbids claiming + actions it cannot take (`src/server/research.ts`). +- Voice `ask_compute` results are the only source of truth for the voice + agent; it is instructed never to claim work happened without a tool result + (`src/server/voice.ts`). +- **Not hardened:** there is no output sanitizer for tool results beyond + Markdown rendering and the existing CSP; indirect injection remains a + residual risk. + +## 13. CopilotKit-specific exposure removed (PLANNED — WORK-004) + +- Removal of `CopilotKitIntelligence`, the hosted realtime WS plane, the hosted + Slack channel, hosted learning containers, and CopilotKit telemetry removes + all outbound calls to CopilotKit hosts and the requirement for + `CPK_INTELLIGENCE_API_KEY` / `INTELLIGENCE_API_KEY`. +- Until WORK-004 lands, the upstream exposure remains: the app can be + configured to send conversation data to CopilotKit's hosted services. This is + the single most important security reason for the fork. + +## 14. Reporting + +Follow the upstream `SECURITY.md` reporting guidance: use the repository's +private vulnerability reporting feature when available; otherwise open an issue +requesting a private channel without exploit details, credentials, private +URLs, or personal data. No SLA or bounty is offered. diff --git a/docs/fork/TEST-PLAN.md b/docs/fork/TEST-PLAN.md new file mode 100644 index 00000000..7640d53c --- /dev/null +++ b/docs/fork/TEST-PLAN.md @@ -0,0 +1,172 @@ +# Test Plan + +Testing strategy for the decoupled fork, the recorded baseline, how to run +everything, and the new tests the decoupling requires. Status uses the shared +legend. + +## 1. Strategy + +| Layer | Scope | Tooling | Where | +| ------------ | --------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Unit | Pure functions, schemas, stores, route handlers in isolation | Vitest | `tests/*.test.ts` | +| Component | React components rendered in isolation | Vitest + `react-test-renderer` | `tests/*.test.tsx` | +| Integration | Hono app + stores + services wired together (SQLite `:memory:`) | Vitest | `tests/app.test.ts`, `tests/workspace.test.ts`, `tests/page-service.test.ts`, `tests/connection*.test.ts`, `tests/computer*.test.ts` | +| End-to-end | Real HTTP routes and process behaviour | Vitest with real `serve`/`fetch` and subprocesses | `tests/telemetry.test.ts`, `tests/headless*.test.ts`, `tests/slack-channel*.test.ts` | +| Build/static | Types, lint, dead code, formatting | `tsc --noEmit`, ESLint, knip, Prettier | `npm run typecheck\|lint\|knip\|check-format` | + +Baseline: **56 test files** in `tests/`, all Vitest. Fixtures live in +`tests/fixtures/`. + +## 2. How to run + +```bash +npm run typecheck # tsc --noEmit +npm run lint # eslint src tests vite.config.ts +npm test # cross-env DO_NOT_TRACK=1 vitest run +npm run build # vite build && tsc -p tsconfig.server.json +npm run knip # report-only dead-code check +npm run knip:production +npm run check-format # prettier --check +``` + +CI (`.github/workflows/ci.yml`) runs, in order: knip (report only), knip +production (report only), `check-format`, `lint`, `typecheck`, `test`, `build`, +on Node 24, with a 15-minute timeout. + +## 3. Recorded baseline (WORK-000) + +Platform: this Windows host, Node 26.4.0, npm 11.17.0. + +| Command | Result | Evidence | +| ------------------- | -------------------- | ------------------------ | +| `npm run typecheck` | PASS (clean) | `baseline-typecheck.log` | +| `npm run lint` | PASS (clean) | `baseline-lint.log` | +| `npm test` | 348/351 pass, 3 fail | see below | +| `npm run build` | PASS | — | + +### Baseline failures and explanation + +All 3 failures are in `tests/telemetry.test.ts`, each reported as +`Test timed out in 5000ms` at ~5.0s. + +- **Cause:** those tests `execFile('node', ['--import', 'tsx', ...])`, spawning a + Node subprocess that takes 4.0–5.0 s to boot on this host, against Vitest's + default `testTimeout` of 5000 ms. Sibling tests in the same file pass at + 4.0–4.5 s. +- **Classification:** pre-existing host timing flake, not a logic defect. The + same suite passes on Linux CI. +- **Fork action:** do not mask it by raising the timeout globally. WORK-009 + either (a) confirms it passes on Linux CI unchanged, or (b) if a deterministic + fix is wanted, raises `testTimeout` only for the subprocess-booting tests, with + a comment explaining why. Any such change must be recorded in + [CHECKPOINT.md](./CHECKPOINT.md). + +## 4. Existing coverage of the retained surface + +The baseline suite already tests the upstream behaviour the fork retains, so it +doubles as the parity regression net: + +| Area | Representative files | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Auth/origin/scope | `app.test.ts`, `app-origin.test.ts`, `runtime-scope.test.ts`, `security.test.ts` | +| Workspace, Dots, Spaces | `workspace.test.ts`, `library.test.ts` | +| Pages, reviews, snapshots, autosave | `pages.test.ts`, `page-routes.test.ts`, `page-service.test.ts`, `page-review*.test.tsx`, `page-snapshots.test.ts`, `autosave.test.ts` | +| Conversations ↔ pages | `page-conversation-deletion.test.ts`, `page-chat-request.test.ts`, `page-context.test.ts`, `page-load-latest.test.tsx` | +| MCP connections/approvals | `connections.test.ts`, `connection-card.test.tsx` | +| Computers | `computer.test.ts`, `computer-routes.test.ts`, `computer-deployment.test.js`, `computer-card.test.tsx` | +| Tasks/runner | `runner.test.ts`, `agent-run-timeout.test.ts`, `store.test.ts` | +| Voice | `voice.test.ts`, `use-voice.test.ts`, `use-voice-control-poll.test.ts`, `transcript.test.tsx` | +| Slack (upstream shape) | `slack-channel.test.ts`, `slack-channel-wiring.test.ts`, `dot-agent-channel.test.ts` | +| Learning (upstream shape) | `learning.test.ts`, `learning-delivery.test.ts` | +| Headless/server turns | `headless.test.ts`, `headless-runtime.test.ts` | +| Setup/telemetry | `setup.test.ts`, `setup-telemetry.test.ts`, `telemetry.test.ts`, `intelligence-key.test.ts` | +| Client components | `chat-composer.test.ts`, `chat-run-error.test.tsx`, `controls.test.tsx`, `markdown.test.ts`, `dark-theme.test.ts`, `poll-notice.test.ts`, `transport.test.ts` | + +Several of these encode upstream-only behaviour and will need updating as part +of the work orders (`intelligence-key.test.ts`, the `intelligence` assertions in +`setup.test.ts`, the managed-channel tests in `slack-channel*.test.ts`). Tests +must be changed deliberately, not deleted: each change is recorded in +[CHECKPOINT.md](./CHECKPOINT.md). + +## 5. New tests required (PLANNED) + +### 5.1 PostgreSQL store (WORK-003) + +| Test | Verifies | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pg-migrations.test.ts` | Migrations apply idempotently on a clean DB and on an already-migrated DB; `schema_migrations` is recorded once per version; a failed migration leaves the last good version. | +| `conversation-store.test.ts` | `createThread`/`getOrCreateThread`/`getThreadMessages` satisfy the `PageIntelligence` seam; message `seq` ordering; content parts vs string; cascade delete; per-owner scoping. | +| `conversation-store-idempotency.test.ts` | Re-running an append with the same `(conversation_id, seq)` does not duplicate; concurrent appends keep `seq` monotonic. | + +### 5.2 Agent runner + local thread endpoints (WORK-003/004) + +| Test | Verifies | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agent-runner.test.ts` | `PostgresAgentRunner` implements `AgentRunner` (`run`, `connect`, `isRunning`, `stop`) and `LocalThreadEndpointRunner` (`listThreads`, `getThreadMessages`, `getThreadEvents`, `getThreadState`, `clearThreads`, `ɵsupportsLocalThreadEndpoints: true`); `supportsLocalThreadEndpoints` returns true. | +| `runtime-sse-wiring.test.ts` | The runtime is constructed in SSE mode (`runner` set, `intelligence`/`channels` undefined); `GET /api/copilotkit/info` no longer requires `intelligence.wsUrl`; `useThreads()`-equivalent REST calls return the local conversation list. | +| `headless-httpagent.test.ts` | Server-side/voice/scheduled turns use `HttpAgent` against `/api/copilotkit/run` with the owner bearer; no `z.literal('intelligence')` gate; run error surfaces as a turn error. | +| `no-copilotkit-egress.test.ts` | With the app running a turn, no request is made to any CopilotKit host (assert on intercepted fetch/DNS or block the host and assert success). | + +### 5.3 Slack self-hosted (WORK-005) + +| Test | Verifies | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `slack-signature.test.ts` | Valid signature accepted; wrong signature, wrong signing secret, and stale timestamp rejected; `url_verification` challenge answered. | +| `slack-identity-allowlist.test.ts` | Events from a non-allowlisted team/user are ignored; only `created` operations trigger a run; owner mapping is explicit. | +| `slack-run-local-agent.test.ts` | An eligible message invokes the local agent runner (not a managed channel), posts one reply, and posts one error reply on failure without leaking provider messages. | + +### 5.4 Learning store (WORK-006) + +| Test | Verifies | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `learning-store.test.ts` | Skill create/revise; `delivery_enabled` gating; per-Dot and per-scope isolation; snapshot query only includes enabled skills. | +| `learning-replaces-containers.test.ts` | The Dot `learningContainerId`/`skillDeliveryEnabled` settings drive local skills; no hosted `getLearnedSkillsSnapshots` call is made. | + +### 5.5 Health/readiness and deployment (WORK-008) + +| Test | Verifies | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `health-ready.test.ts` | `/api/health` returns 200 with no datastore access; `/api/ready` returns 200 only when SQLite and Postgres are reachable and migrated, 503 otherwise. | +| `compose-smoke` (scripted, not Vitest) | `docker compose up` starts app + Postgres; app passes `/api/health`; data persists across `restart`; backup/restore round-trips both stores. | + +## 6. Benchmark plan for agent turn latency + +Goal: quantify the cost of decoupling versus upstream Intelligence mode, and +guard against regressions. + +- **Harness:** a Vitest benchmark (`tests/fixtures/` for prompts) or a scripted + harness that drives `/api/copilotkit/agent/:dotId/run` against a local + OpenAI-compatible stub model, so measurements are not dominated by provider + network variance. +- **Metric 1 — time to first SSE event (TTFE):** from request send to first AG-UI + event received, p50/p95 over ≥50 runs. +- **Metric 2 — time to final message (TTFM):** from request send to run + completion, p50/p95. +- **Metric 3 — persistence overhead:** `TTFM` with Postgres persistence enabled + minus `TTFM` with persistence disabled, isolating the per-run write cost of + `conversations`/`messages`/`thread_runs`/`thread_events`/`thread_state`. +- **Metric 4 — conversation-list read:** `GET /api/copilotkit/threads` and + `GET /threads/:id/messages` at 10 / 100 / 1000 conversations, p50/p95, to + confirm indexes in [DATA-MODEL.md](./DATA-MODEL.md) are effective. +- **Method:** warm the pool, discard the first 5 runs, report p50/p95/σ, and + record host, Node version, Postgres version, and stub model in the evidence. +- **Budget:** record the measured numbers in WORK-009 evidence; treat a + regression >20% in TTFE or TTFM versus the recorded baseline as a release + blocker pending explanation. + +## 7. Evidence and artifacts (WORK-009) + +Per work order, capture and retain: + +- Command output logs (`baseline-typecheck.log`, `baseline-lint.log`, and the + post-change equivalents). +- `npm test` summary with pass/fail counts and the named failing tests. +- `npm run build` success with the `dist/` layout check. +- Migration apply/rollback output on a clean database. +- Docker Compose smoke output (start, health, restart persistence, + backup/restore). +- Benchmark results for Section 6. + +Evidence files must be reproducible from a documented command; no evidence is +invented. Anything not yet produced is marked PLANNED in +[CHECKPOINT.md](./CHECKPOINT.md). diff --git a/docs/telemetry/opendots-setup-conversion.hogql.sql b/docs/telemetry/nexbot-setup-conversion.hogql.sql similarity index 95% rename from docs/telemetry/opendots-setup-conversion.hogql.sql rename to docs/telemetry/nexbot-setup-conversion.hogql.sql index 6d93c3fb..f76686b8 100644 --- a/docs/telemetry/opendots-setup-conversion.hogql.sql +++ b/docs/telemetry/nexbot-setup-conversion.hogql.sql @@ -21,7 +21,7 @@ FROM ( 'oss.runtime.setup_activated', 'oss.runtime.setup_abandoned' ) - AND properties.accessibility_title = 'OpenDots' + AND properties.accessibility_title = 'NexBot' AND timestamp >= now() - INTERVAL 30 DAY AND empty(toString(properties.validation_run_id)) AND notEmpty(toString(properties.installation_id)) diff --git a/docs/telemetry/opendots-setup.hogql.sql b/docs/telemetry/nexbot-setup.hogql.sql similarity index 93% rename from docs/telemetry/opendots-setup.hogql.sql rename to docs/telemetry/nexbot-setup.hogql.sql index 92461c9a..3574bfd3 100644 --- a/docs/telemetry/opendots-setup.hogql.sql +++ b/docs/telemetry/nexbot-setup.hogql.sql @@ -12,7 +12,7 @@ WHERE event IN ( 'oss.runtime.setup_activated', 'oss.runtime.setup_abandoned' ) - AND properties.accessibility_title = 'OpenDots' + AND properties.accessibility_title = 'NexBot' AND timestamp >= now() - INTERVAL 30 DAY AND empty(toString(properties.validation_run_id)) GROUP BY event, step, error_class diff --git a/docs/telemetry/opendots-signups.hogql.sql b/docs/telemetry/nexbot-signups.hogql.sql similarity index 85% rename from docs/telemetry/opendots-signups.hogql.sql rename to docs/telemetry/nexbot-signups.hogql.sql index 554581fe..f08e1994 100644 --- a/docs/telemetry/opendots-signups.hogql.sql +++ b/docs/telemetry/nexbot-signups.hogql.sql @@ -1,7 +1,7 @@ --- Signup accounts with OpenDots requests in the same 30-day window. +-- Signup accounts with NexBot requests in the same 30-day window. -- Resolve a missing request Clerk ID from boot/runtime events with the same -- telemetry identity. Validation probes are excluded from both sides. -SELECT count(DISTINCT signup.distinct_id) AS signups_with_opendots_usage +SELECT count(DISTINCT signup.distinct_id) AS signups_with_nexbot_usage FROM events AS signup INNER JOIN ( SELECT DISTINCT clerk_user_id FROM ( @@ -13,7 +13,7 @@ INNER JOIN ( toString(properties.clerk_user_id) AS clerk_user_id FROM events WHERE event = 'oss.runtime.copilot_request_created' - AND properties.accessibility_title = 'OpenDots' + AND properties.accessibility_title = 'NexBot' AND timestamp >= now() - INTERVAL 30 DAY AND empty(toString(properties.validation_run_id)) ) AS request @@ -22,7 +22,7 @@ INNER JOIN ( argMax(toString(properties.clerk_user_id), timestamp) AS clerk_user_id FROM events WHERE event IN ('oss.runtime.instance_created', 'oss.runtime.copilot_request_created') - AND properties.accessibility_title = 'OpenDots' + AND properties.accessibility_title = 'NexBot' AND timestamp >= now() - INTERVAL 30 DAY AND empty(toString(properties.validation_run_id)) AND notEmpty(toString(properties.telemetry_id)) diff --git a/docs/telemetry/opendots-usage.hogql.sql b/docs/telemetry/nexbot-usage.hogql.sql similarity index 92% rename from docs/telemetry/opendots-usage.hogql.sql rename to docs/telemetry/nexbot-usage.hogql.sql index 925327ca..8b0249b6 100644 --- a/docs/telemetry/opendots-usage.hogql.sql +++ b/docs/telemetry/nexbot-usage.hogql.sql @@ -11,7 +11,7 @@ FROM ( toString(properties.clerk_user_id) AS clerk_user_id FROM events WHERE event = 'oss.runtime.copilot_request_created' - AND properties.accessibility_title = 'OpenDots' + AND properties.accessibility_title = 'NexBot' AND timestamp >= now() - INTERVAL 30 DAY AND empty(toString(properties.validation_run_id)) ) AS request @@ -20,7 +20,7 @@ FROM ( argMax(toString(properties.clerk_user_id), timestamp) AS clerk_user_id FROM events WHERE event IN ('oss.runtime.instance_created', 'oss.runtime.copilot_request_created') - AND properties.accessibility_title = 'OpenDots' + AND properties.accessibility_title = 'NexBot' AND timestamp >= now() - INTERVAL 30 DAY AND empty(toString(properties.validation_run_id)) AND notEmpty(toString(properties.telemetry_id)) diff --git a/index.html b/index.html index 3e37f29e..39f2f484 100644 --- a/index.html +++ b/index.html @@ -7,9 +7,9 @@ - OpenDots + NexBot diff --git a/package-lock.json b/package-lock.json index 89eb2552..13a93053 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,22 @@ { - "name": "opendots", + "name": "nexbot", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "opendots", + "name": "nexbot", "version": "0.1.0", "license": "MIT", "dependencies": { "@ag-ui/client": "^0.0.59", "@ag-ui/core": "^0.0.59", "@composio/core": "^0.22.0", - "@copilotkit/channels": "^0.11.0", "@copilotkit/core": "^1.75.0", "@copilotkit/react-core": "^1.75.0", "@copilotkit/runtime": "^1.75.0", - "@hono/node-server": "^2.1.3", - "@modelcontextprotocol/sdk": "^1.31.0", + "@hono/node-server": "^2.1.6", + "@modelcontextprotocol/sdk": "^1.32.2", "@tanstack/ai": "^0.63.0", "@tanstack/ai-openai": "^0.25.1", "@tiptap/core": "3.31.3", @@ -32,17 +31,19 @@ "@tiptap/suggestion": "3.31.3", "hono": "^4.13.11", "lucide-react": "^1.48.0", + "pg": "^8.23.1", "playwright": "^1.63.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "rxjs": "^7.8.1", - "zod": "^4.6.5" + "zod": "^4.6.7" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^26.6.3", + "@types/pg": "^8.23.1", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@types/react-test-renderer": "^19.3.0", @@ -827,41 +828,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/@copilotkit/channels": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@copilotkit/channels/-/channels-0.11.0.tgz", - "integrity": "sha512-L7pGm+n52cWH1LrME50HBbABk0hXnvwvG58zTGGjFFoxcPHtJaTghosqpv/Me0TwwCKY5hnXMmeaxvuSXiTB8Q==", - "license": "MIT", - "dependencies": { - "@copilotkit/channels-core": "0.11.0", - "@copilotkit/channels-discord": "0.11.0", - "@copilotkit/channels-slack": "0.11.0", - "@copilotkit/channels-teams": "0.11.0", - "@copilotkit/channels-telegram": "0.11.0", - "@copilotkit/channels-ui": "0.11.0", - "@copilotkit/channels-whatsapp": "0.11.0" - }, - "peerDependencies": { - "@microsoft/agents-activity": "^1.5.3", - "@microsoft/agents-hosting": "^1.5.3", - "express": "^4.18.0 || ^5.0.0", - "vitest": "^4.0.0" - }, - "peerDependenciesMeta": { - "@microsoft/agents-activity": { - "optional": true - }, - "@microsoft/agents-hosting": { - "optional": true - }, - "express": { - "optional": true - }, - "vitest": { - "optional": true - } - } - }, "node_modules/@copilotkit/channels-core": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@copilotkit/channels-core/-/channels-core-0.11.0.tgz", @@ -884,18 +850,6 @@ } } }, - "node_modules/@copilotkit/channels-discord": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@copilotkit/channels-discord/-/channels-discord-0.11.0.tgz", - "integrity": "sha512-wiABSPV7q1mcYUsNZB+vZmUjkWb8zbUcLhtqoE8zQSjP8Lu+Midki9sthaojjsrX4elL9Gy19JofTV8YltaRYQ==", - "license": "MIT", - "dependencies": { - "@ag-ui/client": "0.0.59", - "@copilotkit/channels-core": "^0.11.0", - "@copilotkit/channels-ui": "^0.11.0", - "discord.js": "^14.16.0" - } - }, "node_modules/@copilotkit/channels-intelligence": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@copilotkit/channels-intelligence/-/channels-intelligence-0.11.0.tgz", @@ -959,18 +913,6 @@ } } }, - "node_modules/@copilotkit/channels-telegram": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@copilotkit/channels-telegram/-/channels-telegram-0.11.0.tgz", - "integrity": "sha512-RZWwmMVFzxVejsMK0XB3EVqRfqs4d241Gq+Qik+06FxUuy5b7iD/N7jgmrsuCYl292/y+rstygKzdKmFaoJDXA==", - "license": "MIT", - "dependencies": { - "@ag-ui/client": "0.0.59", - "@copilotkit/channels-core": "^0.11.0", - "@copilotkit/channels-ui": "^0.11.0", - "grammy": "^1.30.0" - } - }, "node_modules/@copilotkit/channels-ui": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@copilotkit/channels-ui/-/channels-ui-0.11.0.tgz", @@ -980,17 +922,6 @@ "@copilotkit/shared": "^1.73.1" } }, - "node_modules/@copilotkit/channels-whatsapp": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@copilotkit/channels-whatsapp/-/channels-whatsapp-0.11.0.tgz", - "integrity": "sha512-Kgw9F+CPINxT2zCjh0ASiFO4YGqCLsk+mibkotSwfBwI8jSErwXKc66PoYV9z5R/pmGtHfCzWyH2ODJPxWeP8A==", - "license": "MIT", - "dependencies": { - "@ag-ui/client": "0.0.59", - "@copilotkit/channels-core": "^0.11.0", - "@copilotkit/channels-ui": "^0.11.0" - } - }, "node_modules/@copilotkit/core": { "version": "1.75.0", "resolved": "https://registry.npmjs.org/@copilotkit/core/-/core-1.75.0.tgz", @@ -2124,136 +2055,6 @@ "node": ">=10" } }, - "node_modules/@discordjs/builders": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", - "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/formatters": "^0.6.2", - "@discordjs/util": "^1.2.0", - "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.40", - "fast-deep-equal": "^3.1.3", - "ts-mixer": "^6.0.4", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/collection": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", - "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=16.11.0" - } - }, - "node_modules/@discordjs/formatters": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", - "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", - "license": "Apache-2.0", - "dependencies": { - "discord-api-types": "^0.38.33" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz", - "integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.2.0", - "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.5", - "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.50", - "magic-bytes.js": "^1.13.0", - "tslib": "^2.6.3", - "undici": "^6.27.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/util": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", - "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", - "license": "Apache-2.0", - "dependencies": { - "discord-api-types": "^0.38.33" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/ws": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", - "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/collection": "^2.1.0", - "@discordjs/rest": "^2.5.1", - "@discordjs/util": "^1.1.0", - "@sapphire/async-queue": "^1.5.2", - "@types/ws": "^8.5.10", - "@vladfrangu/async_event_emitter": "^2.2.4", - "discord-api-types": "^0.38.1", - "tslib": "^2.6.2", - "ws": "^8.17.0" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, "node_modules/@emnapi/core": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", @@ -2924,12 +2725,6 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, - "node_modules/@grammyjs/types": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-5.0.0.tgz", - "integrity": "sha512-iq1Qrq1iPKkB8yAa0qSuIURMZOCuqTY5pWy5gHpCeL1oQ+GPadGhw/cDTVE8waJwuCzacUzuIjRv1sESvk7u7A==", - "license": "MIT" - }, "node_modules/@graphql-tools/executor": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-2.0.1.tgz", @@ -3478,9 +3273,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.31.0.tgz", - "integrity": "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==", + "version": "1.32.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz", + "integrity": "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", @@ -5114,39 +4909,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@sapphire/async-queue": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", - "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@sapphire/shapeshift": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", - "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=v16" - } - }, - "node_modules/@sapphire/snowflake": { - "version": "3.5.5", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", - "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -6588,6 +6350,18 @@ "undici-types": "~8.9.0" } }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -7129,16 +6903,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vladfrangu/async_event_emitter": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", - "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, "node_modules/@whatwg-node/disposablestack": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", @@ -8708,43 +8472,10 @@ "node": ">=0.3.1" } }, - "node_modules/discord-api-types": { - "version": "0.38.56", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.56.tgz", - "integrity": "sha512-Hti4RZsGvF5C7SM8rF2MDHtIm0Hyfrlk84GpEA29nj91tvVVMvX+oQSCodu10t2ZcjFGofq8TX0YyC04B2E69Q==", - "license": "MIT" - }, - "node_modules/discord.js": { - "version": "14.27.0", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.27.0.tgz", - "integrity": "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/builders": "^1.14.1", - "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.6.2", - "@discordjs/rest": "^2.6.2", - "@discordjs/util": "^1.2.0", - "@discordjs/ws": "^1.2.3", - "@sapphire/snowflake": "3.5.5", - "discord-api-types": "^0.38.49", - "fast-deep-equal": "3.1.3", - "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.13.0", - "tslib": "^2.6.3", - "undici": "^6.27.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, "node_modules/dompurify": { - "version": "3.4.16", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", - "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -9830,21 +9561,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/grammy": { - "version": "1.46.0", - "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.46.0.tgz", - "integrity": "sha512-/8Qw+iisrUdOMk+p2mjEHouMm/BBdBEN1DHh16wiTpRUZkxDG3PxexdjCvR+wvK3LWPdrEvnQbdrwpU954sPhg==", - "license": "MIT", - "dependencies": { - "@grammyjs/types": "5.0.0", - "abort-controller": "^3.0.0", - "debug": "^4.4.3", - "node-fetch": "^2.7.0" - }, - "engines": { - "node": "^12.20.0 || >=14.13.1" - } - }, "node_modules/graphql": { "version": "16.14.2", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", @@ -11217,12 +10933,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -11278,12 +10988,6 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "license": "MIT" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -11331,12 +11035,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/magic-bytes.js": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz", - "integrity": "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==", - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -13090,6 +12788,95 @@ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/phoenix": { "version": "1.8.15", "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.8.15.tgz", @@ -13267,6 +13054,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -14740,12 +14566,6 @@ "node": ">=6.10" } }, - "node_modules/ts-mixer": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", - "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -15622,6 +15442,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -15707,9 +15536,9 @@ } }, "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 88b4d05c..1170297c 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "opendots", + "name": "nexbot", "version": "0.1.0", "description": "A self-hosted personal-agent template with CopilotKit Intelligence, Threads, and Channels.", "directories": { @@ -28,12 +28,11 @@ "@ag-ui/client": "^0.0.59", "@ag-ui/core": "^0.0.59", "@composio/core": "^0.22.0", - "@copilotkit/channels": "^0.11.0", "@copilotkit/core": "^1.75.0", "@copilotkit/react-core": "^1.75.0", "@copilotkit/runtime": "^1.75.0", - "@hono/node-server": "^2.1.3", - "@modelcontextprotocol/sdk": "^1.31.0", + "@hono/node-server": "^2.1.6", + "@modelcontextprotocol/sdk": "^1.32.2", "@tanstack/ai": "^0.63.0", "@tanstack/ai-openai": "^0.25.1", "@tiptap/core": "3.31.3", @@ -48,17 +47,19 @@ "@tiptap/suggestion": "3.31.3", "hono": "^4.13.11", "lucide-react": "^1.48.0", + "pg": "^8.23.1", "playwright": "^1.63.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "rxjs": "^7.8.1", - "zod": "^4.6.5" + "zod": "^4.6.7" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^26.6.3", + "@types/pg": "^8.23.1", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@types/react-test-renderer": "^19.3.0", @@ -86,6 +87,7 @@ "url": "git+https://github.com/CopilotKit/OpenDots.git" }, "overrides": { - "undici": "^6.29.0" + "undici": "^6.29.0", + "dompurify": "^3.4.17" } } diff --git a/public/favicon.svg b/public/favicon.svg index 571021b1..337e67fc 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1,36 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/nexbot-logo.svg b/public/nexbot-logo.svg new file mode 100644 index 00000000..e477f2d3 --- /dev/null +++ b/public/nexbot-logo.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + NexBot + diff --git a/public/nexbot-mark.svg b/public/nexbot-mark.svg new file mode 100644 index 00000000..337e67fc --- /dev/null +++ b/public/nexbot-mark.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/nexbot.ico b/public/nexbot.ico new file mode 100644 index 00000000..6a9cfe0c Binary files /dev/null and b/public/nexbot.ico differ diff --git a/public/theme.js b/public/theme.js index b1722ed3..4865d628 100644 --- a/public/theme.js +++ b/public/theme.js @@ -3,7 +3,7 @@ (function () { var preference = 'system'; try { - preference = localStorage.getItem('opendots-theme') || 'system'; + preference = localStorage.getItem('nexbot-theme') || 'system'; } catch (error) {} var dark = preference === 'dark' || diff --git a/src/browser/transport.ts b/src/browser/transport.ts index 5f20caef..2115c3d1 100644 --- a/src/browser/transport.ts +++ b/src/browser/transport.ts @@ -16,7 +16,7 @@ export async function readResource( method: 'GET', signal, headers: { - 'User-Agent': 'OpenDots/0.1 (read-only research)', + 'User-Agent': 'NexBot/0.1 (read-only research)', Accept: 'text/html,image/*,text/css,*/*;q=0.5', 'Accept-Encoding': 'identity', }, diff --git a/src/build/dark-theme.ts b/src/build/dark-theme.ts index 6129aa93..21e2eed7 100644 --- a/src/build/dark-theme.ts +++ b/src/build/dark-theme.ts @@ -190,7 +190,7 @@ export function excludeFixed(selector: string, containers: string[]) { } export function darkTheme(): Plugin { return { - postcssPlugin: 'opendots-dark-theme', + postcssPlugin: 'nexbot-dark-theme', Once(root) { let fixed = false; const containers: string[] = []; diff --git a/src/client/App.tsx b/src/client/App.tsx index e5cc6466..51dcdd29 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -280,7 +280,7 @@ export function App() { onChange={(e) => setAuth(e.target.value)} required /> - + {error && (

@@ -308,7 +308,7 @@ export function App() {