sync: update from private repo (dfadcd5f)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-23 06:38:48 +00:00
parent 6a2f2cc736
commit 29ccaf1e92
377 changed files with 31028 additions and 8994 deletions
+182 -33
View File
@@ -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 });