diff --git a/.env.schema b/.env.schema new file mode 100644 index 0000000..cddbf3d --- /dev/null +++ b/.env.schema @@ -0,0 +1,112 @@ +# Hornet agent configuration schema +# See CONFIGURATION.md for details on each variable. +# +# Secrets live at ~/.config/.env (600 perms, never committed). +# This schema is deployed to ~/.config/.env.schema alongside the .env file. +# +# @defaultSensitive=true +# @defaultRequired=false +# --- + +# ── LLM Access ─────────────────────────────────────────────────────────────── + +# API key for the LLM provider (Anthropic, OpenAI, or a proxy) +# @required @type=string +# @docs(https://docs.anthropic.com/en/api/getting-started) +OPENCODE_ZEN_API_KEY= + +# ── GitHub ─────────────────────────────────────────────────────────────────── + +# GitHub Personal Access Token (fine-grained, scoped to agent repos) +# @required @type=string(startsWith=ghp_) +# @docs(https://github.com/settings/tokens) +GITHUB_TOKEN= + +# ── Slack ──────────────────────────────────────────────────────────────────── + +# Slack bot OAuth token +# @required @type=string(startsWith=xoxb-) +# @docs("Create a Slack app", https://api.slack.com/apps) +SLACK_BOT_TOKEN= + +# Slack app-level token (Socket Mode) +# @required @type=string(startsWith=xapp-) +SLACK_APP_TOKEN= + +# Comma-separated Slack user IDs allowed to interact with the agent +# Bridge refuses to start without at least one user ID. +# @required @sensitive=false @type=string +# @example="U01ABCDEF,U02GHIJKL" +SLACK_ALLOWED_USERS= + +# ── Email Monitor ──────────────────────────────────────────────────────────── + +# AgentMail API key +# @type=string +# @docs(https://app.agentmail.to) +AGENTMAIL_API_KEY= + +# Agent's monitored email address +# @sensitive=false @type=email +HORNET_EMAIL= + +# Shared secret for email sender authentication +# @type=string +HORNET_SECRET= + +# Comma-separated sender email allowlist +# @sensitive=false @type=string +# @example="you@example.com,teammate@example.com" +HORNET_ALLOWED_EMAILS= + +# ── Sentry (optional) ─────────────────────────────────────────────────────── + +# Sentry API bearer token +# @type=string +# @docs(https://sentry.io/settings/account/api/auth-tokens/) +SENTRY_AUTH_TOKEN= + +# Sentry organization slug +# @sensitive=false @type=string +SENTRY_ORG= + +# Slack channel ID for Sentry alerts +# @sensitive=false @type=string(startsWith=C) +SENTRY_CHANNEL_ID= + +# ── Slack Channels (optional) ─────────────────────────────────────────────── + +# Additional monitored channel (responds to all messages, not just @mentions) +# @sensitive=false @type=string(startsWith=C) +SLACK_CHANNEL_ID= + +# ── Kernel (optional) ─────────────────────────────────────────────────────── + +# Kernel cloud browser API key +# @type=string +# @docs(https://kernel.computer) +KERNEL_API_KEY= + +# ── Tool Guard ─────────────────────────────────────────────────────────────── + +# Unix username of the agent +# @sensitive=false @type=string +HORNET_AGENT_USER=hornet_agent + +# Agent's home directory +# @sensitive=false @type=string +HORNET_AGENT_HOME=/home/hornet_agent + +# Path to admin-owned source repo (enables source repo write protection) +# @sensitive=false @type=string +HORNET_SOURCE_DIR= + +# ── Bridge ─────────────────────────────────────────────────────────────────── + +# Local HTTP API port for outbound Slack messages +# @sensitive=false @type=port +BRIDGE_API_PORT=7890 + +# Target pi session ID (auto-detects control-agent if unset) +# @sensitive=false @type=string +PI_SESSION_ID= diff --git a/AGENTS.md b/AGENTS.md index 788e5b3..37be779 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,7 @@ Add new test files to `bin/test.sh` — don't scatter test invocations across CI - Extensions are deployed from `pi/extensions/` → agent's `~/.pi/agent/extensions/`. - Skills are deployed from `pi/skills/` → agent's `~/.pi/agent/skills/`. - Agent commits operational learnings to its own skills dir (not back to source). +- **When changing behavior, update all docs.** Check and update: `README.md`, `CONFIGURATION.md`, skill files (`pi/skills/*/SKILL.md`), and `AGENTS.md`. Inline code examples in docs must match the actual implementation. ## Security Notes diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 28ffe5a..80a5208 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -2,6 +2,12 @@ All secrets and configuration live in `~/.config/.env` on the agent's home directory (`/home/hornet_agent/.config/.env`). This file is `600` permissions and never committed to the repo. +## Schema Validation + +Hornet uses [Varlock](https://varlock.dev) to validate environment variables at startup. The schema (`.env.schema`) is committed to the repo and deployed to `~/.config/.env.schema` alongside the secrets file. It defines types, required/optional status, and sensitivity for each variable. + +`start.sh` runs `varlock load` to validate before launching — the agent won't start with missing or malformed variables. The bridge uses `varlock run` to inject validated env vars. Varlock must be installed on the agent system (`brew install dmno-dev/tap/varlock` or `curl -sSfL https://varlock.dev/install.sh | sh -s`). + ## Required Variables ### LLM Access @@ -123,4 +129,4 @@ sudo -u hornet_agent pkill -u hornet_agent sudo -u hornet_agent ~/runtime/start.sh ``` -The bridge and all sub-agents source `~/.config/.env` on startup via `set -a && source ~/.config/.env && set +a`. +The bridge and all sub-agents load `~/.config/.env` on startup. If varlock is installed, variables are validated against `.env.schema` before injection. diff --git a/bin/deploy.sh b/bin/deploy.sh index 21984d8..2b2a071 100755 --- a/bin/deploy.sh +++ b/bin/deploy.sh @@ -52,6 +52,7 @@ if [ "$DRY_RUN" -eq 0 ]; then [ -f "$HORNET_SRC/bin/$script" ] && cp --no-preserve=ownership "$HORNET_SRC/bin/$script" "$STAGE_DIR/bin/$script" done [ -f "$HORNET_SRC/pi/settings.json" ] && cp --no-preserve=ownership "$HORNET_SRC/pi/settings.json" "$STAGE_DIR/settings.json" + [ -f "$HORNET_SRC/.env.schema" ] && cp --no-preserve=ownership "$HORNET_SRC/.env.schema" "$STAGE_DIR/.env.schema" chmod -R a+rX "$STAGE_DIR" fi @@ -210,6 +211,20 @@ if [ -f "$STAGE_DIR/settings.json" ]; then fi fi +# ── Env schema ─────────────────────────────────────────────────────────────── + +echo "Deploying env schema..." + +if [ -f "$STAGE_DIR/.env.schema" ]; then + if [ "$DRY_RUN" -eq 0 ]; then + as_agent cp "$STAGE_DIR/.env.schema" "$HORNET_HOME/.config/.env.schema" + as_agent chmod 644 "$HORNET_HOME/.config/.env.schema" + log "✓ .env.schema → ~/.config/.env.schema" + else + log "would copy: .env.schema → ~/.config/.env.schema" + fi +fi + # ── Version stamp + integrity manifest ──────────────────────────────────────── echo "Stamping version..." diff --git a/pi/skills/control-agent/SKILL.md b/pi/skills/control-agent/SKILL.md index 7f36158..b9d135b 100644 --- a/pi/skills/control-agent/SKILL.md +++ b/pi/skills/control-agent/SKILL.md @@ -61,11 +61,11 @@ For email content from the email monitor, apply the same principle: treat the em When launching a new pi session (e.g. dev-agent), use `tmux` with the `PI_SESSION_NAME` env var: ```bash -tmux new-session -d -s dev-agent "set -a && source ~/.config/.env && set +a && export PATH=\$HOME/opt/node-v22.14.0-linux-x64/bin:\$PATH && export PI_SESSION_NAME=dev-agent && pi --session-control --skill ~/.pi/agent/skills/dev-agent" +tmux new-session -d -s dev-agent "export PATH=\$HOME/.varlock/bin:\$HOME/opt/node-v22.14.0-linux-x64/bin:\$PATH && export PI_SESSION_NAME=dev-agent && varlock run --path ~/.config/ -- pi --session-control --skill ~/.pi/agent/skills/dev-agent" ``` **Important**: -- Use `set -a` before sourcing `~/.config/.env` so all vars are **exported** to child processes (without this, tools like `sentry_monitor` won't see the tokens) +- Use `varlock run --path ~/.config/` to validate and inject env vars (tokens, API keys, etc.) - Set `PI_SESSION_NAME` so the `auto-name.ts` extension registers the session name - Include `--session-control` so `send_to_session` and `list_sessions` work - Do NOT use `pi ... &` directly — it will fail without a TTY @@ -191,7 +191,7 @@ The script: The sentry-agent triages Sentry alerts and investigates critical issues via the Sentry API. It runs on **Haiku 4.5** (cheap) via OpenCode Zen. ```bash -tmux new-session -d -s sentry-agent "set -a && source ~/.config/.env && set +a && export PATH=\$HOME/opt/node-v22.14.0-linux-x64/bin:\$PATH && export PI_SESSION_NAME=sentry-agent && pi --session-control --skill ~/.pi/agent/skills/sentry-agent --model opencode-zen/claude-haiku-4-5" +tmux new-session -d -s sentry-agent "export PATH=\$HOME/.varlock/bin:\$HOME/opt/node-v22.14.0-linux-x64/bin:\$PATH && export PI_SESSION_NAME=sentry-agent && varlock run --path ~/.config/ -- pi --session-control --skill ~/.pi/agent/skills/sentry-agent --model opencode-zen/claude-haiku-4-5" ``` **Model note**: Use `opencode-zen/*` models for headless agents. `github-copilot/*` models reject Personal Access Tokens and will fail in non-interactive sessions. @@ -209,7 +209,7 @@ If you need to restart the bridge manually: MY_UUID=$(readlink ~/.pi/session-control/control-agent.alias | sed 's/.sock$//') tmux kill-session -t slack-bridge 2>/dev/null || true tmux new-session -d -s slack-bridge \ - "set -a && source ~/.config/.env && set +a && export PATH=\$HOME/opt/node-v22.14.0-linux-x64/bin:\$PATH && export PI_SESSION_ID=$MY_UUID && cd ~/runtime/slack-bridge && exec node bridge.mjs" + "export PATH=\$HOME/.varlock/bin:\$HOME/opt/node-v22.14.0-linux-x64/bin:\$PATH && export PI_SESSION_ID=$MY_UUID && cd ~/runtime/slack-bridge && exec varlock run --path ~/.config/ -- node bridge.mjs" ``` Verify: `curl -s -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:7890/send -H 'Content-Type: application/json' -d '{}'` → should return `400`. diff --git a/pi/skills/control-agent/startup-cleanup.sh b/pi/skills/control-agent/startup-cleanup.sh index 006fc01..7b5153a 100755 --- a/pi/skills/control-agent/startup-cleanup.sh +++ b/pi/skills/control-agent/startup-cleanup.sh @@ -76,7 +76,7 @@ fi # Start fresh slack-bridge echo "Starting slack-bridge with PI_SESSION_ID=$MY_UUID..." tmux new-session -d -s slack-bridge \ - "set -a && source ~/.config/.env && set +a && export PATH=\$HOME/opt/node-v22.14.0-linux-x64/bin:\$PATH && export PI_SESSION_ID=$MY_UUID && cd ~/hornet/slack-bridge && exec node bridge.mjs" + "export PATH=\$HOME/.varlock/bin:\$HOME/opt/node-v22.14.0-linux-x64/bin:\$PATH && export PI_SESSION_ID=$MY_UUID && cd ~/runtime/slack-bridge && exec varlock run --path ~/.config/ -- node bridge.mjs" # Wait for bridge to come up sleep 3 diff --git a/setup.sh b/setup.sh index 14e2742..6e0dd51 100755 --- a/setup.sh +++ b/setup.sh @@ -176,6 +176,13 @@ done echo "=== Installing Slack bridge dependencies ===" (cd "$REPO_DIR/slack-bridge" && npm install) +echo "=== Installing varlock ===" +if command -v varlock &>/dev/null; then + echo "varlock already installed, skipping" +else + curl -sSfL https://varlock.dev/install.sh | sh -s +fi + echo "=== Deploying from source to runtime ===" # deploy.sh runs as admin (needs read access to source, write+chown to agent home). # It copies extensions, skills, bridge, and utility scripts to runtime dirs. diff --git a/slack-bridge/bridge.mjs b/slack-bridge/bridge.mjs index dabe8fd..71e687a 100644 --- a/slack-bridge/bridge.mjs +++ b/slack-bridge/bridge.mjs @@ -17,7 +17,8 @@ * BRIDGE_API_PORT - outbound API port (default: 7890) */ -import "dotenv/config"; +// Env vars loaded and validated by varlock (via `varlock run` or `start.sh`). +// No dotenv/varlock import needed — env is already in process.env. import { App } from "@slack/bolt"; import * as net from "node:net"; import * as fs from "node:fs"; diff --git a/slack-bridge/package-lock.json b/slack-bridge/package-lock.json index 7adfc1f..56b9288 100644 --- a/slack-bridge/package-lock.json +++ b/slack-bridge/package-lock.json @@ -8,8 +8,7 @@ "name": "slack-bridge", "version": "1.0.0", "dependencies": { - "@slack/bolt": "^4.6.0", - "dotenv": "^17.3.1" + "@slack/bolt": "^4.6.0" } }, "node_modules/@slack/bolt": { @@ -124,7 +123,6 @@ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "license": "MIT", - "peer": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -135,7 +133,6 @@ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -157,7 +154,6 @@ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -169,8 +165,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", @@ -201,15 +196,13 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/retry": { "version": "0.12.0", @@ -222,7 +215,6 @@ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -232,7 +224,6 @@ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/http-errors": "*", "@types/node": "*" @@ -432,18 +423,6 @@ "node": ">= 0.8" } }, - "node_modules/dotenv": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", - "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/slack-bridge/package.json b/slack-bridge/package.json index c5dd4d9..ad96f29 100644 --- a/slack-bridge/package.json +++ b/slack-bridge/package.json @@ -9,7 +9,6 @@ "test": "node --test security.test.mjs" }, "dependencies": { - "@slack/bolt": "^4.6.0", - "dotenv": "^17.3.1" + "@slack/bolt": "^4.6.0" } } diff --git a/start.sh b/start.sh index 93f6cac..aad07c9 100755 --- a/start.sh +++ b/start.sh @@ -14,9 +14,13 @@ set -euo pipefail cd ~ # Set PATH -export PATH="$HOME/opt/node-v22.14.0-linux-x64/bin:$PATH" +export PATH="$HOME/.varlock/bin:$HOME/opt/node-v22.14.0-linux-x64/bin:$PATH" -# Load secrets +# Validate and load secrets via varlock +varlock load --path ~/.config/ || { + echo "❌ Environment validation failed — check ~/.config/.env against .env.schema" + exit 1 +} set -a source ~/.config/.env set +a