create-ironclaws 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -0
- package/bin/create.js +394 -0
- package/package.json +33 -0
- package/template/.env.example +38 -0
- package/template/CLAUDE.md +104 -0
- package/template/agent-credentials.yaml +33 -0
- package/template/agents.yaml +22 -0
- package/template/container/Dockerfile +70 -0
- package/template/container/Dockerfile.argus +34 -0
- package/template/container/agent-runner/package-lock.json +1524 -0
- package/template/container/agent-runner/package.json +23 -0
- package/template/container/agent-runner/src/index.ts +630 -0
- package/template/container/agent-runner/src/ipc-mcp-stdio.ts +339 -0
- package/template/container/agent-runner/tsconfig.json +15 -0
- package/template/container/build-argus.sh +25 -0
- package/template/container/build.sh +23 -0
- package/template/container/skills/agent-browser/SKILL.md +159 -0
- package/template/container/skills/agent-status/SKILL.md +69 -0
- package/template/container/skills/capabilities/SKILL.md +100 -0
- package/template/container/skills/edit-agent/SKILL.md +93 -0
- package/template/container/skills/slack-formatting/SKILL.md +92 -0
- package/template/container/skills/status/SKILL.md +104 -0
- package/template/container/tools/elastic_query.py +161 -0
- package/template/container/tools/gdrive_tool.py +185 -0
- package/template/container/tools/jira_tool.py +433 -0
- package/template/container/tools/slack_history_tool.py +144 -0
- package/template/container/tools/youtube_tool.py +174 -0
- package/template/docker-compose.yml +54 -0
- package/template/docs/how-it-works.md +496 -0
- package/template/eslint.config.js +32 -0
- package/template/groups/forge/CLAUDE.md +107 -0
- package/template/package-lock.json +5278 -0
- package/template/package.json +52 -0
- package/template/scripts/github-app-token.py +58 -0
- package/template/scripts/register-expense-agent.sh +121 -0
- package/template/scripts/run-migrations.ts +105 -0
- package/template/scripts/setup-onecli-secrets.sh +252 -0
- package/template/setup-agents.sh +142 -0
- package/template/src/channels/index.ts +13 -0
- package/template/src/channels/registry.test.ts +42 -0
- package/template/src/channels/registry.ts +28 -0
- package/template/src/channels/slack.test.ts +859 -0
- package/template/src/channels/slack.ts +373 -0
- package/template/src/claw-skill.test.ts +45 -0
- package/template/src/config.ts +94 -0
- package/template/src/container-runner.test.ts +221 -0
- package/template/src/container-runner.ts +1029 -0
- package/template/src/container-runtime.test.ts +149 -0
- package/template/src/container-runtime.ts +124 -0
- package/template/src/db-migration.test.ts +67 -0
- package/template/src/db.test.ts +484 -0
- package/template/src/db.ts +837 -0
- package/template/src/env.ts +42 -0
- package/template/src/formatting.test.ts +294 -0
- package/template/src/github-token.ts +48 -0
- package/template/src/google-token.ts +75 -0
- package/template/src/group-folder.test.ts +43 -0
- package/template/src/group-folder.ts +44 -0
- package/template/src/group-queue.test.ts +484 -0
- package/template/src/group-queue.ts +363 -0
- package/template/src/http-server.ts +343 -0
- package/template/src/index.ts +960 -0
- package/template/src/ipc-auth.test.ts +679 -0
- package/template/src/ipc.ts +548 -0
- package/template/src/logger.ts +16 -0
- package/template/src/mount-security.ts +421 -0
- package/template/src/network-policy.ts +119 -0
- package/template/src/remote-control.test.ts +397 -0
- package/template/src/remote-control.ts +224 -0
- package/template/src/router.ts +52 -0
- package/template/src/routing.test.ts +170 -0
- package/template/src/sender-allowlist.test.ts +216 -0
- package/template/src/sender-allowlist.ts +128 -0
- package/template/src/task-scheduler.test.ts +129 -0
- package/template/src/task-scheduler.ts +290 -0
- package/template/src/timezone.test.ts +73 -0
- package/template/src/timezone.ts +37 -0
- package/template/src/types.ts +114 -0
- package/template/src/worktree.ts +206 -0
- package/template/tsconfig.json +20 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nanoclaw",
|
|
3
|
+
"version": "1.2.26",
|
|
4
|
+
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"start": "node dist/index.js",
|
|
10
|
+
"dev": "tsx src/index.ts",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
13
|
+
"format:fix": "prettier --write \"src/**/*.ts\"",
|
|
14
|
+
"format:check": "prettier --check \"src/**/*.ts\"",
|
|
15
|
+
"prepare": "husky",
|
|
16
|
+
"setup": "tsx setup/index.ts",
|
|
17
|
+
"auth": "tsx src/whatsapp-auth.ts",
|
|
18
|
+
"lint": "eslint src/",
|
|
19
|
+
"lint:fix": "eslint src/ --fix",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@onecli-sh/sdk": "^0.2.0",
|
|
25
|
+
"@slack/bolt": "^4.3.0",
|
|
26
|
+
"@slack/types": "^2.15.0",
|
|
27
|
+
"better-sqlite3": "11.10.0",
|
|
28
|
+
"cron-parser": "5.5.0",
|
|
29
|
+
"pino": "^9.6.0",
|
|
30
|
+
"pino-pretty": "^13.0.0",
|
|
31
|
+
"yaml": "^2.8.2",
|
|
32
|
+
"zod": "^4.3.6"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@eslint/js": "^9.35.0",
|
|
36
|
+
"@types/better-sqlite3": "^7.6.12",
|
|
37
|
+
"@types/node": "^22.10.0",
|
|
38
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
39
|
+
"eslint": "^9.35.0",
|
|
40
|
+
"eslint-plugin-no-catch-all": "^1.1.0",
|
|
41
|
+
"globals": "^15.12.0",
|
|
42
|
+
"husky": "^9.1.7",
|
|
43
|
+
"prettier": "^3.8.1",
|
|
44
|
+
"tsx": "^4.19.0",
|
|
45
|
+
"typescript": "^5.7.0",
|
|
46
|
+
"typescript-eslint": "^8.35.0",
|
|
47
|
+
"vitest": "^4.0.18"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Generate a GitHub App installation token for Argus.
|
|
4
|
+
Prints the token to stdout so it can be captured by the caller.
|
|
5
|
+
|
|
6
|
+
Usage: python3 scripts/github-app-token.py
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
import jwt
|
|
15
|
+
|
|
16
|
+
APP_ID = os.environ.get("GITHUB_APP_ID", "3224445")
|
|
17
|
+
INSTALLATION_ID = os.environ.get("GITHUB_APP_INSTALLATION_ID", "120099588")
|
|
18
|
+
PEM_PATH = os.environ.get(
|
|
19
|
+
"GITHUB_APP_PRIVATE_KEY_PATH",
|
|
20
|
+
os.path.join(os.path.dirname(__file__), "..", "github-app.pem"),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_jwt() -> str:
|
|
25
|
+
with open(PEM_PATH) as f:
|
|
26
|
+
private_key = f.read()
|
|
27
|
+
payload = {
|
|
28
|
+
"iat": int(time.time()) - 60,
|
|
29
|
+
"exp": int(time.time()) + 600,
|
|
30
|
+
"iss": APP_ID,
|
|
31
|
+
}
|
|
32
|
+
return jwt.encode(payload, private_key, algorithm="RS256")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_installation_token(jwt_token: str) -> str:
|
|
36
|
+
url = f"https://api.github.com/app/installations/{INSTALLATION_ID}/access_tokens"
|
|
37
|
+
req = urllib.request.Request(
|
|
38
|
+
url,
|
|
39
|
+
method="POST",
|
|
40
|
+
headers={
|
|
41
|
+
"Authorization": f"Bearer {jwt_token}",
|
|
42
|
+
"Accept": "application/vnd.github+json",
|
|
43
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
with urllib.request.urlopen(req) as r:
|
|
47
|
+
data = json.loads(r.read())
|
|
48
|
+
return data["token"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
try:
|
|
53
|
+
jwt_token = generate_jwt()
|
|
54
|
+
token = get_installation_token(jwt_token)
|
|
55
|
+
print(token)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
58
|
+
sys.exit(1)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# register-expense-agent.sh
|
|
3
|
+
#
|
|
4
|
+
# One-command setup for the expense-policy-checker agent.
|
|
5
|
+
# Run this ONCE after you have:
|
|
6
|
+
# 1. Created the private Slack channel and added Linn + Elisabeth
|
|
7
|
+
# 2. Added Expensify credentials to .env
|
|
8
|
+
# 3. Ran sync-to-vm.sh to push the latest files
|
|
9
|
+
#
|
|
10
|
+
# Usage (on VM):
|
|
11
|
+
# EXPENSE_CHANNEL_ID=C... bash scripts/register-expense-agent.sh
|
|
12
|
+
#
|
|
13
|
+
# Or set directly:
|
|
14
|
+
# EXPENSE_CHANNEL_ID=C0987654321 bash scripts/register-expense-agent.sh
|
|
15
|
+
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
CHANNEL_ID="${EXPENSE_CHANNEL_ID:?Set EXPENSE_CHANNEL_ID=C...}"
|
|
19
|
+
ENV_FILE="${ENV_FILE:-$HOME/nanoclaw-docker/.env}"
|
|
20
|
+
API="http://localhost:10254"
|
|
21
|
+
|
|
22
|
+
echo "=== Registering expense-policy-checker agent ==="
|
|
23
|
+
echo ""
|
|
24
|
+
|
|
25
|
+
cd ~/nanoclaw-docker
|
|
26
|
+
|
|
27
|
+
# 1. Register in NanoClaw database
|
|
28
|
+
echo "Registering agent in NanoClaw..."
|
|
29
|
+
npx tsx setup/index.ts register \
|
|
30
|
+
--jid "slack:${CHANNEL_ID}" \
|
|
31
|
+
--name "Expense Policy Checker" \
|
|
32
|
+
--trigger "@Argus" \
|
|
33
|
+
--folder "expense-policy-checker" \
|
|
34
|
+
--channel slack \
|
|
35
|
+
--no-trigger-required
|
|
36
|
+
echo " ✓ Agent registered"
|
|
37
|
+
|
|
38
|
+
# 2. Register Expensify secret in OneCLI
|
|
39
|
+
# Note: Expensify uses credentials in JSON request bodies, not HTTP headers.
|
|
40
|
+
# OneCLI cannot inject body params, so these are passed as env vars.
|
|
41
|
+
# We still register a marker secret so the agent identity is tracked in OneCLI.
|
|
42
|
+
echo ""
|
|
43
|
+
echo "Registering OneCLI agent for expense-policy-checker..."
|
|
44
|
+
|
|
45
|
+
RAND_TOKEN=$(openssl rand -hex 20)
|
|
46
|
+
EXISTING=$(docker exec onecli-postgres-1 psql -U onecli -d onecli -t -c \
|
|
47
|
+
"SELECT COUNT(*) FROM agents WHERE name='expense-policy-checker';" | tr -d ' \n')
|
|
48
|
+
|
|
49
|
+
if [ "$EXISTING" = "0" ]; then
|
|
50
|
+
docker exec onecli-postgres-1 psql -U onecli -d onecli -c \
|
|
51
|
+
"INSERT INTO agents (id, name, access_token, identifier, project_id, created_at, updated_at)
|
|
52
|
+
VALUES ('nanoclaw-expense', 'expense-policy-checker', 'aoc_${RAND_TOKEN}',
|
|
53
|
+
'expense-policy-checker', (SELECT id FROM projects LIMIT 1), NOW(), NOW())
|
|
54
|
+
ON CONFLICT (id) DO NOTHING;" > /dev/null
|
|
55
|
+
echo " ✓ OneCLI agent created"
|
|
56
|
+
else
|
|
57
|
+
echo " ✓ OneCLI agent already exists"
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
# Link litellm (LLM gateway) to expense-policy-checker
|
|
61
|
+
docker exec onecli-postgres-1 psql -U onecli -d onecli -c "
|
|
62
|
+
INSERT INTO agent_secrets (agent_id, secret_id, created_at, updated_at)
|
|
63
|
+
SELECT 'nanoclaw-expense', id, NOW(), NOW()
|
|
64
|
+
FROM secrets WHERE name = 'litellm'
|
|
65
|
+
ON CONFLICT DO NOTHING;
|
|
66
|
+
" > /dev/null
|
|
67
|
+
echo " ✓ LiteLLM secret linked"
|
|
68
|
+
|
|
69
|
+
# 3. Update sender allowlist to allow Linn and Elisabeth
|
|
70
|
+
echo ""
|
|
71
|
+
echo "Updating sender allowlist..."
|
|
72
|
+
ALLOWLIST_FILE="$HOME/.config/nanoclaw/sender-allowlist.json"
|
|
73
|
+
if [ -f "$ALLOWLIST_FILE" ]; then
|
|
74
|
+
# Add the channel with no sender restrictions (Linn + Elisabeth are the only members)
|
|
75
|
+
CURRENT=$(cat "$ALLOWLIST_FILE")
|
|
76
|
+
CHANNEL_ENTRY="\"slack:${CHANNEL_ID}\": {\"mode\": \"allow\", \"senders\": []}"
|
|
77
|
+
if echo "$CURRENT" | grep -q "$CHANNEL_ID"; then
|
|
78
|
+
echo " ✓ Channel already in allowlist"
|
|
79
|
+
else
|
|
80
|
+
echo " ✓ Add this to ~/.config/nanoclaw/sender-allowlist.json manually:"
|
|
81
|
+
echo " \"slack:${CHANNEL_ID}\": {\"mode\": \"allow\", \"senders\": []}"
|
|
82
|
+
fi
|
|
83
|
+
else
|
|
84
|
+
echo " ⚠ No sender-allowlist.json found at $ALLOWLIST_FILE — create it manually"
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# 4. Set up scheduled tasks
|
|
88
|
+
echo ""
|
|
89
|
+
echo "Creating scheduled tasks..."
|
|
90
|
+
|
|
91
|
+
# Daily compliance check (9am Oslo time, Mon-Fri)
|
|
92
|
+
npx tsx setup/index.ts schedule-task \
|
|
93
|
+
--jid "slack:${CHANNEL_ID}" \
|
|
94
|
+
--prompt "Run the daily expense compliance check. Fetch reports for the last ${CHECK_LOOKBACK_DAYS:-7} days, inspect receipts, check policy, notify submitters and approvers of any violations, and update the state file." \
|
|
95
|
+
--cron "0 9 * * 1-5" \
|
|
96
|
+
--name "Daily expense compliance check" 2>/dev/null && \
|
|
97
|
+
echo " ✓ Daily check scheduled (9am Mon-Fri Oslo time)" || \
|
|
98
|
+
echo " ⚠ Could not schedule daily task — set it up via Global Claw or manually"
|
|
99
|
+
|
|
100
|
+
# Weekly report (Monday 8am)
|
|
101
|
+
npx tsx setup/index.ts schedule-task \
|
|
102
|
+
--jid "slack:${CHANNEL_ID}" \
|
|
103
|
+
--prompt "Generate and post the weekly expense compliance report to the finance Slack channel." \
|
|
104
|
+
--cron "0 8 * * 1" \
|
|
105
|
+
--name "Weekly expense report" 2>/dev/null && \
|
|
106
|
+
echo " ✓ Weekly report scheduled (8am Mondays)" || \
|
|
107
|
+
echo " ⚠ Could not schedule weekly task — set it up via Global Claw or manually"
|
|
108
|
+
|
|
109
|
+
echo ""
|
|
110
|
+
echo "=== Done! ==="
|
|
111
|
+
echo ""
|
|
112
|
+
echo "Next steps:"
|
|
113
|
+
echo " 1. Add Expensify credentials to .env on the VM:"
|
|
114
|
+
echo " EXPENSIFY_PARTNER_USER_ID=..."
|
|
115
|
+
echo " EXPENSIFY_PARTNER_USER_SECRET=..."
|
|
116
|
+
echo " EXPENSIFY_POLICY_ID=..."
|
|
117
|
+
echo " EXPENSE_CHANNEL_ID=${CHANNEL_ID}"
|
|
118
|
+
echo ""
|
|
119
|
+
echo " 2. Restart NanoClaw: (kill tsx and restart npm run dev)"
|
|
120
|
+
echo ""
|
|
121
|
+
echo " 3. Test by sending 'run expense check' in the channel"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { execFileSync, execSync } from 'child_process';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
function compareSemver(a: string, b: string): number {
|
|
7
|
+
const partsA = a.split('.').map(Number);
|
|
8
|
+
const partsB = b.split('.').map(Number);
|
|
9
|
+
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
|
10
|
+
const diff = (partsA[i] || 0) - (partsB[i] || 0);
|
|
11
|
+
if (diff !== 0) return diff;
|
|
12
|
+
}
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Resolve tsx binary once to avoid npx race conditions across migrations
|
|
17
|
+
function resolveTsx(): string {
|
|
18
|
+
// Check local node_modules first
|
|
19
|
+
const local = path.resolve('node_modules/.bin/tsx');
|
|
20
|
+
if (fs.existsSync(local)) return local;
|
|
21
|
+
// Fall back to whichever tsx is in PATH
|
|
22
|
+
try {
|
|
23
|
+
return execSync('which tsx', { encoding: 'utf-8' }).trim();
|
|
24
|
+
} catch {
|
|
25
|
+
return 'npx'; // last resort
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const tsxBin = resolveTsx();
|
|
30
|
+
|
|
31
|
+
const fromVersion = process.argv[2];
|
|
32
|
+
const toVersion = process.argv[3];
|
|
33
|
+
const newCorePath = process.argv[4];
|
|
34
|
+
|
|
35
|
+
if (!fromVersion || !toVersion || !newCorePath) {
|
|
36
|
+
console.error(
|
|
37
|
+
'Usage: tsx scripts/run-migrations.ts <from-version> <to-version> <new-core-path>',
|
|
38
|
+
);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface MigrationResult {
|
|
43
|
+
version: string;
|
|
44
|
+
success: boolean;
|
|
45
|
+
error?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const results: MigrationResult[] = [];
|
|
49
|
+
|
|
50
|
+
// Look for migrations in the new core
|
|
51
|
+
const migrationsDir = path.join(newCorePath, 'migrations');
|
|
52
|
+
|
|
53
|
+
if (!fs.existsSync(migrationsDir)) {
|
|
54
|
+
console.log(JSON.stringify({ migrationsRun: 0, results: [] }, null, 2));
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Discover migration directories (version-named)
|
|
59
|
+
const entries = fs.readdirSync(migrationsDir, { withFileTypes: true });
|
|
60
|
+
const migrationVersions = entries
|
|
61
|
+
.filter((e) => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name))
|
|
62
|
+
.map((e) => e.name)
|
|
63
|
+
.filter(
|
|
64
|
+
(v) =>
|
|
65
|
+
compareSemver(v, fromVersion) > 0 && compareSemver(v, toVersion) <= 0,
|
|
66
|
+
)
|
|
67
|
+
.sort(compareSemver);
|
|
68
|
+
|
|
69
|
+
const projectRoot = process.cwd();
|
|
70
|
+
|
|
71
|
+
for (const version of migrationVersions) {
|
|
72
|
+
const migrationIndex = path.join(migrationsDir, version, 'index.ts');
|
|
73
|
+
if (!fs.existsSync(migrationIndex)) {
|
|
74
|
+
results.push({
|
|
75
|
+
version,
|
|
76
|
+
success: false,
|
|
77
|
+
error: `Migration ${version}/index.ts not found`,
|
|
78
|
+
});
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const tsxArgs = tsxBin.endsWith('npx')
|
|
84
|
+
? ['tsx', migrationIndex, projectRoot]
|
|
85
|
+
: [migrationIndex, projectRoot];
|
|
86
|
+
execFileSync(tsxBin, tsxArgs, {
|
|
87
|
+
stdio: 'pipe',
|
|
88
|
+
cwd: projectRoot,
|
|
89
|
+
timeout: 120_000,
|
|
90
|
+
});
|
|
91
|
+
results.push({ version, success: true });
|
|
92
|
+
} catch (err) {
|
|
93
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
94
|
+
results.push({ version, success: false, error: message });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(
|
|
99
|
+
JSON.stringify({ migrationsRun: results.length, results }, null, 2),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Exit with error if any migration failed
|
|
103
|
+
if (results.some((r) => !r.success)) {
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# setup-onecli-secrets.sh
|
|
3
|
+
#
|
|
4
|
+
# Configure OneCLI secrets via the LOCAL HTTP API (handles encryption correctly).
|
|
5
|
+
# Direct DB insertion bypasses encryption and breaks the gateway — never do that.
|
|
6
|
+
#
|
|
7
|
+
# Run this on the VM after deploying NanoClaw:
|
|
8
|
+
# cd ~/nanoclaw-docker && bash scripts/setup-onecli-secrets.sh
|
|
9
|
+
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
ENV_FILE="${ENV_FILE:-$(dirname "$0")/../.env}"
|
|
13
|
+
API="http://localhost:10254"
|
|
14
|
+
|
|
15
|
+
# Read ONECLI_API_KEY from env or .env file
|
|
16
|
+
ONECLI_API_KEY="${ONECLI_API_KEY:-$(grep -E '^ONECLI_API_KEY=' "$ENV_FILE" 2>/dev/null | head -1 | sed 's/^[^=]*=//' | tr -d '"'"'" || true)}"
|
|
17
|
+
|
|
18
|
+
# Wrapper: curl with optional Bearer auth
|
|
19
|
+
api_curl() {
|
|
20
|
+
if [ -n "$ONECLI_API_KEY" ]; then
|
|
21
|
+
curl -s -H "Authorization: Bearer $ONECLI_API_KEY" "$@"
|
|
22
|
+
else
|
|
23
|
+
curl -s "$@"
|
|
24
|
+
fi
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
env_val() {
|
|
30
|
+
grep -E "^${1}=" "$ENV_FILE" | head -1 | sed 's/^[^=]*=//' | tr -d '"'"'"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Create or update a secret via the API (handles AES encryption automatically).
|
|
34
|
+
# If a secret with the same name already exists, delete it first.
|
|
35
|
+
upsert_secret() {
|
|
36
|
+
local name="$1"
|
|
37
|
+
local host_pattern="$2"
|
|
38
|
+
local header_name="$3"
|
|
39
|
+
local value="$4"
|
|
40
|
+
|
|
41
|
+
# Delete existing secret with this name (if any)
|
|
42
|
+
local existing_id
|
|
43
|
+
existing_id=$(api_curl "$API/api/secrets" | python3 -c "
|
|
44
|
+
|
|
45
|
+
import json,sys
|
|
46
|
+
secrets = json.load(sys.stdin)
|
|
47
|
+
match = next((s for s in secrets if s['name'] == '${name}'), None)
|
|
48
|
+
print(match['id'] if match else '')
|
|
49
|
+
" 2>/dev/null || echo "")
|
|
50
|
+
|
|
51
|
+
if [ -n "$existing_id" ]; then
|
|
52
|
+
api_curl -X DELETE "$API/api/secrets/$existing_id" > /dev/null
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# Create via API — value is encrypted by the API before storing
|
|
56
|
+
local result
|
|
57
|
+
result=$(api_curl -X POST "$API/api/secrets" \
|
|
58
|
+
-H 'Content-Type: application/json' \
|
|
59
|
+
-d "$(python3 -c "
|
|
60
|
+
import json
|
|
61
|
+
print(json.dumps({
|
|
62
|
+
'name': '${name}',
|
|
63
|
+
'type': 'generic',
|
|
64
|
+
'value': '''${value}''',
|
|
65
|
+
'hostPattern': '${host_pattern}',
|
|
66
|
+
'injectionConfig': {'headerName': '${header_name}'}
|
|
67
|
+
}))
|
|
68
|
+
")")
|
|
69
|
+
|
|
70
|
+
if echo "$result" | grep -q '"error"'; then
|
|
71
|
+
echo " ✗ Failed '${name}': $result"
|
|
72
|
+
else
|
|
73
|
+
echo " ✓ Secret '${name}' → ${host_pattern} (${header_name})"
|
|
74
|
+
fi
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
link_secret_to_agent() {
|
|
78
|
+
local secret_name="$1"
|
|
79
|
+
local agent_name="$2"
|
|
80
|
+
|
|
81
|
+
# Match by identifier (set to folder name by setup-agents.sh) first,
|
|
82
|
+
# fall back to name for agents registered before identifier was used.
|
|
83
|
+
docker exec onecli-postgres-1 psql -U onecli -d onecli -c "
|
|
84
|
+
INSERT INTO agent_secrets (agent_id, secret_id, created_at, updated_at)
|
|
85
|
+
SELECT a.id, s.id, NOW(), NOW()
|
|
86
|
+
FROM agents a, secrets s
|
|
87
|
+
WHERE (a.identifier = '${agent_name}' OR (COALESCE(a.identifier, '') = '' AND a.name = '${agent_name}'))
|
|
88
|
+
AND s.name = '${secret_name}'
|
|
89
|
+
ON CONFLICT DO NOTHING;
|
|
90
|
+
" > /dev/null 2>&1
|
|
91
|
+
|
|
92
|
+
local linked
|
|
93
|
+
linked=$(docker exec onecli-postgres-1 psql -U onecli -d onecli -t -c "
|
|
94
|
+
SELECT COUNT(*) FROM agent_secrets ags
|
|
95
|
+
JOIN agents a ON a.id = ags.agent_id
|
|
96
|
+
JOIN secrets s ON s.id = ags.secret_id
|
|
97
|
+
WHERE (a.identifier = '${agent_name}' OR (COALESCE(a.identifier, '') = '' AND a.name = '${agent_name}'))
|
|
98
|
+
AND s.name = '${secret_name}';
|
|
99
|
+
" | tr -d ' \n')
|
|
100
|
+
|
|
101
|
+
if [ "$linked" = "1" ]; then
|
|
102
|
+
echo " ✓ Linked '${secret_name}' → '${agent_name}'"
|
|
103
|
+
else
|
|
104
|
+
echo " ⚠ Could not link '${secret_name}' to '${agent_name}'"
|
|
105
|
+
fi
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# ── Main ─────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
if [ ! -f "$ENV_FILE" ]; then
|
|
111
|
+
echo "ERROR: .env not found at $ENV_FILE"
|
|
112
|
+
exit 1
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
echo "Reading credentials from $ENV_FILE"
|
|
116
|
+
echo ""
|
|
117
|
+
|
|
118
|
+
# Read all credentials
|
|
119
|
+
ELASTIC_API_KEY=$(env_val "ELASTIC_API_KEY")
|
|
120
|
+
ELASTIC_BASE_URL=$(env_val "ELASTIC_BASE_URL")
|
|
121
|
+
ELASTIC_HOST=$(echo "$ELASTIC_BASE_URL" | sed 's|^https://||;s|^http://||' | cut -d/ -f1)
|
|
122
|
+
|
|
123
|
+
JIRA_EMAIL=$(env_val "JIRA_EMAIL")
|
|
124
|
+
JIRA_API_TOKEN=$(env_val "JIRA_API_TOKEN")
|
|
125
|
+
JIRA_BASE_URL=$(env_val "JIRA_BASE_URL")
|
|
126
|
+
JIRA_HOST=$(echo "$JIRA_BASE_URL" | sed 's|^https://||;s|^http://||' | cut -d/ -f1)
|
|
127
|
+
JIRA_BASIC=$(printf '%s:%s' "$JIRA_EMAIL" "$JIRA_API_TOKEN" | base64 | tr -d '\n')
|
|
128
|
+
|
|
129
|
+
CONFLUENCE_USERNAME=$(env_val "CONFLUENCE_USERNAME")
|
|
130
|
+
CONFLUENCE_PASSWORD=$(env_val "CONFLUENCE_PASSWORD")
|
|
131
|
+
CONFLUENCE_BASE_URL=$(env_val "CONFLUENCE_BASE_URL")
|
|
132
|
+
CONFLUENCE_HOST=$(echo "$CONFLUENCE_BASE_URL" | sed 's|^https://||;s|^http://||' | cut -d/ -f1)
|
|
133
|
+
CONFLUENCE_BASIC=$(printf '%s:%s' "$CONFLUENCE_USERNAME" "$CONFLUENCE_PASSWORD" | base64 | tr -d '\n')
|
|
134
|
+
|
|
135
|
+
SLACK_BOT_TOKEN=$(env_val "SLACK_BOT_TOKEN")
|
|
136
|
+
INTERCOM_ACCESS_TOKEN=$(env_val "INTERCOM_ACCESS_TOKEN")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
ANTHROPIC_AUTH_TOKEN=$(env_val "ANTHROPIC_AUTH_TOKEN")
|
|
140
|
+
LLM_GATEWAY=$(env_val "ANTHROPIC_BASE_URL")
|
|
141
|
+
LLM_GATEWAY_HOST=$(echo "$LLM_GATEWAY" | sed 's|^https://||;s|^http://||' | cut -d/ -f1)
|
|
142
|
+
|
|
143
|
+
# ── Register secrets via API (with proper encryption) ─────────────────────────
|
|
144
|
+
|
|
145
|
+
echo "Registering secrets..."
|
|
146
|
+
echo ""
|
|
147
|
+
|
|
148
|
+
[ -n "$ELASTIC_API_KEY" ] && [ -n "$ELASTIC_HOST" ] && \
|
|
149
|
+
upsert_secret "elastic" "$ELASTIC_HOST" "Authorization" "ApiKey $ELASTIC_API_KEY" || \
|
|
150
|
+
echo " ⚠ Skipping elastic"
|
|
151
|
+
|
|
152
|
+
[ -n "$JIRA_BASIC" ] && [ -n "$JIRA_HOST" ] && \
|
|
153
|
+
upsert_secret "jira" "$JIRA_HOST" "Authorization" "Basic $JIRA_BASIC" || \
|
|
154
|
+
echo " ⚠ Skipping jira"
|
|
155
|
+
|
|
156
|
+
# Confluence: only if host differs from Jira (usually same Atlassian instance)
|
|
157
|
+
if [ -n "$CONFLUENCE_BASIC" ] && [ -n "$CONFLUENCE_HOST" ] && [ "$CONFLUENCE_HOST" != "$JIRA_HOST" ]; then
|
|
158
|
+
upsert_secret "confluence" "$CONFLUENCE_HOST" "Authorization" "Basic $CONFLUENCE_BASIC"
|
|
159
|
+
else
|
|
160
|
+
echo " ✓ Confluence shares host with Jira — using jira secret for both"
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
[ -n "$SLACK_BOT_TOKEN" ] && \
|
|
164
|
+
upsert_secret "slack" "slack.com" "Authorization" "Bearer $SLACK_BOT_TOKEN" || \
|
|
165
|
+
echo " ⚠ Skipping slack"
|
|
166
|
+
|
|
167
|
+
[ -n "$INTERCOM_ACCESS_TOKEN" ] && \
|
|
168
|
+
upsert_secret "intercom" "api.intercom.io" "Authorization" "Bearer $INTERCOM_ACCESS_TOKEN" || \
|
|
169
|
+
echo " ⚠ Skipping intercom"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
[ -n "$ANTHROPIC_AUTH_TOKEN" ] && [ -n "$LLM_GATEWAY_HOST" ] && \
|
|
173
|
+
upsert_secret "litellm" "$LLM_GATEWAY_HOST" "Authorization" "Bearer $ANTHROPIC_AUTH_TOKEN" || \
|
|
174
|
+
echo " ⚠ Skipping litellm"
|
|
175
|
+
|
|
176
|
+
echo ""
|
|
177
|
+
|
|
178
|
+
# ── Register global-claw agent if missing ────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
echo "Ensuring agents exist..."
|
|
181
|
+
GLOBAL_CLAW_EXISTS=$(docker exec onecli-postgres-1 psql -U onecli -d onecli -t -c \
|
|
182
|
+
"SELECT COUNT(*) FROM agents WHERE name='global-claw';" | tr -d ' \n')
|
|
183
|
+
|
|
184
|
+
if [ "$GLOBAL_CLAW_EXISTS" = "0" ]; then
|
|
185
|
+
RAND_TOKEN=$(openssl rand -hex 20)
|
|
186
|
+
# Try new schema (account_id) first, fall back to old schema (project_id)
|
|
187
|
+
docker exec onecli-postgres-1 psql -U onecli -d onecli -c \
|
|
188
|
+
"INSERT INTO agents (id, name, access_token, identifier, secret_mode, account_id, created_at, updated_at)
|
|
189
|
+
SELECT 'nanoclaw-global-claw', 'global-claw', 'aoc_${RAND_TOKEN}', 'global-claw', 'selective', id, NOW(), NOW()
|
|
190
|
+
FROM accounts LIMIT 1
|
|
191
|
+
ON CONFLICT (id) DO NOTHING;" > /dev/null 2>&1 || \
|
|
192
|
+
docker exec onecli-postgres-1 psql -U onecli -d onecli -c \
|
|
193
|
+
"INSERT INTO agents (id, name, access_token, identifier, secret_mode, project_id, created_at, updated_at)
|
|
194
|
+
VALUES ('nanoclaw-global-claw', 'global-claw', 'aoc_${RAND_TOKEN}', 'global-claw', 'selective',
|
|
195
|
+
(SELECT id FROM projects LIMIT 1), NOW(), NOW())
|
|
196
|
+
ON CONFLICT (id) DO NOTHING;" > /dev/null 2>&1
|
|
197
|
+
echo " ✓ Created global-claw agent"
|
|
198
|
+
else
|
|
199
|
+
echo " ✓ global-claw agent already exists"
|
|
200
|
+
fi
|
|
201
|
+
echo ""
|
|
202
|
+
|
|
203
|
+
# ── Link secrets to agents (driven by agents.yaml — single source of truth) ───
|
|
204
|
+
#
|
|
205
|
+
# Reads onecli_secrets from agents.yaml for each agent.
|
|
206
|
+
# Adding a new agent with onecli_secrets there is all that's needed —
|
|
207
|
+
# no changes to this script required.
|
|
208
|
+
|
|
209
|
+
AGENTS_YAML="${AGENTS_YAML:-$(dirname "$0")/../agents.yaml}"
|
|
210
|
+
|
|
211
|
+
if [ ! -f "$AGENTS_YAML" ]; then
|
|
212
|
+
echo "ERROR: agents.yaml not found at $AGENTS_YAML"
|
|
213
|
+
exit 1
|
|
214
|
+
fi
|
|
215
|
+
|
|
216
|
+
# Ensure PyYAML is available
|
|
217
|
+
if ! python3 -c "import yaml" 2>/dev/null; then
|
|
218
|
+
echo "Installing PyYAML (required for agents.yaml parsing)..."
|
|
219
|
+
pip3 install pyyaml --quiet --break-system-packages 2>/dev/null || \
|
|
220
|
+
pip3 install pyyaml --quiet 2>/dev/null || \
|
|
221
|
+
pip install pyyaml --quiet 2>/dev/null || \
|
|
222
|
+
{ echo "ERROR: Could not install PyYAML. Run: pip3 install pyyaml"; exit 1; }
|
|
223
|
+
fi
|
|
224
|
+
|
|
225
|
+
echo "Linking secrets to agents (from agents.yaml)..."
|
|
226
|
+
echo ""
|
|
227
|
+
|
|
228
|
+
# Parse agents.yaml → emit "secret_name agent_folder" lines
|
|
229
|
+
LINKS=$(AGENTS_YAML_PATH="$AGENTS_YAML" python3 - << 'PYEOF'
|
|
230
|
+
import yaml, os
|
|
231
|
+
|
|
232
|
+
with open(os.environ["AGENTS_YAML_PATH"]) as f:
|
|
233
|
+
config = yaml.safe_load(f)
|
|
234
|
+
|
|
235
|
+
for agent in config.get("agents", []):
|
|
236
|
+
folder = agent.get("folder", "")
|
|
237
|
+
secrets = agent.get("onecli_secrets", [])
|
|
238
|
+
for secret in secrets:
|
|
239
|
+
print(f"{secret} {folder}")
|
|
240
|
+
PYEOF
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if [ -z "$LINKS" ]; then
|
|
244
|
+
echo " ⚠ No agent-secret links found in agents.yaml"
|
|
245
|
+
else
|
|
246
|
+
while IFS=' ' read -r secret agent; do
|
|
247
|
+
[ -n "$secret" ] && [ -n "$agent" ] && link_secret_to_agent "$secret" "$agent"
|
|
248
|
+
done <<< "$LINKS"
|
|
249
|
+
fi
|
|
250
|
+
|
|
251
|
+
echo ""
|
|
252
|
+
echo "Done. Verify with: curl http://localhost:10254/api/secrets | python3 -m json.tool | grep name"
|