diff --git a/config.yaml.example b/config.yaml.example index 217bdf2..fcd4ffd 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -208,6 +208,10 @@ subtasks: # # always sandbox を強制。bwrap 不在なら起動失敗(本番推奨) # # off bwrap を使わない(env スクラブは維持)。デバッグ用、非推奨 # bash_sandbox: auto +# bash_allow_network: false # true: サンドボックスで host network を許可 +# # (--unshare-net を外す)。pip/npm install/curl 等が +# # 可能になる。⚠ 隔離が弱まり情報漏えい/SSRF のリスク。 +# # 既定 false (ネット遮断)。sandboxed 時のみ有効。 # ─── Search Filter (WebSearch の機密情報漏洩防止) ───────────── # search_filter: diff --git a/src/config-normalize.ts b/src/config-normalize.ts index 5ec66e0..6eeb344 100644 --- a/src/config-normalize.ts +++ b/src/config-normalize.ts @@ -530,15 +530,23 @@ function buildStorage(out: Record): StorageConfig { // sub-form which binds directly to `tools.*`, so keeping that path // alive is the cheapest fix for the v2 read path. if (storage.taskUploadMaxSizeMb !== undefined) { + // Audit 2026-06-08 fix: a user-authored v2 storage block + // (existing.X !== undefined) is authoritative and must override the tools.* + // default — matching the worktreeDir precedence above. The prior + // `toolsObj.X === undefined` guard was a permanent no-op because defaults + // already seeded tools.{taskUploadMaxSizeMb,trashRetentionDays}, so a v2 + // `storage.*` value was silently ignored (same default-precedence trap as + // PR #368→#369). The UI path writes tools.* directly (not storage.*), so + // there existing.X is undefined and tools.X is set → no overwrite. const toolsObj = (out.tools ?? {}) as Record; - if (toolsObj.taskUploadMaxSizeMb === undefined) { + if (existing.taskUploadMaxSizeMb !== undefined || toolsObj.taskUploadMaxSizeMb === undefined) { toolsObj.taskUploadMaxSizeMb = storage.taskUploadMaxSizeMb; out.tools = toolsObj; } } if (storage.trashRetentionDays !== undefined) { const toolsObj = (out.tools ?? {}) as Record; - if (toolsObj.trashRetentionDays === undefined) { + if (existing.trashRetentionDays !== undefined || toolsObj.trashRetentionDays === undefined) { toolsObj.trashRetentionDays = storage.trashRetentionDays; out.tools = toolsObj; } diff --git a/src/config.audit-regression.test.ts b/src/config.audit-regression.test.ts new file mode 100644 index 0000000..16f0f36 --- /dev/null +++ b/src/config.audit-regression.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { loadConfig } from './config.js'; + +// Audit 2026-06-08 (config-consistency, P1×2). Both encode the documented, +// expected behavior that the current normalizer/env-override plumbing violates. +function withTempConfig(yaml: string, fn: (path: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), 'maestro-cfg-audit-')); + try { + const p = join(dir, 'config.yaml'); + writeFileSync(p, yaml); + fn(p); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe('config audit regression (2026-06-08)', () => { + const envSnapshot: Record = {}; + afterEach(() => { + for (const [k, v] of Object.entries(envSnapshot)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + // P1: defaults seed tools.trashRetentionDays=30 BEFORE the normalizer runs, so + // the reverse-backfill (`if (tools.X === undefined)`) is a no-op and a user's + // v2 `storage.trash_retention_days` is silently ignored by the actual consumer + // (server.ts reads config.tools.trashRetentionDays). Same default-precedence + // trap as PR #368→#369. + it('v2 storage.trash_retention_days actually takes effect on the consumed value', () => { + withTempConfig('config_version: 2\nstorage:\n trash_retention_days: 60\n', (p) => { + const config = loadConfig(p); + expect(config.tools.trashRetentionDays).toBe(60); + }); + }); + + // P1: OLLAMA_BASE_URL / OLLAMA_MODEL are applied to `provider.*` BEFORE + // normalize, but for a v2 config with explicit llm.workers the backfill keeps + // the YAML workers and never reads provider — so the documented env override + // silently has no effect on the endpoint that is actually called. + // The runtime executes config.provider.workers (worker.ts:459), so the env + // override must reach THAT array. config.llm.workers is the v2 block that + // config-manager persists, so it must stay the explicit YAML value — otherwise + // saving any setting while OLLAMA_BASE_URL is set would bake the env value into + // the YAML and permanently clobber the user's endpoint (codex P2). + it('OLLAMA_BASE_URL overrides the executed (provider) worker but not the persisted llm block', () => { + envSnapshot['OLLAMA_BASE_URL'] = process.env['OLLAMA_BASE_URL']; + process.env['OLLAMA_BASE_URL'] = 'http://override-host:9999/v1'; + withTempConfig( + 'config_version: 2\nllm:\n workers:\n - name: w1\n endpoint: http://yaml-host:11434/v1\n model: m1\n', + (p) => { + const config = loadConfig(p); + expect(config.provider.workers[0]?.endpoint).toBe('http://override-host:9999/v1'); // runtime + expect(config.llm.workers[0]?.endpoint).toBe('http://yaml-host:11434/v1'); // persisted, untouched + }, + ); + }); + + // The override is scoped to the FIRST/default worker only — a multi-worker + // pool (gateway/title/reflection) must NOT have every endpoint clobbered when + // a Docker .env sets OLLAMA_BASE_URL (codex P1). + it('OLLAMA_BASE_URL leaves additional workers untouched on the runtime array', () => { + envSnapshot['OLLAMA_BASE_URL'] = process.env['OLLAMA_BASE_URL']; + process.env['OLLAMA_BASE_URL'] = 'http://override-host:9999/v1'; + withTempConfig( + 'config_version: 2\nllm:\n workers:\n' + + ' - name: w1\n endpoint: http://yaml-host:11434/v1\n model: m1\n' + + ' - name: w2\n endpoint: http://gateway-host:4000/v1\n model: m2\n', + (p) => { + const config = loadConfig(p); + expect(config.provider.workers[0]?.endpoint).toBe('http://override-host:9999/v1'); + expect(config.provider.workers[1]?.endpoint).toBe('http://gateway-host:4000/v1'); + }, + ); + }); +}); diff --git a/src/config.ts b/src/config.ts index 1ae8dfe..88f62f8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -251,6 +251,17 @@ export interface SafetyConfig { * - 'off': 旧来の素 exec(後方互換・デバッグ用、非推奨) */ bashSandbox?: 'auto' | 'always' | 'off'; + /** + * When true, the bwrap sandbox keeps host network access (the `--unshare-net` + * isolation is dropped) so sandboxed Bash / python / npm can reach the network + * (pip install, npm install, curl, etc.). SECURITY: this weakens isolation — + * sandboxed commands can then exfiltrate data and reach the internal network + * (SSRF / metadata endpoints). The other --unshare-* isolations remain. + * Only meaningful when the sandbox is active (bashSandbox auto/always with bwrap + * present); in 'off' / hardened-whitelist fallback the network is already open. + * Default: false (network isolated). + */ + bashAllowNetwork?: boolean; } export interface SkillsConfig { @@ -791,6 +802,34 @@ export function loadConfig(configPath: string = 'config.yaml'): AppConfig { } } + // Audit 2026-06-08 fix: OLLAMA_BASE_URL / OLLAMA_MODEL must also override the + // FIRST (default) worker's endpoint/model post-normalize. The pre-normalize + // provider.baseUrl override (above) is lost when a v2 config carries an + // explicit `llm.workers` block, because normalizeConfig re-derives the worker + // arrays from it and never consults provider.baseUrl, so a fresh v2 install + // silently ignored the env override. + // + // Apply ONLY to provider.workers[0] — the array worker.ts actually executes — + // and deliberately NOT to llm.workers. Rationale: + // - Scope to workers[0]: config-manager treats only llm.workers[0] as the + // env-overridden worker; overwriting every worker would clobber a + // multi-worker pool (gateway/title/reflection) the moment a Docker .env + // sets OLLAMA_BASE_URL. + // - provider only (not llm): config-manager persists the v2 `llm` block and + // STRIPS the legacy `provider` block on save (V2_STRIPPED_TOP_LEVEL_KEYS). + // Mutating llm.workers here would bake the transient env value into the + // saved YAML on the next Settings save, permanently clobbering the user's + // explicit endpoint (codex P2). Mutating provider keeps the override + // runtime-only — the UI still flags the env override via overriddenByEnv. + if (process.env['OLLAMA_BASE_URL']) { + const ep = process.env['OLLAMA_BASE_URL']; + if (config.provider?.workers?.[0]) config.provider.workers[0].endpoint = ep; + } + if (process.env['OLLAMA_MODEL']) { + const model = process.env['OLLAMA_MODEL']; + if (config.provider?.workers?.[0]) config.provider.workers[0].model = model; + } + const errors = validateConfig(config); for (const err of errors) { logger.warn(`Config validation: ${err}`); diff --git a/src/db/migrate.test.ts b/src/db/migrate.test.ts index 3c00747..d47f2d5 100644 --- a/src/db/migrate.test.ts +++ b/src/db/migrate.test.ts @@ -202,3 +202,30 @@ describe('MCP table migrations', () => { expect(row.owner_id).toBeNull(); }); }); + +// Audit 2026-06-08 (P1, db schema dual-path): user_gitea_orgs is created ONLY +// in Repository.initSchema() — not in schema.sql nor migrate.ts. The task-list +// display SELECT (repository.ts:33,39) references it in a correlated subquery, +// so any DB built via the migration path alone crashes on the most common +// query. This pins the invariant from the rule: +// tables/columns must live in BOTH the fresh path and the migration path. +describe('audit regression: migration path provides display-join tables (2026-06-08)', () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + seedMinimalSchema(db); + }); + + afterEach(() => { + db.close(); + }); + + it('runMigrations creates user_gitea_orgs (referenced by the task-list display SELECT)', () => { + runMigrations(db); + expect(() => + db.prepare('SELECT MIN(org_name) AS n FROM user_gitea_orgs WHERE org_id = ?').get('x'), + ).not.toThrow(); + }); +}); diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 2d279f0..64d1060 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -24,6 +24,22 @@ export function runMigrations(db: Database.Database): void { db.exec("ALTER TABLE local_tasks ADD COLUMN owner_id TEXT REFERENCES users(id)"); } + // Audit 2026-06-08 fix: user_gitea_orgs was created ONLY in + // Repository.initSchema(), so a DB built via the migration path alone lacked + // it and the task-list display SELECT (correlated subquery on org_name in + // repository.ts) crashed with "no such table". Create it here too so the + // fresh path (schema.sql) and the migration path stay in sync. + db.exec(` + CREATE TABLE IF NOT EXISTS user_gitea_orgs ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + org_id TEXT NOT NULL, + org_name TEXT NOT NULL, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, org_id) + ); + CREATE INDEX IF NOT EXISTS idx_user_gitea_orgs_org_id ON user_gitea_orgs(org_id); + `); + // Add context tracking columns to jobs (if not exists) // re-fetch after the owner_id ALTER above to reflect the updated schema const jobsColsAfter = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>; diff --git a/src/db/schema.sql b/src/db/schema.sql index 1c2c410..9d4e4de 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -590,3 +590,15 @@ CREATE TABLE IF NOT EXISTS user_notification_prefs ( v1_migrated INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); + +-- Audit 2026-06-08 fix: user_gitea_orgs (per-user Gitea org cache) was created +-- only in Repository.initSchema(); mirrored here (fresh path) and in migrate.ts +-- (upgrade path) so the task-list display SELECT never hits "no such table". +CREATE TABLE IF NOT EXISTS user_gitea_orgs ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + org_id TEXT NOT NULL, + org_name TEXT NOT NULL, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, org_id) +); +CREATE INDEX IF NOT EXISTS idx_user_gitea_orgs_org_id ON user_gitea_orgs(org_id); diff --git a/src/engine/piece-runner.ts b/src/engine/piece-runner.ts index 6afd777..becba4f 100644 --- a/src/engine/piece-runner.ts +++ b/src/engine/piece-runner.ts @@ -291,7 +291,7 @@ export async function runPiece( spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>; cancelCheck?: () => boolean; abortController?: AbortController; - safetyConfig?: { maxIterations?: number; maxRevisits?: number; bashUnrestricted?: boolean; bashSandbox?: 'auto' | 'always' | 'off' }; + safetyConfig?: { maxIterations?: number; maxRevisits?: number; bashUnrestricted?: boolean; bashSandbox?: 'auto' | 'always' | 'off'; bashAllowNetwork?: boolean }; searchFilter?: SearchFilterConfig; customPiecesDir?: string | string[]; contextManager?: ContextManager; @@ -690,7 +690,7 @@ function prepareMovementContext( /** Role for the job owner. */ notesUserRole?: 'admin' | 'user'; /** Safety config — threaded so prepareMovementContext can propagate bashUnrestricted. */ - safetyConfig?: { bashUnrestricted?: boolean; bashSandbox?: 'auto' | 'always' | 'off' }; + safetyConfig?: { bashUnrestricted?: boolean; bashSandbox?: 'auto' | 'always' | 'off'; bashAllowNetwork?: boolean }; skillCatalog?: import('./skills.js').SkillCatalog; /** Per-task option: when true, MCP tools are not loaded/dispatched. */ mcpDisabled?: boolean; @@ -716,6 +716,7 @@ function prepareMovementContext( allowedCommands: movementDef.allowed_commands, bashUnrestricted: options?.safetyConfig?.bashUnrestricted, bashSandbox: options?.safetyConfig?.bashSandbox, + bashAllowNetwork: options?.safetyConfig?.bashAllowNetwork, skillCatalog: options?.skillCatalog, allowedSshConnections: movementDef.allowed_ssh_connections, pieceName: piece.name, diff --git a/src/engine/tools/core.ts b/src/engine/tools/core.ts index 29246f8..6323430 100644 --- a/src/engine/tools/core.ts +++ b/src/engine/tools/core.ts @@ -62,6 +62,7 @@ export interface ToolContext { allowedCommands?: string[]; // Bash ツールで許可するコマンド名一覧 (省略時は DEFAULT_ALLOWED_COMMANDS) bashUnrestricted?: boolean; // true: skip the command whitelist (bwrap/exec is chosen by bashSandbox, not this) bashSandbox?: 'auto' | 'always' | 'off'; // サンドボックス機構の選択 (config.safety.bashSandbox 由来) + bashAllowNetwork?: boolean; // true: bwrap サンドボックスで host network を許可 (--unshare-net を外す。config.safety.bashAllowNetwork 由来) skillCatalog?: import('../skills.js').SkillCatalog; toolsConfig?: ToolsConfig; searchFilter?: SearchFilterConfig; // AppConfig.searchFilter (トップレベル) @@ -1007,6 +1008,7 @@ async function executeBash(input: Record, ctx: ToolContext): Pr } const result: SandboxedBashResult = await executeSandboxedBash( command, ctx.workspacePath, timeoutSec, BASH_MAX_BUFFER_BYTES, ctx.abortSignal, skillBinds, + ctx.bashAllowNetwork === true, ); const out = result.isError ? result.output : capOutput(result.output, 'stdout'); logBashHistory(ctx.workspacePath, command, result.isError, Date.now() - startedAt, { diff --git a/src/engine/tools/sandbox.test.ts b/src/engine/tools/sandbox.test.ts index 224880e..970ab50 100644 --- a/src/engine/tools/sandbox.test.ts +++ b/src/engine/tools/sandbox.test.ts @@ -245,6 +245,27 @@ describe('buildBwrapArgs sandboxing', () => { expect(i).toBeGreaterThan(-1); expect(args).toContain('HOME'); }); + + it('unshares network by default (allowNetwork omitted)', () => { + const args = buildBwrapArgs('echo hi', '/work/ws'); + expect(args).toContain('--unshare-net'); + }); + + it('keeps host network when allowNetwork=true (drops only --unshare-net)', () => { + const args = buildBwrapArgs('echo hi', '/work/ws', undefined, process.env, true); + expect(args).not.toContain('--unshare-net'); + // all other isolations remain + expect(args).toContain('--unshare-user'); + expect(args).toContain('--unshare-ipc'); + expect(args).toContain('--unshare-pid'); + expect(args).toContain('--unshare-uts'); + expect(args).toContain('--unshare-cgroup'); + }); + + it('unshares network when allowNetwork=false (explicit)', () => { + const args = buildBwrapArgs('echo hi', '/work/ws', undefined, process.env, false); + expect(args).toContain('--unshare-net'); + }); }); describe('checkBwrapAvailable', () => { diff --git a/src/engine/tools/sandbox.ts b/src/engine/tools/sandbox.ts index 6ba42d6..c01f880 100644 --- a/src/engine/tools/sandbox.ts +++ b/src/engine/tools/sandbox.ts @@ -36,6 +36,7 @@ export function buildBwrapArgs( workspacePath: string, extraReadOnlyBinds?: ExtraReadOnlyBind[], parentEnv: NodeJS.ProcessEnv = process.env, + allowNetwork: boolean = false, ): string[] { const args: string[] = []; @@ -70,7 +71,13 @@ export function buildBwrapArgs( args.push('--chdir', workspacePath); args.push('--die-with-parent'); - args.push('--unshare-user', '--unshare-ipc', '--unshare-pid', '--unshare-uts', '--unshare-cgroup', '--unshare-net'); + // Network isolation is the one --unshare-* that's optional. When + // allowNetwork is true (config.safety.bashAllowNetwork) the sandbox keeps host + // network so pip/npm/curl work; all other namespaces stay unshared. + args.push('--unshare-user', '--unshare-ipc', '--unshare-pid', '--unshare-uts', '--unshare-cgroup'); + if (!allowNetwork) { + args.push('--unshare-net'); + } args.push('--clearenv'); const sandboxEnv = buildSandboxEnv(parentEnv, workspacePath); @@ -124,8 +131,9 @@ export async function executeSandboxedBash( maxBuffer: number, abortSignal?: AbortSignal, extraReadOnlyBinds?: ExtraReadOnlyBind[], + allowNetwork: boolean = false, ): Promise { - const args = buildBwrapArgs(command, workspacePath, extraReadOnlyBinds); + const args = buildBwrapArgs(command, workspacePath, extraReadOnlyBinds, process.env, allowNetwork); if (abortSignal?.aborted) { return { output: 'Cancelled before sandbox bash launch', isError: true }; diff --git a/src/metrics/tool-name-allowlist.test.ts b/src/metrics/tool-name-allowlist.test.ts index 2064298..e0a3f43 100644 --- a/src/metrics/tool-name-allowlist.test.ts +++ b/src/metrics/tool-name-allowlist.test.ts @@ -5,6 +5,8 @@ import { describe, it, expect } from 'vitest'; import { Registry } from 'prom-client'; import { normalizeToolNameForMetric, BUILTIN_TOOL_NAMES } from './tool-name-allowlist.js'; import { createWorkerMetrics } from './worker-metrics.js'; +import { TOOL_DEFS as SLIDE_DEFS } from '../engine/tools/slide.js'; +import { TOOL_DEFS as MSLEARN_DEFS } from '../engine/tools/ms-learn.js'; describe('normalizeToolNameForMetric', () => { it('passes built-in tool names through verbatim', () => { @@ -59,3 +61,31 @@ describe('normalizeToolNameForMetric', () => { expect(dump).not.toMatch(/tool_name="mcp__/); }); }); + +// Audit 2026-06-08 (P1, metrics): the allowlist carried GHOST names +// (CreateSlide, MsLearn{Fetch,Read,Search,Summarize}) that no tool emits, while +// the REAL slide (SetTheme/AddSlide/BuildPptx/ResetSlides) and ms-learn +// (Search/Fetch/SearchCache/RefreshMicrosoftLearn[Cache]) tools were missing — +// so every real call collapsed to 'unknown' in metrics. Pin the allowlist to +// the actual TOOL_DEFS so it can't drift again. +describe('metrics allowlist ↔ real tool definitions (audit regression)', () => { + const realNames = [...Object.keys(SLIDE_DEFS), ...Object.keys(MSLEARN_DEFS)]; + + it('every real slide/ms-learn tool normalizes to itself (not "unknown")', () => { + for (const name of realNames) { + expect(normalizeToolNameForMetric(name)).toBe(name); + } + }); + + it('every real slide/ms-learn tool is present in BUILTIN_TOOL_NAMES', () => { + for (const name of realNames) { + expect(BUILTIN_TOOL_NAMES.has(name)).toBe(true); + } + }); + + it('carries no ghost names that no real tool emits', () => { + for (const ghost of ['CreateSlide', 'MsLearnFetch', 'MsLearnRead', 'MsLearnSearch', 'MsLearnSummarize']) { + expect(BUILTIN_TOOL_NAMES.has(ghost)).toBe(false); + } + }); +}); diff --git a/src/metrics/tool-name-allowlist.ts b/src/metrics/tool-name-allowlist.ts index dbbd32b..72165eb 100644 --- a/src/metrics/tool-name-allowlist.ts +++ b/src/metrics/tool-name-allowlist.ts @@ -50,14 +50,14 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray = [ // review.ts 'BatchReviewTextWithLLM', 'MergeReviewedResults', // browser.ts - 'BrowseWeb', + 'BrowseWeb', 'BrowseWithSession', 'InteractiveBrowse', // knowledge.ts 'IngestDocument', 'IngestStatus', 'ListDocuments', 'ListNamespaces', 'SearchKnowledge', // orchestration.ts 'SpawnSubTask', // x.ts - 'XPostDetail', 'XSearch', 'XTimeline', 'XUserPosts', + 'XFetchCardMedia', 'XPostDetail', 'XSearch', 'XTimeline', 'XUserPosts', // maps.ts 'GetDirections', 'ReverseGeocode', 'SearchPlaces', // youtube.ts @@ -75,7 +75,8 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray = [ // mission.ts 'MissionUpdate', // user-folder.ts - 'ListUserAssets', 'ReadUserTemplate', 'RenderUserTemplate', 'RunUserScript', + 'ListUserAssets', 'ReadUserMemory', 'ReadUserTemplate', 'RenderUserTemplate', + 'RunUserScript', 'UpdateUserMemory', 'WriteUserScript', 'WriteUserTemplate', // brainstorm.ts 'Brainstorm', // app-docs.ts @@ -88,10 +89,12 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray = [ 'ReadNote', 'SearchNotes', 'WriteNote', // dashboard.ts 'UpdateDashboardWidget', + // skills.ts + 'InstallSkill', 'ListSkills', 'ReadSkill', // ms-learn.ts (Microsoft Learn search) - 'MsLearnFetch', 'MsLearnRead', 'MsLearnSearch', 'MsLearnSummarize', - // slide.ts - 'CreateSlide', + 'FetchMicrosoftLearn', 'RefreshMicrosoftLearnCache', 'SearchMicrosoftLearn', 'SearchMicrosoftLearnCache', + // slide.ts (pptxgenjs) + 'AddSlide', 'BuildPptx', 'ResetSlides', 'SetTheme', ]; export const BUILTIN_TOOL_NAMES: ReadonlySet = new Set([ diff --git a/ui/src/components/settings/AskSubtasksForm.tsx b/ui/src/components/settings/AskSubtasksForm.tsx index de2a19b..4cd2e9d 100644 --- a/ui/src/components/settings/AskSubtasksForm.tsx +++ b/ui/src/components/settings/AskSubtasksForm.tsx @@ -21,6 +21,12 @@ export function AskSubtasksForm({ config, onChange }: SectionFormProps) { onChange('subtasks.maxDepth', v ? Number(v) : undefined)} /> サブタスクのネスト最大深度 + +
+ Subtasks: Max Per Parent + onChange('subtasks.maxPerParent', v ? Number(v) : undefined)} /> + 1 つの親ジョブが spawn できるサブタスクの最大数。デフォルト: 10 +
); } diff --git a/ui/src/components/settings/ConfigForm.tsx b/ui/src/components/settings/ConfigForm.tsx index 709eeaa..8061874 100644 --- a/ui/src/components/settings/ConfigForm.tsx +++ b/ui/src/components/settings/ConfigForm.tsx @@ -26,6 +26,8 @@ import { ReflectionForm } from './ReflectionForm'; import { McpForm } from './McpForm'; import { SshForm } from './SshForm'; import { GatewayServerForm } from './GatewayServerForm'; +import { NotesForm } from './NotesForm'; +import { PushNotificationsForm } from './PushNotificationsForm'; import { useAuthState } from '../../App'; @@ -185,6 +187,8 @@ function ConfigFormInner({ section }: ConfigFormProps) { case 'context': return ; case 'safety': return ; case 'reflection': return ; + case 'notes': return ; + case 'push-notifications': return ; // ── Tools sub-sections — Step 9 split the legacy grab-bag // ToolsForm into focused per-category forms. Each binds to the diff --git a/ui/src/components/settings/NotesForm.tsx b/ui/src/components/settings/NotesForm.tsx new file mode 100644 index 0000000..d797add --- /dev/null +++ b/ui/src/components/settings/NotesForm.tsx @@ -0,0 +1,48 @@ +import { HelpText } from './HelpText'; +import { FieldLabel, FieldInput } from './formUtils'; +import type { SectionFormProps } from './types'; + +/** + * Shared Knowledge Notes injection budget (`notes.inject.*`). Controls how much + * of a subscribed note is injected into the agent's context per job. + */ +export function NotesForm({ config, onChange }: SectionFormProps) { + const inject = config.notes?.inject ?? {}; + + return ( +
+

Notes Injection

+

+ 購読済みの共有ナレッジノートを、ジョブ実行時にエージェントの context へ注入する際の予算。 +

+ +
+ Per-Note Max (KB) + onChange('notes.inject.perNoteMaxKb', v ? Number(v) : undefined)} /> + 1 ノートあたり注入する最大サイズ。デフォルト: 8 KB +
+ +
+ Total Max (KB) + onChange('notes.inject.totalMaxKb', v ? Number(v) : undefined)} /> + 全ノート合算で注入する最大サイズ。デフォルト: 32 KB +
+ +
+ Over-Budget Strategy + + 合計予算を超えたときの挙動。デフォルト: skip_remaining +
+
+ ); +} diff --git a/ui/src/components/settings/PushNotificationsForm.tsx b/ui/src/components/settings/PushNotificationsForm.tsx new file mode 100644 index 0000000..52711ec --- /dev/null +++ b/ui/src/components/settings/PushNotificationsForm.tsx @@ -0,0 +1,78 @@ +import { HelpText } from './HelpText'; +import { FieldLabel, FieldInput } from './formUtils'; +import type { SectionFormProps } from './types'; + +/** + * Server-side Web Push (V2) config (`notifications.push.*`): VAPID identity + + * delivery tuning. This is the ADMIN counterpart to the per-user browser + * subscription UI in NotificationsForm (which only manages this device's + * subscription, not whether the server feature is enabled at all). + */ +export function PushNotificationsForm({ config, onChange }: SectionFormProps) { + const push = config.notifications?.push ?? {}; + + return ( +
+

Web Push (Server)

+

+ ブラウザ通知 V2(Web Push)のサーバ設定。HTTPS ホスティング必須(iOS は PWA インストールも)。 + 各ユーザーの購読操作は 🔔 Notifications タブで行う。 +

+ +
+ + マスタースイッチ。デフォルト: 無効(オペレーターが明示的に opt-in) +
+ +
+ VAPID Subject + onChange('notifications.push.vapidSubject', v || undefined)} /> + RFC 8292 の VAPID subject。汎用 mailto: より運用 URL を推奨 +
+ +
+ VAPID Current Key Path + onChange('notifications.push.vapidCurrentPath', v || undefined)} /> + 現行 VAPID 鍵ペアファイルのパス(未生成なら mode 0600 で自動生成) +
+ +
+ VAPID History Dir + onChange('notifications.push.vapidHistoryDir', v || undefined)} /> + 失効した VAPID 鍵を退避するディレクトリ +
+ +
+ Payload Max Bytes + onChange('notifications.push.payloadMaxBytes', v ? Number(v) : undefined)} /> + 暗号化前のプッシュペイロード最大バイト数(上限 4096)。デフォルト: 3072 +
+ +
+ Queue Concurrency + onChange('notifications.push.queueConcurrency', v ? Number(v) : undefined)} /> + キューからの同時送信数。デフォルト: 8 +
+ +
+ Per-Send Timeout (ms) + onChange('notifications.push.perSendTimeoutMs', v ? Number(v) : undefined)} /> + 1 送信あたりのタイムアウト(ミリ秒)。デフォルト: 10000 +
+
+ ); +} diff --git a/ui/src/components/settings/SafetyForm.tsx b/ui/src/components/settings/SafetyForm.tsx index b30b88a..9a27c6d 100644 --- a/ui/src/components/settings/SafetyForm.tsx +++ b/ui/src/components/settings/SafetyForm.tsx @@ -38,6 +38,55 @@ export function SafetyForm({ config, onChange }: SectionFormProps) { 送信前に prompt がコンテキスト上限の何割を占めたら自動圧縮するか(0.5〜0.95、デフォルト: 0.8) +

Bash サンドボックス

+ +
+ Bash Sandbox Mode + + Bash ツールのサンドボックス機構。デフォルト: auto +
+ +
+ + bwrap サンドボックス下で任意コマンドを許可する。ワークスペース(rw)とシステムディレクトリ(ro)のみ bind-mount。bwrap の user-namespace 対応が必要。デフォルト: 無効(セキュリティ上、変更は慎重に) +
+ +
+ + + ⚠ セキュリティ警告:{' '} + 通常はサンドボックス内の bash・python・npm はネット遮断(--unshare-net)されます。 + 有効にすると pip / npm install / curl 等が使えるようになりますが、 + サンドボックスの隔離が弱まり、任意のデータ送信(情報漏えい)や内部ネットワーク・メタデータエンドポイントへの到達(SSRF)が可能になります。 + 信頼できる環境でのみ有効化してください。デフォルト: 無効。 + (Bash Sandbox Mode が off や bwrap 不在時は元々ネット遮断が無いため、この設定は sandboxed 時のみ効きます) + +
+

History Summarization

diff --git a/ui/src/components/settings/SettingsSidebar.tsx b/ui/src/components/settings/SettingsSidebar.tsx index f9cff79..28f49ec 100644 --- a/ui/src/components/settings/SettingsSidebar.tsx +++ b/ui/src/components/settings/SettingsSidebar.tsx @@ -34,6 +34,7 @@ const CONFIG_GROUPS = [ { id: 'branding', label: 'Branding' }, { id: 'paths-storage', label: 'Paths & Storage' }, { id: 'execution', label: 'Execution' }, + { id: 'push-notifications', label: 'Web Push (Server)' }, ], }, { @@ -56,6 +57,7 @@ const CONFIG_GROUPS = [ { id: 'context', label: 'Context' }, { id: 'safety', label: 'Safety' }, { id: 'reflection', label: 'Reflection' }, + { id: 'notes', label: 'Notes Injection' }, ], }, { diff --git a/ui/src/lib/urlState.ts b/ui/src/lib/urlState.ts index ade8367..c47eee5 100644 --- a/ui/src/lib/urlState.ts +++ b/ui/src/lib/urlState.ts @@ -12,6 +12,7 @@ const SETTINGS_SECTIONS = [ 'branding', 'paths-storage', 'execution', + 'push-notifications', // LLM group 'llm-workers', 'gateway-server', @@ -21,6 +22,7 @@ const SETTINGS_SECTIONS = [ 'context', 'safety', 'reflection', + 'notes', // Tools group (sub-sections) 'tools-web', 'tools-browser',