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.
Files changed (80) hide show
  1. package/README.md +101 -0
  2. package/bin/create.js +394 -0
  3. package/package.json +33 -0
  4. package/template/.env.example +38 -0
  5. package/template/CLAUDE.md +104 -0
  6. package/template/agent-credentials.yaml +33 -0
  7. package/template/agents.yaml +22 -0
  8. package/template/container/Dockerfile +70 -0
  9. package/template/container/Dockerfile.argus +34 -0
  10. package/template/container/agent-runner/package-lock.json +1524 -0
  11. package/template/container/agent-runner/package.json +23 -0
  12. package/template/container/agent-runner/src/index.ts +630 -0
  13. package/template/container/agent-runner/src/ipc-mcp-stdio.ts +339 -0
  14. package/template/container/agent-runner/tsconfig.json +15 -0
  15. package/template/container/build-argus.sh +25 -0
  16. package/template/container/build.sh +23 -0
  17. package/template/container/skills/agent-browser/SKILL.md +159 -0
  18. package/template/container/skills/agent-status/SKILL.md +69 -0
  19. package/template/container/skills/capabilities/SKILL.md +100 -0
  20. package/template/container/skills/edit-agent/SKILL.md +93 -0
  21. package/template/container/skills/slack-formatting/SKILL.md +92 -0
  22. package/template/container/skills/status/SKILL.md +104 -0
  23. package/template/container/tools/elastic_query.py +161 -0
  24. package/template/container/tools/gdrive_tool.py +185 -0
  25. package/template/container/tools/jira_tool.py +433 -0
  26. package/template/container/tools/slack_history_tool.py +144 -0
  27. package/template/container/tools/youtube_tool.py +174 -0
  28. package/template/docker-compose.yml +54 -0
  29. package/template/docs/how-it-works.md +496 -0
  30. package/template/eslint.config.js +32 -0
  31. package/template/groups/forge/CLAUDE.md +107 -0
  32. package/template/package-lock.json +5278 -0
  33. package/template/package.json +52 -0
  34. package/template/scripts/github-app-token.py +58 -0
  35. package/template/scripts/register-expense-agent.sh +121 -0
  36. package/template/scripts/run-migrations.ts +105 -0
  37. package/template/scripts/setup-onecli-secrets.sh +252 -0
  38. package/template/setup-agents.sh +142 -0
  39. package/template/src/channels/index.ts +13 -0
  40. package/template/src/channels/registry.test.ts +42 -0
  41. package/template/src/channels/registry.ts +28 -0
  42. package/template/src/channels/slack.test.ts +859 -0
  43. package/template/src/channels/slack.ts +373 -0
  44. package/template/src/claw-skill.test.ts +45 -0
  45. package/template/src/config.ts +94 -0
  46. package/template/src/container-runner.test.ts +221 -0
  47. package/template/src/container-runner.ts +1029 -0
  48. package/template/src/container-runtime.test.ts +149 -0
  49. package/template/src/container-runtime.ts +124 -0
  50. package/template/src/db-migration.test.ts +67 -0
  51. package/template/src/db.test.ts +484 -0
  52. package/template/src/db.ts +837 -0
  53. package/template/src/env.ts +42 -0
  54. package/template/src/formatting.test.ts +294 -0
  55. package/template/src/github-token.ts +48 -0
  56. package/template/src/google-token.ts +75 -0
  57. package/template/src/group-folder.test.ts +43 -0
  58. package/template/src/group-folder.ts +44 -0
  59. package/template/src/group-queue.test.ts +484 -0
  60. package/template/src/group-queue.ts +363 -0
  61. package/template/src/http-server.ts +343 -0
  62. package/template/src/index.ts +960 -0
  63. package/template/src/ipc-auth.test.ts +679 -0
  64. package/template/src/ipc.ts +548 -0
  65. package/template/src/logger.ts +16 -0
  66. package/template/src/mount-security.ts +421 -0
  67. package/template/src/network-policy.ts +119 -0
  68. package/template/src/remote-control.test.ts +397 -0
  69. package/template/src/remote-control.ts +224 -0
  70. package/template/src/router.ts +52 -0
  71. package/template/src/routing.test.ts +170 -0
  72. package/template/src/sender-allowlist.test.ts +216 -0
  73. package/template/src/sender-allowlist.ts +128 -0
  74. package/template/src/task-scheduler.test.ts +129 -0
  75. package/template/src/task-scheduler.ts +290 -0
  76. package/template/src/timezone.test.ts +73 -0
  77. package/template/src/timezone.ts +37 -0
  78. package/template/src/types.ts +114 -0
  79. package/template/src/worktree.ts +206 -0
  80. package/template/tsconfig.json +20 -0
@@ -0,0 +1,290 @@
1
+ import { ChildProcess } from 'child_process';
2
+ import { CronExpressionParser } from 'cron-parser';
3
+ import fs from 'fs';
4
+
5
+ import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TASK_CLOSE_DELAY_MS, TIMEZONE } from './config.js';
6
+ import {
7
+ ContainerOutput,
8
+ runContainerAgent,
9
+ writeTasksSnapshot,
10
+ } from './container-runner.js';
11
+ import {
12
+ getAllTasks,
13
+ getDueTasks,
14
+ getTaskById,
15
+ logTaskRun,
16
+ updateTask,
17
+ updateTaskAfterRun,
18
+ } from './db.js';
19
+ import { GroupQueue } from './group-queue.js';
20
+ import { resolveGroupFolderPath } from './group-folder.js';
21
+ import { logger } from './logger.js';
22
+ import { RegisteredGroup, ScheduledTask } from './types.js';
23
+
24
+ /**
25
+ * Compute the next run time for a recurring task, anchored to the
26
+ * task's scheduled time rather than Date.now() to prevent cumulative
27
+ * drift on interval-based tasks.
28
+ *
29
+ * Co-authored-by: @community-pr-601
30
+ */
31
+ export function computeNextRun(task: ScheduledTask): string | null {
32
+ if (task.schedule_type === 'once') return null;
33
+
34
+ const now = Date.now();
35
+
36
+ if (task.schedule_type === 'cron') {
37
+ const interval = CronExpressionParser.parse(task.schedule_value, {
38
+ tz: TIMEZONE,
39
+ });
40
+ return interval.next().toISOString();
41
+ }
42
+
43
+ if (task.schedule_type === 'interval') {
44
+ const ms = parseInt(task.schedule_value, 10);
45
+ if (!ms || ms <= 0) {
46
+ // Guard against malformed interval that would cause an infinite loop
47
+ logger.warn(
48
+ { taskId: task.id, value: task.schedule_value },
49
+ 'Invalid interval value',
50
+ );
51
+ return new Date(now + 60_000).toISOString();
52
+ }
53
+ // Anchor to the scheduled time, not now, to prevent drift.
54
+ // Skip past any missed intervals so we always land in the future.
55
+ let next = new Date(task.next_run!).getTime() + ms;
56
+ while (next <= now) {
57
+ next += ms;
58
+ }
59
+ return new Date(next).toISOString();
60
+ }
61
+
62
+ return null;
63
+ }
64
+
65
+ export interface SchedulerDependencies {
66
+ registeredGroups: () => Record<string, RegisteredGroup>;
67
+ getSessions: () => Record<string, string>;
68
+ queue: GroupQueue;
69
+ onProcess: (
70
+ groupJid: string,
71
+ proc: ChildProcess,
72
+ containerName: string,
73
+ groupFolder: string,
74
+ ) => void;
75
+ sendMessage: (jid: string, text: string) => Promise<void>;
76
+ }
77
+
78
+ async function runTask(
79
+ task: ScheduledTask,
80
+ deps: SchedulerDependencies,
81
+ ): Promise<void> {
82
+ const startTime = Date.now();
83
+ let groupDir: string;
84
+ try {
85
+ groupDir = resolveGroupFolderPath(task.group_folder);
86
+ } catch (err) {
87
+ const error = err instanceof Error ? err.message : String(err);
88
+ // Stop retry churn for malformed legacy rows.
89
+ updateTask(task.id, { status: 'paused' });
90
+ logger.error(
91
+ { taskId: task.id, groupFolder: task.group_folder, error },
92
+ 'Task has invalid group folder',
93
+ );
94
+ logTaskRun({
95
+ task_id: task.id,
96
+ run_at: new Date().toISOString(),
97
+ duration_ms: Date.now() - startTime,
98
+ status: 'error',
99
+ result: null,
100
+ error,
101
+ });
102
+ return;
103
+ }
104
+ fs.mkdirSync(groupDir, { recursive: true });
105
+
106
+ // Advance next_run immediately so getDueTasks() won't re-find this task
107
+ // on the next scheduler tick while the container is still running.
108
+ const nextRun = computeNextRun(task);
109
+ updateTask(task.id, { next_run: nextRun ?? undefined });
110
+
111
+ logger.info(
112
+ { taskId: task.id, group: task.group_folder },
113
+ 'Running scheduled task',
114
+ );
115
+
116
+ const groups = deps.registeredGroups();
117
+ const group = Object.values(groups).find(
118
+ (g) => g.folder === task.group_folder,
119
+ );
120
+
121
+ if (!group) {
122
+ logger.error(
123
+ { taskId: task.id, groupFolder: task.group_folder },
124
+ 'Group not found for task',
125
+ );
126
+ logTaskRun({
127
+ task_id: task.id,
128
+ run_at: new Date().toISOString(),
129
+ duration_ms: Date.now() - startTime,
130
+ status: 'error',
131
+ result: null,
132
+ error: `Group not found: ${task.group_folder}`,
133
+ });
134
+ return;
135
+ }
136
+
137
+ // Update tasks snapshot for container to read (filtered by group)
138
+ const isMain = group.isMain === true;
139
+ const tasks = getAllTasks();
140
+ writeTasksSnapshot(
141
+ task.group_folder,
142
+ isMain,
143
+ tasks.map((t) => ({
144
+ id: t.id,
145
+ groupFolder: t.group_folder,
146
+ prompt: t.prompt,
147
+ script: t.script,
148
+ schedule_type: t.schedule_type,
149
+ schedule_value: t.schedule_value,
150
+ status: t.status,
151
+ next_run: t.next_run,
152
+ })),
153
+ );
154
+
155
+ let result: string | null = null;
156
+ let error: string | null = null;
157
+
158
+ // For group context mode, use the group's current session
159
+ const sessions = deps.getSessions();
160
+ const sessionId =
161
+ task.context_mode === 'group' ? sessions[task.group_folder] : undefined;
162
+
163
+ // After the task produces a result, close the container promptly.
164
+ // Tasks are single-turn — no need to wait IDLE_TIMEOUT (30 min) for the
165
+ // query loop to time out. A short delay handles any final MCP calls.
166
+ // TASK_CLOSE_DELAY_MS imported from config (default 10s, env-configurable)
167
+ let closeTimer: ReturnType<typeof setTimeout> | null = null;
168
+
169
+ const scheduleClose = () => {
170
+ if (closeTimer) return; // already scheduled
171
+ closeTimer = setTimeout(() => {
172
+ logger.debug({ taskId: task.id }, 'Closing task container after result');
173
+ deps.queue.closeStdin(task.chat_jid);
174
+ }, TASK_CLOSE_DELAY_MS);
175
+ };
176
+
177
+ try {
178
+ const output = await runContainerAgent(
179
+ group,
180
+ {
181
+ prompt: task.prompt,
182
+ sessionId,
183
+ groupFolder: task.group_folder,
184
+ chatJid: task.chat_jid,
185
+ isMain,
186
+ isScheduledTask: true,
187
+ assistantName: ASSISTANT_NAME,
188
+ script: task.script || undefined,
189
+ },
190
+ (proc, containerName) =>
191
+ deps.onProcess(task.chat_jid, proc, containerName, task.group_folder),
192
+ async (streamedOutput: ContainerOutput) => {
193
+ if (streamedOutput.result) {
194
+ result = streamedOutput.result;
195
+ // Forward result to user unless task is marked silent
196
+ if (!task.silent) {
197
+ await deps.sendMessage(task.chat_jid, streamedOutput.result);
198
+ }
199
+ scheduleClose();
200
+ }
201
+ if (streamedOutput.status === 'success') {
202
+ deps.queue.notifyIdle(task.chat_jid);
203
+ scheduleClose(); // Close promptly even when result is null (e.g. IPC-only tasks)
204
+ }
205
+ if (streamedOutput.status === 'error') {
206
+ error = streamedOutput.error || 'Unknown error';
207
+ }
208
+ },
209
+ );
210
+
211
+ if (closeTimer) clearTimeout(closeTimer);
212
+
213
+ if (output.status === 'error') {
214
+ error = output.error || 'Unknown error';
215
+ } else if (output.result) {
216
+ // Result was already forwarded to the user via the streaming callback above
217
+ result = output.result;
218
+ }
219
+
220
+ logger.info(
221
+ { taskId: task.id, durationMs: Date.now() - startTime },
222
+ 'Task completed',
223
+ );
224
+ } catch (err) {
225
+ if (closeTimer) clearTimeout(closeTimer);
226
+ error = err instanceof Error ? err.message : String(err);
227
+ logger.error({ taskId: task.id, error }, 'Task failed');
228
+ }
229
+
230
+ const durationMs = Date.now() - startTime;
231
+
232
+ logTaskRun({
233
+ task_id: task.id,
234
+ run_at: new Date().toISOString(),
235
+ duration_ms: durationMs,
236
+ status: error ? 'error' : 'success',
237
+ result,
238
+ error,
239
+ });
240
+
241
+ const resultSummary = error
242
+ ? `Error: ${error}`
243
+ : result
244
+ ? result.slice(0, 200)
245
+ : 'Completed';
246
+ updateTaskAfterRun(task.id, nextRun, resultSummary);
247
+ }
248
+
249
+ let schedulerRunning = false;
250
+
251
+ export function startSchedulerLoop(deps: SchedulerDependencies): void {
252
+ if (schedulerRunning) {
253
+ logger.debug('Scheduler loop already running, skipping duplicate start');
254
+ return;
255
+ }
256
+ schedulerRunning = true;
257
+ logger.info('Scheduler loop started');
258
+
259
+ const loop = async () => {
260
+ try {
261
+ const dueTasks = getDueTasks();
262
+ if (dueTasks.length > 0) {
263
+ logger.info({ count: dueTasks.length }, 'Found due tasks');
264
+ }
265
+
266
+ for (const task of dueTasks) {
267
+ // Re-check task status in case it was paused/cancelled
268
+ const currentTask = getTaskById(task.id);
269
+ if (!currentTask || currentTask.status !== 'active') {
270
+ continue;
271
+ }
272
+
273
+ deps.queue.enqueueTask(currentTask.chat_jid, currentTask.id, () =>
274
+ runTask(currentTask, deps),
275
+ );
276
+ }
277
+ } catch (err) {
278
+ logger.error({ err }, 'Error in scheduler loop');
279
+ }
280
+
281
+ setTimeout(loop, SCHEDULER_POLL_INTERVAL);
282
+ };
283
+
284
+ loop();
285
+ }
286
+
287
+ /** @internal - for tests only. */
288
+ export function _resetSchedulerLoopForTests(): void {
289
+ schedulerRunning = false;
290
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import {
4
+ formatLocalTime,
5
+ isValidTimezone,
6
+ resolveTimezone,
7
+ } from './timezone.js';
8
+
9
+ // --- formatLocalTime ---
10
+
11
+ describe('formatLocalTime', () => {
12
+ it('converts UTC to local time display', () => {
13
+ // 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM
14
+ const result = formatLocalTime(
15
+ '2026-02-04T18:30:00.000Z',
16
+ 'America/New_York',
17
+ );
18
+ expect(result).toContain('1:30');
19
+ expect(result).toContain('PM');
20
+ expect(result).toContain('Feb');
21
+ expect(result).toContain('2026');
22
+ });
23
+
24
+ it('handles different timezones', () => {
25
+ // Same UTC time should produce different local times
26
+ const utc = '2026-06-15T12:00:00.000Z';
27
+ const ny = formatLocalTime(utc, 'America/New_York');
28
+ const tokyo = formatLocalTime(utc, 'Asia/Tokyo');
29
+ // NY is UTC-4 in summer (EDT), Tokyo is UTC+9
30
+ expect(ny).toContain('8:00');
31
+ expect(tokyo).toContain('9:00');
32
+ });
33
+
34
+ it('does not throw on invalid timezone, falls back to UTC', () => {
35
+ expect(() =>
36
+ formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2'),
37
+ ).not.toThrow();
38
+ const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2');
39
+ // Should format as UTC (noon UTC = 12:00 PM)
40
+ expect(result).toContain('12:00');
41
+ expect(result).toContain('PM');
42
+ });
43
+ });
44
+
45
+ describe('isValidTimezone', () => {
46
+ it('accepts valid IANA identifiers', () => {
47
+ expect(isValidTimezone('America/New_York')).toBe(true);
48
+ expect(isValidTimezone('UTC')).toBe(true);
49
+ expect(isValidTimezone('Asia/Tokyo')).toBe(true);
50
+ expect(isValidTimezone('Asia/Jerusalem')).toBe(true);
51
+ });
52
+
53
+ it('rejects invalid timezone strings', () => {
54
+ expect(isValidTimezone('IST-2')).toBe(false);
55
+ expect(isValidTimezone('XYZ+3')).toBe(false);
56
+ });
57
+
58
+ it('rejects empty and garbage strings', () => {
59
+ expect(isValidTimezone('')).toBe(false);
60
+ expect(isValidTimezone('NotATimezone')).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe('resolveTimezone', () => {
65
+ it('returns the timezone if valid', () => {
66
+ expect(resolveTimezone('America/New_York')).toBe('America/New_York');
67
+ });
68
+
69
+ it('falls back to UTC for invalid timezone', () => {
70
+ expect(resolveTimezone('IST-2')).toBe('UTC');
71
+ expect(resolveTimezone('')).toBe('UTC');
72
+ });
73
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Check whether a timezone string is a valid IANA identifier
3
+ * that Intl.DateTimeFormat can use.
4
+ */
5
+ export function isValidTimezone(tz: string): boolean {
6
+ try {
7
+ Intl.DateTimeFormat(undefined, { timeZone: tz });
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Return the given timezone if valid IANA, otherwise fall back to UTC.
16
+ */
17
+ export function resolveTimezone(tz: string): string {
18
+ return isValidTimezone(tz) ? tz : 'UTC';
19
+ }
20
+
21
+ /**
22
+ * Convert a UTC ISO timestamp to a localized display string.
23
+ * Uses the Intl API (no external dependencies).
24
+ * Falls back to UTC if the timezone is invalid.
25
+ */
26
+ export function formatLocalTime(utcIso: string, timezone: string): string {
27
+ const date = new Date(utcIso);
28
+ return date.toLocaleString('en-US', {
29
+ timeZone: resolveTimezone(timezone),
30
+ year: 'numeric',
31
+ month: 'short',
32
+ day: 'numeric',
33
+ hour: 'numeric',
34
+ minute: '2-digit',
35
+ hour12: true,
36
+ });
37
+ }
@@ -0,0 +1,114 @@
1
+ export interface AdditionalMount {
2
+ hostPath: string; // Absolute path on host (supports ~ for home)
3
+ containerPath?: string; // Optional — defaults to basename of hostPath. Mounted at /workspace/extra/{value}
4
+ readonly?: boolean; // Default: true for safety
5
+ worktree?: boolean; // If true, create a per-group git worktree instead of bind-mounting the repo directly
6
+ }
7
+
8
+ /**
9
+ * Mount Allowlist - Security configuration for additional mounts
10
+ * This file should be stored at ~/.config/nanoclaw/mount-allowlist.json
11
+ * and is NOT mounted into any container, making it tamper-proof from agents.
12
+ */
13
+ export interface MountAllowlist {
14
+ // Directories that can be mounted into containers
15
+ allowedRoots: AllowedRoot[];
16
+ // Glob patterns for paths that should never be mounted (e.g., ".ssh", ".gnupg")
17
+ blockedPatterns: string[];
18
+ // If true, non-main groups can only mount read-only regardless of config
19
+ nonMainReadOnly: boolean;
20
+ }
21
+
22
+ export interface AllowedRoot {
23
+ // Absolute path or ~ for home (e.g., "~/projects", "/var/repos")
24
+ path: string;
25
+ // Whether read-write mounts are allowed under this root
26
+ allowReadWrite: boolean;
27
+ // Optional description for documentation
28
+ description?: string;
29
+ }
30
+
31
+ export interface ContainerConfig {
32
+ additionalMounts?: AdditionalMount[];
33
+ timeout?: number; // Default: 300000 (5 minutes)
34
+ noThreading?: boolean; // If true, always post to channel (never in threads)
35
+ }
36
+
37
+ export interface RegisteredGroup {
38
+ name: string;
39
+ folder: string;
40
+ trigger: string;
41
+ added_at: string;
42
+ containerConfig?: ContainerConfig;
43
+ requiresTrigger?: boolean; // Default: true for groups, false for solo chats
44
+ isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
45
+ }
46
+
47
+ export interface NewMessage {
48
+ id: string;
49
+ chat_jid: string;
50
+ sender: string;
51
+ sender_name: string;
52
+ sender_email?: string;
53
+ content: string;
54
+ timestamp: string;
55
+ is_from_me?: boolean;
56
+ is_bot_message?: boolean;
57
+ }
58
+
59
+ export interface ScheduledTask {
60
+ id: string;
61
+ group_folder: string;
62
+ chat_jid: string;
63
+ prompt: string;
64
+ script?: string | null;
65
+ schedule_type: 'cron' | 'interval' | 'once';
66
+ schedule_value: string;
67
+ context_mode: 'group' | 'isolated';
68
+ next_run: string | null;
69
+ last_run: string | null;
70
+ last_result: string | null;
71
+ status: 'active' | 'paused' | 'completed';
72
+ silent?: number;
73
+ created_at: string;
74
+ }
75
+
76
+ export interface TaskRunLog {
77
+ task_id: string;
78
+ run_at: string;
79
+ duration_ms: number;
80
+ status: 'success' | 'error';
81
+ result: string | null;
82
+ error: string | null;
83
+ }
84
+
85
+ // --- Channel abstraction ---
86
+
87
+ export interface Channel {
88
+ name: string;
89
+ connect(): Promise<void>;
90
+ sendMessage(jid: string, text: string): Promise<void>;
91
+ isConnected(): boolean;
92
+ ownsJid(jid: string): boolean;
93
+ disconnect(): Promise<void>;
94
+ // Optional: typing indicator. Channels that support it implement it.
95
+ setTyping?(jid: string, isTyping: boolean): Promise<void>;
96
+ // Optional: sync group/chat names from the platform.
97
+ syncGroups?(force: boolean): Promise<void>;
98
+ // Optional: list members of a channel/group for @mention support.
99
+ getChannelMembers?(jid: string): Promise<Array<{ id: string; name: string }>>;
100
+ }
101
+
102
+ // Callback type that channels use to deliver inbound messages
103
+ export type OnInboundMessage = (chatJid: string, message: NewMessage) => void;
104
+
105
+ // Callback for chat metadata discovery.
106
+ // name is optional — channels that deliver names inline (Telegram) pass it here;
107
+ // channels that sync names separately (via syncGroups) omit it.
108
+ export type OnChatMetadata = (
109
+ chatJid: string,
110
+ timestamp: string,
111
+ name?: string,
112
+ channel?: string,
113
+ isGroup?: boolean,
114
+ ) => void;