diff --git a/.icons/1claw.svg b/.icons/1claw.svg new file mode 100644 index 000000000..f6854deae --- /dev/null +++ b/.icons/1claw.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/registry/kmjones1979/.images/avatar.png b/registry/kmjones1979/.images/avatar.png new file mode 100644 index 000000000..dd7f47e62 Binary files /dev/null and b/registry/kmjones1979/.images/avatar.png differ diff --git a/registry/kmjones1979/README.md b/registry/kmjones1979/README.md new file mode 100644 index 000000000..5d0510782 --- /dev/null +++ b/registry/kmjones1979/README.md @@ -0,0 +1,11 @@ +--- +display_name: Kevin Jones +bio: Developer building modules for Coder workspaces +avatar: ./.images/avatar.png +github: kmjones1979 +status: community +--- + +# Kevin Jones + +Developer building modules for Coder workspaces. diff --git a/registry/kmjones1979/modules/oneclaw/README.md b/registry/kmjones1979/modules/oneclaw/README.md new file mode 100644 index 000000000..171dff65b --- /dev/null +++ b/registry/kmjones1979/modules/oneclaw/README.md @@ -0,0 +1,61 @@ +--- +display_name: 1Claw +description: Vault-backed secrets and MCP server wiring for 1Claw in Coder workspaces +icon: ../../../../.icons/1claw.svg +verified: false +tags: [secrets, mcp, ai] +--- + +# 1Claw + +Give every Coder workspace scoped access to [1Claw](https://1claw.xyz) so AI coding agents can read secrets from an encrypted vault instead of hardcoded credentials. The module supports three provisioning modes — Terraform-native, shell bootstrap, and manual — and merges a `streamable-http` MCP server entry into Cursor and Claude Code config files without overwriting other MCP servers. + +Upstream source: [github.com/1clawAI/1claw-coder-workspace-module](https://github.com/1clawAI/1claw-coder-workspace-module). + +## Usage + +### Terraform-native mode (recommended) + +Provisions vault, agent, and access policy at `terraform apply`; cleans up on `terraform destroy`. + +```tf +module "oneclaw" { + source = "registry.coder.com/kmjones1979/oneclaw/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + master_api_key = var.oneclaw_key +} +``` + +### Manual mode + +Use an existing vault and agent API key from the 1Claw dashboard. + +```tf +module "oneclaw" { + source = "registry.coder.com/kmjones1979/oneclaw/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + vault_id = var.oneclaw_vault_id + api_token = var.oneclaw_agent_key +} +``` + +### Shell bootstrap mode + +Creates vault and agent on the first workspace boot, then caches credentials for subsequent starts. + +```tf +module "oneclaw" { + source = "registry.coder.com/kmjones1979/oneclaw/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + human_api_key = var.oneclaw_human_key +} +``` + +> [!NOTE] +> **Terraform-native mode** runs a `local-exec` provisioner on the machine executing Terraform. It needs network access to the 1Claw API, `curl`, and `python3`. + +> [!TIP] +> Combine this module with other registry modules (e.g. Cursor or Claude Code). The MCP setup script merges into existing `mcp.json` files instead of replacing them. diff --git a/registry/kmjones1979/modules/oneclaw/main.test.ts b/registry/kmjones1979/modules/oneclaw/main.test.ts new file mode 100644 index 000000000..89e03d8e8 --- /dev/null +++ b/registry/kmjones1979/modules/oneclaw/main.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, + findResourceInstance, +} from "~test"; + +describe("oneclaw", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "test-agent", + }); + + it("manual mode sets env vars and mcp script", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + vault_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + api_token: "ocv_testtoken", + }); + + const vaultEnv = findResourceInstance( + state, + "coder_env", + "oneclaw_vault_id", + ); + expect(vaultEnv.name).toBe("ONECLAW_VAULT_ID"); + + const apiKeyEnv = findResourceInstance( + state, + "coder_env", + "oneclaw_agent_api_key", + ); + expect(apiKeyEnv.name).toBe("ONECLAW_AGENT_API_KEY"); + + const baseUrlEnv = findResourceInstance( + state, + "coder_env", + "oneclaw_base_url", + ); + expect(baseUrlEnv.name).toBe("ONECLAW_BASE_URL"); + expect(baseUrlEnv.value).toBe("https://api.1claw.xyz"); + + const mcpScript = findResourceInstance( + state, + "coder_script", + "oneclaw_mcp_setup", + ); + expect(mcpScript.display_name).toBe("1Claw MCP Setup"); + + const bootstrapScripts = state.resources.filter( + (r) => r.type === "coder_script" && r.name === "oneclaw_bootstrap", + ); + expect(bootstrapScripts.length).toBe(0); + + const provisions = state.resources.filter( + (r) => r.type === "null_resource" && r.name === "oneclaw_provision", + ); + expect(provisions.length).toBe(0); + }); + + it("bootstrap mode creates bootstrap script", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + human_api_key: "1ck_test_human_key", + }); + + const bootstrap = findResourceInstance( + state, + "coder_script", + "oneclaw_bootstrap", + ); + expect(bootstrap.display_name).toBe("1Claw Bootstrap"); + + const provisions = state.resources.filter( + (r) => r.type === "null_resource" && r.name === "oneclaw_provision", + ); + expect(provisions.length).toBe(0); + }); + + it("custom base_url is reflected in env", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + vault_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + api_token: "ocv_testtoken", + base_url: "https://api.example.com", + }); + + const baseUrlEnv = findResourceInstance( + state, + "coder_env", + "oneclaw_base_url", + ); + expect(baseUrlEnv.value).toBe("https://api.example.com"); + }); +}); diff --git a/registry/kmjones1979/modules/oneclaw/main.tf b/registry/kmjones1979/modules/oneclaw/main.tf new file mode 100644 index 000000000..3dbabfa98 --- /dev/null +++ b/registry/kmjones1979/modules/oneclaw/main.tf @@ -0,0 +1,216 @@ +terraform { + required_version = ">= 1.4" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.12" + } + null = { + source = "hashicorp/null" + version = ">= 3.0" + } + } +} + +locals { + # Which mode are we in? + tf_native_mode = var.master_api_key != "" + bootstrap_mode = var.human_api_key != "" && !local.tf_native_mode + manual_mode = !local.tf_native_mode && !local.bootstrap_mode + + provision_state_file = "${path.module}/.provision-state.json" + + provision_vault_name = ( + var.provision_vault_name != "" ? var.provision_vault_name : + "coder-${data.coder_workspace.me.name}" + ) + provision_agent_name = ( + var.provision_agent_name != "" ? var.provision_agent_name : + "coder-${data.coder_workspace.me.name}-agent" + ) + + # Resolve effective vault_id and api_token. + # In TF-native mode these come from the provision state file after null_resource runs. + effective_vault_id = local.tf_native_mode ? local.provisioned_vault_id : var.vault_id + effective_token = local.tf_native_mode ? local.provisioned_token : var.api_token + + # Read provision state (only meaningful after null_resource.oneclaw_provision has run). + provision_state = local.tf_native_mode && fileexists(local.provision_state_file) ? jsondecode(file(local.provision_state_file)) : {} + + provisioned_vault_id = lookup(local.provision_state, "vault_id", "") + provisioned_token = lookup(local.provision_state, "agent_api_key", "") + provisioned_agent_id = lookup(local.provision_state, "agent_id", "") +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +# =========================================================================== +# Terraform-native provisioning (apply-time create, destroy-time cleanup) +# =========================================================================== + +resource "null_resource" "oneclaw_provision" { + count = local.tf_native_mode ? 1 : 0 + + # All values needed at destroy time must live in triggers (Terraform restriction). + triggers = { + workspace_id = data.coder_workspace.me.id + workspace_name = data.coder_workspace.me.name + vault_name = local.provision_vault_name + agent_name = local.provision_agent_name + state_file = local.provision_state_file + base_url = var.base_url + master_api_key = var.master_api_key + destroy_vault = tostring(var.auto_destroy_vault) + } + + provisioner "local-exec" { + interpreter = ["bash", "-c"] + command = templatefile("${path.module}/scripts/provision.sh", { + BASE_URL = var.base_url + MASTER_API_KEY = var.master_api_key + WORKSPACE_ID = data.coder_workspace.me.id + WORKSPACE_NAME = data.coder_workspace.me.name + VAULT_NAME = local.provision_vault_name + AGENT_NAME = local.provision_agent_name + POLICY_PATH = var.provision_policy_path + TOKEN_TTL_SECONDS = tostring(var.token_ttl_hours * 3600) + STATE_FILE = local.provision_state_file + }) + } + + provisioner "local-exec" { + when = destroy + interpreter = ["bash", "-c"] + command = <<-EOT + set -euo pipefail + STATE_FILE="${self.triggers.state_file}" + API_URL="${self.triggers.base_url}" + MASTER_KEY="${self.triggers.master_api_key}" + DESTROY_VAULT="${self.triggers.destroy_vault}" + + if [ ! -f "$STATE_FILE" ]; then + echo "[1claw-deprovision] No state file — nothing to clean up" + exit 0 + fi + + VAULT_ID=$(python3 -c "import json; print(json.load(open('$STATE_FILE'))['vault_id'])") + AGENT_ID=$(python3 -c "import json; print(json.load(open('$STATE_FILE'))['agent_id'])") + echo "[1claw-deprovision] Agent: $AGENT_ID Vault: $VAULT_ID" + + # Authenticate + AUTH=$(curl -sf -w "\n%%{http_code}" \ + -H "Content-Type: application/json" \ + -d "{\"api_key\": \"$MASTER_KEY\"}" \ + "$API_URL/v1/auth/api-key-token" 2>&1) || { + echo "[1claw-deprovision] WARN: Auth failed — manual cleanup needed" + rm -f "$STATE_FILE"; exit 0 + } + AUTH_HTTP=$(echo "$AUTH" | tail -1) + AUTH_BODY=$(echo "$AUTH" | sed '$d') + if [ "$(echo "$AUTH_HTTP" | head -c1)" != "2" ]; then + echo "[1claw-deprovision] WARN: Auth HTTP $AUTH_HTTP — manual cleanup needed" + rm -f "$STATE_FILE"; exit 0 + fi + JWT=$(python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])" <<< "$AUTH_BODY") + + # Delete agent + echo "[1claw-deprovision] Deleting agent $AGENT_ID..." + curl -sf -X DELETE -H "Authorization: Bearer $JWT" "$API_URL/v1/agents/$AGENT_ID" >/dev/null 2>&1 \ + && echo "[1claw-deprovision] Agent deleted" \ + || echo "[1claw-deprovision] WARN: Agent delete failed (may already be gone)" + + # Optionally delete vault + if [ "$DESTROY_VAULT" = "true" ]; then + echo "[1claw-deprovision] Deleting vault $VAULT_ID..." + curl -sf -X DELETE -H "Authorization: Bearer $JWT" "$API_URL/v1/vaults/$VAULT_ID" >/dev/null 2>&1 \ + && echo "[1claw-deprovision] Vault deleted" \ + || echo "[1claw-deprovision] WARN: Vault delete failed (may have secrets or already be gone)" + else + echo "[1claw-deprovision] Vault $VAULT_ID retained (set auto_destroy_vault = true to delete)" + fi + + rm -f "$STATE_FILE" + echo "[1claw-deprovision] Cleanup complete" + EOT + } +} + +# =========================================================================== +# Environment variables (injected into the workspace agent) +# =========================================================================== + +resource "coder_env" "oneclaw_vault_id" { + count = local.effective_vault_id != "" ? 1 : 0 + agent_id = var.agent_id + name = "ONECLAW_VAULT_ID" + value = local.effective_vault_id +} + +resource "coder_env" "oneclaw_agent_api_key" { + count = local.effective_token != "" ? 1 : 0 + agent_id = var.agent_id + name = "ONECLAW_AGENT_API_KEY" + value = local.effective_token +} + +resource "coder_env" "oneclaw_agent_id" { + count = var.agent_id_1claw != "" || local.provisioned_agent_id != "" ? 1 : 0 + agent_id = var.agent_id + name = "ONECLAW_AGENT_ID" + value = var.agent_id_1claw != "" ? var.agent_id_1claw : local.provisioned_agent_id +} + +resource "coder_env" "oneclaw_base_url" { + agent_id = var.agent_id + name = "ONECLAW_BASE_URL" + value = var.base_url +} + +# =========================================================================== +# Shell bootstrap (optional, first-run provisioning inside the workspace) +# =========================================================================== + +resource "coder_script" "oneclaw_bootstrap" { + count = local.bootstrap_mode ? 1 : 0 + agent_id = var.agent_id + display_name = "1Claw Bootstrap" + icon = var.icon + run_on_start = true + start_blocks_login = true + + script = templatefile("${path.module}/scripts/bootstrap.sh", { + HUMAN_API_KEY = var.human_api_key + BASE_URL = var.base_url + VAULT_ID = var.vault_id + VAULT_NAME = var.bootstrap_vault_name + AGENT_NAME = var.bootstrap_agent_name != "" ? var.bootstrap_agent_name : "coder-${data.coder_workspace.me.name}" + POLICY_PATH = var.bootstrap_policy_path + STATE_DIR = "$HOME/.1claw" + }) +} + +# =========================================================================== +# MCP config file injection +# =========================================================================== + +resource "coder_script" "oneclaw_mcp_setup" { + agent_id = var.agent_id + display_name = "1Claw MCP Setup" + icon = var.icon + run_on_start = true + start_blocks_login = false + + script = templatefile("${path.module}/scripts/setup.sh", { + MCP_HOST = var.mcp_host + VAULT_ID = local.effective_vault_id + API_TOKEN = local.effective_token + BOOTSTRAP_MODE = local.bootstrap_mode ? "true" : "false" + INSTALL_CURSOR_CONFIG = var.install_cursor_config + INSTALL_CLAUDE_CONFIG = var.install_claude_config + CURSOR_CONFIG_PATH = var.cursor_config_path + CLAUDE_CONFIG_PATH = var.claude_config_path + }) +} diff --git a/registry/kmjones1979/modules/oneclaw/main.tftest.hcl b/registry/kmjones1979/modules/oneclaw/main.tftest.hcl new file mode 100644 index 000000000..9c8ee927a --- /dev/null +++ b/registry/kmjones1979/modules/oneclaw/main.tftest.hcl @@ -0,0 +1,103 @@ +run "manual_mode" { + command = plan + + variables { + agent_id = "test-agent-manual" + vault_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + api_token = "ocv_testtoken" + } + + assert { + condition = length(coder_env.oneclaw_vault_id) == 1 + error_message = "ONECLAW_VAULT_ID should be set in manual mode" + } + + assert { + condition = length(coder_env.oneclaw_agent_api_key) == 1 + error_message = "ONECLAW_AGENT_API_KEY should be set in manual mode" + } + + assert { + condition = length(null_resource.oneclaw_provision) == 0 + error_message = "No provision resource in manual mode" + } + + assert { + condition = length(coder_script.oneclaw_bootstrap) == 0 + error_message = "No bootstrap script in manual mode" + } +} + +run "terraform_native_mode" { + command = plan + + variables { + agent_id = "test-agent-tf" + master_api_key = "1ck_test_master_key" + } + + assert { + condition = length(null_resource.oneclaw_provision) == 1 + error_message = "Terraform-native mode should create the provision null_resource" + } + + assert { + condition = length(coder_script.oneclaw_bootstrap) == 0 + error_message = "No bootstrap script in terraform-native mode" + } +} + +run "bootstrap_mode" { + command = plan + + variables { + agent_id = "test-agent-bootstrap" + human_api_key = "1ck_test_human_key" + } + + assert { + condition = length(coder_script.oneclaw_bootstrap) == 1 + error_message = "Bootstrap mode should create the bootstrap script" + } + + assert { + condition = length(null_resource.oneclaw_provision) == 0 + error_message = "No provision resource in bootstrap mode" + } +} + +run "master_key_takes_precedence_over_human" { + command = plan + + variables { + agent_id = "test-agent-priority" + master_api_key = "1ck_master" + human_api_key = "1ck_human" + } + + assert { + condition = length(null_resource.oneclaw_provision) == 1 + error_message = "master_api_key should win when both keys are set" + } + + assert { + condition = length(coder_script.oneclaw_bootstrap) == 0 + error_message = "No bootstrap script when master_api_key is set" + } +} + +run "custom_base_url" { + command = plan + + variables { + agent_id = "test-agent-mcp" + vault_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + api_token = "ocv_testtoken" + base_url = "https://api.example.com" + } + + assert { + condition = coder_env.oneclaw_base_url.value == "https://api.example.com" + error_message = "ONECLAW_BASE_URL should match base_url" + } +} diff --git a/registry/kmjones1979/modules/oneclaw/outputs.tf b/registry/kmjones1979/modules/oneclaw/outputs.tf new file mode 100644 index 000000000..f106b092a --- /dev/null +++ b/registry/kmjones1979/modules/oneclaw/outputs.tf @@ -0,0 +1,33 @@ +output "mcp_config_path" { + description = "Primary MCP config file path (Cursor). Use this to reference the config from downstream resources." + value = var.cursor_config_path +} + +output "claude_config_path" { + description = "Claude Code MCP config file path." + value = var.install_claude_config ? var.claude_config_path : "" +} + +output "vault_id" { + description = "The 1Claw vault ID configured for this workspace." + value = local.effective_vault_id + sensitive = true +} + +output "scoped_token" { + description = "The agent API key (ocv_) for this workspace. Only populated in Terraform-native mode." + value = local.provisioned_token + sensitive = true +} + +output "agent_id_1claw" { + description = "The 1Claw agent UUID provisioned for this workspace." + value = local.provisioned_agent_id != "" ? local.provisioned_agent_id : var.agent_id_1claw + sensitive = true +} + +output "provisioning_mode" { + description = "Which provisioning mode is active: terraform_native, bootstrap, or manual." + value = local.tf_native_mode ? "terraform_native" : (local.bootstrap_mode ? "bootstrap" : "manual") + sensitive = true +} diff --git a/registry/kmjones1979/modules/oneclaw/scripts/bootstrap.sh b/registry/kmjones1979/modules/oneclaw/scripts/bootstrap.sh new file mode 100644 index 000000000..0faeeabaa --- /dev/null +++ b/registry/kmjones1979/modules/oneclaw/scripts/bootstrap.sh @@ -0,0 +1,151 @@ +#!/bin/bash +set -euo pipefail + +LOG_PREFIX="[1claw-bootstrap]" + +log() { + echo "$LOG_PREFIX $*" +} + +die() { + log "ERROR: $*" >&2 + exit 1 +} + +STATE_DIR=$(eval echo "${STATE_DIR}") +STATE_FILE="$STATE_DIR/bootstrap.json" +HUMAN_KEY="${HUMAN_API_KEY}" +API_URL="${BASE_URL}" +VAULT="${VAULT_ID}" +VAULT_NAME_IN="${VAULT_NAME}" +AGENT_NAME_IN="${AGENT_NAME}" +POLICY_PATH_IN="${POLICY_PATH}" + +# --- Early exit if already bootstrapped --- +if [ -f "$STATE_FILE" ]; then + log "Bootstrap state found at $STATE_FILE — skipping provisioning" + exit 0 +fi + +if [ -z "$HUMAN_KEY" ]; then + die "human_api_key is required for bootstrap mode" +fi + +api_call() { + local method="$1" + local path="$2" + local token="$3" + local body="$${4:-}" + + local response + response=$(curl -s -w "\n%%{http_code}" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $token" \ + $${body:+-d "$body"} \ + -X "$method" "$API_URL$path" 2>&1) || { + log "API call failed: $method $path" + log "Response: $response" + return 1 + } + + local http_code + http_code=$(echo "$response" | tail -1) + local body_out + body_out=$(echo "$response" | sed '$d') + + if [ "$${http_code:0:1}" != "2" ]; then + log "API error: $method $path returned HTTP $http_code" + log "Response: $body_out" + return 1 + fi + + echo "$body_out" +} + +json_get() { + python3 -c "import json,sys; print(json.load(sys.stdin)$1)" +} + +# --- Step 1: Exchange human API key for JWT --- +log "Authenticating with 1Claw API..." +AUTH_RESPONSE=$(curl -s -w "\n%%{http_code}" \ + -H "Content-Type: application/json" \ + -d "{\"api_key\": \"$HUMAN_KEY\"}" \ + "$API_URL/v1/auth/api-key-token" 2>&1) || die "Failed to authenticate with human API key" + +AUTH_HTTP=$(echo "$AUTH_RESPONSE" | tail -1) +AUTH_BODY=$(echo "$AUTH_RESPONSE" | sed '$d') + +if [ "$${AUTH_HTTP:0:1}" != "2" ]; then + die "Authentication failed (HTTP $AUTH_HTTP): $AUTH_BODY" +fi + +JWT=$(echo "$AUTH_BODY" | json_get "['access_token']") +log "Authenticated successfully" + +# --- Step 2: Resolve or create vault --- +if [ -n "$VAULT" ]; then + log "Using provided vault: $VAULT" +else + log "Creating vault '$VAULT_NAME_IN'..." + VAULT_RESPONSE=$(api_call POST "/v1/vaults" "$JWT" \ + "{\"name\": \"$VAULT_NAME_IN\"}") || { + log "Vault creation failed — looking for existing vault named '$VAULT_NAME_IN'" + VAULTS_RESPONSE=$(api_call GET "/v1/vaults" "$JWT") || die "Failed to list vaults" + VAULT=$(echo "$VAULTS_RESPONSE" | python3 -c " +import json, sys +vaults = json.load(sys.stdin).get('vaults', []) +for v in vaults: + if v['name'] == '$VAULT_NAME_IN': + print(v['id']) + sys.exit(0) +sys.exit(1) +") || die "Could not find existing vault named '$VAULT_NAME_IN'" + log "Found existing vault: $VAULT" + } + if [ -z "$VAULT" ]; then + VAULT=$(echo "$VAULT_RESPONSE" | json_get "['id']") + log "Created vault: $VAULT" + fi +fi + +# --- Step 3: Create agent --- +log "Creating agent '$AGENT_NAME_IN'..." +AGENT_RESPONSE=$(api_call POST "/v1/agents" "$JWT" \ + "{\"name\": \"$AGENT_NAME_IN\", \"vault_ids\": [\"$VAULT\"]}") || die "Failed to create agent" + +AGENT_ID=$(echo "$AGENT_RESPONSE" | json_get "['agent']['id']") +AGENT_API_KEY=$(echo "$AGENT_RESPONSE" | json_get "['api_key']") + +if [ -z "$AGENT_API_KEY" ] || [ "$AGENT_API_KEY" = "None" ]; then + die "Agent created but no API key returned — check auth_method" +fi +log "Created agent: $AGENT_ID" + +# --- Step 4: Create access policy --- +log "Creating access policy (path: $POLICY_PATH_IN)..." +api_call POST "/v1/vaults/$VAULT/policies" "$JWT" \ + "{\"secret_path_pattern\": \"$POLICY_PATH_IN\", \"principal_type\": \"agent\", \"principal_id\": \"$AGENT_ID\", \"permissions\": [\"read\", \"write\"]}" \ + > /dev/null || die "Failed to create policy" +log "Policy created — agent can access $POLICY_PATH_IN" + +# --- Step 5: Save state --- +mkdir -p "$STATE_DIR" + +python3 - "$STATE_FILE" "$VAULT" "$AGENT_ID" "$AGENT_API_KEY" << 'PYEOF' +import json, sys +state = { + "vault_id": sys.argv[2], + "agent_id": sys.argv[3], + "agent_api_key": sys.argv[4] +} +with open(sys.argv[1], "w") as f: + json.dump(state, f, indent=2) +PYEOF + +chmod 600 "$STATE_FILE" + +log "Bootstrap complete — credentials saved to $STATE_FILE" +log " Vault ID: $VAULT" +log " Agent ID: $AGENT_ID" +log " Agent key: $${AGENT_API_KEY:0:12}..." diff --git a/registry/kmjones1979/modules/oneclaw/scripts/provision.sh b/registry/kmjones1979/modules/oneclaw/scripts/provision.sh new file mode 100755 index 000000000..893b7afff --- /dev/null +++ b/registry/kmjones1979/modules/oneclaw/scripts/provision.sh @@ -0,0 +1,151 @@ +#!/bin/bash +set -euo pipefail + +LOG_PREFIX="[1claw-provision]" +log() { echo "$LOG_PREFIX $*"; } +die() { + log "ERROR: $*" >&2 + exit 1 +} + +API_URL="${BASE_URL}" +MASTER_KEY="${MASTER_API_KEY}" +WORKSPACE_ID="${WORKSPACE_ID}" +WORKSPACE_NAME="${WORKSPACE_NAME}" +VAULT_NAME="${VAULT_NAME}" +AGENT_NAME="${AGENT_NAME}" +POLICY_PATH="${POLICY_PATH}" +TOKEN_TTL_SECS="${TOKEN_TTL_SECONDS}" +STATE_FILE="${STATE_FILE}" + +[ -n "$MASTER_KEY" ] || die "master_api_key is required" + +if [ -f "$STATE_FILE" ]; then + log "Provision state already exists at $STATE_FILE — skipping" + exit 0 +fi + +api_call() { + local method="$1" path="$2" token="$3" body="$${4:-}" + local response http_code body_out + + response=$(curl -sf -w "\n%%{http_code}" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $token" \ + $${body:+-d "$body"} \ + -X "$method" "$API_URL$path" 2>&1) || { + log "curl failed: $method $path" + return 1 + } + + http_code=$(echo "$response" | tail -1) + body_out=$(echo "$response" | sed '$d') + + if [ "$${http_code:0:1}" != "2" ]; then + log "API $method $path => HTTP $http_code" + log "Body: $body_out" + return 1 + fi + echo "$body_out" +} + +json_get() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; } + +# --- Step 1: Exchange master key for JWT --- +log "Authenticating..." +AUTH=$(curl -sf -w "\n%%{http_code}" \ + -H "Content-Type: application/json" \ + -d "{\"api_key\": \"$MASTER_KEY\"}" \ + "$API_URL/v1/auth/api-key-token" 2>&1) || die "Auth request failed" + +AUTH_HTTP=$(echo "$AUTH" | tail -1) +AUTH_BODY=$(echo "$AUTH" | sed '$d') +[ "$${AUTH_HTTP:0:1}" = "2" ] || die "Auth failed (HTTP $AUTH_HTTP): $AUTH_BODY" + +JWT=$(echo "$AUTH_BODY" | json_get "['access_token']") +log "Authenticated" + +# --- Step 2: Resolve or create vault --- +log "Creating vault '$VAULT_NAME'..." +VAULT_ID="" +VAULT_RESP=$(api_call POST "/v1/vaults" "$JWT" \ + "{\"name\": \"$VAULT_NAME\", \"description\": \"Auto-provisioned for Coder workspace $WORKSPACE_NAME ($WORKSPACE_ID)\"}") && { + VAULT_ID=$(echo "$VAULT_RESP" | json_get "['id']") + log "Created vault: $VAULT_ID" +} || { + log "Vault creation failed — searching for existing '$VAULT_NAME'" + LIST_RESP=$(api_call GET "/v1/vaults" "$JWT") || die "Cannot list vaults" + VAULT_ID=$(echo "$LIST_RESP" | python3 -c " +import json, sys +for v in json.load(sys.stdin).get('vaults', []): + if v['name'] == '$VAULT_NAME': + print(v['id']); sys.exit(0) +sys.exit(1) +") || die "No vault named '$VAULT_NAME' found" + log "Using existing vault: $VAULT_ID" +} + +# --- Step 3: Create agent scoped to this vault --- +AGENT_PAYLOAD=$(python3 -c " +import json, sys +payload = { + 'name': '$AGENT_NAME', + 'vault_ids': ['$VAULT_ID'], + 'description': 'Coder workspace $WORKSPACE_NAME ($WORKSPACE_ID)' +} +ttl = int('$TOKEN_TTL_SECS') if '$TOKEN_TTL_SECS' and '$TOKEN_TTL_SECS' != '0' else None +if ttl: + payload['token_ttl_seconds'] = ttl +print(json.dumps(payload)) +") + +log "Creating agent '$AGENT_NAME' (ttl=$${TOKEN_TTL_SECS}s)..." +AGENT_RESP=$(api_call POST "/v1/agents" "$JWT" "$AGENT_PAYLOAD") || die "Failed to create agent" + +AGENT_ID=$(echo "$AGENT_RESP" | json_get "['agent']['id']") +AGENT_KEY=$(echo "$AGENT_RESP" | json_get "['api_key']") + +[ -n "$AGENT_KEY" ] && [ "$AGENT_KEY" != "None" ] || die "Agent created but no API key returned" +log "Created agent: $AGENT_ID" + +# --- Step 4: Create access policy --- +log "Creating policy (path: $POLICY_PATH)..." +api_call POST "/v1/vaults/$VAULT_ID/policies" "$JWT" \ + "{\"secret_path_pattern\": \"$POLICY_PATH\", \"principal_type\": \"agent\", \"principal_id\": \"$AGENT_ID\", \"permissions\": [\"read\", \"write\"]}" \ + > /dev/null || die "Failed to create policy" +log "Policy created" + +# --- Step 5: Exchange agent key for a scoped JWT --- +log "Exchanging agent key for scoped token..." +TOKEN_RESP=$(curl -sf -w "\n%%{http_code}" \ + -H "Content-Type: application/json" \ + -d "{\"agent_id\": \"$AGENT_ID\", \"api_key\": \"$AGENT_KEY\"}" \ + "$API_URL/v1/auth/agent-token" 2>&1) || die "Token exchange failed" + +TOKEN_HTTP=$(echo "$TOKEN_RESP" | tail -1) +TOKEN_BODY=$(echo "$TOKEN_RESP" | sed '$d') +[ "$${TOKEN_HTTP:0:1}" = "2" ] || die "Token exchange failed (HTTP $TOKEN_HTTP)" + +SCOPED_TOKEN=$(echo "$TOKEN_BODY" | json_get "['access_token']") +log "Got scoped token" + +# --- Step 6: Write state file --- +mkdir -p "$(dirname "$STATE_FILE")" +python3 - "$STATE_FILE" "$VAULT_ID" "$AGENT_ID" "$AGENT_KEY" "$SCOPED_TOKEN" "$WORKSPACE_ID" << 'PYEOF' +import json, sys +state = { + "vault_id": sys.argv[2], + "agent_id": sys.argv[3], + "agent_api_key": sys.argv[4], + "scoped_token": sys.argv[5], + "workspace_id": sys.argv[6] +} +with open(sys.argv[1], "w") as f: + json.dump(state, f, indent=2) +PYEOF +chmod 600 "$STATE_FILE" + +log "Provision complete" +log " Vault: $VAULT_ID" +log " Agent: $AGENT_ID" +log " Key: $${AGENT_KEY:0:12}..." diff --git a/registry/kmjones1979/modules/oneclaw/scripts/setup.sh b/registry/kmjones1979/modules/oneclaw/scripts/setup.sh new file mode 100644 index 000000000..3286531c8 --- /dev/null +++ b/registry/kmjones1979/modules/oneclaw/scripts/setup.sh @@ -0,0 +1,124 @@ +#!/bin/bash +set -euo pipefail + +LOG_PREFIX="[1claw-mcp]" + +log() { + echo "$LOG_PREFIX $*" +} + +API_TOKEN="${API_TOKEN}" +VAULT_ID="${VAULT_ID}" + +# In bootstrap mode, API_TOKEN and VAULT_ID are empty at templatefile time. +# Wait for bootstrap.sh to produce the state file (scripts run concurrently). +BOOTSTRAP_MODE="${BOOTSTRAP_MODE}" +STATE_FILE="$HOME/.1claw/bootstrap.json" +if [ -z "$API_TOKEN" ] && [ "$BOOTSTRAP_MODE" = "true" ]; then + WAIT_SECS=0 + while [ ! -f "$STATE_FILE" ] && [ "$WAIT_SECS" -lt 120 ]; do + log "Waiting for bootstrap to complete ($WAIT_SECS/120s)..." + sleep 3 + WAIT_SECS=$((WAIT_SECS + 3)) + done +fi + +if [ -z "$API_TOKEN" ] && [ -f "$STATE_FILE" ]; then + log "Loading credentials from bootstrap state" + API_TOKEN=$(python3 -c "import json; print(json.load(open('$STATE_FILE'))['agent_api_key'])") + VAULT_ID=$(python3 -c "import json; print(json.load(open('$STATE_FILE'))['vault_id'])") +fi + +if [ -z "$API_TOKEN" ] || [ -z "$VAULT_ID" ]; then + log "WARNING: No API token or vault ID available — skipping MCP config" + log "Provide api_token + vault_id, or use human_api_key for bootstrap mode" + exit 0 +fi + +# Build the MCP config JSON via python3 for safe handling of special characters. +MCP_CONFIG=$( + python3 - "$API_TOKEN" "$VAULT_ID" << 'PYEOF' +import json, sys +config = { + "mcpServers": { + "1claw": { + "url": "${MCP_HOST}", + "headers": { + "Authorization": "Bearer " + sys.argv[1], + "X-Vault-ID": sys.argv[2] + } + } + } +} +print(json.dumps(config, indent=2)) +PYEOF +) + +# Write MCP_CONFIG to a temp file so the merge script can read it safely. +MCP_CONFIG_TMP=$(mktemp) +trap 'rm -f "$MCP_CONFIG_TMP"' EXIT +echo "$MCP_CONFIG" > "$MCP_CONFIG_TMP" + +write_config() { + local target_path="$1" + local label="$2" + + # Expand $HOME in the path + target_path=$(eval echo "$target_path") + + local target_dir + target_dir=$(dirname "$target_path") + + if [ ! -d "$target_dir" ]; then + log "Creating directory $target_dir for $label config" + mkdir -p "$target_dir" + fi + + if [ -f "$target_path" ]; then + log "Merging 1Claw MCP server into existing $label config at $target_path" + if command -v python3 &> /dev/null; then + python3 - "$target_path" "$MCP_CONFIG_TMP" << 'PYEOF' +import json, sys + +target_path = sys.argv[1] +new_config_path = sys.argv[2] + +existing = {} +try: + with open(target_path) as f: + existing = json.load(f) +except (json.JSONDecodeError, FileNotFoundError): + pass + +with open(new_config_path) as f: + new_server = json.load(f) + +existing.setdefault("mcpServers", {}).update(new_server.get("mcpServers", {})) + +with open(target_path, "w") as f: + json.dump(existing, f, indent=2) +PYEOF + else + log "python3 not found — overwriting $target_path" + cat "$MCP_CONFIG_TMP" > "$target_path" + fi + else + log "Writing $label MCP config to $target_path" + cat "$MCP_CONFIG_TMP" > "$target_path" + fi + + chmod 600 "$target_path" + log "$label MCP config ready at $target_path" +} + +# Cursor IDE config +if [ "${INSTALL_CURSOR_CONFIG}" = "true" ]; then + write_config "${CURSOR_CONFIG_PATH}" "Cursor" +fi + +# Claude Code config +if [ "${INSTALL_CLAUDE_CONFIG}" = "true" ]; then + write_config "${CLAUDE_CONFIG_PATH}" "Claude Code" +fi + +log "1Claw MCP setup complete" diff --git a/registry/kmjones1979/modules/oneclaw/variables.tf b/registry/kmjones1979/modules/oneclaw/variables.tf new file mode 100644 index 000000000..564b902d7 --- /dev/null +++ b/registry/kmjones1979/modules/oneclaw/variables.tf @@ -0,0 +1,153 @@ +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "vault_id" { + type = string + description = "The 1Claw vault ID to scope MCP access to. Optional when using bootstrap mode (human_api_key)." + default = "" + + validation { + condition = var.vault_id == "" || can(regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", var.vault_id)) + error_message = "vault_id must be a valid UUID or empty (for bootstrap mode)." + } +} + +variable "api_token" { + type = string + sensitive = true + description = "1Claw agent API key (starts with ocv_). Optional when using bootstrap mode (human_api_key)." + default = "" +} + +variable "human_api_key" { + type = string + sensitive = true + default = "" + description = "One-time human 1ck_ API key for auto-provisioning. On first workspace start, creates a vault, agent, and policy automatically. Credentials are cached in ~/.1claw/bootstrap.json for subsequent starts." +} + +variable "bootstrap_vault_name" { + type = string + default = "coder-workspace" + description = "Name for the auto-created vault (only used when vault_id is not provided and human_api_key is set)." +} + +variable "bootstrap_agent_name" { + type = string + default = "" + description = "Name for the auto-created agent. Defaults to coder-." +} + +variable "bootstrap_policy_path" { + type = string + default = "**" + description = "Secret path pattern for the auto-created policy (glob). Defaults to all secrets." +} + +variable "master_api_key" { + type = string + sensitive = true + default = "" + description = "Human 1ck_ API key for Terraform-native provisioning. Creates vault + agent at terraform apply; cleans up at terraform destroy. Credentials are available as outputs immediately — no shell bootstrap needed." +} + +variable "token_ttl_hours" { + type = number + default = 8 + description = "TTL in hours for the agent's scoped JWT (Terraform-native mode). Set to 0 for the platform default (1 hour)." + + validation { + condition = var.token_ttl_hours >= 0 && var.token_ttl_hours <= 720 + error_message = "token_ttl_hours must be between 0 and 720 (30 days)." + } +} + +variable "auto_destroy_vault" { + type = bool + default = false + description = "Whether to delete the provisioned vault on terraform destroy. When false (default), only the agent is deleted." +} + +variable "provision_vault_name" { + type = string + default = "" + description = "Vault name for Terraform-native provisioning. Defaults to coder-." +} + +variable "provision_agent_name" { + type = string + default = "" + description = "Agent name for Terraform-native provisioning. Defaults to coder--agent." +} + +variable "provision_policy_path" { + type = string + default = "**" + description = "Secret path pattern for the auto-created access policy (Terraform-native mode)." +} + +variable "agent_id_1claw" { + type = string + description = "Optional 1Claw agent UUID. When omitted, the MCP server resolves the agent from the API key prefix." + default = "" +} + +variable "mcp_host" { + type = string + description = "Base URL of the 1Claw MCP server." + default = "https://mcp.1claw.xyz/mcp" + + validation { + condition = can(regex("^https?://", var.mcp_host)) + error_message = "mcp_host must start with http:// or https://." + } +} + +variable "base_url" { + type = string + description = "Base URL of the 1Claw Vault API (used by ONECLAW_BASE_URL env var)." + default = "https://api.1claw.xyz" + + validation { + condition = can(regex("^https?://", var.base_url)) + error_message = "base_url must start with http:// or https://." + } +} + +variable "install_cursor_config" { + type = bool + description = "Whether to write MCP config to the Cursor IDE config path." + default = true +} + +variable "install_claude_config" { + type = bool + description = "Whether to write MCP config to the Claude Code config path." + default = true +} + +variable "cursor_config_path" { + type = string + description = "Path where the Cursor MCP config file is written." + default = "$HOME/.cursor/mcp.json" +} + +variable "claude_config_path" { + type = string + description = "Path where the Claude Code MCP config file is written." + default = "$HOME/.config/claude/mcp.json" +} + +variable "icon" { + type = string + description = "Icon to display for the setup script in the Coder UI." + default = "/icon/vault.svg" +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation." + default = null +}