feat: remove container layer, run agents as direct host processes
- Rewrite container-runner.ts to spawn node processes directly instead of Docker/Apple Container - Runner paths configurable via env vars (NANOCLAW_GROUP_DIR, etc.) with container-path defaults for backwards compat - Pass real credentials directly (no credential proxy needed) - Remove ensureContainerSystemRunning() and startCredentialProxy() from startup - Add build:runners script for building agent-runner and codex-runner - Fix TS strict mode errors in agent-runner ipc-mcp-stdio.ts
This commit is contained in:
3
container/agent-runner/package-lock.json
generated
3
container/agent-runner/package-lock.json
generated
@@ -724,6 +724,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -928,6 +929,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz",
|
||||||
"integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==",
|
"integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
@@ -1507,6 +1509,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,13 @@ interface SDKUserMessage {
|
|||||||
session_id: string;
|
session_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IPC_INPUT_DIR = '/workspace/ipc/input';
|
// Paths configurable via env vars (defaults to container paths for backwards compat)
|
||||||
|
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
|
||||||
|
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
|
||||||
|
const GLOBAL_DIR = process.env.NANOCLAW_GLOBAL_DIR || '/workspace/global';
|
||||||
|
const EXTRA_BASE = process.env.NANOCLAW_EXTRA_DIR || '/workspace/extra';
|
||||||
|
|
||||||
|
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
|
||||||
const MAX_TURNS = 100;
|
const MAX_TURNS = 100;
|
||||||
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
||||||
const IPC_POLL_MS = 500;
|
const IPC_POLL_MS = 500;
|
||||||
@@ -167,7 +173,7 @@ function createPreCompactHook(assistantName?: string): HookCallback {
|
|||||||
const summary = getSessionSummary(sessionId, transcriptPath);
|
const summary = getSessionSummary(sessionId, transcriptPath);
|
||||||
const name = summary ? sanitizeFilename(summary) : generateFallbackName();
|
const name = summary ? sanitizeFilename(summary) : generateFallbackName();
|
||||||
|
|
||||||
const conversationsDir = '/workspace/group/conversations';
|
const conversationsDir = path.join(GROUP_DIR, 'conversations');
|
||||||
fs.mkdirSync(conversationsDir, { recursive: true });
|
fs.mkdirSync(conversationsDir, { recursive: true });
|
||||||
|
|
||||||
const date = new Date().toISOString().split('T')[0];
|
const date = new Date().toISOString().split('T')[0];
|
||||||
@@ -393,7 +399,7 @@ async function runQuery(
|
|||||||
let resultCount = 0;
|
let resultCount = 0;
|
||||||
|
|
||||||
// Load global CLAUDE.md as additional system context (shared across all groups)
|
// Load global CLAUDE.md as additional system context (shared across all groups)
|
||||||
const globalClaudeMdPath = '/workspace/global/CLAUDE.md';
|
const globalClaudeMdPath = path.join(GLOBAL_DIR, 'CLAUDE.md');
|
||||||
let globalClaudeMd: string | undefined;
|
let globalClaudeMd: string | undefined;
|
||||||
if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
|
if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
|
||||||
globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
|
globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
|
||||||
@@ -402,7 +408,7 @@ async function runQuery(
|
|||||||
// Discover additional directories mounted at /workspace/extra/*
|
// Discover additional directories mounted at /workspace/extra/*
|
||||||
// These are passed to the SDK so their CLAUDE.md files are loaded automatically
|
// These are passed to the SDK so their CLAUDE.md files are loaded automatically
|
||||||
const extraDirs: string[] = [];
|
const extraDirs: string[] = [];
|
||||||
const extraBase = '/workspace/extra';
|
const extraBase = EXTRA_BASE;
|
||||||
if (fs.existsSync(extraBase)) {
|
if (fs.existsSync(extraBase)) {
|
||||||
for (const entry of fs.readdirSync(extraBase)) {
|
for (const entry of fs.readdirSync(extraBase)) {
|
||||||
const fullPath = path.join(extraBase, entry);
|
const fullPath = path.join(extraBase, entry);
|
||||||
@@ -434,7 +440,7 @@ async function runQuery(
|
|||||||
for await (const message of query({
|
for await (const message of query({
|
||||||
prompt: stream,
|
prompt: stream,
|
||||||
options: {
|
options: {
|
||||||
cwd: '/workspace/group',
|
cwd: GROUP_DIR,
|
||||||
model,
|
model,
|
||||||
thinking,
|
thinking,
|
||||||
effort,
|
effort,
|
||||||
@@ -573,7 +579,7 @@ async function main(): Promise<void> {
|
|||||||
for await (const message of query({
|
for await (const message of query({
|
||||||
prompt: trimmedPrompt,
|
prompt: trimmedPrompt,
|
||||||
options: {
|
options: {
|
||||||
cwd: '/workspace/group',
|
cwd: GROUP_DIR,
|
||||||
resume: sessionId,
|
resume: sessionId,
|
||||||
systemPrompt: undefined,
|
systemPrompt: undefined,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ server.tool(
|
|||||||
'pause_task',
|
'pause_task',
|
||||||
'Pause a scheduled task. It will not run until resumed.',
|
'Pause a scheduled task. It will not run until resumed.',
|
||||||
{ task_id: z.string().describe('The task ID to pause') },
|
{ task_id: z.string().describe('The task ID to pause') },
|
||||||
async (args) => {
|
async (args: { task_id: string }) => {
|
||||||
const data = {
|
const data = {
|
||||||
type: 'pause_task',
|
type: 'pause_task',
|
||||||
taskId: args.task_id,
|
taskId: args.task_id,
|
||||||
@@ -213,7 +213,7 @@ server.tool(
|
|||||||
'resume_task',
|
'resume_task',
|
||||||
'Resume a paused task.',
|
'Resume a paused task.',
|
||||||
{ task_id: z.string().describe('The task ID to resume') },
|
{ task_id: z.string().describe('The task ID to resume') },
|
||||||
async (args) => {
|
async (args: { task_id: string }) => {
|
||||||
const data = {
|
const data = {
|
||||||
type: 'resume_task',
|
type: 'resume_task',
|
||||||
taskId: args.task_id,
|
taskId: args.task_id,
|
||||||
@@ -232,7 +232,7 @@ server.tool(
|
|||||||
'cancel_task',
|
'cancel_task',
|
||||||
'Cancel and delete a scheduled task.',
|
'Cancel and delete a scheduled task.',
|
||||||
{ task_id: z.string().describe('The task ID to cancel') },
|
{ task_id: z.string().describe('The task ID to cancel') },
|
||||||
async (args) => {
|
async (args: { task_id: string }) => {
|
||||||
const data = {
|
const data = {
|
||||||
type: 'cancel_task',
|
type: 'cancel_task',
|
||||||
taskId: args.task_id,
|
taskId: args.task_id,
|
||||||
@@ -256,7 +256,7 @@ server.tool(
|
|||||||
schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'),
|
schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'),
|
||||||
schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'),
|
schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'),
|
||||||
},
|
},
|
||||||
async (args) => {
|
async (args: { task_id: string; prompt?: string; schedule_type?: 'cron' | 'interval' | 'once'; schedule_value?: string }) => {
|
||||||
// Validate schedule_value if provided
|
// Validate schedule_value if provided
|
||||||
if (args.schedule_type === 'cron' || (!args.schedule_type && args.schedule_value)) {
|
if (args.schedule_type === 'cron' || (!args.schedule_type && args.schedule_value)) {
|
||||||
if (args.schedule_value) {
|
if (args.schedule_value) {
|
||||||
@@ -308,7 +308,7 @@ Use available_groups.json to find the JID for a group. The folder name must be c
|
|||||||
folder: z.string().describe('Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")'),
|
folder: z.string().describe('Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")'),
|
||||||
trigger: z.string().describe('Trigger word (e.g., "@Andy")'),
|
trigger: z.string().describe('Trigger word (e.g., "@Andy")'),
|
||||||
},
|
},
|
||||||
async (args) => {
|
async (args: { jid: string; name: string; folder: string; trigger: string }) => {
|
||||||
if (!isMain) {
|
if (!isMain) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: 'Only the main group can register new groups.' }],
|
content: [{ type: 'text' as const, text: 'Only the main group can register new groups.' }],
|
||||||
|
|||||||
47
container/codex-runner/package-lock.json
generated
Normal file
47
container/codex-runner/package-lock.json
generated
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "nanoclaw-codex-runner",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "nanoclaw-codex-runner",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,10 @@ interface ContainerOutput {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IPC_INPUT_DIR = '/workspace/ipc/input';
|
// Paths configurable via env vars (defaults to container paths for backwards compat)
|
||||||
|
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
|
||||||
|
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
|
||||||
|
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
|
||||||
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
||||||
const IPC_POLL_MS = 500;
|
const IPC_POLL_MS = 500;
|
||||||
const MAX_TURNS = 100;
|
const MAX_TURNS = 100;
|
||||||
@@ -140,7 +143,7 @@ async function runCodexExec(prompt: string, resume: boolean): Promise<{ result:
|
|||||||
args.push(
|
args.push(
|
||||||
'exec',
|
'exec',
|
||||||
'--dangerously-bypass-approvals-and-sandbox',
|
'--dangerously-bypass-approvals-and-sandbox',
|
||||||
'-C', '/workspace/group',
|
'-C', GROUP_DIR,
|
||||||
'--skip-git-repo-check',
|
'--skip-git-repo-check',
|
||||||
'--color', 'never',
|
'--color', 'never',
|
||||||
prompt,
|
prompt,
|
||||||
@@ -151,7 +154,7 @@ async function runCodexExec(prompt: string, resume: boolean): Promise<{ result:
|
|||||||
|
|
||||||
const codex: ChildProcess = spawn('codex', args, {
|
const codex: ChildProcess = spawn('codex', args, {
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
cwd: '/workspace/group',
|
cwd: GROUP_DIR,
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"build:runners": "cd container/agent-runner && npm install && npm run build && cd ../codex-runner && npm install && npm run build",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|||||||
@@ -161,9 +161,7 @@ function createMessage(overrides: {
|
|||||||
member: overrides.memberDisplayName
|
member: overrides.memberDisplayName
|
||||||
? { displayName: overrides.memberDisplayName }
|
? { displayName: overrides.memberDisplayName }
|
||||||
: null,
|
: null,
|
||||||
guild: overrides.guildName
|
guild: overrides.guildName ? { name: overrides.guildName } : null,
|
||||||
? { name: overrides.guildName }
|
|
||||||
: null,
|
|
||||||
channel: {
|
channel: {
|
||||||
name: overrides.channelName ?? 'general',
|
name: overrides.channelName ?? 'general',
|
||||||
messages: {
|
messages: {
|
||||||
@@ -646,8 +644,11 @@ describe('DiscordChannel', () => {
|
|||||||
|
|
||||||
await channel.sendMessage('dc:1234567890123456', 'Hello');
|
await channel.sendMessage('dc:1234567890123456', 'Hello');
|
||||||
|
|
||||||
const fetchedChannel = await currentClient().channels.fetch('1234567890123456');
|
const fetchedChannel =
|
||||||
expect(currentClient().channels.fetch).toHaveBeenCalledWith('1234567890123456');
|
await currentClient().channels.fetch('1234567890123456');
|
||||||
|
expect(currentClient().channels.fetch).toHaveBeenCalledWith(
|
||||||
|
'1234567890123456',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips dc: prefix from JID', async () => {
|
it('strips dc: prefix from JID', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Client, Events, GatewayIntentBits, Message, TextChannel } from 'discord.js';
|
import {
|
||||||
|
Client,
|
||||||
|
Events,
|
||||||
|
GatewayIntentBits,
|
||||||
|
Message,
|
||||||
|
TextChannel,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
|
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
|
||||||
import { readEnvFile } from '../env.js';
|
import { readEnvFile } from '../env.js';
|
||||||
@@ -27,7 +33,11 @@ export class DiscordChannel implements Channel {
|
|||||||
private typingIntervals = new Map<string, NodeJS.Timeout>();
|
private typingIntervals = new Map<string, NodeJS.Timeout>();
|
||||||
private agentTypeFilter?: AgentType;
|
private agentTypeFilter?: AgentType;
|
||||||
|
|
||||||
constructor(botToken: string, opts: DiscordChannelOpts, agentTypeFilter?: AgentType) {
|
constructor(
|
||||||
|
botToken: string,
|
||||||
|
opts: DiscordChannelOpts,
|
||||||
|
agentTypeFilter?: AgentType,
|
||||||
|
) {
|
||||||
this.botToken = botToken;
|
this.botToken = botToken;
|
||||||
this.opts = opts;
|
this.opts = opts;
|
||||||
this.agentTypeFilter = agentTypeFilter;
|
this.agentTypeFilter = agentTypeFilter;
|
||||||
@@ -95,7 +105,8 @@ export class DiscordChannel implements Channel {
|
|||||||
|
|
||||||
// Handle attachments — store placeholders so the agent knows something was sent
|
// Handle attachments — store placeholders so the agent knows something was sent
|
||||||
if (message.attachments.size > 0) {
|
if (message.attachments.size > 0) {
|
||||||
const attachmentDescriptions = [...message.attachments.values()].map((att) => {
|
const attachmentDescriptions = [...message.attachments.values()].map(
|
||||||
|
(att) => {
|
||||||
const contentType = att.contentType || '';
|
const contentType = att.contentType || '';
|
||||||
if (contentType.startsWith('image/')) {
|
if (contentType.startsWith('image/')) {
|
||||||
return `[Image: ${att.name || 'image'}]`;
|
return `[Image: ${att.name || 'image'}]`;
|
||||||
@@ -106,7 +117,8 @@ export class DiscordChannel implements Channel {
|
|||||||
} else {
|
} else {
|
||||||
return `[File: ${att.name || 'file'}]`;
|
return `[File: ${att.name || 'file'}]`;
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
if (content) {
|
if (content) {
|
||||||
content = `${content}\n${attachmentDescriptions.join('\n')}`;
|
content = `${content}\n${attachmentDescriptions.join('\n')}`;
|
||||||
} else {
|
} else {
|
||||||
@@ -132,7 +144,13 @@ export class DiscordChannel implements Channel {
|
|||||||
|
|
||||||
// Store chat metadata for discovery
|
// Store chat metadata for discovery
|
||||||
const isGroup = message.guild !== null;
|
const isGroup = message.guild !== null;
|
||||||
this.opts.onChatMetadata(chatJid, timestamp, chatName, 'discord', isGroup);
|
this.opts.onChatMetadata(
|
||||||
|
chatJid,
|
||||||
|
timestamp,
|
||||||
|
chatName,
|
||||||
|
'discord',
|
||||||
|
isGroup,
|
||||||
|
);
|
||||||
|
|
||||||
// Only deliver full message for registered groups matching our agent type
|
// Only deliver full message for registered groups matching our agent type
|
||||||
const group = this.opts.registeredGroups()[chatJid];
|
const group = this.opts.registeredGroups()[chatJid];
|
||||||
@@ -143,7 +161,10 @@ export class DiscordChannel implements Channel {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.agentTypeFilter && (group.agentType || 'claude-code') !== this.agentTypeFilter) {
|
if (
|
||||||
|
this.agentTypeFilter &&
|
||||||
|
(group.agentType || 'claude-code') !== this.agentTypeFilter
|
||||||
|
) {
|
||||||
return; // This JID belongs to a different agent type's bot
|
return; // This JID belongs to a different agent type's bot
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,14 +299,22 @@ registerChannel('discord', (opts: ChannelOpts) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// If a second Codex bot token exists, this instance only handles claude-code groups
|
// If a second Codex bot token exists, this instance only handles claude-code groups
|
||||||
const hasCodexBot = !!(process.env.DISCORD_CODEX_BOT_TOKEN || envVars.DISCORD_CODEX_BOT_TOKEN);
|
const hasCodexBot = !!(
|
||||||
return new DiscordChannel(token, opts, hasCodexBot ? 'claude-code' : undefined);
|
process.env.DISCORD_CODEX_BOT_TOKEN || envVars.DISCORD_CODEX_BOT_TOKEN
|
||||||
|
);
|
||||||
|
return new DiscordChannel(
|
||||||
|
token,
|
||||||
|
opts,
|
||||||
|
hasCodexBot ? 'claude-code' : undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
registerChannel('discord-codex', (opts: ChannelOpts) => {
|
registerChannel('discord-codex', (opts: ChannelOpts) => {
|
||||||
const envVars = readEnvFile(['DISCORD_CODEX_BOT_TOKEN']);
|
const envVars = readEnvFile(['DISCORD_CODEX_BOT_TOKEN']);
|
||||||
const token =
|
const token =
|
||||||
process.env.DISCORD_CODEX_BOT_TOKEN || envVars.DISCORD_CODEX_BOT_TOKEN || '';
|
process.env.DISCORD_CODEX_BOT_TOKEN ||
|
||||||
|
envVars.DISCORD_CODEX_BOT_TOKEN ||
|
||||||
|
'';
|
||||||
if (!token) return null; // Codex Discord bot is optional
|
if (!token) return null; // Codex Discord bot is optional
|
||||||
return new DiscordChannel(token, opts, 'codex');
|
return new DiscordChannel(token, opts, 'codex');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,20 +35,25 @@ vi.mock('fs', async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
default: {
|
default: {
|
||||||
...actual,
|
...actual,
|
||||||
existsSync: vi.fn(() => false),
|
existsSync: vi.fn((p: string) => {
|
||||||
|
// Return true for runner dist entry so tests proceed past the build check
|
||||||
|
if (typeof p === 'string' && p.includes('dist/index.js')) return true;
|
||||||
|
return false;
|
||||||
|
}),
|
||||||
mkdirSync: vi.fn(),
|
mkdirSync: vi.fn(),
|
||||||
writeFileSync: vi.fn(),
|
writeFileSync: vi.fn(),
|
||||||
readFileSync: vi.fn(() => ''),
|
readFileSync: vi.fn(() => ''),
|
||||||
readdirSync: vi.fn(() => []),
|
readdirSync: vi.fn(() => []),
|
||||||
statSync: vi.fn(() => ({ isDirectory: () => false })),
|
statSync: vi.fn(() => ({ isDirectory: () => false })),
|
||||||
copyFileSync: vi.fn(),
|
copyFileSync: vi.fn(),
|
||||||
|
cpSync: vi.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock mount-security
|
// Mock env
|
||||||
vi.mock('./mount-security.js', () => ({
|
vi.mock('./env.js', () => ({
|
||||||
validateAdditionalMounts: vi.fn(() => []),
|
readEnvFile: vi.fn(() => ({})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create a controllable fake ChildProcess
|
// Create a controllable fake ChildProcess
|
||||||
|
|||||||
@@ -1,35 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Container Runner for NanoClaw
|
* Agent Process Runner for NanoClaw
|
||||||
* Spawns agent execution in containers and handles IPC
|
* Spawns agent execution as direct host processes and handles IPC
|
||||||
*/
|
*/
|
||||||
import { ChildProcess, exec, spawn } from 'child_process';
|
import { ChildProcess, spawn } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CODEX_CONTAINER_IMAGE,
|
|
||||||
CONTAINER_IMAGE,
|
|
||||||
CONTAINER_MAX_OUTPUT_SIZE,
|
CONTAINER_MAX_OUTPUT_SIZE,
|
||||||
CONTAINER_TIMEOUT,
|
CONTAINER_TIMEOUT,
|
||||||
CREDENTIAL_PROXY_PORT,
|
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
GROUPS_DIR,
|
GROUPS_DIR,
|
||||||
IDLE_TIMEOUT,
|
IDLE_TIMEOUT,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
|
||||||
import { logger } from './logger.js';
|
|
||||||
import {
|
import {
|
||||||
CONTAINER_HOST_GATEWAY,
|
resolveGroupFolderPath,
|
||||||
CONTAINER_RUNTIME_BIN,
|
resolveGroupIpcPath,
|
||||||
hostGatewayArgs,
|
} from './group-folder.js';
|
||||||
readonlyMountArgs,
|
import { logger } from './logger.js';
|
||||||
stopContainer,
|
|
||||||
} from './container-runtime.js';
|
|
||||||
import { detectAuthMode } from './credential-proxy.js';
|
|
||||||
import { readEnvFile } from './env.js';
|
import { readEnvFile } from './env.js';
|
||||||
import { validateAdditionalMounts } from './mount-security.js';
|
|
||||||
import { RegisteredGroup } from './types.js';
|
import { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
// Sentinel markers for robust output parsing (must match agent-runner)
|
// Sentinel markers for robust output parsing (must match agent-runner)
|
||||||
@@ -54,60 +45,19 @@ export interface ContainerOutput {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VolumeMount {
|
/**
|
||||||
hostPath: string;
|
* Prepare the group's environment: directories, sessions, env vars.
|
||||||
containerPath: string;
|
* Returns the environment variables and paths for the runner process.
|
||||||
readonly: boolean;
|
*/
|
||||||
}
|
function prepareGroupEnvironment(
|
||||||
|
|
||||||
function buildVolumeMounts(
|
|
||||||
group: RegisteredGroup,
|
group: RegisteredGroup,
|
||||||
isMain: boolean,
|
isMain: boolean,
|
||||||
): VolumeMount[] {
|
): { env: Record<string, string>; groupDir: string; runnerDir: string } {
|
||||||
const mounts: VolumeMount[] = [];
|
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
const groupDir = resolveGroupFolderPath(group.folder);
|
const groupDir = resolveGroupFolderPath(group.folder);
|
||||||
|
fs.mkdirSync(groupDir, { recursive: true });
|
||||||
|
|
||||||
if (isMain) {
|
// Per-group Claude sessions directory
|
||||||
// Main gets the project root read-only. Writable paths the agent needs
|
|
||||||
// (group folder, IPC, .claude/) are mounted separately below.
|
|
||||||
// Read-only prevents the agent from modifying host application code
|
|
||||||
// (src/, dist/, package.json, etc.) which would bypass the sandbox
|
|
||||||
// entirely on next restart.
|
|
||||||
mounts.push({
|
|
||||||
hostPath: projectRoot,
|
|
||||||
containerPath: '/workspace/project',
|
|
||||||
readonly: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Main also gets its group folder as the working directory
|
|
||||||
mounts.push({
|
|
||||||
hostPath: groupDir,
|
|
||||||
containerPath: '/workspace/group',
|
|
||||||
readonly: false,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Other groups only get their own folder
|
|
||||||
mounts.push({
|
|
||||||
hostPath: groupDir,
|
|
||||||
containerPath: '/workspace/group',
|
|
||||||
readonly: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Global memory directory (read-only for non-main)
|
|
||||||
// Only directory mounts are supported, not file mounts
|
|
||||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
|
||||||
if (fs.existsSync(globalDir)) {
|
|
||||||
mounts.push({
|
|
||||||
hostPath: globalDir,
|
|
||||||
containerPath: '/workspace/global',
|
|
||||||
readonly: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-group Claude sessions directory (isolated from other groups)
|
|
||||||
// Each group gets their own .claude/ to prevent cross-group session access
|
|
||||||
const groupSessionsDir = path.join(
|
const groupSessionsDir = path.join(
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
'sessions',
|
'sessions',
|
||||||
@@ -122,14 +72,8 @@ function buildVolumeMounts(
|
|||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
env: {
|
env: {
|
||||||
// Enable agent swarms (subagent orchestration)
|
|
||||||
// https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions
|
|
||||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||||
// Load CLAUDE.md from additional mounted directories
|
|
||||||
// https://code.claude.com/docs/en/memory#load-memory-from-additional-directories
|
|
||||||
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
|
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
|
||||||
// Enable Claude's memory feature (persists user preferences between sessions)
|
|
||||||
// https://code.claude.com/docs/en/memory#manage-auto-memory
|
|
||||||
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
|
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -140,7 +84,7 @@ function buildVolumeMounts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sync skills from container/skills/ into each group's .claude/skills/
|
// Sync skills from container/skills/ into each group's .claude/skills/
|
||||||
const skillsSrc = path.join(process.cwd(), 'container', 'skills');
|
const skillsSrc = path.join(projectRoot, 'container', 'skills');
|
||||||
const skillsDst = path.join(groupSessionsDir, 'skills');
|
const skillsDst = path.join(groupSessionsDir, 'skills');
|
||||||
if (fs.existsSync(skillsSrc)) {
|
if (fs.existsSync(skillsSrc)) {
|
||||||
for (const skillDir of fs.readdirSync(skillsSrc)) {
|
for (const skillDir of fs.readdirSync(skillsSrc)) {
|
||||||
@@ -150,15 +94,72 @@ function buildVolumeMounts(
|
|||||||
fs.cpSync(srcDir, dstDir, { recursive: true });
|
fs.cpSync(srcDir, dstDir, { recursive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mounts.push({
|
|
||||||
hostPath: groupSessionsDir,
|
|
||||||
containerPath: '/home/node/.claude',
|
|
||||||
readonly: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// For Codex groups: create a per-session writable .codex/ directory
|
// Per-group IPC namespace
|
||||||
if (group.agentType === 'codex') {
|
const groupIpcDir = resolveGroupIpcPath(group.folder);
|
||||||
const hostCodexDir = path.join(process.env.HOME || os.homedir(), '.codex');
|
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
|
||||||
|
|
||||||
|
// Global memory directory (for non-main groups)
|
||||||
|
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||||
|
|
||||||
|
// Additional mount directories (validated)
|
||||||
|
const extraDirs: string[] = [];
|
||||||
|
if (group.containerConfig?.additionalMounts) {
|
||||||
|
for (const mount of group.containerConfig.additionalMounts) {
|
||||||
|
if (fs.existsSync(mount.hostPath)) {
|
||||||
|
extraDirs.push(mount.hostPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine runner directory
|
||||||
|
const agentType = group.agentType || 'claude-code';
|
||||||
|
const runnerDirName =
|
||||||
|
agentType === 'codex' ? 'codex-runner' : 'agent-runner';
|
||||||
|
const runnerDir = path.join(projectRoot, 'container', runnerDirName);
|
||||||
|
|
||||||
|
// Build environment variables for the runner process
|
||||||
|
const envVars = readEnvFile([
|
||||||
|
'ANTHROPIC_API_KEY',
|
||||||
|
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||||
|
'CLAUDE_MODEL',
|
||||||
|
'CLAUDE_THINKING',
|
||||||
|
'CLAUDE_THINKING_BUDGET',
|
||||||
|
'CLAUDE_EFFORT',
|
||||||
|
'OPENAI_API_KEY',
|
||||||
|
'CODEX_OPENAI_API_KEY',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const env: Record<string, string> = {
|
||||||
|
...process.env as Record<string, string>,
|
||||||
|
TZ: TIMEZONE,
|
||||||
|
HOME: os.homedir(),
|
||||||
|
// Path configuration for the runner
|
||||||
|
NANOCLAW_GROUP_DIR: groupDir,
|
||||||
|
NANOCLAW_IPC_DIR: groupIpcDir,
|
||||||
|
NANOCLAW_GLOBAL_DIR: globalDir,
|
||||||
|
NANOCLAW_EXTRA_DIR: extraDirs.length > 0 ? extraDirs[0] : '',
|
||||||
|
// MCP server context
|
||||||
|
NANOCLAW_CHAT_JID: group.folder,
|
||||||
|
NANOCLAW_GROUP_FOLDER: group.folder,
|
||||||
|
NANOCLAW_IS_MAIN: isMain ? '1' : '0',
|
||||||
|
// Claude sessions directory — set CLAUDE_CONFIG_DIR so SDK uses per-group sessions
|
||||||
|
CLAUDE_CONFIG_DIR: groupSessionsDir,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pass credentials directly (no proxy needed on host)
|
||||||
|
if (agentType === 'codex') {
|
||||||
|
const openaiKey =
|
||||||
|
envVars.CODEX_OPENAI_API_KEY ||
|
||||||
|
process.env.CODEX_OPENAI_API_KEY ||
|
||||||
|
envVars.OPENAI_API_KEY ||
|
||||||
|
process.env.OPENAI_API_KEY;
|
||||||
|
if (openaiKey) env.OPENAI_API_KEY = openaiKey;
|
||||||
|
|
||||||
|
// Codex session directory
|
||||||
|
const hostCodexDir = path.join(os.homedir(), '.codex');
|
||||||
const sessionCodexDir = path.join(
|
const sessionCodexDir = path.join(
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
'sessions',
|
'sessions',
|
||||||
@@ -166,15 +167,9 @@ function buildVolumeMounts(
|
|||||||
'.codex',
|
'.codex',
|
||||||
);
|
);
|
||||||
fs.mkdirSync(sessionCodexDir, { recursive: true });
|
fs.mkdirSync(sessionCodexDir, { recursive: true });
|
||||||
|
|
||||||
// Always refresh auth credentials from host
|
|
||||||
const authSrc = path.join(hostCodexDir, 'auth.json');
|
const authSrc = path.join(hostCodexDir, 'auth.json');
|
||||||
const authDst = path.join(sessionCodexDir, 'auth.json');
|
const authDst = path.join(sessionCodexDir, 'auth.json');
|
||||||
if (fs.existsSync(authSrc)) {
|
if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst);
|
||||||
fs.copyFileSync(authSrc, authDst);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only copy config files if they don't exist yet (preserves per-session customization)
|
|
||||||
for (const file of ['config.toml', 'config.json']) {
|
for (const file of ['config.toml', 'config.json']) {
|
||||||
const src = path.join(hostCodexDir, file);
|
const src = path.join(hostCodexDir, file);
|
||||||
const dst = path.join(sessionCodexDir, file);
|
const dst = path.join(sessionCodexDir, file);
|
||||||
@@ -182,148 +177,37 @@ function buildVolumeMounts(
|
|||||||
fs.copyFileSync(src, dst);
|
fs.copyFileSync(src, dst);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
env.CODEX_CONFIG_DIR = sessionCodexDir;
|
||||||
mounts.push({
|
|
||||||
hostPath: sessionCodexDir,
|
|
||||||
containerPath: '/home/node/.codex',
|
|
||||||
readonly: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-group IPC namespace: each group gets its own IPC directory
|
|
||||||
// This prevents cross-group privilege escalation via IPC
|
|
||||||
const groupIpcDir = resolveGroupIpcPath(group.folder);
|
|
||||||
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
|
|
||||||
mounts.push({
|
|
||||||
hostPath: groupIpcDir,
|
|
||||||
containerPath: '/workspace/ipc',
|
|
||||||
readonly: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Copy runner source into a per-group writable location so agents
|
|
||||||
// can customize it (add tools, change behavior) without affecting other
|
|
||||||
// groups. Recompiled on container startup via entrypoint.sh.
|
|
||||||
const agentType = group.agentType || 'claude-code';
|
|
||||||
const runnerDirName = agentType === 'codex' ? 'codex-runner' : 'agent-runner';
|
|
||||||
const runnerSrcSuffix =
|
|
||||||
agentType === 'codex' ? 'codex-runner-src' : 'agent-runner-src';
|
|
||||||
const agentRunnerSrc = path.join(
|
|
||||||
projectRoot,
|
|
||||||
'container',
|
|
||||||
runnerDirName,
|
|
||||||
'src',
|
|
||||||
);
|
|
||||||
const groupAgentRunnerDir = path.join(
|
|
||||||
DATA_DIR,
|
|
||||||
'sessions',
|
|
||||||
group.folder,
|
|
||||||
runnerSrcSuffix,
|
|
||||||
);
|
|
||||||
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
|
|
||||||
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
|
|
||||||
}
|
|
||||||
mounts.push({
|
|
||||||
hostPath: groupAgentRunnerDir,
|
|
||||||
containerPath: '/app/src',
|
|
||||||
readonly: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Additional mounts validated against external allowlist (tamper-proof from containers)
|
|
||||||
if (group.containerConfig?.additionalMounts) {
|
|
||||||
const validatedMounts = validateAdditionalMounts(
|
|
||||||
group.containerConfig.additionalMounts,
|
|
||||||
group.name,
|
|
||||||
isMain,
|
|
||||||
);
|
|
||||||
mounts.push(...validatedMounts);
|
|
||||||
}
|
|
||||||
|
|
||||||
return mounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildContainerArgs(
|
|
||||||
mounts: VolumeMount[],
|
|
||||||
containerName: string,
|
|
||||||
isMain: boolean,
|
|
||||||
agentType: 'claude-code' | 'codex' = 'claude-code',
|
|
||||||
): string[] {
|
|
||||||
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
|
|
||||||
|
|
||||||
// Pass host timezone so container's local time matches the user's
|
|
||||||
args.push('-e', `TZ=${TIMEZONE}`);
|
|
||||||
|
|
||||||
if (agentType === 'codex') {
|
|
||||||
// Codex uses OpenAI API — pass the key directly (no credential proxy)
|
|
||||||
const openaiKey =
|
|
||||||
process.env.CODEX_OPENAI_API_KEY || process.env.OPENAI_API_KEY;
|
|
||||||
if (openaiKey) {
|
|
||||||
args.push('-e', `OPENAI_API_KEY=${openaiKey}`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Route API traffic through the credential proxy (containers never see real secrets)
|
// Claude Code — pass real credentials directly
|
||||||
args.push(
|
if (envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) {
|
||||||
'-e',
|
env.ANTHROPIC_API_KEY =
|
||||||
`ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`,
|
envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';
|
||||||
);
|
}
|
||||||
|
if (
|
||||||
// Mirror the host's auth method with a placeholder value.
|
envVars.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||||
const authMode = detectAuthMode();
|
process.env.CLAUDE_CODE_OAUTH_TOKEN
|
||||||
if (authMode === 'api-key') {
|
) {
|
||||||
args.push('-e', 'ANTHROPIC_API_KEY=placeholder');
|
env.CLAUDE_CODE_OAUTH_TOKEN =
|
||||||
} else {
|
envVars.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||||
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder');
|
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
// Model/thinking config
|
||||||
|
for (const key of [
|
||||||
|
'CLAUDE_MODEL',
|
||||||
|
'CLAUDE_THINKING',
|
||||||
|
'CLAUDE_THINKING_BUDGET',
|
||||||
|
'CLAUDE_EFFORT',
|
||||||
|
]) {
|
||||||
|
const val =
|
||||||
|
envVars[key as keyof typeof envVars] ||
|
||||||
|
process.env[key];
|
||||||
|
if (val) env[key] = val;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime-specific args for host gateway resolution
|
return { env, groupDir, runnerDir };
|
||||||
args.push(...hostGatewayArgs());
|
|
||||||
|
|
||||||
// Pass model and thinking configuration to container agent (read from .env file)
|
|
||||||
const modelEnv = readEnvFile(['CLAUDE_MODEL', 'CLAUDE_THINKING', 'CLAUDE_THINKING_BUDGET', 'CLAUDE_EFFORT']);
|
|
||||||
if (modelEnv.CLAUDE_MODEL) {
|
|
||||||
args.push('-e', `CLAUDE_MODEL=${modelEnv.CLAUDE_MODEL}`);
|
|
||||||
}
|
|
||||||
if (modelEnv.CLAUDE_THINKING) {
|
|
||||||
args.push('-e', `CLAUDE_THINKING=${modelEnv.CLAUDE_THINKING}`);
|
|
||||||
}
|
|
||||||
if (modelEnv.CLAUDE_THINKING_BUDGET) {
|
|
||||||
args.push('-e', `CLAUDE_THINKING_BUDGET=${modelEnv.CLAUDE_THINKING_BUDGET}`);
|
|
||||||
}
|
|
||||||
if (modelEnv.CLAUDE_EFFORT) {
|
|
||||||
args.push('-e', `CLAUDE_EFFORT=${modelEnv.CLAUDE_EFFORT}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run as host user so bind-mounted files are accessible.
|
|
||||||
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
|
||||||
// or when getuid is unavailable (native Windows without WSL).
|
|
||||||
const hostUid = process.getuid?.();
|
|
||||||
const hostGid = process.getgid?.();
|
|
||||||
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
|
|
||||||
if (isMain) {
|
|
||||||
// Main containers start as root so the entrypoint can mount --bind
|
|
||||||
// to shadow .env. Privileges are dropped via setpriv in entrypoint.sh.
|
|
||||||
args.push('-e', `RUN_UID=${hostUid}`);
|
|
||||||
args.push('-e', `RUN_GID=${hostGid}`);
|
|
||||||
} else {
|
|
||||||
args.push('--user', `${hostUid}:${hostGid}`);
|
|
||||||
}
|
|
||||||
args.push('-e', 'HOME=/home/node');
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const mount of mounts) {
|
|
||||||
if (mount.readonly) {
|
|
||||||
args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
|
|
||||||
} else {
|
|
||||||
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select container image based on agent type
|
|
||||||
args.push(agentType === 'codex' ? CODEX_CONTAINER_IMAGE : CONTAINER_IMAGE);
|
|
||||||
|
|
||||||
return args;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runContainerAgent(
|
export async function runContainerAgent(
|
||||||
@@ -333,71 +217,66 @@ export async function runContainerAgent(
|
|||||||
onOutput?: (output: ContainerOutput) => Promise<void>,
|
onOutput?: (output: ContainerOutput) => Promise<void>,
|
||||||
): Promise<ContainerOutput> {
|
): Promise<ContainerOutput> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
|
||||||
const groupDir = resolveGroupFolderPath(group.folder);
|
group,
|
||||||
fs.mkdirSync(groupDir, { recursive: true });
|
|
||||||
|
|
||||||
const mounts = buildVolumeMounts(group, input.isMain);
|
|
||||||
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
|
||||||
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
|
|
||||||
const agentType = group.agentType || 'claude-code';
|
|
||||||
const containerArgs = buildContainerArgs(
|
|
||||||
mounts,
|
|
||||||
containerName,
|
|
||||||
input.isMain,
|
input.isMain,
|
||||||
agentType,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.debug(
|
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||||
{
|
const processName = `nanoclaw-${safeName}-${Date.now()}`;
|
||||||
group: group.name,
|
|
||||||
containerName,
|
// Check if runner is built
|
||||||
mounts: mounts.map(
|
const distEntry = path.join(runnerDir, 'dist', 'index.js');
|
||||||
(m) =>
|
if (!fs.existsSync(distEntry)) {
|
||||||
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
|
logger.error(
|
||||||
),
|
{ runnerDir },
|
||||||
containerArgs: containerArgs.join(' '),
|
'Runner not built. Run: cd container/agent-runner && npm install && npm run build',
|
||||||
},
|
|
||||||
'Container mount configuration',
|
|
||||||
);
|
);
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
result: null,
|
||||||
|
error: `Runner not built at ${distEntry}. Run npm run build:runners first.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
group: group.name,
|
group: group.name,
|
||||||
containerName,
|
processName,
|
||||||
mountCount: mounts.length,
|
agentType: group.agentType || 'claude-code',
|
||||||
isMain: input.isMain,
|
isMain: input.isMain,
|
||||||
},
|
},
|
||||||
'Spawning container agent',
|
'Spawning agent process',
|
||||||
);
|
);
|
||||||
|
|
||||||
const logsDir = path.join(groupDir, 'logs');
|
const logsDir = path.join(groupDir, 'logs');
|
||||||
fs.mkdirSync(logsDir, { recursive: true });
|
fs.mkdirSync(logsDir, { recursive: true });
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
|
const proc = spawn('node', [distEntry], {
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
cwd: runnerDir,
|
||||||
|
env,
|
||||||
});
|
});
|
||||||
|
|
||||||
onProcess(container, containerName);
|
onProcess(proc, processName);
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
let stdoutTruncated = false;
|
let stdoutTruncated = false;
|
||||||
let stderrTruncated = false;
|
let stderrTruncated = false;
|
||||||
|
|
||||||
container.stdin.write(JSON.stringify(input));
|
proc.stdin.write(JSON.stringify(input));
|
||||||
container.stdin.end();
|
proc.stdin.end();
|
||||||
|
|
||||||
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
|
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
|
||||||
let parseBuffer = '';
|
let parseBuffer = '';
|
||||||
let newSessionId: string | undefined;
|
let newSessionId: string | undefined;
|
||||||
let outputChain = Promise.resolve();
|
let outputChain = Promise.resolve();
|
||||||
|
|
||||||
container.stdout.on('data', (data) => {
|
proc.stdout.on('data', (data) => {
|
||||||
const chunk = data.toString();
|
const chunk = data.toString();
|
||||||
|
|
||||||
// Always accumulate for logging
|
|
||||||
if (!stdoutTruncated) {
|
if (!stdoutTruncated) {
|
||||||
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
|
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
|
||||||
if (chunk.length > remaining) {
|
if (chunk.length > remaining) {
|
||||||
@@ -405,20 +284,21 @@ export async function runContainerAgent(
|
|||||||
stdoutTruncated = true;
|
stdoutTruncated = true;
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ group: group.name, size: stdout.length },
|
{ group: group.name, size: stdout.length },
|
||||||
'Container stdout truncated due to size limit',
|
'Agent stdout truncated due to size limit',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
stdout += chunk;
|
stdout += chunk;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream-parse for output markers
|
|
||||||
if (onOutput) {
|
if (onOutput) {
|
||||||
parseBuffer += chunk;
|
parseBuffer += chunk;
|
||||||
let startIdx: number;
|
let startIdx: number;
|
||||||
while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
|
while (
|
||||||
|
(startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1
|
||||||
|
) {
|
||||||
const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
|
const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
|
||||||
if (endIdx === -1) break; // Incomplete pair, wait for more data
|
if (endIdx === -1) break;
|
||||||
|
|
||||||
const jsonStr = parseBuffer
|
const jsonStr = parseBuffer
|
||||||
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
|
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
|
||||||
@@ -431,10 +311,7 @@ export async function runContainerAgent(
|
|||||||
newSessionId = parsed.newSessionId;
|
newSessionId = parsed.newSessionId;
|
||||||
}
|
}
|
||||||
hadStreamingOutput = true;
|
hadStreamingOutput = true;
|
||||||
// Activity detected — reset the hard timeout
|
|
||||||
resetTimeout();
|
resetTimeout();
|
||||||
// Call onOutput for all markers (including null results)
|
|
||||||
// so idle timers start even for "silent" query completions.
|
|
||||||
outputChain = outputChain.then(() => onOutput(parsed));
|
outputChain = outputChain.then(() => onOutput(parsed));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -446,23 +323,17 @@ export async function runContainerAgent(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
container.stderr.on('data', (data) => {
|
proc.stderr.on('data', (data) => {
|
||||||
const chunk = data.toString();
|
const chunk = data.toString();
|
||||||
const lines = chunk.trim().split('\n');
|
const lines = chunk.trim().split('\n');
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line) logger.debug({ container: group.folder }, line);
|
if (line) logger.debug({ agent: group.folder }, line);
|
||||||
}
|
}
|
||||||
// Don't reset timeout on stderr — SDK writes debug logs continuously.
|
|
||||||
// Timeout only resets on actual output (OUTPUT_MARKER in stdout).
|
|
||||||
if (stderrTruncated) return;
|
if (stderrTruncated) return;
|
||||||
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
|
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
|
||||||
if (chunk.length > remaining) {
|
if (chunk.length > remaining) {
|
||||||
stderr += chunk.slice(0, remaining);
|
stderr += chunk.slice(0, remaining);
|
||||||
stderrTruncated = true;
|
stderrTruncated = true;
|
||||||
logger.warn(
|
|
||||||
{ group: group.name, size: stderr.length },
|
|
||||||
'Container stderr truncated due to size limit',
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
stderr += chunk;
|
stderr += chunk;
|
||||||
}
|
}
|
||||||
@@ -471,240 +342,170 @@ export async function runContainerAgent(
|
|||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
let hadStreamingOutput = false;
|
let hadStreamingOutput = false;
|
||||||
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
|
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
|
||||||
// Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
|
|
||||||
// graceful _close sentinel has time to trigger before the hard kill fires.
|
|
||||||
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
|
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
|
||||||
|
|
||||||
const killOnTimeout = () => {
|
const killOnTimeout = () => {
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
logger.error(
|
logger.error(
|
||||||
{ group: group.name, containerName },
|
{ group: group.name, processName },
|
||||||
'Container timeout, stopping gracefully',
|
'Agent timeout, sending SIGTERM',
|
||||||
);
|
);
|
||||||
exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
|
proc.kill('SIGTERM');
|
||||||
if (err) {
|
// Force kill after 15s if still alive
|
||||||
logger.warn(
|
setTimeout(() => {
|
||||||
{ group: group.name, containerName, err },
|
if (!proc.killed) proc.kill('SIGKILL');
|
||||||
'Graceful stop failed, force killing',
|
}, 15000);
|
||||||
);
|
|
||||||
container.kill('SIGKILL');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let timeout = setTimeout(killOnTimeout, timeoutMs);
|
let timeout = setTimeout(killOnTimeout, timeoutMs);
|
||||||
|
|
||||||
// Reset the timeout whenever there's activity (streaming output)
|
|
||||||
const resetTimeout = () => {
|
const resetTimeout = () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
timeout = setTimeout(killOnTimeout, timeoutMs);
|
timeout = setTimeout(killOnTimeout, timeoutMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
container.on('close', (code) => {
|
proc.on('close', (code) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
if (timedOut) {
|
if (timedOut) {
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
const timeoutLog = path.join(logsDir, `container-${ts}.log`);
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
timeoutLog,
|
path.join(logsDir, `agent-${ts}.log`),
|
||||||
[
|
[
|
||||||
`=== Container Run Log (TIMEOUT) ===`,
|
`=== Agent Run Log (TIMEOUT) ===`,
|
||||||
`Timestamp: ${new Date().toISOString()}`,
|
`Timestamp: ${new Date().toISOString()}`,
|
||||||
`Group: ${group.name}`,
|
`Group: ${group.name}`,
|
||||||
`Container: ${containerName}`,
|
`Process: ${processName}`,
|
||||||
`Duration: ${duration}ms`,
|
`Duration: ${duration}ms`,
|
||||||
`Exit Code: ${code}`,
|
`Exit Code: ${code}`,
|
||||||
`Had Streaming Output: ${hadStreamingOutput}`,
|
`Had Streaming Output: ${hadStreamingOutput}`,
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Timeout after output = idle cleanup, not failure.
|
|
||||||
// The agent already sent its response; this is just the
|
|
||||||
// container being reaped after the idle period expired.
|
|
||||||
if (hadStreamingOutput) {
|
if (hadStreamingOutput) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ group: group.name, containerName, duration, code },
|
{ group: group.name, processName, duration, code },
|
||||||
'Container timed out after output (idle cleanup)',
|
'Agent timed out after output (idle cleanup)',
|
||||||
);
|
);
|
||||||
outputChain.then(() => {
|
outputChain.then(() => {
|
||||||
resolve({
|
resolve({ status: 'success', result: null, newSessionId });
|
||||||
status: 'success',
|
|
||||||
result: null,
|
|
||||||
newSessionId,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
|
||||||
{ group: group.name, containerName, duration, code },
|
|
||||||
'Container timed out with no output',
|
|
||||||
);
|
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
result: null,
|
result: null,
|
||||||
error: `Container timed out after ${configTimeout}ms`,
|
error: `Agent timed out after ${configTimeout}ms`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
const logFile = path.join(logsDir, `container-${timestamp}.log`);
|
const logFile = path.join(logsDir, `agent-${timestamp}.log`);
|
||||||
const isVerbose =
|
const isVerbose =
|
||||||
process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
|
process.env.LOG_LEVEL === 'debug' ||
|
||||||
|
process.env.LOG_LEVEL === 'trace';
|
||||||
|
|
||||||
const logLines = [
|
const logLines = [
|
||||||
`=== Container Run Log ===`,
|
`=== Agent Run Log ===`,
|
||||||
`Timestamp: ${new Date().toISOString()}`,
|
`Timestamp: ${new Date().toISOString()}`,
|
||||||
`Group: ${group.name}`,
|
`Group: ${group.name}`,
|
||||||
`IsMain: ${input.isMain}`,
|
`IsMain: ${input.isMain}`,
|
||||||
|
`AgentType: ${group.agentType || 'claude-code'}`,
|
||||||
`Duration: ${duration}ms`,
|
`Duration: ${duration}ms`,
|
||||||
`Exit Code: ${code}`,
|
`Exit Code: ${code}`,
|
||||||
`Stdout Truncated: ${stdoutTruncated}`,
|
|
||||||
`Stderr Truncated: ${stderrTruncated}`,
|
|
||||||
``,
|
``,
|
||||||
];
|
];
|
||||||
|
|
||||||
const isError = code !== 0;
|
const isError = code !== 0;
|
||||||
|
|
||||||
if (isVerbose || isError) {
|
if (isVerbose || isError) {
|
||||||
logLines.push(
|
logLines.push(
|
||||||
`=== Input ===`,
|
`=== Input ===`,
|
||||||
JSON.stringify(input, null, 2),
|
JSON.stringify(input, null, 2),
|
||||||
``,
|
``,
|
||||||
`=== Container Args ===`,
|
`=== Stderr ===`,
|
||||||
containerArgs.join(' '),
|
|
||||||
``,
|
|
||||||
`=== Mounts ===`,
|
|
||||||
mounts
|
|
||||||
.map(
|
|
||||||
(m) =>
|
|
||||||
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
|
|
||||||
)
|
|
||||||
.join('\n'),
|
|
||||||
``,
|
|
||||||
`=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`,
|
|
||||||
stderr,
|
stderr,
|
||||||
``,
|
``,
|
||||||
`=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
|
`=== Stdout ===`,
|
||||||
stdout,
|
stdout,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logLines.push(
|
logLines.push(
|
||||||
`=== Input Summary ===`,
|
|
||||||
`Prompt length: ${input.prompt.length} chars`,
|
`Prompt length: ${input.prompt.length} chars`,
|
||||||
`Session ID: ${input.sessionId || 'new'}`,
|
`Session ID: ${input.sessionId || 'new'}`,
|
||||||
``,
|
|
||||||
`=== Mounts ===`,
|
|
||||||
mounts
|
|
||||||
.map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`)
|
|
||||||
.join('\n'),
|
|
||||||
``,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(logFile, logLines.join('\n'));
|
fs.writeFileSync(logFile, logLines.join('\n'));
|
||||||
logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
|
|
||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{
|
{ group: group.name, code, duration, logFile },
|
||||||
group: group.name,
|
'Agent exited with error',
|
||||||
code,
|
|
||||||
duration,
|
|
||||||
stderr,
|
|
||||||
stdout,
|
|
||||||
logFile,
|
|
||||||
},
|
|
||||||
'Container exited with error',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
result: null,
|
result: null,
|
||||||
error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
|
error: `Agent exited with code ${code}: ${stderr.slice(-200)}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming mode: wait for output chain to settle, return completion marker
|
|
||||||
if (onOutput) {
|
if (onOutput) {
|
||||||
outputChain.then(() => {
|
outputChain.then(() => {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ group: group.name, duration, newSessionId },
|
{ group: group.name, duration, newSessionId },
|
||||||
'Container completed (streaming mode)',
|
'Agent completed (streaming mode)',
|
||||||
);
|
);
|
||||||
resolve({
|
resolve({ status: 'success', result: null, newSessionId });
|
||||||
status: 'success',
|
|
||||||
result: null,
|
|
||||||
newSessionId,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy mode: parse the last output marker pair from accumulated stdout
|
// Legacy mode: parse output from stdout
|
||||||
try {
|
try {
|
||||||
// Extract JSON between sentinel markers for robust parsing
|
|
||||||
const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
|
const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
|
||||||
const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
|
const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
|
||||||
|
|
||||||
let jsonLine: string;
|
let jsonLine: string;
|
||||||
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
||||||
jsonLine = stdout
|
jsonLine = stdout
|
||||||
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
|
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
|
||||||
.trim();
|
.trim();
|
||||||
} else {
|
} else {
|
||||||
// Fallback: last non-empty line (backwards compatibility)
|
|
||||||
const lines = stdout.trim().split('\n');
|
const lines = stdout.trim().split('\n');
|
||||||
jsonLine = lines[lines.length - 1];
|
jsonLine = lines[lines.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
const output: ContainerOutput = JSON.parse(jsonLine);
|
const output: ContainerOutput = JSON.parse(jsonLine);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{ group: group.name, duration, status: output.status },
|
||||||
group: group.name,
|
'Agent completed',
|
||||||
duration,
|
|
||||||
status: output.status,
|
|
||||||
hasResult: !!output.result,
|
|
||||||
},
|
|
||||||
'Container completed',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
resolve(output);
|
resolve(output);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{
|
{ group: group.name, error: err },
|
||||||
group: group.name,
|
'Failed to parse agent output',
|
||||||
stdout,
|
|
||||||
stderr,
|
|
||||||
error: err,
|
|
||||||
},
|
|
||||||
'Failed to parse container output',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
result: null,
|
result: null,
|
||||||
error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`,
|
error: `Failed to parse agent output: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
container.on('error', (err) => {
|
proc.on('error', (err) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
logger.error(
|
logger.error(
|
||||||
{ group: group.name, containerName, error: err },
|
{ group: group.name, processName, error: err },
|
||||||
'Container spawn error',
|
'Agent spawn error',
|
||||||
);
|
);
|
||||||
resolve({
|
resolve({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
result: null,
|
result: null,
|
||||||
error: `Container spawn error: ${err.message}`,
|
error: `Agent spawn error: ${err.message}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -723,15 +524,11 @@ export function writeTasksSnapshot(
|
|||||||
next_run: string | null;
|
next_run: string | null;
|
||||||
}>,
|
}>,
|
||||||
): void {
|
): void {
|
||||||
// Write filtered tasks to the group's IPC directory
|
|
||||||
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
||||||
fs.mkdirSync(groupIpcDir, { recursive: true });
|
fs.mkdirSync(groupIpcDir, { recursive: true });
|
||||||
|
|
||||||
// Main sees all tasks, others only see their own
|
|
||||||
const filteredTasks = isMain
|
const filteredTasks = isMain
|
||||||
? tasks
|
? tasks
|
||||||
: tasks.filter((t) => t.groupFolder === groupFolder);
|
: tasks.filter((t) => t.groupFolder === groupFolder);
|
||||||
|
|
||||||
const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
|
const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
|
||||||
fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
|
fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
|
||||||
}
|
}
|
||||||
@@ -743,11 +540,6 @@ export interface AvailableGroup {
|
|||||||
isRegistered: boolean;
|
isRegistered: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Write available groups snapshot for the container to read.
|
|
||||||
* Only main group can see all available groups (for activation).
|
|
||||||
* Non-main groups only see their own registration status.
|
|
||||||
*/
|
|
||||||
export function writeGroupsSnapshot(
|
export function writeGroupsSnapshot(
|
||||||
groupFolder: string,
|
groupFolder: string,
|
||||||
isMain: boolean,
|
isMain: boolean,
|
||||||
@@ -756,18 +548,12 @@ export function writeGroupsSnapshot(
|
|||||||
): void {
|
): void {
|
||||||
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
||||||
fs.mkdirSync(groupIpcDir, { recursive: true });
|
fs.mkdirSync(groupIpcDir, { recursive: true });
|
||||||
|
|
||||||
// Main sees all groups; others see nothing (they can't activate groups)
|
|
||||||
const visibleGroups = isMain ? groups : [];
|
const visibleGroups = isMain ? groups : [];
|
||||||
|
|
||||||
const groupsFile = path.join(groupIpcDir, 'available_groups.json');
|
const groupsFile = path.join(groupIpcDir, 'available_groups.json');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
groupsFile,
|
groupsFile,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{ groups: visibleGroups, lastSync: new Date().toISOString() },
|
||||||
groups: visibleGroups,
|
|
||||||
lastSync: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ describe('ensureContainerRuntimeRunning', () => {
|
|||||||
`${CONTAINER_RUNTIME_BIN} system status`,
|
`${CONTAINER_RUNTIME_BIN} system status`,
|
||||||
{ stdio: 'pipe' },
|
{ stdio: 'pipe' },
|
||||||
);
|
);
|
||||||
expect(logger.debug).toHaveBeenCalledWith('Container runtime already running');
|
expect(logger.debug).toHaveBeenCalledWith(
|
||||||
|
'Container runtime already running',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('auto-starts when system status fails', () => {
|
it('auto-starts when system status fails', () => {
|
||||||
|
|||||||
@@ -30,8 +30,14 @@ export function hostGatewayArgs(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Returns CLI args for a readonly bind mount. */
|
/** Returns CLI args for a readonly bind mount. */
|
||||||
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
|
export function readonlyMountArgs(
|
||||||
return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`];
|
hostPath: string,
|
||||||
|
containerPath: string,
|
||||||
|
): string[] {
|
||||||
|
return [
|
||||||
|
'--mount',
|
||||||
|
`type=bind,source=${hostPath},target=${containerPath},readonly`,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the shell command to stop a container by name. */
|
/** Returns the shell command to stop a container by name. */
|
||||||
@@ -47,7 +53,10 @@ export function ensureContainerRuntimeRunning(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
logger.info('Starting container runtime...');
|
logger.info('Starting container runtime...');
|
||||||
try {
|
try {
|
||||||
execSync(`${CONTAINER_RUNTIME_BIN} system start`, { stdio: 'pipe', timeout: 30000 });
|
execSync(`${CONTAINER_RUNTIME_BIN} system start`, {
|
||||||
|
stdio: 'pipe',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
logger.info('Container runtime started');
|
logger.info('Container runtime started');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, 'Failed to start container runtime');
|
logger.error({ err }, 'Failed to start container runtime');
|
||||||
@@ -87,17 +96,26 @@ export function cleanupOrphans(): void {
|
|||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]');
|
const containers: { status: string; configuration: { id: string } }[] =
|
||||||
|
JSON.parse(output || '[]');
|
||||||
const orphans = containers
|
const orphans = containers
|
||||||
.filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'))
|
.filter(
|
||||||
|
(c) =>
|
||||||
|
c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'),
|
||||||
|
)
|
||||||
.map((c) => c.configuration.id);
|
.map((c) => c.configuration.id);
|
||||||
for (const name of orphans) {
|
for (const name of orphans) {
|
||||||
try {
|
try {
|
||||||
execSync(stopContainer(name), { stdio: 'pipe' });
|
execSync(stopContainer(name), { stdio: 'pipe' });
|
||||||
} catch { /* already stopped */ }
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (orphans.length > 0) {
|
if (orphans.length > 0) {
|
||||||
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
|
logger.info(
|
||||||
|
{ count: orphans.length, names: orphans },
|
||||||
|
'Stopped orphaned containers',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ err }, 'Failed to clean up orphaned containers');
|
logger.warn({ err }, 'Failed to clean up orphaned containers');
|
||||||
|
|||||||
57
src/index.ts
57
src/index.ts
@@ -3,13 +3,11 @@ import path from 'path';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ASSISTANT_NAME,
|
ASSISTANT_NAME,
|
||||||
CREDENTIAL_PROXY_PORT,
|
|
||||||
IDLE_TIMEOUT,
|
IDLE_TIMEOUT,
|
||||||
POLL_INTERVAL,
|
POLL_INTERVAL,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
TRIGGER_PATTERN,
|
TRIGGER_PATTERN,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { startCredentialProxy } from './credential-proxy.js';
|
|
||||||
import './channels/index.js';
|
import './channels/index.js';
|
||||||
import {
|
import {
|
||||||
getChannelFactory,
|
getChannelFactory,
|
||||||
@@ -21,11 +19,6 @@ import {
|
|||||||
writeGroupsSnapshot,
|
writeGroupsSnapshot,
|
||||||
writeTasksSnapshot,
|
writeTasksSnapshot,
|
||||||
} from './container-runner.js';
|
} from './container-runner.js';
|
||||||
import {
|
|
||||||
cleanupOrphans,
|
|
||||||
ensureContainerRuntimeRunning,
|
|
||||||
PROXY_BIND_HOST,
|
|
||||||
} from './container-runtime.js';
|
|
||||||
import {
|
import {
|
||||||
getAllChats,
|
getAllChats,
|
||||||
getAllRegisteredGroups,
|
getAllRegisteredGroups,
|
||||||
@@ -53,7 +46,11 @@ import {
|
|||||||
loadSenderAllowlist,
|
loadSenderAllowlist,
|
||||||
shouldDropMessage,
|
shouldDropMessage,
|
||||||
} from './sender-allowlist.js';
|
} from './sender-allowlist.js';
|
||||||
import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js';
|
import {
|
||||||
|
extractSessionCommand,
|
||||||
|
handleSessionCommand,
|
||||||
|
isSessionCommandAllowed,
|
||||||
|
} from './session-commands.js';
|
||||||
import { startSchedulerLoop } from './task-scheduler.js';
|
import { startSchedulerLoop } from './task-scheduler.js';
|
||||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
@@ -175,18 +172,26 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
|||||||
timezone: TIMEZONE,
|
timezone: TIMEZONE,
|
||||||
deps: {
|
deps: {
|
||||||
sendMessage: (text) => channel.sendMessage(chatJid, text),
|
sendMessage: (text) => channel.sendMessage(chatJid, text),
|
||||||
setTyping: (typing) => channel.setTyping?.(chatJid, typing) ?? Promise.resolve(),
|
setTyping: (typing) =>
|
||||||
runAgent: (prompt, onOutput) => runAgent(group, prompt, chatJid, onOutput),
|
channel.setTyping?.(chatJid, typing) ?? Promise.resolve(),
|
||||||
|
runAgent: (prompt, onOutput) =>
|
||||||
|
runAgent(group, prompt, chatJid, onOutput),
|
||||||
closeStdin: () => queue.closeStdin(chatJid),
|
closeStdin: () => queue.closeStdin(chatJid),
|
||||||
advanceCursor: (ts) => { lastAgentTimestamp[chatJid] = ts; saveState(); },
|
advanceCursor: (ts) => {
|
||||||
|
lastAgentTimestamp[chatJid] = ts;
|
||||||
|
saveState();
|
||||||
|
},
|
||||||
formatMessages,
|
formatMessages,
|
||||||
canSenderInteract: (msg) => {
|
canSenderInteract: (msg) => {
|
||||||
const hasTrigger = TRIGGER_PATTERN.test(msg.content.trim());
|
const hasTrigger = TRIGGER_PATTERN.test(msg.content.trim());
|
||||||
const reqTrigger = !isMainGroup && group.requiresTrigger !== false;
|
const reqTrigger = !isMainGroup && group.requiresTrigger !== false;
|
||||||
return isMainGroup || !reqTrigger || (hasTrigger && (
|
return (
|
||||||
msg.is_from_me ||
|
isMainGroup ||
|
||||||
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())
|
!reqTrigger ||
|
||||||
));
|
(hasTrigger &&
|
||||||
|
(msg.is_from_me ||
|
||||||
|
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())))
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -431,8 +436,7 @@ async function startMessageLoop(): Promise<void> {
|
|||||||
const lastHuman = getLastHumanMessageTimestamp(chatJid);
|
const lastHuman = getLastHumanMessageTimestamp(chatJid);
|
||||||
if (
|
if (
|
||||||
!lastHuman ||
|
!lastHuman ||
|
||||||
Date.now() - new Date(lastHuman).getTime() >
|
Date.now() - new Date(lastHuman).getTime() > BOT_COLLAB_TIMEOUT_MS
|
||||||
BOT_COLLAB_TIMEOUT_MS
|
|
||||||
) {
|
) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ chatJid, lastHuman },
|
{ chatJid, lastHuman },
|
||||||
@@ -453,7 +457,12 @@ async function startMessageLoop(): Promise<void> {
|
|||||||
// Only close active container if the sender is authorized — otherwise an
|
// Only close active container if the sender is authorized — otherwise an
|
||||||
// untrusted user could kill in-flight work by sending /compact (DoS).
|
// untrusted user could kill in-flight work by sending /compact (DoS).
|
||||||
// closeStdin no-ops internally when no container is active.
|
// closeStdin no-ops internally when no container is active.
|
||||||
if (isSessionCommandAllowed(isMainGroup, loopCmdMsg.is_from_me === true)) {
|
if (
|
||||||
|
isSessionCommandAllowed(
|
||||||
|
isMainGroup,
|
||||||
|
loopCmdMsg.is_from_me === true,
|
||||||
|
)
|
||||||
|
) {
|
||||||
queue.closeStdin(chatJid);
|
queue.closeStdin(chatJid);
|
||||||
}
|
}
|
||||||
// Enqueue so processGroupMessages handles auth + cursor advancement.
|
// Enqueue so processGroupMessages handles auth + cursor advancement.
|
||||||
@@ -536,23 +545,11 @@ function recoverPendingMessages(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureContainerSystemRunning(): void {
|
|
||||||
ensureContainerRuntimeRunning();
|
|
||||||
cleanupOrphans();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
ensureContainerSystemRunning();
|
|
||||||
initDatabase();
|
initDatabase();
|
||||||
logger.info('Database initialized');
|
logger.info('Database initialized');
|
||||||
loadState();
|
loadState();
|
||||||
|
|
||||||
// Start credential proxy (containers route API calls through this)
|
|
||||||
await startCredentialProxy(
|
|
||||||
CREDENTIAL_PROXY_PORT,
|
|
||||||
PROXY_BIND_HOST,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Graceful shutdown handlers
|
// Graceful shutdown handlers
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
logger.info({ signal }, 'Shutdown signal received');
|
logger.info({ signal }, 'Shutdown signal received');
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js';
|
import {
|
||||||
|
extractSessionCommand,
|
||||||
|
handleSessionCommand,
|
||||||
|
isSessionCommandAllowed,
|
||||||
|
} from './session-commands.js';
|
||||||
import type { NewMessage } from './types.js';
|
import type { NewMessage } from './types.js';
|
||||||
import type { SessionCommandDeps } from './session-commands.js';
|
import type { SessionCommandDeps } from './session-commands.js';
|
||||||
|
|
||||||
@@ -23,7 +27,9 @@ describe('extractSessionCommand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects regular messages', () => {
|
it('rejects regular messages', () => {
|
||||||
expect(extractSessionCommand('please compact the conversation', trigger)).toBeNull();
|
expect(
|
||||||
|
extractSessionCommand('please compact the conversation', trigger),
|
||||||
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles whitespace', () => {
|
it('handles whitespace', () => {
|
||||||
@@ -53,7 +59,10 @@ describe('isSessionCommandAllowed', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function makeMsg(content: string, overrides: Partial<NewMessage> = {}): NewMessage {
|
function makeMsg(
|
||||||
|
content: string,
|
||||||
|
overrides: Partial<NewMessage> = {},
|
||||||
|
): NewMessage {
|
||||||
return {
|
return {
|
||||||
id: 'msg-1',
|
id: 'msg-1',
|
||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
@@ -65,7 +74,9 @@ function makeMsg(content: string, overrides: Partial<NewMessage> = {}): NewMessa
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeDeps(overrides: Partial<SessionCommandDeps> = {}): SessionCommandDeps {
|
function makeDeps(
|
||||||
|
overrides: Partial<SessionCommandDeps> = {},
|
||||||
|
): SessionCommandDeps {
|
||||||
return {
|
return {
|
||||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
setTyping: vi.fn().mockResolvedValue(undefined),
|
setTyping: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -105,7 +116,10 @@ describe('handleSessionCommand', () => {
|
|||||||
deps,
|
deps,
|
||||||
});
|
});
|
||||||
expect(result).toEqual({ handled: true, success: true });
|
expect(result).toEqual({ handled: true, success: true });
|
||||||
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
|
expect(deps.runAgent).toHaveBeenCalledWith(
|
||||||
|
'/compact',
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,13 +134,17 @@ describe('handleSessionCommand', () => {
|
|||||||
deps,
|
deps,
|
||||||
});
|
});
|
||||||
expect(result).toEqual({ handled: true, success: true });
|
expect(result).toEqual({ handled: true, success: true });
|
||||||
expect(deps.sendMessage).toHaveBeenCalledWith('Session commands require admin access.');
|
expect(deps.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'Session commands require admin access.',
|
||||||
|
);
|
||||||
expect(deps.runAgent).not.toHaveBeenCalled();
|
expect(deps.runAgent).not.toHaveBeenCalled();
|
||||||
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('silently consumes denied command when sender cannot interact', async () => {
|
it('silently consumes denied command when sender cannot interact', async () => {
|
||||||
const deps = makeDeps({ canSenderInteract: vi.fn().mockReturnValue(false) });
|
const deps = makeDeps({
|
||||||
|
canSenderInteract: vi.fn().mockReturnValue(false),
|
||||||
|
});
|
||||||
const result = await handleSessionCommand({
|
const result = await handleSessionCommand({
|
||||||
missedMessages: [makeMsg('/compact', { is_from_me: false })],
|
missedMessages: [makeMsg('/compact', { is_from_me: false })],
|
||||||
isMainGroup: false,
|
isMainGroup: false,
|
||||||
@@ -158,8 +176,14 @@ describe('handleSessionCommand', () => {
|
|||||||
expect(deps.formatMessages).toHaveBeenCalledWith([msgs[0]], 'UTC');
|
expect(deps.formatMessages).toHaveBeenCalledWith([msgs[0]], 'UTC');
|
||||||
// Two runAgent calls: pre-compact + /compact
|
// Two runAgent calls: pre-compact + /compact
|
||||||
expect(deps.runAgent).toHaveBeenCalledTimes(2);
|
expect(deps.runAgent).toHaveBeenCalledTimes(2);
|
||||||
expect(deps.runAgent).toHaveBeenCalledWith('<formatted>', expect.any(Function));
|
expect(deps.runAgent).toHaveBeenCalledWith(
|
||||||
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
|
'<formatted>',
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(deps.runAgent).toHaveBeenCalledWith(
|
||||||
|
'/compact',
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows is_from_me sender in non-main group', async () => {
|
it('allows is_from_me sender in non-main group', async () => {
|
||||||
@@ -173,15 +197,20 @@ describe('handleSessionCommand', () => {
|
|||||||
deps,
|
deps,
|
||||||
});
|
});
|
||||||
expect(result).toEqual({ handled: true, success: true });
|
expect(result).toEqual({ handled: true, success: true });
|
||||||
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
|
expect(deps.runAgent).toHaveBeenCalledWith(
|
||||||
|
'/compact',
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports failure when command-stage runAgent returns error without streamed status', async () => {
|
it('reports failure when command-stage runAgent returns error without streamed status', async () => {
|
||||||
// runAgent resolves 'error' but callback never gets status: 'error'
|
// runAgent resolves 'error' but callback never gets status: 'error'
|
||||||
const deps = makeDeps({ runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => {
|
const deps = makeDeps({
|
||||||
|
runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => {
|
||||||
await onOutput({ status: 'success', result: null });
|
await onOutput({ status: 'success', result: null });
|
||||||
return 'error';
|
return 'error';
|
||||||
})});
|
}),
|
||||||
|
});
|
||||||
const result = await handleSessionCommand({
|
const result = await handleSessionCommand({
|
||||||
missedMessages: [makeMsg('/compact')],
|
missedMessages: [makeMsg('/compact')],
|
||||||
isMainGroup: true,
|
isMainGroup: true,
|
||||||
@@ -191,7 +220,9 @@ describe('handleSessionCommand', () => {
|
|||||||
deps,
|
deps,
|
||||||
});
|
});
|
||||||
expect(result).toEqual({ handled: true, success: true });
|
expect(result).toEqual({ handled: true, success: true });
|
||||||
expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('failed'));
|
expect(deps.sendMessage).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('failed'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns success:false on pre-compact failure with no output', async () => {
|
it('returns success:false on pre-compact failure with no output', async () => {
|
||||||
@@ -209,6 +240,8 @@ describe('handleSessionCommand', () => {
|
|||||||
deps,
|
deps,
|
||||||
});
|
});
|
||||||
expect(result).toEqual({ handled: true, success: false });
|
expect(result).toEqual({ handled: true, success: false });
|
||||||
expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to process'));
|
expect(deps.sendMessage).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Failed to process'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { logger } from './logger.js';
|
|||||||
* Extract a session slash command from a message, stripping the trigger prefix if present.
|
* Extract a session slash command from a message, stripping the trigger prefix if present.
|
||||||
* Returns the slash command (e.g., '/compact') or null if not a session command.
|
* Returns the slash command (e.g., '/compact') or null if not a session command.
|
||||||
*/
|
*/
|
||||||
export function extractSessionCommand(content: string, triggerPattern: RegExp): string | null {
|
export function extractSessionCommand(
|
||||||
|
content: string,
|
||||||
|
triggerPattern: RegExp,
|
||||||
|
): string | null {
|
||||||
let text = content.trim();
|
let text = content.trim();
|
||||||
text = text.replace(triggerPattern, '').trim();
|
text = text.replace(triggerPattern, '').trim();
|
||||||
if (text === '/compact') return '/compact';
|
if (text === '/compact') return '/compact';
|
||||||
@@ -16,7 +19,10 @@ export function extractSessionCommand(content: string, triggerPattern: RegExp):
|
|||||||
* Check if a session command sender is authorized.
|
* Check if a session command sender is authorized.
|
||||||
* Allowed: main group (any sender), or trusted/admin sender (is_from_me) in any group.
|
* Allowed: main group (any sender), or trusted/admin sender (is_from_me) in any group.
|
||||||
*/
|
*/
|
||||||
export function isSessionCommandAllowed(isMainGroup: boolean, isFromMe: boolean): boolean {
|
export function isSessionCommandAllowed(
|
||||||
|
isMainGroup: boolean,
|
||||||
|
isFromMe: boolean,
|
||||||
|
): boolean {
|
||||||
return isMainGroup || isFromMe;
|
return isMainGroup || isFromMe;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,12 +67,21 @@ export async function handleSessionCommand(opts: {
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
deps: SessionCommandDeps;
|
deps: SessionCommandDeps;
|
||||||
}): Promise<{ handled: false } | { handled: true; success: boolean }> {
|
}): Promise<{ handled: false } | { handled: true; success: boolean }> {
|
||||||
const { missedMessages, isMainGroup, groupName, triggerPattern, timezone, deps } = opts;
|
const {
|
||||||
|
missedMessages,
|
||||||
|
isMainGroup,
|
||||||
|
groupName,
|
||||||
|
triggerPattern,
|
||||||
|
timezone,
|
||||||
|
deps,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
const cmdMsg = missedMessages.find(
|
const cmdMsg = missedMessages.find(
|
||||||
(m) => extractSessionCommand(m.content, triggerPattern) !== null,
|
(m) => extractSessionCommand(m.content, triggerPattern) !== null,
|
||||||
);
|
);
|
||||||
const command = cmdMsg ? extractSessionCommand(cmdMsg.content, triggerPattern) : null;
|
const command = cmdMsg
|
||||||
|
? extractSessionCommand(cmdMsg.content, triggerPattern)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!command || !cmdMsg) return { handled: false };
|
if (!command || !cmdMsg) return { handled: false };
|
||||||
|
|
||||||
@@ -109,8 +124,13 @@ export async function handleSessionCommand(opts: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (preResult === 'error' || hadPreError) {
|
if (preResult === 'error' || hadPreError) {
|
||||||
logger.warn({ group: groupName }, 'Pre-compact processing failed, aborting session command');
|
logger.warn(
|
||||||
await deps.sendMessage(`Failed to process messages before ${command}. Try again.`);
|
{ group: groupName },
|
||||||
|
'Pre-compact processing failed, aborting session command',
|
||||||
|
);
|
||||||
|
await deps.sendMessage(
|
||||||
|
`Failed to process messages before ${command}. Try again.`,
|
||||||
|
);
|
||||||
if (preOutputSent) {
|
if (preOutputSent) {
|
||||||
// Output was already sent — don't retry or it will duplicate.
|
// Output was already sent — don't retry or it will duplicate.
|
||||||
// Advance cursor past pre-compact messages, leave command pending.
|
// Advance cursor past pre-compact messages, leave command pending.
|
||||||
|
|||||||
Reference in New Issue
Block a user