feat: add Codex CLI support, dual Discord bots, and Discord typing fix

- Add Codex as alternative agent type (agentType: 'codex' per group)
- Codex container runner with session resume and 100-turn limit
- Dual Discord bot support (DISCORD_BOT_TOKEN + DISCORD_CODEX_BOT_TOKEN)
- Each bot handles only its agentType's registered groups
- Discord typing indicator refreshes every 8s (was single-shot, expired after ~10s)
- Agent runner 100-turn limit to prevent infinite bot-to-bot loops
- Apple Container runtime support (convert-to-apple-container skill)
- Session commands (/compact) support
- DB migration for agent_type column
This commit is contained in:
Eyejoker
2026-03-10 20:45:48 +09:00
parent d572bab5c6
commit 4a34a9c802
24 changed files with 2606 additions and 176 deletions

View File

@@ -7,7 +7,6 @@ FROM node:22-slim
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \
libgbm1 \
libnss3 \
@@ -52,17 +51,16 @@ RUN npm run build
RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
# Create entrypoint script
# Container input (prompt, group info) is passed via stdin JSON.
# Credentials are injected by the host's credential proxy — never passed here.
# Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it
# Follow-up messages arrive via IPC files in /workspace/ipc/input/
RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
# Apple Container only supports directory mounts (VirtioFS), so .env cannot be
# shadowed with a host-side /dev/null file mount. Instead the entrypoint starts
# as root, uses mount --bind to shadow .env, then drops to the host user via setpriv.
RUN printf '#!/bin/bash\nset -e\n\n# Shadow .env so the agent cannot read host secrets (requires root)\nif [ "$(id -u)" = "0" ] && [ -f /workspace/project/.env ]; then\n mount --bind /dev/null /workspace/project/.env\nfi\n\n# Compile agent-runner\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\n\n# Capture stdin (secrets JSON) to temp file\ncat > /tmp/input.json\n\n# Drop privileges if running as root (main-group containers)\nif [ "$(id -u)" = "0" ] && [ -n "$RUN_UID" ]; then\n chown "$RUN_UID:$RUN_GID" /tmp/input.json /tmp/dist\n exec setpriv --reuid="$RUN_UID" --regid="$RUN_GID" --clear-groups -- node /tmp/dist/index.js < /tmp/input.json\nfi\n\nexec node /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
# Set ownership to node user (non-root) for writable directories
RUN chown -R node:node /workspace && chmod 777 /home/node
# Switch to non-root user (required for --dangerously-skip-permissions)
USER node
# Set working directory to group workspace
WORKDIR /workspace/group

View File

@@ -0,0 +1,43 @@
# NanoClaw Codex Agent Container
# Runs Codex CLI in isolated Linux VM
FROM node:22-slim
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# Install Codex CLI globally
RUN npm install -g @openai/codex
# Create app directory
WORKDIR /app
# Copy package files first for better caching
COPY codex-runner/package*.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY codex-runner/ ./
# Build TypeScript
RUN npm run build
# Create workspace directories
RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
# Create entrypoint script — use pre-built dist/ from image,
# but if /app/src is mounted with newer source, recompile on the fly.
RUN printf '#!/bin/bash\nset -e\n\n# Use pre-built dist if available, recompile only if source was mounted\nif [ -f /app/dist/index.js ]; then\n RUNNER_DIR=/app/dist\nelse\n cd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\n ln -s /app/node_modules /tmp/dist/node_modules\n RUNNER_DIR=/tmp/dist\nfi\n\n# Capture stdin to temp file\ncat > /tmp/input.json\n\n# Drop privileges if running as root\nif [ "$(id -u)" = "0" ] && [ -n "$RUN_UID" ]; then\n chown "$RUN_UID:$RUN_GID" /tmp/input.json\n exec setpriv --reuid="$RUN_UID" --regid="$RUN_GID" --clear-groups -- node "$RUNNER_DIR/index.js" < /tmp/input.json\nfi\n\nexec node "$RUNNER_DIR/index.js" < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
# Set ownership to node user
RUN chown -R node:node /workspace && chmod 777 /home/node
# Set working directory to group workspace
WORKDIR /workspace/group
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -16,7 +16,7 @@
import fs from 'fs';
import path from 'path';
import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
import { fileURLToPath } from 'url';
interface ContainerInput {
@@ -27,6 +27,7 @@ interface ContainerInput {
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
secrets?: Record<string, string>;
}
interface ContainerOutput {
@@ -55,6 +56,7 @@ interface SDKUserMessage {
}
const IPC_INPUT_DIR = '/workspace/ipc/input';
const MAX_TURNS = 100;
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
@@ -184,6 +186,30 @@ function createPreCompactHook(assistantName?: string): HookCallback {
};
}
// Secrets to strip from Bash tool subprocess environments.
// These are needed by claude-code for API auth but should never
// be visible to commands Kit runs.
const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
function createSanitizeBashHook(): HookCallback {
return async (input, _toolUseId, _context) => {
const preInput = input as PreToolUseHookInput;
const command = (preInput.tool_input as { command?: string })?.command;
if (!command) return {};
const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `;
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
updatedInput: {
...(preInput.tool_input as Record<string, unknown>),
command: unsetPrefix + command,
},
},
};
};
}
function sanitizeFilename(summary: string): string {
return summary
.toLowerCase()
@@ -389,10 +415,29 @@ async function runQuery(
log(`Additional directories: ${extraDirs.join(', ')}`);
}
// Model and thinking configuration from environment
const model = process.env.CLAUDE_MODEL || undefined;
const thinkingType = process.env.CLAUDE_THINKING || undefined; // 'adaptive' | 'enabled' | 'disabled'
const thinkingBudget = process.env.CLAUDE_THINKING_BUDGET
? parseInt(process.env.CLAUDE_THINKING_BUDGET, 10)
: undefined;
const effort = (process.env.CLAUDE_EFFORT as 'low' | 'medium' | 'high' | 'max') || undefined;
const thinking = thinkingType === 'adaptive' ? { type: 'adaptive' as const }
: thinkingType === 'enabled' ? { type: 'enabled' as const, budgetTokens: thinkingBudget }
: thinkingType === 'disabled' ? { type: 'disabled' as const }
: undefined;
if (model) log(`Using model: ${model}`);
if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`);
if (effort) log(`Effort: ${effort}`);
for await (const message of query({
prompt: stream,
options: {
cwd: '/workspace/group',
model,
thinking,
effort,
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
resume: sessionId,
resumeSessionAt: resumeAt,
@@ -426,6 +471,7 @@ async function runQuery(
},
hooks: {
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
},
}
})) {
@@ -470,6 +516,7 @@ async function main(): Promise<void> {
try {
const stdinData = await readStdin();
containerInput = JSON.parse(stdinData);
// Delete the temp file the entrypoint wrote — it contains secrets
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
log(`Received input for group: ${containerInput.groupFolder}`);
} catch (err) {
@@ -481,9 +528,12 @@ async function main(): Promise<void> {
process.exit(1);
}
// Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL.
// No real secrets exist in the container environment.
// Build SDK env: merge secrets into process.env for the SDK only.
// Secrets never touch process.env itself, so Bash subprocesses can't see them.
const sdkEnv: Record<string, string | undefined> = { ...process.env };
for (const [key, value] of Object.entries(containerInput.secrets || {})) {
sdkEnv[key] = value;
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
@@ -505,11 +555,118 @@ async function main(): Promise<void> {
prompt += '\n' + pending.join('\n');
}
// --- Slash command handling ---
// Only known session slash commands are handled here. This prevents
// accidental interception of user prompts that happen to start with '/'.
const KNOWN_SESSION_COMMANDS = new Set(['/compact']);
const trimmedPrompt = prompt.trim();
const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(trimmedPrompt);
if (isSessionSlashCommand) {
log(`Handling session command: ${trimmedPrompt}`);
let slashSessionId: string | undefined;
let compactBoundarySeen = false;
let hadError = false;
let resultEmitted = false;
try {
for await (const message of query({
prompt: trimmedPrompt,
options: {
cwd: '/workspace/group',
resume: sessionId,
systemPrompt: undefined,
allowedTools: [],
env: sdkEnv,
permissionMode: 'bypassPermissions' as const,
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'] as const,
hooks: {
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
},
},
})) {
const msgType = message.type === 'system'
? `system/${(message as { subtype?: string }).subtype}`
: message.type;
log(`[slash-cmd] type=${msgType}`);
if (message.type === 'system' && message.subtype === 'init') {
slashSessionId = message.session_id;
log(`Session after slash command: ${slashSessionId}`);
}
// Observe compact_boundary to confirm compaction completed
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
compactBoundarySeen = true;
log('Compact boundary observed — compaction completed');
}
if (message.type === 'result') {
const resultSubtype = (message as { subtype?: string }).subtype;
const textResult = 'result' in message ? (message as { result?: string }).result : null;
if (resultSubtype?.startsWith('error')) {
hadError = true;
writeOutput({
status: 'error',
result: null,
error: textResult || 'Session command failed.',
newSessionId: slashSessionId,
});
} else {
writeOutput({
status: 'success',
result: textResult || 'Conversation compacted.',
newSessionId: slashSessionId,
});
}
resultEmitted = true;
}
}
} catch (err) {
hadError = true;
const errorMsg = err instanceof Error ? err.message : String(err);
log(`Slash command error: ${errorMsg}`);
writeOutput({ status: 'error', result: null, error: errorMsg });
}
log(`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`);
// Warn if compact_boundary was never observed — compaction may not have occurred
if (!hadError && !compactBoundarySeen) {
log('WARNING: compact_boundary was not observed. Compaction may not have completed.');
}
// Only emit final session marker if no result was emitted yet and no error occurred
if (!resultEmitted && !hadError) {
writeOutput({
status: 'success',
result: compactBoundarySeen
? 'Conversation compacted.'
: 'Compaction requested but compact_boundary was not observed.',
newSessionId: slashSessionId,
});
} else if (!hadError) {
// Emit session-only marker so host updates session tracking
writeOutput({ status: 'success', result: null, newSessionId: slashSessionId });
}
return;
}
// --- End slash command handling ---
// Query loop: run query → wait for IPC message → run new query → repeat
let resumeAt: string | undefined;
let turnCount = 0;
try {
while (true) {
log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`);
turnCount++;
if (turnCount > MAX_TURNS) {
log(`Turn limit reached (${MAX_TURNS}), exiting`);
writeOutput({ status: 'success', result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]', newSessionId: sessionId });
break;
}
log(`Starting query (session: ${sessionId || 'new'}, turn: ${turnCount}/${MAX_TURNS}, resumeAt: ${resumeAt || 'latest'})...`);
const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt);
if (queryResult.newSessionId) {

View File

@@ -8,13 +8,22 @@ cd "$SCRIPT_DIR"
IMAGE_NAME="nanoclaw-agent"
TAG="${1:-latest}"
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}"
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}"
echo "Building NanoClaw agent container image..."
echo "Image: ${IMAGE_NAME}:${TAG}"
${CONTAINER_RUNTIME} build -t "${IMAGE_NAME}:${TAG}" .
# Build Codex agent image (if Dockerfile.codex exists)
if [ -f "$SCRIPT_DIR/Dockerfile.codex" ]; then
echo ""
echo "Building NanoClaw Codex agent container image..."
echo "Image: nanoclaw-codex-agent:${TAG}"
${CONTAINER_RUNTIME} build -t "nanoclaw-codex-agent:${TAG}" -f Dockerfile.codex .
echo "Built nanoclaw-codex-agent:${TAG}"
fi
echo ""
echo "Build complete!"
echo "Image: ${IMAGE_NAME}:${TAG}"

View File

@@ -0,0 +1,16 @@
{
"name": "nanoclaw-codex-runner",
"version": "1.0.0",
"type": "module",
"description": "Container-side Codex CLI runner for NanoClaw",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,294 @@
/**
* NanoClaw Codex Runner
* Runs inside a container, receives config via stdin, executes Codex CLI, outputs result to stdout
*
* Input protocol:
* Stdin: Full ContainerInput JSON (read until EOF)
* IPC: Follow-up messages written as JSON files to /workspace/ipc/input/
* Sentinel: /workspace/ipc/input/_close — signals session end
*
* Stdout protocol:
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
*/
import { spawn, ChildProcess } from 'child_process';
import fs from 'fs';
import path from 'path';
interface ContainerInput {
prompt: string;
sessionId?: string;
groupFolder: string;
chatJid: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
agentType?: string;
}
interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
}
const IPC_INPUT_DIR = '/workspace/ipc/input';
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
const MAX_TURNS = 100;
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
function writeOutput(output: ContainerOutput): void {
console.log(OUTPUT_START_MARKER);
console.log(JSON.stringify(output));
console.log(OUTPUT_END_MARKER);
}
function log(message: string): void {
console.error(`[codex-runner] ${message}`);
}
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk: string) => { data += chunk; });
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
function shouldClose(): boolean {
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
return true;
}
return false;
}
function drainIpcInput(): string[] {
try {
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
const files = fs.readdirSync(IPC_INPUT_DIR)
.filter(f => f.endsWith('.json'))
.sort();
const messages: string[] = [];
for (const file of files) {
const filePath = path.join(IPC_INPUT_DIR, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
fs.unlinkSync(filePath);
if (data.type === 'message' && data.text) {
messages.push(data.text);
}
} catch (err) {
log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`);
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
}
return messages;
} catch (err) {
log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`);
return [];
}
}
function waitForIpcMessage(): Promise<string | null> {
return new Promise((resolve) => {
const poll = () => {
if (shouldClose()) {
resolve(null);
return;
}
const messages = drainIpcInput();
if (messages.length > 0) {
resolve(messages.join('\n'));
return;
}
setTimeout(poll, IPC_POLL_MS);
};
poll();
});
}
/**
* Run a Codex CLI exec command and return the result.
* If resume=true, resumes the most recent session instead of starting fresh.
*/
async function runCodexExec(prompt: string, resume: boolean): Promise<{ result: string; error?: string }> {
return new Promise((resolve) => {
const args: string[] = [];
if (resume) {
// Resume the most recent session with a new prompt
// --dangerously-bypass-approvals-and-sandbox: fully disables sandbox
// (--full-auto forces workspace-write which blocks /workspace/extra/ mounts)
args.push(
'exec', 'resume', '--last',
'--dangerously-bypass-approvals-and-sandbox',
'--skip-git-repo-check',
prompt,
);
} else {
// Fresh session
// --dangerously-bypass-approvals-and-sandbox: fully disables sandbox
// (--full-auto forces workspace-write which blocks /workspace/extra/ mounts)
args.push(
'exec',
'--dangerously-bypass-approvals-and-sandbox',
'-C', '/workspace/group',
'--skip-git-repo-check',
'--color', 'never',
prompt,
);
}
log(`Running: codex ${args.slice(0, 6).join(' ')}... (resume=${resume})`);
const codex: ChildProcess = spawn('codex', args, {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: '/workspace/group',
env: { ...process.env },
});
let stdout = '';
let stderr = '';
codex.stdout?.on('data', (data: Buffer) => {
stdout += data.toString();
});
codex.stderr?.on('data', (data: Buffer) => {
const chunk = data.toString();
stderr += chunk;
// Log stderr lines for debugging
for (const line of chunk.trim().split('\n')) {
if (line) log(line);
}
});
codex.on('close', (code: number | null) => {
log(`Codex exited with code ${code}`);
if (code !== 0) {
resolve({
result: stdout.trim() || '',
error: `Codex exited with code ${code}: ${stderr.slice(-500)}`,
});
return;
}
// Extract the meaningful output
const result = stdout.trim();
resolve({ result });
});
codex.on('error', (err: Error) => {
resolve({
result: '',
error: `Failed to spawn codex: ${err.message}`,
});
});
// Close stdin immediately since we pass prompt as argument
codex.stdin?.end();
});
}
async function main(): Promise<void> {
let containerInput: ContainerInput;
try {
const stdinData = await readStdin();
containerInput = JSON.parse(stdinData);
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
log(`Received input for group: ${containerInput.groupFolder}`);
} catch (err) {
writeOutput({
status: 'error',
result: null,
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`
});
process.exit(1);
}
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
// Clean up stale _close sentinel
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
// Build initial prompt
let prompt = containerInput.prompt;
if (containerInput.isScheduledTask) {
prompt = `[SCHEDULED TASK]\n\n${prompt}`;
}
const pending = drainIpcInput();
if (pending.length > 0) {
prompt += '\n' + pending.join('\n');
}
// Query loop: run codex exec → wait for IPC message → repeat
// First exec is a fresh session; subsequent execs resume the last session.
let isFirstExec = true;
let turnCount = 0;
try {
while (true) {
turnCount++;
if (turnCount > MAX_TURNS) {
log(`Turn limit reached (${MAX_TURNS}), exiting`);
writeOutput({ status: 'success', result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]' });
break;
}
log(`Starting codex exec (first=${isFirstExec}, turn=${turnCount}/${MAX_TURNS})...`);
const { result, error } = await runCodexExec(prompt, !isFirstExec);
isFirstExec = false;
if (error) {
log(`Codex error: ${error}`);
writeOutput({
status: 'error',
result: result || null,
error,
});
} else {
writeOutput({
status: 'success',
result: result || null,
});
}
// Check if close was requested
if (shouldClose()) {
log('Close sentinel detected, exiting');
break;
}
log('Codex exec done, waiting for next IPC message...');
const nextMessage = await waitForIpcMessage();
if (nextMessage === null) {
log('Close sentinel received, exiting');
break;
}
log(`Got new message (${nextMessage.length} chars), starting new codex exec`);
prompt = nextMessage;
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Runner error: ${errorMessage}`);
writeOutput({
status: 'error',
result: null,
error: errorMessage,
});
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}