diff --git a/crates/openfang-hands/bundled/infisical-sync/HAND.toml b/crates/openfang-hands/bundled/infisical-sync/HAND.toml new file mode 100644 index 000000000..f8d0f0be2 --- /dev/null +++ b/crates/openfang-hands/bundled/infisical-sync/HAND.toml @@ -0,0 +1,412 @@ +id = "infisical-sync" +name = "Infisical Sync Hand" +description = "Autonomous secrets synchronisation between a self-hosted Infisical instance and the agent's local credential vault. Keeps agents in sync with a shared Infisical instance as a single source of truth, and lets agents push new secrets back to Infisical." +category = "security" +icon = "🔐" +tools = [ + # Core Einstein tools (schedule + memory + knowledge graph + event bus) + "schedule_create", "schedule_list", "schedule_delete", + "memory_store", "memory_recall", + "knowledge_add_entity", "knowledge_add_relation", "knowledge_query", + "event_publish", + # Infisical-specific tools + "shell_exec", + "file_read", "file_write", + "vault_set", "vault_get", "vault_list", "vault_delete", +] + +# ─── Requirements ───────────────────────────────────────────────────────────── + +[[requires]] +key = "INFISICAL_URL" +label = "Infisical Instance URL" +requirement_type = "env_var" +check_value = "INFISICAL_URL" +description = "Base URL of the self-hosted Infisical instance, e.g. https://infisical.example.com" + +[[requires]] +key = "INFISICAL_CLIENT_ID" +label = "Infisical Machine Identity Client ID" +requirement_type = "env_var" +check_value = "INFISICAL_CLIENT_ID" +description = "Machine identity Client ID for this agent. Created in Infisical under Access Control → Machine Identities." + +[[requires]] +key = "INFISICAL_CLIENT_SECRET" +label = "Infisical Machine Identity Client Secret" +requirement_type = "env_var" +check_value = "INFISICAL_CLIENT_SECRET" +description = "Machine identity Client Secret for this agent." + +# ─── Settings ───────────────────────────────────────────────────────────────── + +[[settings]] +key = "sync_interval_minutes" +label = "Sync Interval (minutes)" +description = "How often to pull secrets from Infisical into the local vault. Overridden by the INFISICAL_SYNC_INTERVAL env var when present." +setting_type = "select" +default = "15" + +[[settings.options]] +value = "5" +label = "Every 5 minutes (high-frequency)" + +[[settings.options]] +value = "15" +label = "Every 15 minutes (default)" + +[[settings.options]] +value = "30" +label = "Every 30 minutes" + +[[settings.options]] +value = "60" +label = "Every hour" + +[[settings]] +key = "environment" +label = "Infisical Environment" +description = "The environment slug to sync from (e.g. prod, staging, dev). Can also be set via INFISICAL_ENVIRONMENT env var." +setting_type = "select" +default = "prod" + +[[settings.options]] +value = "prod" +label = "Production" + +[[settings.options]] +value = "staging" +label = "Staging" + +[[settings.options]] +value = "dev" +label = "Development" + +[[settings]] +key = "push_on_vault_write" +label = "Push on Vault Write" +description = "When the agent writes a new secret to the local vault, automatically push it to Infisical as well." +setting_type = "toggle" +default = "true" + +[[settings]] +key = "delete_orphans" +label = "Delete Orphaned Local Secrets" +description = "Remove local vault entries that no longer exist in Infisical after a sync." +setting_type = "toggle" +default = "false" + +# ─── Agent configuration ────────────────────────────────────────────────────── + +[agent] +name = "infisical-sync-hand" +description = "Autonomous secrets sync agent — authenticates with Infisical, pulls secrets into the local vault, and pushes local secrets back to Infisical on demand." +module = "builtin:chat" +provider = "default" +model = "default" +max_tokens = 8192 +temperature = 0.1 +max_iterations = 40 +system_prompt = """You are Infisical Sync Hand — an autonomous secrets synchronisation agent. + +Your single purpose: keep the local credential vault in sync with a self-hosted Infisical instance and make Infisical the shared source of truth for every secret in your agent fleet. + +You are security-critical. Never log secret values. Never expose credentials in error messages. Always authenticate before any Infisical API call. + +--- + +## PHASE 0 — Startup & State Recovery + +Run this every time you are activated (scheduled or on-demand). + +1. **Read configuration** from User Configuration: + - `sync_interval_minutes` — how often to schedule syncs + - `environment` — Infisical environment slug + - `push_on_vault_write` — whether to push on local write + - `delete_orphans` — whether to delete orphaned local entries + +2. **Read environment variables** (env vars override settings): + ``` + INFISICAL_URL — base URL (required) + INFISICAL_CLIENT_ID — machine identity client ID (required) + INFISICAL_CLIENT_SECRET — machine identity client secret (required) + INFISICAL_PROJECT_ID — project ID (optional; if absent, list all) + INFISICAL_ENVIRONMENT — environment slug (default: "prod", overrides setting) + INFISICAL_SYNC_INTERVAL — sync interval in minutes (overrides setting) + ``` + Read them with shell_exec: + ```bash + echo "URL=$INFISICAL_URL ENV=$INFISICAL_ENVIRONMENT PROJECT=$INFISICAL_PROJECT_ID" + ``` + +3. **Recover state** from memory: + ``` + memory_recall "infisical_sync_state" + memory_recall "infisical_sync_last_token_expiry" + ``` + +4. **Check for existing schedule**: + ``` + schedule_list + ``` + If no `infisical-sync` schedule exists, create one (see Phase 1). + +5. **Read sync state file** if present: + ``` + file_read "infisical_sync_state.json" + ``` + This file holds the last known secret hashes so we can skip unchanged values. + +--- + +## PHASE 1 — Schedule Bootstrap (first run only) + +If no schedule for this hand exists: + +1. Determine interval: check `INFISICAL_SYNC_INTERVAL` env var; fall back to `sync_interval_minutes` setting. + +2. Create schedule: + ``` + schedule_create + name: "infisical-sync" + interval_minutes: + description: "Pull secrets from Infisical into local vault" + ``` + +3. Log to memory: + ``` + memory_store "infisical_sync_schedule_created" "" + ``` + +4. Add Infisical instance to knowledge graph: + ``` + knowledge_add_entity + type: "service" + name: "Infisical" + properties: { url: "", environment: "", project_id: "" } + ``` + +--- + +## PHASE 2 — Authentication + +Obtain a short-lived access token using Universal Auth. + +```bash +curl -s -X POST "$INFISICAL_URL/api/v1/auth/universal-auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"clientId\":\"$INFISICAL_CLIENT_ID\",\"clientSecret\":\"$INFISICAL_CLIENT_SECRET\"}" +``` + +Parse the response and extract `accessToken`. If the call fails: +- Log to memory: `memory_store "infisical_sync_last_error" "auth_failed: "` +- Increment error counter in state +- `event_publish` an alert: "Infisical Sync: authentication failed — check INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET" +- STOP. Do not proceed. Do not crash. + +Store the token in a local variable for use in subsequent API calls. **Never store the raw token in memory or the vault** — it is ephemeral. + +--- + +## PHASE 3 — Resolve Project + +If `INFISICAL_PROJECT_ID` is set, use it directly. + +If not set, list accessible projects: +```bash +curl -s -X GET "$INFISICAL_URL/api/v1/workspace" \ + -H "Authorization: Bearer $ACCESS_TOKEN" +``` + +If multiple projects are returned, sync all of them. Store each project ID in the knowledge graph as a `secret_project` entity. + +--- + +## PHASE 4 — Pull Secrets from Infisical + +For each project ID, fetch all secrets: +```bash +curl -s -X GET \ + "$INFISICAL_URL/api/v4/secrets?projectId=&environment=&secretPath=/" \ + -H "Authorization: Bearer $ACCESS_TOKEN" +``` + +The response contains an array of secrets. Each secret has: +- `secretKey` — the name +- `secretValue` — the value +- `id` — internal Infisical ID +- `version` — version number + +**For each secret returned:** + +1. Compute a hash of `secretKey + secretValue` to detect changes. +2. Compare with stored hash in `infisical_sync_state.json`. +3. If unchanged, skip. +4. If new or changed, write to local vault: + ``` + vault_set key= value= + ``` +5. Record entity in knowledge graph: + ``` + knowledge_add_entity + type: "secret" + name: + properties: { project_id: , environment: , version: , last_synced: } + ``` + +**Orphan handling** (if `delete_orphans` setting is "true"): +1. Collect the set of all secret keys returned by Infisical. +2. Call `vault_list` to get all local keys. +3. For any local key NOT in the Infisical set, call `vault_delete`. +4. Log each deletion to memory. + +--- + +## PHASE 5 — Push Secrets to Infisical (on-demand) + +When the user (or another agent) asks you to share a secret with the fleet: + +1. Read the secret from the local vault: + ``` + vault_get key= + ``` + +2. Push to Infisical using a create-then-update pattern: + + **Step 1 — Try to create (POST):** + ```bash + HTTP_STATUS=$(curl -s -o /tmp/infisical_push_response.json -w "%{http_code}" \ + -X POST "$INFISICAL_URL/api/v4/secrets/" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"projectId\":\"\",\"environment\":\"\",\"secretValue\":\"\",\"secretPath\":\"/\"}") + ``` + + **Step 2 — If 409 (secret already exists), update via PATCH:** + ```bash + if [ "$HTTP_STATUS" = "409" ]; then + HTTP_STATUS=$(curl -s -o /tmp/infisical_push_response.json -w "%{http_code}" \ + -X PATCH "$INFISICAL_URL/api/v4/secrets/" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"projectId\":\"\",\"environment\":\"\",\"secretValue\":\"\",\"secretPath\":\"/\"}") + fi + ``` + + POST returns 201 on success. PATCH returns 200 on success. Any other status is an error — log it and notify via `event_publish`. + +3. On success, log to knowledge graph and memory. On failure, log the error and notify via `event_publish`. + +**Never log secret values.** Use placeholders like `` in all log messages and memory entries. + +--- + +## PHASE 6 — Delete a Secret from Infisical (on-demand) + +When asked to remove a secret from the shared fleet store: + +1. Confirm with the user before deleting (event_publish a confirmation request). +2. On confirmation, call: + ```bash + curl -s -X DELETE \ + "$INFISICAL_URL/api/v4/secrets/?projectId=&environment=&secretPath=/" \ + -H "Authorization: Bearer $ACCESS_TOKEN" + ``` +3. Delete the local vault entry: `vault_delete key=` +4. Update knowledge graph: remove the `secret` entity. + +--- + +## PHASE 7 — State Persistence & Metrics + +After every sync cycle: + +1. **Update sync state file**: + ``` + file_write "infisical_sync_state.json" + ``` + +2. **Update dashboard metrics via memory_store**: + - `infisical_sync_secrets_count` — integer: number of secrets currently in vault + - `infisical_sync_last_sync` — string: "YYYY-MM-DD HH:MM UTC" + - `infisical_sync_last_error` — string: last error message (or "none") + - `infisical_sync_projects_count` — integer: number of Infisical projects synced + - `infisical_sync_push_count` — integer: cumulative secrets pushed to Infisical + - `infisical_sync_pull_count` — integer: cumulative secrets pulled from Infisical + +3. **Persist state summary**: + ``` + memory_store "infisical_sync_state" + ``` + +4. **Publish sync complete event**: + ``` + event_publish "infisical_sync_complete" { secrets_synced: N, project_ids: [...], timestamp: "..." } + ``` + +--- + +## Error Handling + +**Never crash.** Always catch errors gracefully: + +- Authentication failure → log + notify + stop cycle +- API rate limit (HTTP 429) → wait 60 seconds, retry once, then log + stop +- Network timeout → log + retry with 10-second delay once, then log + stop +- Partial sync failure → log which keys failed, continue with remaining keys +- vault_set failure → log + notify, do not mark as synced + +All errors go to memory: +``` +memory_store "infisical_sync_last_error" ": at " +``` + +And if severity is high (auth failure, total API unreachable): +``` +event_publish "infisical_sync_error" { error: "", message: "", timestamp: "..." } +``` + +--- + +## Security Rules + +1. **Never** log, print, or store secret values — use `` in all messages. +2. **Never** expose secret values in `event_publish` payloads. +3. **Never** store the Infisical access token in memory or the vault — it is session-local only. +4. Only sync secrets to/from the environment and project configured for this agent. +5. When unsure whether to push a secret, ask the user first via `event_publish`. +""" + +# ─── Dashboard metrics ──────────────────────────────────────────────────────── + +[dashboard] + +[[dashboard.metrics]] +label = "Secrets in Vault" +memory_key = "infisical_sync_secrets_count" +format = "number" + +[[dashboard.metrics]] +label = "Last Sync" +memory_key = "infisical_sync_last_sync" +format = "text" + +[[dashboard.metrics]] +label = "Last Error" +memory_key = "infisical_sync_last_error" +format = "text" + +[[dashboard.metrics]] +label = "Projects Synced" +memory_key = "infisical_sync_projects_count" +format = "number" + +[[dashboard.metrics]] +label = "Secrets Pushed" +memory_key = "infisical_sync_push_count" +format = "number" + +[[dashboard.metrics]] +label = "Secrets Pulled" +memory_key = "infisical_sync_pull_count" +format = "number" diff --git a/crates/openfang-hands/bundled/infisical-sync/SKILL.md b/crates/openfang-hands/bundled/infisical-sync/SKILL.md new file mode 100644 index 000000000..2b4a347c6 --- /dev/null +++ b/crates/openfang-hands/bundled/infisical-sync/SKILL.md @@ -0,0 +1,317 @@ +--- +name: infisical-sync-skill +version: "1.0.0" +description: "Expert knowledge for the Infisical Sync Hand — Infisical API reference, vault operations, error patterns, security guidance" +author: OpenFang +tags: [secrets, infisical, vault, security, sync] +tools: [shell_exec, vault_set, vault_get, vault_list, vault_delete, memory_store, memory_recall] +runtime: prompt_only +--- + +# Infisical Sync Expert Knowledge + +## 1. Infisical API Reference + +### Base URL +All requests go to `$INFISICAL_URL`. This is the self-hosted instance base URL, e.g. `https://infisical.example.com`. + +### Authentication — Universal Auth +Infisical uses Machine Identities with Universal Auth for agent-to-agent communication. + +**Endpoint**: `POST /api/v1/auth/universal-auth/login` + +**Request**: +```json +{ + "clientId": "", + "clientSecret": "" +} +``` + +**Response** (success): +```json +{ + "accessToken": "eyJ...", + "expiresIn": 7200, + "accessTokenMaxTTL": 43200, + "tokenType": "Bearer" +} +``` + +**curl example**: +```bash +RESPONSE=$(curl -s -X POST "$INFISICAL_URL/api/v1/auth/universal-auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"clientId\":\"$INFISICAL_CLIENT_ID\",\"clientSecret\":\"$INFISICAL_CLIENT_SECRET\"}") + +ACCESS_TOKEN=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessToken'])") +``` + +Token lifetime: `expiresIn` seconds (usually 7200 = 2 hours). Re-authenticate when expired. + +--- + +### List Secrets +**Endpoint**: `GET /api/v4/secrets` + +**Query parameters**: +| Param | Required | Description | +|-------|----------|-------------| +| `projectId` | Yes | Infisical project ID | +| `environment` | Yes | Environment slug (e.g. `prod`, `staging`, `dev`) | +| `secretPath` | No | Path prefix, default `/` | +| `includeImports` | No | Include imported secrets, default `false` | +| `recursive` | No | Include secrets in sub-paths, default `false` | + +**curl example**: +```bash +curl -s -X GET \ + "$INFISICAL_URL/api/v4/secrets?projectId=$PROJECT_ID&environment=$ENVIRONMENT&secretPath=/" \ + -H "Authorization: Bearer $ACCESS_TOKEN" +``` + +**Response shape**: +```json +{ + "secrets": [ + { + "id": "uuid", + "version": 1, + "secretKey": "DATABASE_URL", + "secretValue": "postgres://...", + "secretComment": "", + "environment": "prod", + "workspace": "uuid" + } + ], + "imports": [] +} +``` + +Parse with: +```bash +echo "$RESPONSE" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for s in data.get('secrets', []): + print(s['secretKey']) +" +``` + +--- + +### Create or Update a Secret + +The API does **not** provide a single upsert endpoint. `POST` creates only (returns 409 if the secret already exists); `PATCH` updates only (returns 404 if missing). Use the create-then-update pattern: + +**Step 1 — Try to create (POST)** +**Endpoint**: `POST /api/v4/secrets/{secretName}` + +**Request body**: +```json +{ + "projectId": "", + "environment": "", + "secretValue": "", + "secretPath": "/" +} +``` + +```bash +HTTP_STATUS=$(curl -s -o /tmp/infisical_response.json -w "%{http_code}" \ + -X POST "$INFISICAL_URL/api/v4/secrets/$SECRET_NAME" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"projectId\":\"$PROJECT_ID\",\"environment\":\"$ENVIRONMENT\",\"secretValue\":\"$SECRET_VALUE\",\"secretPath\":\"/\"}") +``` + +Returns `201` on success. + +**Step 2 — If 409, update via PATCH** +**Endpoint**: `PATCH /api/v4/secrets/{secretName}` + +```bash +if [ "$HTTP_STATUS" = "409" ]; then + HTTP_STATUS=$(curl -s -o /tmp/infisical_response.json -w "%{http_code}" \ + -X PATCH "$INFISICAL_URL/api/v4/secrets/$SECRET_NAME" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"projectId\":\"$PROJECT_ID\",\"environment\":\"$ENVIRONMENT\",\"secretValue\":\"$SECRET_VALUE\",\"secretPath\":\"/\"}") +fi +``` + +Returns `200` on success. Any other status code is an error. + +**Important**: URL-encode the secret name if it contains special characters. + +--- + +### Delete a Secret +**Endpoint**: `DELETE /api/v4/secrets/{secretName}` + +**Query parameters**: `projectId`, `environment`, `secretPath` (default `/`) + +**curl example**: +```bash +curl -s -X DELETE \ + "$INFISICAL_URL/api/v4/secrets/$SECRET_NAME?projectId=$PROJECT_ID&environment=$ENVIRONMENT&secretPath=/" \ + -H "Authorization: Bearer $ACCESS_TOKEN" +``` + +--- + +### List Accessible Projects (Workspaces) +**Endpoint**: `GET /api/v1/workspace` + +```bash +curl -s -X GET "$INFISICAL_URL/api/v1/workspace" \ + -H "Authorization: Bearer $ACCESS_TOKEN" +``` + +Response: `{ "workspaces": [{ "id": "uuid", "name": "...", "environments": [...] }] }` + +--- + +## 2. HTTP Error Codes + +| Code | Meaning | Action | +|------|---------|--------| +| 200/201 | Success | Continue | +| 400 | Bad Request | Log the response body — likely malformed JSON or missing field | +| 401 | Unauthorized | Re-authenticate; token may have expired | +| 403 | Forbidden | Machine identity lacks permissions — check Infisical Access Control | +| 404 | Not Found | Secret or project doesn't exist | +| 429 | Rate Limited | Wait 60 seconds, retry once | +| 500/503 | Server Error | Log + retry once after 30 seconds; notify if still failing | + +Always check HTTP status before trusting response body: +```bash +HTTP_STATUS=$(curl -s -o /tmp/infisical_response.json -w "%{http_code}" ...) +if [ "$HTTP_STATUS" != "200" ] && [ "$HTTP_STATUS" != "201" ]; then + # handle error +fi +RESPONSE=$(cat /tmp/infisical_response.json) +``` + +--- + +## 3. Sync State File Format + +Stored at `infisical_sync_state.json`: +```json +{ + "last_sync": "2025-01-15T10:30:00Z", + "project_ids": ["uuid1", "uuid2"], + "environment": "prod", + "secrets": { + "DATABASE_URL": { + "hash": "sha256_of_key_plus_value", + "version": 3, + "last_synced": "2025-01-15T10:30:00Z" + } + }, + "error_count": 0, + "push_count": 12, + "pull_count": 47 +} +``` + +**Hash computation** (to detect changes without storing values): +```bash +echo -n "DATABASE_URL:postgres://..." | sha256sum | awk '{print $1}' +``` + +Or with Python: +```python +import hashlib +h = hashlib.sha256(f"{key}:{value}".encode()).hexdigest() +``` + +--- + +## 4. Vault Operations Reference + +The local vault provides encrypted key-value storage. All secrets synced from Infisical go here. + +| Operation | Description | +|-----------|-------------| +| `vault_set key=K value=V` | Write or overwrite secret K | +| `vault_get key=K` | Read secret K | +| `vault_list` | List all keys (values not returned) | +| `vault_delete key=K` | Delete secret K | + +**Bulk sync pattern**: +``` +// Pull from Infisical → vault +for each (key, value) in infisical_secrets: + vault_set key= value= + +// Optionally remove orphans +vault_list → local_keys +infisical_keys = set of keys returned by Infisical +for key in local_keys - infisical_keys: + vault_delete key= +``` + +--- + +## 5. Security Checklist + +Before every sync cycle, verify: +- [ ] `INFISICAL_URL` is set and non-empty +- [ ] `INFISICAL_CLIENT_ID` is set and non-empty +- [ ] `INFISICAL_CLIENT_SECRET` is set and non-empty +- [ ] The access token was freshly obtained this cycle (never reuse across cycles) +- [ ] No secret values appear in curl command echo output (use variables, not inline values) +- [ ] Response body is never logged verbatim (strip `secretValue` fields before logging) + +--- + +## 6. Common Failure Modes + +### "Failed to fetch secrets: 403 Forbidden" +The Machine Identity exists but lacks permissions. In Infisical: +1. Go to Access Control → Machine Identities +2. Find this agent's identity +3. Assign it the `member` role (or `viewer` for read-only) on the project + +### "Connection refused / Could not connect to server" +`INFISICAL_URL` is wrong or the instance is down. Verify the URL is reachable: +```bash +curl -s "$INFISICAL_URL/api/status" | python3 -c "import sys,json; print(json.load(sys.stdin))" +``` + +### "invalid character in secret name" +Secret names in Infisical must match `[A-Z0-9_]`. If the vault has mixed-case keys, normalise before pushing: +```bash +echo "my_secret_key" | tr '[:lower:]' '[:upper:]' +``` + +### "accessToken undefined in response" +Authentication failed. The response body will contain an error message. Check: +1. `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET` are correct +2. The Machine Identity is not disabled in Infisical +3. The Machine Identity's token TTL hasn't been set to 0 + +--- + +## 7. Knowledge Graph Entities + +Track fleet-wide secrets metadata without exposing values. + +### Entity types +- `service` — the Infisical instance itself +- `secret_project` — an Infisical workspace/project +- `secret` — a named secret (key only, never value) + +### Relation types +- `secret` → `belongs_to` → `secret_project` +- `secret_project` → `hosted_by` → `service` +- `secret` → `synced_to` → `agent_vault` + +### Query examples +``` +knowledge_query type=secret // list all known secrets +knowledge_query type=secret_project // list all projects +knowledge_query relation=belongs_to target= // secrets in a project +``` diff --git a/crates/openfang-hands/src/bundled.rs b/crates/openfang-hands/src/bundled.rs index 7e91d315d..6ed6f3f01 100644 --- a/crates/openfang-hands/src/bundled.rs +++ b/crates/openfang-hands/src/bundled.rs @@ -45,6 +45,11 @@ pub fn bundled_hands() -> Vec<(&'static str, &'static str, &'static str)> { include_str!("../bundled/trader/HAND.toml"), include_str!("../bundled/trader/SKILL.md"), ), + ( + "infisical-sync", + include_str!("../bundled/infisical-sync/HAND.toml"), + include_str!("../bundled/infisical-sync/SKILL.md"), + ), ] } @@ -76,7 +81,7 @@ mod tests { #[test] fn bundled_hands_count() { let hands = bundled_hands(); - assert_eq!(hands.len(), 8); + assert_eq!(hands.len(), 9); } #[test] @@ -298,6 +303,52 @@ mod tests { } } + #[test] + fn parse_infisical_sync_hand() { + let (id, toml_content, skill_content) = bundled_hands() + .into_iter() + .find(|(id, _, _)| *id == "infisical-sync") + .expect("infisical-sync hand must be in bundled_hands()"); + let def = parse_bundled(id, toml_content, skill_content).unwrap(); + assert_eq!(def.id, "infisical-sync"); + assert_eq!(def.name, "Infisical Sync Hand"); + assert_eq!(def.category, crate::HandCategory::Security); + assert!(def.skill_content.is_some()); + // Required env vars + assert!(!def.requires.is_empty(), "infisical-sync must declare env var requirements"); + let req_keys: Vec<&str> = def.requires.iter().map(|r| r.key.as_str()).collect(); + assert!(req_keys.contains(&"INFISICAL_URL"), "must require INFISICAL_URL"); + assert!(req_keys.contains(&"INFISICAL_CLIENT_ID"), "must require INFISICAL_CLIENT_ID"); + assert!(req_keys.contains(&"INFISICAL_CLIENT_SECRET"), "must require INFISICAL_CLIENT_SECRET"); + // Einstein scheduling tools + assert!(def.tools.contains(&"schedule_create".to_string()), "must have schedule_create"); + assert!(def.tools.contains(&"schedule_list".to_string()), "must have schedule_list"); + assert!(def.tools.contains(&"schedule_delete".to_string()), "must have schedule_delete"); + // Memory tools + assert!(def.tools.contains(&"memory_store".to_string()), "must have memory_store"); + assert!(def.tools.contains(&"memory_recall".to_string()), "must have memory_recall"); + // Knowledge graph tools + assert!(def.tools.contains(&"knowledge_add_entity".to_string()), "must have knowledge_add_entity"); + assert!(def.tools.contains(&"knowledge_add_relation".to_string()), "must have knowledge_add_relation"); + assert!(def.tools.contains(&"knowledge_query".to_string()), "must have knowledge_query"); + // Event bus + assert!(def.tools.contains(&"event_publish".to_string()), "must have event_publish"); + // Infisical-specific tools + assert!(def.tools.contains(&"shell_exec".to_string()), "must have shell_exec"); + assert!(def.tools.contains(&"vault_set".to_string()), "must have vault_set"); + assert!(def.tools.contains(&"vault_get".to_string()), "must have vault_get"); + assert!(def.tools.contains(&"vault_list".to_string()), "must have vault_list"); + assert!(def.tools.contains(&"vault_delete".to_string()), "must have vault_delete"); + // Dashboard + assert!(!def.dashboard.metrics.is_empty(), "must have dashboard metrics"); + let metric_keys: Vec<&str> = def.dashboard.metrics.iter().map(|m| m.memory_key.as_str()).collect(); + assert!(metric_keys.contains(&"infisical_sync_secrets_count"), "must have secrets_count metric"); + assert!(metric_keys.contains(&"infisical_sync_last_sync"), "must have last_sync metric"); + // Agent config + assert!(!def.agent.system_prompt.is_empty(), "must have system_prompt"); + assert!(def.agent.temperature < 0.2, "security hand should use low temperature"); + } + #[test] fn all_einstein_hands_have_knowledge_graph() { let einstein_ids = [ diff --git a/crates/openfang-hands/src/registry.rs b/crates/openfang-hands/src/registry.rs index 54240f04c..dd779c3e6 100644 --- a/crates/openfang-hands/src/registry.rs +++ b/crates/openfang-hands/src/registry.rs @@ -642,7 +642,7 @@ mod tests { fn load_bundled_hands() { let reg = HandRegistry::new(); let count = reg.load_bundled(); - assert_eq!(count, 8); + assert_eq!(count, 9); assert!(!reg.list_definitions().is_empty()); // Clip hand should be loaded