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,149 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock logger
|
|
4
|
+
vi.mock('./logger.js', () => ({
|
|
5
|
+
logger: {
|
|
6
|
+
debug: vi.fn(),
|
|
7
|
+
info: vi.fn(),
|
|
8
|
+
warn: vi.fn(),
|
|
9
|
+
error: vi.fn(),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Mock child_process — store the mock fn so tests can configure it
|
|
14
|
+
const mockExecSync = vi.fn();
|
|
15
|
+
vi.mock('child_process', () => ({
|
|
16
|
+
execSync: (...args: unknown[]) => mockExecSync(...args),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
CONTAINER_RUNTIME_BIN,
|
|
21
|
+
readonlyMountArgs,
|
|
22
|
+
stopContainer,
|
|
23
|
+
ensureContainerRuntimeRunning,
|
|
24
|
+
cleanupOrphans,
|
|
25
|
+
} from './container-runtime.js';
|
|
26
|
+
import { logger } from './logger.js';
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// --- Pure functions ---
|
|
33
|
+
|
|
34
|
+
describe('readonlyMountArgs', () => {
|
|
35
|
+
it('returns -v flag with :ro suffix', () => {
|
|
36
|
+
const args = readonlyMountArgs('/host/path', '/container/path');
|
|
37
|
+
expect(args).toEqual(['-v', '/host/path:/container/path:ro']);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('stopContainer', () => {
|
|
42
|
+
it('returns stop command using CONTAINER_RUNTIME_BIN', () => {
|
|
43
|
+
expect(stopContainer('nanoclaw-test-123')).toBe(
|
|
44
|
+
`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// --- ensureContainerRuntimeRunning ---
|
|
50
|
+
|
|
51
|
+
describe('ensureContainerRuntimeRunning', () => {
|
|
52
|
+
it('does nothing when runtime is already running', () => {
|
|
53
|
+
mockExecSync.mockReturnValueOnce('');
|
|
54
|
+
|
|
55
|
+
ensureContainerRuntimeRunning();
|
|
56
|
+
|
|
57
|
+
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, {
|
|
59
|
+
stdio: 'pipe',
|
|
60
|
+
timeout: 10000,
|
|
61
|
+
});
|
|
62
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
63
|
+
'Container runtime already running',
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('throws when docker info fails', () => {
|
|
68
|
+
mockExecSync.mockImplementationOnce(() => {
|
|
69
|
+
throw new Error('Cannot connect to the Docker daemon');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(() => ensureContainerRuntimeRunning()).toThrow(
|
|
73
|
+
'Container runtime is required but failed to start',
|
|
74
|
+
);
|
|
75
|
+
expect(logger.error).toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// --- cleanupOrphans ---
|
|
80
|
+
|
|
81
|
+
describe('cleanupOrphans', () => {
|
|
82
|
+
it('stops orphaned nanoclaw containers', () => {
|
|
83
|
+
// docker ps returns container names, one per line
|
|
84
|
+
mockExecSync.mockReturnValueOnce(
|
|
85
|
+
'nanoclaw-group1-111\nnanoclaw-group2-222\n',
|
|
86
|
+
);
|
|
87
|
+
// stop calls succeed
|
|
88
|
+
mockExecSync.mockReturnValue('');
|
|
89
|
+
|
|
90
|
+
cleanupOrphans();
|
|
91
|
+
|
|
92
|
+
// ps + 2 stop calls
|
|
93
|
+
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
|
94
|
+
expect(mockExecSync).toHaveBeenNthCalledWith(
|
|
95
|
+
2,
|
|
96
|
+
`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group1-111`,
|
|
97
|
+
{ stdio: 'pipe' },
|
|
98
|
+
);
|
|
99
|
+
expect(mockExecSync).toHaveBeenNthCalledWith(
|
|
100
|
+
3,
|
|
101
|
+
`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`,
|
|
102
|
+
{ stdio: 'pipe' },
|
|
103
|
+
);
|
|
104
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
105
|
+
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] },
|
|
106
|
+
'Stopped orphaned containers',
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('does nothing when no orphans exist', () => {
|
|
111
|
+
mockExecSync.mockReturnValueOnce('');
|
|
112
|
+
|
|
113
|
+
cleanupOrphans();
|
|
114
|
+
|
|
115
|
+
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
|
116
|
+
expect(logger.info).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('warns and continues when ps fails', () => {
|
|
120
|
+
mockExecSync.mockImplementationOnce(() => {
|
|
121
|
+
throw new Error('docker not available');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
cleanupOrphans(); // should not throw
|
|
125
|
+
|
|
126
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
127
|
+
expect.objectContaining({ err: expect.any(Error) }),
|
|
128
|
+
'Failed to clean up orphaned containers',
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('continues stopping remaining containers when one stop fails', () => {
|
|
133
|
+
mockExecSync.mockReturnValueOnce('nanoclaw-a-1\nnanoclaw-b-2\n');
|
|
134
|
+
// First stop fails
|
|
135
|
+
mockExecSync.mockImplementationOnce(() => {
|
|
136
|
+
throw new Error('already stopped');
|
|
137
|
+
});
|
|
138
|
+
// Second stop succeeds
|
|
139
|
+
mockExecSync.mockReturnValueOnce('');
|
|
140
|
+
|
|
141
|
+
cleanupOrphans(); // should not throw
|
|
142
|
+
|
|
143
|
+
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
|
144
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
145
|
+
{ count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] },
|
|
146
|
+
'Stopped orphaned containers',
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container runtime abstraction for NanoClaw.
|
|
3
|
+
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
|
4
|
+
*/
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
|
|
8
|
+
const IPTABLES_SUPPORTED = os.platform() === 'linux';
|
|
9
|
+
|
|
10
|
+
import { logger } from './logger.js';
|
|
11
|
+
|
|
12
|
+
/** The container runtime binary name. */
|
|
13
|
+
export const CONTAINER_RUNTIME_BIN = 'docker';
|
|
14
|
+
|
|
15
|
+
/** CLI args needed for the container to resolve the host gateway. */
|
|
16
|
+
export function hostGatewayArgs(): string[] {
|
|
17
|
+
// On Linux, host.docker.internal isn't built-in — add it explicitly
|
|
18
|
+
if (os.platform() === 'linux') {
|
|
19
|
+
return ['--add-host=host.docker.internal:host-gateway'];
|
|
20
|
+
}
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Returns CLI args for a readonly bind mount. */
|
|
25
|
+
export function readonlyMountArgs(
|
|
26
|
+
hostPath: string,
|
|
27
|
+
containerPath: string,
|
|
28
|
+
): string[] {
|
|
29
|
+
return ['-v', `${hostPath}:${containerPath}:ro`];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Returns the shell command to stop a container by name. */
|
|
33
|
+
export function stopContainer(name: string): string {
|
|
34
|
+
return `${CONTAINER_RUNTIME_BIN} stop -t 1 ${name}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Ensure the container runtime is running, starting it if needed. */
|
|
38
|
+
export function ensureContainerRuntimeRunning(): void {
|
|
39
|
+
try {
|
|
40
|
+
execSync(`${CONTAINER_RUNTIME_BIN} info`, {
|
|
41
|
+
stdio: 'pipe',
|
|
42
|
+
timeout: 10000,
|
|
43
|
+
});
|
|
44
|
+
logger.debug('Container runtime already running');
|
|
45
|
+
} catch (err) {
|
|
46
|
+
logger.error({ err }, 'Failed to reach container runtime');
|
|
47
|
+
console.error(
|
|
48
|
+
'\n╔════════════════════════════════════════════════════════════════╗',
|
|
49
|
+
);
|
|
50
|
+
console.error(
|
|
51
|
+
'║ FATAL: Container runtime failed to start ║',
|
|
52
|
+
);
|
|
53
|
+
console.error(
|
|
54
|
+
'║ ║',
|
|
55
|
+
);
|
|
56
|
+
console.error(
|
|
57
|
+
'║ Agents cannot run without a container runtime. To fix: ║',
|
|
58
|
+
);
|
|
59
|
+
console.error(
|
|
60
|
+
'║ 1. Ensure Docker is installed and running ║',
|
|
61
|
+
);
|
|
62
|
+
console.error(
|
|
63
|
+
'║ 2. Run: docker info ║',
|
|
64
|
+
);
|
|
65
|
+
console.error(
|
|
66
|
+
'║ 3. Restart NanoClaw ║',
|
|
67
|
+
);
|
|
68
|
+
console.error(
|
|
69
|
+
'╚════════════════════════════════════════════════════════════════╝\n',
|
|
70
|
+
);
|
|
71
|
+
throw new Error('Container runtime is required but failed to start', {
|
|
72
|
+
cause: err,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Kill orphaned NanoClaw containers from previous runs. */
|
|
78
|
+
export function cleanupOrphans(): void {
|
|
79
|
+
try {
|
|
80
|
+
const output = execSync(
|
|
81
|
+
`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`,
|
|
82
|
+
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' },
|
|
83
|
+
);
|
|
84
|
+
const orphans = output.trim().split('\n').filter(Boolean);
|
|
85
|
+
for (const name of orphans) {
|
|
86
|
+
try {
|
|
87
|
+
execSync(stopContainer(name), { stdio: 'pipe' });
|
|
88
|
+
} catch {
|
|
89
|
+
/* already stopped */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (orphans.length > 0) {
|
|
93
|
+
logger.info(
|
|
94
|
+
{ count: orphans.length, names: orphans },
|
|
95
|
+
'Stopped orphaned containers',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
logger.warn({ err }, 'Failed to clean up orphaned containers');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Remove stale iptables rules from any previous NanoClaw run. */
|
|
104
|
+
export function cleanupStaleIptablesRules(): void {
|
|
105
|
+
if (!IPTABLES_SUPPORTED) return;
|
|
106
|
+
try {
|
|
107
|
+
const rules = execSync(
|
|
108
|
+
`sudo iptables -S DOCKER-USER 2>/dev/null | grep 'nanoclaw-'`,
|
|
109
|
+
{ encoding: 'utf-8', timeout: 5000 },
|
|
110
|
+
).trim();
|
|
111
|
+
if (!rules) return;
|
|
112
|
+
let cleaned = 0;
|
|
113
|
+
for (const rule of rules.split('\n')) {
|
|
114
|
+
const deleteCmd = rule.replace(/^-[AI]\s+DOCKER-USER\s+/, '-D DOCKER-USER ');
|
|
115
|
+
try {
|
|
116
|
+
execSync(`sudo iptables ${deleteCmd}`, { stdio: 'pipe', timeout: 5000 });
|
|
117
|
+
cleaned++;
|
|
118
|
+
} catch { /* already gone */ }
|
|
119
|
+
}
|
|
120
|
+
if (cleaned > 0) {
|
|
121
|
+
logger.info({ count: cleaned }, 'Cleaned up stale iptables rules from previous run');
|
|
122
|
+
}
|
|
123
|
+
} catch { /* no stale rules */ }
|
|
124
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
describe('database migrations', () => {
|
|
8
|
+
it('defaults Telegram backfill chats to direct messages', async () => {
|
|
9
|
+
const repoRoot = process.cwd();
|
|
10
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-db-test-'));
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
process.chdir(tempDir);
|
|
14
|
+
fs.mkdirSync(path.join(tempDir, 'store'), { recursive: true });
|
|
15
|
+
|
|
16
|
+
const dbPath = path.join(tempDir, 'store', 'messages.db');
|
|
17
|
+
const legacyDb = new Database(dbPath);
|
|
18
|
+
legacyDb.exec(`
|
|
19
|
+
CREATE TABLE chats (
|
|
20
|
+
jid TEXT PRIMARY KEY,
|
|
21
|
+
name TEXT,
|
|
22
|
+
last_message_time TEXT
|
|
23
|
+
);
|
|
24
|
+
`);
|
|
25
|
+
legacyDb
|
|
26
|
+
.prepare(
|
|
27
|
+
`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`,
|
|
28
|
+
)
|
|
29
|
+
.run('tg:12345', 'Telegram DM', '2024-01-01T00:00:00.000Z');
|
|
30
|
+
legacyDb
|
|
31
|
+
.prepare(
|
|
32
|
+
`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`,
|
|
33
|
+
)
|
|
34
|
+
.run('tg:-10012345', 'Telegram Group', '2024-01-01T00:00:01.000Z');
|
|
35
|
+
legacyDb
|
|
36
|
+
.prepare(
|
|
37
|
+
`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`,
|
|
38
|
+
)
|
|
39
|
+
.run('room@g.us', 'WhatsApp Group', '2024-01-01T00:00:02.000Z');
|
|
40
|
+
legacyDb.close();
|
|
41
|
+
|
|
42
|
+
vi.resetModules();
|
|
43
|
+
const { initDatabase, getAllChats, _closeDatabase } =
|
|
44
|
+
await import('./db.js');
|
|
45
|
+
|
|
46
|
+
initDatabase();
|
|
47
|
+
|
|
48
|
+
const chats = getAllChats();
|
|
49
|
+
expect(chats.find((chat) => chat.jid === 'tg:12345')).toMatchObject({
|
|
50
|
+
channel: 'telegram',
|
|
51
|
+
is_group: 0,
|
|
52
|
+
});
|
|
53
|
+
expect(chats.find((chat) => chat.jid === 'tg:-10012345')).toMatchObject({
|
|
54
|
+
channel: 'telegram',
|
|
55
|
+
is_group: 0,
|
|
56
|
+
});
|
|
57
|
+
expect(chats.find((chat) => chat.jid === 'room@g.us')).toMatchObject({
|
|
58
|
+
channel: 'whatsapp',
|
|
59
|
+
is_group: 1,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
_closeDatabase();
|
|
63
|
+
} finally {
|
|
64
|
+
process.chdir(repoRoot);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|