This commit is contained in:
+182
-33
@@ -8,6 +8,7 @@ import type { SearchFilterConfig } from '../../config.js';
|
||||
import type { ContextManager } from '../context-manager.js';
|
||||
import { executeSandboxedBash, isBwrapAvailable, buildSandboxEnv, type SandboxedBashResult } from './sandbox.js';
|
||||
import { looksLikeBinaryBytes, SNIFF_HEAD_BYTES } from './binary-detect.js';
|
||||
import { recordVersion, resolveWriteTarget } from './conflict-guard.js';
|
||||
|
||||
export interface ToolsConfig {
|
||||
searxngUrl?: string; // default: http://searxng:8080
|
||||
@@ -44,8 +45,6 @@ export interface ToolsConfig {
|
||||
speechServerUrl?: string; // 音声認識サーバー URL (例: http://localhost:8000/v1)
|
||||
speechTimeout?: number; // タイムアウト秒 (default: 300)
|
||||
speechLanguage?: string; // デフォルト言語 (default: ja)
|
||||
knowledgeServiceUrl?: string; // DKS サーバー URL (例: http://dks-server:8100)
|
||||
knowledgeNamespaces?: Record<string, { apiKey: string }>; // namespace ごとの API キー
|
||||
officeExcelMaxSizeMb?: number; // ReadExcel の最大ファイルサイズ (default: 10)
|
||||
officeDocxMaxSizeMb?: number; // ReadDocx の最大ファイルサイズ (default: 10)
|
||||
officePdfMaxSizeMb?: number; // ReadPdf の最大ファイルサイズ (default: 10)
|
||||
@@ -58,6 +57,13 @@ export interface ToolsConfig {
|
||||
|
||||
export interface ToolContext {
|
||||
workspacePath: string; // ツール実行のルートディレクトリ
|
||||
/**
|
||||
* 実行ログ root(計画5 / spec §10)。logs/bash-history・raw・checklist 等の
|
||||
* 実行ログ書込先。未指定時は `join(workspacePath, 'logs')` にフォールバック
|
||||
* (後方互換: Phase A では常に undefined なので現行と byte 一致)。
|
||||
* runtime_dir は files jail の外になりうるため resolveAndGuard を経由させず直接書く。
|
||||
*/
|
||||
runtimeDir?: string;
|
||||
editAllowed: boolean; // movement の edit フラグ
|
||||
vlmEnabled?: boolean; // worker の vlm=true → ReadImage で AgentLoop LLM に画像注入可能
|
||||
allowedCommands?: string[]; // Bash ツールで許可するコマンド名一覧 (省略時は DEFAULT_ALLOWED_COMMANDS)
|
||||
@@ -68,6 +74,19 @@ export interface ToolContext {
|
||||
toolsConfig?: ToolsConfig;
|
||||
searchFilter?: SearchFilterConfig; // AppConfig.searchFilter (トップレベル)
|
||||
customPiecesDir?: string | string[];
|
||||
/**
|
||||
* Resolved space folder context (spaces foundation). When set, AGENTS.md /
|
||||
* memory / custom-piece resolution uses `(rootDir, leafId)` instead of the
|
||||
* legacy `(userFolderRoot, userId)`. Absent => legacy personal-folder
|
||||
* resolution (backward compatible; space_id=null tasks behave as before).
|
||||
*/
|
||||
folderContext?: { rootDir: string; leafId: string };
|
||||
/**
|
||||
* 実行ジョブが属するスペース ID(spaces foundation)。MCP/SSH の per-space
|
||||
* 解決に使う予定。null/undefined = global/legacy(全体スコープで解決)。
|
||||
* Phase 1 では値を配線するだけで、まだ誰も参照しない(no-op、後方互換)。
|
||||
*/
|
||||
spaceId?: string;
|
||||
/**
|
||||
* ローカルタスク ID (string)。BrowseWeb / InteractiveBrowse が
|
||||
* per-task noVNC session を使うときのキー。subtask 実行のときは親の
|
||||
@@ -143,34 +162,71 @@ export interface ToolContext {
|
||||
* Plumbed by piece-runner from `piece.name`.
|
||||
*/
|
||||
pieceName?: string;
|
||||
/**
|
||||
* Tool-request mechanism: the current movement's name + its effective
|
||||
* allowed-tool set (shared_tools ∪ allowed_tools ∪ META_TOOLS), so RequestTool
|
||||
* can tell "already available" from "not allowed here", and the passive
|
||||
* blocked-call capture knows which movement to attribute. Plumbed by
|
||||
* piece-runner from the prepared movement.
|
||||
*/
|
||||
movementName?: string;
|
||||
effectiveToolNames?: string[];
|
||||
/**
|
||||
* Full catalog of known tool names (static + meta), injected by the tool
|
||||
* dispatcher only for RequestTool, so it can classify an unknown tool name
|
||||
* (possible hallucination) vs one that exists but isn't allowed here.
|
||||
*/
|
||||
knownToolNames?: string[];
|
||||
/**
|
||||
* Recorder for tool requests (RequestTool active declaration + passive
|
||||
* blocked-call capture). Provided by the worker bound to task/job/space;
|
||||
* piece-runner wraps it to inject piece + movement. Undefined => no-op
|
||||
* (e.g. unit tests, contexts without a repo).
|
||||
*/
|
||||
recordToolRequest?: (req: {
|
||||
toolName: string;
|
||||
reason?: string | null;
|
||||
category: 'requested' | 'blocked' | 'unknown';
|
||||
status?: 'pending' | 'approved' | 'denied' | 'auto_denied';
|
||||
}) => void;
|
||||
/**
|
||||
* Tool-request mechanism: true when this run can pause for inline approval
|
||||
* (a user is reachable — a local task, not a subtask/headless run). When
|
||||
* false, RequestTool records `auto_denied` and the agent proceeds without
|
||||
* the tool (the user finds the request later in the task detail).
|
||||
*/
|
||||
toolApprovalInteractive?: boolean;
|
||||
/**
|
||||
* Set by RequestTool to signal the movement loop to pause (waiting_human)
|
||||
* for inline approval of the named tool. Read + reset by agent-loop.
|
||||
*/
|
||||
pendingToolApproval?: { toolName: string } | null;
|
||||
/** Per-task option: when true, MCP tools are not loaded/dispatched. */
|
||||
mcpDisabled?: boolean;
|
||||
/** Per-task option: when true, skill index is not injected into the system prompt. */
|
||||
skillsDisabled?: boolean;
|
||||
/**
|
||||
* Shared-knowledge notes service. When set, buildSystemPrompt injects a
|
||||
* "## Subscribed Notes" section for inject-mode subscriptions owned by
|
||||
* the job's user. Constructed once in the worker and forwarded here.
|
||||
* Role of the job owner ('admin' | 'user'). Used to gate admin-only tool
|
||||
* actions (e.g. system-scope InstallSkill). Defaults to 'user' when unset
|
||||
* (no-auth mode or unresolved owner).
|
||||
*/
|
||||
notesService?: import('../../notes/notes-service.js').NotesService;
|
||||
userRole?: 'admin' | 'user';
|
||||
/**
|
||||
* Resolved inject config (merged with defaults). Forwarded alongside
|
||||
* notesService so buildSystemPrompt can apply the same budget limits.
|
||||
* Spaces foundation (plan 3): conflict detection for persistent (shared)
|
||||
* workspaces. When `true`, Read/Edit/Write record per-file content versions
|
||||
* in a workspace sidecar ledger, and Write to a file that changed on disk
|
||||
* since the agent last saw it (or was never read) is diverted to a
|
||||
* "(競合コピー N)" copy instead of blind-overwriting. Default `undefined`
|
||||
* (= OFF): Read/Write/Edit behave byte-identically to legacy. Only the
|
||||
* worker sets it `true`, for persistent local-task workspaces.
|
||||
*/
|
||||
notesInjectConfig?: import('../../config.js').NotesInjectConfig;
|
||||
conflictDetection?: boolean;
|
||||
/**
|
||||
* orgIds for the job owner. Required by notesService.listInjectableForConsumer
|
||||
* which calls buildVisibilityWhere. The worker resolves this from
|
||||
* repo.listUserGiteaOrgs at job start. Undefined when auth is disabled
|
||||
* (no-auth mode — treated as empty list).
|
||||
* 競合台帳 dir(計画5 / spec §10.3)。台帳は files ツリーの外に住む:
|
||||
* スペースタスクは `space/{id}/.conflict`、それ以外は `join(workspacePath,'.conflict')`。
|
||||
* 未指定時は後方互換で `join(workspacePath,'.conflict')` にフォールバックする。
|
||||
*/
|
||||
notesUserOrgIds?: string[];
|
||||
/**
|
||||
* Role for the job owner ('admin' | 'user'). Required to construct the
|
||||
* minimal Express.User object passed to buildInjectSection. Defaults to
|
||||
* 'user' when not set.
|
||||
*/
|
||||
notesUserRole?: 'admin' | 'user';
|
||||
conflictDir?: string;
|
||||
}
|
||||
|
||||
export interface MissionBriefValue {
|
||||
@@ -377,10 +433,26 @@ export function truncateToBudget(
|
||||
// --- パスガード ---
|
||||
|
||||
export function resolveAndGuard(workspacePath: string, filePath: string): string {
|
||||
const resolved = path.resolve(workspacePath, filePath);
|
||||
if (!resolved.startsWith(path.resolve(workspacePath) + path.sep) && resolved !== path.resolve(workspacePath)) {
|
||||
const root = path.resolve(workspacePath);
|
||||
const resolved = path.resolve(root, filePath);
|
||||
// 1) 字句的な封じ込めチェック(従来動作)
|
||||
if (!resolved.startsWith(root + path.sep) && resolved !== root) {
|
||||
throw new Error(`Path traversal detected: "${filePath}" is outside workspace`);
|
||||
}
|
||||
// 2) symlink 脱出チェック。ワークスペース root が実在するときだけ行う
|
||||
// (未作成 root を渡すテスト等の挙動を保つため)。
|
||||
if (fs.existsSync(root)) {
|
||||
const realRoot = fs.realpathSync(root);
|
||||
// resolved から上に辿り、実在する最も深い祖先を見つける
|
||||
let probe = resolved;
|
||||
while (!fs.existsSync(probe) && path.dirname(probe) !== probe) {
|
||||
probe = path.dirname(probe);
|
||||
}
|
||||
const realProbe = fs.realpathSync(probe);
|
||||
if (realProbe !== realRoot && !realProbe.startsWith(realRoot + path.sep)) {
|
||||
throw new Error(`Path traversal detected (symlink escape): "${filePath}" resolves outside workspace`);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -794,10 +866,21 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
|
||||
};
|
||||
}
|
||||
|
||||
// 1. ファイルサイズ取得
|
||||
// 1. ファイルサイズ取得 + ディレクトリ拒否
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
totalBytes = fs.statSync(resolved).size;
|
||||
const st = fs.statSync(resolved);
|
||||
// ディレクトリは Read 不可。readFileSync(dir) は EISDIR を投げる。これを
|
||||
// ここで弾かないと、後続の競合検知 recordVersion()(persistent ワークスペース
|
||||
// で ON)が dir を readFileSync して EISDIR を投げ、ジョブごとクラッシュする。
|
||||
// ツールエラー (isError) として返せばエージェントは回復できる。
|
||||
if (st.isDirectory()) {
|
||||
return {
|
||||
output: `Read: "${filePath}" はディレクトリです。ファイルパスを指定してください(一覧は Glob か Bash の \`ls\` を使用)。`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
totalBytes = st.size;
|
||||
} catch (e) {
|
||||
return { output: `Read error: ${(e as Error).message}`, isError: true };
|
||||
}
|
||||
@@ -811,6 +894,14 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
|
||||
};
|
||||
}
|
||||
|
||||
// 競合検知 (persistent workspace): Read はファイルを変更しないので、ここで
|
||||
// 現在のディスク版を「エージェントが見た版」として台帳に記録する。後続 Write は
|
||||
// この版を基準に外部変更を検出する。OFF (既定) のときは完全に no-op。
|
||||
if (ctx.conflictDetection) {
|
||||
const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved);
|
||||
recordVersion(ctx.workspacePath, relPath, conflictDirOf(ctx));
|
||||
}
|
||||
|
||||
const budgetTokens = getToolOutputBudgetTokens(ctx);
|
||||
const budgetBytes = estimateCharsForTokenBudget(budgetTokens);
|
||||
|
||||
@@ -892,6 +983,41 @@ function executeWrite(input: Record<string, unknown>, ctx: ToolContext): ToolRes
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
|
||||
// 競合検知 (persistent workspace): 対象がエージェントの見た版から外部変更
|
||||
// されている / そもそも読まれていない場合は、黙って上書きせず競合コピーへ逃がす。
|
||||
// OFF (既定) のときはこのブロックを完全にスキップし、従来挙動を保つ。
|
||||
if (ctx.conflictDetection) {
|
||||
const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved);
|
||||
const resolution = resolveWriteTarget(ctx.workspacePath, relPath, content, conflictDirOf(ctx));
|
||||
let writeResolved = resolved;
|
||||
if (resolution.conflict) {
|
||||
try {
|
||||
// 競合コピー名を再ガード (念のためルート内であることを保証)。
|
||||
writeResolved = resolveAndGuard(ctx.workspacePath, resolution.targetRelPath);
|
||||
} catch (e) {
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(writeResolved), { recursive: true });
|
||||
fs.writeFileSync(writeResolved, content, 'utf-8');
|
||||
// 書き込んだパスの版を台帳に記録 (このエージェントの最新版)。
|
||||
recordVersion(ctx.workspacePath, resolution.targetRelPath, conflictDirOf(ctx));
|
||||
if (resolution.conflict) {
|
||||
const name = resolution.targetRelPath.split('/').pop() ?? resolution.targetRelPath;
|
||||
return {
|
||||
output:
|
||||
`⚠️ 競合を検知したため「${name}」として保存しました(元ファイルは保持)。` +
|
||||
` Written ${content.length} bytes to ${resolution.targetRelPath}`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
return { output: `Written ${content.length} bytes to ${filePath}`, isError: false };
|
||||
} catch (e) {
|
||||
return { output: `Write error: ${(e as Error).message}`, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
||||
fs.writeFileSync(resolved, content, 'utf-8');
|
||||
@@ -924,21 +1050,43 @@ function executeEdit(input: Record<string, unknown>, ctx: ToolContext): ToolResu
|
||||
}
|
||||
const updated = original.slice(0, idx) + newString + original.slice(idx + oldString.length);
|
||||
fs.writeFileSync(resolved, updated, 'utf-8');
|
||||
// 競合検知: Edit は in-place 書き換えなので、書き込んだ版を台帳に記録するだけ
|
||||
// (Edit は old_string 一致を要求するので、そもそも盲目上書きの問題は起きない)。
|
||||
if (ctx.conflictDetection) {
|
||||
const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved);
|
||||
recordVersion(ctx.workspacePath, relPath, conflictDirOf(ctx));
|
||||
}
|
||||
return { output: `Edited ${filePath}: replaced ${oldString.length} chars`, isError: false };
|
||||
} catch (e) {
|
||||
return { output: `Edit error: ${(e as Error).message}`, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 実行ログ root を解決する(計画5)。ctx.runtimeDir があればそれ、
|
||||
* 無ければ従来の `workspacePath/logs`(後方互換)。runtime_dir は files jail
|
||||
* の外になりうるため resolveAndGuard を経由させず直接書く。
|
||||
*/
|
||||
export function runtimeLogsDir(ctx: ToolContext): string {
|
||||
return ctx.runtimeDir ?? path.join(ctx.workspacePath, 'logs');
|
||||
}
|
||||
|
||||
/**
|
||||
* 競合台帳 dir を解決する(計画5)。ctx.conflictDir があればそれ、無ければ
|
||||
* 後方互換で `workspacePath/.conflict`(files ツリーの外)。
|
||||
*/
|
||||
function conflictDirOf(ctx: ToolContext): string {
|
||||
return ctx.conflictDir ?? path.join(ctx.workspacePath, '.conflict');
|
||||
}
|
||||
|
||||
function logBashHistory(
|
||||
workspacePath: string,
|
||||
logsDir: string,
|
||||
command: string,
|
||||
isError: boolean,
|
||||
durationMs: number,
|
||||
extra?: { blocked?: boolean; outputBytes?: number },
|
||||
): void {
|
||||
try {
|
||||
const logsDir = path.join(workspacePath, 'logs');
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
const entry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -971,6 +1119,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
let timeoutSec = typeof input['timeout'] === 'number' ? input['timeout'] : 30;
|
||||
timeoutSec = Math.min(timeoutSec, 300);
|
||||
const startedAt = Date.now();
|
||||
const logsDir = runtimeLogsDir(ctx);
|
||||
|
||||
const budgetTokens = getToolOutputBudgetTokens(ctx);
|
||||
const capOutput = (raw: string, label: string): string => {
|
||||
@@ -990,7 +1139,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
try {
|
||||
checkBlockedInstallPatterns(command);
|
||||
} catch (e) {
|
||||
logBashHistory(ctx.workspacePath, command, true, Date.now() - startedAt, { blocked: true });
|
||||
logBashHistory(logsDir, command, true, Date.now() - startedAt, { blocked: true });
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
|
||||
@@ -1003,7 +1152,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
try {
|
||||
checkAllowedCommand(command, ctx.allowedCommands ?? DEFAULT_ALLOWED_COMMANDS);
|
||||
} catch (e) {
|
||||
logBashHistory(ctx.workspacePath, command, true, Date.now() - startedAt, { blocked: true });
|
||||
logBashHistory(logsDir, command, true, Date.now() - startedAt, { blocked: true });
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
}
|
||||
@@ -1012,7 +1161,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
ctx.bashAllowNetwork === true,
|
||||
);
|
||||
const out = result.isError ? result.output : capOutput(result.output, 'stdout');
|
||||
logBashHistory(ctx.workspacePath, command, result.isError, Date.now() - startedAt, {
|
||||
logBashHistory(logsDir, command, result.isError, Date.now() - startedAt, {
|
||||
outputBytes: Buffer.byteLength(out, 'utf-8'),
|
||||
});
|
||||
return { output: out, isError: result.isError };
|
||||
@@ -1023,13 +1172,13 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
if (applyWhitelist) checkAllowedCommand(command, ctx.allowedCommands ?? DEFAULT_ALLOWED_COMMANDS);
|
||||
checkBashPathScope(command, ctx.workspacePath);
|
||||
} catch (e) {
|
||||
logBashHistory(ctx.workspacePath, command, true, Date.now() - startedAt, { blocked: true });
|
||||
logBashHistory(logsDir, command, true, Date.now() - startedAt, { blocked: true });
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
if (ctx.abortSignal?.aborted) {
|
||||
logBashHistory(ctx.workspacePath, command, true, Date.now() - startedAt);
|
||||
logBashHistory(logsDir, command, true, Date.now() - startedAt);
|
||||
resolve({ output: 'Cancelled before bash launch', isError: true });
|
||||
return;
|
||||
}
|
||||
@@ -1046,7 +1195,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
(error, stdout, stderr) => {
|
||||
if (!error) {
|
||||
const out = capOutput(stdout, 'stdout');
|
||||
logBashHistory(ctx.workspacePath, command, false, Date.now() - startedAt, {
|
||||
logBashHistory(logsDir, command, false, Date.now() - startedAt, {
|
||||
outputBytes: Buffer.byteLength(out, 'utf-8'),
|
||||
});
|
||||
resolve({ output: out, isError: false });
|
||||
@@ -1067,7 +1216,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
details.push(`Signal: ${execError.signal}`);
|
||||
}
|
||||
const output = [...details, msg].filter(Boolean).join('\n');
|
||||
logBashHistory(ctx.workspacePath, command, true, Date.now() - startedAt, {
|
||||
logBashHistory(logsDir, command, true, Date.now() - startedAt, {
|
||||
outputBytes: Buffer.byteLength(output, 'utf-8'),
|
||||
});
|
||||
resolve({ output, isError: true });
|
||||
|
||||
Reference in New Issue
Block a user