Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions src/__tests__/issueStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('SqliteIssueStore', () => {
acceptanceCriteria: ['단위 테스트 통과', 'E2E 통과'],
});

expect(issue.labels).toEqual([label.id]);
expect(issue.labels).toEqual([label.name]);
expect(issue.relevantFiles).toEqual(expect.arrayContaining(['src/foo.ts', 'src/bar.ts']));
expect(issue.acceptanceCriteria).toEqual(['단위 테스트 통과', 'E2E 통과']);
});
Expand Down Expand Up @@ -105,7 +105,7 @@ describe('SqliteIssueStore', () => {
const issue = store.createIssue({ projectId: 'p1', title: 't', labels: [l1.id] });

const updated = store.updateIssue(issue.id, { labels: [l2.id] });
expect(updated?.labels).toEqual([l2.id]);
expect(updated?.labels).toEqual([l2.name]);
});
});

Expand Down Expand Up @@ -154,6 +154,29 @@ describe('SqliteIssueStore', () => {
expect(issues[0].title).toContain('API');
});

it('FTS OR 검색', () => {
store.createIssue({ projectId: 'p1', title: 'API endpoint failure' });
store.createIssue({ projectId: 'p1', title: 'login regression' });
store.createIssue({ projectId: 'p1', title: 'billing cleanup' });

const { issues } = store.listIssues({ search: 'API OR login', limit: 50, offset: 0 });
expect(issues.map((i) => i.title)).toEqual(expect.arrayContaining([
'API endpoint failure',
'login regression',
]));
expect(issues).toHaveLength(2);
});

it('FTS 정규화된 quoted AND 검색', () => {
store.createIssue({ projectId: 'p1', title: 'API login failure' });
store.createIssue({ projectId: 'p1', title: 'API billing failure' });
store.createIssue({ projectId: 'p1', title: 'login page copy' });

const { issues } = store.listIssues({ search: '"API" AND "login"', limit: 50, offset: 0 });
expect(issues).toHaveLength(1);
expect(issues[0].title).toBe('API login failure');
});

it('우선순위 정렬 (urgent > high > medium > low)', () => {
store.createIssue({ projectId: 'p1', title: 'low', priority: 'low' });
store.createIssue({ projectId: 'p1', title: 'urgent', priority: 'urgent' });
Expand Down Expand Up @@ -207,6 +230,16 @@ describe('SqliteIssueStore', () => {
expect(store.listLabels().length).toBe(0);
});

it('중복 라벨 생성 시 기존 행을 반환', () => {
const first = store.createLabel('bug', '#ff0000', '버그');
const second = store.createLabel('bug', '#00ff00', '중복');

expect(second.id).toBe(first.id);
expect(second.color).toBe(first.color);
expect(store.listLabels()).toHaveLength(1);
expect(store.deleteLabel(second.id)).toBe(true);
});

it('마일스톤 생성', () => {
const ms = store.createMilestone('v1.0', 'First release', '2026-05-01');
expect(ms.name).toBe('v1.0');
Expand Down
14 changes: 10 additions & 4 deletions src/adapters/agenticLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ export interface AgenticLoopOptions {
bashTimeoutMs?: number;
/** Expose web_fetch + web_search tools (default true). Disabled e.g. for SWE-bench integrity. */
webTools?: boolean;
/** Read-only mode: hide mutation/shell tools and refuse response-text edits. */
readOnly?: boolean;
/** Expose the apply_patch (V4A) tool — codex adapters only (codex models are
* RLHF-trained on V4A; non-codex models emit malformed V4A). Default false. */
applyPatch?: boolean;
Expand Down Expand Up @@ -185,6 +187,7 @@ export async function runAgenticLoop(options: AgenticLoopOptions): Promise<Agent
protectedFiles,
bashTimeoutMs,
webTools = true,
readOnly = false,
applyPatch = false,
mcpTools,
signal,
Expand Down Expand Up @@ -215,10 +218,13 @@ export async function runAgenticLoop(options: AgenticLoopOptions): Promise<Agent
const baseTools = editFormat === 'json'
? TOOL_DEFINITIONS
: TOOL_DEFINITIONS.filter(t => t.function.name !== 'edit_file');
const visibleBaseTools = readOnly
? baseTools.filter((t) => !['write_file', 'edit_file', 'bash'].includes(t.function.name))
: baseTools;
const tools = enableTools
? [
...baseTools,
...(applyPatch && editFormat === 'json' ? [APPLY_PATCH_TOOL] : []),
...visibleBaseTools,
...(applyPatch && editFormat === 'json' && !readOnly ? [APPLY_PATCH_TOOL] : []),
...(webTools ? WEB_TOOL_DEFINITIONS : []),
...(mcpTools ?? []),
]
Expand Down Expand Up @@ -329,7 +335,7 @@ export async function runAgenticLoop(options: AgenticLoopOptions): Promise<Agent
if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0) {
// SEARCH/REPLACE 모드 — 도구 호출이 아니라 응답 본문의 S/R 블록으로 편집한다.
// 블록이 있으면 직접 적용하고(보호 파일은 거부) 결과를 되돌려 루프를 잇는다. (INT-1676)
if (editFormat === 'search-replace' && assistantMsg.content) {
if (!readOnly && editFormat === 'search-replace' && assistantMsg.content) {
const parsed = parseSearchReplaceBlocks(assistantMsg.content);
if (parsed.blocks.length > 0) {
const resultLines = await Promise.all(parsed.blocks.map(async (block) => {
Expand Down Expand Up @@ -425,7 +431,7 @@ export async function runAgenticLoop(options: AgenticLoopOptions): Promise<Agent
}
}

const results: ToolResult[] = await executeToolCalls(toolCalls, cwd, readCache, { protectedFiles, bashTimeoutMs });
const results: ToolResult[] = await executeToolCalls(toolCalls, cwd, readCache, { protectedFiles, bashTimeoutMs, readOnly });
toolCallCount += toolCalls.length;
// Count only SUCCESSFUL edits — a model whose edit_file calls all fail
// (old_string not found, protected file) has not modified anything, and
Expand Down
1 change: 1 addition & 0 deletions src/adapters/codexResponses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ export class CodexResponsesAdapter implements CliAdapter {
bashTimeoutMs: options.bashTimeoutMs,
webTools: options.webTools,
mcpTools: options.mcpTools,
readOnly: options.readOnly,
// codex models are RLHF-trained on the V4A apply_patch format — expose it as
// the primary edit tool (edit_file stays as fallback). Verified: gpt-5.3-codex-spark
// emits clean V4A here, whereas non-codex adapters keep edit_file only.
Expand Down
1 change: 1 addition & 0 deletions src/adapters/gpt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export class GptCliAdapter implements CliAdapter {
protectedFiles: options.protectedFiles,
bashTimeoutMs: options.bashTimeoutMs,
webTools: options.webTools,
readOnly: options.readOnly,
mcpTools,
signal: options.signal,
editFormat: options.editFormat,
Expand Down
1 change: 1 addition & 0 deletions src/adapters/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export class LocalModelAdapter implements CliAdapter {
protectedFiles: options.protectedFiles,
bashTimeoutMs: options.bashTimeoutMs,
webTools: options.webTools,
readOnly: options.readOnly,
mcpTools,
signal: options.signal,
editFormat: options.editFormat,
Expand Down
1 change: 1 addition & 0 deletions src/adapters/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export class OpenRouterCliAdapter implements CliAdapter {
protectedFiles: options.protectedFiles,
bashTimeoutMs: options.bashTimeoutMs,
webTools: options.webTools,
readOnly: options.readOnly,
mcpTools,
signal: options.signal,
editFormat: options.editFormat,
Expand Down
24 changes: 24 additions & 0 deletions src/adapters/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,30 @@ describe('Safety guards (isCommandBlocked via bash)', () => {
// Should not be blocked (may still fail for other reasons, but not BLOCKED)
expect(result.content).not.toContain('BLOCKED');
});

it('refuses mutation and shell tools in read-only mode', async () => {
const filePath = path.join(TMP_DIR, 'readonly-target.txt');
await fs.writeFile(filePath, 'keep', 'utf-8');

const write = await executeTool(
makeCall('write_file', { path: filePath, content: 'changed' }),
TMP_DIR,
undefined,
{ readOnly: true },
);
const bash = await executeTool(
makeCall('bash', { command: 'echo changed > readonly-target.txt' }),
TMP_DIR,
undefined,
{ readOnly: true },
);

expect(write.is_error).toBe(true);
expect(write.content).toContain('READ_ONLY');
expect(bash.is_error).toBe(true);
expect(bash.content).toContain('READ_ONLY');
await expect(fs.readFile(filePath, 'utf-8')).resolves.toBe('keep');
});
});

// ──────────────────────────────────────────────
Expand Down
9 changes: 9 additions & 0 deletions src/adapters/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ export interface ToolExecOptions {
protectedFiles?: string[];
/** bash tool timeout (default DEFAULT_BASH_TIMEOUT_MS) */
bashTimeoutMs?: number;
/** Refuse mutation and shell tools even if a model emits hidden tool names. */
readOnly?: boolean;
}

const DEFAULT_BASH_TIMEOUT_MS = 30000;
Expand Down Expand Up @@ -343,6 +345,13 @@ export async function executeTool(

try {
const args = JSON.parse(argsJson);
if (execOptions?.readOnly && ['write_file', 'edit_file', 'apply_patch', 'bash'].includes(name)) {
return {
tool_call_id: callId,
content: `READ_ONLY: ${name} is disabled for this run. Use read_file/search_files/search_memory only.`,
is_error: true,
};
}

switch (name) {
case 'read_file': {
Expand Down
2 changes: 2 additions & 0 deletions src/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export interface CliRunOptions {
bashTimeoutMs?: number;
/** Expose web_fetch + web_search tools (default true). Set false for SWE-bench integrity. */
webTools?: boolean;
/** Enforce read-only tool exposure/execution where the adapter supports OpenSwarm's tool layer. */
readOnly?: boolean;
/**
* Expose the file/bash tool set to the agentic loop. Defaults to each adapter's
* normal behavior (usually on). Set false for plain conversational completion
Expand Down
2 changes: 1 addition & 1 deletion src/automation/autonomousRunner.cancel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe('AutonomousRunner cancellation state sync', () => {
({ AutonomousRunner } = await import('./autonomousRunner.js'));
runnerExecution = await import('./runnerExecution.js');
taskStateStore = await import('../taskState/store.js');
});
}, 30000);

afterEach(() => {
vi.unstubAllEnvs();
Expand Down
2 changes: 1 addition & 1 deletion src/automation/autonomousRunner.infraError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('AutonomousRunner infra_error handling (INT-2010)', () => {
vi.stubEnv('OPENSWARM_TASK_STATE_FILE', join(tempDir, 'task-state.json'));
({ AutonomousRunner } = await import('./autonomousRunner.js'));
runnerExecution = await import('./runnerExecution.js');
});
}, 30000);

afterEach(() => {
vi.unstubAllEnvs();
Expand Down
34 changes: 19 additions & 15 deletions src/automation/ciWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,21 +98,25 @@ export class CIWorker {
const state = await loadCIState();

for (const repo of this.config.repos) {
const current = state.repos[repo];
const { health, transition } = await checkRepoHealth(repo, current);

// Update state
state.repos[repo] = health;

// Handle transitions
if (transition) {
await this.handleTransition(transition);
}

// Handle persistent failures (reminder)
if (needsReminder(health, 24)) {
await this.handlePersistentFailure(health);
health.lastReminder = new Date().toISOString();
try {
const current = state.repos[repo];
const { health, transition } = await checkRepoHealth(repo, current, this.config.maxAgeDays);

// Update state
state.repos[repo] = health;

// Handle transitions
if (transition) {
await this.handleTransition(transition);
}

// Handle persistent failures (reminder)
if (needsReminder(health, 24)) {
await this.handlePersistentFailure(health);
health.lastReminder = new Date().toISOString();
}
} catch (err) {
console.error(`[CIWorker] Failed to process repo ${repo}:`, err);
}
}

Expand Down
11 changes: 8 additions & 3 deletions src/automation/dailyReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// ============================================

import { Cron } from 'croner';
import { LinearClient } from '@linear/sdk';
import { LinearClient, type Project } from '@linear/sdk';
import { postStatusUpdate } from '../linear/index.js';

let cronJob: Cron | null = null;
Expand Down Expand Up @@ -97,8 +97,13 @@ export async function generateDailyReports(): Promise<void> {
return;
}

const projects = await team.projects({ first: 50 });
const activeProjects = projects.nodes.filter(p => p.state !== 'canceled');
const activeProjects: Project[] = [];
let after: string | undefined;
do {
const projects = await team.projects({ first: 50, after });
activeProjects.push(...projects.nodes.filter(p => p.state !== 'canceled'));
after = projects.pageInfo.hasNextPage ? projects.pageInfo.endCursor ?? undefined : undefined;
} while (after);

if (activeProjects.length === 0) {
console.log('[DailyReporter] No active projects found');
Expand Down
39 changes: 15 additions & 24 deletions src/automation/longRunningMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// ============================================

import { execFile } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import type {
Expand All @@ -17,29 +17,21 @@ import { broadcastEvent } from '../core/eventHub.js';

// Constants

const PERSIST_FILE = join(homedir(), '.claude', 'openswarm-monitors.json');
const PERSIST_DIR = join(homedir(), '.openswarm');
const PERSIST_FILE = join(PERSIST_DIR, 'openswarm-monitors.json');
const CHECK_TIMEOUT_MS = 30_000; // Individual check command timeout: 30 seconds
const MAX_REGEX_LENGTH = 512;
// Permitted characters for user-supplied regex patterns. Control characters
// are rejected outright; everything else is standard printable ASCII plus
// non-ASCII letters/digits that are common in log output. The allowlist
// doubles as a CodeQL sanitizer for `js/regex-injection`.
const ALLOWED_REGEX_CHARS = /^[\x20-\x7E\t\u00A0-\uFFFF]*$/;
const MAX_OUTPUT_PATTERN_LENGTH = 512;
const ALLOWED_OUTPUT_PATTERN_CHARS = /^[\x20-\x7E\t\u00A0-\uFFFF]*$/;

/**
* Safely compile a user-supplied pattern. Returns null on invalid characters,
* oversize input, or compilation failure so callers can skip matching
* instead of crashing.
* Treat configured output patterns as literal strings. This preserves the
* completion-check shape without evaluating user-supplied regular expressions.
*/
function safeCompileRegex(pattern: string | undefined): RegExp | null {
if (!pattern) return null;
if (pattern.length > MAX_REGEX_LENGTH) return null;
if (!ALLOWED_REGEX_CHARS.test(pattern)) return null;
try {
return new RegExp(pattern);
} catch {
return null;
}
function outputIncludesPattern(stdout: string, pattern: string | undefined): boolean {
if (!pattern) return false;
if (pattern.length > MAX_OUTPUT_PATTERN_LENGTH) return false;
if (!ALLOWED_OUTPUT_PATTERN_CHARS.test(pattern)) return false;
return stdout.includes(pattern);
}

// Argv validation: reject null bytes, newlines, and other control chars.
Expand Down Expand Up @@ -145,6 +137,7 @@ function loadFromDisk(): void {

function saveToDisk(): void {
try {
mkdirSync(PERSIST_DIR, { recursive: true });
const active = Array.from(monitors.values()).filter(
m => m.state === 'pending' || m.state === 'running'
);
Expand Down Expand Up @@ -211,12 +204,10 @@ function evaluateResult(
}

case 'output-regex': {
const failureRe = safeCompileRegex(check.failurePattern);
if (failureRe && failureRe.test(stdout)) {
if (outputIncludesPattern(stdout, check.failurePattern)) {
return 'failed';
}
const successRe = safeCompileRegex(check.successPattern);
if (successRe && successRe.test(stdout)) {
if (outputIncludesPattern(stdout, check.successPattern)) {
return 'completed';
}
return 'running';
Expand Down
Loading
Loading