From 94d53e4cc3139d784d324a47878fdee4ee2a5546 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 11 Apr 2026 04:47:16 +0900 Subject: [PATCH] build: unify bun quality gate --- .github/workflows/ci.yml | 16 +- .husky/pre-commit | 2 +- package.json | 14 +- runners/agent-runner/src/host-evidence.ts | 4 +- runners/agent-runner/src/index.ts | 390 +++++++++++++----- runners/agent-runner/src/memory-selection.ts | 34 +- runners/agent-runner/src/verification.ts | 10 +- runners/agent-runner/src/watch-ci.ts | 5 +- runners/agent-runner/test/ipc-paths.test.ts | 5 +- .../test/memory-selection.test.ts | 16 +- .../test/reviewer-runtime.test.ts | 28 +- .../agent-runner/test/verification.test.ts | 9 +- runners/codex-runner/src/app-server-client.ts | 44 +- runners/codex-runner/src/app-server-state.ts | 5 +- runners/codex-runner/src/index.ts | 57 +-- .../test/reviewer-runtime.test.ts | 8 +- setup/legacy-room-registrations.ts | 4 +- setup/migrate-room-registrations.ts | 13 +- setup/platform.test.ts | 55 ++- setup/platform.ts | 10 +- setup/restart-stack.test.ts | 23 +- setup/restart-stack.ts | 4 +- setup/service.test.ts | 4 +- setup/verify-services.test.ts | 30 +- setup/verify-services.ts | 4 +- src/restart-context.test.ts | 34 +- test/vitest-env.ts | 23 ++ tsconfig.check.json | 19 + vitest.config.ts | 1 + 29 files changed, 574 insertions(+), 297 deletions(-) create mode 100644 test/vitest-env.ts create mode 100644 tsconfig.check.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e11c2f4..f63f573 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,14 +12,10 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 20 - cache: npm - - run: npm ci + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + - run: bun install --frozen-lockfile - - name: Format check - run: npm run format:check - - - name: Typecheck - run: npx tsc --noEmit - - - name: Tests - run: npx vitest run + - name: Check + run: bun run check diff --git a/.husky/pre-commit b/.husky/pre-commit index 73c726d..dfad9b2 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -npm run format:fix +bun run format:fix diff --git a/package.json b/package.json index d5b25c3..98d5e85 100644 --- a/package.json +++ b/package.json @@ -7,15 +7,19 @@ "main": "dist/index.js", "scripts": { "build": "tsc", - "build:runners": "bun install --cwd runners/agent-runner && bun run --cwd runners/agent-runner build && bun install --cwd runners/codex-runner && bun run --cwd runners/codex-runner build", - "build:runtime": "bun run build && bun run build:runners", - "deploy": "git pull --ff-only && bun run build:runtime && systemctl --user restart ejclaw", + "install:runners": "bun install --frozen-lockfile --cwd runners/shared && bun install --frozen-lockfile --cwd runners/agent-runner && bun install --frozen-lockfile --cwd runners/codex-runner", + "build:runners": "bun run install:runners && bun run --cwd runners/shared build && bun run --cwd runners/agent-runner build && bun run --cwd runners/codex-runner build", + "build:all": "bun run build && bun run build:runners", + "build:runtime": "bun run build:all", + "deploy": "git pull --ff-only && bun run build:all && systemctl --user restart ejclaw", "start": "bun dist/index.js", "dev": "bun --watch src/index.ts", "test": "vitest run", "typecheck": "tsc --noEmit", - "format:fix": "prettier --write \"src/**/*.ts\"", - "format:check": "prettier --check \"src/**/*.ts\"", + "typecheck:all": "bun run install:runners && bunx tsc --noEmit -p tsconfig.check.json && bunx tsc --noEmit -p runners/shared/tsconfig.json && bunx tsc --noEmit -p runners/agent-runner/tsconfig.json && bunx tsc --noEmit -p runners/codex-runner/tsconfig.json", + "format:fix": "prettier --write \"{src,setup,test,scripts}/**/*.{ts,js}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"", + "format:check": "prettier --check \"{src,setup,test,scripts}/**/*.{ts,js}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"", + "check": "bun run format:check && bun run typecheck:all && bun run test && bun run build:all", "prepare": "husky", "setup": "bun setup/index.ts" }, diff --git a/runners/agent-runner/src/host-evidence.ts b/runners/agent-runner/src/host-evidence.ts index 0fdb432..ec75586 100644 --- a/runners/agent-runner/src/host-evidence.ts +++ b/runners/agent-runner/src/host-evidence.ts @@ -58,9 +58,7 @@ export async function waitForHostEvidenceResponse( await new Promise((resolve) => setTimeout(resolve, pollMs)); } - throw new Error( - `Timed out waiting for host evidence response: ${requestId}`, - ); + throw new Error(`Timed out waiting for host evidence response: ${requestId}`); } export function formatHostEvidenceResponse( diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 949762e..a6a8750 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -16,7 +16,12 @@ import fs from 'fs'; import path from 'path'; -import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'; +import { + query, + HookCallback, + PreCompactHookInput, + PreToolUseHookInput, +} from '@anthropic-ai/claude-agent-sdk'; import { fileURLToPath } from 'url'; import { @@ -76,7 +81,14 @@ interface SessionsIndex { type ContentBlock = | { type: 'text'; text: string } - | { type: 'image'; source: { type: 'base64'; media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; data: string } }; + | { + type: 'image'; + source: { + type: 'base64'; + media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; + data: string; + }; + }; interface SDKUserMessage { type: 'user'; @@ -106,8 +118,11 @@ const HOST_TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks'); /** SSOT: src/agent-protocol.ts — keep in sync */ const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g; const MIME_TYPES: Record = { - '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', - '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', }; /** @@ -138,11 +153,20 @@ function buildMultimodalContent(text: string): string | ContentBlock[] { } const data = fs.readFileSync(imgPath).toString('base64'); const ext = path.extname(imgPath).toLowerCase(); - const mediaType = (MIME_TYPES[ext] || 'image/png') as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; - blocks.push({ type: 'image', source: { type: 'base64', media_type: mediaType, data } }); + const mediaType = (MIME_TYPES[ext] || 'image/png') as + | 'image/jpeg' + | 'image/png' + | 'image/gif' + | 'image/webp'; + blocks.push({ + type: 'image', + source: { type: 'base64', media_type: mediaType, data }, + }); log(`Added image block: ${imgPath} (${mediaType})`); } catch (err) { - log(`Failed to read image ${imgPath}: ${err instanceof Error ? err.message : String(err)}`); + log( + `Failed to read image ${imgPath}: ${err instanceof Error ? err.message : String(err)}`, + ); } } @@ -180,7 +204,9 @@ class MessageStream { yield this.queue.shift()!; } if (this.done) return; - await new Promise(r => { this.waiting = r; }); + await new Promise((r) => { + this.waiting = r; + }); this.waiting = null; } } @@ -190,7 +216,9 @@ async function readStdin(): Promise { return new Promise((resolve, reject) => { let data = ''; process.stdin.setEncoding('utf8'); - process.stdin.on('data', chunk => { data += chunk; }); + process.stdin.on('data', (chunk) => { + data += chunk; + }); process.stdin.on('end', () => resolve(data)); process.stdin.on('error', reject); }); @@ -246,7 +274,10 @@ function extractAssistantText(message: unknown): string | null { return text || null; } -function getSessionSummary(sessionId: string, transcriptPath: string): string | null { +function getSessionSummary( + sessionId: string, + transcriptPath: string, +): string | null { const projectDir = path.dirname(transcriptPath); const indexPath = path.join(projectDir, 'sessions-index.json'); @@ -256,13 +287,17 @@ function getSessionSummary(sessionId: string, transcriptPath: string): string | } try { - const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); - const entry = index.entries.find(e => e.sessionId === sessionId); + const index: SessionsIndex = JSON.parse( + fs.readFileSync(indexPath, 'utf-8'), + ); + const entry = index.entries.find((e) => e.sessionId === sessionId); if (entry?.summary) { return entry.summary; } } catch (err) { - log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`); + log( + `Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`, + ); } return null; @@ -283,7 +318,10 @@ function writeHostTaskIpcFile(data: object): string { return filename; } -async function persistCompactMemory(summary: string, sessionId: string): Promise { +async function persistCompactMemory( + summary: string, + sessionId: string, +): Promise { const normalized = summary.trim(); if (!normalized || !GROUP_FOLDER) return; @@ -359,7 +397,11 @@ function createPreCompactHook(assistantName?: string): HookCallback { const filename = `${date}-${name}.md`; const filePath = path.join(conversationsDir, filename); - const markdown = formatTranscriptMarkdown(messages, summary, assistantName); + const markdown = formatTranscriptMarkdown( + messages, + summary, + assistantName, + ); fs.writeFileSync(filePath, markdown); log(`Archived conversation to ${filePath}`); @@ -368,7 +410,9 @@ function createPreCompactHook(assistantName?: string): HookCallback { await persistCompactMemory(summary, sessionId); } } catch (err) { - log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); + log( + `Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`, + ); } return {}; @@ -444,9 +488,12 @@ function parseTranscript(content: string): ParsedMessage[] { try { const entry = JSON.parse(line); if (entry.type === 'user' && entry.message?.content) { - const text = typeof entry.message.content === 'string' - ? entry.message.content - : entry.message.content.map((c: { text?: string }) => c.text || '').join(''); + const text = + typeof entry.message.content === 'string' + ? entry.message.content + : entry.message.content + .map((c: { text?: string }) => c.text || '') + .join(''); if (text) messages.push({ role: 'user', content: text }); } else if (entry.type === 'assistant' && entry.message?.content) { const textParts = entry.message.content @@ -455,22 +502,26 @@ function parseTranscript(content: string): ParsedMessage[] { const text = textParts.join(''); if (text) messages.push({ role: 'assistant', content: text }); } - } catch { - } + } catch {} } return messages; } -function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string { +function formatTranscriptMarkdown( + messages: ParsedMessage[], + title?: string | null, + assistantName?: string, +): string { const now = new Date(); - const formatDateTime = (d: Date) => d.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true - }); + const formatDateTime = (d: Date) => + d.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); const lines: string[] = []; lines.push(`# ${title || 'Conversation'}`); @@ -481,10 +532,11 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu lines.push(''); for (const msg of messages) { - const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant'); - const content = msg.content.length > 2000 - ? msg.content.slice(0, 2000) + '...' - : msg.content; + const sender = msg.role === 'user' ? 'User' : assistantName || 'Assistant'; + const content = + msg.content.length > 2000 + ? msg.content.slice(0, 2000) + '...' + : msg.content; lines.push(`**${sender}**: ${content}`); lines.push(''); } @@ -497,7 +549,11 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu */ function shouldClose(): boolean { if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) { - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } + try { + fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); + } catch { + /* ignore */ + } return true; } return false; @@ -510,8 +566,9 @@ function shouldClose(): boolean { function drainIpcInput(): string[] { try { fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - const files = fs.readdirSync(IPC_INPUT_DIR) - .filter(f => f.endsWith('.json')) + const files = fs + .readdirSync(IPC_INPUT_DIR) + .filter((f) => f.endsWith('.json')) .sort(); const messages: string[] = []; @@ -524,8 +581,14 @@ function drainIpcInput(): string[] { 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 */ } + log( + `Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`, + ); + try { + fs.unlinkSync(filePath); + } catch { + /* ignore */ + } } } return messages; @@ -568,7 +631,9 @@ async function runQuery( // Flush any buffered text before closing — the for-await loop may not // reach the post-loop flush code after stream.end(). if (pendingProgressText && !terminalResultObserved) { - log(`Flushing pending text before close (${pendingProgressText.length} chars)`); + log( + `Flushing pending text before close (${pendingProgressText.length} chars)`, + ); writeOutput({ status: 'success', ...normalizeStructuredOutput(pendingProgressText), @@ -605,7 +670,9 @@ async function runQuery( const effectiveCwd = WORK_DIR || GROUP_DIR; if (WORK_DIR && WORK_DIR !== GROUP_DIR) { extraDirs.push(GROUP_DIR); - log(`Work directory override: ${WORK_DIR} (group dir added to additionalDirectories)`); + log( + `Work directory override: ${WORK_DIR} (group dir added to additionalDirectories)`, + ); } if (extraDirs.length > 0) { log(`Additional directories: ${extraDirs.join(', ')}`); @@ -617,11 +684,17 @@ async function runQuery( 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; + 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)}`); @@ -643,22 +716,42 @@ async function runQuery( const allowedTools = readonlyReviewerRuntime ? [ 'Bash', - 'Read', 'Glob', 'Grep', - 'WebSearch', 'WebFetch', - 'Task', 'TaskOutput', 'TaskStop', - 'TeamCreate', 'TeamDelete', 'SendMessage', - 'TodoWrite', 'ToolSearch', 'Skill', + 'Read', + 'Glob', + 'Grep', + 'WebSearch', + 'WebFetch', + 'Task', + 'TaskOutput', + 'TaskStop', + 'TeamCreate', + 'TeamDelete', + 'SendMessage', + 'TodoWrite', + 'ToolSearch', + 'Skill', 'mcp__ejclaw__*', ] : [ 'Bash', - 'Read', 'Write', 'Edit', 'Glob', 'Grep', - 'WebSearch', 'WebFetch', - 'Task', 'TaskOutput', 'TaskStop', - 'TeamCreate', 'TeamDelete', 'SendMessage', - 'TodoWrite', 'ToolSearch', 'Skill', + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'WebSearch', + 'WebFetch', + 'Task', + 'TaskOutput', + 'TaskStop', + 'TeamCreate', + 'TeamDelete', + 'SendMessage', + 'TodoWrite', + 'ToolSearch', + 'Skill', 'NotebookEdit', - 'mcp__ejclaw__*' + 'mcp__ejclaw__*', ]; const readonlyProtectedPaths = [effectiveCwd, ...extraDirs].filter( @@ -701,8 +794,7 @@ async function runQuery( EJCLAW_CHAT_JID: runnerInput.chatJid, EJCLAW_GROUP_FOLDER: runnerInput.groupFolder, EJCLAW_IS_MAIN: runnerInput.isMain ? '1' : '0', - EJCLAW_AGENT_TYPE: - process.env.EJCLAW_AGENT_TYPE || 'claude-code', + EJCLAW_AGENT_TYPE: process.env.EJCLAW_AGENT_TYPE || 'claude-code', EJCLAW_ROOM_ROLE: runnerInput.roomRoleContext?.role || '', ...(process.env.EJCLAW_IPC_DIR && { EJCLAW_IPC_DIR: process.env.EJCLAW_IPC_DIR, @@ -714,7 +806,9 @@ async function runQuery( }, }, hooks: { - PreCompact: [{ hooks: [createPreCompactHook(runnerInput.assistantName)] }], + PreCompact: [ + { hooks: [createPreCompactHook(runnerInput.assistantName)] }, + ], PreToolUse: [ { matcher: 'Bash', @@ -725,10 +819,13 @@ async function runQuery( ], }, agentProgressSummaries: true, - } + }, })) { messageCount++; - const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type; + const msgType = + message.type === 'system' + ? `system/${(message as { subtype?: string }).subtype}` + : message.type; log(`[msg #${messageCount}] type=${msgType}`); // Flush pending intermediate text as a regular message on non-assistant events. @@ -747,15 +844,37 @@ async function runQuery( log(`Session initialized: ${newSessionId}`); } - if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') { - const meta = (message as { compact_metadata?: { trigger?: string; pre_tokens?: number } }).compact_metadata; - log(`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`); + if ( + message.type === 'system' && + (message as { subtype?: string }).subtype === 'compact_boundary' + ) { + const meta = ( + message as { + compact_metadata?: { trigger?: string; pre_tokens?: number }; + } + ).compact_metadata; + log( + `Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`, + ); } - if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { - const tn = message as { task_id: string; status: string; summary: string }; - log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`); - if (tn.status === 'completed' || tn.status === 'error' || tn.status === 'cancelled') { + if ( + message.type === 'system' && + (message as { subtype?: string }).subtype === 'task_notification' + ) { + const tn = message as { + task_id: string; + status: string; + summary: string; + }; + log( + `Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`, + ); + if ( + tn.status === 'completed' || + tn.status === 'error' || + tn.status === 'cancelled' + ) { writeOutput({ status: 'success', phase: 'progress', @@ -767,11 +886,15 @@ async function runQuery( } } - if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_progress') { + if ( + message.type === 'system' && + (message as { subtype?: string }).subtype === 'task_progress' + ) { const tp = message as Record; const taskId = typeof tp.task_id === 'string' ? tp.task_id : undefined; const summary = typeof tp.summary === 'string' ? tp.summary : ''; - const description = typeof tp.description === 'string' ? tp.description : ''; + const description = + typeof tp.description === 'string' ? tp.description : ''; if (description && description.length <= 80) { // Short tool description → show as sub-line in progress const normalized = normalizeStructuredOutput(description); @@ -784,11 +907,16 @@ async function runQuery( }); } else if (description) { // Long AI summary → skip (too long for progress sub-line) - log(`Skipping long task_progress description (${description.length} chars)`); + log( + `Skipping long task_progress description (${description.length} chars)`, + ); } } - if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_started') { + if ( + message.type === 'system' && + (message as { subtype?: string }).subtype === 'task_started' + ) { const ts = message as { task_id: string; description?: string }; const desc = ts.description || ''; log(`Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`); @@ -835,28 +963,44 @@ async function runQuery( if (message.type === 'result') { resultCount++; - let textResult = 'result' in message ? (message as { result?: string }).result : null; + let textResult = + 'result' in message ? (message as { result?: string }).result : null; const isError = message.subtype?.startsWith('error'); // Discard pending progress if it matches the final result (prevent duplicate) - if (pendingProgressText && textResult && pendingProgressText === textResult) { + if ( + pendingProgressText && + textResult && + pendingProgressText === textResult + ) { log(`Discarding pending progress (matches result)`); pendingProgressText = null; } else if (pendingProgressText) { // If the result has no text, promote pending progress to the result // so it gets delivered as the final output instead of being lost. if (!textResult) { - log(`Promoting pending progress text to result (${pendingProgressText.length} chars)`); + log( + `Promoting pending progress text to result (${pendingProgressText.length} chars)`, + ); textResult = pendingProgressText; } else { - writeOutput({ status: 'success', phase: 'intermediate', result: pendingProgressText, newSessionId }); + writeOutput({ + status: 'success', + phase: 'intermediate', + result: pendingProgressText, + newSessionId, + }); } pendingProgressText = null; } - log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`); + log( + `Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`, + ); if (isError) { // Log full error details for debugging const msg = message as Record; - const sdkErrors = Array.isArray(msg.errors) ? msg.errors as string[] : []; + const sdkErrors = Array.isArray(msg.errors) + ? (msg.errors as string[]) + : []; const errorDetail = JSON.stringify({ subtype: message.subtype, result: textResult?.slice(0, 500), @@ -868,7 +1012,8 @@ async function runQuery( }); log(`Error result detail: ${errorDetail}`); // Pass SDK errors through so host can detect session issues - const errorText = sdkErrors.length > 0 ? sdkErrors.join('; ') : undefined; + const errorText = + sdkErrors.length > 0 ? sdkErrors.join('; ') : undefined; writeOutput({ status: 'error', result: textResult || null, @@ -880,7 +1025,7 @@ async function runQuery( writeOutput({ status: 'success', ...normalized, - newSessionId + newSessionId, }); } @@ -899,7 +1044,9 @@ async function runQuery( const textResult = extractAssistantText(message); // Only log when there's something interesting (text or terminal) if (textResult || stopReason === 'end_turn') { - log(`Assistant: stop=${stopReason} text=${textResult ? textResult.length + ' chars' : 'null'}`); + log( + `Assistant: stop=${stopReason} text=${textResult ? textResult.length + ' chars' : 'null'}`, + ); } if (stopReason === 'end_turn' && textResult) { resultCount++; @@ -932,7 +1079,9 @@ async function runQuery( }); } pendingProgressText = textResult; - log(`Intermediate assistant text buffered (${textResult.length} chars, stop=${stopReason})`); + log( + `Intermediate assistant text buffered (${textResult.length} chars, stop=${stopReason})`, + ); } } } @@ -940,7 +1089,9 @@ async function runQuery( // Flush any remaining buffered text that was never followed by a result event. // This happens when the agent produces a short response without a formal end_turn. if (pendingProgressText && !terminalResultObserved) { - log(`Flushing remaining pending progress text as final output (${pendingProgressText.length} chars)`); + log( + `Flushing remaining pending progress text as final output (${pendingProgressText.length} chars)`, + ); writeOutput({ status: 'success', ...normalizeStructuredOutput(pendingProgressText), @@ -964,13 +1115,17 @@ async function main(): Promise { const stdinData = await readStdin(); runnerInput = JSON.parse(stdinData); // Delete the temp file the entrypoint wrote — it contains secrets - try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ } + try { + fs.unlinkSync('/tmp/input.json'); + } catch { + /* may not exist */ + } log(`Received input for group: ${runnerInput.groupFolder}`); } catch (err) { writeOutput({ status: 'error', result: null, - error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}` + error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`, }); process.exit(1); } @@ -984,8 +1139,9 @@ async function main(): Promise { const reviewerRuntime = process.env.EJCLAW_REVIEWER_RUNTIME === '1' || isReviewerRuntime(runnerInput.roomRoleContext); - const claudeReadonlyReviewerRuntime = - isClaudeReadonlyReviewerRuntime(runnerInput.roomRoleContext); + const claudeReadonlyReviewerRuntime = isClaudeReadonlyReviewerRuntime( + runnerInput.roomRoleContext, + ); const claudeReadonlySandboxMode = claudeReadonlyReviewerRuntime ? getClaudeReadonlySandboxMode() : null; @@ -1006,7 +1162,11 @@ async function main(): Promise { fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); // Clean up stale _close sentinel from previous runner sessions - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } + try { + fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); + } catch { + /* ignore */ + } // Effective working directory (WORK_DIR overrides GROUP_DIR) const mainEffectiveCwd = WORK_DIR || GROUP_DIR; @@ -1024,7 +1184,9 @@ async function main(): Promise { // --- Slash command handling --- // Check BEFORE prepending room role header so /compact isn't masked. const KNOWN_SESSION_COMMANDS = new Set(['/compact']); - const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(runnerInput.prompt.trim()); + const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has( + runnerInput.prompt.trim(), + ); if (!isSessionSlashCommand) { prompt = prependRoomRoleHeader(prompt, runnerInput.roomRoleContext); @@ -1052,13 +1214,16 @@ async function main(): Promise { settingSources: ['project', 'user'] as const, abortController: agentAbortController, hooks: { - PreCompact: [{ hooks: [createPreCompactHook(runnerInput.assistantName)] }], + PreCompact: [ + { hooks: [createPreCompactHook(runnerInput.assistantName)] }, + ], }, }, })) { - const msgType = message.type === 'system' - ? `system/${(message as { subtype?: string }).subtype}` - : message.type; + 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') { @@ -1067,15 +1232,27 @@ async function main(): Promise { } // Observe compact_boundary to confirm compaction completed - if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') { + if ( + message.type === 'system' && + (message as { subtype?: string }).subtype === 'compact_boundary' + ) { compactBoundarySeen = true; - const meta = (message as { compact_metadata?: { trigger?: string; pre_tokens?: number } }).compact_metadata; - log(`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`); + const meta = ( + message as { + compact_metadata?: { trigger?: string; pre_tokens?: number }; + } + ).compact_metadata; + log( + `Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`, + ); } if (message.type === 'result') { const resultSubtype = (message as { subtype?: string }).subtype; - const textResult = 'result' in message ? (message as { result?: string }).result : null; + const textResult = + 'result' in message + ? (message as { result?: string }).result + : null; if (resultSubtype?.startsWith('error')) { hadError = true; @@ -1102,11 +1279,15 @@ async function main(): Promise { writeOutput({ status: 'error', result: null, error: errorMsg }); } - log(`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`); + 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.'); + 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 @@ -1120,7 +1301,11 @@ async function main(): Promise { }); } else if (!hadError) { // Emit session-only marker so host updates session tracking - writeOutput({ status: 'success', result: null, newSessionId: slashSessionId }); + writeOutput({ + status: 'success', + result: null, + newSessionId: slashSessionId, + }); } return; } @@ -1153,7 +1338,8 @@ async function main(): Promise { } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); const errorStack = err instanceof Error ? err.stack : undefined; - const errorCause = err instanceof Error && err.cause ? String(err.cause) : undefined; + const errorCause = + err instanceof Error && err.cause ? String(err.cause) : undefined; log(`Agent error: ${errorMessage}`); if (errorStack) log(`Stack: ${errorStack}`); if (errorCause) log(`Cause: ${errorCause}`); @@ -1161,7 +1347,7 @@ async function main(): Promise { status: 'error', result: null, newSessionId: sessionId, - error: errorMessage + error: errorMessage, }); process.exit(1); } diff --git a/runners/agent-runner/src/memory-selection.ts b/runners/agent-runner/src/memory-selection.ts index 52a715a..f9a9f74 100644 --- a/runners/agent-runner/src/memory-selection.ts +++ b/runners/agent-runner/src/memory-selection.ts @@ -11,10 +11,28 @@ const MEMORY_PATTERNS: Array<{ regex: RegExp; keyword: string; }> = [ - { kind: 'room_norm', regex: /(규칙|원칙|금지|반드시|하지 않|세션 시작 시에만|새 세션 시작 시에만)/i, keyword: 'rule' }, - { kind: 'preference', regex: /(선호|원함|원한다|원하는|원했다)/i, keyword: 'preference' }, - { kind: 'decision', regex: /(합의|결정|방향|기준|우선|책임지는 방향)/i, keyword: 'decision' }, - { kind: 'project_fact', regex: /(owner|reviewer|trigger|모드|메모리|기억|세션 리셋|recall|persist|compact)/i, keyword: 'memory' }, + { + kind: 'room_norm', + regex: + /(규칙|원칙|금지|반드시|하지 않|세션 시작 시에만|새 세션 시작 시에만)/i, + keyword: 'rule', + }, + { + kind: 'preference', + regex: /(선호|원함|원한다|원하는|원했다)/i, + keyword: 'preference', + }, + { + kind: 'decision', + regex: /(합의|결정|방향|기준|우선|책임지는 방향)/i, + keyword: 'decision', + }, + { + kind: 'project_fact', + regex: + /(owner|reviewer|trigger|모드|메모리|기억|세션 리셋|recall|persist|compact)/i, + keyword: 'memory', + }, ]; function normalizeSentence(raw: string): string | null { @@ -35,15 +53,15 @@ function splitSummaryIntoSentences(summary: string): string[] { function classifyMemorySentence(content: string): SelectedCompactMemory | null { if (!content) return null; - const matchedPatterns = MEMORY_PATTERNS.filter(({ regex }) => regex.test(content)); + const matchedPatterns = MEMORY_PATTERNS.filter(({ regex }) => + regex.test(content), + ); if (matchedPatterns.length === 0) return null; const primary = matchedPatterns[0]; const keywords = [ ...new Set( - matchedPatterns - .map((pattern) => pattern.keyword) - .filter(Boolean), + matchedPatterns.map((pattern) => pattern.keyword).filter(Boolean), ), ]; diff --git a/runners/agent-runner/src/verification.ts b/runners/agent-runner/src/verification.ts index 09d14ae..01cdc62 100644 --- a/runners/agent-runner/src/verification.ts +++ b/runners/agent-runner/src/verification.ts @@ -1,15 +1,9 @@ import { execFile } from 'child_process'; import path from 'path'; import { pathToFileURL } from 'url'; -export { - computeVerificationSnapshotId, -} from '../../../shared/verification-snapshot.js'; +export { computeVerificationSnapshotId } from '../../../shared/verification-snapshot.js'; -export const VERIFICATION_PROFILES = [ - 'test', - 'typecheck', - 'build', -] as const; +export const VERIFICATION_PROFILES = ['test', 'typecheck', 'build'] as const; export type VerificationProfile = (typeof VERIFICATION_PROFILES)[number]; diff --git a/runners/agent-runner/src/watch-ci.ts b/runners/agent-runner/src/watch-ci.ts index 38b1781..f452192 100644 --- a/runners/agent-runner/src/watch-ci.ts +++ b/runners/agent-runner/src/watch-ci.ts @@ -34,10 +34,7 @@ export function normalizeWatchCiIntervalSeconds( throw new Error('poll_interval_seconds must be an integer.'); } - if ( - seconds < minSeconds || - seconds > MAX_WATCH_CI_INTERVAL_SECONDS - ) { + if (seconds < minSeconds || seconds > MAX_WATCH_CI_INTERVAL_SECONDS) { throw new Error( `poll_interval_seconds must be between ${minSeconds} and ${MAX_WATCH_CI_INTERVAL_SECONDS}.`, ); diff --git a/runners/agent-runner/test/ipc-paths.test.ts b/runners/agent-runner/test/ipc-paths.test.ts index 6f96c61..f5e8a8c 100644 --- a/runners/agent-runner/test/ipc-paths.test.ts +++ b/runners/agent-runner/test/ipc-paths.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - isTaskScopedIpcDir, - resolveIpcDirectories, -} from '../src/ipc-paths.js'; +import { isTaskScopedIpcDir, resolveIpcDirectories } from '../src/ipc-paths.js'; describe('ipc path helpers', () => { it('detects task-scoped IPC directories', () => { diff --git a/runners/agent-runner/test/memory-selection.test.ts b/runners/agent-runner/test/memory-selection.test.ts index 154068b..4f1303a 100644 --- a/runners/agent-runner/test/memory-selection.test.ts +++ b/runners/agent-runner/test/memory-selection.test.ts @@ -14,11 +14,17 @@ describe('memory selection', () => { ); expect(selected).toHaveLength(2); - expect(selected[0].content).toBe('방 메모리는 새 세션 시작 시에만 주입한다.'); + expect(selected[0].content).toBe( + '방 메모리는 새 세션 시작 시에만 주입한다.', + ); expect(selected[0].memoryKind).toBe('room_norm'); - expect(selected[1].content).toBe('사용자는 수동 명령보다 자동 기억 형성을 원한다.'); + expect(selected[1].content).toBe( + '사용자는 수동 명령보다 자동 기억 형성을 원한다.', + ); expect(selected[1].memoryKind).toBe('preference'); - expect(selected.every((memory) => memory.keywords.includes('room:ejclaw'))).toBe(true); + expect( + selected.every((memory) => memory.keywords.includes('room:ejclaw')), + ).toBe(true); }); it('returns no memories for purely operational summaries', () => { @@ -37,7 +43,9 @@ describe('memory selection', () => { ); expect(selected).toHaveLength(1); - expect(selected[0].content).toBe('배포 전에 항상 테스트를 돌리는 것이 원칙이다.'); + expect(selected[0].content).toBe( + '배포 전에 항상 테스트를 돌리는 것이 원칙이다.', + ); expect(selected[0].memoryKind).toBe('room_norm'); }); }); diff --git a/runners/agent-runner/test/reviewer-runtime.test.ts b/runners/agent-runner/test/reviewer-runtime.test.ts index af32007..a8f06b0 100644 --- a/runners/agent-runner/test/reviewer-runtime.test.ts +++ b/runners/agent-runner/test/reviewer-runtime.test.ts @@ -110,11 +110,11 @@ describe('claude reviewer runtime guard', () => { }); it('builds a read-only sandbox with normalized protected paths', () => { - const sandbox = buildClaudeReadonlySandboxSettings([ - '/repo/work', - '/repo/work', - '/repo/owner/../owner', - ], 'linux', 'strict'); + const sandbox = buildClaudeReadonlySandboxSettings( + ['/repo/work', '/repo/work', '/repo/owner/../owner'], + 'linux', + 'strict', + ); expect(sandbox).toMatchObject({ enabled: true, @@ -157,12 +157,10 @@ describe('claude reviewer runtime guard', () => { it('flags mutating shell commands', () => { expect(isReviewerMutatingShellCommand('git commit -m "x"')).toBe(false); - expect(isReviewerMutatingShellCommand('git -c color.ui=false commit -m "x"')).toBe( - false, - ); - expect(isReviewerMutatingShellCommand('sed -i s/a/b/ file.ts')).toBe( - true, - ); + expect( + isReviewerMutatingShellCommand('git -c color.ui=false commit -m "x"'), + ).toBe(false); + expect(isReviewerMutatingShellCommand('sed -i s/a/b/ file.ts')).toBe(true); expect(isReviewerMutatingShellCommand('git status')).toBe(false); expect(isReviewerMutatingShellCommand('npm test')).toBe(false); }); @@ -263,7 +261,9 @@ describe('claude reviewer runtime guard', () => { true, ); - expect(() => assertReadonlyWorkspaceRepoConnectivity(env, true)).not.toThrow(); + expect(() => + assertReadonlyWorkspaceRepoConnectivity(env, true), + ).not.toThrow(); }); it.each([ @@ -286,7 +286,9 @@ describe('claude reviewer runtime guard', () => { true, ); - expect(() => assertReadonlyWorkspaceRepoConnectivity(env, true)).not.toThrow(); + expect(() => + assertReadonlyWorkspaceRepoConnectivity(env, true), + ).not.toThrow(); }, ); diff --git a/runners/agent-runner/test/verification.test.ts b/runners/agent-runner/test/verification.test.ts index 85a08eb..b01a7aa 100644 --- a/runners/agent-runner/test/verification.test.ts +++ b/runners/agent-runner/test/verification.test.ts @@ -11,10 +11,15 @@ import { describe('runner verification helpers', () => { it('computes the same snapshot when excluded files change', () => { - const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-runner-snapshot-')); + const repoDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-runner-snapshot-'), + ); fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true }); fs.mkdirSync(path.join(repoDir, 'node_modules'), { recursive: true }); - fs.writeFileSync(path.join(repoDir, 'src', 'index.ts'), 'export const x = 1;\n'); + fs.writeFileSync( + path.join(repoDir, 'src', 'index.ts'), + 'export const x = 1;\n', + ); fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=1\n'); const first = computeVerificationSnapshotId(repoDir); diff --git a/runners/codex-runner/src/app-server-client.ts b/runners/codex-runner/src/app-server-client.ts index 798dc47..333be05 100644 --- a/runners/codex-runner/src/app-server-client.ts +++ b/runners/codex-runner/src/app-server-client.ts @@ -102,7 +102,11 @@ export class CodexAppServerClient { if (this.proc) return; const codexPackagePath = this.require.resolve('@openai/codex/package.json'); - const codexBin = path.join(path.dirname(codexPackagePath), 'bin', 'codex.js'); + const codexBin = path.join( + path.dirname(codexPackagePath), + 'bin', + 'codex.js', + ); this.proc = spawn(process.execPath, [codexBin, 'app-server'], { cwd: this.cwd, @@ -213,15 +217,17 @@ export class CodexAppServerClient { throw new Error('A Codex app-server turn is already active.'); } - const turnPromise = new Promise((resolve, reject) => { - this.activeTurn = { - threadId, - state: createInitialAppServerTurnState(), - onProgress: options.onProgress, - resolve, - reject, - }; - }); + const turnPromise = new Promise( + (resolve, reject) => { + this.activeTurn = { + threadId, + state: createInitialAppServerTurnState(), + onProgress: options.onProgress, + resolve, + reject, + }; + }, + ); let turnId = ''; try { @@ -285,14 +291,16 @@ export class CodexAppServerClient { throw new Error('A Codex app-server turn is already active.'); } - const turnPromise = new Promise((resolve, reject) => { - this.activeTurn = { - threadId, - state: createInitialAppServerTurnState(), - resolve, - reject, - }; - }); + const turnPromise = new Promise( + (resolve, reject) => { + this.activeTurn = { + threadId, + state: createInitialAppServerTurnState(), + resolve, + reject, + }; + }, + ); try { await this.request('thread/compact/start', { threadId }); diff --git a/runners/codex-runner/src/app-server-state.ts b/runners/codex-runner/src/app-server-state.ts index 97c0b86..7e7cac5 100644 --- a/runners/codex-runner/src/app-server-state.ts +++ b/runners/codex-runner/src/app-server-state.ts @@ -37,10 +37,7 @@ export type AppServerTurnEvent = turn?: { id?: string | null; status?: string | null; - error?: - | { message?: string | null } - | string - | null; + error?: { message?: string | null } | string | null; }; }; } diff --git a/runners/codex-runner/src/index.ts b/runners/codex-runner/src/index.ts index bfbdbf0..40cf957 100644 --- a/runners/codex-runner/src/index.ts +++ b/runners/codex-runner/src/index.ts @@ -99,10 +99,7 @@ function normalizeStructuredOutput(result: string | null): { const envelope = parsed?.ejclaw; if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) { if (envelope.visibility === 'silent') { - if ( - envelope.verdict !== undefined && - envelope.verdict !== 'silent' - ) { + if (envelope.verdict !== undefined && envelope.verdict !== 'silent') { return { result, output: { visibility: 'public', text: result }, @@ -225,7 +222,10 @@ function drainIpcInput(): string[] { } } -function extractImagePaths(text: string): { cleanText: string; imagePaths: string[] } { +function extractImagePaths(text: string): { + cleanText: string; + imagePaths: string[]; +} { /** SSOT: src/agent-protocol.ts — keep in sync */ const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g; const imagePaths: string[] = []; @@ -285,24 +285,28 @@ async function executeAppServerTurn( retryCount = 0, ): Promise<{ result: string | null; error?: string }> { let lastProgressMessage: string | null = null; - const activeTurn = await client.startTurn(threadId, parseAppServerInput(prompt), { - cwd: EFFECTIVE_CWD, - model: CODEX_MODEL || undefined, - effort: CODEX_EFFORT || undefined, - onProgress: (message) => { - const trimmed = message.trim(); - if (!trimmed || trimmed === lastProgressMessage) { - return; - } - lastProgressMessage = trimmed; - writeOutput({ - status: 'success', - phase: 'progress', - ...normalizeStructuredOutput(trimmed), - newSessionId: threadId, - }); + const activeTurn = await client.startTurn( + threadId, + parseAppServerInput(prompt), + { + cwd: EFFECTIVE_CWD, + model: CODEX_MODEL || undefined, + effort: CODEX_EFFORT || undefined, + onProgress: (message) => { + const trimmed = message.trim(); + if (!trimmed || trimmed === lastProgressMessage) { + return; + } + lastProgressMessage = trimmed; + writeOutput({ + status: 'success', + phase: 'progress', + ...normalizeStructuredOutput(trimmed), + newSessionId: threadId, + }); + }, }, - }); + ); let elapsedMs = 0; let polling = true; @@ -360,7 +364,8 @@ async function executeAppServerTurn( } return { result, - error: state.errorMessage || `Codex turn finished with status ${state.status}`, + error: + state.errorMessage || `Codex turn finished with status ${state.status}`, }; } finally { polling = false; @@ -451,7 +456,11 @@ async function runAppServerSession( } log('Starting app-server turn...'); - const { result, error } = await executeAppServerTurn(client, threadId, prompt); + const { result, error } = await executeAppServerTurn( + client, + threadId, + prompt, + ); if (error) { const normalized = normalizeStructuredOutput(result || null); diff --git a/runners/codex-runner/test/reviewer-runtime.test.ts b/runners/codex-runner/test/reviewer-runtime.test.ts index fc26326..d4493e3 100644 --- a/runners/codex-runner/test/reviewer-runtime.test.ts +++ b/runners/codex-runner/test/reviewer-runtime.test.ts @@ -167,7 +167,9 @@ describe('codex reviewer runtime guard', () => { true, ); - expect(() => assertReadonlyWorkspaceRepoConnectivity(env, true)).not.toThrow(); + expect(() => + assertReadonlyWorkspaceRepoConnectivity(env, true), + ).not.toThrow(); }); it.each([ @@ -190,7 +192,9 @@ describe('codex reviewer runtime guard', () => { true, ); - expect(() => assertReadonlyWorkspaceRepoConnectivity(env, true)).not.toThrow(); + expect(() => + assertReadonlyWorkspaceRepoConnectivity(env, true), + ).not.toThrow(); }, ); diff --git a/setup/legacy-room-registrations.ts b/setup/legacy-room-registrations.ts index 0aa3d90..9ddad0d 100644 --- a/setup/legacy-room-registrations.ts +++ b/setup/legacy-room-registrations.ts @@ -55,7 +55,9 @@ export function normalizeLegacyRegisteredGroupsTable(database: Database): void { const hasIsMain = legacyCols.some((col) => col.name === 'is_main'); const hasAgentType = legacyCols.some((col) => col.name === 'agent_type'); const hasWorkDir = legacyCols.some((col) => col.name === 'work_dir'); - const hasAgentConfig = legacyCols.some((col) => col.name === 'agent_config'); + const hasAgentConfig = legacyCols.some( + (col) => col.name === 'agent_config', + ); const hasContainerConfig = legacyCols.some( (col) => col.name === 'container_config', ); diff --git a/setup/migrate-room-registrations.ts b/setup/migrate-room-registrations.ts index d5cd6f0..db1a1ea 100644 --- a/setup/migrate-room-registrations.ts +++ b/setup/migrate-room-registrations.ts @@ -65,7 +65,18 @@ function backupLegacyRows(database: Database): number { requires_trigger, is_main, agent_type, work_dir FROM registered_groups`, ) - .all() as Array>; + .all() as Array<{ + jid: string; + name: string; + folder: string; + trigger_pattern: string; + added_at: string; + agent_config: string | null; + requires_trigger: number | null; + is_main: number | null; + agent_type: string | null; + work_dir: string | null; + }>; const insert = database.prepare( `INSERT OR REPLACE INTO registered_groups_legacy_backup ( diff --git a/setup/platform.test.ts b/setup/platform.test.ts index 7dba53c..ac6707e 100644 --- a/setup/platform.test.ts +++ b/setup/platform.test.ts @@ -105,43 +105,30 @@ describe('commandExists', () => { describe('canUseLinuxBubblewrapReadonlySandbox', () => { it('returns false outside linux', () => { expect( - canUseLinuxBubblewrapReadonlySandbox( - 'macos', - () => { - throw new Error('should not probe commands on macOS'); - }, - ), + canUseLinuxBubblewrapReadonlySandbox('macos', () => { + throw new Error('should not probe commands on macOS'); + }), ).toBe(false); }); it('returns false when bwrap is not installed', () => { - expect( - canUseLinuxBubblewrapReadonlySandbox( - 'linux', - () => false, - ), - ).toBe(false); + expect(canUseLinuxBubblewrapReadonlySandbox('linux', () => false)).toBe( + false, + ); }); it('returns true when linux bwrap readonly probe succeeds', () => { expect( - canUseLinuxBubblewrapReadonlySandbox( - 'linux', - () => true, - (() => Buffer.from('')) as typeof import('child_process').execSync, - ), + canUseLinuxBubblewrapReadonlySandbox('linux', () => true, (() => + Buffer.from('')) as unknown as typeof import('child_process').execSync), ).toBe(true); }); it('returns false when linux bwrap readonly probe fails', () => { expect( - canUseLinuxBubblewrapReadonlySandbox( - 'linux', - () => true, - (() => { - throw new Error('permission denied'); - }) as typeof import('child_process').execSync, - ), + canUseLinuxBubblewrapReadonlySandbox('linux', () => true, (() => { + throw new Error('permission denied'); + }) as typeof import('child_process').execSync), ).toBe(false); }); }); @@ -157,8 +144,9 @@ describe('getAppArmorRestrictUnprivilegedUsernsValue', () => { it('returns the trimmed proc sysctl value', () => { expect( - getAppArmorRestrictUnprivilegedUsernsValue((() => - '1\n') as typeof import('fs').readFileSync), + getAppArmorRestrictUnprivilegedUsernsValue( + (() => '1\n') as unknown as typeof import('fs').readFileSync, + ), ).toBe('1'); }); }); @@ -252,9 +240,11 @@ describe('ensureLinuxReadonlySandboxAppArmorSupport', () => { const writes: string[] = []; const commands: string[] = []; const fileContents = new Map([ - ['/etc/sysctl.d/90-ejclaw-sandbox.conf', + [ + '/etc/sysctl.d/90-ejclaw-sandbox.conf', '# Managed by EJClaw setup to allow bubblewrap readonly sandboxing.\n' + - 'kernel.apparmor_restrict_unprivileged_userns=0\n'], + 'kernel.apparmor_restrict_unprivileged_userns=0\n', + ], ['/proc/sys/kernel/apparmor_restrict_unprivileged_userns', '1\n'], ]); @@ -301,14 +291,17 @@ describe('ensureLinuxReadonlySandboxAppArmorSupport', () => { apparmorRestrictUnprivilegedUserns: '1', sandboxCapable: false, isRootUser: true, - readFileSyncFn: ((target) => { + readFileSyncFn: ((target: import('fs').PathOrFileDescriptor) => { const value = fileContents.get(String(target)); if (value == null) throw new Error('missing'); return value; - }) as typeof import('fs').readFileSync, + }) as unknown as typeof import('fs').readFileSync, mkdirSyncFn: (() => undefined) as typeof import('fs').mkdirSync, writeFileSyncFn: (() => undefined) as typeof import('fs').writeFileSync, - execFn: (() => Buffer.from('')) as typeof import('child_process').execSync, + execFn: (() => + Buffer.from( + '', + )) as unknown as typeof import('child_process').execSync, }), ).toBe('failed'); }); diff --git a/setup/platform.ts b/setup/platform.ts index 3ed57eb..bc59390 100644 --- a/setup/platform.ts +++ b/setup/platform.ts @@ -177,10 +177,7 @@ export function ensureLinuxReadonlySandboxAppArmorSupport(options?: { const sandboxCapable = options?.sandboxCapable ?? canUseLinuxBubblewrapReadonlySandbox(platform); - if ( - apparmorRestrictUnprivilegedUserns !== '1' || - sandboxCapable - ) { + if (apparmorRestrictUnprivilegedUserns !== '1' || sandboxCapable) { return 'not-needed'; } @@ -211,9 +208,8 @@ export function ensureLinuxReadonlySandboxAppArmorSupport(options?: { stdio: 'ignore', }); - const appliedValue = getAppArmorRestrictUnprivilegedUsernsValue( - readFileSyncFn, - ); + const appliedValue = + getAppArmorRestrictUnprivilegedUsernsValue(readFileSyncFn); if (appliedValue !== '0') { throw new Error( `kernel.apparmor_restrict_unprivileged_userns remained ${appliedValue ?? 'unavailable'} after sysctl apply`, diff --git a/setup/restart-stack.test.ts b/setup/restart-stack.test.ts index b9399b8..1c4f5ca 100644 --- a/setup/restart-stack.test.ts +++ b/setup/restart-stack.test.ts @@ -77,24 +77,24 @@ describe('restartStackServices', () => { expect(() => restartStackServices(tempRoot, { - serviceManager: 'nohup', + serviceManager: 'none', }), - ).toThrow('restart:stack only supports Linux systemd services in this repo'); + ).toThrow( + 'restart:stack only supports Linux systemd services in this repo', + ); }); it('falls back to direct restart when the oneshot unit is not installed for an external caller', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-')); tempRoots.push(tempRoot); - const execFileSyncImpl = vi - .fn() - .mockImplementationOnce(() => { - const error = new Error('unit missing'); - Object.assign(error, { - stderr: 'Unit ejclaw-stack-restart.service not found.', - }); - throw error; + const execFileSyncImpl = vi.fn().mockImplementationOnce(() => { + const error = new Error('unit missing'); + Object.assign(error, { + stderr: 'Unit ejclaw-stack-restart.service not found.', }); + throw error; + }); const services = restartStackServices(tempRoot, { execFileSyncImpl, @@ -147,7 +147,8 @@ describe('restartStackServices', () => { const execFileSyncImpl = vi.fn().mockImplementation(() => { const error = new Error('job failed'); Object.assign(error, { - stderr: 'Job for ejclaw-stack-restart.service failed because the control process exited with error code.', + stderr: + 'Job for ejclaw-stack-restart.service failed because the control process exited with error code.', }); throw error; }); diff --git a/setup/restart-stack.ts b/setup/restart-stack.ts index c3fc44d..70a69a3 100644 --- a/setup/restart-stack.ts +++ b/setup/restart-stack.ts @@ -36,7 +36,7 @@ function restartStackServicesDirect( } const execImpl = deps.execFileSyncImpl ?? execFileSync; - const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user']; + const systemctlArgs = (deps.runningAsRoot ?? isRoot()) ? [] : ['--user']; execImpl('systemctl', [...systemctlArgs, 'restart', ...services], { stdio: 'ignore', @@ -64,7 +64,7 @@ export function restartStackServices( const services = getConfiguredServiceNames(projectRoot); const execImpl = deps.execFileSyncImpl ?? execFileSync; - const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user']; + const systemctlArgs = (deps.runningAsRoot ?? isRoot()) ? [] : ['--user']; if (deps.direct) { return restartStackServicesDirect(projectRoot, deps); diff --git a/setup/service.test.ts b/setup/service.test.ts index 940cb71..edec45c 100644 --- a/setup/service.test.ts +++ b/setup/service.test.ts @@ -118,9 +118,7 @@ describe('systemd unit generation', () => { '/home/user', false, ); - expect(unit).toContain( - 'ExecStart=/usr/bin/bun /srv/ejclaw/dist/index.js', - ); + expect(unit).toContain('ExecStart=/usr/bin/bun /srv/ejclaw/dist/index.js'); }); it('preserves EnvironmentFile and extraEnv in the actual builder', () => { diff --git a/setup/verify-services.test.ts b/setup/verify-services.test.ts index cc8d25d..df53df1 100644 --- a/setup/verify-services.test.ts +++ b/setup/verify-services.test.ts @@ -52,9 +52,12 @@ describe('verify service checks', () => { execSyncMock.mockReturnValue(undefined); expect(checkSystemdService('ejclaw')).toBe('running'); - expect(execSyncMock).toHaveBeenCalledWith('systemctl --user is-active ejclaw', { - stdio: 'ignore', - }); + expect(execSyncMock).toHaveBeenCalledWith( + 'systemctl --user is-active ejclaw', + { + stdio: 'ignore', + }, + ); }); it('checks systemd services in both explicit scopes', () => { @@ -71,8 +74,12 @@ describe('verify service checks', () => { throw new Error(`unexpected command: ${cmd}`); }); - expect(checkSystemdServiceInScope('ejclaw-codex', 'system')).toBe('running'); - expect(checkSystemdServiceInScope('ejclaw-codex', 'user')).toBe('not_found'); + expect(checkSystemdServiceInScope('ejclaw-codex', 'system')).toBe( + 'running', + ); + expect(checkSystemdServiceInScope('ejclaw-codex', 'user')).toBe( + 'not_found', + ); }); it('treats an unloaded launchd plist as stopped when artifact detection is enabled', () => { @@ -87,9 +94,9 @@ describe('verify service checks', () => { fs.writeFileSync(plistPath, ''); execSyncMock.mockReturnValue(''); - expect( - checkLaunchdServiceArtifact('com.ejclaw-codex', plistPath), - ).toBe('stopped'); + expect(checkLaunchdServiceArtifact('com.ejclaw-codex', plistPath)).toBe( + 'stopped', + ); fs.rmSync(tempHome, { recursive: true, force: true }); }); @@ -111,7 +118,7 @@ describe('verify service checks', () => { const killSpy = vi .spyOn(process, 'kill') - .mockImplementation((_pid: number, _signal?: number | NodeJS.Signals) => true); + .mockImplementation((_pid: number, _signal?: string | number) => true); expect(checkNohupService(tempRoot, 'ejclaw')).toBe('running'); @@ -121,7 +128,10 @@ describe('verify service checks', () => { it('treats a legacy nohup wrapper without a live pid as stopped when artifact detection is enabled', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-')); - fs.writeFileSync(path.join(tempRoot, 'start-ejclaw-codex.sh'), '#!/bin/bash\n'); + fs.writeFileSync( + path.join(tempRoot, 'start-ejclaw-codex.sh'), + '#!/bin/bash\n', + ); expect(checkNohupServiceArtifact(tempRoot, 'ejclaw-codex')).toBe('stopped'); diff --git a/setup/verify-services.ts b/setup/verify-services.ts index dfc0a67..eff51e4 100644 --- a/setup/verify-services.ts +++ b/setup/verify-services.ts @@ -27,7 +27,9 @@ export function checkLaunchdService(label: string): ServiceStatus { try { const output = execSync('launchctl list', { encoding: 'utf-8' }); if (output.includes(label)) { - const line = output.split('\n').find((candidate) => candidate.includes(label)); + const line = output + .split('\n') + .find((candidate) => candidate.includes(label)); if (line) { const pidField = line.trim().split(/\s+/)[0]; return pidField !== '-' && pidField ? 'running' : 'stopped'; diff --git a/src/restart-context.test.ts b/src/restart-context.test.ts index 8a4a9a7..2337f44 100644 --- a/src/restart-context.test.ts +++ b/src/restart-context.test.ts @@ -64,24 +64,22 @@ describe('getInterruptedRecoveryCandidates', () => { ], }; - expect(getInterruptedRecoveryCandidates(context, roomBindings)).toEqual( - [ - { - chatJid: 'dc:1', - groupFolder: 'group-one', - status: 'processing', - pendingMessages: true, - pendingTasks: 0, - }, - { - chatJid: 'dc:2', - groupFolder: 'group-two', - status: 'idle', - pendingMessages: false, - pendingTasks: 0, - }, - ], - ); + expect(getInterruptedRecoveryCandidates(context, roomBindings)).toEqual([ + { + chatJid: 'dc:1', + groupFolder: 'group-one', + status: 'processing', + pendingMessages: true, + pendingTasks: 0, + }, + { + chatJid: 'dc:2', + groupFolder: 'group-two', + status: 'idle', + pendingMessages: false, + pendingTasks: 0, + }, + ]); }); it('returns empty when there is no explicit restart context', () => { diff --git a/test/vitest-env.ts b/test/vitest-env.ts new file mode 100644 index 0000000..5fc1b8f --- /dev/null +++ b/test/vitest-env.ts @@ -0,0 +1,23 @@ +const CANONICAL_DISCORD_KEYS = new Set([ + 'DISCORD_OWNER_BOT_TOKEN', + 'DISCORD_REVIEWER_BOT_TOKEN', + 'DISCORD_ARBITER_BOT_TOKEN', +]); + +const CANONICAL_SESSION_COMMAND_KEYS = new Set([ + 'SESSION_COMMAND_ALLOWED_SENDERS', +]); + +for (const key of Object.keys(process.env)) { + if (key.startsWith('DISCORD_') && key.endsWith('_BOT_TOKEN')) { + if (!CANONICAL_DISCORD_KEYS.has(key)) { + delete process.env[key]; + } + continue; + } + if (key.startsWith('SESSION_COMMAND_')) { + if (!CANONICAL_SESSION_COMMAND_KEYS.has(key)) { + delete process.env[key]; + } + } +} diff --git a/tsconfig.check.json b/tsconfig.check.json new file mode 100644 index 0000000..ad9f0e2 --- /dev/null +++ b/tsconfig.check.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": [ + "src/**/*", + "setup/**/*", + "scripts/**/*", + "test/**/*", + "vitest*.ts", + "runners/**/test/**/*.ts" + ], + "exclude": ["node_modules", "dist", "runners/**/dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts index f3566c5..ee0c290 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ }, }, test: { + setupFiles: ['test/vitest-env.ts'], include: [ 'src/**/*.test.ts', 'setup/**/*.test.ts',