Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 366d5abd03 | |||
| 4f10a4f0e3 | |||
| 2e46b2f475 | |||
| 5fcf7c0867 | |||
| b12c0874d7 | |||
| 52754af830 | |||
| bb50909f81 | |||
| c2001ef87b |
+86
-1
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"/)
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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'")))
|
||||
})
|
||||
@@ -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
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"] } },
|
||||
])
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user