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,1029 @@
1
+ /**
2
+ * Container Runner for NanoClaw
3
+ * Spawns agent execution in containers and handles IPC
4
+ */
5
+ import { ChildProcess, exec, spawn } from 'child_process';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+
9
+ import YAML from 'yaml';
10
+ import { applyContainerIptables, cleanupContainerIptables, getContainerIp } from './network-policy.js';
11
+ import {
12
+ CONTAINER_IMAGE,
13
+ CONTAINER_MAX_OUTPUT_SIZE,
14
+ CONTAINER_TIMEOUT,
15
+ DATA_DIR,
16
+ GROUPS_DIR,
17
+ IDLE_TIMEOUT,
18
+ ONECLI_URL,
19
+ TIMEZONE,
20
+ } from './config.js';
21
+ import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
22
+ import { logger } from './logger.js';
23
+ import {
24
+ CONTAINER_RUNTIME_BIN,
25
+ hostGatewayArgs,
26
+ readonlyMountArgs,
27
+ stopContainer,
28
+ } from './container-runtime.js';
29
+ import { OneCLI } from '@onecli-sh/sdk';
30
+ import { generateGitHubToken } from './github-token.js';
31
+ import { generateGoogleAccessToken } from './google-token.js';
32
+ import { validateAdditionalMounts } from './mount-security.js';
33
+ import { RegisteredGroup } from './types.js';
34
+ import { ensureWorktree } from './worktree.js';
35
+ import { readEnvFile } from './env.js';
36
+
37
+ // Patterns that identify sensitive env var keys — used to redact values from logs.
38
+ const SENSITIVE_KEY_PATTERNS = [
39
+ 'TOKEN', 'SECRET', 'KEY', 'PASSWORD', 'CREDENTIAL', 'PEM', 'AUTH',
40
+ ];
41
+
42
+ const CREDENTIALS_FILE = path.resolve(process.cwd(), "agent-credentials.yaml");
43
+ interface CredentialsConfig { common: { config: string[]; skills?: string[]; tools?: string[] }; agents: Record<string, { config: string[]; skills?: string[]; tools?: string[] }>; }
44
+ let _cc: CredentialsConfig | null = null;
45
+ function loadCredentialsConfig(): CredentialsConfig {
46
+ if (_cc) return _cc;
47
+ try {
48
+ _cc = YAML.parse(fs.readFileSync(CREDENTIALS_FILE, "utf-8")) as CredentialsConfig;
49
+ return _cc;
50
+ } catch (err) {
51
+ if (fs.existsSync(CREDENTIALS_FILE)) {
52
+ // File exists but failed to parse — this is a real error, not a missing file.
53
+ logger.error({ file: CREDENTIALS_FILE, err }, 'agent-credentials.yaml exists but failed to parse — fix the YAML before spawning agents');
54
+ throw new Error(`Failed to parse ${CREDENTIALS_FILE}: ${err instanceof Error ? err.message : String(err)}`);
55
+ }
56
+ // File doesn't exist — warn once and use empty config (valid for fresh setups).
57
+ logger.warn({ file: CREDENTIALS_FILE }, 'agent-credentials.yaml not found — no per-agent credential scoping applied');
58
+ return { common: { config: [] }, agents: {} };
59
+ }
60
+ }
61
+ function getAgentEnvKeys(gf: string): string[] {
62
+ const c = loadCredentialsConfig();
63
+ if (!c.common.config.length) return FORWARDED_ENV_KEYS;
64
+ return [...c.common.config, ...(c.agents[gf]?.config || [])];
65
+ }
66
+
67
+ function getAgentSkills(gf: string): string[] | null {
68
+ const c = loadCredentialsConfig();
69
+ // If neither common nor agent has skills config, return null (copy all — legacy fallback)
70
+ if (!c.common.skills && !c.agents[gf]?.skills) return null;
71
+ const common = c.common.skills || [];
72
+ const agentExtra = c.agents[gf]?.skills || [];
73
+ return [...common, ...agentExtra];
74
+ }
75
+
76
+ /**
77
+ * Returns the list of tool filenames this agent is allowed to use.
78
+ * Returns null if no tools config defined (legacy fallback — use baked-in image tools).
79
+ */
80
+ function getAgentTools(gf: string): string[] | null {
81
+ const c = loadCredentialsConfig();
82
+ const agentCfg = c.agents[gf];
83
+ if (!agentCfg || !('tools' in agentCfg)) return null;
84
+ const common = c.common.tools || [];
85
+ const agentTools = agentCfg.tools || [];
86
+ return [...common, ...agentTools];
87
+ }
88
+
89
+ /**
90
+ * Build a per-agent tools directory containing only the allowed tool files.
91
+ * This directory is mounted over /workspace/extra/tools to shadow the
92
+ * baked-in image tools. Returns the host path to mount.
93
+ */
94
+ function buildAgentToolsDir(groupFolder: string, allowedTools: string[]): string {
95
+ const toolsSrc = path.join(process.cwd(), 'container', 'tools');
96
+ const toolsDst = path.join(DATA_DIR, 'tools', groupFolder);
97
+ fs.mkdirSync(toolsDst, { recursive: true });
98
+
99
+ // Remove any tool files no longer allowed
100
+ if (fs.existsSync(toolsDst)) {
101
+ for (const existing of fs.readdirSync(toolsDst)) {
102
+ if (!allowedTools.includes(existing)) {
103
+ fs.rmSync(path.join(toolsDst, existing), { force: true });
104
+ }
105
+ }
106
+ }
107
+
108
+ // Copy allowed tools (only if changed)
109
+ for (const toolFile of allowedTools) {
110
+ const src = path.join(toolsSrc, toolFile);
111
+ const dst = path.join(toolsDst, toolFile);
112
+ if (!fs.existsSync(src)) continue;
113
+ const srcMtime = fs.statSync(src).mtimeMs;
114
+ const dstMtime = fs.existsSync(dst) ? fs.statSync(dst).mtimeMs : 0;
115
+ if (srcMtime > dstMtime) {
116
+ fs.copyFileSync(src, dst);
117
+ }
118
+ }
119
+
120
+ return toolsDst;
121
+ }
122
+
123
+ /**
124
+ * Redact sensitive `-e KEY=VALUE` pairs from container args for safe logging.
125
+ * Returns a new string with secret values replaced by `***`.
126
+ */
127
+ function redactContainerArgs(args: string[]): string {
128
+ const redacted: string[] = [];
129
+ for (let i = 0; i < args.length; i++) {
130
+ if (args[i] === '-e' && i + 1 < args.length) {
131
+ const envPair = args[i + 1];
132
+ const eqIdx = envPair.indexOf('=');
133
+ if (eqIdx !== -1) {
134
+ const key = envPair.slice(0, eqIdx).toUpperCase();
135
+ if (SENSITIVE_KEY_PATTERNS.some((p) => key.includes(p))) {
136
+ redacted.push('-e', `${envPair.slice(0, eqIdx)}=***`);
137
+ i++; // skip the value arg
138
+ continue;
139
+ }
140
+ }
141
+ redacted.push(args[i], args[i + 1]);
142
+ i++;
143
+ } else {
144
+ redacted.push(args[i]);
145
+ }
146
+ }
147
+ return redacted.join(' ');
148
+ }
149
+
150
+ // Env vars forwarded from .env into every container so Claude's Bash tool
151
+ // can call service-specific tools (elastic_query.py, gh, etc.) without
152
+ // secrets being baked into the container image.
153
+ const FORWARDED_ENV_KEYS = [
154
+ 'CLAUDE_CODE_API_KEY_HELPER_TTL_MS',
155
+ 'ANTHROPIC_BASE_URL',
156
+ 'ANTHROPIC_BEDROCK_BASE_URL',
157
+ 'ANTHROPIC_AUTH_TOKEN',
158
+ 'ANTHROPIC_MODEL',
159
+ 'ANTHROPIC_SMALL_FAST_MODEL',
160
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
161
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
162
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
163
+
164
+ 'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
165
+ 'CLAUDE_CODE_USE_BEDROCK',
166
+ 'ELASTIC_API_KEY',
167
+ 'ELASTIC_BASE_URL',
168
+ 'ELASTIC_NAMESPACES',
169
+ 'ELASTIC_STAGING_NAMESPACES',
170
+ 'ELASTIC_LOOKBACK_MINUTES',
171
+ 'ELASTIC_MAX_LOGS',
172
+ 'GITHUB_TOKEN',
173
+ 'GITHUB_REPO',
174
+
175
+ // Jira (ticket-creator, jira-worker)
176
+ 'JIRA_BASE_URL',
177
+ 'JIRA_EMAIL',
178
+ 'JIRA_API_TOKEN',
179
+ 'JIRA_PROJECT_KEY',
180
+
181
+ // Google Drive (byte standup agent)
182
+ 'GOOGLE_CLIENT_ID',
183
+ 'GOOGLE_CLIENT_SECRET',
184
+ 'GOOGLE_REFRESH_TOKEN',
185
+ 'GOOGLE_DRIVE_STANDUP_FOLDER_ID',
186
+
187
+ // Slack (byte it-internal channel history tool)
188
+ 'SLACK_BOT_TOKEN',
189
+ ];
190
+
191
+ const onecli = new OneCLI({ url: ONECLI_URL });
192
+
193
+ // Sentinel markers for robust output parsing (must match agent-runner)
194
+ const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
195
+ const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
196
+
197
+ export interface ContainerInput {
198
+ prompt: string;
199
+ sessionId?: string;
200
+ groupFolder: string;
201
+ chatJid: string;
202
+ isMain: boolean;
203
+ isScheduledTask?: boolean;
204
+ assistantName?: string;
205
+ script?: string;
206
+ senderEmail?: string;
207
+ }
208
+
209
+ export interface ContainerOutput {
210
+ status: 'success' | 'error';
211
+ result: string | null;
212
+ newSessionId?: string;
213
+ error?: string;
214
+ }
215
+
216
+ interface VolumeMount {
217
+ hostPath: string;
218
+ containerPath: string;
219
+ readonly: boolean;
220
+ }
221
+
222
+ async function buildVolumeMounts(
223
+ group: RegisteredGroup,
224
+ isMain: boolean,
225
+ ): Promise<VolumeMount[]> {
226
+ const mounts: VolumeMount[] = [];
227
+ const projectRoot = process.cwd();
228
+ const groupDir = resolveGroupFolderPath(group.folder);
229
+
230
+ if (isMain) {
231
+ // Main gets the project root read-only. Writable paths the agent needs
232
+ // (group folder, IPC, .claude/) are mounted separately below.
233
+ // Read-only prevents the agent from modifying host application code
234
+ // (src/, dist/, package.json, etc.) which would bypass the sandbox
235
+ // entirely on next restart.
236
+ mounts.push({
237
+ hostPath: projectRoot,
238
+ containerPath: '/workspace/project',
239
+ readonly: true,
240
+ });
241
+
242
+ // Shadow .env so the agent cannot read secrets from the mounted project root.
243
+ // Credentials are injected by the OneCLI gateway, never exposed to containers.
244
+ const envFile = path.join(projectRoot, '.env');
245
+ if (fs.existsSync(envFile)) {
246
+ mounts.push({
247
+ hostPath: '/dev/null',
248
+ containerPath: '/workspace/project/.env',
249
+ readonly: true,
250
+ });
251
+ }
252
+
253
+ // Main also gets its group folder as the working directory
254
+ mounts.push({
255
+ hostPath: groupDir,
256
+ containerPath: '/workspace/group',
257
+ readonly: false,
258
+ });
259
+ } else {
260
+ // Other groups only get their own folder
261
+ mounts.push({
262
+ hostPath: groupDir,
263
+ containerPath: '/workspace/group',
264
+ readonly: false,
265
+ });
266
+
267
+ // Global memory directory (read-only for non-main)
268
+ // Only directory mounts are supported, not file mounts
269
+ const globalDir = path.join(GROUPS_DIR, 'global');
270
+ if (fs.existsSync(globalDir)) {
271
+ mounts.push({
272
+ hostPath: globalDir,
273
+ containerPath: '/workspace/global',
274
+ readonly: true,
275
+ });
276
+ }
277
+ }
278
+
279
+ // Per-group Claude sessions directory (isolated from other groups)
280
+ // Each group gets their own .claude/ to prevent cross-group session access
281
+ const groupSessionsDir = path.join(
282
+ DATA_DIR,
283
+ 'sessions',
284
+ group.folder,
285
+ '.claude',
286
+ );
287
+ fs.mkdirSync(groupSessionsDir, { recursive: true });
288
+ const settingsFile = path.join(groupSessionsDir, 'settings.json');
289
+ if (!fs.existsSync(settingsFile)) {
290
+ fs.writeFileSync(
291
+ settingsFile,
292
+ JSON.stringify(
293
+ {
294
+ env: {
295
+ // Enable agent swarms (subagent orchestration)
296
+ // https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions
297
+ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
298
+ // Load CLAUDE.md from additional mounted directories
299
+ // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories
300
+ CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
301
+ // Enable Claude's memory feature (persists user preferences between sessions)
302
+ // https://code.claude.com/docs/en/memory#manage-auto-memory
303
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
304
+ },
305
+ },
306
+ null,
307
+ 2,
308
+ ) + '\n',
309
+ );
310
+ }
311
+
312
+ // Sync skills from container/skills/ into each group's .claude/skills/
313
+ // Only copy skills the agent is allowed to use (per agent-credentials.yaml allowlist).
314
+ // Also removes any previously-copied skills that are no longer allowed.
315
+ const skillsSrc = path.join(process.cwd(), 'container', 'skills');
316
+ const skillsDst = path.join(groupSessionsDir, 'skills');
317
+ if (fs.existsSync(skillsSrc)) {
318
+ const allowedSkills = getAgentSkills(group.folder);
319
+ // Remove disallowed skills that may have been copied in previous runs
320
+ if (allowedSkills !== null && fs.existsSync(skillsDst)) {
321
+ for (const existing of fs.readdirSync(skillsDst)) {
322
+ if (!allowedSkills.includes(existing)) {
323
+ fs.rmSync(path.join(skillsDst, existing), { recursive: true, force: true });
324
+ }
325
+ }
326
+ }
327
+ for (const skillDir of fs.readdirSync(skillsSrc)) {
328
+ const srcDir = path.join(skillsSrc, skillDir);
329
+ if (!fs.statSync(srcDir).isDirectory()) continue;
330
+ // If allowlist is defined, skip skills not in it
331
+ if (allowedSkills !== null && !allowedSkills.includes(skillDir)) continue;
332
+ const dstDir = path.join(skillsDst, skillDir);
333
+ fs.cpSync(srcDir, dstDir, { recursive: true });
334
+ }
335
+ }
336
+ mounts.push({
337
+ hostPath: groupSessionsDir,
338
+ containerPath: '/home/node/.claude',
339
+ readonly: false,
340
+ });
341
+
342
+ // Per-group IPC namespace: each group gets its own IPC directory
343
+ // This prevents cross-group privilege escalation via IPC
344
+ const groupIpcDir = resolveGroupIpcPath(group.folder);
345
+ fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
346
+ fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
347
+ fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
348
+ mounts.push({
349
+ hostPath: groupIpcDir,
350
+ containerPath: '/workspace/ipc',
351
+ readonly: false,
352
+ });
353
+
354
+ // Copy agent-runner source into a per-group writable location so agents
355
+ // can customize it (add tools, change behavior) without affecting other
356
+ // groups. Recompiled on container startup via entrypoint.sh.
357
+ const agentRunnerSrc = path.join(
358
+ projectRoot,
359
+ 'container',
360
+ 'agent-runner',
361
+ 'src',
362
+ );
363
+ const groupAgentRunnerDir = path.join(
364
+ DATA_DIR,
365
+ 'sessions',
366
+ group.folder,
367
+ 'agent-runner-src',
368
+ );
369
+ if (fs.existsSync(agentRunnerSrc)) {
370
+ const srcIndex = path.join(agentRunnerSrc, 'index.ts');
371
+ const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts');
372
+ const needsCopy =
373
+ !fs.existsSync(groupAgentRunnerDir) ||
374
+ !fs.existsSync(cachedIndex) ||
375
+ (fs.existsSync(srcIndex) &&
376
+ fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs);
377
+ if (needsCopy) {
378
+ fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
379
+ }
380
+ }
381
+ mounts.push({
382
+ hostPath: groupAgentRunnerDir,
383
+ containerPath: '/app/src',
384
+ readonly: false,
385
+ });
386
+
387
+ // Additional mounts validated against external allowlist (tamper-proof from containers)
388
+ if (group.containerConfig?.additionalMounts) {
389
+ const validatedMounts = validateAdditionalMounts(
390
+ group.containerConfig.additionalMounts,
391
+ group.name,
392
+ isMain,
393
+ );
394
+
395
+ // Resolve worktree mounts: create per-group git worktrees so agents
396
+ // don't operate on the user's local checkout (branch switches, file
397
+ // edits stay isolated inside the worktree).
398
+ for (const mount of validatedMounts) {
399
+ if (mount.worktree && mount.originalHostPath) {
400
+ const containerPathName = mount.containerPath.replace(
401
+ '/workspace/extra/',
402
+ '',
403
+ );
404
+ try {
405
+ const worktreePath = await ensureWorktree(
406
+ mount.originalHostPath,
407
+ group.folder,
408
+ containerPathName,
409
+ );
410
+ mount.hostPath = worktreePath;
411
+ // Worktree mounts must be read-write so the agent can create branches and commit
412
+ mount.readonly = false;
413
+ } catch (err) {
414
+ logger.error(
415
+ { group: group.name, hostPath: mount.originalHostPath, err },
416
+ 'Failed to create worktree, falling back to direct mount',
417
+ );
418
+ }
419
+ }
420
+ }
421
+
422
+ mounts.push(...validatedMounts);
423
+ }
424
+
425
+ // Per-agent tool mounting: build a filtered tools directory and mount it
426
+ // over /workspace/extra/tools to shadow the baked-in image tools.
427
+ // Only tools explicitly allowed in agent-credentials.yaml are accessible.
428
+ const allowedTools = getAgentTools(group.folder);
429
+ if (allowedTools !== null) {
430
+ const agentToolsDir = buildAgentToolsDir(group.folder, allowedTools);
431
+ mounts.push({
432
+ hostPath: agentToolsDir,
433
+ containerPath: '/workspace/extra/tools',
434
+ readonly: true,
435
+ });
436
+ }
437
+
438
+ return mounts;
439
+ }
440
+
441
+ async function buildContainerArgs(
442
+ mounts: VolumeMount[],
443
+ containerName: string,
444
+ agentIdentifier?: string,
445
+ senderEmail?: string,
446
+ ): Promise<string[]> {
447
+ const args: string[] = ['run', '-i', '--rm', '--name', containerName];
448
+
449
+ // Pass host timezone so container's local time matches the user's
450
+ args.push('-e', `TZ=${TIMEZONE}`);
451
+
452
+ // Forward service credentials from .env so Claude's Bash tool can call
453
+ // elastic_query.py, gh CLI, etc. inside the container.
454
+ const agentFolder = agentIdentifier || "global-claw";
455
+ const forwardedEnv = readEnvFile(getAgentEnvKeys(agentFolder));
456
+ for (const [key, value] of Object.entries(forwardedEnv)) {
457
+ args.push('-e', `${key}=${value}`);
458
+ }
459
+ // Generate a fresh GitHub App installation token if app credentials are configured.
460
+ // This ensures PRs are created as the Argus bot, not the user's personal account.
461
+ // Falls back to GITHUB_TOKEN from .env if app credentials are not set.
462
+ const needsGithub = getAgentEnvKeys(agentFolder).includes("GITHUB_TOKEN");
463
+ const githubToken = needsGithub ? await generateGitHubToken(forwardedEnv.GITHUB_TOKEN) : null;
464
+ if (needsGithub && !githubToken) {
465
+ // This agent requires GITHUB_TOKEN but generation failed. Proceeding without it
466
+ // would cause confusing auth failures deep into the run — fail fast instead.
467
+ throw new Error(`GitHub token generation failed for agent ${agentFolder} — check GITHUB_APP_ID, GITHUB_INSTALLATION_ID, and github-app.pem`);
468
+ }
469
+ if (githubToken) {
470
+ args.push('-e', `GITHUB_TOKEN=${githubToken}`);
471
+ args.push('-e', `GH_TOKEN=${githubToken}`);
472
+ // Force git to send auth on the first request. The OneCLI MITM proxy breaks
473
+ // git's normal 401-challenge-retry flow, so we inject the Authorization header
474
+ // upfront via git config env vars.
475
+ const gitAuthB64 = Buffer.from(`x-access-token:${githubToken}`).toString('base64');
476
+ args.push('-e', 'GIT_CONFIG_COUNT=1');
477
+ args.push('-e', 'GIT_CONFIG_KEY_0=http.extraHeader');
478
+ args.push('-e', `GIT_CONFIG_VALUE_0=Authorization: Basic ${gitAuthB64}`);
479
+ }
480
+
481
+
482
+ // Google access token — generated host-side from OAuth credentials in .env.
483
+ // Only injected for agents that have Google config keys (Drive folder IDs, etc.).
484
+ // The OAuth credentials themselves (CLIENT_SECRET, REFRESH_TOKEN) never enter the container.
485
+ const agentConfigKeys = getAgentEnvKeys(agentFolder);
486
+ const needsGoogle = agentConfigKeys.some(k => k.startsWith('GOOGLE_'));
487
+ if (needsGoogle) {
488
+ const googleToken = await generateGoogleAccessToken();
489
+ if (googleToken) {
490
+ args.push('-e', `GOOGLE_ACCESS_TOKEN=${googleToken}`);
491
+ } else {
492
+ logger.warn({ containerName }, 'Google OAuth not configured — GOOGLE_ACCESS_TOKEN not injected');
493
+ }
494
+ }
495
+
496
+ // OneCLI gateway handles credential injection — containers never see real secrets.
497
+ // The gateway intercepts HTTPS traffic and injects API keys or OAuth tokens.
498
+ const onecliApplied = await onecli.applyContainerConfig(args, {
499
+ addHostMapping: false, // Nanoclaw already handles host gateway
500
+ agent: agentIdentifier,
501
+ });
502
+ if (onecliApplied) {
503
+ logger.info({ containerName }, 'OneCLI gateway config applied');
504
+ // Python requests library needs its own CA bundle env var for OneCLI proxy TLS
505
+ args.push("-e", "REQUESTS_CA_BUNDLE=/tmp/onecli-combined-ca.pem");
506
+ args.push("-e", "GIT_SSL_CAINFO=/tmp/onecli-combined-ca.pem");
507
+ } else {
508
+ logger.warn(
509
+ { containerName },
510
+ 'OneCLI gateway not reachable — container will have no credentials',
511
+ );
512
+ }
513
+
514
+ // Runtime-specific args for host gateway resolution
515
+ args.push(...hostGatewayArgs());
516
+
517
+ // Run as host user so bind-mounted files are accessible.
518
+ // Skip when running as root (uid 0), as the container's node user (uid 1000),
519
+ // or when getuid is unavailable (native Windows without WSL).
520
+ const hostUid = process.getuid?.();
521
+ const hostGid = process.getgid?.();
522
+ if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
523
+ args.push('--user', `${hostUid}:${hostGid}`);
524
+ args.push('-e', 'HOME=/home/node');
525
+ }
526
+
527
+ for (const mount of mounts) {
528
+ if (mount.readonly) {
529
+ args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
530
+ } else {
531
+ args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
532
+ }
533
+ }
534
+
535
+ // Inject sender email so agents can map the Slack user to external systems (e.g. Jira reporter).
536
+ // Must be before the image name — Docker treats args after the image as commands, not flags.
537
+ if (senderEmail) {
538
+ args.push('-e', `SLACK_USER_EMAIL=${senderEmail}`);
539
+ }
540
+
541
+ args.push(CONTAINER_IMAGE);
542
+
543
+ return args;
544
+ }
545
+
546
+ export async function runContainerAgent(
547
+ group: RegisteredGroup,
548
+ input: ContainerInput,
549
+ onProcess: (proc: ChildProcess, containerName: string) => void,
550
+ onOutput?: (output: ContainerOutput) => Promise<void>,
551
+ ): Promise<ContainerOutput> {
552
+ const startTime = Date.now();
553
+
554
+ const groupDir = resolveGroupFolderPath(group.folder);
555
+ fs.mkdirSync(groupDir, { recursive: true });
556
+
557
+ const mounts = await buildVolumeMounts(group, input.isMain);
558
+ const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
559
+ const containerName = `nanoclaw-${safeName}-${Date.now()}`;
560
+ // Main group uses the default OneCLI agent; others use their own agent.
561
+ const agentIdentifier = input.isMain
562
+ ? undefined
563
+ : group.folder.toLowerCase().replace(/_/g, '-');
564
+ const containerArgs = await buildContainerArgs(
565
+ mounts,
566
+ containerName,
567
+ agentIdentifier,
568
+ input.senderEmail,
569
+ );
570
+
571
+ logger.debug(
572
+ {
573
+ group: group.name,
574
+ containerName,
575
+ mounts: mounts.map(
576
+ (m) =>
577
+ `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
578
+ ),
579
+ containerArgs: redactContainerArgs(containerArgs),
580
+ },
581
+ 'Container mount configuration',
582
+ );
583
+
584
+ logger.info(
585
+ {
586
+ group: group.name,
587
+ containerName,
588
+ mountCount: mounts.length,
589
+ isMain: input.isMain,
590
+ },
591
+ 'Spawning container agent',
592
+ );
593
+
594
+ const logsDir = path.join(groupDir, 'logs');
595
+ fs.mkdirSync(logsDir, { recursive: true });
596
+
597
+ return new Promise((resolve) => {
598
+ const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
599
+ stdio: ['pipe', 'pipe', 'pipe'],
600
+ });
601
+
602
+ onProcess(container, containerName);
603
+
604
+ // Apply iptables rules before writing to stdin — the container must not be
605
+ // able to make outbound calls before enforcement is in place.
606
+ // If enforcement fails for any reason, kill the container immediately.
607
+ const containerIp = getContainerIp(containerName);
608
+ if (!containerIp) {
609
+ logger.error({ containerName }, 'Could not get container IP — killing container');
610
+ container.kill();
611
+ resolve({ status: 'error', result: null, error: 'Could not get container IP for iptables enforcement' });
612
+ return;
613
+ }
614
+ try {
615
+ applyContainerIptables(containerName, containerIp);
616
+ } catch (err) {
617
+ const error = err instanceof Error ? err.message : String(err);
618
+ logger.error({ containerName, containerIp, error }, 'iptables enforcement failed — killing container');
619
+ container.kill();
620
+ resolve({ status: 'error', result: null, error: `iptables enforcement failed: ${error}` });
621
+ return;
622
+ }
623
+
624
+ let stdout = '';
625
+ let stderr = '';
626
+ let stdoutTruncated = false;
627
+ let stderrTruncated = false;
628
+
629
+ container.stdin.write(JSON.stringify(input));
630
+ container.stdin.end();
631
+
632
+ // Streaming output: parse OUTPUT_START/END marker pairs as they arrive
633
+ let parseBuffer = '';
634
+ let newSessionId: string | undefined;
635
+ let outputChain = Promise.resolve();
636
+
637
+ container.stdout.on('data', (data) => {
638
+ const chunk = data.toString();
639
+
640
+ // Always accumulate for logging
641
+ if (!stdoutTruncated) {
642
+ const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
643
+ if (chunk.length > remaining) {
644
+ stdout += chunk.slice(0, remaining);
645
+ stdoutTruncated = true;
646
+ logger.warn(
647
+ { group: group.name, size: stdout.length },
648
+ 'Container stdout truncated due to size limit',
649
+ );
650
+ } else {
651
+ stdout += chunk;
652
+ }
653
+ }
654
+
655
+ // Stream-parse for output markers
656
+ if (onOutput) {
657
+ parseBuffer += chunk;
658
+ let startIdx: number;
659
+ while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
660
+ const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
661
+ if (endIdx === -1) break; // Incomplete pair, wait for more data
662
+
663
+ const jsonStr = parseBuffer
664
+ .slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
665
+ .trim();
666
+ parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
667
+
668
+ try {
669
+ const parsed: ContainerOutput = JSON.parse(jsonStr);
670
+ if (parsed.newSessionId) {
671
+ newSessionId = parsed.newSessionId;
672
+ }
673
+ hadStreamingOutput = true;
674
+ // Activity detected — reset the hard timeout
675
+ resetTimeout();
676
+ // Call onOutput for all markers (including null results)
677
+ // so idle timers start even for "silent" query completions.
678
+ outputChain = outputChain.then(() => onOutput(parsed));
679
+ } catch (err) {
680
+ logger.warn(
681
+ { group: group.name, error: err },
682
+ 'Failed to parse streamed output chunk',
683
+ );
684
+ }
685
+ }
686
+ }
687
+ });
688
+
689
+ container.stderr.on('data', (data) => {
690
+ const chunk = data.toString();
691
+ const lines = chunk.trim().split('\n');
692
+ for (const line of lines) {
693
+ if (line) logger.debug({ container: group.folder }, line);
694
+ }
695
+ // Don't reset timeout on stderr — SDK writes debug logs continuously.
696
+ // Timeout only resets on actual output (OUTPUT_MARKER in stdout).
697
+ if (stderrTruncated) return;
698
+ const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
699
+ if (chunk.length > remaining) {
700
+ stderr += chunk.slice(0, remaining);
701
+ stderrTruncated = true;
702
+ logger.warn(
703
+ { group: group.name, size: stderr.length },
704
+ 'Container stderr truncated due to size limit',
705
+ );
706
+ } else {
707
+ stderr += chunk;
708
+ }
709
+ });
710
+
711
+ let timedOut = false;
712
+ let hadStreamingOutput = false;
713
+ const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
714
+ // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
715
+ // graceful _close sentinel has time to trigger before the hard kill fires.
716
+ const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
717
+
718
+ const killOnTimeout = () => {
719
+ timedOut = true;
720
+ logger.error(
721
+ { group: group.name, containerName },
722
+ 'Container timeout, stopping gracefully',
723
+ );
724
+ exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
725
+ if (err) {
726
+ logger.warn(
727
+ { group: group.name, containerName, err },
728
+ 'Graceful stop failed, force killing',
729
+ );
730
+ container.kill('SIGKILL');
731
+ }
732
+ });
733
+ };
734
+
735
+ let timeout = setTimeout(killOnTimeout, timeoutMs);
736
+
737
+ // Reset the timeout whenever there's activity (streaming output)
738
+ const resetTimeout = () => {
739
+ clearTimeout(timeout);
740
+ timeout = setTimeout(killOnTimeout, timeoutMs);
741
+ };
742
+
743
+ container.on('close', (code) => {
744
+ clearTimeout(timeout);
745
+ // Always clean up iptables rules, regardless of exit reason
746
+ cleanupContainerIptables(containerName);
747
+ const duration = Date.now() - startTime;
748
+
749
+ if (timedOut) {
750
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
751
+ const timeoutLog = path.join(logsDir, `container-${ts}.log`);
752
+ fs.writeFileSync(
753
+ timeoutLog,
754
+ [
755
+ `=== Container Run Log (TIMEOUT) ===`,
756
+ `Timestamp: ${new Date().toISOString()}`,
757
+ `Group: ${group.name}`,
758
+ `Container: ${containerName}`,
759
+ `Duration: ${duration}ms`,
760
+ `Exit Code: ${code}`,
761
+ `Had Streaming Output: ${hadStreamingOutput}`,
762
+ ].join('\n'),
763
+ );
764
+
765
+ // Timeout after output = idle cleanup, not failure.
766
+ // The agent already sent its response; this is just the
767
+ // container being reaped after the idle period expired.
768
+ if (hadStreamingOutput) {
769
+ logger.info(
770
+ { group: group.name, containerName, duration, code },
771
+ 'Container timed out after output (idle cleanup)',
772
+ );
773
+ outputChain.then(() => {
774
+ resolve({
775
+ status: 'success',
776
+ result: null,
777
+ newSessionId,
778
+ });
779
+ });
780
+ return;
781
+ }
782
+
783
+ logger.error(
784
+ { group: group.name, containerName, duration, code },
785
+ 'Container timed out with no output',
786
+ );
787
+
788
+ resolve({
789
+ status: 'error',
790
+ result: null,
791
+ error: `Container timed out after ${configTimeout}ms`,
792
+ });
793
+ return;
794
+ }
795
+
796
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
797
+ const logFile = path.join(logsDir, `container-${timestamp}.log`);
798
+ const isVerbose =
799
+ process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
800
+
801
+ const logLines = [
802
+ `=== Container Run Log ===`,
803
+ `Timestamp: ${new Date().toISOString()}`,
804
+ `Group: ${group.name}`,
805
+ `IsMain: ${input.isMain}`,
806
+ `Duration: ${duration}ms`,
807
+ `Exit Code: ${code}`,
808
+ `Stdout Truncated: ${stdoutTruncated}`,
809
+ `Stderr Truncated: ${stderrTruncated}`,
810
+ ``,
811
+ ];
812
+
813
+ const isError = code !== 0;
814
+
815
+ if (isVerbose || isError) {
816
+ // On error, log input metadata only — not the full prompt.
817
+ // Full input is only included at verbose level to avoid
818
+ // persisting user conversation content on every non-zero exit.
819
+ if (isVerbose) {
820
+ logLines.push(`=== Input ===`, JSON.stringify(input, null, 2), ``);
821
+ } else {
822
+ logLines.push(
823
+ `=== Input Summary ===`,
824
+ `Prompt length: ${input.prompt.length} chars`,
825
+ `Session ID: ${input.sessionId || 'new'}`,
826
+ ``,
827
+ );
828
+ }
829
+ logLines.push(
830
+ `=== Container Args ===`,
831
+ redactContainerArgs(containerArgs),
832
+ ``,
833
+ `=== Mounts ===`,
834
+ mounts
835
+ .map(
836
+ (m) =>
837
+ `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
838
+ )
839
+ .join('\n'),
840
+ ``,
841
+ `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`,
842
+ stderr,
843
+ ``,
844
+ `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
845
+ stdout,
846
+ );
847
+ } else {
848
+ logLines.push(
849
+ `=== Input Summary ===`,
850
+ `Prompt length: ${input.prompt.length} chars`,
851
+ `Session ID: ${input.sessionId || 'new'}`,
852
+ ``,
853
+ `=== Mounts ===`,
854
+ mounts
855
+ .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`)
856
+ .join('\n'),
857
+ ``,
858
+ );
859
+ }
860
+
861
+ fs.writeFileSync(logFile, logLines.join('\n'));
862
+ logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
863
+
864
+ if (code !== 0) {
865
+ logger.error(
866
+ {
867
+ group: group.name,
868
+ code,
869
+ duration,
870
+ stderr,
871
+ stdout,
872
+ logFile,
873
+ },
874
+ 'Container exited with error',
875
+ );
876
+
877
+ resolve({
878
+ status: 'error',
879
+ result: null,
880
+ error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
881
+ });
882
+ return;
883
+ }
884
+
885
+ // Streaming mode: wait for output chain to settle, return completion marker
886
+ if (onOutput) {
887
+ outputChain.then(() => {
888
+ logger.info(
889
+ { group: group.name, duration, newSessionId },
890
+ 'Container completed (streaming mode)',
891
+ );
892
+ resolve({
893
+ status: 'success',
894
+ result: null,
895
+ newSessionId,
896
+ });
897
+ });
898
+ return;
899
+ }
900
+
901
+ // Legacy mode: parse the last output marker pair from accumulated stdout
902
+ try {
903
+ // Extract JSON between sentinel markers for robust parsing
904
+ const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
905
+ const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
906
+
907
+ let jsonLine: string;
908
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
909
+ jsonLine = stdout
910
+ .slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
911
+ .trim();
912
+ } else {
913
+ // Fallback: last non-empty line (backwards compatibility)
914
+ const lines = stdout.trim().split('\n');
915
+ jsonLine = lines[lines.length - 1];
916
+ }
917
+
918
+ const output: ContainerOutput = JSON.parse(jsonLine);
919
+
920
+ logger.info(
921
+ {
922
+ group: group.name,
923
+ duration,
924
+ status: output.status,
925
+ hasResult: !!output.result,
926
+ },
927
+ 'Container completed',
928
+ );
929
+
930
+ resolve(output);
931
+ } catch (err) {
932
+ logger.error(
933
+ {
934
+ group: group.name,
935
+ stdout,
936
+ stderr,
937
+ error: err,
938
+ },
939
+ 'Failed to parse container output',
940
+ );
941
+
942
+ resolve({
943
+ status: 'error',
944
+ result: null,
945
+ error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`,
946
+ });
947
+ }
948
+ });
949
+
950
+ container.on('error', (err) => {
951
+ clearTimeout(timeout);
952
+ cleanupContainerIptables(containerName);
953
+ logger.error(
954
+ { group: group.name, containerName, error: err },
955
+ 'Container spawn error',
956
+ );
957
+ resolve({
958
+ status: 'error',
959
+ result: null,
960
+ error: `Container spawn error: ${err.message}`,
961
+ });
962
+ });
963
+ });
964
+ }
965
+
966
+ export function writeTasksSnapshot(
967
+ groupFolder: string,
968
+ isMain: boolean,
969
+ tasks: Array<{
970
+ id: string;
971
+ groupFolder: string;
972
+ prompt: string;
973
+ script?: string | null;
974
+ schedule_type: string;
975
+ schedule_value: string;
976
+ status: string;
977
+ next_run: string | null;
978
+ }>,
979
+ ): void {
980
+ // Write filtered tasks to the group's IPC directory
981
+ const groupIpcDir = resolveGroupIpcPath(groupFolder);
982
+ fs.mkdirSync(groupIpcDir, { recursive: true });
983
+
984
+ // Main sees all tasks, others only see their own
985
+ const filteredTasks = isMain
986
+ ? tasks
987
+ : tasks.filter((t) => t.groupFolder === groupFolder);
988
+
989
+ const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
990
+ fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
991
+ }
992
+
993
+ export interface AvailableGroup {
994
+ jid: string;
995
+ name: string;
996
+ lastActivity: string;
997
+ isRegistered: boolean;
998
+ }
999
+
1000
+ /**
1001
+ * Write available groups snapshot for the container to read.
1002
+ * Only main group can see all available groups (for activation).
1003
+ * Non-main groups only see their own registration status.
1004
+ */
1005
+ export function writeGroupsSnapshot(
1006
+ groupFolder: string,
1007
+ isMain: boolean,
1008
+ groups: AvailableGroup[],
1009
+ _registeredJids: Set<string>,
1010
+ ): void {
1011
+ const groupIpcDir = resolveGroupIpcPath(groupFolder);
1012
+ fs.mkdirSync(groupIpcDir, { recursive: true });
1013
+
1014
+ // Main sees all groups; others see nothing (they can't activate groups)
1015
+ const visibleGroups = isMain ? groups : [];
1016
+
1017
+ const groupsFile = path.join(groupIpcDir, 'available_groups.json');
1018
+ fs.writeFileSync(
1019
+ groupsFile,
1020
+ JSON.stringify(
1021
+ {
1022
+ groups: visibleGroups,
1023
+ lastSync: new Date().toISOString(),
1024
+ },
1025
+ null,
1026
+ 2,
1027
+ ),
1028
+ );
1029
+ }