8 Commits

Author SHA1 Message Date
Zachariah K. Sharma 366d5abd03 Add scratch homes for isolated workers 2026-06-12 21:09:59 -06:00
Zachariah K. Sharma 4f10a4f0e3 Reconcile automation worker mount policy 2026-06-12 20:37:31 -06:00
Zachariah K. Sharma 2e46b2f475 Enable approved broker container lifecycle 2026-06-12 09:22:23 -06:00
Zachariah K. Sharma 5fcf7c0867 Expose approval bundle metadata in write tools 2026-06-12 09:19:16 -06:00
Zachariah K. Sharma b12c0874d7 Enable bounded fake-live specialist worker 2026-06-12 09:18:14 -06:00
Zachariah K. Sharma 52754af830 Persist Vynte MCP write audit log 2026-06-12 09:13:04 -06:00
Zachariah K. Sharma bb50909f81 Protect Hermes runtime execution controls 2026-06-12 09:00:34 -06:00
Zachariah K. Sharma c2001ef87b Protect Hermes MCP runtime foundation 2026-06-12 07:29:54 -06:00
62 changed files with 10414 additions and 35 deletions
+86 -1
View File
@@ -1,5 +1,6 @@
HERMES_CONTAINER_USER=0:0
HERMES_IMAGE=10.0.3.6:4000/zachariahsharma/hermes-control-plane:latest
HERMES_EXTERNAL_SAAS_MCP_IMAGE=10.0.3.6:4000/zachariahsharma/hermes-external-saas-mcp:latest
HERMES_AGENT_REF=458a94e42568b332e8794ca8fbb8c8e1279160a3
HERMES_OAUTH_BROWSER=echo
@@ -12,10 +13,11 @@ HERMES_INTERNAL_API_SERVER_KEY=change-this-to-a-separate-long-random-key
HERMES_DEFAULT_PROVIDER=openai-codex
HERMES_DEFAULT_THINKING_EFFORT=medium
HERMES_DEFAULT_CLAUDE_MODEL=claude-sonnet-4.6
HERMES_DEFAULT_CODEX_MODEL=gpt-5.4-codex
HERMES_DEFAULT_CODEX_MODEL=gpt-5.4
HERMES_DEFAULT_GEMINI_MODEL=gemini-3.5-flash
HERMES_HOME_HOST=/opt/hermes-control-plane/hermes
HERMES_PRE_HOME_HOST=/opt/hermes-control-plane/hermes-pre
CODEX_HOME_HOST=/opt/hermes-control-plane/codex
CLAUDE_HOME_HOST=/opt/hermes-control-plane/claude
GEMINI_HOME_HOST=/opt/hermes-control-plane/gemini
@@ -27,3 +29,86 @@ HERMES_ADMIN_SESSION_TTL_HOURS=12
HERMES_ADMIN_COOKIE_SECURE=false
HERMES_LOG_RETENTION_DAYS=90
HERMES_AUDIT_MAX_BYTES=10485760
HERMES_AGENT_EXECUTION_MODE=disabled
HERMES_AGENT_CONTROLLER_PORT=8793
HERMES_AGENT_CONTROLLER_TOKEN=change-this-to-a-separate-long-random-agent-controller-token
HERMES_REGISTER_VYNTE_INTERNAL_MCP=true
HERMES_VYNTE_INTERNAL_MCP_NAME=vynte-internal
HERMES_VYNTE_INTERNAL_MCP_URL=http://vynte-internal-mcp:8787/mcp
HERMES_VYNTE_INTERNAL_MCP_TOKEN=
HERMES_REGISTER_FORMS_MCP=true
HERMES_FORMS_MCP_NAME=forms
HERMES_FORMS_MCP_URL=https://forms.internal.vyntehome.com/api/mcp
HERMES_FORMS_MCP_TOKEN=
HERMES_REGISTER_AUTOMATION_CONTROL_MCP=false
HERMES_AUTOMATION_CONTROL_MCP_NAME=automation-control
HERMES_AUTOMATION_CONTROL_MCP_URL=http://automation-control:8791/mcp
HERMES_AUTOMATION_CONTROL_MCP_TOKEN=
AUTOMATION_CONTROL_MCP_TOKEN=
HERMES_REGISTER_CONTAINER_PROVISIONER_MCP=false
HERMES_CONTAINER_PROVISIONER_MCP_NAME=container-provisioner
HERMES_CONTAINER_PROVISIONER_MCP_URL=http://container-provisioner:8792/mcp
HERMES_CONTAINER_PROVISIONER_MCP_TOKEN=
CONTAINER_PROVISIONER_MCP_TOKEN=
HERMES_REGISTER_EXTERNAL_SAAS_MCP=false
HERMES_EXTERNAL_SAAS_MCP_NAME=external-saas
HERMES_EXTERNAL_SAAS_MCP_URL=http://hermes-external-saas-mcp:8787/mcp
HERMES_EXTERNAL_SAAS_MCP_TOKEN=
CONTAINER_PROVISIONER_EXECUTION_ENABLED=false
CONTAINER_PROVISIONER_ALLOWED_IMAGE_PREFIXES=10.0.3.6:4000/zachariahsharma/hermes-automation-
CONTAINER_PROVISIONER_PROFILES_JSON=[{"id":"hermes-automation-smoke","name":"Hermes automation smoke","description":"Short-lived no-network smoke container for broker verification.","image":"10.0.3.6:4000/zachariahsharma/hermes-automation-smoke@sha256:301145c7fda9da76f3b69dc6394804e0294ea6d6be2cc066b454e7f983c88d57","enabled":true,"executionEnabled":false,"resources":{"cpus":1,"memoryMb":128,"pidsLimit":64},"ttlSeconds":300,"networkMode":"none","command":["node","-e","setTimeout(() => {}, 5000)"],"labels":{"profile-kind":"smoke"}}]
KNOWLEDGE_MCP_SERVER_TOKEN=
VYNTE_MCP_ALLOW_ARBITRARY_URLS=false
VYNTE_HERMES_URL=https://hermes.internal.vyntehome.com
VYNTE_FORMS_URL=https://forms.internal.vyntehome.com
VYNTE_PLANE_URL=https://plane.internal.vyntehome.com
VYNTE_PLANE_AUTH_HEADER=X-API-Key
VYNTE_MCP_WRITES_ENABLED=false
VYNTE_MCP_WRITE_POLICY=strict_allowlist
VYNTE_PLANE_ALLOWED_WORKSPACES=
VYNTE_PLANE_ALLOWED_PROJECTS=
VYNTE_PLANE_ALLOWED_WRITE_PATHS=
VYNTE_TWENTY_ALLOWED_OBJECTS=
VYNTE_TWENTY_ALLOWED_WRITE_PATHS=
VYNTE_PLUNK_ALLOWED_WRITE_PATHS=
VYNTE_PLUNK_ALLOWED_EMAIL_DOMAINS=
VYNTE_CAL_ALLOWED_WRITE_PATHS=
VYNTE_MCP_WRITE_AUDIT_HOST=/opt/hermes-control-plane/audit/vynte-mcp
VYNTE_MCP_WRITE_AUDIT_PATH=/var/log/hermes/vynte-mcp/write-audit.jsonl
VYNTE_MCP_WRITE_RESPONSE_LIMIT=4096
VYNTE_GITEA_URL=https://git.internal.vyntehome.com
VYNTE_HERMES_PRE_API_URL=https://pre.hermes.internal.vyntehome.com
VYNTE_HERMES_POST_API_URL=https://post.hermes.internal.vyntehome.com
VYNTE_OPENWEBUI_URL=https://openwebui.internal.vyntehome.com
VYNTE_PORTAINER_URL=https://portainer.internal.vyntehome.com
VYNTE_NPM_URL=https://npm.internal.vyntehome.com
VYNTE_AUTHENTIK_URL=https://authentik.vyntehome.com
VYNTE_NETBIRD_URL=https://vpn.vyntehome.com
VYNTE_MEDIA_URL=https://media.internal.vyntehome.com
VYNTE_PENPOT_URL=https://penpot.internal.vyntehome.com
VYNTE_POSTIZ_URL=https://postiz.internal.vyntehome.com
VYNTE_TWENTY_URL=https://twenty.internal.vyntehome.com
VYNTE_PLAUSIBLE_URL=https://plausible.internal.vyntehome.com
VYNTE_PLUNK_URL=https://plunk.internal.vyntehome.com
VYNTE_CAL_URL=https://cal.internal.vyntehome.com
VYNTE_CAL_API_VERSION=2026-05-01
# Optional read-only service tokens. Leave unset until generated/rotated.
VYNTE_FORMS_TOKEN=
VYNTE_PLANE_TOKEN=
VYNTE_GITEA_TOKEN=
VYNTE_HERMES_PRE_API_TOKEN=
VYNTE_HERMES_POST_API_TOKEN=
VYNTE_OPENWEBUI_TOKEN=
VYNTE_PORTAINER_TOKEN=
VYNTE_NPM_TOKEN=
VYNTE_AUTHENTIK_TOKEN=
VYNTE_NETBIRD_TOKEN=
VYNTE_MEDIA_TOKEN=
VYNTE_PENPOT_TOKEN=
VYNTE_POSTIZ_TOKEN=
VYNTE_TWENTY_TOKEN=
VYNTE_PLAUSIBLE_TOKEN=
VYNTE_PLUNK_TOKEN=
VYNTE_CAL_TOKEN=
+6 -3
View File
@@ -19,18 +19,21 @@ RUN git clone --filter=blob:none https://github.com/NousResearch/hermes-agent.gi
&& git -C /opt/hermes-agent checkout "$HERMES_AGENT_REF" \
&& python3 -m venv /opt/hermes-agent/venv \
&& /opt/hermes-agent/venv/bin/pip install --no-cache-dir --upgrade pip setuptools wheel \
&& /opt/hermes-agent/venv/bin/pip install --no-cache-dir -e '/opt/hermes-agent[messaging]' \
&& /opt/hermes-agent/venv/bin/pip install --no-cache-dir -e '/opt/hermes-agent[messaging,mcp]' \
&& /opt/hermes-agent/venv/bin/hermes version
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY index.html app.js style.css server.cjs api-gateway.cjs README.md ./
COPY index.html app.js style.css server.cjs api-gateway.cjs agent-controller.cjs agent-worker.cjs vynte-internal-mcp.cjs automation-control-mcp.cjs automation-worker.cjs container-provisioner-mcp.cjs safe-write-policy.cjs focused-write.cjs write-audit.cjs README.md ./
COPY login.html login.js login.css ./
COPY docker-entrypoint.sh ./
COPY lib/ ./lib/
COPY migrations/ ./migrations/
COPY agent-profiles/ ./agent-profiles/
COPY agent-schemas/ ./agent-schemas/
COPY docs/ ./docs/
ENV NODE_ENV=production \
HERMES_SETUP_UI_HOST=0.0.0.0 \
@@ -52,7 +55,7 @@ RUN usermod -l hermes -d /home/hermes -m node \
USER hermes
EXPOSE 7843 8645 8646
EXPOSE 7843 8645 8646 8787 8790 8791 8792 8793
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["node", "/app/server.cjs"]
+228 -2
View File
@@ -46,6 +46,8 @@ HERMES_AUDIT_MAX_BYTES Max bytes per logged request body (default: 65536
HERMES_IMAGE Registry image Portainer pulls for app services
HERMES_AGENT_REF Pinned Hermes source revision baked into the image
HERMES_INTERNAL_API_SERVER_KEY Separate internal key required when enabling API gateways
HERMES_AGENT_EXECUTION_MODE Agent hierarchy mode: disabled or fake (default: disabled)
HERMES_AGENT_CONTROLLER_TOKEN Internal bearer token for the agent controller API
HERMES_DEFAULT_PROVIDER Provider used when an API request omits model/provider (default: openai-codex)
HERMES_DEFAULT_THINKING_EFFORT Effort used when an API request omits reasoning/thinking effort (default: medium)
```
@@ -182,6 +184,220 @@ HERMES_IMAGE=10.0.3.6:4000/zachariahsharma/hermes-control-plane:latest
Portainer pulls the latest Git version when it redeploys the Stack. A normal Docker container restart does not pull Git or rebuild the image, so use Portainer's webhook/polling redeploy for updates.
### Vynte Internal MCP
The stack includes an internal-only MCP adapter named `vynte-internal-mcp`.
It exposes `http://vynte-internal-mcp:8787/mcp` on the Compose network and is
registered in Hermes as a URL-mode MCP server at container startup. Disable
that auto-registration by setting `HERMES_REGISTER_VYNTE_INTERNAL_MCP=false`.
Set `HERMES_VYNTE_INTERNAL_MCP_TOKEN` to a long random value before adding real
service tokens; Hermes stores it in `.hermes/.env` and sends it as a bearer
token when calling the adapter.
The custom Forms/FormBuilder app is connected as its own MCP server at
`https://forms.internal.vyntehome.com/api/mcp`, not through the generic Vynte
adapter. Set `HERMES_FORMS_MCP_TOKEN` to a rotated Forms MCP token; Hermes
stores it in `.hermes/.env` and sends it as a bearer token to the Forms MCP
endpoint. Disable that direct registration with `HERMES_REGISTER_FORMS_MCP=false`.
The external SaaS MCP foundation runs as `hermes-external-saas-mcp` with fake
Google Drive, Gmail, and Slack adapters only. It is not registered by default.
To enable it for post-Hermes only, set `HERMES_REGISTER_EXTERNAL_SAAS_MCP=true`
and `HERMES_EXTERNAL_SAAS_MCP_TOKEN` to a long random value. Pre-Hermes
explicitly keeps this registration disabled so raw pre-Hermes requests remain
tool-free.
The adapter is read-only by default:
- `vynte_services_list` returns the configured Vynte service catalog.
- `vynte_service_health_check` checks a catalog URL with `HEAD`/`GET` and does
not return response bodies.
- `vynte_api_request_readonly` allows only `GET` or `HEAD` requests against
configured service base URLs and caps returned response previews.
- Focused read tools are included for Plane projects/work items, Cal event
types/slots/bookings, Twenty CRM records, and Plunk contacts/campaigns.
- Plane exposes focused state/label/member reads and disabled-by-default
work-item actions.
- Cal exposes disabled-by-default actions for booking create/reschedule/cancel/
confirm/decline, attendee add/remove, guest add, event type create/update,
and schedule create/update.
- Twenty exposes disabled-by-default actions for creating/updating allowlisted
records and creating tasks/notes.
- Plunk exposes disabled-by-default actions for contact upsert/update,
subscription changes, campaign draft creation, and campaign test sends.
Real campaign sends are intentionally not exposed.
### Container Provisioner MCP
The stack includes a first reviewable `container-provisioner` broker service at
`http://container-provisioner:8792/mcp`. It is the only service that may receive
Docker authority for Hermes automation containers:
- No Hermes service gets the Docker socket.
- The provisioner service mounts `/var/run/docker.sock`; no other compose
service should mount it.
- `CONTAINER_PROVISIONER_EXECUTION_ENABLED=false` by default, so profile
listing, validation, and audited dry-runs remain available before execution is
enabled.
- Real creates use `container_provision_create`, which accepts a profile ID only
and builds the Docker create/start spec from reviewed profile policy. Requests
cannot pass arbitrary Docker flags, bind mounts, ports, capabilities, or
commands.
- Created containers use the `hermes-automation-` name prefix, explicit
broker-only start/stop/remove lifecycle tools,
no network by default, no privileged mode, no bind mounts, no published ports,
dropped Linux capabilities, read-only rootfs, resource caps, and Hermes
automation labels.
- Broker actions are recorded in the `container_events` table and, when
configured, appended to `CONTAINER_PROVISIONER_AUDIT_PATH`.
- Hermes does not register the MCP unless
`HERMES_REGISTER_CONTAINER_PROVISIONER_MCP=true` and
`HERMES_CONTAINER_PROVISIONER_MCP_TOKEN` is set.
- Pre-Hermes keeps the provisioner unregistered even when the post-Hermes
registration flag is enabled.
Container profiles are stored in Postgres by migration
`003_container_provisioner.sql`. Profiles must use digest-pinned internal
registry images under the configured
`CONTAINER_PROVISIONER_ALLOWED_IMAGE_PREFIXES`, must not request privileged mode,
host bind mounts, host PID/IPC namespaces, devices, environment values/secrets,
published ports, reserved labels, or extra Linux capabilities,
and must fit the resource/TTL caps enforced by
`lib/container-provisioner-policy.cjs`.
Create/start/stop/remove execution requires the same user-approval-bundle
metadata shape as focused writes: approval ID, approval summary, reason, and
idempotency key. The broker stores only digests for summary, reason, and
idempotency metadata, verifies managed labels before lifecycle calls, and never
accepts a caller-supplied Docker ID, image, command, mount, or runtime flag.
The reviewed default smoke profile in `.env.example` points at a tiny
short-lived internal-registry image and has `executionEnabled=false`. Live
deployments may set that profile flag and
`CONTAINER_PROVISIONER_EXECUTION_ENABLED=true` only after validation and dry-run
checks pass.
### Tiered Specialist Agent Runtime Foundation
The stack includes an internal-only `hermes-agent-controller` service at
`http://hermes-agent-controller:8793`. It provides the first hierarchy runtime
foundation:
- Built-in orchestrator, coordinator, and worker profiles are synchronized into
Postgres at startup.
- Delegation requests and result envelopes are validated by stable contracts in
`lib/agent/contracts.cjs`; schema artifacts live in `agent-schemas/`.
- Policy enforcement rejects worker delegation, depth greater than 2, disallowed
child profiles, disallowed context/capability requests, expired timeouts, and
child budgets that exceed profile or parent remaining limits.
- Delegation create/list/cancel APIs are exposed at `/v1/agent/*` on the
internal controller and `/api/admin/agent/*` through the authenticated control
plane.
- `HERMES_AGENT_EXECUTION_MODE=disabled` by default. This creates audited
queued records but does not launch Hermes, tools, MCPs, containers, or live
subagents.
The hierarchy schema is installed by `004_agent_hierarchy_core.sql`. Live
subagent execution is intentionally not enabled here; a later adapter must add
isolated post-Hermes runtime dispatch, context-pack construction, grant delivery,
broker approvals, and strict result-envelope parsing before any autonomous work
is allowed.
All write actions have generated paths and fixed request bodies only; there is
no generic write request tool and no delete, host-reassignment, or slot
reservation action.
Every write action defaults to `dryRun:true`. Dry runs validate the generated
tool path and field schemas, then return the approval requirements without
making an upstream request. Execution requires all of the following:
```text
VYNTE_MCP_WRITES_ENABLED=true
VYNTE_MCP_WRITE_POLICY=strict_allowlist
VYNTE_PLANE_ALLOWED_WORKSPACES=<comma-separated approved workspace slugs>
VYNTE_PLANE_ALLOWED_PROJECTS=<comma-separated approved project IDs>
VYNTE_PLANE_ALLOWED_WRITE_PATHS=<comma-separated approved path prefixes>
VYNTE_TWENTY_ALLOWED_OBJECTS=<comma-separated approved Twenty objects, e.g. people,companies,tasks,notes>
VYNTE_TWENTY_ALLOWED_WRITE_PATHS=<comma-separated approved Twenty path prefixes>
VYNTE_PLUNK_ALLOWED_WRITE_PATHS=<comma-separated approved Plunk path prefixes>
VYNTE_PLUNK_ALLOWED_EMAIL_DOMAINS=<comma-separated approved recipient/sender domains>
VYNTE_CAL_ALLOWED_WRITE_PATHS=<comma-separated approved Cal path prefixes, normally /v2/bookings,/v2/event-types,/v2/schedules>
VYNTE_MCP_WRITE_AUDIT_PATH=<durable JSONL audit path writable by the MCP service>
```
In the default `strict_allowlist` policy, the tool call must also include
`dryRun:false`, the exact `confirmOperation` value returned by dry run, a 3-500
character `reason`, and an 8-200 character `idempotencyKey`.
For Hermes-mediated user approval, set:
```text
VYNTE_MCP_WRITES_ENABLED=true
VYNTE_MCP_WRITE_POLICY=user_approval_bundle
VYNTE_MCP_WRITE_AUDIT_HOST=/opt/hermes-control-plane/audit/vynte-mcp
VYNTE_MCP_WRITE_AUDIT_PATH=/var/log/hermes/vynte-mcp/write-audit.jsonl
```
In `user_approval_bundle` mode, Plane workspace/project/path allowlists,
Twenty object/path allowlists, Cal path allowlists, and Plunk path allowlists
are not required. Hermes must present the whole intended action bundle to the
user before execution, for example: "I'm going to create this ticket, assign
this person, and add this note." After the user approves that bundle once,
Hermes must pass the same stable `approvalId`, a concise `approvalSummary`,
a 3-500 character `reason`, `dryRun:false`, and a unique 8-200 character
`idempotencyKey` to every write tool call in that approved bundle. Missing or
invalid approval metadata fails closed before any upstream request.
Destructive or high-impact Cal actions still require the exact `confirmImpact`
string returned by dry run. Audit entries store operation metadata, status,
identifiers, approval id, and SHA-256 digests only; they must not contain
request bodies, response bodies, reasons, approval summaries, tokens, raw
idempotency keys, emails, or phone numbers.
Keep `VYNTE_MCP_WRITES_ENABLED=false` until write tokens are scoped to the
supported focused actions and a durable `VYNTE_MCP_WRITE_AUDIT_PATH` is
configured. Before enabling Twenty writes, replace the read-only Twenty token
with a token scoped to the intended CRM write APIs. Before enabling Plunk
writes, replace the read-only Plunk token with a token scoped to contacts,
draft campaigns, and test sends only; do not grant real campaign-send authority
to Hermes. Before enabling Cal writes, replace the read-only Cal token with a
scoped Cal token that has only the needed `BOOKING_WRITE`, `EVENT_TYPE_WRITE`,
and/or `SCHEDULE_WRITE` authority for the intended account; do not grant
delete, host reassignment, or slot reservation authority.
Set service tokens in Portainer only for services Hermes should be able to read:
```text
VYNTE_PLANE_TOKEN=<Plane read API token>
VYNTE_GITEA_TOKEN=<Gitea read-only token>
VYNTE_HERMES_PRE_API_TOKEN=<Hermes pre-gateway read API token>
VYNTE_HERMES_POST_API_TOKEN=<Hermes post-gateway read API token>
VYNTE_OPENWEBUI_TOKEN=<Open WebUI read API token>
VYNTE_PORTAINER_TOKEN=<Portainer read-only/API token>
VYNTE_NPM_TOKEN=<Nginx Proxy Manager read-only/API token>
VYNTE_AUTHENTIK_TOKEN=<Authentik read-only API token>
VYNTE_NETBIRD_TOKEN=<NetBird read-only management token>
VYNTE_MEDIA_TOKEN=<media service read token>
VYNTE_PENPOT_TOKEN=<Penpot read API token>
VYNTE_PLAUSIBLE_TOKEN=<Plausible read API token>
VYNTE_PLUNK_TOKEN=<Plunk read API token>
VYNTE_CAL_TOKEN=<Cal read API token>
VYNTE_POSTIZ_TOKEN=<Postiz read API token>
VYNTE_TWENTY_TOKEN=<Twenty read API token>
HERMES_FORMS_MCP_TOKEN=<rotated Forms MCP token>
```
Plane API keys use `X-API-Key` by default via `VYNTE_PLANE_AUTH_HEADER`. Cal
read requests include `VYNTE_CAL_API_VERSION`, defaulting to `2026-05-01`.
Cal write actions use endpoint-specific v2 headers: booking create/reschedule/
cancel/confirm/decline and attendee removal use `2026-02-25`, attendee/guest
addition uses `2024-08-13`, event types use `2024-06-14`, and schedules use
`2024-06-11`. Before enabling writes, replace the read-only Plane token with a
scoped Plane token approved for only the allowlisted projects and actions.
Infrastructure-control services such as Portainer, NPM, Authentik, and NetBird
should stay read-only unless the operator explicitly approves a change.
### Nginx Proxy Manager
The compose stack binds published ports to `127.0.0.1` by default via `HERMES_PUBLISHED_BIND_IP`. This prevents clients on the LAN or internet from bypassing Nginx Proxy Manager by calling `http://<docker-host>:7843`, `:8645`, or `:8646` directly.
@@ -210,6 +426,7 @@ HERMES_ADMIN_PASSWORD=<long random password>
HERMES_ADMIN_COOKIE_SECURE=true
POSTGRES_PASSWORD=<long random password>
HERMES_HOME_HOST=/opt/hermes-control-plane/hermes
HERMES_PRE_HOME_HOST=/opt/hermes-control-plane/hermes-pre
CODEX_HOME_HOST=/opt/hermes-control-plane/codex
CLAUDE_HOME_HOST=/opt/hermes-control-plane/claude
GEMINI_HOME_HOST=/opt/hermes-control-plane/gemini
@@ -221,6 +438,7 @@ Recommended persistent host layout on the Portainer host:
```text
/opt/hermes-control-plane/hermes
/opt/hermes-control-plane/hermes-pre
/opt/hermes-control-plane/codex
/opt/hermes-control-plane/claude
/opt/hermes-control-plane/gemini
@@ -273,10 +491,17 @@ both API profiles when you want the OpenAI-compatible endpoints.
```text
Default services: hermes-postgres, hermes-control-plane
Pre profile: hermes-ai-upstream, hermes-pre-api
Post profile: hermes-ai-upstream, hermes-post-api
Pre profile: hermes-pre-upstream, hermes-pre-api
Post profile: hermes-post-upstream, hermes-post-api
```
The pre and post gateways intentionally use different upstream Hermes
runtimes. `hermes-pre-upstream` stores state in `HERMES_PRE_HOME_HOST` and
starts with MCP registration disabled, so raw Codex, Claude, or Gemini model
calls do not inherit the Forms or Vynte internal tools. `hermes-post-upstream`
shares the main `HERMES_HOME_HOST` state with the control plane and keeps MCP
registration enabled for agent/tool workflows.
Enable optional gateway profiles only after their prerequisites are configured.
With Docker Compose, use `--profile pre-gateway --profile post-gateway`. In
Portainer, set `COMPOSE_PROFILES=pre-gateway,post-gateway` in the stack
@@ -294,6 +519,7 @@ HERMES_DEFAULT_THINKING_EFFORT=medium
HERMES_DEFAULT_CLAUDE_MODEL=claude-sonnet-4.6
HERMES_DEFAULT_CODEX_MODEL=gpt-5.4
HERMES_DEFAULT_GEMINI_MODEL=gemini-3.5-flash
HERMES_PRE_HOME_HOST=/opt/hermes-control-plane/hermes-pre
```
The public pre and post APIs accept the control plane's `hms_` user keys.
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
"use strict"
const http = require("http")
const { createPool, runMigrations } = require("./lib/db.cjs")
const { required } = require("./lib/config.cjs")
const { createAgentRouteHandler } = require("./lib/agent/controller-routes.cjs")
const { loadProfileRegistry } = require("./lib/agent/profile-registry.cjs")
const { syncProfiles } = require("./lib/agent/profile-store.cjs")
const { sendJson } = require("./lib/http.cjs")
const HOST = process.env.HERMES_AGENT_CONTROLLER_HOST || "127.0.0.1"
const PORT = Number(process.env.HERMES_AGENT_CONTROLLER_PORT || 8792)
const EXECUTION_MODE = process.env.HERMES_AGENT_EXECUTION_MODE || "disabled"
async function main() {
if (!["disabled", "fake-live"].includes(EXECUTION_MODE)) {
throw new Error("HERMES_AGENT_EXECUTION_MODE must be disabled or fake-live")
}
const databaseUrl = required("DATABASE_URL")
const bearerToken = required("HERMES_AGENT_CONTROLLER_TOKEN")
const pool = createPool(databaseUrl)
await runMigrations(pool)
await syncProfiles(pool, loadProfileRegistry())
const agentHandler = createAgentRouteHandler({
pool,
bearerToken,
executionMode: EXECUTION_MODE,
routePrefix: "/v1/agent",
})
const server = http.createServer(async (req, res) => {
if (req.method === "GET" && req.url.split("?")[0] === "/healthz") {
sendJson(res, 200, { ok: true, executionMode: EXECUTION_MODE })
return
}
await agentHandler(req, res)
})
await new Promise((resolve) => {
server.listen(PORT, HOST, () => {
console.log(`Hermes agent controller listening on http://${HOST}:${PORT}`)
console.log(` execution mode: ${EXECUTION_MODE}`)
resolve()
})
})
const shutdown = async () => {
server.close(() => {})
await pool.end().catch(() => {})
process.exit(0)
}
process.on("SIGINT", shutdown)
process.on("SIGTERM", shutdown)
}
if (require.main === module) {
main().catch((err) => {
console.error(err.message)
process.exit(1)
})
}
+35
View File
@@ -0,0 +1,35 @@
# Agent Profile Manifests
The first runtime foundation keeps built-in profile manifests in
`lib/agent/profile-registry.cjs` so startup can synchronize immutable,
versioned profiles without adding a YAML parser dependency.
The active manifest set includes:
- `orchestrator.hermes`
- Coordinators: `coordinator.software`, `coordinator.sales`,
`coordinator.marketing`, `coordinator.communications`,
`coordinator.operations`, `coordinator.automation`
- Software workers: `worker.software.repo-analysis`,
`worker.software.implementation`, `worker.software.test-verification`,
`worker.software.code-review`
- Sales workers: `worker.sales.account-research`,
`worker.sales.pipeline-analysis`, `worker.sales.meeting-prep`,
`worker.sales.follow-up-draft`
- Marketing workers: `worker.marketing.campaign-analysis`,
`worker.marketing.content-draft`, `worker.marketing.seo-research`,
`worker.marketing.performance-report`
- Communications workers: `worker.communications.email-draft`,
`worker.communications.slack-draft`,
`worker.communications.announcement-draft`,
`worker.communications.response-triage`
- Operations workers: `worker.operations.service-triage`,
`worker.operations.runbook-lookup`, `worker.operations.incident-summary`,
`worker.operations.change-plan-draft`
- Automation workers: `worker.automation.design`,
`worker.automation.validate`, `worker.automation.execute`
Future editable profile files should conform to `profile.schema.json`. Any
runtime-editable manifest loader must preserve the existing immutability rule:
an existing `(id, version)` hash mismatch fails startup instead of rewriting
profiles already referenced by executions.
+65
View File
@@ -0,0 +1,65 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.vyntehome.com/schemas/agent-profile.schema.json",
"title": "Hermes Agent Profile Manifest",
"type": "object",
"required": ["apiVersion", "kind", "metadata", "spec"],
"properties": {
"apiVersion": { "const": "hermes.vyntehome.com/v1" },
"kind": { "const": "AgentProfile" },
"metadata": {
"type": "object",
"required": ["id", "version", "displayName"],
"properties": {
"id": { "type": "string", "minLength": 1, "maxLength": 120 },
"version": { "type": "integer", "minimum": 1 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 160 }
},
"additionalProperties": false
},
"spec": {
"type": "object",
"required": ["tier", "domain", "objective", "canDelegate", "allowedChildProfiles", "allowedContextSources", "allowedCapabilityScopes", "allowedBrokerClasses", "defaultBudget", "maxChildBudget", "contextLimits", "resultContract", "systemPromptRef"],
"properties": {
"tier": { "enum": ["orchestrator", "coordinator", "worker"] },
"domain": { "type": "string", "minLength": 1, "maxLength": 80 },
"objective": { "type": "string", "minLength": 1, "maxLength": 1000 },
"canDelegate": { "type": "boolean" },
"allowedChildProfiles": { "type": "array", "items": { "type": "string" } },
"allowedContextSources": { "type": "array", "items": { "type": "string" } },
"allowedCapabilityScopes": { "type": "array", "items": { "type": "string" } },
"allowedBrokerClasses": { "type": "array", "items": { "enum": ["approval", "credential", "container", "automation"] } },
"defaultBudget": { "$ref": "#/$defs/budget" },
"maxChildBudget": { "$ref": "#/$defs/budget" },
"contextLimits": {
"type": "object",
"required": ["maxBytes", "maxItems", "maxItemBytes"],
"properties": {
"maxBytes": { "type": "integer", "minimum": 1 },
"maxItems": { "type": "integer", "minimum": 1 },
"maxItemBytes": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
},
"resultContract": { "const": "hermes.agent.result.v1" },
"systemPromptRef": { "type": "string", "minLength": 1, "maxLength": 240 }
},
"additionalProperties": false
}
},
"additionalProperties": false,
"$defs": {
"budget": {
"type": "object",
"required": ["runtimeMs", "toolCalls", "inputTokens", "outputTokens", "costUsdMicros"],
"properties": {
"runtimeMs": { "type": "integer", "minimum": 1 },
"toolCalls": { "type": "integer", "minimum": 1 },
"inputTokens": { "type": "integer", "minimum": 1 },
"outputTokens": { "type": "integer", "minimum": 1 },
"costUsdMicros": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
}
}
}
@@ -0,0 +1,82 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.vyntehome.com/schemas/delegation-request.schema.json",
"title": "Hermes Agent Delegation Request",
"type": "object",
"required": ["requestedProfile", "objective", "budget", "timeoutAt", "idempotencyKey"],
"properties": {
"schema": { "const": "hermes.agent.delegation.v1" },
"requestId": { "type": "string", "minLength": 1, "maxLength": 120 },
"parentExecutionId": { "type": ["string", "null"], "maxLength": 120 },
"requestedProfile": {
"type": "object",
"required": ["id", "version"],
"properties": {
"id": { "type": "string", "minLength": 1, "maxLength": 120 },
"version": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
},
"objective": { "type": "string", "minLength": 1, "maxLength": 2000 },
"acceptanceCriteria": {
"type": "array",
"maxItems": 20,
"items": { "type": "string", "minLength": 1, "maxLength": 500 }
},
"constraints": {
"type": "array",
"maxItems": 20,
"items": { "type": "string", "minLength": 1, "maxLength": 500 }
},
"contextSelectors": {
"type": "array",
"maxItems": 20,
"items": {
"type": "object",
"required": ["source", "selector", "purpose"],
"properties": {
"source": { "type": "string", "minLength": 1, "maxLength": 120 },
"selector": { "type": "object" },
"purpose": { "type": "string", "minLength": 1, "maxLength": 240 }
},
"additionalProperties": false
}
},
"requestedCapabilities": {
"type": "array",
"maxItems": 20,
"items": {
"type": "object",
"required": ["scope", "resource", "operations"],
"properties": {
"scope": { "type": "string", "minLength": 1, "maxLength": 120 },
"resource": { "type": "string", "minLength": 1, "maxLength": 240 },
"operations": {
"type": "array",
"maxItems": 20,
"items": { "type": "string", "minLength": 1, "maxLength": 80 }
}
},
"additionalProperties": false
}
},
"budget": { "$ref": "#/$defs/budget" },
"timeoutAt": { "type": "string", "format": "date-time" },
"idempotencyKey": { "type": "string", "minLength": 1, "maxLength": 180 }
},
"additionalProperties": false,
"$defs": {
"budget": {
"type": "object",
"required": ["runtimeMs", "toolCalls", "inputTokens", "outputTokens", "costUsdMicros"],
"properties": {
"runtimeMs": { "type": "integer", "minimum": 1 },
"toolCalls": { "type": "integer", "minimum": 1 },
"inputTokens": { "type": "integer", "minimum": 1 },
"outputTokens": { "type": "integer", "minimum": 1 },
"costUsdMicros": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
}
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.vyntehome.com/schemas/result-envelope.schema.json",
"title": "Hermes Agent Result Envelope",
"type": "object",
"required": ["schema", "auditId", "executionId", "delegationId", "profile", "status", "summary", "outputs", "artifacts", "delegations", "budgetUsage", "capabilityUsage", "warnings", "error", "startedAt", "completedAt"],
"properties": {
"schema": { "const": "hermes.agent.result.v1" },
"auditId": { "type": "string", "minLength": 1, "maxLength": 120 },
"executionId": { "type": "string", "minLength": 1, "maxLength": 120 },
"delegationId": { "type": "string", "minLength": 1, "maxLength": 120 },
"profile": {
"type": "object",
"required": ["id", "version"],
"properties": {
"id": { "type": "string", "minLength": 1, "maxLength": 120 },
"version": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
},
"status": { "enum": ["succeeded", "failed", "cancelled", "timed_out", "budget_exhausted", "rejected"] },
"summary": { "type": "string", "minLength": 1, "maxLength": 4000 },
"outputs": { "type": "array", "maxItems": 100, "items": { "type": "object" } },
"artifacts": { "type": "array", "maxItems": 50, "items": { "type": "object" } },
"delegations": { "type": "array", "maxItems": 20, "items": { "type": "object" } },
"budgetUsage": { "$ref": "#/$defs/budget" },
"capabilityUsage": { "type": "array", "maxItems": 50, "items": { "type": "object" } },
"warnings": { "type": "array", "maxItems": 50, "items": { "type": "string", "maxLength": 500 } },
"error": { "type": ["object", "null"] },
"startedAt": { "type": "string", "format": "date-time" },
"completedAt": { "type": "string", "format": "date-time" }
},
"additionalProperties": false,
"$defs": {
"budget": {
"type": "object",
"required": ["runtimeMs", "toolCalls", "inputTokens", "outputTokens", "costUsdMicros"],
"properties": {
"runtimeMs": { "type": "integer", "minimum": 1 },
"toolCalls": { "type": "integer", "minimum": 1 },
"inputTokens": { "type": "integer", "minimum": 1 },
"outputTokens": { "type": "integer", "minimum": 1 },
"costUsdMicros": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
}
}
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env node
"use strict"
const { createPool, runMigrations } = require("./lib/db.cjs")
const { required } = require("./lib/config.cjs")
const { resolveContextSelectors } = require("./lib/agent/context-resolver.cjs")
const { executeLeasedTask } = require("./lib/agent/worker-runtime.cjs")
const {
activeGrants,
buildContextPack,
completeExecution,
expireTimedOutExecutions,
leaseNextExecution,
startExecution,
} = require("./lib/agent/worker-store.cjs")
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function main() {
const pool = createPool(required("DATABASE_URL"))
const owner = process.env.HERMES_AGENT_WORKER_ID || `agent-worker-${process.pid}`
const providerMode = process.env.HERMES_AGENT_WORKER_PROVIDER_MODE || "fake-live"
const pollMs = Number(process.env.HERMES_AGENT_WORKER_POLL_MS || 1000)
await runMigrations(pool)
console.log(`Hermes isolated agent worker started in ${providerMode} mode`)
let stopping = false
process.on("SIGTERM", () => { stopping = true })
process.on("SIGINT", () => { stopping = true })
while (!stopping) {
try {
await expireTimedOutExecutions(pool)
const task = await leaseNextExecution(pool, { owner })
if (!task) {
await sleep(pollMs)
continue
}
const items = await resolveContextSelectors(task.delegation.contextSelectors, {
systemKnowledgeUrl: process.env.HERMES_SYSTEM_KNOWLEDGE_MCP_URL,
systemKnowledgeToken: process.env.KNOWLEDGE_MCP_SERVER_TOKEN,
})
task.contextPack = await buildContextPack(pool, task, items)
await startExecution(pool, task)
const grants = await activeGrants(pool, task.execution.id)
const result = await executeLeasedTask(task, { grants, providerMode })
await completeExecution(pool, task, result)
} catch (err) {
console.error(`agent worker cycle failed: ${err.message}`)
await sleep(pollMs)
}
}
await pool.end()
}
if (require.main === module) main().catch((err) => {
console.error(err.message)
process.exit(1)
})
+497
View File
@@ -0,0 +1,497 @@
"use strict"
const crypto = require("crypto")
const http = require("http")
const { Pool } = require("pg")
const {
actionFingerprint,
evaluateActionPolicy,
nextOccurrence,
} = require("./lib/automation-runtime.cjs")
const RISK_LEVELS = Object.freeze(["low", "medium", "high", "critical"])
const APPROVERS = Object.freeze({ low: 0, medium: 0, high: 1, critical: 2 })
const TOOLS = Object.freeze([
["automation_script_create_draft", "Create a draft automation script."],
["automation_job_create_draft", "Create a draft automation job."],
["automation_jobs_list", "List automation jobs."],
["automation_job_get", "Get an automation job."],
["automation_runs_list", "List automation runs."],
["automation_run_get", "Get an automation run."],
["automation_policy_check", "Check automation policy."],
["automation_approval_request", "Create an immutable approval bundle for a script."],
["automation_approval_decide", "Approve or reject an immutable execution bundle. Requires the separate approver token."],
["automation_job_activate", "Activate a bounded automation job after policy and approval checks."],
["automation_run_enqueue", "Enqueue an immediate run for an active bounded automation job."],
["automation_job_pause", "Pause an automation job."],
["automation_run_cancel", "Request cancellation of an automation run."],
].map(([name, description]) => ({ name, description, inputSchema: { type: "object", additionalProperties: true } })))
class McpError extends Error {
constructor(code, message) {
super(message)
this.code = code
}
}
function requireValue(value, name) {
if (value === undefined || value === null || value === "") throw new McpError("invalid_input", `${name} is required`)
return value
}
function evaluatePolicy(input) {
const risk = String(input.risk || "")
if (!RISK_LEVELS.includes(risk)) throw new McpError("invalid_risk", `Invalid risk level: ${risk}`)
const minimumApprovers = APPROVERS[risk]
return {
risk,
allowed: true,
executionEnabled: true,
executionMode: "bounded",
approval: {
required: minimumApprovers > 0,
minimumApprovers,
},
}
}
function validateSchedule(schedule) {
requireValue(schedule, "schedule")
const type = String(schedule.type || "")
if (!["once", "interval", "cron"].includes(type)) throw new McpError("invalid_schedule", "schedule.type must be once, interval, or cron")
if (type === "once") requireValue(schedule.runAt, "schedule.runAt")
if (type === "interval") {
const seconds = Number(schedule.everySeconds)
if (!Number.isInteger(seconds) || seconds < 60) throw new McpError("invalid_schedule", "schedule.everySeconds must be an integer >= 60")
requireValue(schedule.anchor, "schedule.anchor")
}
if (type === "cron") {
const expression = String(requireValue(schedule.expression, "schedule.expression")).trim()
if (expression.split(/\s+/).length !== 5) throw new McpError("invalid_schedule", "schedule.expression must be a five-field cron expression")
}
return type
}
function createPool(databaseUrl) {
return new Pool({
connectionString: databaseUrl,
connectionTimeoutMillis: 5000,
query_timeout: 10000,
idleTimeoutMillis: 30000,
})
}
async function writeEvent(pool, actorId, action, subjectType, subjectId, payload) {
await pool.query(
`insert into automation_events (actor_id, action, subject_type, subject_id, payload)
values ($1, $2, $3, $4, $5::jsonb)`,
[actorId, action, subjectType, subjectId, JSON.stringify(payload || {})]
)
}
function rowToScript(row) {
return {
id: row.id,
name: row.name,
description: row.description,
status: row.status,
risk: row.risk,
definition: row.definition,
version: row.version,
createdBy: row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function rowToJob(row) {
return {
id: row.id,
scriptId: row.script_id,
name: row.name,
status: row.status,
scheduleType: row.schedule_type,
schedule: row.schedule,
nextRunAt: row.next_run_at,
createdBy: row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function rowToRun(row) {
return {
id: row.id,
jobId: row.job_id,
scriptId: row.script_id,
status: row.status,
scheduledFor: row.scheduled_for,
startedAt: row.started_at,
completedAt: row.completed_at,
requestedBy: row.requested_by,
result: row.result,
error: row.error,
approvalId: row.approval_id,
leaseOwner: row.lease_owner,
leaseExpiresAt: row.lease_expires_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
async function createScriptDraft(pool, principal, args) {
const name = String(requireValue(args.name, "name")).trim()
const description = String(args.description || "")
const risk = String(requireValue(args.risk, "risk"))
const definition = requireValue(args.definition, "definition")
const policy = evaluatePolicy({ risk })
const { rows } = await pool.query(
`insert into automation_scripts (name, description, risk, definition, created_by)
values ($1, $2, $3, $4::jsonb, $5)
returning *`,
[name, description, risk, JSON.stringify(definition), principal.id]
)
const script = rowToScript(rows[0])
await writeEvent(pool, principal.id, "script.create_draft", "script", script.id, { script, policy })
return script
}
async function createJobDraft(pool, principal, args) {
const name = String(requireValue(args.name, "name")).trim()
const scriptId = String(requireValue(args.scriptId, "scriptId"))
const schedule = requireValue(args.schedule, "schedule")
const scheduleType = validateSchedule(schedule)
const script = await pool.query("select id from automation_scripts where id = $1 and status = 'draft'", [scriptId])
if (script.rows.length === 0) throw new McpError("script_not_found", "Draft script not found")
const { rows } = await pool.query(
`insert into automation_jobs (script_id, name, schedule_type, schedule, created_by)
values ($1, $2, $3, $4::jsonb, $5)
returning *`,
[scriptId, name, scheduleType, JSON.stringify(schedule), principal.id]
)
const job = rowToJob(rows[0])
await writeEvent(pool, principal.id, "job.create_draft", "job", job.id, { job })
return job
}
function rowToApproval(row) {
if (!row) return null
return {
id: row.id,
subjectType: row.subject_type,
subjectId: row.subject_id,
risk: row.risk,
status: row.status,
minimumApprovers: row.minimum_approvers,
decisions: row.decisions,
bundleHash: row.bundle_hash,
requestedBy: row.requested_by,
expiresAt: row.expires_at,
}
}
async function requestApproval(pool, principal, args, options) {
const scriptId = String(requireValue(args.scriptId, "scriptId"))
const { rows } = await pool.query("select * from automation_scripts where id = $1", [scriptId])
if (!rows[0]) throw new McpError("script_not_found", "Script not found")
const script = rows[0]
let actionPolicy
try {
actionPolicy = evaluateActionPolicy(script.definition, options.env)
} catch (err) {
if (!/approval/i.test(err.message)) throw new McpError("policy_denied", err.message)
actionPolicy = { approvalRequired: true }
}
const riskPolicy = evaluatePolicy({ risk: script.risk })
const minimumApprovers = Math.max(riskPolicy.approval.minimumApprovers, actionPolicy.approvalRequired ? 1 : 0)
if (minimumApprovers === 0) throw new McpError("approval_not_required", "This script does not require approval")
const expiresInSeconds = Math.min(Math.max(Number(args.expiresInSeconds || 3600), 60), 86_400)
const bundleHash = actionFingerprint(script.definition)
const result = await pool.query(
`insert into automation_approvals
(subject_type, subject_id, risk, minimum_approvers, requested_by, expires_at, bundle_hash)
values ('script', $1, $2, $3, $4, now() + ($5::text || ' seconds')::interval, $6)
returning *`,
[script.id, script.risk, minimumApprovers, principal.id, expiresInSeconds, bundleHash]
)
const approval = rowToApproval(result.rows[0])
await writeEvent(pool, principal.id, "approval.request", "approval", approval.id, { approval })
return approval
}
async function decideApproval(pool, principal, args) {
if (!["approver", "admin"].includes(principal.role)) throw new McpError("forbidden", "Separate approver authorization is required")
const id = String(requireValue(args.id, "id"))
const decision = String(requireValue(args.decision, "decision"))
if (!["approved", "rejected"].includes(decision)) throw new McpError("invalid_decision", "decision must be approved or rejected")
const current = await pool.query("select * from automation_approvals where id = $1", [id])
const approval = current.rows[0]
if (!approval || approval.status !== "pending") throw new McpError("approval_not_pending", "Approval is not pending")
if (new Date(approval.expires_at) <= new Date()) throw new McpError("approval_expired", "Approval has expired")
const decisions = Array.isArray(approval.decisions) ? approval.decisions : []
if (decisions.some((item) => item.principalId === principal.id)) throw new McpError("duplicate_decision", "Principal already decided")
const nextDecisions = [...decisions, { principalId: principal.id, decision, decidedAt: new Date().toISOString() }]
const approvedCount = new Set(nextDecisions.filter((item) => item.decision === "approved").map((item) => item.principalId)).size
const status = decision === "rejected" ? "rejected" : approvedCount >= approval.minimum_approvers ? "approved" : "pending"
const result = await pool.query(
"update automation_approvals set decisions = $1::jsonb, status = $2 where id = $3 returning *",
[JSON.stringify(nextDecisions), status, id]
)
const updated = rowToApproval(result.rows[0])
await writeEvent(pool, principal.id, `approval.${decision}`, "approval", id, { approval: updated })
return updated
}
async function activateJob(pool, principal, args, options) {
const id = String(requireValue(args.id, "id"))
const result = await pool.query(
`select j.*, s.risk, s.definition
from automation_jobs j join automation_scripts s on s.id = j.script_id
where j.id = $1 and j.status in ('draft', 'paused')`,
[id]
)
const job = result.rows[0]
if (!job) throw new McpError("job_not_found", "Draft or paused job not found")
let approval = null
if (args.approvalId) {
const approvalResult = await pool.query("select * from automation_approvals where id = $1", [String(args.approvalId)])
approval = approvalResult.rows[0] || null
}
let actionPolicy
try {
actionPolicy = evaluateActionPolicy(job.definition, options.env, approval)
} catch (err) {
throw new McpError("policy_denied", err.message)
}
const riskPolicy = evaluatePolicy({ risk: job.risk })
if (riskPolicy.approval.required && (!approval || approval.status !== "approved" || approval.bundle_hash !== actionFingerprint(job.definition))) {
throw new McpError("approval_required", "Approved immutable approval bundle is required")
}
const next = nextOccurrence(job.schedule, new Date(Date.now() - 1000))
await pool.query("update automation_scripts set status = 'active' where id = $1", [job.script_id])
const updated = await pool.query(
"update automation_jobs set status = 'active', approval_id = $1, next_run_at = $2 where id = $3 returning *",
[approval?.id || null, next?.toISOString() || null, id]
)
const activated = rowToJob(updated.rows[0])
await writeEvent(pool, principal.id, "job.activate", "job", id, { job: activated, actionPolicy })
return activated
}
async function enqueueRun(pool, principal, args) {
const id = String(requireValue(args.id, "id"))
const result = await pool.query(
`insert into automation_runs (job_id, script_id, status, scheduled_for, requested_by, approval_id)
select id, script_id, 'queued', now(), $2, approval_id from automation_jobs where id = $1 and status = 'active'
returning *`,
[id, principal.id]
)
if (!result.rows[0]) throw new McpError("job_not_found", "Active job not found")
const run = rowToRun(result.rows[0])
await writeEvent(pool, principal.id, "run.enqueue", "run", run.id, { run })
return run
}
async function callTool(pool, principal, name, args = {}, options = {}) {
if (name === "automation_script_create_draft") return createScriptDraft(pool, principal, args)
if (name === "automation_job_create_draft") return createJobDraft(pool, principal, args)
if (name === "automation_jobs_list") {
const { rows } = await pool.query("select * from automation_jobs order by created_at desc limit 200")
return { jobs: rows.map(rowToJob), executionEnabled: true, executionMode: "bounded" }
}
if (name === "automation_job_get") {
const { rows } = await pool.query("select * from automation_jobs where id = $1", [String(requireValue(args.id, "id"))])
return rows[0] ? rowToJob(rows[0]) : null
}
if (name === "automation_runs_list") {
const { rows } = await pool.query("select * from automation_runs order by scheduled_for desc limit 200")
return { runs: rows.map(rowToRun), executionEnabled: true, executionMode: "bounded" }
}
if (name === "automation_run_get") {
const { rows } = await pool.query("select * from automation_runs where id = $1", [String(requireValue(args.id, "id"))])
return rows[0] ? rowToRun(rows[0]) : null
}
if (name === "automation_policy_check") return evaluatePolicy(args)
if (name === "automation_approval_request") return requestApproval(pool, principal, args, options)
if (name === "automation_approval_decide") return decideApproval(pool, principal, args)
if (name === "automation_job_activate") return activateJob(pool, principal, args, options)
if (name === "automation_run_enqueue") return enqueueRun(pool, principal, args)
if (name === "automation_job_pause") {
const { rows } = await pool.query(
"update automation_jobs set status = 'paused' where id = $1 and status in ('draft', 'active') returning *",
[String(requireValue(args.id, "id"))]
)
if (!rows[0]) throw new McpError("job_not_found", "Job not found or cannot be paused")
const job = rowToJob(rows[0])
await writeEvent(pool, principal.id, "job.pause", "job", job.id, { job })
return job
}
if (name === "automation_run_cancel") {
const { rows } = await pool.query(
"update automation_runs set status = 'cancel_requested' where id = $1 and status in ('queued', 'running') returning *",
[String(requireValue(args.id, "id"))]
)
if (!rows[0]) throw new McpError("run_not_found", "Run not found or cannot be canceled")
const run = rowToRun(rows[0])
await writeEvent(pool, principal.id, "run.cancel", "run", run.id, { run })
return run
}
throw new McpError("unknown_tool", `Unknown or disabled tool: ${name}`)
}
function rpcResult(id, result) {
return { jsonrpc: "2.0", id, result }
}
function rpcError(id, code, message, data = undefined) {
const error = { code, message }
if (data) error.data = data
return { jsonrpc: "2.0", id, error }
}
async function handleRpc(pool, request, principal, options = {}) {
if (!request || request.jsonrpc !== "2.0" || typeof request.method !== "string") {
return rpcError(request?.id ?? null, -32600, "Invalid JSON-RPC request")
}
const id = Object.hasOwn(request, "id") ? request.id : null
try {
if (request.method === "initialize") {
return rpcResult(id, {
protocolVersion: "2024-11-05",
serverInfo: { name: "hermes-automation-control", version: "0.1.0" },
capabilities: { tools: {} },
})
}
if (request.method === "tools/list") return rpcResult(id, { tools: TOOLS })
if (request.method === "tools/call") {
const params = request.params || {}
return rpcResult(id, await callTool(pool, principal, params.name, params.arguments || {}, options))
}
return rpcError(id, -32601, `Method not found: ${request.method}`)
} catch (err) {
if (err instanceof McpError) return rpcError(id, -32000, err.message, { code: err.code })
return rpcError(id, -32603, "Internal error")
}
}
function readJsonBody(req, maxBytes = 1_000_000) {
return new Promise((resolve, reject) => {
const chunks = []
let total = 0
req.on("data", (chunk) => {
total += chunk.length
if (total > maxBytes) {
reject(Object.assign(new Error("request body too large"), { status: 413 }))
req.destroy()
return
}
chunks.push(chunk)
})
req.on("end", () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")))
} catch (err) {
reject(Object.assign(err, { status: 400 }))
}
})
req.on("error", reject)
})
}
function sendJson(res, status, body) {
const json = JSON.stringify(body)
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
"Content-Length": Buffer.byteLength(json),
})
res.end(json)
}
function createPrincipal(env) {
return {
id: env.AUTOMATION_CONTROL_MCP_PRINCIPAL_ID || "hermes-post-automation-control",
role: "writer",
}
}
function hasValidBearerAuth(req, token) {
const authorization = req.headers.authorization || ""
const expected = `Bearer ${token}`
const actualBuffer = Buffer.from(authorization)
const expectedBuffer = Buffer.from(expected)
return actualBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(actualBuffer, expectedBuffer)
}
function principalForRequest(req, env, writerPrincipal) {
if (env.AUTOMATION_CONTROL_APPROVER_TOKEN && hasValidBearerAuth(req, env.AUTOMATION_CONTROL_APPROVER_TOKEN)) {
return { id: env.AUTOMATION_CONTROL_APPROVER_PRINCIPAL_ID || "automation-user-approver", role: "approver" }
}
if (hasValidBearerAuth(req, env.AUTOMATION_CONTROL_MCP_TOKEN)) return writerPrincipal
return null
}
function createServer(options = {}) {
const env = options.env || process.env
const databaseUrl = options.databaseUrl || env.DATABASE_URL
const token = options.token || env.AUTOMATION_CONTROL_MCP_TOKEN
if (!databaseUrl) throw new Error("DATABASE_URL is required")
if (!token) throw new Error("AUTOMATION_CONTROL_MCP_TOKEN is required")
const pool = options.pool || createPool(databaseUrl)
const principal = options.principal || createPrincipal(env)
return http.createServer(async (req, res) => {
if (req.method === "GET" && req.url === "/healthz") {
sendJson(res, 200, { ok: true, service: "hermes-automation-control", executionEnabled: true, executionMode: "bounded", auth: "bearer" })
return
}
if (req.method !== "POST" || req.url !== "/mcp") {
sendJson(res, 404, { error: "not found" })
return
}
const requestPrincipal = principalForRequest(req, { ...env, AUTOMATION_CONTROL_MCP_TOKEN: token }, principal)
if (!requestPrincipal) {
sendJson(res, 401, { error: "unauthorized" })
return
}
let body
try {
body = await readJsonBody(req)
} catch (err) {
sendJson(res, err.status || 400, rpcError(null, -32700, "Parse error"))
return
}
if (Array.isArray(body)) {
if (body.length === 0) {
sendJson(res, 200, rpcError(null, -32600, "Invalid JSON-RPC batch"))
return
}
sendJson(res, 200, await Promise.all(body.map((item) => handleRpc(pool, item, requestPrincipal, { env }))))
return
}
sendJson(res, 200, await handleRpc(pool, body, requestPrincipal, { env }))
})
}
if (require.main === module) {
const host = process.env.AUTOMATION_CONTROL_HOST || "0.0.0.0"
const port = Number.parseInt(process.env.AUTOMATION_CONTROL_PORT || "8791", 10)
createServer().listen(port, host, () => {
console.log(`hermes-automation-control listening on http://${host}:${port}`)
})
}
module.exports = {
TOOLS,
createServer,
createPool,
evaluatePolicy,
validateSchedule,
handleRpc,
}
+159
View File
@@ -0,0 +1,159 @@
"use strict"
const crypto = require("crypto")
const { Pool } = require("pg")
const { executeAction, nextOccurrence } = require("./lib/automation-runtime.cjs")
function createPool(databaseUrl) {
return new Pool({
connectionString: databaseUrl,
connectionTimeoutMillis: 5000,
query_timeout: 10000,
idleTimeoutMillis: 30000,
})
}
async function writeEvent(pool, actorId, action, runId, payload = {}) {
await pool.query(
`insert into automation_events (actor_id, action, subject_type, subject_id, payload)
values ($1, $2, 'run', $3, $4::jsonb)`,
[actorId, action, runId, JSON.stringify(payload)]
)
}
async function scheduleDueJobs(pool, now = new Date()) {
const { rows } = await pool.query(
`select j.id, j.script_id, j.schedule, j.next_run_at
from automation_jobs j
where j.status = 'active'
and j.next_run_at <= $1
and (select count(*) from automation_runs r
where r.job_id = j.id and r.created_at >= date_trunc('day', $1::timestamptz)) < j.max_runs_per_day
order by j.next_run_at asc
limit 100`,
[now.toISOString()]
)
for (const job of rows) {
await pool.query(
`insert into automation_runs (job_id, script_id, status, scheduled_for, requested_by, approval_id)
select $1, $2, 'queued', $3, 'scheduler', j.approval_id
from automation_jobs j where j.id = $1
on conflict (job_id, scheduled_for) do nothing`,
[job.id, job.script_id, job.next_run_at]
)
const next = nextOccurrence(job.schedule, job.next_run_at)
await pool.query(
"update automation_jobs set next_run_at = $1, status = case when $1::timestamptz is null then 'paused' else status end where id = $2",
[next ? next.toISOString() : null, job.id]
)
}
return rows.length
}
async function leaseNextRun(pool, workerId, leaseSeconds) {
const { rows } = await pool.query(
`with candidate as (
select r.id
from automation_runs r
where r.status = 'queued'
or (r.status = 'running' and r.lease_expires_at < now())
order by r.scheduled_for asc
for update skip locked
limit 1
)
update automation_runs r
set status = 'running',
started_at = coalesce(started_at, now()),
lease_owner = $1,
lease_expires_at = now() + ($2::text || ' seconds')::interval
from candidate, automation_scripts s
where r.id = candidate.id and s.id = r.script_id
returning r.*, s.definition`,
[workerId, leaseSeconds]
)
return rows[0] || null
}
async function runLeasedRun(pool, run, options = {}) {
const workerId = options.workerId || "automation-worker"
if (run.status === "cancel_requested") {
await pool.query(
"update automation_runs set status = 'canceled', completed_at = now(), lease_owner = null, lease_expires_at = null where id = $1",
[run.id]
)
await writeEvent(pool, workerId, "run.canceled", run.id)
return
}
let approval = null
if (run.approval_id) {
const result = await pool.query("select id, status, bundle_hash from automation_approvals where id = $1", [run.approval_id])
approval = result.rows[0] || null
}
try {
const result = await executeAction(run.definition, { ...options, approval, runId: run.id })
await pool.query(
`update automation_runs
set status = 'succeeded', completed_at = now(), result = $1::jsonb, error = null,
lease_owner = null, lease_expires_at = null
where id = $2 and status = 'running'`,
[JSON.stringify(result), run.id]
)
await writeEvent(pool, workerId, "run.succeeded", run.id, { result })
} catch (err) {
const error = { message: String(err.message || "Execution failed").slice(0, 1000) }
await pool.query(
`update automation_runs
set status = 'failed', completed_at = now(), error = $1::jsonb,
lease_owner = null, lease_expires_at = null
where id = $2 and status in ('running', 'cancel_requested')`,
[JSON.stringify(error), run.id]
)
await writeEvent(pool, workerId, "run.failed", run.id, { error })
}
}
async function tick(pool, options = {}) {
await scheduleDueJobs(pool)
const run = await leaseNextRun(pool, options.workerId, options.leaseSeconds)
if (run) await runLeasedRun(pool, run, options)
return Boolean(run)
}
async function main() {
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required")
const pool = createPool(process.env.DATABASE_URL)
const options = {
env: process.env,
workerId: process.env.AUTOMATION_WORKER_ID || `automation-worker-${crypto.randomUUID()}`,
leaseSeconds: Number(process.env.AUTOMATION_LEASE_SECONDS || 60),
timeoutMs: Number(process.env.AUTOMATION_MAX_TIMEOUT_MS || 30_000),
maxResultBytes: Number(process.env.AUTOMATION_MAX_RESULT_BYTES || 65_536),
brokerUrl: process.env.AUTOMATION_CONTAINER_PROVISIONER_URL,
brokerToken: process.env.AUTOMATION_CONTAINER_PROVISIONER_TOKEN,
}
const pollMs = Number(process.env.AUTOMATION_POLL_MS || 2000)
console.log("hermes-automation-worker started in bounded mode")
while (true) {
try {
const worked = await tick(pool, options)
if (!worked) await new Promise((resolve) => setTimeout(resolve, pollMs))
} catch (err) {
console.error(`automation worker tick failed: ${String(err.message || err)}`)
await new Promise((resolve) => setTimeout(resolve, pollMs))
}
}
}
if (require.main === module) main().catch((err) => {
console.error(err.message)
process.exit(1)
})
module.exports = {
createPool,
leaseNextRun,
runLeasedRun,
scheduleDueJobs,
tick,
}
+645
View File
@@ -0,0 +1,645 @@
"use strict"
const crypto = require("crypto")
const fs = require("fs")
const http = require("http")
const { Pool } = require("pg")
const {
evaluateContainerPolicy,
loadContainerProfilesFromEnv,
NAME_PREFIX,
normalizeProfile,
} = require("./lib/container-provisioner-policy.cjs")
const TOOLS = Object.freeze([
["container_profiles_list", "List approved container provisioning profiles."],
["container_provision_validate", "Validate a requested container profile against policy without creating anything."],
["container_provision_dry_run", "Record an audited dry-run provisioning request without Docker API access."],
["container_provision_create", "Create and start one broker-managed container from an approved profile."],
["container_instance_start", "Start one broker-managed container."],
["container_instance_stop", "Stop one broker-managed container."],
["container_instance_remove", "Remove one broker-managed container."],
["container_instance_status", "Return the stored status for a dry-run or future broker-managed container instance."],
["container_instance_logs", "Return broker event stubs for a dry-run or future broker-managed container instance."],
].map(([name, description]) => ({ name, description, inputSchema: { type: "object", additionalProperties: true } })))
class McpError extends Error {
constructor(code, message) {
super(message)
this.code = code
}
}
function requireValue(value, name) {
if (value === undefined || value === null || value === "") throw new McpError("invalid_input", `${name} is required`)
return value
}
function digestText(value) {
return `sha256:${crypto.createHash("sha256").update(String(value || "")).digest("hex")}`
}
function requireApprovalMetadata(args) {
if (typeof args.approvalId !== "string" || !/^[A-Za-z0-9._:-]{8,200}$/.test(args.approvalId)) {
throw new McpError("missing_approval_id", "A valid approval id is required")
}
if (typeof args.approvalSummary !== "string" || args.approvalSummary.trim().length < 10 || args.approvalSummary.length > 1000) {
throw new McpError("missing_approval_summary", "An approval summary between 10 and 1000 characters is required")
}
if (typeof args.reason !== "string" || args.reason.trim().length < 3 || args.reason.length > 500) {
throw new McpError("missing_reason", "A reason between 3 and 500 characters is required")
}
if (typeof args.idempotencyKey !== "string" || !/^[A-Za-z0-9._:-]{8,200}$/.test(args.idempotencyKey)) {
throw new McpError("missing_idempotency_key", "A valid idempotency key is required")
}
return {
approvalId: args.approvalId,
approvalSummaryDigest: digestText(args.approvalSummary),
reasonDigest: digestText(args.reason),
idempotencyKeyDigest: digestText(args.idempotencyKey),
}
}
function createPool(databaseUrl) {
return new Pool({
connectionString: databaseUrl,
connectionTimeoutMillis: 5000,
query_timeout: 10000,
idleTimeoutMillis: 30000,
})
}
function createPrincipal(env) {
return {
id: env.CONTAINER_PROVISIONER_MCP_PRINCIPAL_ID || "hermes-post-container-provisioner",
role: "broker",
}
}
function rowToProfile(row) {
return {
id: row.id,
name: row.name,
description: row.description,
image: row.image,
enabled: row.enabled,
resources: row.resource_limits,
ttlSeconds: row.ttl_seconds,
networkMode: row.network_mode,
executionEnabled: Boolean(row.execution_enabled ?? row.policy?.executionEnabled),
labels: row.policy?.labels || {},
command: row.policy?.command || [],
policy: row.policy,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function rowToInstance(row) {
if (!row) return null
return {
id: row.id,
profileId: row.profile_id,
containerName: row.container_name,
dockerId: row.docker_id,
image: row.image,
status: row.status,
requestedBy: row.requested_by,
policy: row.policy_result,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function sanitizeNamePart(value) {
return String(value || "")
.toLowerCase()
.replace(/[^a-z0-9_.-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40) || "container"
}
function createContainerName(profileId) {
return `${NAME_PREFIX}${sanitizeNamePart(profileId)}-${crypto.randomBytes(4).toString("hex")}`
}
function createDockerClient(socketPath) {
const dockerSocket = socketPath || "/var/run/docker.sock"
async function request(method, path, body) {
const payload = body === undefined ? null : Buffer.from(JSON.stringify(body))
return new Promise((resolve, reject) => {
const req = http.request({
socketPath: dockerSocket,
method,
path,
headers: payload ? {
"Content-Type": "application/json",
"Content-Length": payload.length,
} : undefined,
}, (res) => {
const chunks = []
res.on("data", (chunk) => chunks.push(chunk))
res.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf8")
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`Docker API ${method} ${path} failed with ${res.statusCode}: ${raw.slice(0, 500)}`))
return
}
if (!raw) {
resolve({})
return
}
try {
resolve(JSON.parse(raw))
} catch (err) {
reject(err)
}
})
})
req.on("error", reject)
if (payload) req.write(payload)
req.end()
})
}
return {
async createContainer(spec) {
const name = encodeURIComponent(spec.name)
const body = { ...spec }
delete body.name
const response = await request("POST", `/containers/create?name=${name}`, body)
return { id: response.Id || response.id }
},
async startContainer(id) {
await request("POST", `/containers/${encodeURIComponent(id)}/start`)
},
async inspectContainer(id) {
return request("GET", `/containers/${encodeURIComponent(id)}/json`)
},
async stopContainer(id) {
await request("POST", `/containers/${encodeURIComponent(id)}/stop?t=10`)
},
async removeContainer(id) {
await request("DELETE", `/containers/${encodeURIComponent(id)}?v=1&force=0`)
},
}
}
async function appendAuditFile(auditPath, event) {
if (!auditPath) return
await fs.promises.appendFile(auditPath, `${JSON.stringify(event)}\n`, { mode: 0o600 })
}
async function writeEvent(pool, actorId, action, subjectType, subjectId, payload, options = {}) {
await pool.query(
`insert into container_events (actor_id, action, subject_type, subject_id, payload)
values ($1, $2, $3, $4, $5::jsonb)`,
[actorId, action, subjectType, subjectId, JSON.stringify(payload || {})]
)
await appendAuditFile(options.auditPath, {
occurredAt: new Date().toISOString(),
actorId,
action,
subjectType,
subjectId,
payload: payload || {},
})
}
async function syncProfilesFromEnv(pool, env) {
const profiles = loadContainerProfilesFromEnv(env)
for (const profile of profiles) {
await pool.query(
`insert into container_profiles
(id, name, description, image, enabled, resource_limits, ttl_seconds, network_mode, policy, execution_enabled)
values ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9::jsonb, $10)
on conflict (id) do update set
name = excluded.name,
description = excluded.description,
image = excluded.image,
enabled = excluded.enabled,
resource_limits = excluded.resource_limits,
ttl_seconds = excluded.ttl_seconds,
network_mode = excluded.network_mode,
policy = excluded.policy,
execution_enabled = excluded.execution_enabled`,
[
profile.id,
profile.name,
profile.description,
profile.image,
profile.enabled,
JSON.stringify(profile.resources),
profile.ttlSeconds,
profile.networkMode,
JSON.stringify({
envManaged: true,
labels: profile.labels,
command: profile.command,
executionEnabled: profile.executionEnabled,
}),
profile.executionEnabled,
]
)
}
}
async function getProfile(pool, profileId, env = process.env) {
await syncProfilesFromEnv(pool, env)
const { rows } = await pool.query("select * from container_profiles where id = $1", [profileId])
return rows[0] ? rowToProfile(rows[0]) : null
}
async function listProfiles(pool, env = process.env) {
await syncProfilesFromEnv(pool, env)
const { rows } = await pool.query("select * from container_profiles where enabled = true order by name asc")
return rows.map(rowToProfile).map((profile) => ({
...profile,
policy: evaluateContainerPolicy(profile, { env }),
}))
}
async function validateProvision(pool, args, env = process.env) {
const profileId = String(requireValue(args.profileId, "profileId"))
const profile = await getProfile(pool, profileId, env)
if (!profile) throw new McpError("profile_not_found", "Container profile not found")
return {
profile,
policy: evaluateContainerPolicy(normalizeProfile(profile), { env, dryRun: true }),
}
}
async function dryRunProvision(pool, principal, args, options = {}) {
const validation = await validateProvision(pool, args, options.env || process.env)
const { profile, policy } = validation
if (!policy.allowed) throw new McpError("policy_denied", policy.errors.join("; "))
const { rows } = await pool.query(
`insert into container_instances (profile_id, image, status, requested_by, policy_result)
values ($1, $2, 'dry_run', $3, $4::jsonb)
returning *`,
[profile.id, profile.image, principal.id, JSON.stringify(policy)]
)
const instance = rowToInstance(rows[0])
await writeEvent(pool, principal.id, "instance.dry_run", "container_instance", instance.id, {
instance,
reason: String(args.reason || ""),
}, options)
return instance
}
function buildDockerSpec(profile, policy, instance, containerName, principal, reason) {
const runtime = policy.runtime
return {
name: containerName,
Image: runtime.image,
Labels: {
...runtime.labels,
"com.vynte.hermes.instance": instance.id,
"com.vynte.hermes.requested-by": principal.id,
"com.vynte.hermes.reason-digest": digestText(reason),
},
Env: [],
Cmd: runtime.command.length > 0 ? runtime.command : undefined,
ExposedPorts: {},
HostConfig: {
AutoRemove: false,
Binds: [],
CapDrop: ["ALL"],
Memory: Number(runtime.resources.memoryMb) * 1024 * 1024,
NanoCpus: Number(runtime.resources.cpus) * 1_000_000_000,
NetworkMode: runtime.networkMode,
PidsLimit: Number(runtime.resources.pidsLimit),
PortBindings: {},
Privileged: false,
ReadonlyRootfs: true,
RestartPolicy: { Name: "no" },
},
}
}
async function createProvision(pool, principal, args, options = {}) {
const env = options.env || process.env
const validation = await validateProvision(pool, args, env)
const { profile, policy } = validation
if (!policy.allowed) throw new McpError("policy_denied", policy.errors.join("; "))
if (!policy.executionEnabled) throw new McpError("execution_disabled", "Container execution is disabled for this broker or profile")
const approval = requireApprovalMetadata(args)
const dockerClient = options.dockerClient || createDockerClient(env.CONTAINER_PROVISIONER_DOCKER_SOCKET)
const containerName = createContainerName(profile.id)
const { rows } = await pool.query(
`insert into container_instances (profile_id, image, container_name, status, requested_by, policy_result, requested_payload, expires_at, approval_id, idempotency_key_digest)
values ($1, $2, $3, 'creating', $4, $5::jsonb, $6::jsonb, now() + ($7::text || ' seconds')::interval, $8, $9)
on conflict (requested_by, idempotency_key_digest) do nothing
returning *`,
[
profile.id,
profile.image,
containerName,
principal.id,
JSON.stringify(policy),
JSON.stringify(approval),
Number(policy.runtime.ttlSeconds),
approval.approvalId,
approval.idempotencyKeyDigest,
]
)
if (rows.length === 0) {
const existing = await pool.query(
"select * from container_instances where requested_by = $1 and idempotency_key_digest = $2",
[principal.id, approval.idempotencyKeyDigest]
)
return rowToInstance(existing.rows[0])
}
let instance = rowToInstance(rows[0])
await writeEvent(pool, principal.id, "instance.create_requested", "container_instance", instance.id, {
instance,
approval,
}, options)
try {
const docker = await dockerClient.createContainer(buildDockerSpec(profile, policy, instance, containerName, principal, args.reason))
await dockerClient.startContainer(docker.id)
const updated = await pool.query(
`update container_instances
set container_name = $1, docker_id = $2, status = $3
where id = $4
returning *`,
[containerName, docker.id, "running", instance.id]
)
instance = rowToInstance(updated.rows[0])
await writeEvent(pool, principal.id, "instance.create", "container_instance", instance.id, { instance }, options)
return instance
} catch (err) {
const updated = await pool.query(
`update container_instances
set container_name = $1, docker_id = $2, status = $3
where id = $4
returning *`,
[containerName, null, "failed", instance.id]
)
instance = rowToInstance(updated.rows[0])
await writeEvent(pool, principal.id, "instance.create_failed", "container_instance", instance.id, {
instance,
error: err.message,
}, options)
throw new McpError("docker_create_failed", "Docker broker failed to create the requested container")
}
}
async function claimAction(pool, principal, action, instanceId, args) {
const approval = requireApprovalMetadata(args)
const { rows } = await pool.query(
`insert into container_action_requests (actor_id, action, instance_id, approval_id, approval_summary_digest, reason_digest, idempotency_key_digest)
values ($1, $2, $3, $4, $5, $6, $7)
on conflict (actor_id, action, idempotency_key_digest) do nothing
returning *`,
[
principal.id,
action,
instanceId,
approval.approvalId,
approval.approvalSummaryDigest,
approval.reasonDigest,
approval.idempotencyKeyDigest,
]
)
return { approval, repeated: rows.length === 0 }
}
async function getManagedInstance(pool, id, dockerClient) {
const { rows } = await pool.query("select * from container_instances where id = $1", [id])
const row = rows[0]
const instance = rowToInstance(row)
if (!instance || !instance.dockerId || !String(instance.containerName || "").startsWith(NAME_PREFIX)) {
throw new McpError("instance_not_managed", "Broker-managed container instance not found")
}
const inspected = await dockerClient.inspectContainer(instance.dockerId)
const labels = inspected?.Config?.Labels || {}
if (labels["com.vynte.hermes.managed"] !== "true" || labels["com.vynte.hermes.instance"] !== instance.id) {
throw new McpError("instance_not_managed", "Docker container is missing required broker-managed labels")
}
return { instance, inspected }
}
async function lifecycleAction(pool, principal, action, args, options = {}) {
const env = options.env || process.env
if (!/^(1|true|yes|on)$/i.test(String(env.CONTAINER_PROVISIONER_EXECUTION_ENABLED || ""))) {
throw new McpError("execution_disabled", "Container execution is disabled for this broker")
}
const id = String(requireValue(args.id, "id"))
const claimed = await claimAction(pool, principal, action, id, args)
if (claimed.repeated) {
const { rows } = await pool.query("select * from container_instances where id = $1", [id])
return rowToInstance(rows[0])
}
const dockerClient = options.dockerClient || createDockerClient(env.CONTAINER_PROVISIONER_DOCKER_SOCKET)
const { instance, inspected } = await getManagedInstance(pool, id, dockerClient)
let status = instance.status
if (action === "start" && !inspected.State?.Running) {
await dockerClient.startContainer(instance.dockerId)
status = "running"
}
if (action === "stop" && inspected.State?.Running) {
await dockerClient.stopContainer(instance.dockerId)
status = "stopped"
}
if (action === "remove") {
if (inspected.State?.Running) throw new McpError("instance_running", "Stop the broker-managed container before removal")
await dockerClient.removeContainer(instance.dockerId)
status = "removed"
}
const { rows } = await pool.query(
"update container_instances set status = $1 where id = $2 returning *",
[status, id]
)
const updated = rowToInstance(rows[0]) || { ...instance, status }
await writeEvent(pool, principal.id, `instance.${action}`, "container_instance", id, {
instance: updated,
approval: claimed.approval,
repeated: claimed.repeated,
}, options)
return updated
}
async function callTool(pool, principal, name, args = {}, options = {}) {
const env = options.env || process.env
if (name === "container_profiles_list") return {
profiles: await listProfiles(pool, env),
executionEnabled: /^(1|true|yes|on)$/i.test(String(env.CONTAINER_PROVISIONER_EXECUTION_ENABLED || "")),
}
if (name === "container_provision_validate") return validateProvision(pool, args, env)
if (name === "container_provision_dry_run") return dryRunProvision(pool, principal, args, options)
if (name === "container_provision_create") return createProvision(pool, principal, args, options)
if (name === "container_instance_start") return lifecycleAction(pool, principal, "start", args, options)
if (name === "container_instance_stop") return lifecycleAction(pool, principal, "stop", args, options)
if (name === "container_instance_remove") return lifecycleAction(pool, principal, "remove", args, options)
if (name === "container_instance_status") {
const { rows } = await pool.query("select * from container_instances where id = $1", [String(requireValue(args.id, "id"))])
return rowToInstance(rows[0])
}
if (name === "container_instance_logs") {
const instanceId = String(requireValue(args.id, "id"))
const { rows } = await pool.query(
"select * from container_events where instance_id = $1 or subject_id = $1 order by occurred_at desc limit 100",
[instanceId]
)
return {
instanceId,
logsAvailable: false,
note: "Real container logs are disabled until Docker broker execution is explicitly authorized.",
events: rows,
}
}
throw new McpError("unknown_tool", `Unknown or disabled tool: ${name}`)
}
function rpcResult(id, result) {
return { jsonrpc: "2.0", id, result }
}
function rpcError(id, code, message, data = undefined) {
const error = { code, message }
if (data) error.data = data
return { jsonrpc: "2.0", id, error }
}
async function handleRpc(pool, request, principal, options = {}) {
if (!request || request.jsonrpc !== "2.0" || typeof request.method !== "string") {
return rpcError(request?.id ?? null, -32600, "Invalid JSON-RPC request")
}
const id = Object.hasOwn(request, "id") ? request.id : null
try {
if (request.method === "initialize") {
return rpcResult(id, {
protocolVersion: "2024-11-05",
serverInfo: { name: "hermes-container-provisioner", version: "0.1.0" },
capabilities: { tools: {} },
})
}
if (request.method === "tools/list") return rpcResult(id, { tools: TOOLS })
if (request.method === "tools/call") {
const params = request.params || {}
return rpcResult(id, await callTool(pool, principal, params.name, params.arguments || {}, options))
}
return rpcError(id, -32601, `Method not found: ${request.method}`)
} catch (err) {
if (err instanceof McpError) return rpcError(id, -32000, err.message, { code: err.code })
return rpcError(id, -32603, "Internal error")
}
}
function readJsonBody(req, maxBytes = 1_000_000) {
return new Promise((resolve, reject) => {
const chunks = []
let total = 0
req.on("data", (chunk) => {
total += chunk.length
if (total > maxBytes) {
reject(Object.assign(new Error("request body too large"), { status: 413 }))
req.destroy()
return
}
chunks.push(chunk)
})
req.on("end", () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")))
} catch (err) {
reject(Object.assign(err, { status: 400 }))
}
})
req.on("error", reject)
})
}
function sendJson(res, status, body) {
const json = JSON.stringify(body)
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
"Content-Length": Buffer.byteLength(json),
})
res.end(json)
}
function hasValidBearerAuth(req, token) {
const authorization = req.headers.authorization || ""
const expected = `Bearer ${token}`
const actualBuffer = Buffer.from(authorization)
const expectedBuffer = Buffer.from(expected)
return actualBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(actualBuffer, expectedBuffer)
}
function createServer(options = {}) {
const env = options.env || process.env
const databaseUrl = options.databaseUrl || env.DATABASE_URL
const token = options.token || env.CONTAINER_PROVISIONER_MCP_TOKEN
if (!databaseUrl) throw new Error("DATABASE_URL is required")
if (!token) throw new Error("CONTAINER_PROVISIONER_MCP_TOKEN is required")
const pool = options.pool || createPool(databaseUrl)
const principal = options.principal || createPrincipal(env)
const serverOptions = {
env,
auditPath: env.CONTAINER_PROVISIONER_AUDIT_PATH,
dockerClient: options.dockerClient,
}
return http.createServer(async (req, res) => {
if (req.method === "GET" && req.url === "/healthz") {
sendJson(res, 200, {
ok: true,
service: "hermes-container-provisioner",
executionEnabled: /^(1|true|yes|on)$/i.test(String(env.CONTAINER_PROVISIONER_EXECUTION_ENABLED || "")),
dockerSocketConfigured: Boolean(env.CONTAINER_PROVISIONER_DOCKER_SOCKET),
auth: "bearer",
})
return
}
if (req.method !== "POST" || req.url !== "/mcp") {
sendJson(res, 404, { error: "not found" })
return
}
if (!hasValidBearerAuth(req, token)) {
sendJson(res, 401, { error: "unauthorized" })
return
}
let body
try {
body = await readJsonBody(req)
} catch (err) {
sendJson(res, err.status || 400, rpcError(null, -32700, "Parse error"))
return
}
if (Array.isArray(body)) {
if (body.length === 0) {
sendJson(res, 200, rpcError(null, -32600, "Invalid JSON-RPC batch"))
return
}
sendJson(res, 200, await Promise.all(body.map((item) => handleRpc(pool, item, principal, serverOptions))))
return
}
sendJson(res, 200, await handleRpc(pool, body, principal, serverOptions))
})
}
if (require.main === module) {
const host = process.env.CONTAINER_PROVISIONER_HOST || "0.0.0.0"
const port = Number.parseInt(process.env.CONTAINER_PROVISIONER_PORT || "8792", 10)
createServer().listen(port, host, () => {
console.log(`hermes-container-provisioner listening on http://${host}:${port}`)
})
}
module.exports = {
TOOLS,
createServer,
createDockerClient,
createPool,
handleRpc,
}
+360 -6
View File
@@ -5,6 +5,7 @@ x-hermes-build: &hermes-build
HERMES_AGENT_REF: ${HERMES_AGENT_REF:-458a94e42568b332e8794ca8fbb8c8e1279160a3}
x-hermes-image: &hermes-image ${HERMES_IMAGE:-10.0.3.6:4000/zachariahsharma/hermes-control-plane:latest}
x-external-saas-mcp-image: &external-saas-mcp-image ${HERMES_EXTERNAL_SAAS_MCP_IMAGE:-10.0.3.6:4000/zachariahsharma/hermes-external-saas-mcp:latest}
x-hermes-environment: &hermes-environment
HOME: /home/hermes
@@ -16,6 +17,26 @@ x-hermes-environment: &hermes-environment
BROWSER: ${HERMES_OAUTH_BROWSER:-echo}
NO_COLOR: "1"
HERMES_NO_TUI: "1"
HERMES_REGISTER_VYNTE_INTERNAL_MCP: ${HERMES_REGISTER_VYNTE_INTERNAL_MCP:-true}
HERMES_VYNTE_INTERNAL_MCP_NAME: ${HERMES_VYNTE_INTERNAL_MCP_NAME:-vynte-internal}
HERMES_VYNTE_INTERNAL_MCP_URL: ${HERMES_VYNTE_INTERNAL_MCP_URL:-http://vynte-internal-mcp:8787/mcp}
HERMES_VYNTE_INTERNAL_MCP_TOKEN: ${HERMES_VYNTE_INTERNAL_MCP_TOKEN:-}
HERMES_REGISTER_FORMS_MCP: ${HERMES_REGISTER_FORMS_MCP:-true}
HERMES_FORMS_MCP_NAME: ${HERMES_FORMS_MCP_NAME:-forms}
HERMES_FORMS_MCP_URL: ${HERMES_FORMS_MCP_URL:-https://forms.internal.vyntehome.com/api/mcp}
HERMES_FORMS_MCP_TOKEN: ${HERMES_FORMS_MCP_TOKEN:-}
HERMES_REGISTER_AUTOMATION_CONTROL_MCP: "false"
HERMES_AUTOMATION_CONTROL_MCP_NAME: ${HERMES_AUTOMATION_CONTROL_MCP_NAME:-automation-control}
HERMES_AUTOMATION_CONTROL_MCP_URL: ${HERMES_AUTOMATION_CONTROL_MCP_URL:-http://automation-control:8791/mcp}
HERMES_AUTOMATION_CONTROL_MCP_TOKEN: ${HERMES_AUTOMATION_CONTROL_MCP_TOKEN:-}
HERMES_REGISTER_CONTAINER_PROVISIONER_MCP: "false"
HERMES_CONTAINER_PROVISIONER_MCP_NAME: ${HERMES_CONTAINER_PROVISIONER_MCP_NAME:-container-provisioner}
HERMES_CONTAINER_PROVISIONER_MCP_URL: ${HERMES_CONTAINER_PROVISIONER_MCP_URL:-http://container-provisioner:8792/mcp}
HERMES_CONTAINER_PROVISIONER_MCP_TOKEN: ${HERMES_CONTAINER_PROVISIONER_MCP_TOKEN:-}
HERMES_REGISTER_EXTERNAL_SAAS_MCP: "false"
HERMES_EXTERNAL_SAAS_MCP_NAME: ${HERMES_EXTERNAL_SAAS_MCP_NAME:-external-saas}
HERMES_EXTERNAL_SAAS_MCP_URL: ${HERMES_EXTERNAL_SAAS_MCP_URL:-http://hermes-external-saas-mcp:8787/mcp}
HERMES_EXTERNAL_SAAS_MCP_TOKEN: ${HERMES_EXTERNAL_SAAS_MCP_TOKEN:-}
x-hermes-volumes: &hermes-volumes
- type: bind
@@ -39,6 +60,35 @@ x-hermes-volumes: &hermes-volumes
bind:
create_host_path: true
x-hermes-pre-volumes: &hermes-pre-volumes
- type: bind
source: ${HERMES_PRE_HOME_HOST:-/opt/hermes-control-plane/hermes-pre}
target: /home/hermes/.hermes
bind:
create_host_path: true
- type: bind
source: ${CODEX_HOME_HOST:-/opt/hermes-control-plane/codex}
target: /home/hermes/.codex
bind:
create_host_path: true
- type: bind
source: ${CLAUDE_HOME_HOST:-/opt/hermes-control-plane/claude}
target: /home/hermes/.claude
bind:
create_host_path: true
- type: bind
source: ${GEMINI_HOME_HOST:-/opt/hermes-control-plane/gemini}
target: /home/hermes/.gemini
bind:
create_host_path: true
x-hermes-scratch-environment: &hermes-scratch-environment
HOME: /tmp/hermes-home
HERMES_HOME: /tmp/hermes-home/.hermes
CODEX_HOME: /tmp/hermes-home/.codex
CLAUDE_CONFIG_DIR: /tmp/hermes-home/.claude
GEMINI_CONFIG_DIR: /tmp/hermes-home/.gemini
x-db-url: &db-url "postgresql://hermes:${POSTGRES_PASSWORD:-hermes-change-me}@hermes-postgres:5432/hermes"
services:
@@ -75,6 +125,7 @@ services:
HERMES_ADMIN_SESSION_TTL_HOURS: ${HERMES_ADMIN_SESSION_TTL_HOURS:-12}
HERMES_ADMIN_COOKIE_SECURE: ${HERMES_ADMIN_COOKIE_SECURE:-false}
HERMES_LOG_RETENTION_DAYS: ${HERMES_LOG_RETENTION_DAYS:-90}
HERMES_AGENT_EXECUTION_MODE: ${HERMES_AGENT_EXECUTION_MODE:-disabled}
volumes: *hermes-volumes
depends_on:
hermes-postgres:
@@ -86,8 +137,263 @@ services:
retries: 3
start_period: 10s
hermes-ai-upstream:
profiles: ["pre-gateway", "post-gateway"]
vynte-internal-mcp:
build: *hermes-build
image: *hermes-image
user: ${HERMES_CONTAINER_USER:-0:0}
restart: unless-stopped
command: ["node", "/app/vynte-internal-mcp.cjs"]
expose:
- "8787"
networks:
- default
- portainer-network
environment:
VYNTE_MCP_HOST: 0.0.0.0
VYNTE_MCP_PORT: 8787
VYNTE_MCP_ALLOW_ARBITRARY_URLS: ${VYNTE_MCP_ALLOW_ARBITRARY_URLS:-false}
VYNTE_MCP_SERVER_TOKEN: ${HERMES_VYNTE_INTERNAL_MCP_TOKEN:-}
VYNTE_MCP_WRITES_ENABLED: ${VYNTE_MCP_WRITES_ENABLED:-false}
VYNTE_MCP_WRITE_POLICY: ${VYNTE_MCP_WRITE_POLICY:-strict_allowlist}
VYNTE_MCP_WRITE_AUDIT_PATH: ${VYNTE_MCP_WRITE_AUDIT_PATH:-}
VYNTE_MCP_WRITE_RESPONSE_LIMIT: ${VYNTE_MCP_WRITE_RESPONSE_LIMIT:-4096}
VYNTE_HERMES_URL: ${VYNTE_HERMES_URL:-https://hermes.internal.vyntehome.com}
VYNTE_FORMS_URL: ${VYNTE_FORMS_URL:-https://forms.internal.vyntehome.com}
VYNTE_PLANE_URL: ${VYNTE_PLANE_URL:-https://plane.internal.vyntehome.com}
VYNTE_PLANE_AUTH_HEADER: ${VYNTE_PLANE_AUTH_HEADER:-X-API-Key}
VYNTE_PLANE_ALLOWED_WORKSPACES: ${VYNTE_PLANE_ALLOWED_WORKSPACES:-}
VYNTE_PLANE_ALLOWED_PROJECTS: ${VYNTE_PLANE_ALLOWED_PROJECTS:-}
VYNTE_PLANE_ALLOWED_WRITE_PATHS: ${VYNTE_PLANE_ALLOWED_WRITE_PATHS:-}
VYNTE_TWENTY_ALLOWED_OBJECTS: ${VYNTE_TWENTY_ALLOWED_OBJECTS:-}
VYNTE_TWENTY_ALLOWED_WRITE_PATHS: ${VYNTE_TWENTY_ALLOWED_WRITE_PATHS:-}
VYNTE_PLUNK_ALLOWED_WRITE_PATHS: ${VYNTE_PLUNK_ALLOWED_WRITE_PATHS:-}
VYNTE_PLUNK_ALLOWED_EMAIL_DOMAINS: ${VYNTE_PLUNK_ALLOWED_EMAIL_DOMAINS:-}
VYNTE_GITEA_URL: ${VYNTE_GITEA_URL:-https://git.internal.vyntehome.com}
VYNTE_HERMES_PRE_API_URL: ${VYNTE_HERMES_PRE_API_URL:-https://pre.hermes.internal.vyntehome.com}
VYNTE_HERMES_POST_API_URL: ${VYNTE_HERMES_POST_API_URL:-https://post.hermes.internal.vyntehome.com}
VYNTE_OPENWEBUI_URL: ${VYNTE_OPENWEBUI_URL:-https://openwebui.internal.vyntehome.com}
VYNTE_PORTAINER_URL: ${VYNTE_PORTAINER_URL:-https://portainer.internal.vyntehome.com}
VYNTE_NPM_URL: ${VYNTE_NPM_URL:-https://npm.internal.vyntehome.com}
VYNTE_AUTHENTIK_URL: ${VYNTE_AUTHENTIK_URL:-https://authentik.vyntehome.com}
VYNTE_NETBIRD_URL: ${VYNTE_NETBIRD_URL:-https://vpn.vyntehome.com}
VYNTE_MEDIA_URL: ${VYNTE_MEDIA_URL:-https://media.internal.vyntehome.com}
VYNTE_PENPOT_URL: ${VYNTE_PENPOT_URL:-https://penpot.internal.vyntehome.com}
VYNTE_POSTIZ_URL: ${VYNTE_POSTIZ_URL:-https://postiz.internal.vyntehome.com}
VYNTE_TWENTY_URL: ${VYNTE_TWENTY_URL:-https://twenty.internal.vyntehome.com}
VYNTE_PLAUSIBLE_URL: ${VYNTE_PLAUSIBLE_URL:-https://plausible.internal.vyntehome.com}
VYNTE_PLUNK_URL: ${VYNTE_PLUNK_URL:-https://plunk.internal.vyntehome.com}
VYNTE_CAL_URL: ${VYNTE_CAL_URL:-https://cal.internal.vyntehome.com}
VYNTE_CAL_API_VERSION: ${VYNTE_CAL_API_VERSION:-2026-05-01}
VYNTE_CAL_ALLOWED_WRITE_PATHS: ${VYNTE_CAL_ALLOWED_WRITE_PATHS:-}
VYNTE_FORMS_TOKEN: ${VYNTE_FORMS_TOKEN:-}
VYNTE_PLANE_TOKEN: ${VYNTE_PLANE_TOKEN:-}
VYNTE_GITEA_TOKEN: ${VYNTE_GITEA_TOKEN:-}
VYNTE_HERMES_PRE_API_TOKEN: ${VYNTE_HERMES_PRE_API_TOKEN:-}
VYNTE_HERMES_POST_API_TOKEN: ${VYNTE_HERMES_POST_API_TOKEN:-}
VYNTE_OPENWEBUI_TOKEN: ${VYNTE_OPENWEBUI_TOKEN:-}
VYNTE_PORTAINER_TOKEN: ${VYNTE_PORTAINER_TOKEN:-}
VYNTE_NPM_TOKEN: ${VYNTE_NPM_TOKEN:-}
VYNTE_AUTHENTIK_TOKEN: ${VYNTE_AUTHENTIK_TOKEN:-}
VYNTE_NETBIRD_TOKEN: ${VYNTE_NETBIRD_TOKEN:-}
VYNTE_MEDIA_TOKEN: ${VYNTE_MEDIA_TOKEN:-}
VYNTE_PENPOT_TOKEN: ${VYNTE_PENPOT_TOKEN:-}
VYNTE_POSTIZ_TOKEN: ${VYNTE_POSTIZ_TOKEN:-}
VYNTE_TWENTY_TOKEN: ${VYNTE_TWENTY_TOKEN:-}
VYNTE_PLAUSIBLE_TOKEN: ${VYNTE_PLAUSIBLE_TOKEN:-}
VYNTE_PLUNK_TOKEN: ${VYNTE_PLUNK_TOKEN:-}
VYNTE_CAL_TOKEN: ${VYNTE_CAL_TOKEN:-}
volumes:
- type: bind
source: ${VYNTE_MCP_WRITE_AUDIT_HOST:-/opt/hermes-control-plane/audit/vynte-mcp}
target: /var/log/hermes/vynte-mcp
bind:
create_host_path: true
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8787/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
hermes-system-knowledge-mcp:
image: 10.0.3.6:4000/zachariahsharma/hermes-system-knowledge-mcp:20260612T150223Z
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
expose:
- "8790"
environment:
KNOWLEDGE_MCP_HOST: 0.0.0.0
KNOWLEDGE_MCP_PORT: 8790
KNOWLEDGE_MCP_SERVER_TOKEN: ${KNOWLEDGE_MCP_SERVER_TOKEN}
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8790/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
automation-control:
build: *hermes-build
image: *hermes-image
user: ${HERMES_CONTAINER_USER:-0:0}
restart: unless-stopped
command: ["node", "/app/automation-control-mcp.cjs"]
expose:
- "8791"
environment:
DATABASE_URL: *db-url
AUTOMATION_CONTROL_HOST: 0.0.0.0
AUTOMATION_CONTROL_PORT: 8791
AUTOMATION_CONTROL_MCP_TOKEN: ${AUTOMATION_CONTROL_MCP_TOKEN:-}
AUTOMATION_CONTROL_APPROVER_TOKEN: ${AUTOMATION_CONTROL_APPROVER_TOKEN:-}
AUTOMATION_ALLOWED_HTTP_PREFIXES: ${AUTOMATION_ALLOWED_HTTP_PREFIXES:-}
depends_on:
hermes-postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8791/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
automation-worker:
build: *hermes-build
image: *hermes-image
user: ${HERMES_CONTAINER_USER:-0:0}
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
command: ["node", "/app/automation-worker.cjs"]
environment:
<<: *hermes-scratch-environment
DATABASE_URL: *db-url
AUTOMATION_ALLOWED_HTTP_PREFIXES: ${AUTOMATION_ALLOWED_HTTP_PREFIXES:-}
AUTOMATION_POLL_MS: ${AUTOMATION_POLL_MS:-2000}
AUTOMATION_LEASE_SECONDS: ${AUTOMATION_LEASE_SECONDS:-60}
AUTOMATION_MAX_TIMEOUT_MS: ${AUTOMATION_MAX_TIMEOUT_MS:-30000}
AUTOMATION_MAX_RESULT_BYTES: ${AUTOMATION_MAX_RESULT_BYTES:-65536}
AUTOMATION_CONTAINER_PROVISIONER_URL: http://container-provisioner:8792/mcp
AUTOMATION_CONTAINER_PROVISIONER_TOKEN: ${CONTAINER_PROVISIONER_MCP_TOKEN:-}
depends_on:
hermes-postgres:
condition: service_healthy
container-provisioner:
condition: service_healthy
container-provisioner:
build: *hermes-build
image: *hermes-image
user: ${HERMES_CONTAINER_USER:-0:0}
restart: unless-stopped
command: ["node", "/app/container-provisioner-mcp.cjs"]
read_only: true
tmpfs:
- /tmp
expose:
- "8792"
environment:
DATABASE_URL: *db-url
CONTAINER_PROVISIONER_HOST: 0.0.0.0
CONTAINER_PROVISIONER_PORT: 8792
<<: *hermes-scratch-environment
CONTAINER_PROVISIONER_MCP_TOKEN: ${CONTAINER_PROVISIONER_MCP_TOKEN:-}
CONTAINER_PROVISIONER_EXECUTION_ENABLED: ${CONTAINER_PROVISIONER_EXECUTION_ENABLED:-false}
CONTAINER_PROVISIONER_DOCKER_SOCKET: /var/run/docker.sock
CONTAINER_PROVISIONER_AUDIT_PATH: /var/log/hermes/container-provisioner-audit.jsonl
CONTAINER_PROVISIONER_ALLOWED_IMAGE_PREFIXES: ${CONTAINER_PROVISIONER_ALLOWED_IMAGE_PREFIXES:-10.0.3.6:4000/zachariahsharma/hermes-automation-}
CONTAINER_PROVISIONER_PROFILES_JSON: ${CONTAINER_PROVISIONER_PROFILES_JSON:-[]}
volumes:
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
- type: bind
source: ${CONTAINER_PROVISIONER_AUDIT_HOST:-/opt/hermes-control-plane/audit}
target: /var/log/hermes
bind:
create_host_path: true
depends_on:
hermes-postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8792/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
hermes-external-saas-mcp:
image: *external-saas-mcp-image
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
expose:
- "8787"
environment:
PORT: 8787
EXTERNAL_SAAS_ADAPTER_MODE: fake
EXTERNAL_SAAS_MCP_SERVER_TOKEN: ${HERMES_EXTERNAL_SAAS_MCP_TOKEN:-}
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8787/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
hermes-agent-controller:
build: *hermes-build
image: *hermes-image
user: ${HERMES_CONTAINER_USER:-0:0}
restart: unless-stopped
command: ["node", "/app/agent-controller.cjs"]
expose:
- "8793"
environment:
DATABASE_URL: *db-url
HERMES_AGENT_CONTROLLER_HOST: 0.0.0.0
HERMES_AGENT_CONTROLLER_PORT: 8793
HERMES_AGENT_CONTROLLER_TOKEN: ${HERMES_AGENT_CONTROLLER_TOKEN}
HERMES_AGENT_EXECUTION_MODE: ${HERMES_AGENT_EXECUTION_MODE:-disabled}
depends_on:
hermes-postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8793/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
hermes-agent-worker:
build: *hermes-build
image: *hermes-image
user: ${HERMES_CONTAINER_USER:-0:0}
restart: unless-stopped
command: ["node", "/app/agent-worker.cjs"]
read_only: true
tmpfs:
- /tmp
environment:
<<: *hermes-scratch-environment
DATABASE_URL: *db-url
HERMES_AGENT_WORKER_PROVIDER_MODE: ${HERMES_AGENT_WORKER_PROVIDER_MODE:-fake-live}
HERMES_AGENT_WORKER_POLL_MS: ${HERMES_AGENT_WORKER_POLL_MS:-1000}
HERMES_SYSTEM_KNOWLEDGE_MCP_URL: ${HERMES_SYSTEM_KNOWLEDGE_MCP_URL:-http://hermes-system-knowledge-mcp:8790/mcp}
KNOWLEDGE_MCP_SERVER_TOKEN: ${KNOWLEDGE_MCP_SERVER_TOKEN}
depends_on:
hermes-postgres:
condition: service_healthy
hermes-agent-controller:
condition: service_healthy
hermes-system-knowledge-mcp:
condition: service_healthy
hermes-pre-upstream:
profiles: ["pre-gateway"]
build: *hermes-build
image: *hermes-image
user: ${HERMES_CONTAINER_USER:-0:0}
@@ -100,6 +406,49 @@ services:
- exec "$$HERMES_EXE" gateway run --replace --accept-hooks
environment:
<<: *hermes-environment
HERMES_REGISTER_VYNTE_INTERNAL_MCP: "false"
HERMES_REGISTER_FORMS_MCP: "false"
HERMES_REGISTER_AUTOMATION_CONTROL_MCP: "false"
HERMES_REGISTER_CONTAINER_PROVISIONER_MCP: "false"
HERMES_REGISTER_EXTERNAL_SAAS_MCP: "false"
API_SERVER_ENABLED: "true"
API_SERVER_HOST: 0.0.0.0
API_SERVER_PORT: 8642
API_SERVER_KEY: ${HERMES_INTERNAL_API_SERVER_KEY:-change-this-internal-hermes-key}
volumes: *hermes-pre-volumes
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8642/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
hermes-post-upstream:
profiles: ["post-gateway"]
build: *hermes-build
image: *hermes-image
user: ${HERMES_CONTAINER_USER:-0:0}
restart: unless-stopped
expose:
- "8642"
command:
- /bin/sh
- -lc
- exec "$$HERMES_EXE" gateway run --replace --accept-hooks
environment:
<<: *hermes-environment
HERMES_REGISTER_AUTOMATION_CONTROL_MCP: ${HERMES_REGISTER_AUTOMATION_CONTROL_MCP:-false}
HERMES_AUTOMATION_CONTROL_MCP_NAME: ${HERMES_AUTOMATION_CONTROL_MCP_NAME:-automation-control}
HERMES_AUTOMATION_CONTROL_MCP_URL: ${HERMES_AUTOMATION_CONTROL_MCP_URL:-http://automation-control:8791/mcp}
HERMES_AUTOMATION_CONTROL_MCP_TOKEN: ${HERMES_AUTOMATION_CONTROL_MCP_TOKEN:-}
HERMES_REGISTER_CONTAINER_PROVISIONER_MCP: ${HERMES_REGISTER_CONTAINER_PROVISIONER_MCP:-false}
HERMES_CONTAINER_PROVISIONER_MCP_NAME: ${HERMES_CONTAINER_PROVISIONER_MCP_NAME:-container-provisioner}
HERMES_CONTAINER_PROVISIONER_MCP_URL: ${HERMES_CONTAINER_PROVISIONER_MCP_URL:-http://container-provisioner:8792/mcp}
HERMES_CONTAINER_PROVISIONER_MCP_TOKEN: ${HERMES_CONTAINER_PROVISIONER_MCP_TOKEN:-}
HERMES_REGISTER_EXTERNAL_SAAS_MCP: ${HERMES_REGISTER_EXTERNAL_SAAS_MCP:-false}
HERMES_EXTERNAL_SAAS_MCP_NAME: ${HERMES_EXTERNAL_SAAS_MCP_NAME:-external-saas}
HERMES_EXTERNAL_SAAS_MCP_URL: ${HERMES_EXTERNAL_SAAS_MCP_URL:-http://hermes-external-saas-mcp:8787/mcp}
HERMES_EXTERNAL_SAAS_MCP_TOKEN: ${HERMES_EXTERNAL_SAAS_MCP_TOKEN:-}
API_SERVER_ENABLED: "true"
API_SERVER_HOST: 0.0.0.0
API_SERVER_PORT: 8642
@@ -126,7 +475,7 @@ services:
HERMES_API_ROUTE_KIND: pre
HERMES_API_GATEWAY_HOST: 0.0.0.0
HERMES_API_GATEWAY_PORT: 8645
HERMES_UPSTREAM_URL: http://hermes-ai-upstream:8642
HERMES_UPSTREAM_URL: http://hermes-pre-upstream:8642
HERMES_UPSTREAM_API_KEY: ${HERMES_INTERNAL_API_SERVER_KEY:-change-this-internal-hermes-key}
HERMES_DEFAULT_PROVIDER: ${HERMES_DEFAULT_PROVIDER:-openai-codex}
HERMES_DEFAULT_THINKING_EFFORT: ${HERMES_DEFAULT_THINKING_EFFORT:-medium}
@@ -138,7 +487,7 @@ services:
depends_on:
hermes-postgres:
condition: service_healthy
hermes-ai-upstream:
hermes-pre-upstream:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8645/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
@@ -161,7 +510,7 @@ services:
HERMES_API_ROUTE_KIND: post
HERMES_API_GATEWAY_HOST: 0.0.0.0
HERMES_API_GATEWAY_PORT: 8646
HERMES_UPSTREAM_URL: http://hermes-ai-upstream:8642
HERMES_UPSTREAM_URL: http://hermes-post-upstream:8642
HERMES_UPSTREAM_API_KEY: ${HERMES_INTERNAL_API_SERVER_KEY:-change-this-internal-hermes-key}
HERMES_DEFAULT_PROVIDER: ${HERMES_DEFAULT_PROVIDER:-openai-codex}
HERMES_DEFAULT_THINKING_EFFORT: ${HERMES_DEFAULT_THINKING_EFFORT:-medium}
@@ -173,7 +522,7 @@ services:
depends_on:
hermes-postgres:
condition: service_healthy
hermes-ai-upstream:
hermes-post-upstream:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8646/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
@@ -182,5 +531,10 @@ services:
retries: 3
start_period: 10s
networks:
portainer-network:
name: ${VYNTE_PORTAINER_DOCKER_NETWORK:-portainer_network}
external: true
volumes:
hermes-postgres-data:
+110
View File
@@ -6,7 +6,28 @@ set -eu
: "${CLAUDE_CONFIG_DIR:=/home/hermes/.claude}"
: "${GEMINI_CONFIG_DIR:=/home/hermes/.gemini}"
: "${HERMES_EXE:=/opt/hermes-agent/venv/bin/hermes}"
: "${HERMES_PYTHON:=$(dirname "$HERMES_EXE")/python}"
: "${HERMES_DEFAULT_CONFIG:=/opt/hermes-agent/cli-config.yaml.example}"
: "${HERMES_REGISTER_VYNTE_INTERNAL_MCP:=true}"
: "${HERMES_VYNTE_INTERNAL_MCP_NAME:=vynte-internal}"
: "${HERMES_VYNTE_INTERNAL_MCP_URL:=http://vynte-internal-mcp:8787/mcp}"
: "${HERMES_VYNTE_INTERNAL_MCP_TOKEN:=}"
: "${HERMES_REGISTER_FORMS_MCP:=true}"
: "${HERMES_FORMS_MCP_NAME:=forms}"
: "${HERMES_FORMS_MCP_URL:=https://forms.internal.vyntehome.com/api/mcp}"
: "${HERMES_FORMS_MCP_TOKEN:=}"
: "${HERMES_REGISTER_AUTOMATION_CONTROL_MCP:=false}"
: "${HERMES_AUTOMATION_CONTROL_MCP_NAME:=automation-control}"
: "${HERMES_AUTOMATION_CONTROL_MCP_URL:=http://automation-control:8791/mcp}"
: "${HERMES_AUTOMATION_CONTROL_MCP_TOKEN:=}"
: "${HERMES_REGISTER_CONTAINER_PROVISIONER_MCP:=false}"
: "${HERMES_CONTAINER_PROVISIONER_MCP_NAME:=container-provisioner}"
: "${HERMES_CONTAINER_PROVISIONER_MCP_URL:=http://container-provisioner:8792/mcp}"
: "${HERMES_CONTAINER_PROVISIONER_MCP_TOKEN:=}"
: "${HERMES_REGISTER_EXTERNAL_SAAS_MCP:=false}"
: "${HERMES_EXTERNAL_SAAS_MCP_NAME:=external-saas}"
: "${HERMES_EXTERNAL_SAAS_MCP_URL:=http://hermes-external-saas-mcp:8787/mcp}"
: "${HERMES_EXTERNAL_SAAS_MCP_TOKEN:=}"
mkdir -p "$HERMES_HOME" "$CODEX_HOME" "$CLAUDE_CONFIG_DIR" "$GEMINI_CONFIG_DIR"
@@ -15,8 +36,97 @@ if [ ! -x "$HERMES_EXE" ]; then
exit 1
fi
if [ ! -x "$HERMES_PYTHON" ]; then
echo "startup failed: Hermes Python interpreter is missing or not executable: $HERMES_PYTHON" >&2
exit 1
fi
if [ ! -f "$HERMES_HOME/config.yaml" ] && [ -f "$HERMES_DEFAULT_CONFIG" ]; then
cp "$HERMES_DEFAULT_CONFIG" "$HERMES_HOME/config.yaml"
fi
if [ -f "$HERMES_HOME/config.yaml" ]; then
"$HERMES_PYTHON" <<'PY'
import os, re, sys
from hermes_cli.config import load_config, save_config, save_env_value
def enabled(name):
return (os.environ.get(name) or "").lower() == "true"
def register_url_server(cfg, *, enabled_env, name_env, url_env, token_env, default_name, default_url, require_token=False):
if not enabled(enabled_env):
return
name = os.environ.get(name_env) or default_name
url = os.environ.get(url_env) or default_url
token = os.environ.get(token_env) or ""
if require_token and not token:
print(f"startup failed: {token_env} is required when {enabled_env}=true", file=sys.stderr)
sys.exit(1)
if not re.match(r"^[A-Za-z0-9_-]+$", name):
print(f"startup failed: invalid {name_env}: {name}", file=sys.stderr)
sys.exit(1)
server = {"url": url, "enabled": True}
if token:
env_key = "MCP_%s_API_KEY" % re.sub(r"[^A-Za-z0-9]", "_", name).upper()
save_env_value(env_key, token)
server["headers"] = {"Authorization": "Bearer ${%s}" % env_key}
cfg.setdefault("mcp_servers", {})[name] = server
cfg = load_config()
register_url_server(
cfg,
enabled_env="HERMES_REGISTER_VYNTE_INTERNAL_MCP",
name_env="HERMES_VYNTE_INTERNAL_MCP_NAME",
url_env="HERMES_VYNTE_INTERNAL_MCP_URL",
token_env="HERMES_VYNTE_INTERNAL_MCP_TOKEN",
default_name="vynte-internal",
default_url="http://vynte-internal-mcp:8787/mcp",
)
register_url_server(
cfg,
enabled_env="HERMES_REGISTER_FORMS_MCP",
name_env="HERMES_FORMS_MCP_NAME",
url_env="HERMES_FORMS_MCP_URL",
token_env="HERMES_FORMS_MCP_TOKEN",
default_name="forms",
default_url="https://forms.internal.vyntehome.com/api/mcp",
)
register_url_server(
cfg,
enabled_env="HERMES_REGISTER_AUTOMATION_CONTROL_MCP",
name_env="HERMES_AUTOMATION_CONTROL_MCP_NAME",
url_env="HERMES_AUTOMATION_CONTROL_MCP_URL",
token_env="HERMES_AUTOMATION_CONTROL_MCP_TOKEN",
default_name="automation-control",
default_url="http://automation-control:8791/mcp",
require_token=True,
)
register_url_server(
cfg,
enabled_env="HERMES_REGISTER_CONTAINER_PROVISIONER_MCP",
name_env="HERMES_CONTAINER_PROVISIONER_MCP_NAME",
url_env="HERMES_CONTAINER_PROVISIONER_MCP_URL",
token_env="HERMES_CONTAINER_PROVISIONER_MCP_TOKEN",
default_name="container-provisioner",
default_url="http://container-provisioner:8792/mcp",
require_token=True,
)
register_url_server(
cfg,
enabled_env="HERMES_REGISTER_EXTERNAL_SAAS_MCP",
name_env="HERMES_EXTERNAL_SAAS_MCP_NAME",
url_env="HERMES_EXTERNAL_SAAS_MCP_URL",
token_env="HERMES_EXTERNAL_SAAS_MCP_TOKEN",
default_name="external-saas",
default_url="http://hermes-external-saas-mcp:8787/mcp",
require_token=True,
)
save_config(cfg)
PY
fi
exec "$@"
@@ -0,0 +1,32 @@
# Tiered Specialist Runtime Foundation
This runtime adds validated contracts, database state, and a safe executable adapter for Hermes specialist-agent delegation.
## Runtime Boundary
- Profile manifests are loaded from `lib/agent/profile-registry.cjs` and synchronized into `agent_profiles` on startup.
- Delegation requests are validated by `lib/agent/contracts.cjs`.
- Policy checks in `lib/agent/policy-engine.cjs` enforce tier, depth, context, capability, timeout, and budget limits.
- `agent-controller.cjs` exposes an internal bearer-token API at `/v1/agent`.
- `server.cjs` mounts authenticated admin API routes at `/api/admin/agent`.
- `HERMES_AGENT_EXECUTION_MODE=disabled` is the default and records queued executions with an explicit disabled reason.
## APIs
- `GET /v1/agent/profiles`
- `POST /v1/agent/delegations`
- `GET /v1/agent/delegations?state=queued`
- `GET /v1/agent/delegations/:id`
- `POST /v1/agent/delegations/:id/cancel`
- `POST /v1/agent/executions/:id/grants`
The admin control plane exposes the same suffixes under `/api/admin/agent` after the existing admin session check.
## Execution Status
The isolated `hermes-agent-worker` can now lease and complete bounded tasks. Supported execution modes are:
- `disabled`: create, validate, audit, and queue records without dispatch.
- `fake-live`: lease queued tasks into the isolated worker and complete them through the no-side-effect fake provider.
`fake-live` executes a safe provider adapter with no model or side effects. It builds bounded context packs from approved System Knowledge reads, requires explicit grants for write/automation/provisioning scopes, records budget use and result envelopes, and reaches terminal states. Full real-provider execution remains a later adapter.
@@ -0,0 +1,68 @@
# Hermes Automation Control Live Deployment Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deploy the execution-disabled automation-control foundation into the live Hermes post-Hermes path.
**Architecture:** Add an additive Postgres schema for automation scripts, jobs, runs, events, approvals, and locks. Run a separate HTTP MCP service on the Hermes compose network with bearer auth, backed by the Hermes Postgres database, and register it only in the post-Hermes MCP config.
**Tech Stack:** Node.js CommonJS, Postgres 16, Docker Compose, Hermes HTTP MCP.
---
### Task 1: Local Skeleton Integration
**Files:**
- Create: `automation-control-mcp.cjs`
- Create: `migrations/002_automation_control.sql`
- Modify: `docker-compose.yml`
- Modify: `docker-entrypoint.sh`
- Modify: `.env.example`
- Modify: `Dockerfile`
- Test: `test/automation-control-mcp.test.cjs`
- Test: `test/compose-contract.test.cjs`
- Test: `test/docker-entrypoint.test.cjs`
- [x] Add execution-disabled MCP tools for script draft creation, job draft creation, job/run listing, policy checks, pause, and cancellation request.
- [x] Add bearer authentication and keep the service private to the compose network.
- [x] Keep pre-Hermes registration disabled.
- [x] Preserve the existing system-knowledge MCP on port `8790`; use `8791` for automation-control.
- [x] Verify no shell, Docker provisioning, worker loop, activation, or execution tools are exposed.
### Task 2: Live Safety Gate
**Live target:** Portainer LXC `102`, stack project `hermes`.
- [x] Back up live compose/config before changes.
- [x] Back up live Hermes Postgres full dump and schema-only dump before changes.
- [x] Confirm existing live DB user and migration table are accessible.
- [x] Confirm existing post/pre/control services are healthy before rollout.
### Task 3: Live Skeleton Rollout
- [x] Copy only the automation-control service file, migration, and minimal compose patch to the live stack.
- [x] Generate and store the automation-control MCP bearer token in `stack.env` without printing it.
- [x] Apply `002_automation_control.sql` and record it in `schema_migrations`.
- [x] Start `hermes-automation-control-1` in the existing `hermes` compose project.
- [x] Manually register `automation-control` in the post-Hermes MCP config after health and auth verification.
### Task 4: Verification
- [x] Source package `npm run verify`.
- [x] Control panel `npm run check`.
- [x] Control panel `npm test`.
- [x] Live service health check.
- [x] Live MCP unauthenticated request returns `401`.
- [x] Live MCP authenticated `tools/list` returns only control-plane tools.
- [x] Post-Hermes contains `automation-control`; pre-Hermes does not.
- [x] Live MCP smoke creates draft-only script/job and lists the draft job with `executionEnabled: false`.
### Execution Phase Authority Still Required
- [ ] Dedicated worker identity and token scopes.
- [ ] Explicit allowed action registry.
- [ ] Per-action external-system credentials.
- [ ] Activation rules for scripts/jobs.
- [ ] Approval expiry, consumption, and approver authority.
- [ ] Lock lease duration, retry policy, concurrency, and retention.
- [ ] Separate approval before adding any shell runner, Docker provisioner, or worker execution loop.
+122
View File
@@ -0,0 +1,122 @@
"use strict"
const crypto = require("crypto")
const {
McpError,
createWritePolicy,
payloadDigest,
redactAndCap,
} = require("./safe-write-policy.cjs")
function responseLimit(env) {
const parsed = Number.parseInt(env.VYNTE_MCP_WRITE_RESPONSE_LIMIT || "4096", 10)
return Number.isFinite(parsed) && parsed >= 256 ? Math.min(parsed, 16_384) : 4096
}
function digestText(value) {
return `sha256:${crypto.createHash("sha256").update(String(value || "")).digest("hex")}`
}
async function readResponse(response, limit) {
const text = (await response.text()).slice(0, limit * 2)
let value = text
try {
value = JSON.parse(text)
} catch {
// Plain text responses remain plain text.
}
return redactAndCap(value, limit)
}
async function focusedWrite({
operation,
args = {},
body,
env = process.env,
baseUrl,
headers = {},
fetchImpl = fetch,
auditSink,
}) {
const policy = createWritePolicy(operation, args, env)
const digest = payloadDigest(body)
const limit = responseLimit(env)
if (!policy.execute) {
return {
operation: operation.name,
dryRun: true,
executed: false,
confirmationRequired: policy.confirmationRequired,
impactConfirmationRequired: policy.impactConfirmationRequired,
request: {
service: operation.service,
method: operation.method,
path: operation.path,
body: redactAndCap(body, limit),
payloadDigest: digest,
},
}
}
if (!auditSink || typeof auditSink.record !== "function") {
throw new McpError("An audit sink is required for write execution", "audit_required")
}
const requestHeaders = {
...headers,
"Content-Type": "application/json",
"Idempotency-Key": args.idempotencyKey,
}
const targetUrl = new URL(operation.path, `${baseUrl.replace(/\/$/, "")}/`)
const auditEvent = {
operation: operation.name,
service: operation.service,
method: operation.method,
path: operation.path,
workspaceSlug: operation.workspaceSlug,
projectId: operation.projectId,
workItemId: operation.workItemId,
payloadDigest: digest,
idempotencyKeyDigest: digestText(args.idempotencyKey),
approvalPolicy: policy.approvalPolicy,
approvalId: args.approvalId,
approvalSummaryDigest: args.approvalSummary ? digestText(args.approvalSummary) : undefined,
approvalReasonDigest: args.reason ? digestText(args.reason) : undefined,
executed: true,
dryRun: false,
}
let response
try {
response = await fetchImpl(targetUrl, {
method: operation.method,
headers: requestHeaders,
body: JSON.stringify(body),
redirect: "manual",
signal: AbortSignal.timeout(5000),
})
} catch (err) {
await auditSink.record({ ...auditEvent, status: 0 })
throw err
}
const safeResponse = await readResponse(response, limit)
await auditSink.record({
...auditEvent,
status: response.status,
})
return {
operation: operation.name,
dryRun: false,
executed: true,
ok: response.ok,
status: response.status,
response: safeResponse,
payloadDigest: digest,
}
}
module.exports = {
focusedWrite,
}
+14
View File
@@ -0,0 +1,14 @@
"use strict"
const { createAgentRouteHandler } = require("./controller-routes.cjs")
function createAdminAgentRouteHandler({ pool, executionMode = "disabled" }) {
return createAgentRouteHandler({
pool,
bearerToken: "",
executionMode,
routePrefix: "/api/admin/agent",
})
}
module.exports = { createAdminAgentRouteHandler }
+15
View File
@@ -0,0 +1,15 @@
"use strict"
const { newId } = require("./ids.cjs")
async function appendAgentAuditEvent(client, { delegationId = null, executionId = null, eventType, actor = "agent-controller", event = {} }) {
const id = newId()
await client.query(
`insert into agent_audit_events (id, delegation_id, execution_id, event_type, actor, event)
values ($1, $2, $3, $4, $5, $6)`,
[id, delegationId, executionId, eventType, actor, event]
)
return id
}
module.exports = { appendAgentAuditEvent }
+49
View File
@@ -0,0 +1,49 @@
"use strict"
const crypto = require("crypto")
async function callMcp(url, token, name, args) {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ jsonrpc: "2.0", id: crypto.randomUUID(), method: "tools/call", params: { name, arguments: args } }),
signal: AbortSignal.timeout(5000),
})
if (!response.ok) throw new Error(`System Knowledge MCP returned HTTP ${response.status}`)
const body = await response.json()
if (body.error) throw new Error(`System Knowledge MCP error: ${body.error.message}`)
return body.result
}
function serviceIdFromSelector(selector = {}) {
if (selector.serviceId) return String(selector.serviceId)
const match = String(selector.record || "").match(/^service\/(.+)$/)
return match ? match[1] : ""
}
async function resolveContextSelectors(selectors, options = {}) {
const url = options.systemKnowledgeUrl
const token = options.systemKnowledgeToken || ""
const items = []
for (const request of selectors) {
if (request.source !== "system-knowledge.read") {
items.push({ ...request, status: "unavailable", warning: "No safe read adapter is configured for this context source." })
continue
}
if (!url) {
items.push({ ...request, status: "unavailable", warning: "System Knowledge MCP URL is not configured." })
continue
}
const serviceId = serviceIdFromSelector(request.selector)
const content = serviceId
? await callMcp(url, token, "knowledge_service_get", { id: serviceId })
: await callMcp(url, token, "knowledge_registry_version_get", {})
items.push({ ...request, status: "resolved", content })
}
return items
}
module.exports = { resolveContextSelectors, serviceIdFromSelector }
+150
View File
@@ -0,0 +1,150 @@
"use strict"
const { agentError } = require("./errors.cjs")
const DELEGATION_SCHEMA = "hermes.agent.delegation.v1"
const RESULT_SCHEMA = "hermes.agent.result.v1"
const BUDGET_KEYS = ["runtimeMs", "toolCalls", "inputTokens", "outputTokens", "costUsdMicros"]
const RESULT_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out", "budget_exhausted", "rejected"])
function assertPlainObject(value, field) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw agentError(`${field} must be an object`, "invalid_contract")
}
}
function boundedString(value, field, max, { required = true } = {}) {
if (value === undefined || value === null) {
if (required) throw agentError(`${field} is required`, "invalid_contract")
return ""
}
const out = String(value).trim()
if (!out && required) throw agentError(`${field} is required`, "invalid_contract")
if (out.length > max) throw agentError(`${field} must be at most ${max} characters`, "invalid_contract")
return out
}
function boundedStringArray(value, field, { maxItems = 20, maxItemLength = 500 } = {}) {
if (value === undefined || value === null) return []
if (!Array.isArray(value)) throw agentError(`${field} must be an array`, "invalid_contract")
if (value.length > maxItems) throw agentError(`${field} must have at most ${maxItems} items`, "invalid_contract")
return value.map((item, index) => boundedString(item, `${field}[${index}]`, maxItemLength))
}
function boundedObjectArray(value, field, { maxItems = 20 } = {}) {
if (value === undefined || value === null) return []
if (!Array.isArray(value)) throw agentError(`${field} must be an array`, "invalid_contract")
if (value.length > maxItems) throw agentError(`${field} must have at most ${maxItems} items`, "invalid_contract")
for (const [index, item] of value.entries()) assertPlainObject(item, `${field}[${index}]`)
return value
}
function validateProfileRef(value, field = "requestedProfile") {
assertPlainObject(value, field)
const id = boundedString(value.id, `${field}.id`, 120)
const version = Number(value.version || 1)
if (!Number.isInteger(version) || version < 1) throw agentError(`${field}.version must be a positive integer`, "invalid_contract")
return { id, version }
}
function validateBudget(value, field = "budget") {
assertPlainObject(value, field)
const budget = {}
for (const key of BUDGET_KEYS) {
const amount = Number(value[key])
if (!Number.isFinite(amount) || amount <= 0) throw agentError(`${field}.${key} must be positive`, "invalid_contract")
budget[key] = Math.floor(amount)
}
return budget
}
function validateIsoDate(value, field) {
const raw = boundedString(value, field, 80)
const parsed = new Date(raw)
if (Number.isNaN(parsed.getTime())) throw agentError(`${field} must be an ISO timestamp`, "invalid_contract")
return parsed.toISOString()
}
function validateContextSelector(selector, index) {
assertPlainObject(selector, `contextSelectors[${index}]`)
return {
source: boundedString(selector.source, `contextSelectors[${index}].source`, 120),
selector: selector.selector && typeof selector.selector === "object" && !Array.isArray(selector.selector) ? selector.selector : {},
purpose: boundedString(selector.purpose || "unspecified", `contextSelectors[${index}].purpose`, 240),
}
}
function validateCapability(capability, index) {
assertPlainObject(capability, `requestedCapabilities[${index}]`)
const operations = boundedStringArray(capability.operations || [], `requestedCapabilities[${index}].operations`, {
maxItems: 20,
maxItemLength: 80,
})
return {
scope: boundedString(capability.scope, `requestedCapabilities[${index}].scope`, 120),
resource: boundedString(capability.resource || "*", `requestedCapabilities[${index}].resource`, 240),
operations,
}
}
function validateDelegationRequest(input) {
assertPlainObject(input, "delegation request")
if (input.schema && input.schema !== DELEGATION_SCHEMA) {
throw agentError(`schema must be ${DELEGATION_SCHEMA}`, "invalid_contract")
}
const request = {
schema: DELEGATION_SCHEMA,
requestId: boundedString(input.requestId || "local", "requestId", 120),
parentExecutionId: input.parentExecutionId ? boundedString(input.parentExecutionId, "parentExecutionId", 120) : null,
requestedProfile: validateProfileRef(input.requestedProfile),
objective: boundedString(input.objective, "objective", 2000),
acceptanceCriteria: boundedStringArray(input.acceptanceCriteria, "acceptanceCriteria", { maxItems: 20, maxItemLength: 500 }),
constraints: boundedStringArray(input.constraints, "constraints", { maxItems: 20, maxItemLength: 500 }),
contextSelectors: boundedObjectArray(input.contextSelectors, "contextSelectors", { maxItems: 20 }).map(validateContextSelector),
requestedCapabilities: boundedObjectArray(input.requestedCapabilities, "requestedCapabilities", { maxItems: 20 }).map(validateCapability),
budget: validateBudget(input.budget),
timeoutAt: validateIsoDate(input.timeoutAt, "timeoutAt"),
idempotencyKey: boundedString(input.idempotencyKey, "idempotencyKey", 180),
}
return request
}
function validateResultEnvelope(input) {
assertPlainObject(input, "result envelope")
if (input.schema !== RESULT_SCHEMA) throw agentError(`schema must be ${RESULT_SCHEMA}`, "invalid_result")
const profile = validateProfileRef(input.profile, "profile")
const status = boundedString(input.status, "status", 40)
if (!RESULT_STATUSES.has(status)) throw agentError("status is not a valid terminal result status", "invalid_result")
const delegations = boundedObjectArray(input.delegations, "delegations", { maxItems: 20 })
if (profile.id.startsWith("worker.") && delegations.length > 0) {
throw agentError("worker results cannot declare child delegations", "invalid_result")
}
return {
schema: RESULT_SCHEMA,
auditId: boundedString(input.auditId, "auditId", 120),
executionId: boundedString(input.executionId, "executionId", 120),
delegationId: boundedString(input.delegationId, "delegationId", 120),
profile,
status,
summary: boundedString(input.summary, "summary", 4000),
outputs: boundedObjectArray(input.outputs, "outputs", { maxItems: 100 }),
artifacts: boundedObjectArray(input.artifacts, "artifacts", { maxItems: 50 }),
delegations,
budgetUsage: validateBudget(input.budgetUsage, "budgetUsage"),
capabilityUsage: boundedObjectArray(input.capabilityUsage, "capabilityUsage", { maxItems: 50 }),
warnings: boundedStringArray(input.warnings, "warnings", { maxItems: 50, maxItemLength: 500 }),
error: input.error || null,
startedAt: validateIsoDate(input.startedAt, "startedAt"),
completedAt: validateIsoDate(input.completedAt, "completedAt"),
}
}
module.exports = {
DELEGATION_SCHEMA,
RESULT_SCHEMA,
BUDGET_KEYS,
validateDelegationRequest,
validateResultEnvelope,
validateBudget,
}
+100
View File
@@ -0,0 +1,100 @@
"use strict"
const { timingSafeEqual } = require("crypto")
const { readJsonBody, sendJson } = require("../http.cjs")
const { AgentError } = require("./errors.cjs")
const { listProfiles } = require("./profile-store.cjs")
const {
createRootDelegation,
createChildDelegation,
listDelegations,
cancelDelegation,
getDelegationResult,
grantExecutionCapability,
} = require("./delegation-store.cjs")
function safeEqual(a, b) {
const left = Buffer.from(String(a || ""))
const right = Buffer.from(String(b || ""))
return left.length === right.length && timingSafeEqual(left, right)
}
function authorized(req, bearerToken) {
if (!bearerToken) return true
const header = String(req.headers.authorization || "")
const match = header.match(/^Bearer\s+(.+)$/i)
return Boolean(match && safeEqual(match[1], bearerToken))
}
function errorResponse(res, err) {
const status = err instanceof AgentError ? err.status : 500
const code = err instanceof AgentError ? err.code : "agent_error"
sendJson(res, status, { error: { code, message: err.message } })
}
function createAgentRouteHandler({ pool, bearerToken = "", executionMode = "disabled", routePrefix = "/v1/agent" }) {
const prefix = routePrefix.replace(/\/+$/, "")
return async function handleAgentRoute(req, res) {
try {
const url = new URL(req.url, "http://127.0.0.1")
const path = url.pathname
if (!path.startsWith(prefix)) {
sendJson(res, 404, { error: { code: "not_found", message: "not found" } })
return
}
if (!authorized(req, bearerToken)) {
sendJson(res, 401, { error: { code: "unauthorized", message: "missing or invalid bearer token" } })
return
}
const suffix = path.slice(prefix.length) || "/"
if (req.method === "GET" && suffix === "/profiles") {
const profiles = await listProfiles(pool)
sendJson(res, 200, { profiles })
return
}
if (req.method === "GET" && suffix === "/delegations") {
const state = url.searchParams.get("state") || null
const limit = Number(url.searchParams.get("limit") || 100)
const delegations = await listDelegations(pool, { state, limit })
sendJson(res, 200, { delegations })
return
}
if (req.method === "POST" && suffix === "/delegations") {
const body = await readJsonBody(req)
const create = body.parentExecutionId ? createChildDelegation : createRootDelegation
const result = await create(pool, { ...body, executionMode })
sendJson(res, 201, result)
return
}
const delegationMatch = suffix.match(/^\/delegations\/([^/]+)$/)
if (req.method === "GET" && delegationMatch) {
sendJson(res, 200, await getDelegationResult(pool, delegationMatch[1]))
return
}
const cancelMatch = suffix.match(/^\/delegations\/([^/]+)\/cancel$/)
if (req.method === "POST" && cancelMatch) {
const body = await readJsonBody(req).catch(() => ({}))
const result = await cancelDelegation(pool, cancelMatch[1], body.reason || "cancel requested")
sendJson(res, 200, result)
return
}
const grantMatch = suffix.match(/^\/executions\/([^/]+)\/grants$/)
if (req.method === "POST" && grantMatch) {
sendJson(res, 201, { grant: await grantExecutionCapability(pool, grantMatch[1], await readJsonBody(req)) })
return
}
sendJson(res, 404, { error: { code: "not_found", message: "not found" } })
} catch (err) {
errorResponse(res, err)
}
}
}
module.exports = { createAgentRouteHandler, authorized }
+327
View File
@@ -0,0 +1,327 @@
"use strict"
const { withTransaction } = require("../db.cjs")
const { validateDelegationRequest, BUDGET_KEYS } = require("./contracts.cjs")
const { loadProfileRegistry } = require("./profile-registry.cjs")
const { evaluateDelegation } = require("./policy-engine.cjs")
const { appendAgentAuditEvent } = require("./audit-store.cjs")
const { agentError } = require("./errors.cjs")
const { newId } = require("./ids.cjs")
function rowToDelegation(row) {
return {
id: row.id,
auditId: row.audit_id,
parentExecutionId: row.parent_execution_id,
rootExecutionId: row.root_execution_id,
depth: row.depth,
requestedProfile: { id: row.requested_profile_id, version: row.requested_profile_version },
objective: row.objective,
acceptanceCriteria: row.acceptance_criteria || [],
constraints: row.constraints || [],
contextSelectors: row.context_selectors || [],
requestedCapabilities: row.requested_capabilities || [],
requestedBudget: row.requested_budget || {},
timeoutAt: row.timeout_at instanceof Date ? row.timeout_at.toISOString() : row.timeout_at,
idempotencyKey: row.idempotency_key,
requestedAt: row.requested_at instanceof Date ? row.requested_at.toISOString() : row.requested_at,
}
}
function rowToExecution(row) {
return {
id: row.id,
auditId: row.audit_id,
delegationId: row.delegation_id,
rootExecutionId: row.root_execution_id,
parentExecutionId: row.parent_execution_id,
profile: { id: row.profile_id, version: row.profile_version },
depth: row.depth,
state: row.state,
stateReason: row.state_reason,
budgetReserved: row.budget_reserved || {},
budgetRemaining: row.budget_remaining || {},
timeoutAt: row.timeout_at instanceof Date ? row.timeout_at.toISOString() : row.timeout_at,
cancelRequestedAt: row.cancel_requested_at instanceof Date ? row.cancel_requested_at.toISOString() : row.cancel_requested_at,
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
}
}
async function getExistingByIdempotency(client, parentExecutionId, idempotencyKey) {
const { rows } = await client.query(
`select d.*, e.id as execution_id
from agent_delegations d
join agent_executions e on e.delegation_id = d.id
where (($1::text is null and d.parent_execution_id is null) or d.parent_execution_id = $1)
and d.idempotency_key = $2`,
[parentExecutionId, idempotencyKey]
)
if (!rows.length) return null
return getDelegationWithExecution(client, rows[0].id)
}
async function getDelegationWithExecution(client, delegationId) {
const delegation = await client.query("select * from agent_delegations where id = $1", [delegationId])
if (!delegation.rows.length) return null
const execution = await client.query("select * from agent_executions where delegation_id = $1", [delegationId])
return {
delegation: rowToDelegation(delegation.rows[0]),
execution: rowToExecution(execution.rows[0]),
}
}
async function loadParentExecution(client, parentExecutionId, registry) {
const { rows } = await client.query("select * from agent_executions where id = $1 for update", [parentExecutionId])
if (!rows.length) throw agentError("parent execution not found", "parent_not_found", 404)
const row = rows[0]
const profile = registry.get(row.profile_id, row.profile_version)
return {
id: row.id,
state: row.state,
depth: row.depth,
timeoutAt: row.timeout_at,
profile,
remainingBudget: row.budget_remaining,
}
}
function initialExecutionState(executionMode = "disabled") {
if (executionMode === "fake-live") return { state: "queued", reason: "fake-live worker adapter selected; bounded execution enabled" }
return { state: "queued", reason: "execution disabled; runtime foundation only" }
}
function subtractBudget(parentBudget, childBudget) {
const next = { ...parentBudget }
for (const key of BUDGET_KEYS) next[key] = Number(next[key]) - Number(childBudget[key])
return next
}
async function insertDelegationAndExecution(client, { request, profile, parentExecution = null, executionMode = "disabled" }) {
const delegationId = newId()
const executionId = newId()
const delegationAuditId = newId()
const executionAuditId = newId()
const depth = parentExecution ? parentExecution.depth + 1 : 0
const rootExecutionId = parentExecution ? parentExecution.rootExecutionId || parentExecution.id : executionId
const { state, reason } = initialExecutionState(executionMode)
await client.query(
`insert into agent_delegations
(id, audit_id, parent_execution_id, root_execution_id, depth, requested_profile_id,
requested_profile_version, objective, acceptance_criteria, constraints, context_selectors,
requested_capabilities, requested_budget, timeout_at, idempotency_key)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
[
delegationId,
delegationAuditId,
parentExecution ? parentExecution.id : null,
rootExecutionId,
depth,
profile.id,
profile.version,
request.objective,
JSON.stringify(request.acceptanceCriteria),
JSON.stringify(request.constraints),
JSON.stringify(request.contextSelectors),
JSON.stringify(request.requestedCapabilities),
JSON.stringify(request.budget),
request.timeoutAt,
request.idempotencyKey,
]
)
await client.query(
`insert into agent_executions
(id, audit_id, delegation_id, root_execution_id, parent_execution_id, profile_id,
profile_version, depth, state, state_reason, budget_reserved, budget_remaining, timeout_at)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
[
executionId,
executionAuditId,
delegationId,
rootExecutionId,
parentExecution ? parentExecution.id : null,
profile.id,
profile.version,
depth,
state,
reason,
JSON.stringify(request.budget),
JSON.stringify(request.budget),
request.timeoutAt,
]
)
if (parentExecution) {
await client.query(
"update agent_executions set budget_remaining = $1, updated_at = now() where id = $2",
[JSON.stringify(subtractBudget(parentExecution.remainingBudget, request.budget)), parentExecution.id]
)
}
await appendAgentAuditEvent(client, {
delegationId,
executionId,
eventType: "delegation.created",
event: { profile: { id: profile.id, version: profile.version }, depth, executionMode, state },
})
return getDelegationWithExecution(client, delegationId)
}
async function createRootDelegation(pool, input) {
const registry = loadProfileRegistry()
const request = validateDelegationRequest({
schema: "hermes.agent.delegation.v1",
requestId: input.requestId || "root",
...input,
parentExecutionId: null,
})
const profile = registry.get(request.requestedProfile.id, request.requestedProfile.version)
const policy = evaluateDelegation({ parentExecution: null, requestedProfile: profile, request })
if (!policy.allowed) throw agentError(policy.reason, policy.code, 403)
return withTransaction(pool, async (client) => {
const existing = await getExistingByIdempotency(client, null, request.idempotencyKey)
if (existing) return existing
return insertDelegationAndExecution(client, { request, profile, executionMode: input.executionMode || "disabled" })
})
}
async function createChildDelegation(pool, input) {
const registry = loadProfileRegistry()
const request = validateDelegationRequest(input)
return withTransaction(pool, async (client) => {
const existing = await getExistingByIdempotency(client, request.parentExecutionId, request.idempotencyKey)
if (existing) return existing
const parentExecution = await loadParentExecution(client, request.parentExecutionId, registry)
const rootExecutionId = parentExecution.rootExecutionId || parentExecution.id
const requestedProfile = registry.get(request.requestedProfile.id, request.requestedProfile.version)
const policy = evaluateDelegation({ parentExecution, requestedProfile, request })
await appendAgentAuditEvent(client, {
executionId: parentExecution.id,
eventType: "policy.delegation.evaluated",
event: { requestId: request.requestId, requestedProfile: request.requestedProfile, policy },
})
if (!policy.allowed) throw agentError(policy.reason, policy.code, 403)
return insertDelegationAndExecution(client, {
request,
profile: requestedProfile,
parentExecution: { ...parentExecution, rootExecutionId },
executionMode: input.executionMode || "disabled",
})
})
}
async function listDelegations(pool, { state = null, limit = 100 } = {}) {
const params = []
let where = ""
if (state) {
params.push(state)
where = "where e.state = $1"
}
params.push(Math.max(1, Math.min(500, Number(limit) || 100)))
const { rows } = await pool.query(
`select d.*, e.id as e_id, e.audit_id as e_audit_id, e.delegation_id, e.root_execution_id as e_root_execution_id,
e.parent_execution_id as e_parent_execution_id, e.profile_id, e.profile_version, e.depth as e_depth,
e.state, e.state_reason, e.budget_reserved, e.budget_remaining, e.timeout_at as e_timeout_at,
e.cancel_requested_at, e.created_at as e_created_at, e.updated_at as e_updated_at
from agent_delegations d
join agent_executions e on e.delegation_id = d.id
${where}
order by d.requested_at desc
limit $${params.length}`,
params
)
return rows.map((row) => ({
delegation: rowToDelegation(row),
execution: rowToExecution({
id: row.e_id,
audit_id: row.e_audit_id,
delegation_id: row.delegation_id,
root_execution_id: row.e_root_execution_id,
parent_execution_id: row.e_parent_execution_id,
profile_id: row.profile_id,
profile_version: row.profile_version,
depth: row.e_depth,
state: row.state,
state_reason: row.state_reason,
budget_reserved: row.budget_reserved,
budget_remaining: row.budget_remaining,
timeout_at: row.e_timeout_at,
cancel_requested_at: row.cancel_requested_at,
created_at: row.e_created_at,
updated_at: row.e_updated_at,
}),
}))
}
async function cancelDelegation(pool, delegationId, reason = "cancel requested") {
return withTransaction(pool, async (client) => {
const current = await getDelegationWithExecution(client, delegationId)
if (!current) throw agentError("delegation not found", "delegation_not_found", 404)
if (["succeeded", "failed", "cancelled", "timed_out", "budget_exhausted", "rejected"].includes(current.execution.state)) {
return current
}
await client.query(
`update agent_executions
set state = 'cancelled', state_reason = $1, cancel_requested_at = now(), completed_at = now(), updated_at = now()
where delegation_id = $2`,
[String(reason || "cancel requested").slice(0, 500), delegationId]
)
await appendAgentAuditEvent(client, {
delegationId,
executionId: current.execution.id,
eventType: "delegation.cancelled",
event: { reason: String(reason || "cancel requested").slice(0, 500) },
})
return getDelegationWithExecution(client, delegationId)
})
}
async function getDelegationResult(pool, delegationId) {
const current = await getDelegationWithExecution(pool, delegationId)
if (!current) throw agentError("delegation not found", "delegation_not_found", 404)
const { rows } = await pool.query("select envelope from agent_results where delegation_id = $1", [delegationId])
return { ...current, result: rows[0]?.envelope || null }
}
async function grantExecutionCapability(pool, executionId, input = {}) {
const scope = String(input.scope || "").trim()
const resource = String(input.resource || "*").trim()
const operations = Array.isArray(input.operations) ? input.operations.map(String) : []
const reason = String(input.reason || "").trim()
if (!scope || !operations.length || !reason) throw agentError("scope, operations, and reason are required", "invalid_grant")
const expiresAt = new Date(input.expiresAt)
if (Number.isNaN(expiresAt.getTime()) || expiresAt <= new Date()) throw agentError("expiresAt must be in the future", "invalid_grant")
return withTransaction(pool, async (client) => {
const execution = await client.query("select id, delegation_id from agent_executions where id = $1 for update", [executionId])
if (!execution.rows.length) throw agentError("execution not found", "execution_not_found", 404)
const id = newId()
const grantHash = require("crypto").createHash("sha256").update(JSON.stringify({ id, executionId, scope, resource, operations, expiresAt })).digest("hex")
await client.query(
`insert into agent_capability_grants
(id, grant_hash, execution_id, audience, scope, resource, operations, constraints, expires_at, max_uses)
values ($1, $2, $3, 'agent-worker', $4, $5, $6, $7, $8, $9)`,
[id, grantHash, executionId, scope, resource, JSON.stringify(operations), JSON.stringify({ reason }), expiresAt, Math.max(1, Math.min(10, Number(input.maxUses) || 1))]
)
await appendAgentAuditEvent(client, {
delegationId: execution.rows[0].delegation_id,
executionId,
eventType: "capability.granted",
actor: "agent-controller",
event: { grantId: id, scope, resource, operations, reason, expiresAt: expiresAt.toISOString() },
})
return { id, executionId, scope, resource, operations, expiresAt: expiresAt.toISOString(), maxUses: Math.max(1, Math.min(10, Number(input.maxUses) || 1)) }
})
}
module.exports = {
createRootDelegation,
createChildDelegation,
listDelegations,
cancelDelegation,
getDelegationResult,
grantExecutionCapability,
}
+16
View File
@@ -0,0 +1,16 @@
"use strict"
class AgentError extends Error {
constructor(message, { code = "agent_error", status = 400 } = {}) {
super(message)
this.name = "AgentError"
this.code = code
this.status = status
}
}
function agentError(message, code = "agent_error", status = 400) {
return new AgentError(message, { code, status })
}
module.exports = { AgentError, agentError }
+13
View File
@@ -0,0 +1,13 @@
"use strict"
const crypto = require("crypto")
function newId() {
return crypto.randomUUID()
}
function sha256(value) {
return crypto.createHash("sha256").update(String(value)).digest("hex")
}
module.exports = { newId, sha256 }
+63
View File
@@ -0,0 +1,63 @@
"use strict"
const { BUDGET_KEYS } = require("./contracts.cjs")
const ACTIVE_PARENT_STATES = new Set(["requested", "policy_check", "awaiting_approval", "queued", "leased", "running"])
function denial(reason, code = "policy_denied") {
return { allowed: false, code, reason }
}
function approval(reason = "allowed") {
return { allowed: true, code: "allowed", reason }
}
function includesAll(allowlist, requested) {
return requested.every((item) => allowlist.includes(item))
}
function budgetExcessReason(requested, limit, label) {
for (const key of BUDGET_KEYS) {
if (Number(requested[key]) > Number(limit[key])) return `requested budget ${key} exceeds ${label}`
}
return null
}
function evaluateDelegation({ parentExecution = null, requestedProfile, request, now = new Date() }) {
if (!requestedProfile) return denial("requested profile is required", "unknown_profile")
const requestedTimeout = new Date(request.timeoutAt)
if (Number.isNaN(requestedTimeout.getTime()) || requestedTimeout <= now) return denial("timeoutAt must be in the future")
if (!parentExecution) {
if (requestedProfile.spec.tier !== "orchestrator") return denial("root delegation must target orchestrator profile")
const excess = budgetExcessReason(request.budget, requestedProfile.spec.defaultBudget, "profile defaultBudget")
return excess ? denial(excess) : approval("root delegation accepted")
}
const parentProfile = parentExecution.profile
const childDepth = Number(parentExecution.depth) + 1
if (!ACTIVE_PARENT_STATES.has(parentExecution.state)) return denial("parent execution is not active")
if (childDepth > 2) return denial("maximum delegation depth 2 exceeded")
if (!parentProfile.spec.canDelegate) return denial("parent profile cannot delegate")
if (!parentProfile.spec.allowedChildProfiles.includes(requestedProfile.id)) return denial("requested child profile is not allowed by parent")
const requestedContexts = request.contextSelectors.map((selector) => selector.source)
if (!includesAll(requestedProfile.spec.allowedContextSources, requestedContexts)) {
return denial("requested context source is not allowed by profile")
}
const requestedScopes = request.requestedCapabilities.map((capability) => capability.scope)
if (!includesAll(requestedProfile.spec.allowedCapabilityScopes, requestedScopes)) {
return denial("requested capability scope is not allowed by profile")
}
const profileExcess = budgetExcessReason(request.budget, parentProfile.spec.maxChildBudget, "profile maxChildBudget")
if (profileExcess) return denial(profileExcess)
const remainingExcess = budgetExcessReason(request.budget, parentExecution.remainingBudget, "parent remaining budget")
if (remainingExcess) return denial(remainingExcess)
if (parentExecution.timeoutAt && requestedTimeout > new Date(parentExecution.timeoutAt)) {
return denial("child timeoutAt cannot exceed parent timeout")
}
return approval("delegation accepted")
}
module.exports = { evaluateDelegation }
+212
View File
@@ -0,0 +1,212 @@
"use strict"
const crypto = require("crypto")
const { agentError } = require("./errors.cjs")
const sharedBudget = {
runtimeMs: 900000,
toolCalls: 80,
inputTokens: 120000,
outputTokens: 30000,
costUsdMicros: 3000000,
}
const workerBudget = {
runtimeMs: 300000,
toolCalls: 20,
inputTokens: 30000,
outputTokens: 8000,
costUsdMicros: 500000,
}
const coordinatorMaxChildBudget = {
runtimeMs: 600000,
toolCalls: 40,
inputTokens: 60000,
outputTokens: 15000,
costUsdMicros: 1500000,
}
const PROFILE_MANIFESTS = [
profile("orchestrator.hermes", "Hermes Orchestrator", "orchestrator", "global", {
objective: "Classify requests, reserve budgets, delegate to domain coordinators, and synthesize final results.",
canDelegate: true,
allowedChildProfiles: [
"coordinator.software",
"coordinator.sales",
"coordinator.marketing",
"coordinator.communications",
"coordinator.operations",
"coordinator.automation",
],
allowedContextSources: ["system-knowledge.read"],
allowedCapabilityScopes: ["system-knowledge.read", "delegation.create", "delegation.list", "delegation.cancel"],
allowedBrokerClasses: [],
defaultBudget: sharedBudget,
maxChildBudget: sharedBudget,
systemPromptRef: "prompts/orchestrator/hermes-v1.md",
}),
coordinator("software", ["software-repository.read", "gitea.read", "plane.read"], [
"worker.software.repo-analysis",
"worker.software.implementation",
"worker.software.test-verification",
"worker.software.code-review",
], ["repository.read", "repository.write.proposed", "test.execute", "external.docs.read"]),
coordinator("sales", ["crm.read", "calendar.read", "product-knowledge.read"], [
"worker.sales.account-research",
"worker.sales.pipeline-analysis",
"worker.sales.meeting-prep",
"worker.sales.follow-up-draft",
], ["crm.read", "calendar.read", "external.docs.read"]),
coordinator("marketing", ["analytics.read", "campaign.read", "brand-knowledge.read"], [
"worker.marketing.campaign-analysis",
"worker.marketing.content-draft",
"worker.marketing.seo-research",
"worker.marketing.performance-report",
], ["analytics.read", "campaign.read", "external.docs.read"]),
coordinator("communications", ["message-history.read", "contacts.read", "brand-voice.read"], [
"worker.communications.email-draft",
"worker.communications.slack-draft",
"worker.communications.announcement-draft",
"worker.communications.response-triage",
], ["message-history.read", "contacts.read", "draft.create"]),
coordinator("operations", ["service-catalog.read", "health-status.read", "runbook.read", "plane.read"], [
"worker.operations.service-triage",
"worker.operations.runbook-lookup",
"worker.operations.incident-summary",
"worker.operations.change-plan-draft",
], ["service.read", "runbook.read", "plane.read"]),
coordinator("automation", ["automation-template.read", "schedule.read", "runbook.read"], [
"worker.automation.design",
"worker.automation.validate",
"worker.automation.execute",
], ["automation.design", "automation.validate", "automation.execute.proposed"]),
worker("software.repo-analysis", "Read-only repository inspection and implementation recommendation.", ["software-repository.read"], ["repository.read"]),
worker("software.implementation", "Bounded workspace edits requiring a proposed write grant.", ["software-repository.read"], ["repository.read", "repository.write.proposed"]),
worker("software.test-verification", "Run allowlisted test commands without source edits.", ["software-repository.read"], ["test.execute"]),
worker("software.code-review", "Read-only code review findings with file and line evidence.", ["software-repository.read"], ["repository.read"]),
worker("sales.account-research", "Read-only account research.", ["crm.read", "external.docs.read"], ["crm.read", "external.docs.read"]),
worker("sales.pipeline-analysis", "Aggregate CRM pipeline analysis without record writes.", ["crm.read"], ["crm.read"]),
worker("sales.meeting-prep", "CRM and calendar context pack to meeting briefing.", ["crm.read", "calendar.read"], ["crm.read", "calendar.read"]),
worker("sales.follow-up-draft", "Produce follow-up drafts only.", ["crm.read", "brand-voice.read"], ["draft.create"]),
worker("marketing.campaign-analysis", "Read-only campaign and analytics analysis.", ["campaign.read", "analytics.read"], ["campaign.read", "analytics.read"]),
worker("marketing.content-draft", "Produce marketing draft artifacts only.", ["brand-knowledge.read"], ["draft.create"]),
worker("marketing.seo-research", "Approved external SEO research.", ["external.docs.read"], ["external.docs.read"]),
worker("marketing.performance-report", "Metrics report with provenance.", ["analytics.read"], ["analytics.read"]),
worker("communications.email-draft", "Draft email content only.", ["message-history.read", "contacts.read"], ["draft.create"]),
worker("communications.slack-draft", "Draft Slack content only.", ["message-history.read", "contacts.read"], ["draft.create"]),
worker("communications.announcement-draft", "Draft announcement content only.", ["brand-voice.read"], ["draft.create"]),
worker("communications.response-triage", "Classify and recommend responses without sending.", ["message-history.read"], ["message-history.read"]),
worker("operations.service-triage", "Read-only service checks with evidence.", ["service-catalog.read", "health-status.read"], ["service.read"]),
worker("operations.runbook-lookup", "System knowledge and runbook reads.", ["system-knowledge.read", "runbook.read"], ["system-knowledge.read", "runbook.read"]),
worker("operations.incident-summary", "Incident evidence and timeline synthesis.", ["service-catalog.read", "health-status.read", "runbook.read"], ["service.read", "runbook.read"]),
worker("operations.change-plan-draft", "Infrastructure change plan drafts only.", ["service-catalog.read", "runbook.read"], ["service.read", "runbook.read"]),
worker("automation.design", "Generate declarative automation specifications.", ["automation-template.read", "runbook.read"], ["automation.design"]),
worker("automation.validate", "Validate automation templates, permissions, limits, and rollback.", ["automation-template.read", "schedule.read"], ["automation.validate"]),
worker("automation.execute", "Invoke automation broker only with approval and a single-use grant.", ["automation-template.read", "schedule.read"], ["automation.execute.proposed"]),
]
function profile(id, displayName, tier, domain, spec) {
return {
apiVersion: "hermes.vyntehome.com/v1",
kind: "AgentProfile",
metadata: { id, version: 1, displayName },
spec: {
tier,
domain,
objective: spec.objective,
canDelegate: Boolean(spec.canDelegate),
allowedChildProfiles: spec.allowedChildProfiles || [],
allowedContextSources: ["system-knowledge.read", ...(spec.allowedContextSources || [])],
allowedCapabilityScopes: spec.allowedCapabilityScopes || [],
allowedBrokerClasses: spec.allowedBrokerClasses || [],
defaultBudget: spec.defaultBudget || workerBudget,
maxChildBudget: spec.maxChildBudget || workerBudget,
contextLimits: { maxBytes: 524288, maxItems: 80, maxItemBytes: 65536 },
resultContract: "hermes.agent.result.v1",
systemPromptRef: spec.systemPromptRef || `prompts/${tier}s/${id.replace(/\./g, "-")}-v1.md`,
},
}
}
function coordinator(domain, contexts, children, scopes) {
return profile(`coordinator.${domain}`, `${title(domain)} Coordinator`, "coordinator", domain, {
objective: `Coordinate bounded ${domain} tasks.`,
canDelegate: true,
allowedChildProfiles: children,
allowedContextSources: contexts,
allowedCapabilityScopes: ["system-knowledge.read", ...scopes],
allowedBrokerClasses: domain === "automation" ? ["approval", "container", "automation"] : ["approval", "credential", "container"],
defaultBudget: sharedBudget,
maxChildBudget: coordinatorMaxChildBudget,
systemPromptRef: `prompts/coordinators/${domain}-v1.md`,
})
}
function worker(idSuffix, objective, contexts, scopes) {
const domain = idSuffix.split(".")[0]
return profile(`worker.${idSuffix}`, `${title(idSuffix.replace(/\./g, " "))} Worker`, "worker", domain, {
objective,
canDelegate: false,
allowedChildProfiles: [],
allowedContextSources: contexts,
allowedCapabilityScopes: ["system-knowledge.read", ...scopes],
allowedBrokerClasses: [],
defaultBudget: workerBudget,
maxChildBudget: workerBudget,
systemPromptRef: `prompts/workers/${idSuffix.replace(/\./g, "-")}-v1.md`,
})
}
function title(value) {
return String(value).replace(/\b\w/g, (char) => char.toUpperCase())
}
function manifestHash(manifest) {
return crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex")
}
function validateManifest(manifest, manifestsById) {
const { id, version } = manifest.metadata
const { tier, canDelegate, allowedChildProfiles } = manifest.spec
if (!["orchestrator", "coordinator", "worker"].includes(tier)) throw agentError(`invalid tier for ${id}`, "invalid_profile")
if (tier === "worker" && canDelegate) throw agentError(`worker ${id} cannot delegate`, "invalid_profile")
if (tier === "worker" && allowedChildProfiles.length > 0) throw agentError(`worker ${id} cannot have child profiles`, "invalid_profile")
if (!["orchestrator", "coordinator"].includes(tier) && canDelegate) throw agentError(`${id} cannot delegate`, "invalid_profile")
for (const childId of allowedChildProfiles) {
const child = manifestsById.get(`${childId}:1`)
if (!child) throw agentError(`${id} references unknown child profile ${childId}`, "invalid_profile")
if (tier === "orchestrator" && child.spec.tier !== "coordinator") throw agentError(`orchestrator child ${childId} must be coordinator`, "invalid_profile")
if (tier === "coordinator" && child.spec.tier !== "worker") throw agentError(`coordinator child ${childId} must be worker`, "invalid_profile")
}
if (!Number.isInteger(version) || version < 1) throw agentError(`invalid version for ${id}`, "invalid_profile")
}
function loadProfileRegistry() {
const profiles = PROFILE_MANIFESTS.map((manifest) => JSON.parse(JSON.stringify(manifest)))
const byKey = new Map()
for (const manifest of profiles) byKey.set(`${manifest.metadata.id}:${manifest.metadata.version}`, manifest)
for (const manifest of profiles) validateManifest(manifest, byKey)
return {
all() {
return profiles.map((manifest) => ({
...manifest,
manifestSha256: manifestHash(manifest),
}))
},
get(id, version = 1) {
const manifest = byKey.get(`${id}:${version}`)
if (!manifest) throw agentError(`unknown agent profile ${id}@${version}`, "unknown_profile", 404)
return {
id: manifest.metadata.id,
version: manifest.metadata.version,
displayName: manifest.metadata.displayName,
manifest: JSON.parse(JSON.stringify(manifest)),
manifestSha256: manifestHash(manifest),
spec: JSON.parse(JSON.stringify(manifest.spec)),
}
},
}
}
module.exports = { loadProfileRegistry }
+54
View File
@@ -0,0 +1,54 @@
"use strict"
const { agentError } = require("./errors.cjs")
async function syncProfiles(pool, registry) {
const client = await pool.connect()
try {
await client.query("BEGIN")
for (const entry of registry.all()) {
const id = entry.metadata.id
const version = entry.metadata.version
const existing = await client.query(
"select manifest_sha256 from agent_profiles where id = $1 and version = $2",
[id, version]
)
if (existing.rows.length && existing.rows[0].manifest_sha256 !== entry.manifestSha256) {
throw agentError(`profile manifest hash mismatch for ${id}@${version}`, "profile_hash_mismatch", 500)
}
if (!existing.rows.length) {
await client.query(
`insert into agent_profiles
(id, version, tier, domain, display_name, manifest, manifest_sha256)
values ($1, $2, $3, $4, $5, $6, $7)`,
[
id,
version,
entry.spec.tier,
entry.spec.domain,
entry.metadata.displayName,
entry,
entry.manifestSha256,
]
)
}
}
await client.query("COMMIT")
} catch (err) {
await client.query("ROLLBACK")
throw err
} finally {
client.release()
}
}
async function listProfiles(pool) {
const { rows } = await pool.query(
`select id, version, tier, domain, display_name as "displayName", enabled, manifest, manifest_sha256 as "manifestSha256"
from agent_profiles
order by tier, domain, id, version`
)
return rows
}
module.exports = { syncProfiles, listProfiles }
+104
View File
@@ -0,0 +1,104 @@
"use strict"
const { validateResultEnvelope, BUDGET_KEYS } = require("./contracts.cjs")
const DANGEROUS = /(write|execute|automation|provision|create|delete|update|send|deploy|shell)/i
function capabilityNeedsGrant(capability) {
return DANGEROUS.test(String(capability.scope || "")) ||
(capability.operations || []).some((operation) => DANGEROUS.test(String(operation)))
}
function matchingGrant(capability, grants) {
return grants.some((grant) =>
grant.scope === capability.scope &&
(grant.resource === "*" || grant.resource === capability.resource) &&
(capability.operations || []).every((operation) => grant.operations.includes(operation))
)
}
function usageFor(task, elapsedMs) {
const input = JSON.stringify({
objective: task.delegation.objective,
acceptanceCriteria: task.delegation.acceptanceCriteria,
constraints: task.delegation.constraints,
contextPack: task.contextPack,
})
return {
runtimeMs: Math.max(1, elapsedMs),
toolCalls: Math.max(1, task.contextPack.items.length),
inputTokens: Math.max(1, Math.ceil(input.length / 4)),
outputTokens: 64,
costUsdMicros: 1,
}
}
function exceedsBudget(usage, budget) {
return BUDGET_KEYS.some((key) => Number(usage[key]) > Number(budget[key]))
}
async function createFakeResult(task, times = {}) {
const startedAt = times.startedAt || new Date().toISOString()
const completedAt = times.completedAt || new Date().toISOString()
const usage = usageFor(task, Math.max(1, new Date(completedAt) - new Date(startedAt)))
const envelope = {
schema: "hermes.agent.result.v1",
auditId: task.execution.auditId,
executionId: task.execution.id,
delegationId: task.delegation.id,
profile: task.execution.profile,
status: exceedsBudget(usage, task.execution.budgetReserved) ? "budget_exhausted" : "succeeded",
summary: `Fake-live specialist completed the bounded objective using ${task.contextPack.items.length} approved context item(s).`,
outputs: [{
kind: "fake-live-specialist-result",
objective: task.delegation.objective,
acceptanceCriteria: task.delegation.acceptanceCriteria,
contextSources: task.contextPack.items.map((item) => item.source),
}],
artifacts: [],
delegations: [],
budgetUsage: usage,
capabilityUsage: task.delegation.requestedCapabilities.map((capability) => ({
scope: capability.scope,
resource: capability.resource,
operations: capability.operations,
mode: "fake-live-no-side-effect",
})),
warnings: ["Provider mode is fake-live; no real Hermes model invocation or side effect occurred."],
error: null,
startedAt,
completedAt,
}
return validateResultEnvelope(envelope)
}
async function executeLeasedTask(task, { grants = [], providerMode = "fake-live" } = {}) {
const missing = task.delegation.requestedCapabilities.filter((capability) =>
capabilityNeedsGrant(capability) && !matchingGrant(capability, grants)
)
if (missing.length) {
const now = new Date().toISOString()
return validateResultEnvelope({
schema: "hermes.agent.result.v1",
auditId: task.execution.auditId,
executionId: task.execution.id,
delegationId: task.delegation.id,
profile: task.execution.profile,
status: "rejected",
summary: "Execution rejected because an explicit capability grant is required.",
outputs: [],
artifacts: [],
delegations: [],
budgetUsage: { runtimeMs: 1, toolCalls: 1, inputTokens: 1, outputTokens: 1, costUsdMicros: 1 },
capabilityUsage: [],
warnings: missing.map((capability) => `Missing grant for ${capability.scope}:${capability.resource}`),
error: { code: "grant_required", missing },
startedAt: now,
completedAt: now,
})
}
if (providerMode !== "fake-live") throw new Error(`unsupported provider mode: ${providerMode}`)
return createFakeResult(task)
}
module.exports = { capabilityNeedsGrant, createFakeResult, executeLeasedTask }
+176
View File
@@ -0,0 +1,176 @@
"use strict"
const crypto = require("crypto")
const { withTransaction } = require("../db.cjs")
const { appendAgentAuditEvent } = require("./audit-store.cjs")
const { validateResultEnvelope, BUDGET_KEYS } = require("./contracts.cjs")
const { newId } = require("./ids.cjs")
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex")
function mapTask(row) {
return {
delegation: {
id: row.delegation_id,
objective: row.objective,
acceptanceCriteria: row.acceptance_criteria || [],
constraints: row.constraints || [],
contextSelectors: row.context_selectors || [],
requestedCapabilities: row.requested_capabilities || [],
},
execution: {
id: row.execution_id,
auditId: row.audit_id,
profile: { id: row.profile_id, version: row.profile_version },
budgetReserved: row.budget_reserved || {},
timeoutAt: row.timeout_at instanceof Date ? row.timeout_at.toISOString() : row.timeout_at,
},
profileManifest: row.manifest,
}
}
async function expireTimedOutExecutions(pool) {
await pool.query(
`update agent_executions set state = 'queued',
state_reason = 'fake-live worker adapter selected; bounded execution enabled',
lease_owner = null, lease_token_hash = null, lease_expires_at = null, updated_at = now()
where state in ('leased', 'running') and lease_expires_at <= now() and attempt < max_attempts and timeout_at > now()`
)
await pool.query(
`update agent_executions set state = 'failed', state_reason = 'worker lease attempts exhausted',
completed_at = now(), lease_owner = null, lease_token_hash = null, lease_expires_at = null, updated_at = now()
where state in ('leased', 'running') and lease_expires_at <= now() and attempt >= max_attempts`
)
const { rows } = await pool.query(
`update agent_executions set state = 'timed_out', state_reason = 'execution timeout reached',
completed_at = now(), updated_at = now(), lease_owner = null, lease_token_hash = null, lease_expires_at = null
where state in ('queued', 'leased', 'running', 'cancelling') and timeout_at <= now()
returning id, delegation_id`
)
for (const row of rows) {
await appendAgentAuditEvent(pool, { delegationId: row.delegation_id, executionId: row.id, eventType: "execution.timed_out", actor: "agent-worker" })
}
return rows.length
}
async function leaseNextExecution(pool, { owner, leaseMs = 30000 } = {}) {
return withTransaction(pool, async (client) => {
const { rows } = await client.query(
`select e.id as execution_id, e.audit_id, e.profile_id, e.profile_version, e.budget_reserved, e.timeout_at,
d.id as delegation_id, d.objective, d.acceptance_criteria, d.constraints, d.context_selectors, d.requested_capabilities,
p.manifest
from agent_executions e
join agent_delegations d on d.id = e.delegation_id
join agent_profiles p on p.id = e.profile_id and p.version = e.profile_version
where e.state = 'queued' and e.state_reason = 'fake-live worker adapter selected; bounded execution enabled'
and e.timeout_at > now()
order by e.created_at
for update of e skip locked limit 1`
)
if (!rows.length) return null
const token = crypto.randomBytes(32).toString("hex")
const task = mapTask(rows[0])
await client.query(
`update agent_executions set state = 'leased', state_reason = 'leased by isolated worker',
lease_owner = $1, lease_token_hash = $2, lease_expires_at = now() + ($3::text || ' milliseconds')::interval,
heartbeat_at = now(), attempt = attempt + 1, updated_at = now() where id = $4`,
[owner, sha256(token), leaseMs, task.execution.id]
)
await appendAgentAuditEvent(client, {
delegationId: task.delegation.id,
executionId: task.execution.id,
eventType: "execution.leased",
actor: owner,
event: { leaseMs },
})
return { ...task, leaseToken: token }
})
}
async function buildContextPack(pool, task, items) {
const content = {
schema: "hermes.agent.context-pack.v1",
executionId: task.execution.id,
profile: task.execution.profile,
objective: task.delegation.objective,
acceptanceCriteria: task.delegation.acceptanceCriteria,
constraints: task.delegation.constraints,
items,
}
const encoded = JSON.stringify(content)
if (Buffer.byteLength(encoded) > 524288 || items.length > 80) throw new Error("context pack exceeds bounded limits")
const id = newId()
await pool.query(
`insert into agent_context_packs (id, execution_id, content, content_sha256, expires_at)
values ($1, $2, $3, $4, $5) on conflict (execution_id) do update set content = excluded.content,
content_sha256 = excluded.content_sha256, expires_at = excluded.expires_at`,
[id, task.execution.id, encoded, sha256(encoded), task.execution.timeoutAt]
)
return { id, ...content }
}
async function startExecution(pool, task) {
const { rowCount } = await pool.query(
`update agent_executions set state = 'running', state_reason = 'bounded specialist task running',
context_pack_id = $1, started_at = coalesce(started_at, now()), heartbeat_at = now(), updated_at = now()
where id = $2 and state = 'leased' and lease_token_hash = $3`,
[task.contextPack.id, task.execution.id, sha256(task.leaseToken)]
)
if (!rowCount) throw new Error("lease is no longer valid")
}
async function activeGrants(pool, executionId) {
const { rows } = await pool.query(
`select scope, resource, operations, constraints from agent_capability_grants
where execution_id = $1 and revoked_at is null and expires_at > now() and used_count < max_uses`,
[executionId]
)
return rows
}
function remainingBudget(reserved, usage) {
return Object.fromEntries(BUDGET_KEYS.map((key) => [key, Math.max(0, Number(reserved[key]) - Number(usage[key]))]))
}
async function completeExecution(pool, task, input) {
const result = validateResultEnvelope(input)
return withTransaction(pool, async (client) => {
const { rowCount } = await client.query(
`update agent_executions set state = $1, state_reason = $2, budget_remaining = $3, completed_at = now(),
updated_at = now(), lease_owner = null, lease_token_hash = null, lease_expires_at = null
where id = $4 and state = 'running' and lease_token_hash = $5`,
[result.status, result.summary.slice(0, 500), JSON.stringify(remainingBudget(task.execution.budgetReserved, result.budgetUsage)), task.execution.id, sha256(task.leaseToken)]
)
if (!rowCount) return false
await client.query(
`insert into agent_results (id, audit_id, execution_id, delegation_id, status, envelope)
values ($1, $2, $3, $4, $5, $6)`,
[newId(), newId(), task.execution.id, task.delegation.id, result.status, JSON.stringify(result)]
)
for (const capability of result.capabilityUsage) {
await client.query(
`update agent_capability_grants set used_count = used_count + 1
where execution_id = $1 and scope = $2 and resource in ($3, '*')
and revoked_at is null and expires_at > now() and used_count < max_uses`,
[task.execution.id, capability.scope, capability.resource || "*"]
)
}
await appendAgentAuditEvent(client, {
delegationId: task.delegation.id,
executionId: task.execution.id,
eventType: "execution.completed",
actor: "agent-worker",
event: { status: result.status, budgetUsage: result.budgetUsage },
})
return true
})
}
module.exports = {
activeGrants,
buildContextPack,
completeExecution,
expireTimedOutExecutions,
leaseNextExecution,
startExecution,
}
+214
View File
@@ -0,0 +1,214 @@
"use strict"
const crypto = require("crypto")
const CREDENTIAL_KEY = /(?:authorization|password|passwd|secret|token|api[-_]?key|private[-_]?key|credential)/i
const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"])
const MINUTE_MS = 60_000
function canonical(value) {
if (Array.isArray(value)) return value.map(canonical)
if (value && typeof value === "object") {
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]))
}
return value
}
function actionFingerprint(action) {
return crypto.createHash("sha256").update(JSON.stringify(canonical(action))).digest("hex")
}
function containsCredential(value) {
if (!value || typeof value !== "object") return false
if (Array.isArray(value)) return value.some(containsCredential)
return Object.entries(value).some(([key, item]) => CREDENTIAL_KEY.test(key) || containsCredential(item))
}
function approvedBundle(action, approval) {
return approval && approval.status === "approved" && approval.bundle_hash === actionFingerprint(action)
}
function allowedPrefixes(env) {
return String(env.AUTOMATION_ALLOWED_HTTP_PREFIXES || "")
.split(",")
.map((value) => value.trim())
.filter(Boolean)
}
function evaluateActionPolicy(action, env = process.env, approval = null) {
if (!action || typeof action !== "object") throw new Error("Action definition is required")
if (containsCredential(action)) throw new Error("Inline credentials are forbidden")
if (action.kind === "wakeup") {
if (typeof action.message !== "string" || !action.message.trim()) throw new Error("wakeup.message is required")
return { allowed: true, approvalRequired: false, fingerprint: actionFingerprint(action) }
}
if (action.kind === "http") {
const method = String(action.method || "GET").toUpperCase()
if (![...WRITE_METHODS, "GET", "HEAD"].includes(method)) throw new Error("Unsupported HTTP method")
const url = String(action.url || "")
if (!allowedPrefixes(env).some((prefix) => url.startsWith(prefix))) throw new Error("HTTP URL is not on the automation allowlist")
const approvalRequired = WRITE_METHODS.has(method)
if (approvalRequired && !approvedBundle(action, approval)) throw new Error("Approved immutable approval bundle is required")
return { allowed: true, approvalRequired, fingerprint: actionFingerprint(action), method, url }
}
if (action.kind === "container_profile") {
if (!action.profileId) throw new Error("container_profile.profileId is required")
if (Object.hasOwn(action, "image") || Object.hasOwn(action, "command") || Object.hasOwn(action, "env")) {
throw new Error("Container automation is profile-only")
}
if (!approvedBundle(action, approval)) throw new Error("Approved immutable approval bundle is required")
return { allowed: true, approvalRequired: true, fingerprint: actionFingerprint(action) }
}
throw new Error(`Unsupported action kind: ${action.kind || "unknown"}`)
}
function sizeWithinBudget(value, maxResultBytes) {
const bytes = Buffer.byteLength(JSON.stringify(value))
if (bytes > maxResultBytes) throw new Error(`Result budget exceeded: ${bytes} > ${maxResultBytes}`)
return value
}
async function withTimeout(operation, timeoutMs) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await operation(controller.signal)
} catch (err) {
if (controller.signal.aborted) throw new Error(`Action timed out after ${timeoutMs}ms`)
throw err
} finally {
clearTimeout(timer)
}
}
async function callBroker(action, options, signal) {
if (!options.brokerUrl || !options.brokerToken) throw new Error("Container provisioner broker is not configured")
const approvalId = String(options.approval?.id || action.approvalId || "")
if (!approvalId) throw new Error("Container profile execution requires an approval id")
const reason = String(action.reason || `Run approved automation profile ${action.profileId}`)
const idempotencyKey = String(action.idempotencyKey || (options.runId ? `automation-run:${options.runId}` : `automation-action:${actionFingerprint(action)}`))
const response = await options.fetchImpl(options.brokerUrl, {
method: "POST",
signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${options.brokerToken}`,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: crypto.randomUUID(),
method: "tools/call",
params: {
name: "container_provision_create",
arguments: {
profileId: action.profileId,
approvalId,
approvalSummary: String(action.approvalSummary || `Approved automation profile ${action.profileId}`),
reason,
idempotencyKey,
},
},
}),
})
const body = await response.json()
if (!response.ok || body.error) throw new Error("Container provisioner broker rejected the job")
return { kind: "container_profile", instance: body.result }
}
async function executeAction(action, options = {}) {
const env = options.env || process.env
const approval = options.approval || null
const timeoutMs = Math.min(Number(options.timeoutMs || env.AUTOMATION_MAX_TIMEOUT_MS || 30_000), 300_000)
const maxResultBytes = Math.min(Number(options.maxResultBytes || env.AUTOMATION_MAX_RESULT_BYTES || 65_536), 1_048_576)
const fetchImpl = options.fetchImpl || fetch
const policy = evaluateActionPolicy(action, env, approval)
const result = await withTimeout(async (signal) => {
if (action.kind === "wakeup") return { kind: "wakeup", message: action.message }
if (action.kind === "container_profile") return callBroker(action, { ...options, fetchImpl, approval }, signal)
const response = await fetchImpl(action.url, {
method: policy.method,
signal,
headers: action.body === undefined ? undefined : { "Content-Type": "application/json" },
body: action.body === undefined ? undefined : JSON.stringify(action.body),
})
const text = await response.text()
return {
kind: "http",
status: response.status,
ok: response.status >= 200 && response.status < 300,
body: text,
}
}, timeoutMs)
return sizeWithinBudget(result, maxResultBytes)
}
function validDate(value, label) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) throw new Error(`Invalid ${label}`)
return date
}
function parseField(field, min, max) {
const values = new Set()
for (const part of field.split(",")) {
const [range, stepRaw] = part.split("/")
const step = stepRaw === undefined ? 1 : Number(stepRaw)
if (!Number.isInteger(step) || step < 1) throw new Error("Invalid cron step")
let start
let end
if (range === "*") [start, end] = [min, max]
else if (range.includes("-")) [start, end] = range.split("-").map(Number)
else start = end = Number(range)
if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || end < start) throw new Error("Invalid cron field")
for (let value = start; value <= end; value += step) values.add(value)
}
return values
}
function cronMatcher(expression) {
const fields = String(expression || "").trim().split(/\s+/)
if (fields.length !== 5) throw new Error("Cron expression must contain five fields")
const [minutes, hours, days, months, weekdays] = [
parseField(fields[0], 0, 59),
parseField(fields[1], 0, 23),
parseField(fields[2], 1, 31),
parseField(fields[3], 1, 12),
parseField(fields[4], 0, 6),
]
return (date) => minutes.has(date.getUTCMinutes()) && hours.has(date.getUTCHours()) &&
days.has(date.getUTCDate()) && months.has(date.getUTCMonth() + 1) && weekdays.has(date.getUTCDay())
}
function nextOccurrence(schedule, afterValue) {
const after = validDate(afterValue, "cursor")
if (!schedule || !schedule.type) throw new Error("Invalid schedule")
if (schedule.type === "once") {
const at = validDate(schedule.runAt, "once instant")
return at > after ? at : null
}
if (schedule.type === "interval") {
const anchor = validDate(schedule.anchor, "interval anchor")
if (!Number.isInteger(schedule.everySeconds) || schedule.everySeconds < 60) throw new Error("Invalid interval")
const intervalMs = schedule.everySeconds * 1000
const periods = Math.max(0, Math.floor((after.getTime() - anchor.getTime()) / intervalMs) + 1)
return new Date(anchor.getTime() + periods * intervalMs)
}
if (schedule.type === "cron") {
const matches = cronMatcher(schedule.expression)
let cursor = new Date(Math.floor(after.getTime() / MINUTE_MS) * MINUTE_MS + MINUTE_MS)
for (let index = 0; index < 5_300_000; index += 1) {
if (matches(cursor)) return cursor
cursor = new Date(cursor.getTime() + MINUTE_MS)
}
throw new Error("No cron occurrence found within search horizon")
}
throw new Error(`Invalid schedule type: ${schedule.type}`)
}
module.exports = {
actionFingerprint,
evaluateActionPolicy,
executeAction,
nextOccurrence,
}
+138
View File
@@ -0,0 +1,138 @@
"use strict"
const DEFAULT_ALLOWED_IMAGE_PREFIXES = Object.freeze(["10.0.3.6:4000/zachariahsharma/hermes-automation-"])
const DIGEST_RE = /@sha256:[a-f0-9]{64}$/i
const NAME_PREFIX = "hermes-automation-"
function asBoolean(value, fallback = false) {
if (value === undefined || value === null || value === "") return fallback
return /^(1|true|yes|on)$/i.test(String(value))
}
function asPositiveInteger(value, name) {
const number = Number(value)
if (!Number.isInteger(number) || number <= 0) return `${name} must be a positive integer`
return null
}
function normalizeProfile(profile = {}) {
const resources = profile.resources || profile.resource_limits || {}
return {
id: String(profile.id || "").trim(),
name: String(profile.name || "").trim(),
description: String(profile.description || ""),
image: String(profile.image || "").trim(),
enabled: profile.enabled !== false,
resources: {
cpus: resources.cpus ?? 1,
memoryMb: resources.memoryMb ?? resources.memory_mb ?? 256,
pidsLimit: resources.pidsLimit ?? resources.pids_limit ?? 128,
},
ttlSeconds: profile.ttlSeconds ?? profile.ttl_seconds ?? 900,
networkMode: profile.networkMode || profile.network_mode || "none",
env: profile.env || {},
labels: profile.labels || {},
privileged: Boolean(profile.privileged),
hostMounts: profile.hostMounts || profile.host_mounts || [],
publishedPorts: profile.publishedPorts || profile.published_ports || [],
capabilities: profile.capabilities || [],
devices: profile.devices || [],
pidMode: profile.pidMode || profile.pid_mode || "",
ipcMode: profile.ipcMode || profile.ipc_mode || "",
command: profile.command ?? [],
executionEnabled: Boolean(profile.executionEnabled ?? profile.execution_enabled),
}
}
function evaluateContainerPolicy(profile, options = {}) {
const normalized = normalizeProfile(profile)
const env = options.env || process.env
const allowedPrefixes = String(env.CONTAINER_PROVISIONER_ALLOWED_IMAGE_PREFIXES || DEFAULT_ALLOWED_IMAGE_PREFIXES.join(","))
.split(",")
.map((item) => item.trim())
.filter(Boolean)
const errors = []
if (!normalized.id) errors.push("profile id is required")
if (!normalized.name) errors.push("profile name is required")
if (!normalized.enabled) errors.push("profile is disabled")
if (!DIGEST_RE.test(normalized.image)) errors.push("image must be digest-pinned with @sha256:<digest>")
if (!allowedPrefixes.some((prefix) => normalized.image.startsWith(prefix))) {
errors.push(`image must start with one of: ${allowedPrefixes.join(", ")}`)
}
if (normalized.privileged) errors.push("privileged containers are forbidden")
if (normalized.hostMounts.length > 0) errors.push("host bind mounts are forbidden")
if (normalized.publishedPorts.length > 0) errors.push("published ports are forbidden")
if (!["none", "bridge"].includes(String(normalized.networkMode))) errors.push("networkMode must be none or bridge")
if (String(normalized.networkMode) === "host") errors.push("host networking is forbidden")
if (normalized.capabilities.length > 0) errors.push("Linux capabilities are not allowed in the first broker profile")
if (normalized.devices.length > 0) errors.push("devices are forbidden")
if (normalized.pidMode) errors.push("PID namespace overrides are forbidden")
if (normalized.ipcMode) errors.push("IPC namespace overrides are forbidden")
if (Object.keys(normalized.env).length > 0) errors.push("profile environment variables and secrets are forbidden")
if (Object.keys(normalized.labels).some((key) => key.startsWith("com.vynte.hermes."))) {
errors.push("reserved com.vynte.hermes.* labels cannot be overridden")
}
if (!Array.isArray(normalized.command) || normalized.command.length > 16 || normalized.command.some((item) => typeof item !== "string" || item.length > 1000)) {
errors.push("command must be a reviewed array of at most 16 bounded strings")
}
for (const [key, value] of Object.entries({
"resources.cpus": normalized.resources.cpus,
"resources.memoryMb": normalized.resources.memoryMb,
"resources.pidsLimit": normalized.resources.pidsLimit,
ttlSeconds: normalized.ttlSeconds,
})) {
const error = asPositiveInteger(value, key)
if (error) errors.push(error)
}
if (Number(normalized.resources.cpus) > 2) errors.push("resources.cpus must be <= 2")
if (Number(normalized.resources.memoryMb) > 1024) errors.push("resources.memoryMb must be <= 1024")
if (Number(normalized.resources.pidsLimit) > 256) errors.push("resources.pidsLimit must be <= 256")
if (Number(normalized.ttlSeconds) > 3600) errors.push("ttlSeconds must be <= 3600")
const executionEnabled = asBoolean(env.CONTAINER_PROVISIONER_EXECUTION_ENABLED, false) && Boolean(normalized.executionEnabled)
return {
allowed: errors.length === 0,
errors,
executionEnabled: errors.length === 0 && executionEnabled,
requestedExecutionEnabled: executionEnabled,
runtime: {
namePrefix: NAME_PREFIX,
image: normalized.image,
networkMode: normalized.networkMode,
privileged: false,
readonlyRootfs: true,
publishedPorts: [],
hostMounts: [],
resources: normalized.resources,
ttlSeconds: normalized.ttlSeconds,
labels: {
"com.vynte.hermes.managed": "true",
"com.vynte.hermes.kind": "automation",
"com.vynte.hermes.profile": normalized.id,
...normalized.labels,
},
command: normalized.command,
},
}
}
function loadContainerProfilesFromEnv(env = process.env) {
const raw = String(env.CONTAINER_PROVISIONER_PROFILES_JSON || "").trim()
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) throw new Error("CONTAINER_PROVISIONER_PROFILES_JSON must be a JSON array")
return parsed.map((profile) => ({
...normalizeProfile(profile),
}))
}
module.exports = {
DEFAULT_ALLOWED_IMAGE_PREFIXES,
NAME_PREFIX,
evaluateContainerPolicy,
loadContainerProfilesFromEnv,
normalizeProfile,
}
+110
View File
@@ -0,0 +1,110 @@
begin;
create extension if not exists pgcrypto;
create table automation_scripts (
id uuid primary key default gen_random_uuid(),
name text not null,
description text not null default '',
status text not null default 'draft' check (status in ('draft', 'active', 'paused', 'archived')),
risk text not null check (risk in ('low', 'medium', 'high', 'critical')),
definition jsonb not null,
version integer not null default 1 check (version > 0),
created_by text not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table automation_jobs (
id uuid primary key default gen_random_uuid(),
script_id uuid not null references automation_scripts(id),
name text not null,
status text not null default 'draft' check (status in ('draft', 'active', 'paused', 'canceled', 'archived')),
schedule_type text not null check (schedule_type in ('once', 'interval', 'cron')),
schedule jsonb not null,
next_run_at timestamptz,
created_by text not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table automation_runs (
id uuid primary key default gen_random_uuid(),
job_id uuid not null references automation_jobs(id),
script_id uuid not null references automation_scripts(id),
status text not null check (status in ('queued', 'running', 'cancel_requested', 'canceled', 'succeeded', 'failed', 'blocked')),
scheduled_for timestamptz not null,
started_at timestamptz,
completed_at timestamptz,
requested_by text,
result jsonb,
error jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (job_id, scheduled_for)
);
create table automation_events (
id uuid primary key default gen_random_uuid(),
occurred_at timestamptz not null default now(),
actor_id text not null,
action text not null,
subject_type text not null,
subject_id text not null,
payload jsonb not null default '{}'::jsonb,
correlation_id text
);
create table automation_approvals (
id uuid primary key default gen_random_uuid(),
subject_type text not null,
subject_id text not null,
risk text not null check (risk in ('low', 'medium', 'high', 'critical')),
status text not null default 'pending' check (status in ('pending', 'approved', 'rejected', 'expired', 'canceled')),
minimum_approvers integer not null check (minimum_approvers > 0),
decisions jsonb not null default '[]'::jsonb,
requested_by text not null,
expires_at timestamptz not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table automation_locks (
lock_key text primary key,
owner_id text not null,
acquired_at timestamptz not null default now(),
expires_at timestamptz not null,
check (expires_at > acquired_at)
);
create index automation_jobs_due_idx on automation_jobs(next_run_at) where status = 'active';
create index automation_runs_job_idx on automation_runs(job_id, scheduled_for desc);
create index automation_events_subject_idx on automation_events(subject_type, subject_id, occurred_at desc);
create index automation_approvals_subject_idx on automation_approvals(subject_type, subject_id);
create function set_automation_updated_at() returns trigger language plpgsql as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger automation_scripts_updated_at before update on automation_scripts
for each row execute function set_automation_updated_at();
create trigger automation_jobs_updated_at before update on automation_jobs
for each row execute function set_automation_updated_at();
create trigger automation_runs_updated_at before update on automation_runs
for each row execute function set_automation_updated_at();
create trigger automation_approvals_updated_at before update on automation_approvals
for each row execute function set_automation_updated_at();
create function prevent_automation_event_mutation() returns trigger language plpgsql as $$
begin
raise exception 'automation_events is append-only';
end;
$$;
create trigger automation_events_append_only before update or delete on automation_events
for each row execute function prevent_automation_event_mutation();
commit;
+86
View File
@@ -0,0 +1,86 @@
begin;
create extension if not exists pgcrypto;
create table container_profiles (
id text primary key,
name text not null,
description text not null default '',
image text not null,
enabled boolean not null default false,
execution_enabled boolean not null default false,
resource_limits jsonb not null default '{}'::jsonb,
ttl_seconds integer not null check (ttl_seconds > 0 and ttl_seconds <= 3600),
network_mode text not null default 'none' check (network_mode in ('none', 'bridge')),
policy jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
check (image ~ '@sha256:[A-Fa-f0-9]{64}$'),
check (image like '10.0.3.6:4000/zachariahsharma/hermes-automation-%@sha256:%')
);
create table container_instances (
id uuid primary key default gen_random_uuid(),
profile_id text not null references container_profiles(id),
container_name text,
docker_id text,
image text not null,
status text not null check (status in ('dry_run', 'queued', 'creating', 'running', 'expired', 'stopped', 'removed', 'failed', 'blocked')),
requested_by text not null,
policy_result jsonb not null default '{}'::jsonb,
requested_payload jsonb not null default '{}'::jsonb,
approval_id text,
idempotency_key_digest text,
expires_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
check (container_name is null or container_name like 'hermes-automation-%')
);
create unique index container_instances_idempotency_idx
on container_instances(requested_by, idempotency_key_digest)
where idempotency_key_digest is not null;
create table container_action_requests (
id uuid primary key default gen_random_uuid(),
occurred_at timestamptz not null default now(),
actor_id text not null,
action text not null check (action in ('start', 'stop', 'remove')),
instance_id uuid not null references container_instances(id),
approval_id text not null,
approval_summary_digest text not null check (approval_summary_digest like 'sha256:%'),
reason_digest text not null check (reason_digest like 'sha256:%'),
idempotency_key_digest text not null check (idempotency_key_digest like 'sha256:%'),
unique (actor_id, action, idempotency_key_digest)
);
create table container_events (
id uuid primary key default gen_random_uuid(),
occurred_at timestamptz not null default now(),
actor_id text not null,
action text not null,
subject_type text not null,
subject_id text not null,
instance_id uuid references container_instances(id),
payload jsonb not null default '{}'::jsonb,
correlation_id text
);
create index container_profiles_enabled_idx on container_profiles(enabled, name);
create index container_instances_profile_idx on container_instances(profile_id, created_at desc);
create index container_instances_status_idx on container_instances(status, created_at desc);
create index container_events_subject_idx on container_events(subject_type, subject_id, occurred_at desc);
create index container_events_instance_idx on container_events(instance_id, occurred_at desc);
create index container_action_requests_instance_idx on container_action_requests(instance_id, occurred_at desc);
create trigger container_profiles_updated_at before update on container_profiles
for each row execute function set_automation_updated_at();
create trigger container_instances_updated_at before update on container_instances
for each row execute function set_automation_updated_at();
create trigger container_events_append_only before update or delete on container_events
for each row execute function prevent_automation_event_mutation();
create trigger container_action_requests_append_only before update or delete on container_action_requests
for each row execute function prevent_automation_event_mutation();
commit;
+122
View File
@@ -0,0 +1,122 @@
create table if not exists agent_profiles (
id text not null,
version integer not null check (version > 0),
tier text not null check (tier in ('orchestrator', 'coordinator', 'worker')),
domain text not null,
display_name text not null,
manifest jsonb not null,
manifest_sha256 text not null,
enabled boolean not null default true,
created_at timestamptz not null default now(),
primary key (id, version)
);
create table if not exists agent_delegations (
id text primary key,
audit_id text not null unique,
parent_execution_id text,
root_execution_id text not null,
depth integer not null check (depth between 0 and 2),
requested_profile_id text not null,
requested_profile_version integer not null,
objective text not null,
acceptance_criteria jsonb not null,
constraints jsonb not null,
context_selectors jsonb not null,
requested_capabilities jsonb not null,
requested_budget jsonb not null,
timeout_at timestamptz not null,
idempotency_key text not null,
requested_at timestamptz not null default now(),
unique (parent_execution_id, idempotency_key),
foreign key (requested_profile_id, requested_profile_version)
references agent_profiles(id, version)
);
create table if not exists agent_executions (
id text primary key,
audit_id text not null unique,
delegation_id text not null unique references agent_delegations(id),
root_execution_id text not null,
parent_execution_id text,
profile_id text not null,
profile_version integer not null,
depth integer not null check (depth between 0 and 2),
state text not null check (state in (
'requested', 'policy_check', 'awaiting_approval', 'queued',
'leased', 'running', 'cancelling', 'succeeded', 'failed',
'cancelled', 'timed_out', 'budget_exhausted', 'rejected'
)),
state_reason text,
context_pack_id text,
budget_reserved jsonb not null,
budget_remaining jsonb not null,
cancel_requested_at timestamptz,
timeout_at timestamptz not null,
lease_owner text,
lease_token_hash text,
lease_expires_at timestamptz,
heartbeat_at timestamptz,
attempt integer not null default 0,
max_attempts integer not null default 2,
started_at timestamptz,
completed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
foreign key (profile_id, profile_version) references agent_profiles(id, version)
);
create index if not exists agent_executions_runnable_idx
on agent_executions(state, created_at)
where state in ('queued', 'leased', 'running', 'cancelling');
create table if not exists agent_context_packs (
id text primary key,
execution_id text not null unique references agent_executions(id),
content jsonb not null,
content_sha256 text not null,
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
create table if not exists agent_results (
id text primary key,
audit_id text not null unique,
execution_id text not null unique references agent_executions(id),
delegation_id text not null references agent_delegations(id),
status text not null,
envelope jsonb not null,
created_at timestamptz not null default now()
);
create table if not exists agent_capability_grants (
id text primary key,
grant_hash text not null unique,
execution_id text not null references agent_executions(id),
audience text not null,
scope text not null,
resource text not null,
operations jsonb not null,
constraints jsonb not null,
expires_at timestamptz not null,
max_uses integer not null check (max_uses > 0),
used_count integer not null default 0,
revoked_at timestamptz,
created_at timestamptz not null default now()
);
create table if not exists agent_audit_events (
id text primary key,
delegation_id text references agent_delegations(id),
execution_id text references agent_executions(id),
event_type text not null,
actor text not null,
event jsonb not null,
created_at timestamptz not null default now()
);
create index if not exists agent_audit_events_delegation_idx
on agent_audit_events(delegation_id, created_at);
create index if not exists agent_audit_events_execution_idx
on agent_audit_events(execution_id, created_at);
+22
View File
@@ -0,0 +1,22 @@
begin;
alter table automation_approvals
add column if not exists bundle_hash text;
alter table automation_jobs
add column if not exists approval_id uuid references automation_approvals(id),
add column if not exists max_runs_per_day integer not null default 24
check (max_runs_per_day > 0 and max_runs_per_day <= 1000);
alter table automation_runs
add column if not exists approval_id uuid references automation_approvals(id),
add column if not exists lease_owner text,
add column if not exists lease_expires_at timestamptz;
create index if not exists automation_runs_lease_idx
on automation_runs(status, lease_expires_at, scheduled_for);
create index if not exists automation_jobs_approval_idx
on automation_jobs(approval_id);
commit;
@@ -0,0 +1,40 @@
begin;
alter table container_profiles drop constraint if exists container_profiles_image_check1;
alter table container_profiles drop constraint if exists container_profiles_image_internal_digest_check;
alter table container_profiles drop constraint if exists container_profiles_internal_image_check;
alter table container_profiles add constraint container_profiles_internal_image_check
check (image like '10.0.3.6:4000/zachariahsharma/hermes-automation-%@sha256:%');
alter table container_instances
add column if not exists approval_id text,
add column if not exists idempotency_key_digest text;
create unique index if not exists container_instances_idempotency_idx
on container_instances(requested_by, idempotency_key_digest)
where idempotency_key_digest is not null;
alter table container_instances drop constraint if exists container_instances_status_check;
alter table container_instances add constraint container_instances_status_check
check (status in ('dry_run', 'queued', 'creating', 'running', 'expired', 'stopped', 'removed', 'failed', 'blocked'));
create table if not exists container_action_requests (
id uuid primary key default gen_random_uuid(),
occurred_at timestamptz not null default now(),
actor_id text not null,
action text not null check (action in ('start', 'stop', 'remove')),
instance_id uuid not null references container_instances(id),
approval_id text not null,
approval_summary_digest text not null check (approval_summary_digest like 'sha256:%'),
reason_digest text not null check (reason_digest like 'sha256:%'),
idempotency_key_digest text not null check (idempotency_key_digest like 'sha256:%'),
unique (actor_id, action, idempotency_key_digest)
);
create index if not exists container_action_requests_instance_idx
on container_action_requests(instance_id, occurred_at desc);
create trigger container_action_requests_append_only before update or delete on container_action_requests
for each row execute function prevent_automation_event_mutation();
commit;
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"start": "node server.cjs",
"start:gateway": "node api-gateway.cjs",
"check": "node -c server.cjs && node -c api-gateway.cjs && docker compose --env-file .env.example config",
"check": "node -c server.cjs && node -c api-gateway.cjs && node -c agent-controller.cjs && node -c agent-worker.cjs && node -c vynte-internal-mcp.cjs && node -c automation-control-mcp.cjs && node -c automation-worker.cjs && node -c container-provisioner-mcp.cjs && node -c lib/automation-runtime.cjs && node -c lib/container-provisioner-policy.cjs && node -c safe-write-policy.cjs && node -c focused-write.cjs && node -c write-audit.cjs && docker compose --env-file .env.example config",
"test": "node --test test/*.test.cjs"
},
"engines": {
+207
View File
@@ -0,0 +1,207 @@
"use strict"
const crypto = require("crypto")
class McpError extends Error {
constructor(message, code = "internal_error") {
super(message)
this.name = "McpError"
this.code = code
}
}
function csvSet(value) {
return new Set(String(value || "").split(",").map((item) => item.trim()).filter(Boolean))
}
function exactConfirmation(operation) {
return `EXECUTE ${operation.name} ${operation.method} ${operation.path}`
}
function serviceEnvPrefix(service) {
return `VYNTE_${String(service || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}`
}
function writePolicyMode(env = process.env) {
return String(env.VYNTE_MCP_WRITE_POLICY || "strict_allowlist").trim().toLowerCase()
}
function isApprovalBundlePolicy(env = process.env) {
return writePolicyMode(env) === "user_approval_bundle" || writePolicyMode(env) === "approval_bundle"
}
function assertAllowed(operation, env) {
if (isApprovalBundlePolicy(env)) return
const prefix = serviceEnvPrefix(operation.service || "plane")
const paths = [...csvSet(env[`${prefix}_ALLOWED_WRITE_PATHS`] || env.VYNTE_PLANE_ALLOWED_WRITE_PATHS)]
if (operation.service === "plane" || (!operation.service && operation.workspaceSlug)) {
const workspaces = csvSet(env.VYNTE_PLANE_ALLOWED_WORKSPACES)
const projects = csvSet(env.VYNTE_PLANE_ALLOWED_PROJECTS)
if (!workspaces.has(operation.workspaceSlug)) {
throw new McpError(`Workspace '${operation.workspaceSlug}' is not allowlisted`, "workspace_not_allowed")
}
if (!projects.has(operation.projectId)) {
throw new McpError(`Project '${operation.projectId}' is not allowlisted`, "project_not_allowed")
}
}
if (operation.service === "twenty") {
const objects = csvSet(env.VYNTE_TWENTY_ALLOWED_OBJECTS)
if (!objects.has(operation.object)) {
throw new McpError(`Twenty object '${operation.object}' is not allowlisted`, "object_not_allowed")
}
}
if (!paths.some((prefix) => operation.path.startsWith(prefix))) {
throw new McpError("Generated write path is not allowlisted", "write_path_not_allowed")
}
}
function createWritePolicy(operation, args = {}, env = process.env) {
assertAllowed(operation, env)
const dryRun = args.dryRun !== false
const confirmationRequired = exactConfirmation(operation)
const approvalBundle = isApprovalBundlePolicy(env)
if (dryRun) {
return {
dryRun: true,
execute: false,
confirmationRequired,
approvalPolicy: approvalBundle ? "user_approval_bundle" : "strict_allowlist",
approvalMetadataRequired: approvalBundle ? ["approvalId", "approvalSummary", "reason", "idempotencyKey"] : undefined,
impactConfirmationRequired: operation.impactConfirmationRequired,
}
}
if (env.VYNTE_MCP_WRITES_ENABLED !== "true") {
throw new McpError("Writes are disabled", "writes_disabled")
}
if (approvalBundle) {
if (typeof args.approvalId !== "string" || !/^[A-Za-z0-9._:-]{8,200}$/.test(args.approvalId)) {
throw new McpError("A valid approval id is required", "missing_approval_id")
}
if (typeof args.approvalSummary !== "string" || args.approvalSummary.trim().length < 10 || args.approvalSummary.length > 1000) {
throw new McpError("An approval summary between 10 and 1000 characters is required", "missing_approval_summary")
}
} else if (args.confirmOperation !== confirmationRequired) {
throw new McpError("Exact operation confirmation does not match", "confirmation_mismatch")
}
if (operation.impactConfirmationRequired && args.confirmImpact !== operation.impactConfirmationRequired) {
throw new McpError("Impact confirmation does not match", "impact_confirmation_mismatch")
}
if (typeof args.reason !== "string" || args.reason.trim().length < 3 || args.reason.length > 500) {
throw new McpError("A reason between 3 and 500 characters is required", "missing_reason")
}
if (operation.idempotencyRequired && (typeof args.idempotencyKey !== "string" || !/^[A-Za-z0-9._:-]{8,200}$/.test(args.idempotencyKey))) {
throw new McpError("A valid idempotency key is required", "missing_idempotency_key")
}
return {
dryRun: false,
execute: true,
confirmationRequired,
approvalPolicy: approvalBundle ? "user_approval_bundle" : "strict_allowlist",
approvalId: approvalBundle ? args.approvalId : undefined,
}
}
function invalidField(name) {
throw new McpError(`Invalid ${name}`, "invalid_field")
}
function sanitizeValue(name, value, rule) {
if (rule.type === "string") {
if (typeof value !== "string" || value.length < (rule.minLength || 0) || value.length > (rule.maxLength || 10_000)) invalidField(name)
return value
}
if (rule.type === "id") {
if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,200}$/.test(value)) invalidField(name)
return value
}
if (rule.type === "date") {
if (value !== null && (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))) invalidField(name)
return value
}
if (rule.type === "enum") {
if (!rule.values.includes(value)) invalidField(name)
return value
}
if (rule.type === "boolean") {
if (typeof value !== "boolean") invalidField(name)
return value
}
if (rule.type === "email") {
if (typeof value !== "string" || value.length > (rule.maxLength || 320) || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) invalidField(name)
return value
}
if (rule.type === "array") {
if (!Array.isArray(value)) invalidField(name)
if (value.length > rule.maxItems) throw new McpError(`Too many ${name} values`, "array_limit_exceeded")
return value.map((item) => sanitizeValue(name, item, { type: rule.itemType }))
}
invalidField(name)
}
function sanitizeFields(input, schema) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new McpError("Fields must be an object", "invalid_fields")
}
const output = {}
for (const [name, value] of Object.entries(input)) {
const rule = schema[name]
if (!rule) throw new McpError(`Unsupported field '${name}'`, "unsupported_field")
output[name] = sanitizeValue(name, value, rule)
}
return output
}
const SECRET_KEY = /authorization|cookie|token|secret|password|api[-_]?key/i
const EMAIL_KEY = /(^|[_-])(?:email|displayEmail)$/i
const PHONE_KEY = /phone/i
const EMAIL_VALUE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi
const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi
const SECRET_FIELD_VALUE = /(["']?(?:authorization|cookie|token|secret|password|api[-_]?key)["']?\s*[:=]\s*["']?)([^"',\s}]+)/gi
function redact(value, key = "") {
if (SECRET_KEY.test(key)) return "[REDACTED]"
if (EMAIL_KEY.test(key)) return "[REDACTED_EMAIL]"
if (PHONE_KEY.test(key)) return "[REDACTED_PHONE]"
if (typeof value === "string") {
return value
.replace(EMAIL_VALUE, "[REDACTED_EMAIL]")
.replace(BEARER, "Bearer [REDACTED]")
.replace(SECRET_FIELD_VALUE, "$1[REDACTED]")
}
if (Array.isArray(value)) return value.map((item) => redact(item))
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, redact(child, childKey)]))
}
return value
}
function redactAndCap(value, maxBytes = 4096) {
const redacted = redact(value)
const serialized = JSON.stringify(redacted)
if (Buffer.byteLength(serialized) <= maxBytes) return redacted
return {
truncated: true,
preview: Buffer.from(serialized).subarray(0, maxBytes).toString("utf8"),
}
}
function payloadDigest(value) {
return `sha256:${crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex")}`
}
module.exports = {
McpError,
createWritePolicy,
exactConfirmation,
isApprovalBundlePolicy,
payloadDigest,
redactAndCap,
sanitizeFields,
writePolicyMode,
}
+16
View File
@@ -34,6 +34,9 @@ const {
deleteApiUser,
} = require("./lib/api-users-store.cjs")
const { streamJsonlLogs, cleanupExpiredMessageLogs } = require("./lib/audit-store.cjs")
const { createAdminAgentRouteHandler } = require("./lib/agent/admin-routes.cjs")
const { loadProfileRegistry } = require("./lib/agent/profile-registry.cjs")
const { syncProfiles } = require("./lib/agent/profile-store.cjs")
const PORT = Number(process.env.HERMES_SETUP_UI_PORT || 7843)
const HOST = process.env.HERMES_SETUP_UI_HOST || "127.0.0.1"
@@ -76,6 +79,7 @@ const ADMIN_COOKIE_SECURE = /^(1|true|yes|on)$/i.test(String(process.env.HERMES_
// Module-level pool — null when DATABASE_URL is not set (auth disabled)
let pool = null
let adminAgentRouteHandler = null
// ─── Process tracking ─────────────────────────────────────────────────────
const runningProcs = new Map() // key: provider, val: state
@@ -2211,6 +2215,13 @@ const server = http.createServer(async (req, res) => {
return await handleApiUsersRoute(req, res, id, action)
}
const adminAgentMatch = req.url.split("?")[0].match(/^\/api\/admin\/agent(?:\/|$)/)
if (adminAgentMatch) {
if (!(await requireAdmin(req, res))) return
if (!pool || !adminAgentRouteHandler) return sendJson(res, 503, { error: "Agent hierarchy database is not configured" })
return await adminAgentRouteHandler(req, res)
}
// All other routes require auth
if (!(await requireAdmin(req, res))) return
@@ -2230,6 +2241,11 @@ async function main() {
if (adminPw.length < 16) throw new Error("HERMES_ADMIN_PASSWORD must be at least 16 characters")
pool = createPool(DATABASE_URL)
await runMigrations(pool)
await syncProfiles(pool, loadProfileRegistry())
adminAgentRouteHandler = createAdminAgentRouteHandler({
pool,
executionMode: process.env.HERMES_AGENT_EXECUTION_MODE || "disabled",
})
// Run one cleanup immediately
cleanupExpiredMessageLogs(pool).catch((err) => console.error("audit cleanup failed:", err))
+128
View File
@@ -0,0 +1,128 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const { validateDelegationRequest, validateResultEnvelope } = require("../lib/agent/contracts.cjs")
const { loadProfileRegistry } = require("../lib/agent/profile-registry.cjs")
const { evaluateDelegation } = require("../lib/agent/policy-engine.cjs")
test("built-in agent profiles enforce fixed tier delegation rules", () => {
const registry = loadProfileRegistry()
const orchestrator = registry.get("orchestrator.hermes", 1)
const coordinator = registry.get("coordinator.software", 1)
const worker = registry.get("worker.software.repo-analysis", 1)
assert.equal(orchestrator.spec.tier, "orchestrator")
assert.equal(coordinator.spec.tier, "coordinator")
assert.equal(worker.spec.tier, "worker")
assert.equal(worker.spec.canDelegate, false)
assert.deepEqual(worker.spec.allowedChildProfiles, [])
assert(orchestrator.spec.allowedChildProfiles.includes("coordinator.software"))
assert(coordinator.spec.allowedChildProfiles.includes("worker.software.repo-analysis"))
})
test("delegation request validation normalizes bounded contract fields", () => {
const request = validateDelegationRequest({
schema: "hermes.agent.delegation.v1",
requestId: "0b9de277-d096-4851-bf32-97f83dd6813e",
parentExecutionId: "f85e2939-70ec-4fe2-8df5-47cd8d46d123",
requestedProfile: { id: "worker.software.repo-analysis", version: 1 },
objective: "Inspect repository structure.",
acceptanceCriteria: ["Return exact paths"],
constraints: ["Read-only"],
contextSelectors: [{ source: "software-repository.read", selector: { paths: ["lib"] }, purpose: "inspection" }],
requestedCapabilities: [{ scope: "repository.read", resource: "Hermes-Control-Panel", operations: ["list", "read"] }],
budget: { runtimeMs: 300000, toolCalls: 20, inputTokens: 30000, outputTokens: 8000, costUsdMicros: 500000 },
timeoutAt: "2026-06-12T22:00:00.000Z",
idempotencyKey: "parent:repo-analysis:1",
})
assert.equal(request.schema, "hermes.agent.delegation.v1")
assert.equal(request.requestedProfile.version, 1)
assert.equal(request.acceptanceCriteria.length, 1)
assert.equal(request.contextSelectors[0].source, "software-repository.read")
})
test("delegation request validation rejects oversized objectives", () => {
assert.throws(
() => validateDelegationRequest({
schema: "hermes.agent.delegation.v1",
requestId: "0b9de277-d096-4851-bf32-97f83dd6813e",
requestedProfile: { id: "worker.software.repo-analysis", version: 1 },
objective: "x".repeat(2001),
budget: { runtimeMs: 1, toolCalls: 1, inputTokens: 1, outputTokens: 1, costUsdMicros: 1 },
timeoutAt: "2026-06-12T22:00:00.000Z",
idempotencyKey: "too-long",
}),
/objective must be at most 2000 characters/
)
})
test("policy rejects worker delegation, excessive depth, and over-budget children", () => {
const registry = loadProfileRegistry()
const worker = registry.get("worker.software.repo-analysis", 1)
const coordinator = registry.get("coordinator.software", 1)
const childRequest = validateDelegationRequest({
schema: "hermes.agent.delegation.v1",
requestId: "0b9de277-d096-4851-bf32-97f83dd6813e",
parentExecutionId: "f85e2939-70ec-4fe2-8df5-47cd8d46d123",
requestedProfile: { id: "worker.software.repo-analysis", version: 1 },
objective: "Inspect repository structure.",
acceptanceCriteria: ["Return exact paths"],
constraints: ["Read-only"],
contextSelectors: [{ source: "software-repository.read", selector: { paths: ["lib"] }, purpose: "inspection" }],
requestedCapabilities: [{ scope: "repository.read", resource: "Hermes-Control-Panel", operations: ["list", "read"] }],
budget: { runtimeMs: 300000, toolCalls: 20, inputTokens: 30000, outputTokens: 8000, costUsdMicros: 500000 },
timeoutAt: "2026-06-12T22:00:00.000Z",
idempotencyKey: "parent:repo-analysis:1",
})
assert.equal(evaluateDelegation({
parentExecution: { id: "parent", state: "running", depth: 1, profile: worker, remainingBudget: coordinator.spec.defaultBudget },
requestedProfile: worker,
request: childRequest,
now: new Date("2026-06-12T21:00:00.000Z"),
}).allowed, false)
assert.match(evaluateDelegation({
parentExecution: { id: "parent", state: "running", depth: 2, profile: coordinator, remainingBudget: coordinator.spec.defaultBudget },
requestedProfile: worker,
request: childRequest,
now: new Date("2026-06-12T21:00:00.000Z"),
}).reason, /maximum delegation depth/)
const overBudget = { ...childRequest, budget: { ...childRequest.budget, toolCalls: 9999 } }
assert.match(evaluateDelegation({
parentExecution: { id: "parent", state: "running", depth: 1, profile: coordinator, remainingBudget: coordinator.spec.defaultBudget },
requestedProfile: worker,
request: overBudget,
now: new Date("2026-06-12T21:00:00.000Z"),
}).reason, /exceeds profile maxChildBudget/)
})
test("result envelope validation rejects worker-declared child delegations", () => {
assert.throws(
() => validateResultEnvelope({
schema: "hermes.agent.result.v1",
auditId: "2ff0d37c-6ca9-443d-8464-8d953518af6a",
executionId: "d051f270-baca-4a09-a78a-af056a2b9861",
delegationId: "b2bf557a-cfd1-48a4-a94c-e64669d84e59",
profile: { id: "worker.software.repo-analysis", version: 1 },
status: "succeeded",
summary: "done",
outputs: [],
artifacts: [],
delegations: [{ objective: "not allowed" }],
budgetUsage: { runtimeMs: 1, toolCalls: 1, inputTokens: 1, outputTokens: 1, costUsdMicros: 1 },
capabilityUsage: [],
warnings: [],
error: null,
startedAt: "2026-06-12T21:00:00.000Z",
completedAt: "2026-06-12T21:00:01.000Z",
}),
/worker results cannot declare child delegations/
)
})
@@ -0,0 +1,140 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const http = require("http")
const { withTestDatabase } = require("./helpers/db-test.cjs")
const { runMigrations } = require("../lib/db.cjs")
const { loadProfileRegistry } = require("../lib/agent/profile-registry.cjs")
const { syncProfiles } = require("../lib/agent/profile-store.cjs")
const { createAgentRouteHandler } = require("../lib/agent/controller-routes.cjs")
function listen(handler) {
const server = http.createServer(handler)
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
resolve({ server, baseUrl: `http://127.0.0.1:${server.address().port}` })
})
})
}
async function request(baseUrl, path, { method = "GET", body = null, token = "test-token" } = {}) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
"Authorization": `Bearer ${token}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await response.text()
return { status: response.status, body: text ? JSON.parse(text) : null }
}
test("agent controller routes create, list, and cancel disabled delegations", async (t) => {
await withTestDatabase(t, async ({ pool }) => {
await runMigrations(pool)
await syncProfiles(pool, loadProfileRegistry())
const { server, baseUrl } = await listen(createAgentRouteHandler({
pool,
bearerToken: "test-token",
executionMode: "disabled",
routePrefix: "/v1/agent",
}))
t.after(() => new Promise((resolve) => server.close(resolve)))
const profiles = await request(baseUrl, "/v1/agent/profiles")
assert.equal(profiles.status, 200)
assert(profiles.body.profiles.some((profile) => profile.id === "orchestrator.hermes"))
const created = await request(baseUrl, "/v1/agent/delegations", {
method: "POST",
body: {
requestedProfile: { id: "orchestrator.hermes", version: 1 },
objective: "Coordinate one disabled task.",
acceptanceCriteria: ["Create records only"],
constraints: ["Do not execute"],
contextSelectors: [{ source: "system-knowledge.read", selector: { record: "service/hermes" }, purpose: "classification" }],
requestedCapabilities: [],
budget: { runtimeMs: 600000, toolCalls: 40, inputTokens: 60000, outputTokens: 15000, costUsdMicros: 1000000 },
timeoutAt: "2026-06-12T22:00:00.000Z",
idempotencyKey: "api:root:1",
},
})
assert.equal(created.status, 201)
assert.equal(created.body.execution.state, "queued")
assert.match(created.body.execution.stateReason, /execution disabled/)
const listed = await request(baseUrl, "/v1/agent/delegations")
assert.equal(listed.status, 200)
assert.equal(listed.body.delegations.length, 1)
const cancelled = await request(baseUrl, `/v1/agent/delegations/${created.body.delegation.id}/cancel`, {
method: "POST",
body: { reason: "operator cancelled" },
})
assert.equal(cancelled.status, 200)
assert.equal(cancelled.body.execution.state, "cancelled")
})
})
test("agent controller routes reject missing bearer token", async (t) => {
await withTestDatabase(t, async ({ pool }) => {
await runMigrations(pool)
await syncProfiles(pool, loadProfileRegistry())
const { server, baseUrl } = await listen(createAgentRouteHandler({
pool,
bearerToken: "test-token",
executionMode: "disabled",
routePrefix: "/v1/agent",
}))
t.after(() => new Promise((resolve) => server.close(resolve)))
const response = await fetch(`${baseUrl}/v1/agent/profiles`)
assert.equal(response.status, 401)
})
})
test("agent controller exposes explicit grants and terminal result retrieval", async (t) => {
await withTestDatabase(t, async ({ pool }) => {
await runMigrations(pool)
await syncProfiles(pool, loadProfileRegistry())
const { server, baseUrl } = await listen(createAgentRouteHandler({
pool,
bearerToken: "test-token",
executionMode: "fake-live",
routePrefix: "/v1/agent",
}))
t.after(() => new Promise((resolve) => server.close(resolve)))
const created = await request(baseUrl, "/v1/agent/delegations", {
method: "POST",
body: {
requestedProfile: { id: "orchestrator.hermes", version: 1 },
objective: "Create an auditable fake-live execution.",
requestedCapabilities: [],
budget: { runtimeMs: 600000, toolCalls: 40, inputTokens: 60000, outputTokens: 15000, costUsdMicros: 1000000 },
timeoutAt: new Date(Date.now() + 600000).toISOString(),
idempotencyKey: "api:fake-live:1",
},
})
assert.equal(created.body.execution.stateReason, "fake-live worker adapter selected; bounded execution enabled")
const grant = await request(baseUrl, `/v1/agent/executions/${created.body.execution.id}/grants`, {
method: "POST",
body: {
scope: "automation.execute.proposed",
resource: "job/demo",
operations: ["execute"],
reason: "operator-approved test grant",
expiresAt: new Date(Date.now() + 300000).toISOString(),
},
})
assert.equal(grant.status, 201)
assert.equal(grant.body.grant.scope, "automation.execute.proposed")
const current = await request(baseUrl, `/v1/agent/delegations/${created.body.delegation.id}`)
assert.equal(current.status, 200)
assert.equal(current.body.result, null)
})
})
+99
View File
@@ -0,0 +1,99 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const { withTestDatabase } = require("./helpers/db-test.cjs")
const { runMigrations } = require("../lib/db.cjs")
const { loadProfileRegistry } = require("../lib/agent/profile-registry.cjs")
const { syncProfiles } = require("../lib/agent/profile-store.cjs")
const {
createRootDelegation,
createChildDelegation,
listDelegations,
cancelDelegation,
} = require("../lib/agent/delegation-store.cjs")
test("agent hierarchy migrations create core tables idempotently", async (t) => {
await withTestDatabase(t, async ({ pool }) => {
await runMigrations(pool)
await runMigrations(pool)
const result = await pool.query(`
select table_name from information_schema.tables
where table_schema = current_schema()
order by table_name
`)
const names = result.rows.map((row) => row.table_name)
assert(names.includes("agent_profiles"))
assert(names.includes("agent_delegations"))
assert(names.includes("agent_executions"))
assert(names.includes("agent_results"))
assert(names.includes("agent_audit_events"))
})
})
test("store creates, lists, and cancels delegations with execution disabled", async (t) => {
await withTestDatabase(t, async ({ pool }) => {
await runMigrations(pool)
const registry = loadProfileRegistry()
await syncProfiles(pool, registry)
const root = await createRootDelegation(pool, {
requestedProfile: { id: "orchestrator.hermes", version: 1 },
objective: "Coordinate bounded software work.",
acceptanceCriteria: ["Return a final result envelope"],
constraints: ["Do not execute live subagents"],
contextSelectors: [{ source: "system-knowledge.read", selector: { record: "service/hermes" }, purpose: "classification" }],
requestedCapabilities: [],
budget: { runtimeMs: 600000, toolCalls: 40, inputTokens: 60000, outputTokens: 15000, costUsdMicros: 1000000 },
timeoutAt: "2026-06-12T22:00:00.000Z",
idempotencyKey: "root:software:1",
executionMode: "disabled",
})
assert.equal(root.delegation.depth, 0)
assert.equal(root.execution.state, "queued")
assert.match(root.execution.stateReason, /execution disabled/)
const child = await createChildDelegation(pool, {
parentExecutionId: root.execution.id,
schema: "hermes.agent.delegation.v1",
requestId: "0b9de277-d096-4851-bf32-97f83dd6813e",
requestedProfile: { id: "coordinator.software", version: 1 },
objective: "Plan software work.",
acceptanceCriteria: ["Return worker plan"],
constraints: ["No live execution"],
contextSelectors: [{ source: "system-knowledge.read", selector: { record: "repo/Hermes-Control-Panel" }, purpose: "planning" }],
requestedCapabilities: [{ scope: "system-knowledge.read", resource: "registry", operations: ["read"] }],
budget: { runtimeMs: 300000, toolCalls: 20, inputTokens: 30000, outputTokens: 8000, costUsdMicros: 500000 },
timeoutAt: "2026-06-12T21:30:00.000Z",
idempotencyKey: "root:coordinator:software",
executionMode: "disabled",
})
assert.equal(child.delegation.parentExecutionId, root.execution.id)
assert.equal(child.delegation.depth, 1)
const duplicate = await createChildDelegation(pool, {
parentExecutionId: root.execution.id,
schema: "hermes.agent.delegation.v1",
requestId: "0b9de277-d096-4851-bf32-97f83dd6813e",
requestedProfile: { id: "coordinator.software", version: 1 },
objective: "Plan software work.",
budget: { runtimeMs: 300000, toolCalls: 20, inputTokens: 30000, outputTokens: 8000, costUsdMicros: 500000 },
timeoutAt: "2026-06-12T21:30:00.000Z",
idempotencyKey: "root:coordinator:software",
executionMode: "disabled",
})
assert.equal(duplicate.delegation.id, child.delegation.id)
const listed = await listDelegations(pool, {})
assert.equal(listed.length, 2)
const cancelled = await cancelDelegation(pool, child.delegation.id, "test cancellation")
assert.equal(cancelled.execution.state, "cancelled")
const audit = await pool.query("select event_type from agent_audit_events order by created_at")
assert(audit.rows.map((row) => row.event_type).includes("delegation.cancelled"))
})
})
+54
View File
@@ -0,0 +1,54 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const {
capabilityNeedsGrant,
createFakeResult,
executeLeasedTask,
} = require("../lib/agent/worker-runtime.cjs")
const baseTask = {
delegation: {
id: "delegation-1",
objective: "Summarize the current Hermes agent-controller status.",
acceptanceCriteria: ["Return a terminal result envelope"],
constraints: ["Read only"],
requestedCapabilities: [{ scope: "system-knowledge.read", resource: "agent-controller", operations: ["read"] }],
},
execution: {
id: "execution-1",
auditId: "audit-1",
profile: { id: "worker.operations.runbook-lookup", version: 1 },
budgetReserved: { runtimeMs: 300000, toolCalls: 20, inputTokens: 30000, outputTokens: 8000, costUsdMicros: 500000 },
},
contextPack: {
id: "context-1",
items: [{ source: "system-knowledge.read", selector: { serviceId: "agent-controller" }, content: { state: "live" } }],
},
}
test("dangerous capabilities require an explicit grant", () => {
assert.equal(capabilityNeedsGrant({ scope: "system-knowledge.read", operations: ["read"] }), false)
assert.equal(capabilityNeedsGrant({ scope: "repository.write.proposed", operations: ["write"] }), true)
assert.equal(capabilityNeedsGrant({ scope: "automation.execute.proposed", operations: ["execute"] }), true)
assert.equal(capabilityNeedsGrant({ scope: "container.provision", operations: ["create"] }), true)
})
test("fake provider emits a bounded validated terminal result", async () => {
const result = await createFakeResult(baseTask, { startedAt: "2026-06-12T15:00:00.000Z", completedAt: "2026-06-12T15:00:01.000Z" })
assert.equal(result.schema, "hermes.agent.result.v1")
assert.equal(result.status, "succeeded")
assert.equal(result.executionId, "execution-1")
assert.equal(result.outputs[0].kind, "fake-live-specialist-result")
assert(result.budgetUsage.runtimeMs > 0)
assert(result.budgetUsage.inputTokens > 0)
})
test("worker blocks dangerous execution without a matching grant", async () => {
const task = structuredClone(baseTask)
task.delegation.requestedCapabilities = [{ scope: "automation.execute.proposed", resource: "job/demo", operations: ["execute"] }]
const result = await executeLeasedTask(task, { grants: [], providerMode: "fake-live" })
assert.equal(result.status, "rejected")
assert.match(result.summary, /explicit capability grant/i)
})
+210
View File
@@ -0,0 +1,210 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const {
TOOLS,
createServer,
handleRpc,
evaluatePolicy,
validateSchedule,
} = require("../automation-control-mcp.cjs")
const principal = { id: "test-writer", role: "writer" }
function createFakePool() {
const calls = []
const script = {
id: "11111111-1111-1111-1111-111111111111",
name: "Daily report",
description: "",
status: "draft",
risk: "medium",
definition: { action: "summarize" },
version: 1,
created_by: "test-writer",
created_at: "2026-06-12T00:00:00Z",
updated_at: "2026-06-12T00:00:00Z",
}
const job = {
id: "22222222-2222-2222-2222-222222222222",
script_id: script.id,
name: "Daily report job",
status: "draft",
schedule_type: "cron",
schedule: { type: "cron", expression: "0 8 * * *" },
next_run_at: null,
created_by: "test-writer",
created_at: "2026-06-12T00:00:00Z",
updated_at: "2026-06-12T00:00:00Z",
}
return {
calls,
async query(sql, params) {
calls.push({ sql: String(sql), params })
if (String(sql).startsWith("insert into automation_scripts")) return { rows: [script] }
if (String(sql).startsWith("insert into automation_jobs")) return { rows: [job] }
if (String(sql).startsWith("insert into automation_events")) return { rows: [] }
if (String(sql).startsWith("select id from automation_scripts")) return { rows: [{ id: script.id }] }
if (String(sql).startsWith("select * from automation_jobs order")) return { rows: [job] }
return { rows: [] }
},
}
}
test("automation-control exposes approved execution administration but no shell or Docker tools", async () => {
const response = await handleRpc(createFakePool(), { jsonrpc: "2.0", id: 1, method: "tools/list" }, principal)
assert.equal(response.result.tools.length, TOOLS.length)
const names = response.result.tools.map((tool) => tool.name)
assert(names.includes("automation_script_create_draft"))
assert(names.includes("automation_job_create_draft"))
assert(names.includes("automation_policy_check"))
assert(names.includes("automation_approval_request"))
assert(names.includes("automation_approval_decide"))
assert(names.includes("automation_job_activate"))
assert(names.includes("automation_run_enqueue"))
assert(!names.some((name) => /shell|docker|provision/.test(name)))
})
test("automation-control policy check reports bounded execution mode", () => {
assert.deepEqual(evaluatePolicy({ risk: "critical" }), {
risk: "critical",
allowed: true,
executionEnabled: true,
executionMode: "bounded",
approval: {
required: true,
minimumApprovers: 2,
},
})
assert.throws(() => evaluatePolicy({ risk: "unknown" }), /Invalid risk level/)
})
test("automation-control creates only draft scripts and jobs", async () => {
const pool = createFakePool()
const scriptResponse = await handleRpc(pool, {
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: {
name: "automation_script_create_draft",
arguments: { name: "Daily report", risk: "medium", definition: { action: "summarize" } },
},
}, principal)
assert.equal(scriptResponse.result.status, "draft")
const jobResponse = await handleRpc(pool, {
jsonrpc: "2.0",
id: 3,
method: "tools/call",
params: {
name: "automation_job_create_draft",
arguments: { name: "Daily report job", scriptId: scriptResponse.result.id, schedule: { type: "cron", expression: "0 8 * * *" } },
},
}, principal)
assert.equal(jobResponse.result.status, "draft")
assert.equal(jobResponse.result.scheduleType, "cron")
assert(pool.calls.some((call) => call.sql.includes("automation_events")), "draft writes should be audited")
})
test("automation-control validates schedules and rejects unknown tools", async () => {
assert.equal(validateSchedule({ type: "interval", anchor: "2026-06-12T12:00:00Z", everySeconds: 60 }), "interval")
assert.throws(() => validateSchedule({ type: "interval", everySeconds: 60 }), /anchor/)
assert.throws(() => validateSchedule({ type: "interval", everySeconds: 10 }), /everySeconds/)
const response = await handleRpc(createFakePool(), {
jsonrpc: "2.0",
id: 4,
method: "tools/call",
params: { name: "automation_run_execute", arguments: {} },
}, principal)
assert.equal(response.error.data.code, "unknown_tool")
})
test("automation-control creates immutable approval bundles and activates approved write jobs", async () => {
const calls = []
const script = {
id: "script-write",
risk: "high",
status: "draft",
definition: { kind: "http", method: "POST", url: "https://safe.internal/api/migrate", body: { limit: 1 } },
}
const approval = {
id: "approval-1",
subject_type: "script",
subject_id: script.id,
risk: "high",
status: "approved",
minimum_approvers: 1,
decisions: [{ principalId: "approver", decision: "approved" }],
bundle_hash: "pending-test-hash",
requested_by: principal.id,
expires_at: "2026-06-13T00:00:00Z",
}
const job = {
id: "job-write",
script_id: script.id,
status: "draft",
schedule: { type: "once", runAt: "2026-06-13T00:00:00Z" },
}
const pool = {
async query(sql, params) {
calls.push({ sql: String(sql), params })
if (String(sql).includes("select * from automation_scripts where id")) return { rows: [script] }
if (String(sql).startsWith("insert into automation_approvals")) return { rows: [{ ...approval, bundle_hash: params[5] }] }
if (String(sql).includes("from automation_jobs j")) return { rows: [{ ...job, risk: script.risk, definition: script.definition }] }
if (String(sql).includes("select * from automation_approvals where id")) return { rows: [{ ...approval, bundle_hash: params[0] === approval.id ? calls.find((call) => call.sql.startsWith("insert into automation_approvals"))?.params[5] : "" }] }
if (String(sql).startsWith("update automation_scripts")) return { rows: [] }
if (String(sql).startsWith("update automation_jobs")) return { rows: [{ ...job, status: "active", approval_id: approval.id }] }
if (String(sql).startsWith("insert into automation_events")) return { rows: [] }
return { rows: [] }
},
}
const request = await handleRpc(pool, {
jsonrpc: "2.0",
id: 5,
method: "tools/call",
params: { name: "automation_approval_request", arguments: { scriptId: script.id, expiresInSeconds: 3600 } },
}, principal, { env: { AUTOMATION_ALLOWED_HTTP_PREFIXES: "https://safe.internal/api/" } })
assert.equal(request.result.status, "approved")
assert.equal(typeof request.result.bundleHash, "string")
const activated = await handleRpc(pool, {
jsonrpc: "2.0",
id: 6,
method: "tools/call",
params: { name: "automation_job_activate", arguments: { id: job.id, approvalId: approval.id } },
}, principal, { env: { AUTOMATION_ALLOWED_HTTP_PREFIXES: "https://safe.internal/api/" } })
assert.equal(activated.result.status, "active")
assert(calls.some((call) => call.sql.startsWith("update automation_jobs")))
})
test("automation-control HTTP endpoint requires bearer auth", async () => {
const server = createServer({
databaseUrl: "postgresql://unused",
token: "server-secret",
pool: createFakePool(),
principal,
})
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve))
const { port } = server.address()
try {
const unauthorized = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: "POST",
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
})
assert.equal(unauthorized.status, 401)
const authorized = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: "POST",
headers: { Authorization: "Bearer server-secret" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
})
assert.equal(authorized.status, 200)
const body = await authorized.json()
assert.equal(body.result.tools[0].name, "automation_script_create_draft")
} finally {
await new Promise((resolve) => server.close(resolve))
}
})
@@ -0,0 +1,28 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const fs = require("node:fs")
const path = require("node:path")
const root = path.join(__dirname, "..")
const migration = fs.readFileSync(path.join(root, "migrations/005_automation_execution.sql"), "utf8")
const compose = fs.readFileSync(path.join(root, "docker-compose.yml"), "utf8")
test("execution migration adds leases, immutable approval bundles, and run budgets", () => {
assert.match(migration, /bundle_hash/i)
assert.match(migration, /lease_owner/i)
assert.match(migration, /lease_expires_at/i)
assert.match(migration, /max_runs_per_day/i)
assert.match(migration, /approval_id/i)
})
test("automation worker has no Docker socket and pre-Hermes remains tool-free", () => {
const worker = compose.match(/\n automation-worker:\n([\s\S]*?)\n [a-z0-9-]+:\n/i)?.[1] || ""
const pre = compose.match(/\n hermes-pre-upstream:\n([\s\S]*?)\n [a-z0-9-]+:\n/i)?.[1] || ""
assert.match(worker, /automation-worker\.cjs/)
assert.match(worker, /read_only:\s*true/)
assert.doesNotMatch(worker, /\/var\/run\/docker\.sock/)
assert.match(pre, /HERMES_REGISTER_AUTOMATION_CONTROL_MCP:\s*"false"/)
assert.match(pre, /HERMES_REGISTER_CONTAINER_PROVISIONER_MCP:\s*"false"/)
})
+107
View File
@@ -0,0 +1,107 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const {
actionFingerprint,
evaluateActionPolicy,
executeAction,
nextOccurrence,
} = require("../lib/automation-runtime.cjs")
test("wakeup actions execute without shell or network access", async () => {
const result = await executeAction(
{ kind: "wakeup", message: "lane-4-smoke" },
{ timeoutMs: 1000, maxResultBytes: 1024 }
)
assert.deepEqual(result, { kind: "wakeup", message: "lane-4-smoke" })
})
test("runtime rejects arbitrary shell and inline credential fields", () => {
assert.throws(() => evaluateActionPolicy({ kind: "shell", command: "id" }, {}), /unsupported action/i)
assert.throws(
() => evaluateActionPolicy({ kind: "http", method: "GET", url: "https://example.test", token: "secret" }, {}),
/inline credentials/i
)
})
test("HTTP reads require an allowlisted URL and writes require an approved immutable bundle", () => {
const env = { AUTOMATION_ALLOWED_HTTP_PREFIXES: "https://safe.internal/api/" }
assert.equal(evaluateActionPolicy({ kind: "http", method: "GET", url: "https://safe.internal/api/status" }, env).approvalRequired, false)
assert.throws(
() => evaluateActionPolicy({ kind: "http", method: "GET", url: "https://other.internal/api/status" }, env),
/allowlist/i
)
const action = { kind: "http", method: "POST", url: "https://safe.internal/api/migrate", body: { limit: 1 } }
const fingerprint = actionFingerprint(action)
assert.throws(() => evaluateActionPolicy(action, env), /approval/i)
assert.equal(evaluateActionPolicy(action, env, { status: "approved", bundle_hash: fingerprint }).approvalRequired, true)
assert.throws(
() => evaluateActionPolicy({ ...action, body: { limit: 2 } }, env, { status: "approved", bundle_hash: fingerprint }),
/bundle/i
)
})
test("container jobs are profile-only and require approved bundles", () => {
const action = { kind: "container_profile", profileId: "smoke-worker", reason: "bounded smoke" }
const approval = { status: "approved", bundle_hash: actionFingerprint(action) }
assert.equal(evaluateActionPolicy(action, {}, approval).approvalRequired, true)
assert.throws(
() => evaluateActionPolicy({ ...action, image: "unsafe:latest" }, {}, approval),
/profile-only/i
)
})
test("container profile execution calls the provisioner broker with approval metadata", async () => {
const action = { kind: "container_profile", profileId: "smoke-worker", reason: "bounded smoke" }
const approval = { id: "approval-12345678", status: "approved", bundle_hash: actionFingerprint(action) }
let request
await executeAction(action, {
approval,
brokerUrl: "http://container-provisioner:8792/mcp",
brokerToken: "broker-token",
runId: "run-12345678",
fetchImpl: async (_url, options) => {
request = JSON.parse(options.body)
return { ok: true, json: async () => ({ result: { id: "instance-1", status: "running" } }) }
},
})
assert.equal(request.params.name, "container_provision_create")
assert.equal(request.params.arguments.profileId, "smoke-worker")
assert.equal(request.params.arguments.approvalId, "approval-12345678")
assert.match(request.params.arguments.approvalSummary, /approved automation/i)
assert.equal(request.params.arguments.idempotencyKey, "automation-run:run-12345678")
})
test("execution enforces timeout and output budgets", async () => {
await assert.rejects(
() => executeAction(
{ kind: "http", method: "GET", url: "https://safe.internal/api/slow" },
{
env: { AUTOMATION_ALLOWED_HTTP_PREFIXES: "https://safe.internal/api/" },
timeoutMs: 5,
fetchImpl: async (_url, options) => new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(new Error("aborted")))),
}
),
/timed out/i
)
await assert.rejects(
() => executeAction(
{ kind: "http", method: "GET", url: "https://safe.internal/api/large" },
{
env: { AUTOMATION_ALLOWED_HTTP_PREFIXES: "https://safe.internal/api/" },
maxResultBytes: 4,
fetchImpl: async () => ({ status: 200, headers: new Map(), text: async () => "too large" }),
}
),
/result budget/i
)
})
test("scheduler computes deterministic once, interval, and UTC cron occurrences", () => {
assert.equal(nextOccurrence({ type: "once", runAt: "2026-06-12T12:00:00Z" }, "2026-06-12T11:59:00Z").toISOString(), "2026-06-12T12:00:00.000Z")
assert.equal(nextOccurrence({ type: "interval", anchor: "2026-06-12T12:00:00Z", everySeconds: 300 }, "2026-06-12T12:06:00Z").toISOString(), "2026-06-12T12:10:00.000Z")
assert.equal(nextOccurrence({ type: "cron", expression: "15 9 * * 1-5" }, "2026-06-12T09:15:00Z").toISOString(), "2026-06-15T09:15:00.000Z")
})
+81
View File
@@ -0,0 +1,81 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const {
leaseNextRun,
runLeasedRun,
scheduleDueJobs,
} = require("../automation-worker.cjs")
test("scheduler enqueues due jobs once and advances their next occurrence", async () => {
const calls = []
const pool = {
async query(sql, params) {
calls.push({ sql: String(sql), params })
if (String(sql).includes("from automation_jobs j")) {
return { rows: [{
id: "job-1",
script_id: "script-1",
next_run_at: "2026-06-12T12:00:00Z",
schedule: { type: "interval", anchor: "2026-06-12T12:00:00Z", everySeconds: 300 },
}] }
}
return { rows: [] }
},
}
const count = await scheduleDueJobs(pool, new Date("2026-06-12T12:00:01Z"))
assert.equal(count, 1)
assert(calls.some((call) => call.sql.includes("on conflict (job_id, scheduled_for) do nothing")))
assert(calls.some((call) => call.sql.includes("update automation_jobs set next_run_at")))
})
test("leasing uses skip-locked semantics and a bounded lease", async () => {
const calls = []
const pool = {
async query(sql, params) {
calls.push({ sql: String(sql), params })
return { rows: [{ id: "run-1", status: "running" }] }
},
}
const run = await leaseNextRun(pool, "worker-1", 30)
assert.equal(run.id, "run-1")
assert.match(calls[0].sql, /for update skip locked/i)
assert.deepEqual(calls[0].params, ["worker-1", 30])
})
test("worker completes harmless wakeups and persists bounded results", async () => {
const calls = []
const pool = {
async query(sql, params) {
calls.push({ sql: String(sql), params })
return { rows: [] }
},
}
await runLeasedRun(pool, {
id: "run-1",
status: "running",
definition: { kind: "wakeup", message: "smoke" },
approval_id: null,
}, { env: {}, timeoutMs: 1000, maxResultBytes: 1024 })
const completion = calls.find((call) => call.sql.includes("status = 'succeeded'"))
assert(completion)
assert.match(completion.params[0], /smoke/)
})
test("worker honors cancellation before executing an action", async () => {
const calls = []
const pool = {
async query(sql, params) {
calls.push({ sql: String(sql), params })
return { rows: [] }
},
}
await runLeasedRun(pool, {
id: "run-2",
status: "cancel_requested",
definition: { kind: "http", method: "GET", url: "https://should-not-run.invalid" },
approval_id: null,
}, { fetchImpl: async () => { throw new Error("must not execute") } })
assert(calls.some((call) => call.sql.includes("status = 'canceled'")))
})
+282
View File
@@ -0,0 +1,282 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const fs = require("fs")
const http = require("http")
const os = require("os")
const path = require("path")
const { createServer } = require("../vynte-internal-mcp.cjs")
const { exactConfirmation } = require("../safe-write-policy.cjs")
function startServer(env = {}) {
return new Promise((resolve) => {
const server = createServer({ env })
server.listen(0, "127.0.0.1", () => resolve({
port: server.address().port,
close: () => new Promise((done) => server.close(done)),
}))
})
}
function startUpstream(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler)
server.listen(0, "127.0.0.1", () => resolve({
url: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((done) => server.close(done)),
}))
})
}
function rpc(port, name, args = {}) {
const body = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: name === "tools/list" ? "tools/list" : "tools/call",
params: name === "tools/list" ? undefined : { name, arguments: args },
})
return new Promise((resolve, reject) => {
const req = http.request({
hostname: "127.0.0.1",
port,
path: "/mcp",
method: "POST",
headers: {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
authorization: "Bearer server-secret",
},
}, (res) => {
const chunks = []
res.on("data", (chunk) => chunks.push(chunk))
res.on("end", () => resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))))
})
req.on("error", reject)
req.end(body)
})
}
function payload(response) {
return JSON.parse(response.result.content[0].text)
}
function calEnv(url, overrides = {}) {
return {
VYNTE_CAL_URL: url,
VYNTE_CAL_TOKEN: "cal-secret",
VYNTE_MCP_SERVER_TOKEN: "server-secret",
VYNTE_CAL_ALLOWED_WRITE_PATHS: "/v2/bookings,/v2/event-types,/v2/schedules",
...overrides,
}
}
function bookingOperation(name, method, actionPath) {
return { name, method, path: actionPath }
}
test("tools/list exposes focused Cal action tools without delete, reassignment, slot reservation, or generic writes", async (t) => {
const server = await startServer({ VYNTE_MCP_SERVER_TOKEN: "server-secret" })
t.after(server.close)
const response = await rpc(server.port, "tools/list")
const names = response.result.tools.map((tool) => tool.name)
for (const name of [
"vynte_cal_booking_create",
"vynte_cal_booking_reschedule",
"vynte_cal_booking_cancel",
"vynte_cal_booking_confirm",
"vynte_cal_booking_decline",
"vynte_cal_booking_attendee_add",
"vynte_cal_booking_attendee_remove",
"vynte_cal_booking_guests_add",
"vynte_cal_event_type_create",
"vynte_cal_event_type_update",
"vynte_cal_schedule_create",
"vynte_cal_schedule_update",
]) assert(names.includes(name), `missing ${name}`)
assert.equal(names.some((name) => /delete|reassign|slot.*reserve|arbitrary.*write/i.test(name)), false)
for (const tool of response.result.tools.filter((item) => item.name.startsWith("vynte_cal_"))) {
assert.equal(Object.hasOwn(tool.inputSchema.properties, "body"), false, `${tool.name} exposes raw body`)
assert.equal(Object.hasOwn(tool.inputSchema.properties, "json"), false, `${tool.name} exposes raw json`)
}
})
test("Cal booking create defaults to dry run and redacts email and phone data", async (t) => {
let requests = 0
const upstream = await startUpstream((_req, res) => {
requests++
res.writeHead(500)
res.end()
})
t.after(upstream.close)
const server = await startServer(calEnv(upstream.url))
t.after(server.close)
const response = await rpc(server.port, "vynte_cal_booking_create", {
start: "2026-07-01T15:00:00Z",
eventTypeId: 123,
attendee: {
name: "Buyer",
email: "buyer@example.com",
phoneNumber: "+15555550123",
timeZone: "America/Denver",
},
guests: ["guest@example.com"],
})
const body = JSON.stringify(payload(response))
assert.equal(payload(response).dryRun, true)
assert.equal(requests, 0)
assert.doesNotMatch(body, /buyer@example\.com|guest@example\.com|\+15555550123/)
assert.match(body, /\[REDACTED_EMAIL\]/)
assert.match(body, /\[REDACTED_PHONE\]/)
})
test("Cal live writes require global enablement, exact confirmation, reason, idempotency, and impact confirmation", async (t) => {
const upstream = await startUpstream((_req, res) => {
res.writeHead(201, { "content-type": "application/json" })
res.end('{"status":"success"}')
})
t.after(upstream.close)
const server = await startServer(calEnv(upstream.url, { VYNTE_MCP_WRITES_ENABLED: "true" }))
t.after(server.close)
const operation = bookingOperation("vynte_cal_booking_cancel", "POST", "/v2/bookings/booking-1/cancel")
const args = {
bookingUid: "booking-1",
cancellationReason: "Customer asked to cancel",
dryRun: false,
reason: "Approved customer request",
idempotencyKey: "cal-cancel-001",
confirmOperation: exactConfirmation(operation),
}
const missingImpact = await rpc(server.port, operation.name, args)
assert.equal(missingImpact.error.data.code, "impact_confirmation_mismatch")
const missingAudit = await rpc(server.port, operation.name, {
...args,
confirmImpact: "I UNDERSTAND vynte_cal_booking_cancel WILL CANCEL OR REMOVE CALENDAR ATTENDANCE",
})
assert.equal(missingAudit.error.data.code, "audit_required")
})
test("Cal booking actions execute fixed endpoints with endpoint-specific versions and redacted audit", async (t) => {
const seen = []
const upstream = await startUpstream((req, res) => {
const chunks = []
req.on("data", (chunk) => chunks.push(chunk))
req.on("end", () => {
seen.push({
method: req.method,
url: req.url,
version: req.headers["cal-api-version"],
idempotency: req.headers["idempotency-key"],
body: chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : undefined,
})
res.writeHead(req.url.includes("/attendees") ? 201 : 200, { "content-type": "application/json" })
res.end('{"status":"success","data":{"email":"returned@example.com","phoneNumber":"+15555559999","token":"hide-me"}}')
})
})
t.after(upstream.close)
const auditPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "cal-audit-")), "writes.jsonl")
const server = await startServer(calEnv(upstream.url, {
VYNTE_MCP_WRITES_ENABLED: "true",
VYNTE_MCP_WRITE_AUDIT_PATH: auditPath,
}))
t.after(server.close)
const actions = [
["vynte_cal_booking_reschedule", "POST", "/v2/bookings/booking-1/reschedule", "2026-02-25", { bookingUid: "booking-1", start: "2026-07-01T16:00:00Z", reschedulingReason: "Better time" }, { start: "2026-07-01T16:00:00Z", reschedulingReason: "Better time" }],
["vynte_cal_booking_confirm", "POST", "/v2/bookings/booking-2/confirm", "2026-02-25", { bookingUid: "booking-2" }, {}],
["vynte_cal_booking_decline", "POST", "/v2/bookings/booking-3/decline", "2026-02-25", { bookingUid: "booking-3", declineReason: "Not a fit" }, { reason: "Not a fit" }],
["vynte_cal_booking_attendee_add", "POST", "/v2/bookings/booking-4/attendees", "2024-08-13", { bookingUid: "booking-4", attendee: { name: "Attendee", email: "attendee@example.com", timeZone: "America/Denver", phoneNumber: "+15555550000" } }, { name: "Attendee", email: "attendee@example.com", timeZone: "America/Denver", phoneNumber: "+15555550000" }],
["vynte_cal_booking_attendee_remove", "POST", "/v2/bookings/booking-5/cancel", "2026-02-25", { bookingUid: "booking-5", seatUid: "seat-1", cancellationReason: "Remove this attendee" }, { seatUid: "seat-1", cancellationReason: "Remove this attendee" }],
["vynte_cal_booking_guests_add", "POST", "/v2/bookings/booking-6/guests", "2024-08-13", { bookingUid: "booking-6", guests: [{ email: "guest@example.com", name: "Guest" }] }, { guests: [{ email: "guest@example.com", name: "Guest" }] }],
]
for (const [name, method, actionPath, _version, extra] of actions) {
const operation = bookingOperation(name, method, actionPath)
const args = {
...extra,
dryRun: false,
reason: "Approved Cal change",
idempotencyKey: `${name}-001`,
confirmOperation: exactConfirmation(operation),
}
if (name === "vynte_cal_booking_attendee_remove") {
args.confirmImpact = `I UNDERSTAND ${name} WILL CANCEL OR REMOVE CALENDAR ATTENDANCE`
}
if (["vynte_cal_booking_attendee_add", "vynte_cal_booking_guests_add"].includes(name)) {
args.confirmImpact = `I UNDERSTAND ${name} WILL SEND CALENDAR NOTIFICATIONS`
}
const response = await rpc(server.port, name, args)
assert.equal(payload(response).executed, true)
assert.doesNotMatch(JSON.stringify(payload(response)), /returned@example\.com|\+15555559999|hide-me/)
}
assert.deepEqual(seen, actions.map(([_name, method, url, version, extra, expectedBody], index) => ({
method,
url,
version,
idempotency: `${actions[index][0]}-001`,
body: expectedBody,
})))
const audit = fs.readFileSync(auditPath, "utf8")
assert.match(audit, /vynte_cal_booking_reschedule/)
assert.doesNotMatch(audit, /attendee@example\.com|guest@example\.com|\+15555550000|cal-secret|returned@example\.com|hide-me/)
})
test("Cal event type and schedule actions use fixed v2 paths and endpoint versions", async (t) => {
const seen = []
const upstream = await startUpstream((req, res) => {
const chunks = []
req.on("data", (chunk) => chunks.push(chunk))
req.on("end", () => {
seen.push({
method: req.method,
url: req.url,
version: req.headers["cal-api-version"],
body: JSON.parse(Buffer.concat(chunks).toString("utf8")),
})
res.writeHead(req.method === "POST" ? 201 : 200, { "content-type": "application/json" })
res.end('{"status":"success"}')
})
})
t.after(upstream.close)
const auditPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "cal-audit-")), "writes.jsonl")
const server = await startServer(calEnv(upstream.url, {
VYNTE_MCP_WRITES_ENABLED: "true",
VYNTE_MCP_WRITE_AUDIT_PATH: auditPath,
}))
t.after(server.close)
const actions = [
["vynte_cal_event_type_create", "POST", "/v2/event-types", "2024-06-14", { fields: { title: "Demo", slug: "demo", lengthInMinutes: 30 } }, { title: "Demo", slug: "demo", lengthInMinutes: 30 }],
["vynte_cal_event_type_update", "PATCH", "/v2/event-types/123", "2024-06-14", { eventTypeId: 123, fields: { title: "Demo updated", scheduleId: 456 } }, { title: "Demo updated", scheduleId: 456 }],
["vynte_cal_schedule_create", "POST", "/v2/schedules", "2024-06-11", { fields: { name: "Support", timeZone: "America/Denver", isDefault: false, availability: [{ days: ["Monday"], startTime: "09:00", endTime: "17:00" }] } }, { name: "Support", timeZone: "America/Denver", isDefault: false, availability: [{ days: ["Monday"], startTime: "09:00", endTime: "17:00" }] }],
["vynte_cal_schedule_update", "PATCH", "/v2/schedules/456", "2024-06-11", { scheduleId: 456, fields: { name: "Support updated" } }, { name: "Support updated" }],
]
for (const [name, method, actionPath, _version, extra] of actions) {
const response = await rpc(server.port, name, {
...extra,
dryRun: false,
reason: "Approved Cal configuration change",
idempotencyKey: `${name}-001`,
confirmOperation: exactConfirmation({ name, method, path: actionPath }),
})
assert.equal(payload(response).executed, true)
}
assert.deepEqual(seen, actions.map(([_name, method, url, version, _extra, body]) => ({ method, url, version, body })))
const unsafe = await rpc(server.port, "vynte_cal_event_type_update", {
eventTypeId: 123,
fields: { title: "Unsafe", teamId: 99 },
})
assert.equal(unsafe.error.data.code, "unsupported_field")
})
+246 -13
View File
@@ -10,7 +10,7 @@ const root = path.join(__dirname, "..")
function getCompose() {
try {
const output = execSync(
"docker compose --env-file .env.example --profile pre-gateway --profile post-gateway config --format json",
"docker compose --env-file .env.example --profile pre-gateway --profile post-gateway --profile agent-worker config --format json",
{ cwd: root, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
)
return JSON.parse(output)
@@ -29,34 +29,70 @@ test("compose config is valid and has correct service structure", (t) => {
const services = config.services || {}
const serviceNames = Object.keys(services)
// Must have all public services plus one shared native Hermes gateway upstream.
// Must have all public services plus isolated native Hermes gateway upstreams.
assert(serviceNames.includes("hermes-postgres"), "missing hermes-postgres")
assert(serviceNames.includes("hermes-control-plane"), "missing hermes-control-plane")
assert(serviceNames.includes("hermes-ai-upstream"), "missing hermes-ai-upstream")
assert(serviceNames.includes("hermes-pre-upstream"), "missing hermes-pre-upstream")
assert(serviceNames.includes("hermes-post-upstream"), "missing hermes-post-upstream")
assert(serviceNames.includes("hermes-pre-api"), "missing hermes-pre-api")
assert(serviceNames.includes("hermes-post-api"), "missing hermes-post-api")
assert(serviceNames.includes("vynte-internal-mcp"), "missing vynte-internal-mcp")
assert(serviceNames.includes("automation-control"), "missing automation-control")
assert(serviceNames.includes("container-provisioner"), "missing container-provisioner")
assert(serviceNames.includes("hermes-external-saas-mcp"), "missing hermes-external-saas-mcp")
assert(serviceNames.includes("hermes-agent-controller"), "missing hermes-agent-controller")
assert(serviceNames.includes("hermes-agent-worker"), "missing hermes-agent-worker")
// Upstream services must NOT have host ports
const aiUpstream = services["hermes-ai-upstream"]
const preUpstream = services["hermes-pre-upstream"]
const postUpstream = services["hermes-post-upstream"]
const preApi = services["hermes-pre-api"]
const postApi = services["hermes-post-api"]
assert.deepEqual(aiUpstream.profiles, ["pre-gateway", "post-gateway"], "hermes-ai-upstream should start with either API profile")
assert.deepEqual(preUpstream.profiles, ["pre-gateway"], "hermes-pre-upstream should start only with the pre API profile")
assert.deepEqual(postUpstream.profiles, ["post-gateway"], "hermes-post-upstream should start only with the post API profile")
assert.deepEqual(preApi.profiles, ["pre-gateway"], "hermes-pre-api should be opt-in")
assert.deepEqual(postApi.profiles, ["post-gateway"], "hermes-post-api should be opt-in")
assert(!aiUpstream.ports || aiUpstream.ports.length === 0, "hermes-ai-upstream should not publish host ports")
assert(!preUpstream.ports || preUpstream.ports.length === 0, "hermes-pre-upstream should not publish host ports")
assert(!postUpstream.ports || postUpstream.ports.length === 0, "hermes-post-upstream should not publish host ports")
// Public gateway services must publish correct ports
assert(preApi.ports && preApi.ports.length > 0, "hermes-pre-api should publish ports")
assert(postApi.ports && postApi.ports.length > 0, "hermes-post-api should publish ports")
const controlPlane = services["hermes-control-plane"]
const vynteMcp = services["vynte-internal-mcp"]
const automationControl = services["automation-control"]
const containerProvisioner = services["container-provisioner"]
const externalSaasMcp = services["hermes-external-saas-mcp"]
const agentController = services["hermes-agent-controller"]
const agentWorker = services["hermes-agent-worker"]
assert.equal(
controlPlane.image,
"10.0.3.6:4000/zachariahsharma/hermes-control-plane:latest",
"Hermes services should default to the internal HTTP registry image"
)
assert.equal(
vynteMcp.image,
"10.0.3.6:4000/zachariahsharma/hermes-control-plane:latest",
"Vynte MCP adapter should use the same deployable image"
)
assert.equal(
automationControl.image,
"10.0.3.6:4000/zachariahsharma/hermes-control-plane:latest",
"Automation-control MCP should use the same deployable image"
)
assert.equal(
containerProvisioner.image,
"10.0.3.6:4000/zachariahsharma/hermes-control-plane:latest",
"Container provisioner MCP should use the same deployable image"
)
assert.equal(
agentController.image,
"10.0.3.6:4000/zachariahsharma/hermes-control-plane:latest",
"Agent controller should use the same deployable image"
)
const controlPlanePort = controlPlane.ports?.find((port) => Number(port.target) === 7843)
const preApiPort = preApi.ports?.find((port) => Number(port.target) === 8645)
const postApiPort = postApi.ports?.find((port) => Number(port.target) === 8646)
@@ -64,11 +100,69 @@ test("compose config is valid and has correct service structure", (t) => {
assert.equal(controlPlanePort?.host_ip, "127.0.0.1", "control plane should bind to loopback by default")
assert.equal(preApiPort?.host_ip, "127.0.0.1", "pre API should bind to loopback by default")
assert.equal(postApiPort?.host_ip, "127.0.0.1", "post API should bind to loopback by default")
assert(!vynteMcp.ports || vynteMcp.ports.length === 0, "vynte-internal-mcp must not publish host ports")
assert(vynteMcp.expose?.includes("8787"), "vynte-internal-mcp should expose port 8787 only on the compose network")
assert(!automationControl.ports || automationControl.ports.length === 0, "automation-control must not publish host ports")
assert(automationControl.expose?.includes("8791"), "automation-control should expose port 8791 only on the compose network")
assert(!containerProvisioner.ports || containerProvisioner.ports.length === 0, "container-provisioner must not publish host ports")
assert(containerProvisioner.expose?.includes("8792"), "container-provisioner should expose port 8792 only on the compose network")
assert.equal(containerProvisioner.read_only, true, "container-provisioner root filesystem should be read-only")
assert(containerProvisioner.tmpfs?.some((mount) => String(mount).startsWith("/tmp")), "container-provisioner should use tmpfs for /tmp")
assert(!externalSaasMcp.ports || externalSaasMcp.ports.length === 0, "external SaaS MCP must not publish host ports")
assert(externalSaasMcp.expose?.includes("8787"), "external SaaS MCP should expose port 8787 only on the compose network")
assert(!agentController.ports || agentController.ports.length === 0, "agent controller must not publish host ports")
assert(agentController.expose?.includes("8793"), "agent controller should expose port 8793 only on the compose network")
assert(!agentWorker.ports || agentWorker.ports.length === 0, "agent worker must not publish host ports")
assert.equal(agentWorker.read_only, true, "agent worker must use a read-only root filesystem")
assert.equal(String(agentWorker.environment?.HERMES_AGENT_WORKER_PROVIDER_MODE), "fake-live", "agent worker must default to fake-live provider")
assert(
(containerProvisioner.volumes || []).some((volume) => volume.source === "/var/run/docker.sock" && volume.target === "/var/run/docker.sock"),
"container-provisioner must be the only service with Docker socket authority"
)
for (const serviceName of serviceNames.filter((name) => name !== "container-provisioner")) {
const serviceVolumes = services[serviceName].volumes || []
assert(
!serviceVolumes.some((volume) => volume.source === "/var/run/docker.sock" || volume.target === "/var/run/docker.sock"),
`${serviceName} must not receive Docker socket authority`
)
}
assert.equal((services["automation-worker"].volumes || []).length, 0, "automation worker must not receive host mounts")
assert.equal((agentWorker.volumes || []).length, 0, "agent worker must not receive host mounts")
assert.deepEqual(
(containerProvisioner.volumes || []).map((volume) => volume.target).sort(),
["/var/log/hermes", "/var/run/docker.sock"],
"container-provisioner must only receive Docker socket and audit-log host mounts"
)
assert(
Object.prototype.hasOwnProperty.call(vynteMcp.networks || {}, "portainer-network"),
"vynte-internal-mcp should join the external Portainer network for local Portainer reads"
)
assert.equal(
config.networks?.["portainer-network"]?.external,
true,
"Portainer network should be declared as an external Docker network"
)
// Shared native Hermes upstream must have API_SERVER_ENABLED=true.
const aiUpstreamEnv = aiUpstream.environment || {}
assert.equal(String(aiUpstreamEnv.API_SERVER_ENABLED), "true", "hermes-ai-upstream API_SERVER_ENABLED must be true")
assert(aiUpstreamEnv.API_SERVER_KEY, "hermes-ai-upstream must have an internal API server key")
// Native Hermes upstreams must have API_SERVER_ENABLED=true.
const preUpstreamEnv = preUpstream.environment || {}
const postUpstreamEnv = postUpstream.environment || {}
assert.equal(String(preUpstreamEnv.API_SERVER_ENABLED), "true", "hermes-pre-upstream API_SERVER_ENABLED must be true")
assert(preUpstreamEnv.API_SERVER_KEY, "hermes-pre-upstream must have an internal API server key")
assert.equal(String(postUpstreamEnv.API_SERVER_ENABLED), "true", "hermes-post-upstream API_SERVER_ENABLED must be true")
assert(postUpstreamEnv.API_SERVER_KEY, "hermes-post-upstream must have an internal API server key")
assert.equal(preUpstreamEnv.API_SERVER_KEY, postUpstreamEnv.API_SERVER_KEY, "isolated upstreams should share the gateway auth key")
assert.equal(String(preUpstreamEnv.HERMES_REGISTER_VYNTE_INTERNAL_MCP), "false", "pre upstream must not auto-register Vynte MCP")
assert.equal(String(preUpstreamEnv.HERMES_REGISTER_FORMS_MCP), "false", "pre upstream must not auto-register Forms MCP")
assert.equal(String(preUpstreamEnv.HERMES_REGISTER_AUTOMATION_CONTROL_MCP), "false", "pre upstream must not auto-register automation-control MCP")
assert.equal(String(preUpstreamEnv.HERMES_REGISTER_CONTAINER_PROVISIONER_MCP), "false", "pre upstream must not auto-register container-provisioner MCP")
assert.equal(String(preUpstreamEnv.HERMES_REGISTER_EXTERNAL_SAAS_MCP), "false", "pre upstream must not auto-register external SaaS MCP")
assert.equal(String(postUpstreamEnv.HERMES_REGISTER_VYNTE_INTERNAL_MCP), "true", "post upstream should auto-register Vynte MCP")
assert.equal(String(postUpstreamEnv.HERMES_REGISTER_FORMS_MCP), "true", "post upstream should auto-register Forms MCP")
assert.equal(String(postUpstreamEnv.HERMES_REGISTER_AUTOMATION_CONTROL_MCP), "false", "post upstream should not auto-register automation-control without explicit enablement")
assert.equal(String(postUpstreamEnv.HERMES_REGISTER_CONTAINER_PROVISIONER_MCP), "false", "post upstream should not auto-register container-provisioner without explicit enablement")
assert.equal(String(postUpstreamEnv.HERMES_REGISTER_EXTERNAL_SAAS_MCP), "false", "post upstream should not auto-register external SaaS without explicit enablement")
assert.equal(postUpstreamEnv.HERMES_EXTERNAL_SAAS_MCP_URL, "http://hermes-external-saas-mcp:8787/mcp", "post upstream should know the external SaaS MCP URL")
assert("HERMES_EXTERNAL_SAAS_MCP_TOKEN" in postUpstreamEnv, "post upstream must receive the external SaaS MCP bearer token when enabled")
// Control plane and gateway services must receive DATABASE_URL
const controlPlaneEnv = services["hermes-control-plane"].environment || {}
@@ -78,6 +172,44 @@ test("compose config is valid and has correct service structure", (t) => {
"/opt/hermes-agent/venv/bin/hermes",
"Hermes executable must be baked outside the mutable Hermes state mount"
)
assert.equal(
String(controlPlaneEnv.HERMES_REGISTER_VYNTE_INTERNAL_MCP),
"true",
"hermes-control-plane should auto-register the internal Vynte MCP adapter by default"
)
assert.equal(
controlPlaneEnv.HERMES_VYNTE_INTERNAL_MCP_URL,
"http://vynte-internal-mcp:8787/mcp",
"hermes-control-plane should register the compose-internal MCP URL"
)
assert("HERMES_VYNTE_INTERNAL_MCP_TOKEN" in controlPlaneEnv, "hermes-control-plane must receive the Vynte MCP bearer token")
assert.equal(String(controlPlaneEnv.HERMES_REGISTER_FORMS_MCP), "true", "hermes-control-plane should auto-register Forms MCP by default")
assert.equal(
controlPlaneEnv.HERMES_FORMS_MCP_URL,
"https://forms.internal.vyntehome.com/api/mcp",
"hermes-control-plane should register the custom Forms MCP endpoint"
)
assert("HERMES_FORMS_MCP_TOKEN" in controlPlaneEnv, "hermes-control-plane must receive the Forms MCP bearer token")
assert.equal(
String(controlPlaneEnv.HERMES_REGISTER_AUTOMATION_CONTROL_MCP),
"false",
"hermes-control-plane must not register automation-control by default"
)
assert.equal(
String(controlPlaneEnv.HERMES_REGISTER_CONTAINER_PROVISIONER_MCP),
"false",
"hermes-control-plane must not register container-provisioner by default"
)
assert.equal(
String(controlPlaneEnv.HERMES_REGISTER_EXTERNAL_SAAS_MCP),
"false",
"hermes-control-plane must not register external SaaS by default"
)
assert.equal(
String(controlPlaneEnv.HERMES_AGENT_EXECUTION_MODE),
"disabled",
"hermes-control-plane must keep hierarchy execution disabled by default"
)
const expectedStateTargets = [
"/home/hermes/.hermes",
@@ -85,27 +217,42 @@ test("compose config is valid and has correct service structure", (t) => {
"/home/hermes/.claude",
"/home/hermes/.gemini",
]
for (const serviceName of ["hermes-control-plane", "hermes-ai-upstream"]) {
for (const serviceName of ["hermes-control-plane", "hermes-post-upstream"]) {
const volumes = services[serviceName].volumes || []
for (const target of expectedStateTargets) {
const mount = volumes.find((volume) => volume.target === target)
assert.equal(mount?.type, "bind", `${serviceName} should keep ${target} as a host bind mount`)
}
}
const preUpstreamVolumes = preUpstream.volumes || []
const preHermesMount = preUpstreamVolumes.find((volume) => volume.target === "/home/hermes/.hermes")
const postHermesMount = postUpstream.volumes?.find((volume) => volume.target === "/home/hermes/.hermes")
assert.equal(preHermesMount?.type, "bind", "hermes-pre-upstream should keep Hermes state as a host bind mount")
assert.notEqual(
preHermesMount?.source,
postHermesMount?.source,
"pre upstream must use a separate Hermes home so MCP config cannot leak from post"
)
for (const target of ["/home/hermes/.codex", "/home/hermes/.claude", "/home/hermes/.gemini"]) {
const mount = preUpstreamVolumes.find((volume) => volume.target === target)
assert.equal(mount?.type, "bind", `hermes-pre-upstream should keep ${target} as a host bind mount`)
}
const preApiEnv = preApi.environment || {}
assert("DATABASE_URL" in preApiEnv, "hermes-pre-api must have DATABASE_URL")
assert.equal(preApiEnv.HERMES_UPSTREAM_URL, "http://hermes-pre-upstream:8642", "pre API must route to the raw pre upstream")
assert.equal(
preApiEnv.HERMES_UPSTREAM_API_KEY,
aiUpstreamEnv.API_SERVER_KEY,
preUpstreamEnv.API_SERVER_KEY,
"pre API must authenticate to the native Hermes API with the same internal key"
)
const postApiEnv = postApi.environment || {}
assert("DATABASE_URL" in postApiEnv, "hermes-post-api must have DATABASE_URL")
assert.equal(postApiEnv.HERMES_UPSTREAM_URL, "http://hermes-post-upstream:8642", "post API must route to the tools-enabled post upstream")
assert.equal(
postApiEnv.HERMES_UPSTREAM_API_KEY,
aiUpstreamEnv.API_SERVER_KEY,
postUpstreamEnv.API_SERVER_KEY,
"post API must authenticate to the native Hermes API with the same internal key"
)
@@ -121,4 +268,90 @@ test("compose config is valid and has correct service structure", (t) => {
const postApiCmd = Array.isArray(postApiCommand) ? postApiCommand.join(" ") : String(postApiCommand || "")
assert.match(preApiCmd, /api-gateway\.cjs/, "hermes-pre-api must run api-gateway.cjs")
assert.match(postApiCmd, /api-gateway\.cjs/, "hermes-post-api must run api-gateway.cjs")
const vynteMcpCommand = vynteMcp.command
const vynteMcpCmd = Array.isArray(vynteMcpCommand) ? vynteMcpCommand.join(" ") : String(vynteMcpCommand || "")
const vynteMcpEnv = vynteMcp.environment || {}
assert.match(vynteMcpCmd, /vynte-internal-mcp\.cjs/, "vynte-internal-mcp must run the MCP adapter")
assert.equal(String(vynteMcpEnv.VYNTE_MCP_ALLOW_ARBITRARY_URLS), "false", "Vynte MCP adapter must default to catalog-only URLs")
assert("VYNTE_MCP_SERVER_TOKEN" in vynteMcpEnv, "Vynte MCP adapter must receive the server bearer token")
assert.equal(String(vynteMcpEnv.VYNTE_MCP_WRITES_ENABLED), "false", "Vynte MCP writes must default disabled")
assert.equal(String(vynteMcpEnv.VYNTE_MCP_WRITE_POLICY), "strict_allowlist", "Vynte MCP write policy must default strict")
assert.equal(vynteMcpEnv.VYNTE_PLANE_ALLOWED_WORKSPACES, "", "Plane write workspace allowlist must default empty")
assert.equal(vynteMcpEnv.VYNTE_PLANE_ALLOWED_PROJECTS, "", "Plane write project allowlist must default empty")
assert.equal(vynteMcpEnv.VYNTE_PLANE_ALLOWED_WRITE_PATHS, "", "Plane write path allowlist must default empty")
assert.equal(vynteMcpEnv.VYNTE_TWENTY_ALLOWED_OBJECTS, "", "Twenty write object allowlist must default empty")
assert.equal(vynteMcpEnv.VYNTE_TWENTY_ALLOWED_WRITE_PATHS, "", "Twenty write path allowlist must default empty")
assert.equal(vynteMcpEnv.VYNTE_PLUNK_ALLOWED_WRITE_PATHS, "", "Plunk write path allowlist must default empty")
assert.equal(vynteMcpEnv.VYNTE_PLUNK_ALLOWED_EMAIL_DOMAINS, "", "Plunk email domain allowlist must default empty")
assert.equal(
vynteMcpEnv.VYNTE_MCP_WRITE_AUDIT_PATH,
"/var/log/hermes/vynte-mcp/write-audit.jsonl",
"Vynte MCP must use the durable audit mount"
)
assert(
(vynteMcp.volumes || []).some(
(volume) =>
volume.source === "/opt/hermes-control-plane/audit/vynte-mcp" &&
volume.target === "/var/log/hermes/vynte-mcp"
),
"Vynte MCP audit path must be backed by the durable host audit directory"
)
assert.equal(String(vynteMcpEnv.VYNTE_MCP_WRITE_RESPONSE_LIMIT), "4096", "Vynte MCP write response previews should default capped")
assert.equal(vynteMcpEnv.VYNTE_NETBIRD_URL, "https://vpn.vyntehome.com", "Vynte MCP adapter should default NetBird to vpn.vyntehome.com")
assert.equal(vynteMcpEnv.VYNTE_PLANE_AUTH_HEADER, "X-API-Key", "Plane API keys should default to X-API-Key auth")
assert.equal(vynteMcpEnv.VYNTE_CAL_API_VERSION, "2026-05-01", "Cal v2 calls should include a default API version")
const automationControlCommand = automationControl.command
const automationControlCmd = Array.isArray(automationControlCommand) ? automationControlCommand.join(" ") : String(automationControlCommand || "")
const automationControlEnv = automationControl.environment || {}
assert.match(automationControlCmd, /automation-control-mcp\.cjs/, "automation-control must run the automation MCP adapter")
assert("DATABASE_URL" in automationControlEnv, "automation-control must receive DATABASE_URL")
assert("AUTOMATION_CONTROL_MCP_TOKEN" in automationControlEnv, "automation-control must receive its bearer token")
const containerProvisionerCommand = containerProvisioner.command
const containerProvisionerCmd = Array.isArray(containerProvisionerCommand) ? containerProvisionerCommand.join(" ") : String(containerProvisionerCommand || "")
const containerProvisionerEnv = containerProvisioner.environment || {}
assert.match(containerProvisionerCmd, /container-provisioner-mcp\.cjs/, "container-provisioner must run the provisioner MCP adapter")
assert("DATABASE_URL" in containerProvisionerEnv, "container-provisioner must receive DATABASE_URL")
assert("CONTAINER_PROVISIONER_MCP_TOKEN" in containerProvisionerEnv, "container-provisioner must receive its bearer token")
assert.equal(String(containerProvisionerEnv.CONTAINER_PROVISIONER_EXECUTION_ENABLED), "false", "container provisioning must default disabled")
assert.equal(
containerProvisionerEnv.CONTAINER_PROVISIONER_DOCKER_SOCKET,
"/var/run/docker.sock",
"container-provisioner should use the mounted Docker socket explicitly"
)
assert.equal(
containerProvisionerEnv.CONTAINER_PROVISIONER_AUDIT_PATH,
"/var/log/hermes/container-provisioner-audit.jsonl",
"container-provisioner should write an append-only JSONL audit trail"
)
assert.match(
String(containerProvisionerEnv.CONTAINER_PROVISIONER_ALLOWED_IMAGE_PREFIXES),
/^10\.0\.3\.6:4000\/zachariahsharma\/hermes-automation-$/,
"container-provisioner image allowlist must be scoped to the internal automation registry namespace"
)
assert.match(
String(containerProvisionerEnv.CONTAINER_PROVISIONER_PROFILES_JSON),
/hermes-automation-smoke.*@sha256:[a-f0-9]{64}/,
"container-provisioner should include a digest-pinned reviewed smoke automation profile from the env file"
)
const externalSaasMcpEnv = externalSaasMcp.environment || {}
const externalSaasMcpImage = externalSaasMcp.image
assert.equal(
externalSaasMcpImage,
"10.0.3.6:4000/zachariahsharma/hermes-external-saas-mcp:latest",
"external SaaS MCP should use its own deployable image"
)
assert.equal(String(externalSaasMcpEnv.EXTERNAL_SAAS_ADAPTER_MODE), "fake", "external SaaS MCP must default to fake adapters")
assert("EXTERNAL_SAAS_MCP_SERVER_TOKEN" in externalSaasMcpEnv, "external SaaS MCP must receive its bearer token")
const agentControllerCommand = agentController.command
const agentControllerCmd = Array.isArray(agentControllerCommand) ? agentControllerCommand.join(" ") : String(agentControllerCommand || "")
const agentControllerEnv = agentController.environment || {}
assert.match(agentControllerCmd, /agent-controller\.cjs/, "agent controller must run the hierarchy API skeleton")
assert("DATABASE_URL" in agentControllerEnv, "agent controller must receive DATABASE_URL")
assert("HERMES_AGENT_CONTROLLER_TOKEN" in agentControllerEnv, "agent controller must receive its bearer token")
assert.equal(String(agentControllerEnv.HERMES_AGENT_EXECUTION_MODE), "disabled", "agent controller must default execution disabled")
})
+268
View File
@@ -0,0 +1,268 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const {
TOOLS,
createServer,
handleRpc,
} = require("../container-provisioner-mcp.cjs")
const principal = { id: "test-provisioner", role: "broker" }
function createFakePool() {
const calls = []
const profile = {
id: "node-worker",
name: "Node worker",
description: "Runs short node automation tasks",
image: "10.0.3.6:4000/zachariahsharma/hermes-automation-smoke@sha256:301145c7fda9da76f3b69dc6394804e0294ea6d6be2cc066b454e7f983c88d57",
enabled: true,
execution_enabled: true,
resource_limits: { cpus: 1, memoryMb: 256, pidsLimit: 128 },
ttl_seconds: 900,
network_mode: "none",
policy: {},
created_at: "2026-06-12T00:00:00Z",
updated_at: "2026-06-12T00:00:00Z",
}
const instance = {
id: "33333333-3333-3333-3333-333333333333",
profile_id: profile.id,
container_name: null,
image: profile.image,
status: "dry_run",
requested_by: principal.id,
policy_result: { allowed: true, executionEnabled: false },
docker_id: "docker-test-id",
container_name: "hermes-automation-node-worker-test",
created_at: "2026-06-12T00:00:00Z",
updated_at: "2026-06-12T00:00:00Z",
}
return {
calls,
async query(sql, params) {
calls.push({ sql: String(sql), params })
if (String(sql).startsWith("select * from container_profiles where enabled")) return { rows: [profile] }
if (String(sql).startsWith("select * from container_profiles where id")) return { rows: [profile] }
if (String(sql).startsWith("insert into container_profiles")) return { rows: [] }
if (String(sql).startsWith("insert into container_instances")) return { rows: [instance] }
if (String(sql).startsWith("insert into container_action_requests")) return { rows: [{ id: "action-request-id" }] }
if (String(sql).startsWith("update container_instances set status")) return {
rows: [{ ...instance, status: params[0] }],
}
if (String(sql).startsWith("update container_instances")) return {
rows: [{
...instance,
container_name: params[0],
docker_id: params[1],
status: params[2],
}],
}
if (String(sql).startsWith("insert into container_events")) return { rows: [] }
if (String(sql).startsWith("select * from container_instances where id")) return { rows: [instance] }
if (String(sql).startsWith("select * from container_events where instance_id")) return { rows: [] }
return { rows: [] }
},
}
}
test("container provisioner exposes reviewed lifecycle tools without arbitrary Docker authority", async () => {
const response = await handleRpc(createFakePool(), { jsonrpc: "2.0", id: 1, method: "tools/list" }, principal)
const names = response.result.tools.map((tool) => tool.name)
assert.deepEqual(names, TOOLS.map((tool) => tool.name))
assert(names.includes("container_profiles_list"))
assert(names.includes("container_provision_validate"))
assert(names.includes("container_provision_dry_run"))
assert(names.includes("container_provision_create"))
assert(names.includes("container_instance_start"))
assert(names.includes("container_instance_stop"))
assert(names.includes("container_instance_remove"))
assert(names.includes("container_instance_status"))
assert(names.includes("container_instance_logs"))
assert(!names.some((name) => /shell|docker_exec|socket|privileged/.test(name)))
})
test("container provisioner dry-run persists an audited non-running instance", async () => {
const pool = createFakePool()
const response = await handleRpc(pool, {
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: {
name: "container_provision_dry_run",
arguments: { profileId: "node-worker", reason: "policy review" },
},
}, principal)
assert.equal(response.result.status, "dry_run")
assert.equal(response.result.policy.allowed, true)
assert.equal(response.result.policy.executionEnabled, false)
assert(pool.calls.some((call) => call.sql.includes("container_events")), "dry-run should be audited")
assert(!pool.calls.some((call) => /docker|create container/i.test(call.sql)), "dry-run must not call Docker")
})
test("container provisioner create starts a locked-down broker-managed container when execution is enabled", async () => {
const pool = createFakePool()
const dockerCalls = []
const dockerClient = {
async createContainer(spec) {
dockerCalls.push({ action: "create", spec })
return { id: "docker-test-id" }
},
async startContainer(id) {
dockerCalls.push({ action: "start", id })
},
}
const response = await handleRpc(pool, {
jsonrpc: "2.0",
id: 4,
method: "tools/call",
params: {
name: "container_provision_create",
arguments: {
profileId: "node-worker",
approvalId: "approval-bundle-20260612-001",
approvalSummary: "Create the reviewed short-lived smoke container.",
idempotencyKey: "container-create-smoke-001",
reason: "smoke test",
},
},
}, principal, {
env: { CONTAINER_PROVISIONER_EXECUTION_ENABLED: "true" },
dockerClient,
})
assert.equal(response.result.status, "running")
assert.match(response.result.containerName, /^hermes-automation-node-worker-/)
assert.equal(response.result.dockerId, "docker-test-id")
assert.equal(dockerCalls.length, 2)
const spec = dockerCalls[0].spec
assert.equal(spec.Image, "10.0.3.6:4000/zachariahsharma/hermes-automation-smoke@sha256:301145c7fda9da76f3b69dc6394804e0294ea6d6be2cc066b454e7f983c88d57")
assert.equal(spec.HostConfig.NetworkMode, "none")
assert.equal(spec.HostConfig.Privileged, false)
assert.equal(spec.HostConfig.AutoRemove, false)
assert.equal(spec.HostConfig.ReadonlyRootfs, true)
assert.deepEqual(spec.HostConfig.Binds, [])
assert.deepEqual(spec.ExposedPorts, {})
assert.equal(spec.Labels["com.vynte.hermes.managed"], "true")
assert.equal(spec.Labels["com.vynte.hermes.kind"], "automation")
assert(pool.calls.some((call) => call.params?.[1] === "instance.create"), "create should be audited")
})
test("container provisioner create requires approval bundle metadata and idempotency", async () => {
const response = await handleRpc(createFakePool(), {
jsonrpc: "2.0",
id: 6,
method: "tools/call",
params: {
name: "container_provision_create",
arguments: { profileId: "node-worker", reason: "smoke test" },
},
}, principal, {
env: { CONTAINER_PROVISIONER_EXECUTION_ENABLED: "true" },
dockerClient: {
async createContainer() {
throw new Error("docker must not be called")
},
},
})
assert.equal(response.error.data.code, "missing_approval_id")
})
test("container provisioner lifecycle actions require managed instance identity and approval metadata", async () => {
const pool = createFakePool()
const dockerCalls = []
const dockerClient = {
async inspectContainer(id) {
dockerCalls.push({ action: "inspect", id })
return {
Id: "docker-test-id",
Name: "/hermes-automation-node-worker-test",
Config: {
Labels: {
"com.vynte.hermes.managed": "true",
"com.vynte.hermes.instance": "33333333-3333-3333-3333-333333333333",
},
},
State: { Running: true },
}
},
async stopContainer(id) {
dockerCalls.push({ action: "stop", id })
},
}
const response = await handleRpc(pool, {
jsonrpc: "2.0",
id: 7,
method: "tools/call",
params: {
name: "container_instance_stop",
arguments: {
id: "33333333-3333-3333-3333-333333333333",
approvalId: "approval-bundle-20260612-002",
approvalSummary: "Stop the reviewed broker-managed smoke container.",
idempotencyKey: "container-stop-smoke-001",
reason: "smoke test complete",
},
},
}, principal, {
env: { CONTAINER_PROVISIONER_EXECUTION_ENABLED: "true" },
dockerClient,
})
assert.equal(response.result.status, "stopped")
assert.deepEqual(dockerCalls.map((call) => call.action), ["inspect", "stop"])
assert(pool.calls.some((call) => call.params?.[1] === "instance.stop"), "stop should be audited")
})
test("container provisioner create is blocked when broker execution is disabled", async () => {
const response = await handleRpc(createFakePool(), {
jsonrpc: "2.0",
id: 5,
method: "tools/call",
params: {
name: "container_provision_create",
arguments: { profileId: "node-worker", reason: "blocked test" },
},
}, principal, {
env: { CONTAINER_PROVISIONER_EXECUTION_ENABLED: "false" },
dockerClient: {
async createContainer() {
throw new Error("docker must not be called")
},
},
})
assert.equal(response.error.data.code, "execution_disabled")
})
test("container provisioner HTTP endpoint requires bearer auth", async () => {
const server = createServer({
databaseUrl: "postgresql://unused",
token: "server-secret",
pool: createFakePool(),
principal,
})
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve))
const { port } = server.address()
try {
const unauthorized = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: "POST",
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
})
assert.equal(unauthorized.status, 401)
const authorized = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: "POST",
headers: { Authorization: "Bearer server-secret" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
})
assert.equal(authorized.status, 200)
const body = await authorized.json()
assert.equal(body.result.tools[0].name, "container_profiles_list")
} finally {
await new Promise((resolve) => server.close(resolve))
}
})
@@ -0,0 +1,90 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const {
evaluateContainerPolicy,
loadContainerProfilesFromEnv,
} = require("../lib/container-provisioner-policy.cjs")
const baseProfile = {
id: "node-worker",
name: "Node worker",
image: "10.0.3.6:4000/zachariahsharma/hermes-automation-smoke@sha256:301145c7fda9da76f3b69dc6394804e0294ea6d6be2cc066b454e7f983c88d57",
enabled: true,
resources: { cpus: 1, memoryMb: 256, pidsLimit: 128 },
ttlSeconds: 900,
networkMode: "none",
}
test("container policy accepts a locked-down digest-pinned allowlisted profile", () => {
const result = evaluateContainerPolicy(baseProfile, { dryRun: true })
assert.equal(result.allowed, true)
assert.equal(result.executionEnabled, false)
assert.equal(result.runtime.namePrefix, "hermes-automation-")
assert.equal(result.runtime.networkMode, "none")
assert.deepEqual(result.runtime.publishedPorts, [])
assert.deepEqual(result.runtime.labels["com.vynte.hermes.managed"], "true")
})
test("container policy rejects broad or mutable container authority", () => {
assert.equal(
evaluateContainerPolicy({ ...baseProfile, image: "10.0.3.6:4000/zachariahsharma/hermes-control-plane@sha256:node-worker:latest" }).allowed,
false
)
assert.equal(evaluateContainerPolicy({ ...baseProfile, privileged: true }).allowed, false)
assert.equal(evaluateContainerPolicy({ ...baseProfile, publishedPorts: [{ target: 8080, published: 8080 }] }).allowed, false)
assert.equal(evaluateContainerPolicy({ ...baseProfile, hostMounts: ["/var/run/docker.sock:/var/run/docker.sock"] }).allowed, false)
assert.equal(evaluateContainerPolicy({ ...baseProfile, image: "docker.io/library/node@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }).allowed, false)
assert.equal(evaluateContainerPolicy({ ...baseProfile, devices: ["/dev/kvm"] }).allowed, false)
assert.equal(evaluateContainerPolicy({ ...baseProfile, pidMode: "host" }).allowed, false)
assert.equal(evaluateContainerPolicy({ ...baseProfile, ipcMode: "host" }).allowed, false)
assert.equal(evaluateContainerPolicy({ ...baseProfile, env: { TOKEN: "secret" } }).allowed, false)
assert.equal(evaluateContainerPolicy({ ...baseProfile, labels: { "com.vynte.hermes.managed": "false" } }).allowed, false)
assert.equal(evaluateContainerPolicy({ ...baseProfile, command: "sh -c whoami" }).allowed, false)
})
test("container policy permits a bounded reviewed command array", () => {
const result = evaluateContainerPolicy({
...baseProfile,
command: ["node", "-e", "setTimeout(() => {}, 1000)"],
})
assert.equal(result.allowed, true)
assert.deepEqual(result.runtime.command, ["node", "-e", "setTimeout(() => {}, 1000)"])
})
test("container profiles load from JSON environment with disabled default execution", () => {
const profiles = loadContainerProfilesFromEnv({
CONTAINER_PROVISIONER_PROFILES_JSON: JSON.stringify([baseProfile]),
})
assert.equal(profiles.length, 1)
assert.equal(profiles[0].id, "node-worker")
assert.equal(profiles[0].executionEnabled, false)
})
test("container policy reports execution only when broker and profile are both enabled", () => {
const disabledBroker = evaluateContainerPolicy(
{ ...baseProfile, executionEnabled: true },
{ env: { CONTAINER_PROVISIONER_EXECUTION_ENABLED: "false" } }
)
assert.equal(disabledBroker.allowed, true)
assert.equal(disabledBroker.executionEnabled, false)
assert.equal(disabledBroker.requestedExecutionEnabled, false)
const enabledBroker = evaluateContainerPolicy(
{ ...baseProfile, executionEnabled: true },
{ env: { CONTAINER_PROVISIONER_EXECUTION_ENABLED: "true" } }
)
assert.equal(enabledBroker.allowed, true)
assert.equal(enabledBroker.executionEnabled, true)
assert.equal(enabledBroker.requestedExecutionEnabled, true)
})
test("container profiles loaded from JSON can explicitly opt into execution", () => {
const profiles = loadContainerProfilesFromEnv({
CONTAINER_PROVISIONER_PROFILES_JSON: JSON.stringify([{ ...baseProfile, executionEnabled: true }]),
})
assert.equal(profiles.length, 1)
assert.equal(profiles[0].executionEnabled, true)
})
+301 -2
View File
@@ -10,17 +10,115 @@ const { spawnSync } = require("child_process")
const root = path.join(__dirname, "..")
const entrypoint = path.join(root, "docker-entrypoint.sh")
function runEntrypoint(tmp, command) {
function runEntrypoint(tmp, command, env = {}) {
const hermesHome = path.join(tmp, ".hermes")
const codexHome = path.join(tmp, ".codex")
const claudeHome = path.join(tmp, ".claude")
const geminiHome = path.join(tmp, ".gemini")
const defaultConfig = path.join(tmp, "default-config.yaml")
const fakeHermes = path.join(tmp, "hermes")
const fakePython = path.join(tmp, "python")
fs.writeFileSync(defaultConfig, "fallback_providers: []\n")
fs.writeFileSync(fakeHermes, "#!/bin/sh\nexit 0\n")
fs.writeFileSync(fakePython, `#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
let stdin = ""
process.stdin.on("data", (chunk) => { stdin += chunk })
process.stdin.on("end", () => {
const hermesHome = process.env.HERMES_HOME
const configFile = path.join(hermesHome, "config.yaml")
const envFile = path.join(hermesHome, ".env")
const existing = fs.readFileSync(configFile, "utf8")
let next = existing
function enabled(key) {
return String(process.env[key] || "").toLowerCase() === "true"
}
function register({ enabledKey, nameKey, urlKey, tokenKey, defaultName, defaultUrl }) {
if (!enabled(enabledKey)) return
const name = process.env[nameKey] || defaultName
const url = process.env[urlKey] || defaultUrl
const token = process.env[tokenKey] || ""
const envKey = \`MCP_\${name.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY\`
const envRef = "$" + "{" + envKey + "}"
const block = token
? [
"mcp_servers:",
\` \${name}:\`,
\` url: "\${url}"\`,
" enabled: true",
" headers:",
" Authorization: \\"Bearer " + envRef + "\\"",
"",
].join("\\n")
: [
"mcp_servers:",
\` \${name}:\`,
\` url: "\${url}"\`,
" enabled: true",
"",
].join("\\n")
if (/^mcp_servers:\\s*\\{\\}\\s*$/m.test(next)) {
next = next.replace(/^mcp_servers:\\s*\\{\\}\\s*$/m, block.trimEnd())
} else if (!next.includes(\` \${name}:\`)) {
if (!next.endsWith("\\n")) next += "\\n"
if (!next.endsWith("\\n\\n")) next += "\\n"
next += block
}
if (token) fs.appendFileSync(envFile, \`\${envKey}=\${token}\\n\`, { mode: 0o600 })
}
register({
enabledKey: "HERMES_REGISTER_VYNTE_INTERNAL_MCP",
nameKey: "HERMES_VYNTE_INTERNAL_MCP_NAME",
urlKey: "HERMES_VYNTE_INTERNAL_MCP_URL",
tokenKey: "HERMES_VYNTE_INTERNAL_MCP_TOKEN",
defaultName: "vynte-internal",
defaultUrl: "http://vynte-internal-mcp:8787/mcp",
})
register({
enabledKey: "HERMES_REGISTER_FORMS_MCP",
nameKey: "HERMES_FORMS_MCP_NAME",
urlKey: "HERMES_FORMS_MCP_URL",
tokenKey: "HERMES_FORMS_MCP_TOKEN",
defaultName: "forms",
defaultUrl: "https://forms.internal.vyntehome.com/api/mcp",
})
register({
enabledKey: "HERMES_REGISTER_AUTOMATION_CONTROL_MCP",
nameKey: "HERMES_AUTOMATION_CONTROL_MCP_NAME",
urlKey: "HERMES_AUTOMATION_CONTROL_MCP_URL",
tokenKey: "HERMES_AUTOMATION_CONTROL_MCP_TOKEN",
defaultName: "automation-control",
defaultUrl: "http://automation-control:8791/mcp",
})
register({
enabledKey: "HERMES_REGISTER_CONTAINER_PROVISIONER_MCP",
nameKey: "HERMES_CONTAINER_PROVISIONER_MCP_NAME",
urlKey: "HERMES_CONTAINER_PROVISIONER_MCP_URL",
tokenKey: "HERMES_CONTAINER_PROVISIONER_MCP_TOKEN",
defaultName: "container-provisioner",
defaultUrl: "http://container-provisioner:8792/mcp",
})
register({
enabledKey: "HERMES_REGISTER_EXTERNAL_SAAS_MCP",
nameKey: "HERMES_EXTERNAL_SAAS_MCP_NAME",
urlKey: "HERMES_EXTERNAL_SAAS_MCP_URL",
tokenKey: "HERMES_EXTERNAL_SAAS_MCP_TOKEN",
defaultName: "external-saas",
defaultUrl: "http://hermes-external-saas-mcp:8787/mcp",
})
fs.writeFileSync(configFile, next.endsWith("\\n") ? next : \`\${next}\\n\`)
})
`)
fs.chmodSync(fakeHermes, 0o755)
fs.chmodSync(fakePython, 0o755)
const result = spawnSync("bash", [entrypoint, ...command], {
cwd: root,
@@ -33,7 +131,11 @@ function runEntrypoint(tmp, command) {
CLAUDE_CONFIG_DIR: claudeHome,
GEMINI_CONFIG_DIR: geminiHome,
HERMES_EXE: fakeHermes,
HERMES_PYTHON: fakePython,
HERMES_DEFAULT_CONFIG: defaultConfig,
HERMES_REGISTER_VYNTE_INTERNAL_MCP: "true",
HERMES_REGISTER_FORMS_MCP: "false",
...env,
},
})
@@ -46,7 +148,18 @@ test("entrypoint initializes empty mutable state without provider authentication
const state = runEntrypoint(tmp, ["sh", "-c", "printf ready"])
assert.equal(state.result.status, 0, state.result.stderr)
assert.equal(state.result.stdout, "ready")
assert.equal(fs.readFileSync(path.join(state.hermesHome, "config.yaml"), "utf-8"), "fallback_providers: []\n")
assert.equal(
fs.readFileSync(path.join(state.hermesHome, "config.yaml"), "utf-8"),
[
"fallback_providers: []",
"",
"mcp_servers:",
" vynte-internal:",
' url: "http://vynte-internal-mcp:8787/mcp"',
" enabled: true",
"",
].join("\n")
)
assert.equal(fs.existsSync(state.codexHome), true)
assert.equal(fs.existsSync(state.claudeHome), true)
assert.equal(fs.existsSync(state.geminiHome), true)
@@ -64,8 +177,194 @@ test("entrypoint preserves existing Hermes configuration", () => {
const state = runEntrypoint(tmp, ["true"])
assert.equal(state.result.status, 0, state.result.stderr)
assert.equal(
fs.readFileSync(path.join(hermesHome, "config.yaml"), "utf-8"),
[
"existing: true",
"",
"mcp_servers:",
" vynte-internal:",
' url: "http://vynte-internal-mcp:8787/mcp"',
" enabled: true",
"",
].join("\n")
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test("entrypoint leaves Hermes configuration unchanged when Vynte MCP registration is disabled", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-entrypoint-disabled-test-"))
try {
const hermesHome = path.join(tmp, ".hermes")
fs.mkdirSync(hermesHome, { recursive: true })
fs.writeFileSync(path.join(hermesHome, "config.yaml"), "existing: true\n")
const state = runEntrypoint(tmp, ["true"], { HERMES_REGISTER_VYNTE_INTERNAL_MCP: "false" })
assert.equal(state.result.status, 0, state.result.stderr)
assert.equal(fs.readFileSync(path.join(hermesHome, "config.yaml"), "utf-8"), "existing: true\n")
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test("entrypoint registers configured Vynte MCP URL server without probing the network", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-entrypoint-mcp-test-"))
try {
const state = runEntrypoint(tmp, ["true"], {
HERMES_VYNTE_INTERNAL_MCP_NAME: "vynte-local",
HERMES_VYNTE_INTERNAL_MCP_URL: "http://example.invalid:8787/mcp",
})
assert.equal(state.result.status, 0, state.result.stderr)
assert.equal(
fs.readFileSync(path.join(state.hermesHome, "config.yaml"), "utf-8"),
[
"fallback_providers: []",
"",
"mcp_servers:",
" vynte-local:",
' url: "http://example.invalid:8787/mcp"',
" enabled: true",
"",
].join("\n")
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test("entrypoint Vynte MCP registration is idempotent", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-entrypoint-idempotent-test-"))
try {
const first = runEntrypoint(tmp, ["true"])
assert.equal(first.result.status, 0, first.result.stderr)
const configFile = path.join(first.hermesHome, "config.yaml")
const afterFirstRun = fs.readFileSync(configFile, "utf-8")
const second = runEntrypoint(tmp, ["true"])
assert.equal(second.result.status, 0, second.result.stderr)
assert.equal(fs.readFileSync(configFile, "utf-8"), afterFirstRun)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test("entrypoint replaces inline empty MCP config through Hermes config helper", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-entrypoint-inline-mcp-test-"))
try {
const hermesHome = path.join(tmp, ".hermes")
fs.mkdirSync(hermesHome, { recursive: true })
fs.writeFileSync(path.join(hermesHome, "config.yaml"), "fallback_providers: []\nmcp_servers: {}\n")
const state = runEntrypoint(tmp, ["true"])
assert.equal(state.result.status, 0, state.result.stderr)
const config = fs.readFileSync(path.join(hermesHome, "config.yaml"), "utf-8")
assert.match(config, /mcp_servers:\n vynte-internal:/)
assert.doesNotMatch(config, /mcp_servers: \{\}/)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test("entrypoint stores Vynte MCP bearer token in env-backed config", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-entrypoint-mcp-token-test-"))
try {
const state = runEntrypoint(tmp, ["true"], { HERMES_VYNTE_INTERNAL_MCP_TOKEN: "shared-secret" })
assert.equal(state.result.status, 0, state.result.stderr)
const config = fs.readFileSync(path.join(state.hermesHome, "config.yaml"), "utf-8")
assert.match(config, /Authorization: "Bearer \$\{MCP_VYNTE_INTERNAL_API_KEY\}"/)
assert.doesNotMatch(config, /shared-secret/)
assert.equal(fs.readFileSync(path.join(state.hermesHome, ".env"), "utf-8"), "MCP_VYNTE_INTERNAL_API_KEY=shared-secret\n")
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test("entrypoint registers custom Forms MCP endpoint with env-backed bearer token", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-entrypoint-forms-mcp-test-"))
try {
const state = runEntrypoint(tmp, ["true"], {
HERMES_REGISTER_FORMS_MCP: "true",
HERMES_FORMS_MCP_TOKEN: "forms-secret",
})
assert.equal(state.result.status, 0, state.result.stderr)
const config = fs.readFileSync(path.join(state.hermesHome, "config.yaml"), "utf-8")
assert.match(config, / forms:\n url: "https:\/\/forms\.internal\.vyntehome\.com\/api\/mcp"/)
assert.match(config, /Authorization: "Bearer \$\{MCP_FORMS_API_KEY\}"/)
assert.doesNotMatch(config, /forms-secret/)
const env = fs.readFileSync(path.join(state.hermesHome, ".env"), "utf-8")
assert.match(env, /MCP_FORMS_API_KEY=forms-secret/)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test("entrypoint registers automation-control MCP only when explicitly enabled with token", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-entrypoint-automation-mcp-test-"))
try {
const state = runEntrypoint(tmp, ["true"], {
HERMES_REGISTER_AUTOMATION_CONTROL_MCP: "true",
HERMES_AUTOMATION_CONTROL_MCP_TOKEN: "automation-secret",
})
assert.equal(state.result.status, 0, state.result.stderr)
const config = fs.readFileSync(path.join(state.hermesHome, "config.yaml"), "utf-8")
assert.match(config, / automation-control:\n url: "http:\/\/automation-control:8791\/mcp"/)
assert.match(config, /Authorization: "Bearer \$\{MCP_AUTOMATION_CONTROL_API_KEY\}"/)
assert.doesNotMatch(config, /automation-secret/)
const env = fs.readFileSync(path.join(state.hermesHome, ".env"), "utf-8")
assert.match(env, /MCP_AUTOMATION_CONTROL_API_KEY=automation-secret/)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test("entrypoint registers container-provisioner MCP only when explicitly enabled with token", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-entrypoint-container-provisioner-mcp-test-"))
try {
const state = runEntrypoint(tmp, ["true"], {
HERMES_REGISTER_CONTAINER_PROVISIONER_MCP: "true",
HERMES_CONTAINER_PROVISIONER_MCP_TOKEN: "provisioner-secret",
})
assert.equal(state.result.status, 0, state.result.stderr)
const config = fs.readFileSync(path.join(state.hermesHome, "config.yaml"), "utf-8")
assert.match(config, / container-provisioner:\n url: "http:\/\/container-provisioner:8792\/mcp"/)
assert.match(config, /Authorization: "Bearer \$\{MCP_CONTAINER_PROVISIONER_API_KEY\}"/)
assert.doesNotMatch(config, /provisioner-secret/)
const env = fs.readFileSync(path.join(state.hermesHome, ".env"), "utf-8")
assert.match(env, /MCP_CONTAINER_PROVISIONER_API_KEY=provisioner-secret/)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test("entrypoint registers external SaaS MCP only when explicitly enabled with token", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-entrypoint-external-saas-mcp-test-"))
try {
const disabled = runEntrypoint(tmp, ["true"], {
HERMES_REGISTER_VYNTE_INTERNAL_MCP: "false",
HERMES_REGISTER_EXTERNAL_SAAS_MCP: "false",
HERMES_EXTERNAL_SAAS_MCP_TOKEN: "external-secret",
})
assert.equal(disabled.result.status, 0, disabled.result.stderr)
assert.doesNotMatch(fs.readFileSync(path.join(disabled.hermesHome, "config.yaml"), "utf-8"), /external-saas/)
fs.rmSync(tmp, { recursive: true, force: true })
const enabledTmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-entrypoint-external-saas-mcp-enabled-test-"))
const enabled = runEntrypoint(enabledTmp, ["true"], {
HERMES_REGISTER_VYNTE_INTERNAL_MCP: "false",
HERMES_REGISTER_EXTERNAL_SAAS_MCP: "true",
HERMES_EXTERNAL_SAAS_MCP_TOKEN: "external-secret",
})
assert.equal(enabled.result.status, 0, enabled.result.stderr)
const config = fs.readFileSync(path.join(enabled.hermesHome, "config.yaml"), "utf-8")
assert.match(config, / external-saas:\n url: "http:\/\/hermes-external-saas-mcp:8787\/mcp"/)
assert.match(config, /Authorization: "Bearer \$\{MCP_EXTERNAL_SAAS_API_KEY\}"/)
assert.doesNotMatch(config, /external-secret/)
const env = fs.readFileSync(path.join(enabled.hermesHome, ".env"), "utf-8")
assert.match(env, /MCP_EXTERNAL_SAAS_API_KEY=external-secret/)
fs.rmSync(enabledTmp, { recursive: true, force: true })
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
+24
View File
@@ -0,0 +1,24 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const fs = require("fs")
const path = require("path")
const root = path.join(__dirname, "..")
const dockerfile = fs.readFileSync(path.join(root, "Dockerfile"), "utf8")
test("Docker image installs Hermes with HTTP MCP transport support", () => {
assert.match(
dockerfile,
/pip install --no-cache-dir -e ['"]\/opt\/hermes-agent\[messaging,mcp\]['"]/,
"Hermes must install the mcp extra so URL-mode MCP servers can use streamable HTTP"
)
})
test("Docker image includes Vynte MCP safe-write modules", () => {
assert.match(dockerfile, /vynte-internal-mcp\.cjs/, "Docker image must include the Vynte MCP adapter")
assert.match(dockerfile, /safe-write-policy\.cjs/, "Docker image must include the write policy module")
assert.match(dockerfile, /focused-write\.cjs/, "Docker image must include the focused write transport")
assert.match(dockerfile, /write-audit\.cjs/, "Docker image must include the write audit sink")
})
+165
View File
@@ -0,0 +1,165 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const { exactConfirmation } = require("../safe-write-policy.cjs")
const { focusedWrite } = require("../focused-write.cjs")
const operation = {
name: "vynte_plane_work_item_create",
service: "plane",
method: "POST",
path: "/api/v1/workspaces/vynte/projects/project-1/work-items/",
workspaceSlug: "vynte",
projectId: "project-1",
idempotencyRequired: true,
}
const env = {
VYNTE_MCP_WRITES_ENABLED: "true",
VYNTE_PLANE_ALLOWED_WORKSPACES: "vynte",
VYNTE_PLANE_ALLOWED_PROJECTS: "project-1",
VYNTE_PLANE_ALLOWED_WRITE_PATHS: "/api/v1/workspaces/vynte/projects/project-1/",
}
test("focusedWrite defaults to dry run without making or auditing a request", async () => {
let fetchCalls = 0
let auditCalls = 0
const result = await focusedWrite({
operation,
args: {},
body: { name: "Approved issue" },
env,
baseUrl: "https://plane.example.test",
headers: { "X-API-Key": "secret" },
fetchImpl: async () => { fetchCalls++; throw new Error("must not fetch") },
auditSink: { record: async () => { auditCalls++ } },
})
assert.equal(fetchCalls, 0)
assert.equal(auditCalls, 0)
assert.equal(result.dryRun, true)
assert.equal(result.executed, false)
assert.equal(result.confirmationRequired, exactConfirmation(operation))
assert.deepEqual(result.request.body, { name: "Approved issue" })
})
test("focusedWrite executes generated request, forwards idempotency, caps/redacts response, and audits metadata only", async () => {
let received
let audited
const result = await focusedWrite({
operation,
args: {
dryRun: false,
confirmOperation: exactConfirmation(operation),
reason: "Approved by product owner",
idempotencyKey: "plane-create-001",
},
body: { name: "Approved issue" },
env: { ...env, VYNTE_MCP_WRITE_RESPONSE_LIMIT: "120" },
baseUrl: "https://plane.example.test",
headers: { "X-API-Key": "secret" },
fetchImpl: async (url, options) => {
received = { url: url.toString(), options }
return new Response(JSON.stringify({
id: "item-1",
access_token: "must-not-leak",
description_html: "x".repeat(500),
}), { status: 201, headers: { "content-type": "application/json" } })
},
auditSink: { record: async (entry) => { audited = entry } },
})
assert.equal(received.url, "https://plane.example.test/api/v1/workspaces/vynte/projects/project-1/work-items/")
assert.equal(received.options.method, "POST")
assert.equal(received.options.headers["Idempotency-Key"], "plane-create-001")
assert.deepEqual(JSON.parse(received.options.body), { name: "Approved issue" })
assert.equal(result.executed, true)
assert.equal(result.status, 201)
assert.doesNotMatch(JSON.stringify(result), /must-not-leak/)
assert.equal(audited.payloadDigest.startsWith("sha256:"), true)
assert.equal(Object.hasOwn(audited, "requestBody"), false)
assert.equal(Object.hasOwn(audited, "responseBody"), false)
assert.equal(Object.hasOwn(audited, "reason"), false)
})
test("focusedWrite refuses execution without an audit sink", async () => {
await assert.rejects(
focusedWrite({
operation,
args: {
dryRun: false,
confirmOperation: exactConfirmation(operation),
reason: "Approved",
idempotencyKey: "plane-create-002",
},
body: { name: "Issue" },
env,
baseUrl: "https://plane.example.test",
headers: {},
fetchImpl: async () => new Response("{}", { status: 200 }),
}),
/audit sink is required/i
)
})
test("focusedWrite executes approval bundle mode without write allowlists and audits approval metadata", async () => {
let received
let audited
const result = await focusedWrite({
operation,
args: {
dryRun: false,
approvalId: "approval-bundle-20260612-001",
approvalSummary: "Create a Plane ticket, assign the owner, and add the approved note.",
reason: "Approved by Zach in Hermes",
idempotencyKey: "plane-create-004",
},
body: { name: "Approved issue" },
env: {
VYNTE_MCP_WRITES_ENABLED: "true",
VYNTE_MCP_WRITE_POLICY: "user_approval_bundle",
},
baseUrl: "https://plane.example.test",
headers: { "X-API-Key": "secret" },
fetchImpl: async (url, options) => {
received = { url: url.toString(), options }
return new Response('{"ok":true}', { status: 201, headers: { "content-type": "application/json" } })
},
auditSink: { record: async (entry) => { audited = entry } },
})
assert.equal(received.url, "https://plane.example.test/api/v1/workspaces/vynte/projects/project-1/work-items/")
assert.equal(result.executed, true)
assert.equal(audited.approvalId, "approval-bundle-20260612-001")
assert.equal(audited.approvalSummaryDigest.startsWith("sha256:"), true)
assert.equal(Object.hasOwn(audited, "approvalSummary"), false)
assert.equal(Object.hasOwn(audited, "reason"), false)
})
test("focusedWrite audits upstream transport failures without bodies", async () => {
let audited
await assert.rejects(
focusedWrite({
operation,
args: {
dryRun: false,
confirmOperation: exactConfirmation(operation),
reason: "Approved",
idempotencyKey: "plane-create-003",
},
body: { name: "Private issue body" },
env,
baseUrl: "https://plane.example.test",
headers: { "X-API-Key": "secret" },
fetchImpl: async () => { throw new Error("upstream unavailable") },
auditSink: { record: async (entry) => { audited = entry } },
}),
/upstream unavailable/
)
assert.equal(audited.status, 0)
assert.equal(audited.executed, true)
assert.equal(Object.hasOwn(audited, "requestBody"), false)
assert.equal(Object.hasOwn(audited, "responseBody"), false)
})
+242
View File
@@ -0,0 +1,242 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const fs = require("fs")
const http = require("http")
const os = require("os")
const path = require("path")
const { createServer } = require("../vynte-internal-mcp.cjs")
const { exactConfirmation } = require("../safe-write-policy.cjs")
function startServer(env = {}) {
return new Promise((resolve) => {
const server = createServer({ env })
server.listen(0, "127.0.0.1", () => resolve({
port: server.address().port,
close: () => new Promise((done) => server.close(done)),
}))
})
}
function startUpstream(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler)
server.listen(0, "127.0.0.1", () => resolve({
url: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((done) => server.close(done)),
}))
})
}
function rpc(port, name, args = {}) {
const body = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: name === "tools/list" ? "tools/list" : "tools/call",
params: name === "tools/list" ? undefined : { name, arguments: args },
})
return new Promise((resolve, reject) => {
const req = http.request({
hostname: "127.0.0.1",
port,
path: "/mcp",
method: "POST",
headers: {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
authorization: "Bearer server-secret",
},
}, (res) => {
const chunks = []
res.on("data", (chunk) => chunks.push(chunk))
res.on("end", () => resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))))
})
req.on("error", reject)
req.end(body)
})
}
function payload(response) {
return JSON.parse(response.result.content[0].text)
}
function planeEnv(url, overrides = {}) {
return {
VYNTE_PLANE_URL: url,
VYNTE_PLANE_TOKEN: "plane-secret",
VYNTE_PLANE_AUTH_HEADER: "X-API-Key",
VYNTE_MCP_SERVER_TOKEN: "server-secret",
VYNTE_PLANE_ALLOWED_WORKSPACES: "vynte",
VYNTE_PLANE_ALLOWED_PROJECTS: "project-1",
VYNTE_PLANE_ALLOWED_WRITE_PATHS: "/api/v1/workspaces/vynte/projects/project-1/",
...overrides,
}
}
test("tools/list exposes focused Plane reads and actions without generic writes or deletes", async (t) => {
const server = await startServer({})
t.after(server.close)
const response = await rpc(server.port, "tools/list")
const names = response.result.tools.map((tool) => tool.name)
for (const name of [
"vynte_plane_states_list",
"vynte_plane_labels_list",
"vynte_plane_project_members_list",
"vynte_plane_work_item_create",
"vynte_plane_work_item_update",
"vynte_plane_work_item_comment",
"vynte_plane_work_item_status_set",
"vynte_plane_work_item_labels_set",
"vynte_plane_work_item_assignees_set",
]) assert(names.includes(name), `missing ${name}`)
assert.equal(names.some((name) => /delete|arbitrary.*write/i.test(name)), false)
for (const tool of response.result.tools.filter((item) => item.name.startsWith("vynte_plane_work_item_"))) {
assert.equal(Object.hasOwn(tool.inputSchema.properties, "body"), false)
assert.equal(Object.hasOwn(tool.inputSchema.properties, "json"), false)
if (tool.inputSchema.properties.fields) {
assert.equal(tool.inputSchema.properties.fields.additionalProperties, false)
}
}
})
test("focused Plane reads use generated allowlisted project paths", async (t) => {
const seen = []
const upstream = await startUpstream((req, res) => {
seen.push(req.url)
res.writeHead(200, { "content-type": "application/json" })
res.end('{"results":[]}')
})
t.after(upstream.close)
const server = await startServer(planeEnv(upstream.url))
t.after(server.close)
for (const tool of ["vynte_plane_states_list", "vynte_plane_labels_list", "vynte_plane_project_members_list"]) {
const response = await rpc(server.port, tool, { workspaceSlug: "vynte", projectId: "project-1" })
assert.equal(payload(response).status, 200)
}
assert.deepEqual(seen, [
"/api/v1/workspaces/vynte/projects/project-1/states/?per_page=100",
"/api/v1/workspaces/vynte/projects/project-1/labels/?per_page=100",
"/api/v1/workspaces/vynte/projects/project-1/project-members/?per_page=100",
])
})
test("Plane create defaults to dry run and rejects arbitrary fields", async (t) => {
let requests = 0
const upstream = await startUpstream((req, res) => {
requests++
res.writeHead(500)
res.end()
})
t.after(upstream.close)
const server = await startServer(planeEnv(upstream.url))
t.after(server.close)
const response = await rpc(server.port, "vynte_plane_work_item_create", {
workspaceSlug: "vynte",
projectId: "project-1",
fields: { name: "Safe issue", priority: "high", labels: ["label-1"] },
})
assert.equal(payload(response).dryRun, true)
assert.equal(requests, 0)
const unsafe = await rpc(server.port, "vynte_plane_work_item_create", {
workspaceSlug: "vynte",
projectId: "project-1",
fields: { name: "Unsafe issue", arbitrary: { nested: true } },
})
assert.equal(unsafe.error.data.code, "unsupported_field")
assert.equal(requests, 0)
})
test("Plane create executes only with exact authority and writes metadata-only audit", async (t) => {
let received
const upstream = await startUpstream((req, res) => {
const chunks = []
req.on("data", (chunk) => chunks.push(chunk))
req.on("end", () => {
received = { method: req.method, url: req.url, headers: req.headers, body: JSON.parse(Buffer.concat(chunks).toString("utf8")) }
res.writeHead(201, { "content-type": "application/json" })
res.end('{"id":"item-1","token":"must-not-leak"}')
})
})
t.after(upstream.close)
const auditPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "plane-audit-")), "writes.jsonl")
const env = planeEnv(upstream.url, {
VYNTE_MCP_WRITES_ENABLED: "true",
VYNTE_MCP_WRITE_AUDIT_PATH: auditPath,
})
const server = await startServer(env)
t.after(server.close)
const operation = {
name: "vynte_plane_work_item_create",
method: "POST",
path: "/api/v1/workspaces/vynte/projects/project-1/work-items/",
}
const response = await rpc(server.port, operation.name, {
workspaceSlug: "vynte",
projectId: "project-1",
fields: { name: "Safe issue", description_html: "private description" },
dryRun: false,
confirmOperation: exactConfirmation(operation),
reason: "Approved product work",
idempotencyKey: "plane-create-001",
})
assert.equal(received.method, "POST")
assert.equal(received.url, operation.path)
assert.equal(received.headers["idempotency-key"], "plane-create-001")
assert.deepEqual(received.body, { name: "Safe issue", description_html: "private description" })
assert.doesNotMatch(JSON.stringify(payload(response)), /must-not-leak/)
const audit = fs.readFileSync(auditPath, "utf8")
assert.match(audit, /vynte_plane_work_item_create/)
assert.doesNotMatch(audit, /private description|Approved product work|plane-create-001|must-not-leak|plane-secret/)
})
test("Plane update, comment, status, labels, and assignees generate only their fixed methods, paths, and bodies", async (t) => {
const seen = []
const upstream = await startUpstream((req, res) => {
const chunks = []
req.on("data", (chunk) => chunks.push(chunk))
req.on("end", () => {
seen.push({ method: req.method, url: req.url, body: JSON.parse(Buffer.concat(chunks).toString("utf8")) })
res.writeHead(200, { "content-type": "application/json" })
res.end('{"ok":true}')
})
})
t.after(upstream.close)
const auditPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "plane-audit-")), "writes.jsonl")
const server = await startServer(planeEnv(upstream.url, {
VYNTE_MCP_WRITES_ENABLED: "true",
VYNTE_MCP_WRITE_AUDIT_PATH: auditPath,
}))
t.after(server.close)
const common = { workspaceSlug: "vynte", projectId: "project-1", workItemId: "item-1", dryRun: false, reason: "Approved change" }
const actions = [
["vynte_plane_work_item_update", "PATCH", "/api/v1/workspaces/vynte/projects/project-1/work-items/item-1/", { fields: { name: "Renamed" } }],
["vynte_plane_work_item_comment", "POST", "/api/v1/workspaces/vynte/projects/project-1/work-items/item-1/comments/", { commentHtml: "Approved comment" }],
["vynte_plane_work_item_status_set", "PATCH", "/api/v1/workspaces/vynte/projects/project-1/work-items/item-1/", { stateId: "state-1" }],
["vynte_plane_work_item_labels_set", "PATCH", "/api/v1/workspaces/vynte/projects/project-1/work-items/item-1/", { labelIds: ["label-1", "label-2"] }],
["vynte_plane_work_item_assignees_set", "PATCH", "/api/v1/workspaces/vynte/projects/project-1/work-items/item-1/", { assigneeIds: ["member-1"] }],
]
for (const [name, method, actionPath, extra] of actions) {
const response = await rpc(server.port, name, {
...common,
...extra,
idempotencyKey: `${name}-001`,
confirmOperation: exactConfirmation({ name, method, path: actionPath }),
})
assert.equal(payload(response).executed, true)
}
assert.deepEqual(seen, [
{ method: "PATCH", url: actions[0][2], body: { name: "Renamed" } },
{ method: "POST", url: actions[1][2], body: { comment_html: "Approved comment" } },
{ method: "PATCH", url: actions[2][2], body: { state: "state-1" } },
{ method: "PATCH", url: actions[3][2], body: { labels: ["label-1", "label-2"] } },
{ method: "PATCH", url: actions[4][2], body: { assignees: ["member-1"] } },
])
})
+189
View File
@@ -0,0 +1,189 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const fs = require("fs")
const os = require("os")
const path = require("path")
const {
McpError,
createWritePolicy,
exactConfirmation,
redactAndCap,
sanitizeFields,
} = require("../safe-write-policy.cjs")
const { createJsonlAuditSink } = require("../write-audit.cjs")
const operation = {
name: "vynte_plane_work_item_create",
method: "POST",
path: "/api/v1/workspaces/vynte/projects/project-1/work-items/",
workspaceSlug: "vynte",
projectId: "project-1",
idempotencyRequired: true,
}
function enabledEnv(overrides = {}) {
return {
VYNTE_MCP_WRITES_ENABLED: "true",
VYNTE_PLANE_ALLOWED_WORKSPACES: "vynte",
VYNTE_PLANE_ALLOWED_PROJECTS: "project-1",
VYNTE_PLANE_ALLOWED_WRITE_PATHS: "/api/v1/workspaces/vynte/projects/project-1/",
...overrides,
}
}
test("write policy defaults to dry run and returns exact confirmation", () => {
const result = createWritePolicy(operation, {}, enabledEnv())
assert.equal(result.execute, false)
assert.equal(result.dryRun, true)
assert.equal(result.confirmationRequired, exactConfirmation(operation))
})
test("execution requires writes gate, exact confirmation, reason, and idempotency key", () => {
const valid = {
dryRun: false,
confirmOperation: exactConfirmation(operation),
reason: "Create the approved work item",
idempotencyKey: "plane-create-2026-06-11-001",
}
assert.throws(
() => createWritePolicy(operation, valid, enabledEnv({ VYNTE_MCP_WRITES_ENABLED: undefined })),
(err) => err instanceof McpError && err.code === "writes_disabled"
)
assert.throws(
() => createWritePolicy(operation, { ...valid, confirmOperation: operation.name }, enabledEnv()),
(err) => err instanceof McpError && err.code === "confirmation_mismatch"
)
assert.throws(
() => createWritePolicy(operation, { ...valid, reason: "" }, enabledEnv()),
(err) => err instanceof McpError && err.code === "missing_reason"
)
assert.throws(
() => createWritePolicy(operation, { ...valid, idempotencyKey: "" }, enabledEnv()),
(err) => err instanceof McpError && err.code === "missing_idempotency_key"
)
assert.equal(createWritePolicy(operation, valid, enabledEnv()).execute, true)
})
test("write policy enforces workspace, project, and generated path allowlists", () => {
const args = {
dryRun: false,
confirmOperation: exactConfirmation(operation),
reason: "Approved",
idempotencyKey: "key-1",
}
for (const [key, value, code] of [
["VYNTE_PLANE_ALLOWED_WORKSPACES", "other", "workspace_not_allowed"],
["VYNTE_PLANE_ALLOWED_PROJECTS", "other", "project_not_allowed"],
["VYNTE_PLANE_ALLOWED_WRITE_PATHS", "/other/", "write_path_not_allowed"],
]) {
assert.throws(
() => createWritePolicy(operation, args, enabledEnv({ [key]: value })),
(err) => err instanceof McpError && err.code === code
)
}
})
test("approval-bundle policy bypasses service allowlists only with explicit approval metadata", () => {
const args = {
dryRun: false,
approvalId: "approval-bundle-20260612-001",
approvalSummary: "Create a Plane ticket, assign the owner, and add the approved note.",
reason: "Approved by Zach in Hermes",
idempotencyKey: "plane-create-2026-06-12-001",
}
const env = enabledEnv({
VYNTE_MCP_WRITE_POLICY: "user_approval_bundle",
VYNTE_PLANE_ALLOWED_WORKSPACES: "",
VYNTE_PLANE_ALLOWED_PROJECTS: "",
VYNTE_PLANE_ALLOWED_WRITE_PATHS: "",
})
const policy = createWritePolicy(operation, args, env)
assert.equal(policy.execute, true)
assert.equal(policy.approvalId, "approval-bundle-20260612-001")
for (const [field, value, code] of [
["approvalId", "", "missing_approval_id"],
["approvalSummary", "short", "missing_approval_summary"],
["reason", "", "missing_reason"],
]) {
assert.throws(
() => createWritePolicy(operation, { ...args, [field]: value }, env),
(err) => err instanceof McpError && err.code === code
)
}
})
test("sanitizeFields rejects unknown fields, invalid enums, oversized arrays, and nested arbitrary JSON", () => {
const schema = {
name: { type: "string", maxLength: 80 },
priority: { type: "enum", values: ["none", "low", "medium", "high", "urgent"] },
label_ids: { type: "array", maxItems: 3, itemType: "id" },
}
assert.deepEqual(sanitizeFields({ name: "Issue", priority: "high", label_ids: ["a", "b"] }, schema), {
name: "Issue",
priority: "high",
label_ids: ["a", "b"],
})
assert.throws(() => sanitizeFields({ arbitrary: true }, schema), /unsupported field/i)
assert.throws(() => sanitizeFields({ priority: "critical" }, schema), /invalid priority/i)
assert.throws(() => sanitizeFields({ label_ids: ["a", "b", "c", "d"] }, schema), /too many label_ids/i)
assert.throws(() => sanitizeFields({ name: { nested: true } }, schema), /invalid name/i)
})
test("redactAndCap removes common secrets and caps serialized output", () => {
const value = redactAndCap({
token: "super-secret",
authorization: "Bearer abc123",
ok: "abcdefghijklmnopqrstuvwxyz",
}, 90)
const serialized = JSON.stringify(value)
assert.doesNotMatch(serialized, /super-secret|abc123/)
assert.match(serialized, /\[REDACTED\]/)
assert(serialized.length <= 140)
})
test("redactAndCap removes secret-shaped fields from malformed JSON strings", () => {
const value = redactAndCap('{"token":"must-not-leak","unfinished":"', 200)
assert.doesNotMatch(JSON.stringify(value), /must-not-leak/)
})
test("JSONL audit sink persists metadata and digests without bodies, reasons, or secrets", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-write-audit-"))
const auditPath = path.join(dir, "audit.jsonl")
const sink = createJsonlAuditSink(auditPath)
await sink.record({
operation: operation.name,
service: "plane",
method: "POST",
path: operation.path,
workspaceSlug: "vynte",
projectId: "project-1",
reason: "contains private details",
requestBody: { token: "secret", description_html: "private body" },
responseBody: { access_token: "secret-response" },
payloadDigest: "sha256:abc",
status: 201,
executed: true,
})
const raw = fs.readFileSync(auditPath, "utf8")
const entry = JSON.parse(raw.trim())
assert.equal(entry.operation, operation.name)
assert.equal(entry.payloadDigest, "sha256:abc")
assert.equal(entry.executed, true)
assert.equal(Object.hasOwn(entry, "requestBody"), false)
assert.equal(Object.hasOwn(entry, "responseBody"), false)
assert.equal(Object.hasOwn(entry, "reason"), false)
assert.doesNotMatch(raw, /private|secret/)
})
+22 -7
View File
@@ -3,6 +3,7 @@
const assert = require("assert")
const fs = require("fs")
const http = require("http")
const net = require("net")
const os = require("os")
const path = require("path")
const { spawn } = require("child_process")
@@ -64,6 +65,17 @@ function postJson(url, body) {
})
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.listen(0, "127.0.0.1", () => {
const { port } = server.address()
server.close(() => resolve(port))
})
server.on("error", reject)
})
}
function startStatusServer(t, options) {
const {
hermesHome,
@@ -153,16 +165,17 @@ fi
`)
fs.chmodSync(fakeHermes, 0o755)
const port = await freePort()
const proc = startStatusServer({ after: (fn) => process.once("exit", fn) }, {
hermesHome,
codexHome,
claudeHome: path.join(tmp, ".claude"),
geminiHome: path.join(tmp, ".gemini"),
fakeHermes,
port: 19743,
port,
})
try {
const status = await waitForServer("http://127.0.0.1:19743/api/status", proc)
const status = await waitForServer(`http://127.0.0.1:${port}/api/status`, proc)
const byProvider = Object.fromEntries(status.pools.map((pool) => [pool.provider, pool]))
assert.strictEqual(byProvider["openai-codex"].authState.state, "authenticated")
assert.strictEqual(byProvider["openai-codex"].authState.label, "Authenticated")
@@ -217,16 +230,17 @@ fi
`)
fs.chmodSync(fakeHermes, 0o755)
const port = await freePort()
const proc = startStatusServer({ after: (fn) => process.once("exit", fn) }, {
hermesHome,
codexHome,
claudeHome,
geminiHome,
fakeHermes,
port: 19744,
port,
})
try {
const status = await waitForServer("http://127.0.0.1:19744/api/status", proc)
const status = await waitForServer(`http://127.0.0.1:${port}/api/status`, proc)
const byProvider = Object.fromEntries(status.pools.map((pool) => [pool.provider, pool]))
assert.deepStrictEqual(byProvider["openai-codex"].entries.map((entry) => entry.identity), [
"zach@example.com",
@@ -243,7 +257,7 @@ fi
assert.strictEqual(byProvider.anthropic.authState.state, "authenticated")
assert.strictEqual(byProvider["google-gemini-cli"].authState.state, "authenticated")
const remove = await postJson("http://127.0.0.1:19744/api/auth/remove", {
const remove = await postJson(`http://127.0.0.1:${port}/api/auth/remove`, {
provider: "openai-codex",
index: 2,
source: "mounted-auth",
@@ -285,16 +299,17 @@ fi
`)
fs.chmodSync(fakeHermes, 0o755)
const port = await freePort()
const proc = startStatusServer({ after: (fn) => process.once("exit", fn) }, {
hermesHome,
codexHome,
claudeHome: path.join(tmp, ".claude"),
geminiHome: path.join(tmp, ".gemini"),
fakeHermes,
port: 19745,
port,
})
try {
const status = await waitForServer("http://127.0.0.1:19745/api/status", proc)
const status = await waitForServer(`http://127.0.0.1:${port}/api/status`, proc)
const byProvider = Object.fromEntries(status.pools.map((pool) => [pool.provider, pool]))
assert.strictEqual(byProvider["openai-codex"].authState.state, "usage_limited")
assert.match(byProvider["openai-codex"].authState.label, /Usage limit/i)
+308
View File
@@ -0,0 +1,308 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const fs = require("fs")
const http = require("http")
const os = require("os")
const path = require("path")
const { createServer } = require("../vynte-internal-mcp.cjs")
const { exactConfirmation } = require("../safe-write-policy.cjs")
function startServer(env = {}) {
return new Promise((resolve) => {
const server = createServer({ env })
server.listen(0, "127.0.0.1", () => resolve({
port: server.address().port,
close: () => new Promise((done) => server.close(done)),
}))
})
}
function startUpstream(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler)
server.listen(0, "127.0.0.1", () => resolve({
url: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((done) => server.close(done)),
}))
})
}
function rpc(port, name, args = {}) {
const body = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: name === "tools/list" ? "tools/list" : "tools/call",
params: name === "tools/list" ? undefined : { name, arguments: args },
})
return new Promise((resolve, reject) => {
const req = http.request({
hostname: "127.0.0.1",
port,
path: "/mcp",
method: "POST",
headers: {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
authorization: "Bearer server-secret",
},
}, (res) => {
const chunks = []
res.on("data", (chunk) => chunks.push(chunk))
res.on("end", () => resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))))
})
req.on("error", reject)
req.end(body)
})
}
function payload(response) {
return JSON.parse(response.result.content[0].text)
}
function writeEnv(serviceUrl, overrides = {}) {
return {
VYNTE_MCP_SERVER_TOKEN: "server-secret",
VYNTE_MCP_WRITES_ENABLED: "true",
VYNTE_TWENTY_URL: serviceUrl,
VYNTE_TWENTY_TOKEN: "twenty-secret",
VYNTE_TWENTY_ALLOWED_OBJECTS: "people,companies,tasks,notes",
VYNTE_TWENTY_ALLOWED_WRITE_PATHS: "/rest/people,/rest/companies,/rest/tasks,/rest/notes",
VYNTE_PLUNK_URL: serviceUrl,
VYNTE_PLUNK_TOKEN: "plunk-secret",
VYNTE_PLUNK_ALLOWED_WRITE_PATHS: "/contacts,/campaigns",
VYNTE_PLUNK_ALLOWED_EMAIL_DOMAINS: "vyntehome.com,example.com",
...overrides,
}
}
test("tools/list exposes focused Twenty and Plunk actions without generic writes, deletes, or live campaign send", async (t) => {
const server = await startServer({})
t.after(server.close)
const response = await rpc(server.port, "tools/list")
const names = response.result.tools.map((tool) => tool.name)
for (const name of [
"vynte_twenty_record_create",
"vynte_twenty_record_update",
"vynte_twenty_task_create",
"vynte_twenty_note_create",
"vynte_plunk_contact_upsert",
"vynte_plunk_contact_update",
"vynte_plunk_subscription_set",
"vynte_plunk_campaign_draft_create",
"vynte_plunk_campaign_test_send",
]) assert(names.includes(name), `missing ${name}`)
assert.equal(names.some((name) => /delete|generic.*write|arbitrary.*write|campaign_send$/.test(name)), false)
for (const tool of response.result.tools.filter((item) => /twenty|plunk/.test(item.name) && !/list|get/.test(item.name))) {
assert.equal(Object.hasOwn(tool.inputSchema.properties, "body"), false)
assert.equal(Object.hasOwn(tool.inputSchema.properties, "json"), false)
}
})
test("Twenty create defaults to dry run, validates object and field allowlists, and makes no upstream request", async (t) => {
let requests = 0
const upstream = await startUpstream((req, res) => {
requests++
res.writeHead(500)
res.end()
})
t.after(upstream.close)
const server = await startServer(writeEnv(upstream.url, { VYNTE_MCP_WRITES_ENABLED: "false" }))
t.after(server.close)
const response = await rpc(server.port, "vynte_twenty_record_create", {
object: "people",
fields: { name: "Test Person", email: "person@example.com" },
})
assert.equal(payload(response).dryRun, true)
assert.equal(payload(response).request.path, "/rest/people")
assert.equal(requests, 0)
const unsupportedField = await rpc(server.port, "vynte_twenty_record_create", {
object: "people",
fields: { name: "Test Person", arbitrary: "nope" },
})
assert.equal(unsupportedField.error.data.code, "unsupported_field")
const unsupportedObject = await rpc(server.port, "vynte_twenty_record_create", {
object: "opportunities",
fields: { name: "Deal" },
})
assert.equal(unsupportedObject.error.data.code, "object_not_allowed")
assert.equal(requests, 0)
})
test("Twenty create/update/task/note execute only with exact authority and metadata-only audit", async (t) => {
const seen = []
const upstream = await startUpstream((req, res) => {
const chunks = []
req.on("data", (chunk) => chunks.push(chunk))
req.on("end", () => {
seen.push({ method: req.method, url: req.url, headers: req.headers, body: JSON.parse(Buffer.concat(chunks).toString("utf8")) })
res.writeHead(req.method === "POST" ? 201 : 200, { "content-type": "application/json" })
res.end('{"id":"record-1","token":"must-not-leak"}')
})
})
t.after(upstream.close)
const auditPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "twenty-audit-")), "writes.jsonl")
const server = await startServer(writeEnv(upstream.url, { VYNTE_MCP_WRITE_AUDIT_PATH: auditPath }))
t.after(server.close)
const actions = [
["vynte_twenty_record_create", "POST", "/rest/people", { object: "people", fields: { name: "Private Person", email: "person@example.com" } }],
["vynte_twenty_record_update", "PATCH", "/rest/people/record-1", { object: "people", id: "record-1", fields: { jobTitle: "Founder" } }],
["vynte_twenty_task_create", "POST", "/rest/tasks", { title: "Follow up", description: "private task", assigneeId: "user-1", recordId: "record-1", recordObjectName: "people" }],
["vynte_twenty_note_create", "POST", "/rest/notes", { title: "Call notes", content: "private note", recordId: "record-1", recordObjectName: "people" }],
]
for (const [name, method, actionPath, extra] of actions) {
const response = await rpc(server.port, name, {
...extra,
dryRun: false,
confirmOperation: exactConfirmation({ name, method, path: actionPath }),
reason: "Approved CRM update",
idempotencyKey: `${name}-001`,
})
assert.equal(payload(response).executed, true)
}
assert.deepEqual(seen.map((item) => [item.method, item.url, item.headers.authorization]), [
["POST", "/rest/people", "Bearer twenty-secret"],
["PATCH", "/rest/people/record-1", "Bearer twenty-secret"],
["POST", "/rest/tasks", "Bearer twenty-secret"],
["POST", "/rest/notes", "Bearer twenty-secret"],
])
assert.deepEqual(seen[2].body, { title: "Follow up", body: "private task", assigneeId: "user-1", recordId: "record-1", recordObjectName: "people" })
const audit = fs.readFileSync(auditPath, "utf8")
assert.match(audit, /vynte_twenty_record_create/)
assert.doesNotMatch(audit, /Private Person|private task|private note|Approved CRM update|twenty-secret|must-not-leak/)
})
test("Twenty approval-bundle writes do not require object or path allowlists but still require audit", async (t) => {
let requests = 0
const upstream = await startUpstream((req, res) => {
requests++
res.writeHead(201, { "content-type": "application/json" })
res.end('{"id":"person-1"}')
})
t.after(upstream.close)
const auditPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "twenty-audit-")), "writes.jsonl")
const server = await startServer(writeEnv(upstream.url, {
VYNTE_MCP_WRITE_POLICY: "user_approval_bundle",
VYNTE_TWENTY_ALLOWED_OBJECTS: "",
VYNTE_TWENTY_ALLOWED_WRITE_PATHS: "",
VYNTE_MCP_WRITE_AUDIT_PATH: auditPath,
}))
t.after(server.close)
const approved = await rpc(server.port, "vynte_twenty_record_create", {
object: "people",
fields: { name: "Private Person" },
dryRun: false,
approvalId: "approval-bundle-20260612-002",
approvalSummary: "Create the CRM person requested in Hermes.",
reason: "Approved by Zach in Hermes",
idempotencyKey: "twenty-create-approval-001",
})
assert.equal(payload(approved).executed, true)
assert.equal(requests, 1)
assert.match(fs.readFileSync(auditPath, "utf8"), /approval-bundle-20260612-002/)
const missingAudit = await startServer(writeEnv(upstream.url, {
VYNTE_MCP_WRITE_POLICY: "user_approval_bundle",
VYNTE_TWENTY_ALLOWED_OBJECTS: "",
VYNTE_TWENTY_ALLOWED_WRITE_PATHS: "",
VYNTE_MCP_WRITE_AUDIT_PATH: "",
}))
t.after(missingAudit.close)
const blocked = await rpc(missingAudit.port, "vynte_twenty_record_create", {
object: "people",
fields: { name: "Private Person" },
dryRun: false,
approvalId: "approval-bundle-20260612-003",
approvalSummary: "Create the CRM person requested in Hermes.",
reason: "Approved by Zach in Hermes",
idempotencyKey: "twenty-create-approval-002",
})
assert.equal(blocked.error.data.code, "audit_required")
assert.equal(requests, 1)
})
test("Plunk contact and campaign actions are dry-run by default and enforce email-domain allowlists", async (t) => {
let requests = 0
const upstream = await startUpstream((req, res) => {
requests++
res.writeHead(500)
res.end()
})
t.after(upstream.close)
const server = await startServer(writeEnv(upstream.url, { VYNTE_MCP_WRITES_ENABLED: "false" }))
t.after(server.close)
const dryRun = await rpc(server.port, "vynte_plunk_contact_upsert", {
email: "lead@example.com",
firstName: "Lead",
subscribed: true,
})
assert.equal(payload(dryRun).dryRun, true)
assert.equal(payload(dryRun).request.path, "/contacts")
assert.equal(requests, 0)
const blockedDomain = await rpc(server.port, "vynte_plunk_contact_upsert", {
email: "lead@blocked.test",
firstName: "Lead",
})
assert.equal(blockedDomain.error.data.code, "email_domain_not_allowed")
assert.equal(requests, 0)
})
test("Plunk update/subscription/draft/test execute fixed paths, keep emails out of audit paths, and do not expose real send", async (t) => {
const seen = []
const upstream = await startUpstream((req, res) => {
const chunks = []
req.on("data", (chunk) => chunks.push(chunk))
req.on("end", () => {
seen.push({ method: req.method, url: req.url, headers: req.headers, body: JSON.parse(Buffer.concat(chunks).toString("utf8")) })
res.writeHead(200, { "content-type": "application/json" })
res.end('{"ok":true,"apiKey":"must-not-leak"}')
})
})
t.after(upstream.close)
const auditPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "plunk-audit-")), "writes.jsonl")
const server = await startServer(writeEnv(upstream.url, { VYNTE_MCP_WRITE_AUDIT_PATH: auditPath }))
t.after(server.close)
const actions = [
["vynte_plunk_contact_update", "PATCH", "/contacts", { email: "lead@example.com", firstName: "Lead", company: "Vynte" }],
["vynte_plunk_subscription_set", "PATCH", "/contacts/subscription", { email: "lead@example.com", subscribed: false }],
["vynte_plunk_campaign_draft_create", "POST", "/campaigns", { name: "June draft", subject: "Subject", html: "<p>Private</p>", from: "team@vyntehome.com" }],
["vynte_plunk_campaign_test_send", "POST", "/campaigns/campaign-1/test", { campaignId: "campaign-1", emails: ["lead@example.com"] }],
]
for (const [name, method, actionPath, extra] of actions) {
const response = await rpc(server.port, name, {
...extra,
dryRun: false,
confirmOperation: exactConfirmation({ name, method, path: actionPath }),
reason: "Approved marketing update",
idempotencyKey: `${name}-001`,
})
assert.equal(payload(response).executed, true)
}
assert.deepEqual(seen.map((item) => [item.method, item.url, item.headers.authorization]), [
["PATCH", "/contacts", "Bearer plunk-secret"],
["PATCH", "/contacts/subscription", "Bearer plunk-secret"],
["POST", "/campaigns", "Bearer plunk-secret"],
["POST", "/campaigns/campaign-1/test", "Bearer plunk-secret"],
])
assert.deepEqual(seen[1].body, { email: "lead@example.com", subscribed: false })
assert.deepEqual(seen[2].body.status, "draft")
const audit = fs.readFileSync(auditPath, "utf8")
assert.match(audit, /vynte_plunk_campaign_test_send/)
assert.doesNotMatch(audit, /lead@example\.com|Private|Approved marketing update|plunk-secret|must-not-leak/)
})
+522
View File
@@ -0,0 +1,522 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const http = require("http")
const { createServer } = require("../vynte-internal-mcp.cjs")
function withEnv(overrides, fn) {
const previous = {}
for (const key of Object.keys(overrides)) {
previous[key] = process.env[key]
if (overrides[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = overrides[key]
}
}
return Promise.resolve()
.then(fn)
.finally(() => {
for (const key of Object.keys(overrides)) {
if (previous[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = previous[key]
}
}
})
}
function startServer(env = {}) {
return withEnv(env, () => new Promise((resolve) => {
const server = createServer()
server.listen(0, "127.0.0.1", () => {
const { port } = server.address()
resolve({
port,
close: () => new Promise((res) => server.close(res)),
})
})
}))
}
function startUpstream(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler)
server.listen(0, "127.0.0.1", () => {
const { port } = server.address()
resolve({
url: `http://127.0.0.1:${port}`,
close: () => new Promise((res) => server.close(res)),
})
})
})
}
function requestJson({ port, method = "GET", path = "/healthz", body, headers = {} }) {
return new Promise((resolve, reject) => {
const payload = body === undefined ? undefined : JSON.stringify(body)
const req = http.request({
hostname: "127.0.0.1",
port,
path,
method,
headers: payload
? {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
...headers,
}
: headers,
}, (res) => {
const chunks = []
res.on("data", (chunk) => chunks.push(chunk))
res.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf8")
let parsed
try {
parsed = raw ? JSON.parse(raw) : null
} catch {
parsed = raw
}
resolve({ status: res.statusCode, body: parsed, raw, headers: res.headers })
})
res.on("error", reject)
})
req.on("error", reject)
if (payload) req.write(payload)
req.end()
})
}
function rpc(port, method, params = undefined, headers = {}) {
return requestJson({
port,
method: "POST",
path: "/mcp",
headers,
body: {
jsonrpc: "2.0",
id: 1,
method,
params,
},
})
}
function withTimeout(promise, ms) {
let timeout
const timer = new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)
})
return Promise.race([promise, timer]).finally(() => clearTimeout(timeout))
}
test("GET /healthz returns service health JSON", async (t) => {
const server = await startServer()
t.after(server.close)
const res = await requestJson({ port: server.port })
assert.equal(res.status, 200)
assert.deepEqual(res.body, { ok: true, service: "vynte-internal-mcp" })
})
test("tools/list exposes the Vynte MCP tools", async (t) => {
const server = await startServer()
t.after(server.close)
const res = await rpc(server.port, "tools/list")
assert.equal(res.status, 200)
assert.equal(res.body.jsonrpc, "2.0")
assert.equal(res.body.id, 1)
const toolNames = res.body.result.tools.map((tool) => tool.name)
for (const name of [
"vynte_api_request_readonly",
"vynte_cal_bookings_list",
"vynte_cal_event_types_list",
"vynte_cal_me_get",
"vynte_cal_slots_list",
"vynte_plane_projects_list",
"vynte_plane_work_item_schema_get",
"vynte_plane_work_items_list",
"vynte_plunk_list_campaigns",
"vynte_plunk_list_contacts",
"vynte_service_health_check",
"vynte_services_list",
"vynte_twenty_get_record",
"vynte_twenty_list_records",
]) {
assert(toolNames.includes(name), `missing tool ${name}`)
}
assert(!toolNames.includes("vynte_forms_list_forms"))
assert(!toolNames.includes("vynte_forms_list_submissions"))
})
test("every focused write tool advertises approval bundle metadata", async (t) => {
const server = await startServer()
t.after(server.close)
const res = await rpc(server.port, "tools/list")
const writeTools = res.body.result.tools.filter((tool) =>
/_(?:create|update|comment|set|cancel|reschedule|confirm|decline|test_send)$/.test(tool.name)
)
assert(writeTools.length > 0)
for (const tool of writeTools) {
for (const field of ["dryRun", "approvalId", "approvalSummary", "reason", "idempotencyKey"]) {
assert(field in tool.inputSchema.properties, `${tool.name} must advertise ${field}`)
}
}
})
test("initialize returns MCP server capabilities", async (t) => {
const server = await startServer()
t.after(server.close)
const res = await rpc(server.port, "initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
})
assert.equal(res.status, 200)
assert.equal(res.body.result.serverInfo.name, "vynte-internal-mcp")
assert.deepEqual(res.body.result.capabilities, { tools: {} })
})
test("POST /mcp requires bearer auth when VYNTE_MCP_SERVER_TOKEN is set", async (t) => {
const server = await startServer({
VYNTE_MCP_SERVER_TOKEN: "server-secret",
})
t.after(server.close)
const missing = await rpc(server.port, "tools/list")
assert.equal(missing.status, 401)
assert.equal(missing.body.error, "unauthorized")
assert.doesNotMatch(missing.raw, /server-secret/)
const invalid = await rpc(server.port, "tools/list", undefined, {
Authorization: "Bearer wrong-secret",
})
assert.equal(invalid.status, 401)
assert.equal(invalid.body.error, "unauthorized")
assert.doesNotMatch(invalid.raw, /server-secret/)
const valid = await rpc(server.port, "tools/list", undefined, {
Authorization: "Bearer server-secret",
})
assert.equal(valid.status, 200)
assert(valid.body.result.tools.length > 3)
assert.doesNotMatch(valid.raw, /server-secret/)
})
test("server refuses service credentials without MCP bearer auth", async () => {
await assert.rejects(
startServer({
VYNTE_FORMS_TOKEN: "forms-secret",
}),
/VYNTE_MCP_SERVER_TOKEN is required/
)
})
test("vynte_services_list returns env-overridden catalog entries without tokens", async (t) => {
const server = await startServer({
VYNTE_FORMS_URL: "https://forms.test.internal",
VYNTE_FORMS_TOKEN: "secret-token",
VYNTE_MCP_SERVER_TOKEN: "server-secret",
})
t.after(server.close)
const res = await rpc(server.port, "tools/call", {
name: "vynte_services_list",
arguments: {},
}, {
Authorization: "Bearer server-secret",
})
assert.equal(res.status, 200)
const payload = JSON.parse(res.body.result.content[0].text)
const forms = payload.services.find((service) => service.name === "forms")
assert.equal(forms.baseUrl, "https://forms.test.internal")
assert.equal(forms.authConfigured, true)
assert.equal(Object.hasOwn(forms, "token"), false)
assert.equal(Object.hasOwn(forms, "authHeaderValue"), false)
})
test("vynte_service_health_check blocks arbitrary non-catalog URLs by default", async (t) => {
const server = await startServer()
t.after(server.close)
const res = await rpc(server.port, "tools/call", {
name: "vynte_service_health_check",
arguments: {
url: "http://example.com",
},
})
assert.equal(res.status, 200)
assert.equal(res.body.error.code, -32000)
assert.match(res.body.error.message, /unsafe origin/i)
})
test("vynte_service_health_check blocks alternate ports on catalog hosts", async (t) => {
const allowed = await startUpstream((req, res) => {
res.writeHead(200)
res.end()
})
t.after(allowed.close)
const alternate = await startUpstream((req, res) => {
res.writeHead(200)
res.end()
})
t.after(alternate.close)
const server = await startServer({
VYNTE_HERMES_URL: allowed.url,
})
t.after(server.close)
const res = await rpc(server.port, "tools/call", {
name: "vynte_service_health_check",
arguments: {
url: alternate.url,
},
})
assert.equal(res.status, 200)
assert.equal(res.body.error.code, -32000)
assert.match(res.body.error.message, /unsafe origin/i)
})
test("vynte_api_request_readonly blocks non-read methods", async (t) => {
const server = await startServer({
VYNTE_HERMES_URL: "http://hermes.internal.vyntehome.com",
})
t.after(server.close)
const res = await rpc(server.port, "tools/call", {
name: "vynte_api_request_readonly",
arguments: {
service: "hermes",
path: "/api/status",
method: "POST",
},
})
assert.equal(res.status, 200)
assert.equal(res.body.error.code, -32000)
assert.match(res.body.error.message, /unsupported method/i)
})
test("vynte_api_request_readonly attaches configured token and caps text preview", async (t) => {
const upstream = await startUpstream((req, res) => {
assert.equal(req.method, "GET")
assert.equal(req.url, "/api/example")
assert.equal(req.headers.authorization, "Bearer forms-secret")
res.writeHead(200, { "Content-Type": "text/plain" })
res.end("abcdef")
})
t.after(upstream.close)
const server = await startServer({
VYNTE_FORMS_URL: upstream.url,
VYNTE_FORMS_TOKEN: "forms-secret",
VYNTE_MCP_PREVIEW_LIMIT: "4",
VYNTE_MCP_SERVER_TOKEN: "server-secret",
})
t.after(server.close)
const res = await rpc(server.port, "tools/call", {
name: "vynte_api_request_readonly",
arguments: {
service: "forms",
path: "/api/example",
method: "GET",
},
}, {
Authorization: "Bearer server-secret",
})
assert.equal(res.status, 200)
const payload = JSON.parse(res.body.result.content[0].text)
assert.equal(payload.status, 200)
assert.equal(payload.preview, "abcd")
assert.equal(payload.truncated, true)
})
test("vynte_api_request_readonly caps streaming previews without waiting for full body", async (t) => {
const upstream = await startUpstream((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" })
res.write("abcdef")
})
t.after(upstream.close)
const server = await startServer({
VYNTE_HERMES_URL: upstream.url,
VYNTE_MCP_PREVIEW_LIMIT: "4",
VYNTE_MCP_TIMEOUT_MS: "200",
})
t.after(server.close)
const res = await withTimeout(rpc(server.port, "tools/call", {
name: "vynte_api_request_readonly",
arguments: {
service: "hermes",
path: "/api/stream",
method: "GET",
},
}), 1000)
assert.equal(res.status, 200)
const payload = JSON.parse(res.body.result.content[0].text)
assert.equal(payload.status, 200)
assert.equal(payload.preview, "abcd")
assert.equal(payload.truncated, true)
})
test("vynte_plane_projects_list uses Plane X-API-Key auth and caps pagination", async (t) => {
const upstream = await startUpstream((req, res) => {
assert.equal(req.method, "GET")
assert.equal(req.url, "/api/v1/workspaces/vynte/projects/?per_page=100&cursor=next")
assert.equal(req.headers["x-api-key"], "plane-secret")
assert.equal(req.headers.authorization, undefined)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ results: [] }))
})
t.after(upstream.close)
const server = await startServer({
VYNTE_PLANE_URL: upstream.url,
VYNTE_PLANE_TOKEN: "plane-secret",
VYNTE_PLANE_AUTH_HEADER: "X-API-Key",
VYNTE_MCP_SERVER_TOKEN: "server-secret",
})
t.after(server.close)
const res = await rpc(server.port, "tools/call", {
name: "vynte_plane_projects_list",
arguments: { workspaceSlug: "vynte", perPage: 500, cursor: "next" },
}, { Authorization: "Bearer server-secret" })
assert.equal(res.status, 200)
const payload = JSON.parse(res.body.result.content[0].text)
assert.equal(payload.service, "plane")
assert.equal(payload.status, 200)
assert.doesNotMatch(res.raw, /plane-secret/)
})
test("vynte_cal_bookings_list sends Cal API version header", async (t) => {
const upstream = await startUpstream((req, res) => {
assert.equal(req.method, "GET")
assert.equal(req.url, "/v2/bookings?status=upcoming&sortStart=asc")
assert.equal(req.headers.authorization, "Bearer cal-secret")
assert.equal(req.headers["cal-api-version"], "2026-05-01")
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ data: [] }))
})
t.after(upstream.close)
const server = await startServer({
VYNTE_CAL_URL: upstream.url,
VYNTE_CAL_TOKEN: "cal-secret",
VYNTE_MCP_SERVER_TOKEN: "server-secret",
})
t.after(server.close)
const res = await rpc(server.port, "tools/call", {
name: "vynte_cal_bookings_list",
arguments: { status: "upcoming", sortStart: "asc" },
}, { Authorization: "Bearer server-secret" })
assert.equal(res.status, 200)
const payload = JSON.parse(res.body.result.content[0].text)
assert.equal(payload.service, "cal")
assert.equal(payload.status, 200)
})
test("vynte_cal_bookings_list rejects unsupported status", async (t) => {
const server = await startServer()
t.after(server.close)
const res = await rpc(server.port, "tools/call", {
name: "vynte_cal_bookings_list",
arguments: { status: "deleted" },
})
assert.equal(res.status, 200)
assert.equal(res.body.error.code, -32000)
assert.match(res.body.error.message, /unsupported cal booking status/i)
})
test("vynte_twenty_list_records allowlists object names and serializes query", async (t) => {
const upstream = await startUpstream((req, res) => {
assert.equal(req.method, "GET")
assert.equal(req.url, "/rest/people?limit=100&depth=2&filter=%7B%22name%22%3A%22Zach%22%7D")
assert.equal(req.headers.authorization, "Bearer twenty-secret")
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ data: [] }))
})
t.after(upstream.close)
const server = await startServer({
VYNTE_TWENTY_URL: upstream.url,
VYNTE_TWENTY_TOKEN: "twenty-secret",
VYNTE_MCP_SERVER_TOKEN: "server-secret",
})
t.after(server.close)
const res = await rpc(server.port, "tools/call", {
name: "vynte_twenty_list_records",
arguments: { object: "people", limit: 250, depth: 2, filter: { name: "Zach" } },
}, { Authorization: "Bearer server-secret" })
assert.equal(res.status, 200)
const payload = JSON.parse(res.body.result.content[0].text)
assert.equal(payload.service, "twenty")
})
test("vynte_twenty_get_record rejects unsafe object names", async (t) => {
const server = await startServer()
t.after(server.close)
const res = await rpc(server.port, "tools/call", {
name: "vynte_twenty_get_record",
arguments: { object: "../people", id: "record-id" },
})
assert.equal(res.status, 200)
assert.equal(res.body.error.code, -32000)
assert.match(res.body.error.message, /unsupported twenty object/i)
})
test("vynte_plunk_list_contacts sends Plunk bearer token and cursor", async (t) => {
const upstream = await startUpstream((req, res) => {
assert.equal(req.method, "GET")
assert.equal(req.url, "/contacts?search=hvac&limit=100&cursor=abc")
assert.equal(req.headers.authorization, "Bearer plunk-secret")
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ data: [] }))
})
t.after(upstream.close)
const server = await startServer({
VYNTE_PLUNK_URL: upstream.url,
VYNTE_PLUNK_TOKEN: "plunk-secret",
VYNTE_MCP_SERVER_TOKEN: "server-secret",
})
t.after(server.close)
const res = await rpc(server.port, "tools/call", {
name: "vynte_plunk_list_contacts",
arguments: { search: "hvac", limit: 500, cursor: "abc" },
}, { Authorization: "Bearer server-secret" })
assert.equal(res.status, 200)
const payload = JSON.parse(res.body.result.content[0].text)
assert.equal(payload.service, "plunk")
assert.equal(payload.status, 200)
})
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
"use strict"
const fs = require("fs/promises")
const crypto = require("crypto")
const AUDIT_FIELDS = [
"operation",
"service",
"method",
"path",
"workspaceSlug",
"projectId",
"workItemId",
"payloadDigest",
"status",
"executed",
"dryRun",
"idempotencyKeyDigest",
"approvalPolicy",
"approvalId",
"approvalSummaryDigest",
"approvalReasonDigest",
]
function createAuditEntry(event) {
const entry = {
timestamp: new Date().toISOString(),
eventId: crypto.randomUUID(),
}
for (const field of AUDIT_FIELDS) {
if (event[field] !== undefined) entry[field] = event[field]
}
return entry
}
function createJsonlAuditSink(filePath) {
if (!filePath) throw new Error("Audit file path is required")
return {
async record(event) {
await fs.appendFile(filePath, `${JSON.stringify(createAuditEntry(event))}\n`, { mode: 0o600 })
},
}
}
function createNoopAuditSink() {
return { record: async () => {} }
}
module.exports = {
createAuditEntry,
createJsonlAuditSink,
createNoopAuditSink,
}