sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ UI (POST /api/local/tasks)
- **Piece** = `pieces/*.yaml`。`movements` 配列で構成。
- 各 **Movement** は `allowed_tools`(LLM に提示するツール)、`edit`(Write/Edit 可否)、`rules`(遷移条件)を持つ。`allowed_tools` 外のツールは LLM から見えない。
- **遷移**: 中間ホップは `transition`(`rules[].next` に列挙した宛先のみ選択可)、終了は `complete`。`complete.result` がユーザーに見える唯一の最終出力。
- **`default_next`** はエンジン内部の sentinel(コンテキスト溢れ時の強制遷移、ASK 上限時のフォールバック)。
- **`default_next`** はエンジン内部の sentinel(コンテキスト溢れ時の強制遷移、サブタスク待ちからの再開先)。ASK 上限では経由しない——同じ movement へ戻して自己判断を促す。
- **Progressive pressure**: 同一 movement への連続訪問が増えると警告を注入し、閾値超過で ABORT。
## ツールランタイム
+1 -1
View File
@@ -31,7 +31,7 @@ UI (POST /api/local/tasks)
- **Piece** = `pieces/*.yaml`. Composed of a `movements` array.
- Each **Movement** has `allowed_tools` (the tools presented to the LLM), `edit` (whether Write/Edit is allowed), and `rules` (transition conditions). Tools outside `allowed_tools` are invisible to the LLM.
- **Transitions**: an intermediate hop uses `transition` (only the destinations listed in `rules[].next` are selectable); termination uses `complete`. `complete.result` is the only final output visible to the user.
- **`default_next`** is an engine-internal sentinel (forced transition on context overflow, fallback at the ASK limit).
- **`default_next`** is an engine-internal sentinel (forced transition on context overflow, resume target after a subtask wait). It is no longer used at the ASK limit — the run returns to the same movement and the agent is told to decide for itself.
- **Progressive pressure**: as consecutive revisits to the same movement increase, warnings are injected, and exceeding the threshold triggers ABORT.
## Tool runtime
@@ -0,0 +1,205 @@
# Auto Prompter / Hook EU AI Act透明性対応 設計付録
作成日: 2026-07-27
対象: MAESTRO(gitea-agent-orchestrator)
位置づけ: Auto Prompter、汎用Hook、監査、承認機構に対するコンプライアンス設計付録
> 本資料は技術設計上の整理であり、法的助言ではない。実際の適用範囲、保持期間、表示義務は、提供地域、契約上の役割、intended purpose、利用分野、個人データ処理、適用される分野別法令を法務担当者が確認する。
## 1. 結論
EU AI Act対応を単一の `compliant=true` フラグや自然言語プロンプトで保証しない。次の三層へ分離する。
1. 規則・公式ガイドラインから確認できる義務
2. 高リスクAIへ適用され得る追加統制
3. MAESTROが自主的に採用する強化策
Auto PrompterとHookは、法令適用の最終判定者ではなく、構造化された証跡、人間承認、通知、停止、監査exportを実現する技術基盤として扱う。
現行実装が提供するのは透明性・追跡可能性を**支援する技術的統制**であり、法的準拠の認定ではない。Article 50だけを根拠としてhidden chain-of-thoughtの保存や一律5年保持を要求しない。適用条文、事業者の役割、対象システム、分野別法令は法務確認の対象とし、保持期間を選択する設定は将来のCompliance Phaseで実装する。
## 2. 公式資料で確認できた法的基準
2026年7月27日時点で参照した一次情報・欧州委員会公式資料:
- [Regulation (EU) 2024/1689](https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng)
- [Article 50 transparency guidelines announcement](https://digital-strategy.ec.europa.eu/en/news/commission-publishes-guidelines-transparency-obligations-providers-and-deployers-certain-ai-systems)
- [Quick Facts: Transparency rules for AI systems](https://digital-strategy.ec.europa.eu/en/factpages/quick-facts-transparency-rules-ai-systems)
- [AI Act Service Desk FAQ](https://ai-act-service-desk.ec.europa.eu/en/faq)
確認できた内容:
- Article 50は、AIとの直接対話の通知、AI生成・操作コンテンツの機械可読マーキング、deepfake、一定の公益情報テキスト等を対象とする。
- Article 50の透明性義務は原則2026年8月2日から適用される。
- 高リスクAIのautomatic logging、deployerが出力を解釈するための透明性、人間監督は主にArticles 12、13、14で扱われる。
- Article 14は、監督者が出力を無視・上書きし、必要に応じて安全に停止できる設計を求める。
- Article 19のprovider管理下のautomatic logsは、原則としてintended purposeに適切な期間かつ最低6か月保持する。分野別EU法・国内法・個人データ保護法が別期間を定める場合がある。
- AI agentは独立した法的分類名ではなく、AI system、高リスクAI、GPAI等の定義とintended purposeに基づいて評価される。
確認できなかった主張:
- 2026年7月26日にArticle 50を「すべての高リスク分野」へ拡大したという欧州委員会の公式発表
- すべての判断についてhidden chain-of-thoughtを保存する義務
- すべての判断ログを一律5年間保持する義務
- すべてのコード・文章へ同一の機械可読metadataを直接埋め込む義務
- 2027年上半期を一律の「完全施行日」とする整理
これらは法的義務として断定せず、将来の規則改正、ガイドライン、harmonised standards、分野別法令を継続確認する。
## 3. 適用範囲の判定
ワークスペースへ `ComplianceProfile` を設定できる構成を将来追加する。
```ts
interface ComplianceProfile {
region: string[];
intendedPurpose: string;
operatorRole: "provider" | "deployer" | "importer" | "distributor" | "unknown";
aiActScope: "article_50" | "high_risk_candidate" | "not_assessed" | "out_of_scope";
sector: string | null;
humanReviewRequired: boolean;
retentionPolicyId: string;
provenancePolicyId: string;
}
```
- LLMによる意味分類だけで高リスクと確定しない。
- 管理権限を持つ利用者がintended purposeと利用分野を登録する。
- `high_risk_candidate` は法務・リスク担当者の確認待ちを表し、法的分類の確定を意味しない。
- 未評価の場合は「EU AI Act準拠」と表示しない。
## 4. 構造化Decision Record
hidden reasoningやモデル内部のchain-of-thoughtを保存しない。代わりに、人間が判断を再構成できる最小限の構造化証跡を保存する。
```json
{
"schemaVersion": 1,
"decisionId": "...",
"taskId": "...",
"jobId": "...",
"policySnapshotId": "...",
"hookPoint": "tool.before",
"rule": {"id": "...", "version": 3},
"decisionMethod": "deterministic",
"result": "require_approval",
"reasonCode": "external_secret_risk",
"reasonSummary": "外部送信候補に秘密値形式が含まれたため確認を要求",
"evidenceRefs": ["tool-call:...", "source:..."],
"tool": {"name": "WebSearch", "category": "external_communication"},
"approvalRequestId": "...",
"executed": false,
"model": {"provider": "...", "modelId": "..."},
"createdAt": "ISO-8601"
}
```
保存する:
- rule ID/version、Policy Snapshot ID
- 判定方法、三値結果、固定reason code
- 利用者向けの短いreason summary
- 安全に要約したtool名・引数・結果
- 承認・拒否・実行有無
- 参照資料と成果物へのID
- モデル、時刻、相関ID
- コード変更理由、変更ファイル、検証結果、影響範囲
原則保存しない:
- hidden chain-of-thought、reasoning token全文
- 会話全文、prompt全文、Webページ全文
- API key、token、cookie、Authorization header
- 生のtool引数や環境変数
- 人格評価や「悪意ある利用者」等のラベル
## 5. 透明性通知
- チャット画面でAI systemとの対話であることを認識可能にする。
- Auto Prompterによるredirect、deny、approval、stopはチャット履歴へ表示する。
- 通知にはrule名、対象操作、安全な理由要約、実行されたactionを含める。
- AI生成成果物には、タスク結果画面または成果物metadataで生成主体とreview状態を示す。
- 通常のソースコードへ一律のコメントを強制挿入しない。署名、format、build、diff、既存ライセンスを壊すためである。
## 6. 生成物のProvenance
成果物本体ではなく、DBまたはsidecar manifestへ来歴を保存する。
```json
{
"schemaVersion": 1,
"artifactId": "...",
"generatedBy": "ai-agent",
"generatedAt": "ISO-8601",
"reviewStatus": "pending_human_review",
"taskId": "...",
"decisionRecordIds": ["..."],
"contentDigest": "sha256:..."
}
```
Article 50上の機械可読マーキングが特定の出力へ必要かは、content種別、provider/deployerの役割、assistive editing例外、公開方法を別途判定する。内部provenance metadataの存在だけでArticle 50適合を宣言しない。
## 7. Human Oversight
高リスク候補profileでは、次を利用可能にする。
- 専門家または指定roleによる事前承認
- fingerprintで固定された操作だけのone-shot承認
- 承認後に対象が変化した場合の再承認
- 出力の無視、上書き、差戻し
- `policy_stopped`による再試行なしの安全停止
- automation biasを避ける表示
- 人間reviewerとreview結果の証跡
「医療」「金融」等の単語だけで自動的に法的高リスクと断定しない。決定的なCompliance Profileと管理者設定を優先し、自然言語判定は候補提示に限定する。
## 8. 保持・完全性・アクセス
- 保持期間は一律値ではなく `RetentionPolicy` としてregion、operator role、system分類、分野別法令、契約、個人データ処理目的ごとに設定する。
- 高リスクproviderのautomatic logsではArticle 19の最低6か月を法務確認の初期基準とする。
- 5年保持は組織または分野別要件として設定可能にするが、EU AI Act共通義務とは表示しない。
- data minimisation、purpose limitation、アクセス制御、export権限、legal hold、削除手順を定義する。
- 改ざん検知にはhash chain、署名付きmanifest、外部WORM adapterを選択可能にする。
- WORMを全workspaceへ強制せず、Compliance Profileで必要な場合だけ有効化する。
- 利用者向けexportと規制当局向けexportを分け、秘密値と第三者データを再マスクする。
## 9. 定期監視と報告
集計対象:
- rule発火、誤検知、見逃し
- approval、deny、stop、override
- Judge timeout、unknown、schema不正
- 人間review完了率
- provenance欠落
- source verification状況
- audit chain整合性
- retention削除・legal hold
透明性報告書の公開頻度は法令・契約・組織方針で決める。EU AI Actの共通要件として一律「年1回公開」とは断定しない。
## 10. 実装Phaseへの追加
```text
Phase 0
Decision Record schema / masking / Policy Snapshot / OFF互換
Phase 1
AI対話表示 / provenance metadata / audit export基盤
Phase 2
決定的Policy / reason code / stop・override証跡
Phase 3
human approval / reviewer identity / one-shot CAS証跡
Phase 4
Complete前確認 / review status / Decision Recordとの対応
Compliance Phase
Compliance Profile / retention policy / integrity adapter
high-risk candidate workflow / transparency report / authority export
```
Compliance Phaseは自然言語Judgeの一般公開とは分離する。法的適用範囲や保存期間をJudgeに決めさせない。
File diff suppressed because it is too large Load Diff
+636
View File
@@ -0,0 +1,636 @@
# Auto Prompter ワークスペース機能 設計方針
作成日: 2026-07-24
対象: MAESTRO(gitea-agent-orchestrator)
位置づけ: `auto-prompter-hook-feasibility-study.md` に対する製品方針の追補
## 1. 結論
Auto Prompterは管理者専用機能に限定せず、ワークスペースを利用する一般ユーザーも利用できる機能として設計する。
初期実装ではルールの適用範囲をワークスペース単位に限定する。ワークスペース編集権限を持つユーザーが設定し、利用権限だけを持つメンバーには、有効なルールと適用結果を表示する。
一般ユーザー向け機能として提供する方針と、安全な段階公開は両立させる。初期提供はテンプレートと決定的条件に限定する。常時適用ルールはstatic preamble合成経路の実装後、自由記述の自然言語条件はshadow評価の品質基準を満たした後に有効化する。
EU AI Act対応を有効にする場合も、Auto Prompter単体で適合を自己宣言しない。適用範囲、Decision Record、human oversight、provenance、保持・完全性は [EU AI Act透明性対応 設計付録](./auto-prompter-eu-ai-act-transparency-design.md) に従い、Compliance Profileとして段階実装する。
内部実装は汎用Hook基盤の上に構築するが、UIでは「Hook」を主語にせず、「Auto Prompter」または「自動ルール」と表示する。
## 2. 設定の配置 (例)
```text
ワークスペース設定
├─ AGENTS
├─ Memory
├─ MCP
├─ ツール権限
└─ Auto Prompter
```
AGENTSやMemoryと同様に、「このワークスペースでエージェントにどう振る舞わせるか」を定める設定として扱う。
設定画面には、次の注意を明示する。
> このルールは、このワークスペースを利用するすべてのメンバーに適用されます。
個人設定、組織設定、Movement設定との多階層な優先順位は初期実装へ含めない。
Auto Prompterには次の2段階のオン・オフを設ける。
- ワークスペース全体のマスタースイッチ
- ルールごとの有効・無効スイッチ
マスタースイッチをオフにした場合、個別ルールの設定値は保持したまま、常時適用ルールと条件付きルールの両方を停止する。監査上は設定変更の事実だけを記録し、無効化中のルール判定や判定モデル呼び出しは行わない。
## 3. 想定ユースケース
### インフラ管理
- コマンドを提示する場合は公式資料を確認する
- 削除や設定変更の前に影響範囲を確認する
- 危険な操作はユーザー承認を求める
### プログラミング学習
- 初心者向けに説明する
- 専門用語には短い補足を付ける
- 完成コードだけでなく考え方も説明する
### 調査
- 日本語で回答する
- 一次情報を優先する
- 推測と確認済み情報を分ける
## 4. 設定UI
設定項目は次の構造を維持する。
```text
ルール名
いつ
何に対して
どのような条件で
何をする
追加する指示
```
「何に対して」は、選択したタイミングで対象が必要な場合だけ表示する。
### 4.1 いつ
初期実装:
- 常に
- ツールを実行する直前
- 作業を完了する直前
将来候補:
- エージェントが動作を開始する前
- LLMを呼び出す前
- ツールを実行した後
- Movementを開始する前
- Movementを完了する前
### 4.2 何に対して
一般設定ではカテゴリ単位で選択する。
- すべてのツール
- コマンド実行
- インターネット検索
- ファイル読み取り
- ファイル変更
- 外部通信
- ブラウザ操作
- SSHや外部マシン操作
初期リリースでAPIとUIが受け付けるカテゴリは、`command_execution`、`internet_search`、`file_read`、`file_write`、`external_communication`、`browser`、`ssh` に限定する。`git` と `data_deletion` は将来の引数・構文分類に備えてPolicy型には保持するが、初期UIには表示せず、APIは保存を拒否する。既存データ等から未対応カテゴリを含むsnapshotが実行時へ到達した場合も、黙って非一致にせずfail-closedとする。
初期分類ではBash系ツールを `command_execution`、MCPツールを `external_communication` とする。Git操作や削除操作を厳密に区別するには、ツール名だけでなくコマンド構文・引数の決定的な解析が必要なため、その分類器を実装してから `git` と `data_deletion` を公開する。
個別ツール指定は詳細設定に置く。
### 4.3 どのような条件で
将来フェーズでは自然言語条件を入力できるようにする。ただし、すべてを小型LLMへ送らない。初期実装では新規入力・編集を提供せず、決定的テンプレートに変換できる条件だけを扱う。
次は決定的に判定する。
- 対象ツールとカテゴリ
- PieceとMovement
- ユーザー権限と承認状態
- 発火回数
- 外部通信の有無
- 既知の危険操作
- ファイル削除や権限変更
- 言語比率
意味判断が必要な候補だけ、小型LLMが登録済みルールIDを三値判定する。
```text
match
no_match
unknown
```
将来、小型LLMを導入する初期段階ではshadow modeとし、発火結果と遅延を測定するだけで制御actionへ使用しない。自然言語条件を使う本番制御は、precision、P95遅延、Judge対象率、承認疲れの基準を満たした後に段階公開する。
初期UIでは自然言語条件の新規作成・編集を提供せず、既存データに含まれる条件付きルールは読み取り専用で表示する。小型LLMによるshadow評価と自然言語条件の編集UIは将来フェーズで追加する。初期提供では決定的テンプレートに変換できる組合せだけ制御actionを有効にし、停止、管理者通知は表示しない。
判定モデルは追加指示、拒否理由、停止理由を自由生成しない。
### 4.4 何をする
- エージェントに登録済みの追加指示を与える
- 今回のツール実行を中止する
- 既存のユーザー承認を求める
- タスク全体を停止する
- 監査ログへ記録する
- 管理者へ通知する
追加指示には既存のmessage injection方式を再利用するが、DBコメント取得処理を直接呼ばない。専用の `InstructionInjector` が同一iterationの全tool resultを記録した後に、統合済みの登録指示をuser messageとして1件だけ追加する。ツール実行前に追加指示を与えた場合、元のbatchは実行せず、全tool callへ合成tool resultを作ってからエージェントへ戻す。
承認UIの配置と `waiting_human` の状態遷移パターンは既存機能を利用する。Policy承認には専用request、専用waitReason、専用CAS decide/resume処理を追加し、既存tool-requestの再開関数へ直接流さない。
Policy承認は既存のtask+tool名権限付与とは別recordへ保存し、task、Piece、Movement、rule ID/version、tool名、正規化引数をfingerprintで固定する。「今回だけ」はrequest IDとnonceを持ち、tool実行直前に `approved → consumed` をCAS更新できた1回だけ許可する。引数が変更された場合や実行失敗後の再試行には、新しい承認を要求する。
### 4.5 Hook pointごとの許可action
| Hook point | 初期実装で許可するaction |
|---|---|
| `always` | 初期実装では設定APIが拒否する。static preamble合成を実装した将来フェーズで `inject_instruction` を許可 |
| `tool.before` | `audit_only`、`inject_instruction`、`deny_current_tool`、`request_approval` |
| `completion.before_accept` | `audit_only`、`inject_instruction`、`deny_current_tool` |
| `tool.after` / `tool.denied` / `runtime.error` | 観測専用。利用者が制御actionを設定しない |
`stop_task` は初期実装の設定APIが拒否する。専用のタスク終端設計を追加した将来フェーズで再検討する。UIのdisabled表示だけに依存せず、設定APIのschema validationでも未対応の組合せを拒否する。初期 `completion.before_accept` では承認待ちとstopを扱わず、必要になった場合は専用のpark/ABORT設計を追加してから許可表を拡張する。
## 5. 常時適用ルールと条件付きルール
以下は将来フェーズの設計である。初期実装はstatic preamble合成経路を持たないため、`always` の新規作成・更新を設定APIで拒否し、保存成功後のsilent no-opを防ぐ。既存データに残る `always` はUIで読み取り専用として表示する。
```text
常時適用ルール
└─ 判定モデルを呼ばず、Job開始時にstatic preambleへ一度だけ合成
条件付きルール
└─ 対象Hookで状態を判定し、成立時だけ登録済みactionを実行
```
「常に日本語で回答する」「常に初心者向けに説明する」などは常時適用ルールとする。将来フェーズでは設定UIを条件付きルールと共通化し、「いつ」で `常に` を選択できるようにする。
Jobのclaim・workspace準備時に有効ルール、rule version、発火上限、常時指示、Judge設定をsnapshotし、Job内では不変とする。常時指示はAGENTSやMemoryと同じ静的preamble側へ安定した順序で合成し、毎iteration、毎Movement、途中user messageとして重複追加しない。設定変更は次のJobから反映する。
AGENTSとの機能重複は残るが、利用者が一つの画面から振る舞いルールを管理できる利点がある。セキュリティ境界、ツール権限、承認要否は自然言語指示へ移さない。
## 6. テンプレート
次のテンプレートを初期提供する。
- 初心者向けに説明する
- 日本語で回答する
- 難しい言葉を避ける
- コマンドには補足を付ける
- 危険なコマンドを確認する
- 外部送信前に承認を求める
- 公式資料を確認する
- Movement外の操作を防ぐ
- 削除操作を監査ログへ記録する
テンプレート選択時には、「いつ」「対象」「条件」「action」「追加指示」「発火上限」「判定方法」の編集可能な初期値を設定する。テンプレートは設定項目を隠すために使わない。
## 7. 権限
### 一般のワークスペース編集者が設定可能
- 追加指示
- 日本語の維持
- 初心者向け説明
- 難しい言葉の回避
- ユーザー承認要求
- 通常の監査記録
### 追加権限が必要
- タスクの強制停止
- 管理者通知
- 監査ログの保存範囲変更
- ワークスペース全体の強制セキュリティルール変更
- 他ユーザーに不利益を与え得る制限
権限やsandboxの本体はHookへ移行せず、独立した既存機能として維持する。Hookはそれらを共通サービスとして呼び出す。
## 8. 透明性
- 全メンバーがAuto Prompter全体のオン・オフ状態を確認できるようにする
- 全メンバーが有効なルール一覧を確認できるようにする
- 無効なルールは一覧から消さず、オフ状態として表示する
- 追加指示、拒否、承認、停止など、行動を変える発火は通知する
- 常時適用ルールは毎回通知せず、設定画面またはセッション開始時に確認可能とする
- 通知にはルール名、対象操作、実施actionを示す
- 判定結果を「不正利用者」などの人格評価へ変換しない
表示例:
> 自動ルール「削除前の確認」が適用されました。今回の操作は実行せず、影響範囲を確認するようエージェントへ指示しました。
## 9. 初期実装の推奨範囲
初期実装は、実現可能性調査のPhase 0~4を順に検証した最初のGA範囲を指す。各Phaseはfeature flagで分離し、未到達Phaseの設定はUIで非表示または無効化する。
### 含める
- ワークスペースごとのルール保存
- ワークスペース編集権限によるCRUD
- ワークスペース全体のマスタースイッチ
- ルールごとの有効・無効の切り替え
- ルール名
- 「いつ」「何に対して」「条件」「action」「追加指示」
- 常時適用ルール
- テンプレート
- ツール実行前Hook
- Complete受理前の最小Hook
- 決定的判定による候補絞り込み
- 小型LLMによる三値判定のshadow評価
- 既存ユーザー承認との接続
- 発火回数と重複発火の制限
- 基本監査ログ
- 発火時のユーザー通知
### 後回し
- 自由記述の自然言語条件による拒否、停止、承認の本番制御
- ユーザー個人による上書き
- 組織、ワークスペース、ユーザーの多階層ルール
- Movement遷移の制御
- 生成ストリーム途中の判定
- Hook同士の複雑な依存関係
- 一般ユーザーによる管理者通知設定
- 自動的な利用者制限
- 大規模なプラグイン基盤
## 10. リスクと対策
### 共有ワークスペースでの好みの衝突
「詳しく説明」と「簡潔に説明」などが競合し得る。初期実装では個人上書きを導入せず、全員へ適用されることを明示し、ルール変更履歴を残す。競合が運用上問題になる場合にだけ階層化を検討する。
### 自然言語条件の不安定性
曖昧な条件、モデル変更、プロンプトインジェクションにより結果が変化する。決定的フィルタを先行させ、モデルには候補IDと三値判定だけを許可する。Web本文やツール引数は非信頼データとして区切る。
### 動作の予測困難化
適用ルールと発火通知を可視化し、監査イベントにrule ID、rule version、判定方法、actionを残す。
### 承認疲れ
発火上限、同一fingerprint抑止、クールダウン、拒否後の再申請制限を設ける。安全上決定的に拒否すべき操作を承認へ流さない。
### 監査イベントの配信・重複排除契約
policy監査のJSONL保存は、決定的な eventId を付けた **best-effort append** とする。exactly-onceおよびat-least-onceは保証・表明しない。再試行や複数runtimeにより同じ eventId の行が複数保存される場合がある。markerファイルや別のdedupeディレクトリは作成せず、既存markerが残っていても参照しない。
ライブ通知は、永続appendに成功したイベントだけを候補とする。JobEventBus がjob単位のbounded first-seen FIFO(初期上限1,024 ID)でfirst-wins重複排除を行い、jobの done で状態を破棄する。append失敗はseenへ登録しないため、後続の成功した再試行を通知できる。REST読取はJSONL内の同一 eventId をfirst-winsで1件へまとめる。同一IDでpayloadが異なる場合も先頭を正本として扱い、将来必要なら機密情報を含まない警告だけを追加する。
### 監査ログのプライバシー
会話全文や生のツール引数を原則保存せず、allowlist形式の要約、マスキング、保持期限、アクセス制御を適用する。
### Hook基盤の複雑化
観測は既存EventLoggerを用いる非同期・fail-open、制御は明示的な同期PolicyGateとして分離する。既存の権限、承認、timeout、retry、Movement遷移をHook内部へ全面移行しない。
## 11. 敵対的レビュー
一般ユーザーへ公開すると、曖昧な自然言語条件、共有メンバー間の好みの衝突、過剰な承認要求、挙動の再現性低下、問い合わせ増加が起きる可能性がある。
特に「常に日本語」「初心者向け」はAGENTSへ固定記述すれば済むため、Auto Prompterへ統合することが必ずしも技術的に優れているわけではない。また、危険操作は自然言語判定より、ツールカテゴリと引数に対する決定的な検査の方が安全である。
したがって一般ユーザー公開を正当化できるのは、次を守る場合に限る。
- 初期提供テンプレートを中心にする
- 常時ルールはJudgeを使わない
- 自然言語条件は安全境界として扱わない
- 既存承認と権限を置き換えない
- 適用範囲と発火結果を可視化する
- PoCで誤検知、見逃し、遅延、問い合わせ量を測る
PoCで自然言語条件のprecisionが低い、P95遅延が許容範囲を超える、承認疲れが顕著、または設定競合の問い合わせが多い場合は、自然言語条件を縮小し、「常時ルール+定型条件+既存承認拡張」へ戻すべきである。
## 12. 最終判断
採用する方針:
- Auto Prompterをワークスペース設定として一般ユーザーへ提供する
- 初期適用範囲はワークスペース共通に限定する
- ワークスペース全体とルール単位の2段階でオン・オフを切り替えられるようにする
- 設定は「いつ」「何に対して」「どのような条件で」「何をする」を維持する
- テンプレートと初期値で設定を支援する
- 常時適用ルールと条件付きルールを内部的に分離する
- 制御はツール実行前Hook、最終確認はComplete前Hookへ限定する
- 承認機構は独立サービスとして維持し、Hookから再利用する
- 観測用Hookと制御用Hookを分離する
延期する方針:
- 個人設定による上書き
- 多階層ルールの優先順位
- Movement遷移制御
- ストリーム途中の介入
- 自動利用者制限
- すべての既存処理のHook化
この方針は、一般ユーザーが求める説明スタイルや言語設定を扱いつつ、既存の承認・権限・Piece/Movementを最大限維持する。Hookは共通基盤、Auto Prompterはその利用機能の一つとして分離する。
## 13. 既存チャット画面への変更
### 13.1 現在の表示構造
現在のチャット画面は次の経路で構成されている。
```text
ChatPane
├─ ChatMessageFeed
│ ├─ ChatMessage
│ ├─ MovementGroupExpanded
│ └─ ChatMessageFeedPanel
└─ ChatComposer
└─ ChatComposerPanel
├─ ToolRequestApproval
└─ PackageRequestApproval
```
実際に使用されているメッセージ一覧は `ChatMessageFeed` である。かつて類似する旧構成が `ChatTimeline.tsx` に残っていたが、`ChatPane` からは利用されておらず、2026-09-07 に削除した(issue #1483)。
### 13.2 流用できるUI
| 用途 | 既存UI | 判断 | 利用方法 |
|---|---|---|---|
| ユーザー承認 | `ToolRequestApproval.tsx` | 構造と配置を流用可能 | チャット入力欄の直上に承認カードを表示し、Approve/Deny、mutation中の無効化、失敗表示、query更新を踏襲する |
| パッケージ承認 | `PackageRequestApproval.tsx` | 設計パターンを流用可能 | 汎用承認カード抽出時の第2の既存利用例とする |
| 入力ロック | `ChatComposer.tsx` / `ChatComposerPanel.tsx` | 流用可能 | `waiting_human` と `waitReason` にAuto Prompter承認待ちを追加し、承認中の再送信を防ぐ |
| 一時通知 | `useToast.ts` / `ToastHost.tsx` / `ToastCard.tsx` | そのまま流用可能 | 画面外のタスクで発火した場合や、軽微な適用完了を通知する。同一event IDで重複を抑止できる |
| 永続通知 | `NotificationBell.tsx` / `NotificationCenterPage.tsx` | 限定的に流用可能 | 承認待ち、強制停止、管理者通知など、後から確認すべきイベントだけを通知Inboxへ送る |
| 監査タイムライン | `ActivityTimeline.tsx` / `ActivityEventCard.tsx` | 表示基盤を流用可能 | policy用event kind、色、タイトル、要約を追加する。生の条件文やtool引数は表示しない |
| 軽量な進行表示 | `ChatMessage.tsx` の `ProgressPill` | 見た目だけ流用可能 | 「ルールを確認中」「追加指示を適用」の短い状態表示に使える |
| 再試行・Movement履歴 | `MovementGroup.tsx` | 原則変更しない | Auto PrompterイベントをMovement完了イベントとして扱わない |
### 13.3 新規作成が必要なUI
#### PolicyActionNotice
Auto Prompterの発火結果をチャット履歴へ永続表示する専用コンポーネントは現状存在しないため、新規作成する。
表示項目:
- ルール名
- 実行したaction
- 対象ツールまたは対象処理の安全な要約
- 「実行しなかった」「追加指示を与えた」「承認待ち」などの結果
- 発火日時
- 必要な場合だけ詳細表示
通常のエージェント発言と誤認しないよう、左右の吹き出しではなく中央のsystem noticeとして表示する。情報、警告、拒否、承認待ちで視覚状態を分けるが、色だけに依存せずラベルとアイコンを付ける。
候補ファイル:
- `ui/src/components/chat/PolicyActionNotice.tsx`
- `ui/src/components/chat/PolicyActionNotice.test.tsx`
#### PolicyApprovalCard
既存の `ToolRequestApproval` は、`ToolRequest` API、tool名、Movement、task単位のgrantへ直接結合している。このままAuto Prompter承認へ流用すると条件分岐が増えるため、Auto Prompter用カードは新規作成するか、共通の表示部品を抽出する。
推奨は次の分離である。
```text
ApprovalRequestCard(新規・表示専用)
├─ ToolRequestApproval(既存API adapter)
├─ PackageRequestApproval(既存API adapter)
└─ PolicyApprovalCard(新規API adapter)
```
`ApprovalRequestCard` はタイトル、理由、安全な操作要約、scope、Approve/Deny、pending/error状態だけを受け取る。API取得、mutation、権限判定、job再開は各adapterに残す。
候補ファイル:
- `ui/src/components/chat/ApprovalRequestCard.tsx`
- `ui/src/components/chat/PolicyApprovalCard.tsx`
- 各コンポーネントのテスト
### 13.4 チャットデータの持たせ方
Auto Prompter発火通知は一時的なtoastだけにしない。再読み込み後も「なぜ操作が止まったか」を追える必要がある。
初期実装では、既存の `progress` commentへ識別可能な構造化payloadを保存し、`ChatMessage.tsx` でparserを追加して `PolicyActionNotice` を描画する案が変更範囲を抑えやすい。
```json
{
"type": "policy_action",
"eventId": "...",
"ruleId": "...",
"ruleName": "削除前の確認",
"action": "inject_instruction",
"outcome": "tool_not_executed",
"targetLabel": "コマンド実行",
"createdAt": "..."
}
```
ただし、監査ログの完全なpayloadをコメントへ保存してはならない。チャット用payloadは表示に必要なallowlist項目だけに限定する。将来policy eventが増える場合は、`CommentKind` に専用kindを追加するか、タスクイベントfeedへ統合する。
### 13.5 表示方針
- 追加指示、拒否、承認、停止はチャット履歴へ必ず表示する
- `audit_only` はチャットへ毎回表示せず、Activityタブだけに記録する
- 常時適用ルールは各LLM呼び出しで表示せず、チャットヘッダーまたはセッション開始時に有効件数を表示する
- 同一event IDはtoast、チャット、通知Inboxで重複生成しない
- 詳細には自然言語条件全文、判定用プロンプト、生のtool引数、機密値を表示しない
- 共有ビューでは内部監査情報を除外し、利用者向け説明だけを表示する
### 13.6 変更候補ファイル
| ファイル | 変更内容 |
|---|---|
| `ui/src/components/chat/ChatMessage.tsx` | `policy_action` payload parserと専用noticeへの分岐 |
| `ui/src/components/chat/ChatMessageFeed.tsx` | 原則そのまま。既存comment feedへ載せる |
| `ui/src/components/chat/ChatComposer.tsx` | policy approval待ちのロック条件を追加 |
| `ui/src/components/chat/ChatComposerPanel.tsx` | `PolicyApprovalCard` を既存承認カードと同じ位置へ追加 |
| `ui/src/components/chat/ToolRequestApproval.tsx` | 表示部分を共通カードへ段階的に抽出 |
| `ui/src/components/chat/PackageRequestApproval.tsx` | 表示部分を共通カードへ段階的に抽出 |
| `ui/src/hooks/useToast.ts` | 原則変更不要。event IDを既存 `id` に渡して重複抑止 |
| `ui/src/components/notifications/*` | 重要イベントを通知Inboxへ載せる場合だけcategoryと表示文言を追加 |
| `ui/src/components/activity/ActivityEventCard.tsx` | policy event kindの色・ラベル追加 |
| `ui/src/lib/utils.ts` | `ActivityEventKind`、label、titleの拡張 |
| `ui/src/api/tasks.ts` | policy approval DTO、取得・決定API、表示payload型 |
| `ui/src/locales/*/chat.json` | notice、承認、エラー、入力ロック文言 |
### 13.7 推奨実装順
1. `PolicyActionNotice` と構造化comment表示を追加する
2. Auto Prompter承認を既存 `waiting_human` 状態へ接続する
3. `PolicyApprovalCard` を既存カードと同じ場所へ追加する
4. 3つの承認カードで重複する表示部分だけ `ApprovalRequestCard` へ抽出する
5. toastを軽微な通知、通知Inboxを要対応・重要イベントに限定して接続する
6. Activityタイムラインへ監査表示を追加する
承認UIを最初から全面的に汎用化するのは避ける。まずPolicy用adapterを追加し、Tool、Package、Policyの3例で共通部分が確定してから表示部品を抽出する方が回帰リスクを抑えられる。
## 14. 機能OFF時の完全後方互換要件
Auto Prompterを無効にしたワークスペースでは、設定UIとDB schemaの存在を除き、エージェントの実行動作を従来と同一に保つ。
### 14.1 二段階スイッチ
```text
auto_prompter.available(サーバー機能フラグ)
└─ auto_prompter.enabled(ワークスペースのマスタースイッチ)
└─ rule.enabled(ルール単位スイッチ)
```
- `available=false`: APIと設定UIを公開せず、実行経路は即時短絡する。
- `available=true, enabled=false`: 設定の閲覧・編集・保持だけを許可し、実行には介入しない。
- `available=true, enabled=true`: 有効なルールだけを評価する。
- マスタースイッチまたはルールをOFFにしても設定内容は削除しない。
### 14.2 OFF時に変更してはならないもの
- LLMへ渡すmessages、tools、tool schema、順序
- system prompt、Movement guidance、dynamic context block
- tool callの引数、順序、並列実行判定
- Workspace Tool Policy、sandbox、MCP、SSH、編集権限
- Tool Request、Package Request、ASKの状態遷移
- Movement遷移、Complete、WAIT_SUBTASKS、再試行、キャンセル
- Conversation、コメント、activity、最終結果
- Push、Webhook、Notification Inboxの通知条件
OFF時にはJudge、ルール取得、発火状態保存、policy監査、policy通知を実行しない。許容する追加処理は、メモリ上の機能フラグを確認して既存経路へ即時returnする処理だけとする。
### 14.3 実装上の制約
- 既存承認サービスを初期実装で全面的に書き換えない。
- `PolicyGate`は未指定またはOFFなら、既存tool dispatcherを直接呼ぶ。
- OFF経路ではtool callやmessagesをclone、normalize、serializeしない。
- feature flag判定のためにtool callごとのDBアクセスを行わない。
- 既存のTool RequestとPackage RequestはAPI adapterと状態管理を維持する。
- UI共通化は表示専用コンポーネントに限定し、mutationとjob再開は既存adapterへ残す。
### 14.4 後方互換テスト
同一fixtureを機能導入前相当と `available=false`、`enabled=false` で実行し、次を比較する。
- LLM requestのmessages/toolsスナップショットが一致
- tool実行履歴、引数、結果、順序が一致
- Movement historyとPiece resultが一致
- job status、waitReason、resumeMovement、askCountが一致
- task commentsと最終出力が一致
- Auto Prompter専用のrule/state DB query、Judge、通知が0回
- 既存テストを期待値変更なしで通過
- OFF時の追加レイテンシを計測し、回帰閾値を超えない
workspace master enabledは既存のworkspace取得またはJob claim snapshotへ同梱してメモリ判定する。ここでいうDB query 0回は、enabled判定後のAuto Prompter専用rule/state queryが発生しないことを指す。
## 15. 現行タスク処理アーキテクチャ
現行コードに基づく全体フローは、共通資料へ分離した。
- [MAESTRO タスク実行アーキテクチャ](./task-execution-architecture.md)
- Auto Prompter固有の挿入位置とOFF時短絡は次節に記載する。
## 16. Auto Prompterの挿入位置とOFF時短絡
```mermaid
flowchart TD
CLAIM[Job claim / workspace準備]
AVAIL{auto_prompter.available}
MASTER{workspace enabled}
SNAPSHOT[Policy Snapshotを作成]
PREAMBLE[常時指示をstatic preambleへ一度だけ合成]
TOOLCALL[LLMがregular / complete / transitionを生成]
LEGACY[既存dispatcherへ直接渡す]
PREFLIGHT[regular callをparse・基本許可判定<br/>control callを分類]
MATCH[regular callを決定的判定]
JUDGESHADOW[三値Judge<br/>初期shadow]
SHADOWAUDIT[shadow結果を監査<br/>制御には不使用]
JUDGEPROD[品質基準通過後の本番Judge<br/>Phase 5以降]
NEEDPRODJUDGE{本番Judgeが必要か}
DECISION{batch decision}
EXECUTE[allow callだけcache / execute]
POLICYRESULTS[Policy保留時<br/>同一assistant内の全tool callへ合成結果]
INJECT[統合した登録指示をuser messageで1件追記]
PARK[Policy approvalを保存してwaiting_human]
STOP[policy reason付きABORT<br/>既存終端処理で子タスクを取消]
EXISTINGFLOW[既存dispatcherのwaiting / terminal / 次iteration処理]
NEXTLLM[次のLLM iteration]
CLAIM --> AVAIL
AVAIL -->|false| TOOLCALL
AVAIL -->|true| MASTER
MASTER -->|false| TOOLCALL
MASTER -->|true| SNAPSHOT
SNAPSHOT --> PREAMBLE
PREAMBLE --> TOOLCALL
TOOLCALL -->|OFF| LEGACY
TOOLCALL -->|ON| PREFLIGHT
PREFLIGHT --> MATCH
MATCH --> NEEDPRODJUDGE
MATCH -->|意味候補・shadow| JUDGESHADOW
JUDGESHADOW --> SHADOWAUDIT
NEEDPRODJUDGE -->|no| DECISION
NEEDPRODJUDGE -->|yes: Phase 5以降・意味候補| JUDGEPROD
JUDGEPROD --> DECISION
DECISION -->|allow / audit_only| EXECUTE
DECISION -->|denyを含む| EXECUTE
DECISION -->|advise / approval / stop| HELD[batch全体を実行しない]
EXECUTE --> EXISTINGFLOW
HELD --> POLICYRESULTS
POLICYRESULTS -->|advise| INJECT
INJECT --> NEXTLLM
POLICYRESULTS -->|approval| PARK
POLICYRESULTS -->|stop| STOP
```
OFF時はSnapshot、preflight、引数正規化、Judge、policy監査を行わず、現在のdispatcherへ直接渡す。ON時はcall内のrule競合を `stop > deny > approval > advise > allow` で解決した後、iteration全体を集約する。異なるcall間では `stop > approval > advise` を全体保留として優先し、denyだけならdeny対象へ合成resultを作り、残りは既存dispatcherへ渡すため、InteractiveBrowse等の途中待機を含む従来の記録・再開挙動を維持する。advise、approval、stopでbatch全体を保留した場合に限り、regularとcontrolを含む同一assistant内の全tool callへ合成resultを作る。stopは新しいJob statusを増やさずhard-terminal abort reason `policy_stopped` 付きの既存ABORTへ変換し、既存終端処理でpending子タスクを取り消して利用者へ通知する。
### 16.1 Complete受理前Hook
```mermaid
flowchart TD
CANDIDATES[complete / transition候補を分類]
WINNER[既存規則でnative winnerを選択]
PENDING{pending子タスク等があるか}
AUTOPARK[既存WAIT_SUBTASKSへauto-park]
ENABLED{Auto Prompter有効か}
CGATE[completion.before_accept<br/>決定的確認のみ]
CDECISION{decision}
RECORD[既存control tool resultsを記録]
RESULT[Movement resultを生成]
RETRY[全control callへError: tool result<br/>登録済み指示を追記]
CANDIDATES --> WINNER
WINNER --> PENDING
PENDING -->|yes| RECORDP[既存control tool resultsを記録]
RECORDP --> AUTOPARK
PENDING -->|no| ENABLED
ENABLED -->|false| RECORD
ENABLED -->|true| CGATE
CGATE --> CDECISION
CDECISION -->|allow / audit_only| RECORD
CDECISION -->|deny / advise| RETRY
RETRY --> NEXTLLM2[次のLLM iteration]
RECORD --> RESULT
```
Complete Hookは、pending子タスクなどによるauto-park判定を通過し、本当に終端可能なCompleteだけに適用する。拒否または追加確認では、providerへ再送できるよう全control callへ `Error:` で始まるtool resultを記録し、既存retry経路と同じmessage順序で再推論する。発火回数はtask+Movement+rule単位でDBへ保存し、resume後も上限を維持する。
### 16.2 Policy状態の寿命
- Job内で不変かつDB永続: snapshot ID、rule version、常時指示、Judge設定。再claim時は現在設定で再構築せず復元する
- task・MovementをまたいでDB保持: 発火回数、承認要求回数、拒否済みfingerprint、承認decision、one-shotのrequest ID・nonce・消費状態
- Policy park中にDB保持: snapshot IDへ紐付くapprovalRequestId、nonce、fingerprintのresume context
- iteration内だけ保持: 候補rule、正規化済み引数、重複排除用の一時cache
- `jobId` は監査相関に使うが操作fingerprintには含めない。one-shotはrequest ID・nonce・未消費状態も一致条件にする
- delegate子エージェントで承認が必要になった場合、初期実装では独立駐車せず親へbubbleするか決定的に拒否する
+36 -7
View File
@@ -14,7 +14,7 @@
| `config.yaml` の `provider` が動作する LLM を指している(タスク実行 + judge の両方で使う) | ✅ |
| 初回のみ `npm run bench:fixtures` で `bench/fixtures/sales.xlsx` を生成 | ✅ |
ベンチランナーは外部ネットに依存しない。`fixtures/web/*` はランナー内蔵の **localhost HTTP サーバ** が配信する(起動時にランダムポートを取り、`{WEB_PORT}` トークンで prompt に注入)。
テスト用の Web 素材はローカルで配信する。タスク実行とモデルによる採点は、設定したサーバーへ通信する。`fixtures/web/*` はランナー内蔵の **localhost HTTP サーバ** が配信する(起動時にランダムポートを取り、`{WEB_PORT}` トークンで prompt に注入)。
---
@@ -30,7 +30,7 @@ npm run bench -- --task=composite-mini-report
# 別ホスト/ポートのオーケストレータ向け
npm run bench -- --server=http://127.0.0.1:9876
# LLM judge を skip (axis D を 1.0 固定にして programmatic だけで採点)
# LLM judge を skip (axis D は「採点していない」扱いで総合点の計算から外れる)
BENCH_JUDGE=off npm run bench
# judge を別エンドポイント・別モデルにする
@@ -39,6 +39,12 @@ BENCH_JUDGE_MODEL=gpt-oss:20b \
npm run bench
```
### 判定用モデルの設定と認証
既定では `provider.workers` の先頭から接続先・モデル・API キーを取得する。ワーカーが無い場合は旧形式の `provider.baseUrl` / `provider.model` を使う。
`BENCH_JUDGE_ENDPOINT`、`BENCH_JUDGE_MODEL`、`BENCH_JUDGE_API_KEY` は、それぞれワーカー設定より優先される。接続先を別の URL に変更した場合は元のワーカーのキーを引き継がないため、認証が必要なら `BENCH_JUDGE_API_KEY` を設定する。同じ接続先の末尾スラッシュの有無は区別しない。キーを明示的に空文字にすると、認証ヘッダーを送らない。
実行が終わると `bench/results/<run_id>/` に書き出される(`run_id` は ISO タイムスタンプ)。
---
@@ -60,15 +66,23 @@ bench/results/2026-05-01T03-22-11Z/
```
# Bench run @ 2026-05-01T03:22:11.000Z
**Overall: 73 / 100**
**Overall: 77 / 100** (avg of 2 scored tasks of 3, scored weight 75-100 of 100)
| Task | Status | Total | A | B | C | D |
| ---------------------- | ---------- | ----: | --: | --: | --: | --: |
| composite-mini-report | succeeded | 73 | 90% | 100%| 70% | 60% |
**Failed runs: 1** (kept in the table below, excluded from the average)
| Task | Outcome | Status | Total | Scored weight | A | B | C | D |
| ---------------------- | ------- | --------- | ----: | ------------: | --- | ---- | --- | ------- |
| composite-mini-report | graded | succeeded | 73 | 100/100| 90% | 100% | 70% | 60% |
| judge-off-task | graded | succeeded | 80 | 75/100| 100%| 100% | 50% | skipped |
| exploded-task | failed | – | failed| – | – | – | – | – |
```
総合行の `scored weight 75-100 of 100` は、平均に入れた 2 件が別々の重みで割り直されていることを示す。全件が同じ重みなら `scored weight 100 of 100` と 1 つの数字になる。
各タスクの詳細セクションでは axis ごとに ✓/✗ 内訳とツールコールの全シーケンスが折りたたみで見られる。
**Scored weight 列を必ず一緒に見ること。** 採点した軸の顔ぶれが違う総合点どうしは、同じ 100 点満点でも別の試験の点数になる。
`result.json` は CI から機械可読に扱える形式。
---
@@ -83,7 +97,22 @@ bench/results/2026-05-01T03-22-11Z/
| **D. Reasoning** | 25 | 内容の妥当性・統合の質・「次アクション」の具体性など | LLM judge ルーブリック |
| _補助: Efficiency_ | – | duration / prompt tokens(summary に数値表示のみ) | – |
`Total = A×30 + B×15 + C×30 + D×25` を 0..100 に正規化。
### 採点した軸だけで割り直す
各軸は `scored` / `skipped` / `failed` の 3 状態を持つ。
| 状態 | 意味 | 総合点への扱い |
|------|------|----------------|
| `scored` | 実際に評価した。0..1 の点がつく | 重みぶん加算する |
| `skipped` | 測っていない(その軸の期待値をタスクが書いていない、judge が OFF など) | 分子からも分母からも外す |
| `failed` | 採点そのものが壊れた(judge の通信失敗・応答が JSON でない・結果が空) | 分子からも分母からも外す |
総合点は `採点できた軸の (score × 重み) の合計 ÷ 採点できた軸の重みの合計 × 100`。
`skipped` を満点、`failed` を 0 点として混ぜない。前者は「測っていないもの」を「満点だった」に見せかけ、後者は「採点機が壊れた」を「中身が悪かった」に見せかけるため。
採点できた軸が 1 つも無いときは総合点を出さず `not scored` と書く。`result.json` の `scoring` に `scoredAxes` / `scoredWeight` / `uncountedAxes`(状態と理由つき)が入るので、後から run どうしを比べるときに顔ぶれの違いが分かる。
タスクの実行そのものが落ちた場合は `outcome: "failed"` の結果として一覧に残り、平均からは除いたうえで件数を表示する。落ちた課題を黙って捨てない。**1 件でも落ちたら `npm run bench` の終了コードは 1 になる**(点が低いだけなら 0 のまま。走れなかったことだけを信号にする)。
`completion_status: [succeeded, waiting_human, failed, aborted, cancelled]` で受理する終了状態を指定。デフォルトは `[succeeded]` のみ。**failure でも grader は走り部分スコアが出る**。
+1 -1
View File
@@ -145,7 +145,7 @@ UI 上は Web & Search / Browser / Media & Documents / External Services / Legac
**Web & Search**: `searxng_url`(WebSearch フォールバック先), `webfetch_timeout`(sec), `websearch_timeout`, `webfetch_allowed_hosts[]`(SSRF 例外: private IP/.local を許可する場合)。
**Media & Documents**: `vision_model`/`vision_base_url`/`vision_timeout`/`vision_max_tokens`(ReadImage 用 VLM), `ocr_model`, `office_{excel,docx,pdf}_max_size_mb`(既定 10), `office_pptx_max_size_mb`(50), `office_pptx_max_uncompressed_mb`(200, zip-bomb 検知), `speech_server_url`/`speech_timeout`/`speech_language`。
**Media & Documents**: `vision_model`/`vision_base_url`/`vision_timeout`/`vision_max_tokens`(ReadImage 用 VLM), `ocr_model`, `office_{excel,docx,pdf}_max_size_mb`(既定 10), `office_pptx_max_size_mb`(50), `office_pptx_max_uncompressed_mb`(200, zip-bomb 検知), `office_max_entries`(10000, ZIP 項目数の上限), `speech_server_url`/`speech_timeout`/`speech_language`(文字起こし), `speak_server_url`/`speak_model`/`speak_voice`/`speak_format`/`speak_timeout`/`speak_max_chars`(読み上げ Speak。接続先は文字起こしと別。既定形式 mp3・上限 4096 文字)。
**External Services**: `x_*`(Twitter/X CLI 連携: `x_cli_command`/`x_auth_token`/`x_ct0`/`x_proxy`/`x_download_*` 等), `google_maps_api_key`/`maps_timeout`(未設定で Nominatim/OSRM), `amazon_affiliate_tag`/`keepa_api_key`。
+1 -1
View File
@@ -145,7 +145,7 @@ In the UI these are split into Web & Search / Browser / Media & Documents / Exte
**Web & Search**: `searxng_url` (WebSearch fallback target), `webfetch_timeout` (sec), `websearch_timeout`, `webfetch_allowed_hosts[]` (SSRF exceptions: when allowing private IP/.local).
**Media & Documents**: `vision_model`/`vision_base_url`/`vision_timeout`/`vision_max_tokens` (VLM for ReadImage), `ocr_model`, `office_{excel,docx,pdf}_max_size_mb` (default 10), `office_pptx_max_size_mb` (50), `office_pptx_max_uncompressed_mb` (200, zip-bomb detection), `speech_server_url`/`speech_timeout`/`speech_language`.
**Media & Documents**: `vision_model`/`vision_base_url`/`vision_timeout`/`vision_max_tokens` (VLM for ReadImage), `ocr_model`, `office_{excel,docx,pdf}_max_size_mb` (default 10), `office_pptx_max_size_mb` (50), `office_pptx_max_uncompressed_mb` (200, zip-bomb detection), `office_max_entries` (10000, ZIP entry-count limit), `speech_server_url`/`speech_timeout`/`speech_language` (transcription), `speak_server_url`/`speak_model`/`speak_voice`/`speak_format`/`speak_timeout`/`speak_max_chars` (text-to-speech via the Speak tool; a separate endpoint from transcription, mp3 by default, 4096-character limit).
**External Services**: `x_*` (Twitter/X CLI integration: `x_cli_command`/`x_auth_token`/`x_ct0`/`x_proxy`/`x_download_*` etc.), `google_maps_api_key`/`maps_timeout` (Nominatim/OSRM when unset), `amazon_affiliate_tag`/`keepa_api_key`.
+4 -4
View File
@@ -116,7 +116,7 @@ Lessons は最大 2000 文字程度に抑えられ、古いものから削られ
## コンテキスト逼迫時の動作
`ContextManager` は LLM の usage が取れる場合は `prompt_tokens`、取れない場合は文字数推定で使用率を見ます。
`ContextManager` は LLM が有効な `prompt_tokens` を返した場合はその実測値を使い、返さない場合や空・不正な usage の場合は文字数推定で使用率を見ます。明示的に返された 0 は実測値として扱います。iteration 番号 3 以降(4 回目以降)は、以前の反復で実測値が取れていても、現在の反復で欠測した場合は現在の messages から推定し直します。
デフォルト閾値:
@@ -129,9 +129,9 @@ Lessons は最大 2000 文字程度に抑えられ、古いものから削られ
```mermaid
flowchart TD
LLM[LLM 応答完了] --> Usage{usage あり?}
Usage -->|あり| Tokens[prompt_tokens を記録]
Usage -->|なし・3 iteration 以降| Chars[messages 文字数から推定]
LLM[LLM 応答完了] --> Usage{有効な prompt_tokens あり?}
Usage -->|あり・明示0を含む| Tokens[prompt_tokens を記録]
Usage -->|欠損・不正・iteration番号3以降| Chars[現在の messages 文字数から推定]
Tokens --> Ratio[context 使用率を計算]
Chars --> Ratio
Ratio --> Warn{70%以上?}
+44 -8
View File
@@ -20,11 +20,46 @@ opens a setup wizard in the UI where you point MAESTRO at your LLM. To preset th
endpoint instead, `cp .env.example .env` and set `OLLAMA_BASE_URL`/`OLLAMA_MODEL`
(see [The LLM endpoint](#the-llm-endpoint)).
Compose publishes the UI on `9876` on **all host interfaces**, so a Docker host
is reachable from your LAN out of the box. That also means a fresh instance is
exposed and **unauthenticated** — anyone who can reach the port can run code on
the host via the agent's Bash tool. Read [Going beyond localhost](#going-beyond-localhost)
before leaving it up on a shared network.
**A fresh container starts with a login.** Compose publishes `9876` on all host
interfaces, so a Docker host is reachable from your LAN out of the box — and the
agent's Bash tool would hand anyone who reaches it code execution. So a new
install that binds a non-loopback interface generates an admin account on first
boot and prints the password:
```console
$ docker compose logs maestro | grep initial-admin
[initial-admin] ... Sign in as admin@localhost / <generated password> ...
```
The same password is written to `data/.initial-admin-password` (mode `0600`) in
case the log has scrolled away. Sign in, then change it under
**Settings → Preferences** — the reminder stops and the file is removed once you
do. The setup wizard lives behind that login, so sign in first and configure the
LLM afterwards.
This only happens on a **fresh** install with no `auth` section in
`config.yaml`. An existing deployment is never switched to a login behind your
back; if it is unauthenticated and reachable, the UI shows a standing warning
instead. See [Going beyond localhost](#going-beyond-localhost).
Two consequences worth knowing up front:
- **Rebuilding the container keeps the login.** `config.yaml` lives in the image
layer, so `docker compose up -d --build` resets it while `/app/data` (and your
admin account) survives on the named volume. Boot notices the mismatch and
turns `auth.local` back on — your existing password keeps working, nothing is
regenerated. Everything *else* you saved in Settings is still lost on a
rebuild: bind-mount `config.yaml` (writable) or point `AAO_CONFIG` at a path
under the data volume to keep it. To turn the login off deliberately, write
`auth.local.enabled: false` — an explicit setting is never overwritten.
- **A read-only `config.yaml` makes a fresh install refuse to start.** It cannot
write the auth setting, and coming up unauthenticated on a reachable interface
is the one outcome nobody would notice. Make the file writable, configure auth
yourself, or bind loopback.
- **Wiping the data volume while keeping `config.yaml` does not lock you out.**
`docker compose down -v` leaves a config that says "auth on" with no account
behind it; boot notices and mints a fresh admin, printed the same way. If an
OAuth provider is configured, it does nothing — you can still sign in there.
### Windows (WSL2)
@@ -133,10 +168,11 @@ Rebuild after pulling new code with `docker compose up -d --build`.
## Going beyond localhost
By default Compose publishes `9876` on all interfaces, so the instance is already
reachable from your LAN — **and unauthenticated**. Before you rely on it on any
shared or untrusted network, harden it:
reachable from your LAN. A fresh install starts with a generated admin login (see
[Quick start](#quick-start)), but that is the floor, not a finished setup. Before
you rely on it on any shared or untrusted network, harden it:
1. Enable authentication (OAuth, or local accounts).
1. Change the generated password, or switch to OAuth / your own local accounts.
2. Set `safety.bash_sandbox: always`.
3. Terminate TLS — either MAESTRO's native HTTPS or a reverse proxy in front.
+3 -5
View File
@@ -172,7 +172,7 @@
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-031</code></td><td class="c-area">Engine core</td><td class="c-feature">Atomic JSON persistence</td><td class="c-behavior">writeAtomicJson (tmp+rename, mkdir parents); readSafeJson returns missing/corrupt/wrong-version; quarantineCorruptFile</td><td class="c-source"><code>context/atomic-json.ts</code></td><td class="c-test">atomic-json.test.ts (11)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">—</td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-032</code></td><td class="c-area">Engine core</td><td class="c-feature">Workspace path normalization</td><td class="c-behavior">Rejects absolute/backslash/drive/UNC/NUL/empty; canonicalizes <code>..</code>/<code>.</code>; prefixWorkspacePath join with traversal guard</td><td class="c-source"><code>context/path-normalize.ts</code></td><td class="c-test">path-normalize.test.ts (15)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">—</td></tr>
<tr data-cov="PARTIAL" data-area="Engine core"><td class="c-id"><code>ENG-033</code></td><td class="c-area">Engine core</td><td class="c-feature">Piece loop guards (cross-movement)</td><td class="c-behavior"><code>enforceLoopGuards</code>: consecutive revisits &gt; max_consecutive_revisits (default 4) → abort; counters reset on movement change</td><td class="c-source"><code>piece-runner.ts (`enforceLoopGuards`)</code></td><td class="c-test">piece-runner.test.ts &quot;aborts when loop detection fires due to consecutive revisits&quot;</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Per-movement <code>max_consecutive_revisits</code> override and the counter-reset-on-movement-change branch not separately asserted</td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-034</code></td><td class="c-area">Engine core</td><td class="c-feature">ASK limit fallback</td><td class="c-behavior">ASK result resolves <code>default_next</code> or first non-self/non-ASK/non-ABORT rule; aborts when no fallback; resolves COMPLETE default_next via ASK-limit fallback</td><td class="c-source"><code>piece-runner.ts (result.next==='ASK' branch)</code></td><td class="c-test">piece-runner.test.ts &quot;falls back to default_next when ASK limit is reached&quot;; &quot;aborts when ASK limit reached and no fallback&quot;; &quot;resolves a COMPLETE default_next reached via the ASK-limit fallback&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">—</td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-034</code></td><td class="c-area">Engine core</td><td class="c-feature">ASK limit deflection</td><td class="c-behavior">Over the ASK limit the run does NOT park: it returns to the same movement with a self-decide notice. Deflections are counted per run (movement-name agnostic); over MAX_ASK_LIMIT_DEFLECTIONS the run aborts with ask_limit_reached</td><td class="c-source"><code>piece-runner.ts (result.next===&#39;ASK&#39; branch)</code></td><td class="c-test">piece-runner.test.ts &quot;問いかけ上限に達したら、同じステップへ戻して続けさせる(終了しない)&quot;; &quot;戻したステップに「自分で判断して進める」注意書きを渡す&quot;; &quot;2 枚のステップを往復しても、差し戻しの上限で打ち切られる(loop_detected では止まらない)&quot;; worker.ask-limit-final-output.wiring.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">—</td></tr>
<tr data-cov="PARTIAL" data-area="Engine core"><td class="c-id"><code>ENG-035</code></td><td class="c-area">Engine core</td><td class="c-feature">WAIT_SUBTASKS pause</td><td class="c-behavior">result.next==='WAIT_SUBTASKS' pauses piece run (waiting_subtasks)</td><td class="c-source"><code>piece-runner.ts (L1144)</code></td><td class="c-test">piece-runner.test.ts &quot;does NOT write snapshot on waiting_subtasks (transient pause)&quot;</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">The pause/resume return contract is only asserted via the snapshot-suppression side effect; no direct test of the waiting_subtasks PieceRunResult shape</td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-036</code></td><td class="c-area">Engine core</td><td class="c-feature">SpawnSubTask depth-limit skip</td><td class="c-behavior">Movement requiring SpawnSubTask skipped at depth limit (incl. via shared_tools); resolves COMPLETE default_next on skip</td><td class="c-source"><code>piece-runner.ts (`pieceUsesSpawn`, spawn skip)</code></td><td class="c-test">piece-runner.test.ts &quot;resolves a COMPLETE default_next when a SpawnSubTask movement is skipped at the depth limit&quot;; &quot;skips a SpawnSubTask movement at the depth limit even when…shared_tools&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">—</td></tr>
<tr data-cov="NONE" data-area="Engine core"><td class="c-id"><code>ENG-037</code></td><td class="c-area">Engine core</td><td class="c-feature">WAITING_HUMAN_BROWSER / _TOOL_REQUEST pauses</td><td class="c-behavior">Non-ASK human-wait sentinels pause the run</td><td class="c-source"><code>piece-runner.ts (L1159, L1176)</code></td><td class="c-test">(none found)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">No test exercises the WAITING_HUMAN_BROWSER or WAITING_HUMAN_TOOL_REQUEST branches</td></tr>
@@ -277,7 +277,7 @@
<tr data-cov="PARTIAL" data-area="Pieces"><td class="c-id"><code>PIECE-015</code></td><td class="c-area">Pieces</td><td class="c-feature">help</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">load sweep only</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">No direct test</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-001</code></td><td class="c-area">Pieces</td><td class="c-feature">loadPiece (custom-dir priority + builtin fallback)</td><td class="c-behavior">Resolves piece by name across custom dirs (string|string[]), custom wins, fallback to builtin</td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test &quot;loadPiece multi-dir&quot; (string|string[], per-user wins, first-dir wins, backward-compat); worker.test <code>loadPiece('local-piece')</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Multi-dir resolution well covered</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-002</code></td><td class="c-area">Pieces</td><td class="c-feature">validatePieceDef (Phase 6b terminal-next rejection)</td><td class="c-behavior">Throws if any <code>rules[].next</code> ∈ {COMPLETE,ABORT,ASK}; default_next exempt</td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test &quot;loadPiece terminal-rule validation (Phase 6b)&quot;: rejects COMPLETE/ABORT/ASK in rules[].next, accepts default_next: COMPLETE, accepts movement-to-movement + WAIT_SUBTASKS</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Core invariant directly asserted with error-message matching</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-003</code></td><td class="c-area">Pieces</td><td class="c-feature">default_next sentinel (engine-internal fallback)</td><td class="c-behavior">Used for context-overflow / ASK-limit / SpawnSubTask-skip; allows COMPLETE/ABORT/ASK</td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test &quot;falls back to default_next when ASK limit is reached&quot;, &quot;resolves a COMPLETE default_next reached via ASK-limit fallback&quot;, SpawnSubTask-skip→default_next tests</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">All three sentinel paths exercised</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-003</code></td><td class="c-area">Pieces</td><td class="c-feature">default_next sentinel (engine-internal fallback)</td><td class="c-behavior">Used for context-overflow and as the WAIT_SUBTASKS resume target; allows COMPLETE/ABORT/ASK. The ASK limit no longer routes here (#1299) and the SpawnSubTask-skip branch was removed.</td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test &quot;再開の行き先が COMPLETE sentinel でも、movement 名として引かずに完了として解決する&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Terminal-sentinel gate covered via the resume path</td></tr>
<tr data-cov="NONE" data-area="Pieces"><td class="c-id"><code>PINFRA-004</code></td><td class="c-area">Pieces</td><td class="c-feature">lint-pieces.mjs (CI lint, terminal-next ban)</td><td class="c-behavior">Standalone script: hard-fails any piece with COMPLETE/ABORT/ASK in rules[].next</td><td class="c-source"><code></code></td><td class="c-test">**none** (no *.test for the script; runtime equivalent in validatePieceDef IS tested)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">Script itself has no test; logic duplicated/covered by PINFRA-002 but the CLI (file collection, exit codes, parse-error path) is untested</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-005</code></td><td class="c-area">Pieces</td><td class="c-feature">CreatePiece tool validation</td><td class="c-behavior">Rejects missing/0 max_movements, non-array shared_tools, bad rules[].next; writes to dirs[0] (per-user)</td><td class="c-source"><code></code></td><td class="c-test">tools/pieces.test &quot;CreatePiece&quot; block (missing max_movements, max_movements:0, valid write, shared_tools array/non-array)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Validation + write-target asserted</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-006</code></td><td class="c-area">Pieces</td><td class="c-feature">UpdatePiece tool (built-in protection)</td><td class="c-behavior">Refuses overwriting built-in (isBuiltinOnly); allows update when custom override exists</td><td class="c-source"><code></code></td><td class="c-test">tools/pieces.test &quot;UpdatePiece&quot; block (refuses built-in overwrite regression, allows custom override)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Built-in immutability regression covered</td></tr>
@@ -543,7 +543,6 @@
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-023</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Local orgs</td><td class="c-behavior">createLocalOrg (lorg: id), rename, member add/remove idempotent, listUserLocalOrgs, delete cascade + downgrade scoped resources</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository-local-orgs.test.ts (10)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-024</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">ensureLocalUser DEK fix</td><td class="c-behavior">creates per-user row so DEK/SSH create succeeds; idempotent</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">ensure-local-user.test.ts (4)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-025</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">ScheduledTasks CRUD</td><td class="c-behavior">create/get/list/getDue/update/delete; space_id persisted (not dead-wired)</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.test.ts (ScheduledTasks 5); spaces-resolution.test.ts (space_id round-trip)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-026</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Share token (local task)</td><td class="c-behavior">shareLocalTask token, idempotent, unshare clears, getByShareToken null on unknown/after-unshare</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.test.ts (Share feature 6)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Services &amp; DB"><td class="c-id"><code>DB-027</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">addAuditLog</td><td class="c-behavior">single audit_log insert helper</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">scheduler.test.ts (user_script_run audit row asserted via path)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">addAuditLog itself not unit-tested; only one consumer asserted</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-028</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Gateway virtual keys</td><td class="c-behavior">find/list/revoke/delete/touchLastUsed + usage</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.gateway-keys.test.ts; gateway-usage.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">(gateway-adjacent; in repo file)</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-029</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">LLM usage aggregation</td><td class="c-behavior">incrementLlmUsage (daily) + hourly + queryLlmUsageDaily + org map</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.llm-usage.test.ts; llm-usage-hourly.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
@@ -597,7 +596,6 @@
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-039</code></td><td class="c-area">UI</td><td class="c-feature">App auto-approve gate</td><td class="c-behavior">decide auto-approve of app file ops</td><td class="c-source"><code>`components/spaces/AppRunner.tsx` (logic)</code></td><td class="c-test"><code>components/spaces/AppRunner.autoapprove.test.tsx</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">only auto-approve branch; AppRunner iframe lifecycle untested</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-040</code></td><td class="c-area">UI</td><td class="c-feature">App-share API client</td><td class="c-behavior">app-share fetch client behavior</td><td class="c-source"><code>`src/api.ts` (app-share)</code></td><td class="c-test"><code>api.app-share.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">only app-share endpoints; rest of <code>api.ts</code> untested</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-041</code></td><td class="c-area">UI</td><td class="c-feature">Harness gateway</td><td class="c-behavior">test-harness gateway (mounts real AppRunner)</td><td class="c-source"><code>`harness/`</code></td><td class="c-test"><code>harness/harness-gateway.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">—</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-042</code></td><td class="c-area">UI</td><td class="c-feature">Shared tabs (public view)</td><td class="c-behavior">which tabs show in SharedView/SharedAppView</td><td class="c-source"><code>`pages/SharedView.tsx`/`SharedAppView.tsx` (logic)</code></td><td class="c-test"><code>pages/sharedTabs.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">tab-selection logic only</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-043</code></td><td class="c-area">UI</td><td class="c-feature">Detail tab selection</td><td class="c-behavior">detail tab list / readonly gating</td><td class="c-source"><code>`components/detail/*` (logic)</code></td><td class="c-test"><code>detail/detailTabs.test.ts</code>, <code>detail/detail-readonly.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">tab + readonly logic; not full panels</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-044</code></td><td class="c-area">UI</td><td class="c-feature">Task data source resolution</td><td class="c-behavior">choose live vs shared data source for task</td><td class="c-source"><code>`components/detail/task-data-source.tsx` (logic)</code></td><td class="c-test"><code>detail/task-data-source.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">—</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-045</code></td><td class="c-area">UI</td><td class="c-feature">Console key handling</td><td class="c-behavior">terminal key → escape sequences</td><td class="c-source"><code>`detail/tabs/console/*`</code></td><td class="c-test"><code>detail/tabs/console/keys.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">key mapping only; TerminalView render/websocket untested</td></tr>
@@ -636,7 +634,7 @@
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-077</code></td><td class="c-area">UI</td><td class="c-feature">Shared space membership</td><td class="c-behavior">invite→member row+avatars, role change, removal</td><td class="c-source"><code>`components/spaces/SpaceMembersPanel.tsx`</code></td><td class="c-test"><code>e2e-auth/shared-space.auth.spec.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">—</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-078</code></td><td class="c-area">UI</td><td class="c-feature">Space invite links</td><td class="c-behavior">join via token, invalid/revoked states</td><td class="c-source"><code>`components/spaces/JoinSpace.tsx`</code></td><td class="c-test"><code>e2e-auth/space-invite.auth.spec.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">—</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-079</code></td><td class="c-area">UI</td><td class="c-feature">Workspace file input flow</td><td class="c-behavior">input/output folders show, next-action guidance, ephemeral warning</td><td class="c-source"><code>Files tab + create dialog</code></td><td class="c-test"><code>e2e-auth/workspace-file-input.auth.spec.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">—</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-080</code></td><td class="c-area">UI</td><td class="c-feature">App-share public view</td><td class="c-behavior">login-free read-only shared app view</td><td class="c-source"><code>`pages/SharedAppView.tsx`, `SharedView.tsx`</code></td><td class="c-test">none (only lib <code>appShareUrl</code>/<code>sharedTabs</code>/<code>sharedView</code>)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">public viewer flow has no e2e; logic libs covered</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-080</code></td><td class="c-area">UI</td><td class="c-feature">App-share public view</td><td class="c-behavior">login-free read-only shared app view</td><td class="c-source"><code>`pages/SharedAppView.tsx`</code></td><td class="c-test">lib <code>appShareUrl</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">public viewer flow has no e2e; URL logic covered</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-081</code></td><td class="c-area">UI</td><td class="c-feature">Help page</td><td class="c-behavior">render help sections by category, search</td><td class="c-source"><code>`pages/HelpPage.tsx`</code></td><td class="c-test">none (lib <code>help.ts</code> FULL)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">rendering + search wiring not e2e/jsdom tested</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-082</code></td><td class="c-area">UI</td><td class="c-feature">Pieces page (CRUD)</td><td class="c-behavior">list/edit custom vs default pieces, movement editor</td><td class="c-source"><code>`pages/PiecesPage.tsx`, `settings/PieceEditor.tsx`, `MovementForm.tsx`, `RulesTable.tsx`</code></td><td class="c-test">none (lib <code>splitPieces</code> FULL)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">piece editor / movement rules UI untested</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-083</code></td><td class="c-area">UI</td><td class="c-feature">Schedules page</td><td class="c-behavior">list/create scheduled tasks (5 types)</td><td class="c-source"><code>`pages/SchedulesPage.tsx`</code></td><td class="c-test">none (lib <code>cronForm</code> partial)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">schedule CRUD UI untested</td></tr>
+175 -8
View File
@@ -5,12 +5,158 @@
---
## 0-A. ツールモジュールの自己申告(TOOL_MODULE)— まずここを読む
**2026-08-15(#1126)から、モジュールは自分の属性を自分で申告できる。**新しくツールを足すときは、離れた 4 ファイルを追いかける前に、まずモジュールの中に申告を 1 つ書く。
### 書き方
自分のモジュール(`src/engine/tools/<name>.ts`)で、`TOOL_DEFS` の隣に置く。
```ts
import type { ToolModuleManifest } from './module-manifest.js';
export const TOOL_MODULE: ToolModuleManifest = {
id: 'youtube', // ファイル名(拡張子なし)と揃える
category: 'youtube', // 分類。MODULE_SPECS と同じ語彙
rawSaveTools: ['GetYouTubeTranscript', 'SearchYouTube'],
};
```
### 書ける項目
| 項目 | 意味 | 以前の書き場所 |
|---|---|---|
| `id` | モジュールの識別子。診断とテストの照合に使う | (なし) |
| `category` | 分類 | `tool-categories.ts` の `MODULE_SPECS` |
| `metaTools` | ワークスペース設定に関係なく常時載せる | `tool-categories.ts` と `index.ts` の 2 か所 |
| `rawSaveTools` | 結果を `logs/raw/` に生のまま残す | `raw-save.ts` の `RAW_SAVE_TOOLS` |
| `rawLogOnlyTools` | 保存先パスだけ記録する | `raw-save.ts` の `RAW_LOG_ONLY_TOOLS` |
| `sensitiveTools` | 分類が既定 ON でも個別に既定 OFF | `tool-categories.ts` の `SENSITIVE_TOOLS` |
**書かない項目もある。**分類そのものを慎重扱いにする指定(`SENSITIVE_CATEGORIES`)は分類の属性なので申告には載せない。ワークフローの同梱は次の段の話で、まだ入れていない。
### 守ること
- **申告に書いた名前は、そのモジュールの `TOOL_DEFS` に必ずあること。**無い名前は収集時に落とされ、`phantoms` に記録される(採用されない)
- **同じ属性を中央の一覧と申告の両方に書かない。**両方にあると、申告を消しても実効値が変わらず、申告が飾りになる。移すときは中央の一覧から必ず消す
- **読む側は必ず解決関数を通すこと。**中央の一覧を直接読むと、申告した分が落ちる
| 解決関数(`src/engine/tools/module-manifest.ts`) | 返すもの |
|---|---|
| `resolveMetaToolNames()` | 常時利用の実効集合 |
| `resolveRawSaveToolNames()` | 生データ保存の実効集合 |
| `resolveRawLogOnlyToolNames()` | パスだけ記録の実効集合 |
| `resolveSensitiveToolNames()` | 既定 OFF の実効集合 |
| `resolveDeclaredCategories()` | 申告された分類(指定 → 分類) |
どれも「中央の一覧 ∪ 全モジュールの申告」を返す。**申告を持たないモジュールは、いままでどおり中央の一覧だけで決まる。**一度に全部を移す必要はない。
### 中央の一覧は残っている
`tool-categories.ts` の `META_TOOLS` / `SENSITIVE_TOOLS`、`raw-save.ts` の `RAW_SAVE_TOOLS` / `RAW_LOG_ONLY_TOOLS` は消していない。**申告を持たないモジュールの受け皿**として残す。移すときだけ、そのモジュールの分を中央から申告へ動かす。
### 確認方法
```bash
# 申告の収集・実効値・配線をまとめて見る
npx vitest run src/engine/tools/module-manifest.test.ts
```
この試験には**登録済みモジュールが 1 本残らず読み込めることを見る検査**が入っている。`index.ts` の `tryLoadModule` は読み込み失敗を握り潰すので、モジュールが壊れてもツールが黙って消えるだけで試験は緑のままになる。その穴を塞ぐための検査なので、**中に try/catch を足さないこと。**
### 既知の食い違い(未修正・意図的)
`workspace-task-search.ts` と `package-request.ts` は `index.ts` が実行時に読み込んでいるのに `MODULE_SPECS` に載っていない。載っているツールが全部 META なので実行時には届いてしまい、抜けに気づけない形になっている。**`/api/tools` のツール一覧には出ていない。**`MODULE_SPECS` に足すと設定画面の見た目が変わるため、この段では足さず、`module-manifest.ts` の `EXTRA_MODULE_SPECIFIERS` に置いて読み込み検査の対象にだけ入れてある。
---
## 0-B. 群(パック)の台帳 — 環境全体で群ごと止める
台帳: **`src/engine/tools/tool-pack-ledger.ts`**(データだけ。import を持たない)
関所: **`src/engine/tools/tool-pack-gate.ts`**(設定を読んで判定を凍らせる)
`config.yaml` の `tool_packs.disabled` に群の名前を書くと、その群のモジュールを**読み込む前に断る**。ツール定義・分類・申告の収集・`/api/tools` の一覧が同時に消える。
### 台帳に足すときの条件(両方満たすこと)
1. **外の相手をその群だけが持っている。**外部 CLI・外部サーバー・外部 API・外部ライブラリのいずれかが、その群の中からしか呼ばれていない
2. **代わりを置ける。**同じ用途を別の事業者・別の MCP サーバーで満たせる
**分類が同じという理由だけで束ねないこと。**文字起こし(`speech`)と読み上げ(`speak`)は別の群にしてある——接続先のサーバーが別だから。
条件を満たさないものを足すと「外したのに縁が切れていない」群ができて、段 F(実体と依存を捨てる)で必ず詰まる。載せなかったものとその理由は台帳のコメントにある(`mermaid` / `calendar` / `data` / `review` / `app-test`)。
### 守ること
- **設定の変更通知(`ConfigManager` の `config-changed`)に関所を繋がないこと。**走行中にツール一覧が変わると継続ジョブのプロンプトキャッシュが全損する。反映は再起動時のみ
- **モジュール指定を呼び出し側で `replace` しないこと。**`normalizeToolModuleSpecifier()` を通す。`MODULE_SPECS` は `../engine/tools/x.js`、`index.ts` は `./x.js` の 2 通りがあり、片方だけ素通りするのが一番起きやすい壊れ方
- **台帳の群のツールを `META_TOOLS` / `SENSITIVE_TOOLS` に書かないこと。**中央の一覧は群と無関係に効くので、書くと止めた群の名前が許可一覧に残る。`tool-pack-gate.test.ts` の歯止めが落ちる
- 台帳に**載っていない**群はいままでどおり全部使える。これが既定
### 関所を通す先(6 か所。1 つでも抜けると片側だけ素通りする)
| 場所 | 何が消えるか |
|---|---|
| `tools/index.ts` の `tryLoadModule` | エージェントに渡るツール定義と、実行の口 |
| `module-manifest.ts` の `listRegisteredToolModuleSpecifiers` | 自己申告の収集・分類の地図・生データ保存の対象 |
| `tool-categories.ts` の `listToolCategories` | 設定画面に並ぶ分類のトグル |
| `bridge/tools-api.ts` の `loadBuiltinEntries` | `/api/tools` のツール一覧 |
| `workspace-tool-policy.ts` の個別 sensitive ループ | ワークスペース側から入れ直す道(層の向き) |
| `worker.ts` の `ensureKeepaGraphs` 呼び出し | 成果物に差し込まれる価格グラフ(外部の画像 URL) |
最後の 1 行が要る理由は、**群の中の関数を群の外から静的に呼んでいる**から。`import` で直に持ってくる経路はモジュール読み込みの関所を通らないので、呼ぶ側が自分で `isToolPackDisabled()` を見るしかない。ツール定義が消えても差し込みが残ると、成果物を開いた利用者の画面が毎回その外部の相手を叩く——「群を外せば縁が切れる」が嘘になる。**群の中の関数を群の外から import したら、必ずこの形で関所を通すこと。**
### 逆に、関所を通してはいけない 2 か所(保存の検査)
保存済みの設定に、いま止めた群の名前が残っていることがある。それを綴り間違いと同じ扱いで弾くと、**その名前を送ってくるクライアントの保存が全部落ちる。**
送ってくるのは次の 2 つ。画面から普通に開き直した場合は、分類の一覧に無いので送られない。
- 群を止める前に開かれたままのタブ
- API を直接叩くクライアント
自動プロンプトの規則だけは事情が違い、**画面から編集しても必ず送られる**(ツール名は規則そのものの中身で、ツール一覧から組み直されないため)。落ちるのは、その群のツールを名指ししている規則の保存だけ。規則は 1 本ずつ送るので、他の規則は保存できる。
| 場所 | 使うもの |
|---|---|
| `space-tool-policy-api.ts` の保存検査 | `listToolCategories({ includeEnvDisabled: true })` |
| `space-auto-prompter-api.ts` の `knownToolNames` | `getToolCategoryMap({ includeEnvDisabled: true })` |
**「知っている名前か」を判定する側だけ**が `includeEnvDisabled` を使う。実際にどのツールが使えるかを決める側で使うと、層の向きが崩れる。ツール名やカテゴリ名を allowlist で検査する場所を新しく足すときは、ここに並べること。
### 画面に出ていない名前を、保存で消さないこと
`space-tool-policy-api.ts` の保存(PUT)は、**保存済みの名前のうちいま画面に出ていないものを、送られてこなくても残す**(`hiddenStoredNames`)。
画面が送る名前は GET が返した分類一覧から組み立てられる。群を止めるとその分類は一覧から消えるので、以後どの保存でも送られない。送られないことを「入れ直した」と読むと、そのワークスペースが**明示的に切っていた分類**が黙って消え、管理者があとで群を戻したときに既定 ON で復活する。切ったという意思のほうが失われるので、見えない名前は保存済みの値を残す。
残す対象は「既知(環境で止めた分を含む)だが、いまの画面には出ていない」名前だけ。綴り間違いの残骸はこれまでどおり掃除される。ツール名やカテゴリ名を丸ごと置き換える保存 API を新しく足すときは、同じ形にすること。
### 判定の記録
「いつ・なぜ・誰の設定で」は `getToolPackDecisions()` に全部載る。起動時に `[tool-packs] resolved` の 1 行がログに出る。群ごとの記録を足したくなったらここを読む——**新しい記録の仕組みを作らないこと。**
### 確認方法
```bash
npx vitest run src/engine/tools/tool-pack-gate.test.ts \
src/engine/tools/tool-pack-gate.wiring.test.ts \
src/bridge/space-api.tool-policy.pack-gate.test.ts
```
台帳に群を足したら、`config.yaml.example` の一覧・`ui/src/content/help/19-admin.md` の表・更新履歴も合わせて直す。あわせて `pieces/*.yaml` をその群のツール名で grep すること。既定のワークフローが「必ず〜を使う」の形でそのツールを名指ししていたら、群を止めた環境ではその指示が存在しないツールを指す。名指ししているワークフロー名はヘルプの注意書きに出す。
---
## 0. ツールのカテゴリ区分(workspace tool policy)
新しいツールを追加した場合、または既存ツールのカテゴリを変更した場合は、**`src/engine/tools/tool-categories.ts`** の `MODULE_SPECS` に登録し、安全(標準)かセンシティブかの区分を決める。workspace tool policy はこの定義を参照して、ワークスペースごとにツールの利用可否を解決する。
> **先に 0-A を読むこと。**新しいモジュールは、分類も既定 OFF も自分の `TOOL_MODULE` 申告に書ける。以下は申告を持たない既存モジュールの経路。
- `SENSITIVE_CATEGORIES`(現在: `ssh` / `browser`)に含まれるカテゴリのツールはデフォルト無効
- `SENSITIVE_TOOLS`(現在: `Bash`)に列挙されたツールは、カテゴリが `core` であっても個別にセンシティブ扱い
- `SENSITIVE_TOOLS`(現在: `Bash` / `SpawnSubTask`)に列挙されたツールは、カテゴリが `core` であっても個別にセンシティブ扱い。`SpinOffTask` は `task-spawn.ts` の申告側にある
- 新カテゴリは安全側(デフォルト有効)が既定。意図的にセンシティブにする場合のみ `SENSITIVE_CATEGORIES` に追加する
**対象ファイル:**
@@ -78,7 +224,7 @@ CLAUDE.md のモジュールテーブルに新ツール名が含まれている
**対象ファイル:**
- `src/engine/tools/tool-categories.ts` — カテゴリ定義・`SENSITIVE_TOOLS` から旧名を削除/リネーム
- `src/engine/tools/raw-save.ts` — `RAW_SAVE_TOOLS` に旧名が残っていないか
- `ui/src/components/settings/ToolsForm.tsx` — ヘルプテキスト等にツール名の言及がないか
- `ui/src/components/settings/ToolsWebForm.tsx` / `ToolsMediaForm.tsx` / `ToolsExternalForm.tsx` — ヘルプテキスト等にツール名の言及がないか
- `CLAUDE.md` — ツールモジュール構成テーブル
**なぜ必要か:**
@@ -160,13 +306,23 @@ grep -A 20 'LEGACY_SECTION_REDIRECT' ui/src/components/settings/SettingsSidebar.
---
## 4-D. タスク spawn に新しいフィールドを追加した場合(二重実装ミラー)
タスク/ジョブの spawn 経路は **2 系統の並行実装** になっている(統合は別課題):
- 通常作成: `src/bridge/local-tasks-crud-api.ts`(POST /api/local/tasks → createLocalTask + createJob)
- スケジュール実行: `src/scheduler.ts` の `executeAgentScheduledTask()`(createLocalTask + createJob を独自に呼ぶ。Router Phase 2 は通らない・metadata block はハードコード)
タスクやジョブに新しいフィールド(例: issue #974 の `llmWorkerId`/`requiredWorkerId`)を追加したら、**両方の経路に配線されているか必ず確認する**。片方だけだと「UI から作ると効くがスケジュール実行だと落ちる」型の欠落になる(#974 がまさにこれ)。
## 5. SSRF 保護に関わる変更をした場合
**対象ファイル:**
- `src/engine/tools/shared/ssrf.ts` — SSRF チェックロジック本体
- `src/engine/tools/browser.ts` — BrowseWeb(`setupRouteInterception` + `ssrfCheck`)
- `src/engine/tools/web.ts` — WebFetch / DownloadFile
- `ui/src/components/settings/ToolsForm.tsx` — SSRF Allowed Hosts のヘルプテキスト
- `src/engine/tools/shared/ssrf.ts` — SSRF チェックロジック本体 + ブラウザへの装着(`installContextSsrfGuard`)
- `src/engine/tools/browser.ts` — BrowseWeb(`ensureContextHooks` / `openIsolatedGuardedPage` + `ssrfCheck`)
- `src/engine/tools/web.ts` — WebFetch / DownloadFile / 検索・スクショが借りる共有ブラウザ
- `src/engine/tools/x.ts` — X の card 画像取得(同じ共有ブラウザを借りる)
- `ui/src/components/settings/ToolsWebForm.tsx` — SSRF Allowed Hosts のヘルプテキスト
**なぜ必要か:**
`webfetchAllowedHosts` は WebFetch と BrowseWeb の両方で使われている。片方だけ修正すると保護に穴が開くか、意図しないブロックが発生する。
@@ -608,6 +764,17 @@ bwrap のマウント構成を変えた場合、セキュリティ境界が変
- **冪等な状態一致スキップ**: reconciler は `payload.status.state` が算出後の state と一致する場合は再書き込みしない(`waiting_human → input-required` の churn 防止)。`saveA2aTask` は task id の upsert なので executor と reconciler が競合しても二重書きは安全。
- **委任カラムの保持**: reconciler が `saveA2aTask` を呼ぶ際は `payload.metadata` の `a2aDelegationId` / `a2aGrantId` / `a2aActingUserId` を `a2a_tasks` の `delegation_id` / `grant_id` / `acting_user_id` 列へ写す(`task-store.ts` の save 規約と同一)。列を増やすときは executor の初期 Task metadata・`SqliteA2aTaskStore.save`・`reconciler.persistTask` の 3 箇所を揃えること。
### A2A 成果物ダウンロード(`artifact-download-api.ts` / `artifact-parts.ts`)を変更するとき
- **artifactId は決定的**: `deriveArtifactId(a2aTaskId, fileName)` から作る。ランダム UUID に戻すと、executor が流した ID と reconciler が組み直した ID が食い違い、`tasks/get` のたびに成果物の識別子が変わる(受け入れ条件「ID の安定性」が崩れる)。
- **URI は識別子だけ**: `{baseUrl}/a2a/artifacts/{taskId}/{artifactId}`。ワークスペースの実パス・worktree 名・`output/` 相対パスのいずれも URI へ入れないこと。
- **baseUrl は 1 か所から導出**: `resolveA2aBaseUrl`(`a2a-subsystem.ts`)を executor(`request-handler.ts` 経由)と reconciler の両方へ渡す。片方だけ変えると経路によって URI が変わる。
- **認可は書き写さない**: 可視性は `getLocalTask(id, { viewer })`(=`buildVisibilityWhere`)にそのまま乗せ、委任のスペース範囲は `principal.delegation.grantedSpaceIds` との AND 交差で重ねる。可視性のルール(持ち主・組織・公開・スペースメンバー)をこのルータへ再実装しないこと。
- **タスクと委任の突き合わせは grant_id ではなく当事者で**: `resolveTaskDelegationParty` が返す `acting_user_id` × 委任行の `client_id` で一致を見る。`grant_id` の一致に戻すと、アクセストークン(寿命 10 分・更新トークン無し)を取り直すための再承認が新しい grant を作り、それ以前に配ったリンクが一斉に 404 になる(PR #1111 レビュー P2-1)。引いた委任行の生死は見ないこと——提示されたトークン側の生死・期限は `authenticateA2aRequest` が引き直している。
- **`deriveArtifactId` の区切りは生バイトで書かない**: NUL はエスケープ表記(`\u0000`)で書く。生の 1 バイトをソースへ置くと git がファイル全体を二値と判定し、以後この中核モジュールの差分がどこでも読めなくなる。
- **どのファイルを返すかは記録済みメタ情報から引く**: クライアントが送れるのは taskId と artifactId だけ。パスを受け取るクエリ引数を足さないこと。パス解決は必ず `ensurePathWithin(workspace/output, name)` を通す(字句チェックと realpath チェックの両方がここに入っている)。
- **チャットコネクタ経路には baseUrl を渡さない**: `chat-connector-service.ts` は SDK の TaskStore を通さず `a2a_tasks` 行が残らないため、URI を載せると開けないリンクになる。
**確認方法:**
```bash
# A2A 関連テスト一括実行
@@ -638,8 +805,8 @@ grep -n "a2a_delegations\|a2a_skills" src/db/repository.ts
**対象ファイル(RequestPackage を例に):**
- `src/engine/tools/package-request.ts` — ツール本体(`TOOL_DEFS` + `executeTool`)。バリデーションは `engine/python-packages.ts` の `parseAndValidateSpec` / `assertNotShadowing` を再利用
- `src/engine/tools/core.ts` — `ToolContext` に park フラグ(`pendingPackageApproval`)+ recorder(`recordPackageRequest`)+ 補助状態(`installedPackages` / `declinedPackages`)
- `src/engine/tools/index.ts` — module loader / `Object.assign(allDefs, ...)` / **`META_TOOLS` 配列(2 箇所)** / dispatch 分岐
- `src/engine/tools/tool-categories.ts` — `META_TOOLS` Set にツール名を追加
- `src/engine/tools/index.ts` — module loader / `Object.assign(allDefs, ...)` / dispatch 分岐(**META の直書き配列は撤去済み。#1126**)
- 常時利用の登録先は 1 か所になった。モジュールの `TOOL_MODULE.metaTools` に書く(0-A 参照)。申告を持たない既存モジュールの分だけ `tool-categories.ts` の `META_TOOLS` Set に残っている
- `src/engine/agent-loop/pending-states.ts` — `buildPackageApprovalWaitResult`(park → `next:'WAITING_HUMAN_PACKAGE_REQUEST'`)
- `src/engine/agent-loop.ts` — import + `buildToolApprovalWaitResult` の隣で呼ぶ
- `src/engine/piece-runner.ts` — `WAITING_HUMAN_PACKAGE_REQUEST` sentinel 分岐(waiting_human park)+ **2 つの PieceRunOptions interface** に recorder/補助状態を追加 + ctx 構築で piece/movement を注入
+27 -1
View File
@@ -40,12 +40,21 @@ worker.ts
| `COMPLETE` | `complete` ツール | `completed` | — | — | 終了 |
| `ABORT`(`cancelled` 含む) | キャンセル経路 | `cancelled` | — | — | 終了 |
| `ABORT` | `complete`/各種ガード(`text_only_limit`・`max_iterations` 等) | `aborted` | — | — | 終了 |
| `ASK` | `complete({status:'needs_user_input'})` | `waiting_human`(上限超で `aborted`) | 現在の movement | — | 待機 |
| `ASK` | `complete({status:'needs_user_input'})` | `waiting_human`(上限超は停車せず、同じ movement へ戻して自己判断を促す。差し戻しが `MAX_ASK_LIMIT_DEFLECTIONS` 回に達したら `ask_limit_reached` で打ち切り。#1299) | 現在の movement | — | 待機 |
| `WAIT_SUBTASKS` | `transition`(rules 経由)/**WaitSubTask ツール(提案)** | `waiting_subtasks` | `default_next`(ツール版は現在の movement に変更予定) | — | 待機 |
| `WAITING_HUMAN_BROWSER` | `BrowseWeb`(ログイン要求時) | `waiting_human` | 現在の movement | `browser_login` | 待機 |
| `WAITING_HUMAN_BROWSER` | `RequestCaptchaHelp`(検索が CAPTCHA で全滅したとき・#1199 段階 2) | `waiting_human` | 現在の movement | `captcha` | 待機 |
| `WAITING_HUMAN_TOOL_REQUEST` | `RequestTool` | `waiting_human` | 現在の movement | `tool_request` | 待機 |
| `WAITING_HUMAN_SPAWN_REQUEST` | `SpinOffTask`(別ワークスペース指定時) | `waiting_human` | 現在の movement | `spawn_request` | 待機 |
| `null` | 異常(ツール未決着) | `error` | — | — | 異常 |
> `WAITING_HUMAN_BROWSER` は**待ち理由違いで 2 つの入口**を共有する例。人が解く相手が
> 「そのタスクのブラウザ」か「ワークスペース共有の確認画面」かだけが違い、下流の分岐
> (piece-runner)は同じものを通る。再開の口は別で、captcha 側は
> `POST /captcha-pool/resolve` が**同じ画面で止まっている全ジョブ**をまとめて queued に
> 戻す(`resumeCaptchaJobsForSpace`)。どの画面を待っているかは `jobs.captcha_space_id`
> に残す——`jobs.space_id` は個人ワークスペースだと NULL のままなので代用できない。
> `WAIT_SUBTASKS` だけは現状 `rules[].next` にも書ける(rules ベース経由)。ツール版(WaitSubTask)を足すと、**同じ sentinel に「ルール不要の入口」**が増える。下流の `mapMovementResult` 分岐は共有。
## 新しい park ツールを足すレシピ
@@ -63,8 +72,25 @@ worker.ts
- **ツール内で同期ブロックして待たない。** 待機は必ず「停車=ワーカー解放」で表現する。in-tool で `await` し続けると親ワーカーを占有し、最悪ワーカー枯渇でデッドロックする(`WAIT_SUBTASKS` が遷移=停車である理由)。
- **resume 先を意識する。** `default_next` が `COMPLETE` のピース(単一 movement)で再開させると終了に飛ぶ。多くの待機ツールは**現在の movement に再入**させて、再開後に結果を読んで `complete` させる。
- **terminal と park を混同しない。** `COMPLETE`/`ABORT` はジョブを終わらせる(再開なし)。`WAITING_*`/`WAIT_SUBTASKS` は停車→再開。`resumeMovement` の有無が分かれ目。
- **停車から戻ったら同じ呼び出しが来る前提で書く。** 再開は movement を頭からやり直すので、LLM は同じ引数で同じツールをもう一度呼ぶ。決着済みの申請を掴んで短絡する経路(`RequestPackage` の `declinedPackages` / `installedPackages`、`SpinOffTask` の `findDecidedSpawnRequestForJob`)が無いと、却下で永久ループ・承認で 2 枚目のカードになる。
- **sentinel は `rules` の enum に出さない。** これらは engine-internal。`rules[].next` に書けるのは別 movement 名と `WAIT_SUBTASKS` のみ(`loadPiece`/lint が `COMPLETE`/`ABORT`/`ASK` を reject する)。
## KV キャッシュと tools 安定性(2026-07-22 実測・変更禁止事項)
transition ツールは **movement 毎ではなく piece 全体で 1 つ**(enum = `collectPieceTransitionTargets` による全 movement の遷移先の和集合、description は静的)。一見「その movement の rules だけを enum に出す」方が正しく見えるが、**戻してはいけない**。
根拠は llama-server (llama.cpp) への統制プローブと実タスク計測(`return_progress` の `prompt_cache` ログ、PR #931)で確定した以下の実測法則:
1. 追記のみのリクエスト → ほぼ全キャッシュ再利用(評価は追記分のみ)
2. 末尾付近の書き換え → 分岐点以降のみ再評価(部分再利用は健在)
3. 共通接頭辞が全体の半分未満 → 全損(`slot_prompt_similarity` 既定 0.5 の門前払い)
4. **tools 配列の変更 → 位置・大小問わず全損(description 1 文字でも)**
5. 描画順は system 本文 → tools → 会話(Qwen3 テンプレート)
movement 毎に transition ツールを作り替えると法則 4 に直撃し、**遷移のたびに会話全体 (~20k トークン / ~12 秒) を再評価**する(実測: 遷移直後の再利用率 0%、tools 安定なら 99%+)。movement 単位の遷移制約は runtime 検証(`processTransitionCalls` → `validateTransition`、無効なら有効選択肢つきエラーを tool result で返す)と、movement guidance の選択肢一覧(会話への追記なのでキャッシュ無害)が引き続き完全に担保している。
この構造を変更する場合は、開発サーバーで movement 遷移を跨ぐタスクを流し、`[llm-stream] ... prompt_cache reused=/pct=` で遷移直後の再利用率が 99% 台を維持することを必ず実測確認すること。
## 関連
- 設計: `docs/design/2026-06-26-subtask-wait-and-safety.html`(WaitSubTask ツール=この型の新規適用例)
@@ -96,6 +96,16 @@ safety:
`scripts/server.sh restart`。コード変更不要、設定のみで戻せる。
**bwrap が壊れているホストで戻す場合は、これだけでは起動しない**(#1235)。`auto` の hardened フォールバックには外向き通信を遮断する仕組みが無いので、遮断を選んだまま(`bash_allow_network` 既定の `false`)だと起動時に断られる。この状況で戻すなら次のいずれか。
| 状況 | 設定 |
|------|------|
| bwrap は動く(プロビジョニングをやめたいだけ) | 上のまま。`bash_allow_network` は `false` のまま |
| bwrap が動かない | `bash_allow_network: true` を併記する。**bwrap を直したら `false` へ戻すこと**——これは表明ではなく生きたスイッチで、bwrap が動く状態のまま `true` だと本当に外向き通信が開く |
| そもそも隔離を切る | `bash_sandbox: off`。断りの対象外なので、通信の設定は触らなくていい |
`bash_unrestricted: true` を入れている場合は、`bash_sandbox` を何にしても bwrap が要る。戻すときは `bash_unrestricted: false` も併せて戻す。
## 補足: Docker イメージ運用の場合
`Dockerfile` (`node:22-alpine`) には `bash bubblewrap python3 py3-pip` + `pip install -r runtime/python-requirements.txt`(`--break-system-packages`)を追加済み。ただし alpine/musl では `pandas/numpy/lxml/Pillow/pymupdf` の wheel 解決にビルド依存が要る場合があるため、イメージビルドを実機で検証すること(`pymupdf>=1.24` に pin 済)。コンテナ運用では `features: nesting=1`(user namespace)が前提。
+524
View File
@@ -0,0 +1,524 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Caddy + Cloudflare でワイルドカード証明書 — MAESTRO</title>
<link rel="stylesheet" href="guide.css">
</head>
<body>
<div class="wrap">
<header class="doc">
<div class="crumbs"><a href="index.html">運用ガイド</a> / Caddy + Cloudflare でワイルドカード証明書</div>
<h1>Caddy を前に置いて HTTPS 化する <span class="badge done">完成</span></h1>
<p class="meta">対象: 手元やイントラの環境で MAESTRO を正式な証明書つきの HTTPS で使いたい人 / 更新日: 2026-08-10 / 由来: issue #1094</p>
</header>
<h2>このガイドで作るもの</h2>
<p>
Caddy をリバースプロキシとして MAESTRO の前に置き、Cloudflare の DNS を使って
Let's Encrypt からワイルドカード証明書を取ります。証明書の取得も 60 日ごとの更新も Caddy が自分でやるので、
運用中に手を入れる作業は残りません。
</p>
<table>
<thead><tr><th>項目</th><th>内容</th></tr></thead>
<tbody>
<tr><td>証明書の取り方</td><td>Let's Encrypt の DNS-01 チャレンジ。Cloudflare の API で一時的な TXT レコードを置いて検証する</td></tr>
<tr><td>ポート 80 の要否</td><td>発行には不要。80 番が塞がれている回線でも取れる(ここが HTTP-01 との違い)</td></tr>
<tr><td>ワイルドカード</td><td>取れる。DNS-01 でしか取れない</td></tr>
<tr><td>更新</td><td>Caddy が自動で行い、自分で差し替える。再起動は不要</td></tr>
<tr><td>MAESTRO 側の証明書</td><td>持たせない。TLS は Caddy で終端する</td></tr>
</tbody>
</table>
<div class="note">
<b>MAESTRO 自身に証明書を持たせる道もあります</b><br>
<code>server.tls.cert_file</code> / <code>key_file</code> に運用者の証明書を指すやり方です。ただし MAESTRO は<b>起動時に 1 回しか読みません</b>。
自動更新した証明書は再起動するまで反映されず、期限が来るとそこで繋がらなくなります。
プロキシを前に置けば MAESTRO は証明書を持たなくなるので、この問題ごと消えます。
</div>
<h2>前提</h2>
<ul>
<li>Docker Compose で MAESTRO を動かしていること。Compose は <b>v2.24 以上</b>(<code>docker compose version</code> で確認)。</li>
<li>使いたいドメインの DNS を <b>Cloudflare が管理していること</b>(ネームサーバーが Cloudflare を向いている)。</li>
<li>そのドメインの名前が<b>公開 DNS から引けること</b>。手元の DNS サーバーだけで解決している名前では発行できません。</li>
</ul>
<p>
証明書はドメイン単位で取ります。手元の機械が外から見えている必要はありません。
<code>maestro.example.com</code> の A レコードを <code>10.0.0.10</code> のような内側のアドレスに向ける使い方で問題ありません。
</p>
<h2>ステップ 1 — Cloudflare の API トークンを作る</h2>
<p>
Cloudflare のダッシュボードで <b>My Profile → API Tokens → Create Token</b> を開き、
<b>Custom token</b> を選んで次のとおり作ります。
</p>
<table>
<thead><tr><th>設定</th><th>値</th></tr></thead>
<tbody>
<tr><td>Permissions(1つ目)</td><td>Zone / Zone / <b>Read</b></td></tr>
<tr><td>Permissions(2つ目)</td><td>Zone / DNS / <b>Edit</b></td></tr>
<tr><td>Zone Resources</td><td>Include / Specific zone / <b>対象のゾーン 1 つだけ</b></td></tr>
</tbody>
</table>
<div class="warn">
<b>Global API Key は使わないこと</b><br>
アカウント全体を操作できる資格情報です。上のトークンは「そのゾーンの DNS を読み書きする」だけに限られており、漏れたときの被害範囲がまるで違います。
<br><br>
権限が <b>2 つ</b>必要な点に注意してください。DNS の編集だけでは足りず、ゾーンの読み取りも要ります(Caddy 側のプラグインがゾーン ID を引くため)。
</div>
<h2>ステップ 2 — DNS レコードを置く</h2>
<p>Cloudflare の DNS 設定に、MAESTRO を出したいホスト名の A レコードを追加します。</p>
<table>
<thead><tr><th>Type</th><th>Name</th><th>Content</th><th>Proxy status</th></tr></thead>
<tbody>
<tr><td>A</td><td>maestro</td><td>Caddy を動かす機械のアドレス</td><td><b>DNS only</b>(オレンジの雲を消す)</td></tr>
</tbody>
</table>
<div class="note">
<b>Proxied(オレンジの雲)にしない</b><br>
内側のアドレスを指す場合、Cloudflare のプロキシを通すと届きません。証明書の発行自体は TXT レコードで行うので、A レコードは DNS only のままで足ります。
</div>
<h2>ステップ 3 — 設定ファイルを用意する</h2>
<p>リポジトリのルートで見本をコピーし、値を埋めます。</p>
<pre><code>cp .env.caddy.example .env.caddy</code></pre>
<table>
<thead><tr><th>キー</th><th>意味</th><th>例</th></tr></thead>
<tbody>
<tr><td><code>CLOUDFLARE_API_TOKEN</code></td><td>ステップ1で作ったトークン</td><td>—</td></tr>
<tr><td><code>MAESTRO_BASE_DOMAIN</code></td><td>ワイルドカード証明書の対象。<code>*.</code> を付けた形で発行される。<b>必ず書き換える</b></td><td><code>example.com</code></td></tr>
<tr><td><code>MAESTRO_HOSTNAME</code></td><td>MAESTRO を出すホスト名。上の 1 つ下の階層にすること。<b>必ず書き換える</b></td><td><code>maestro.example.com</code></td></tr>
<tr><td><code>GATEWAY_HOSTNAME</code></td><td>ゲートウェイも出す場合だけ(後述)。使わないなら<b>行ごとコメントアウトのまま</b></td><td><code>gateway.example.com</code></td></tr>
<tr><td><code>GATEWAY_UPSTREAM_PORT</code></td><td>ゲートウェイの待ち受けポートを既定から変えている場合だけ。既定のままなら<b>行ごとコメントアウトのまま</b></td><td><code>4000</code></td></tr>
</tbody>
</table>
<div class="warn">
<b>使わないキーは「行ごとコメントアウトしたまま」にすること</b><br>
<code>GATEWAY_HOSTNAME=</code> のように<b>等号だけ書いた空の行を残さないでください</b>。
見本ファイルは該当行を <code>#</code> で始めた状態で配っているので、コピーしただけなら問題ありません。
<br><br>
Caddy の設定で使っている既定値つきの置換(<code>{$NAME:既定値}</code>)は、<b>その変数が未設定のときだけ</b>既定値に落ちます。
空文字を設定すると空文字がそのまま入り、次のようになります。
<ul>
<li><code>GATEWAY_HOSTNAME=</code> → Caddy が <code>module value cannot be null</code> で起動しない(スタック全体が上がらない)</li>
<li><code>GATEWAY_UPSTREAM_PORT=</code> → <b>エラーも警告も出ないまま</b> <code>maestro:80</code> へ中継され、502 になる</li>
<li><code>MAESTRO_BASE_DOMAIN=</code> → 証明書の対象が <code>*.</code> という成立しない名前になり、<b>黙って</b>発行を要求し続ける</li>
</ul>
見本の <code>CHANGE-ME.example</code> は穴埋め用の値です。書き換えずに起動すると、存在しないドメインで発行を試み続けます。
</div>
<div class="warn">
<b>Cloudflare のトークンは <code>.env</code> に書かないこと</b><br>
<code>.env</code> は MAESTRO のコンテナの環境変数として読み込まれます。エージェントは Bash ツールを持っているので、
そこに置いたトークンは<b>エージェントから読めます</b>。
プロキシ向けの値だけを <code>.env.caddy</code> に分けてあるのはこのためで、こちらは Caddy のコンテナしか読み込みません。
</div>
<p>
ワイルドカードが覆うのは 1 階層分だけです。<code>maestro.home.example.com</code> を使いたいなら
<code>MAESTRO_BASE_DOMAIN=home.example.com</code>(証明書は <code>*.home.example.com</code>)にします。
</p>
<h2>ステップ 4 — <code>config.yaml</code> をホスト側に置く(起動より先)</h2>
<p>
次のステップで <code>config.yaml</code> を書き換えます。その前に、<b>設定ファイルがホスト側に残る形にしておく必要があります</b>。
Docker の既定では <code>config.yaml</code> はイメージの中にあり、どのボリュームにも入っていません。
重ねがけを始めた最初の起動で MAESTRO のコンテナが作り直されるので、<b>コンテナの中で書いた設定はそこで消えます</b>。
</p>
<div class="warn">
<b>OAuth(Google / Gitea)でログインしている環境は、必ずここを先にやること</b><br>
<code>config.yaml</code> が初期状態に戻ると、<b>認証の設定ごと消えます</b>。
自動で復旧する仕組み(<code>src/bridge/initial-admin.ts</code>)はパスワードを持つローカル管理者がいる前提なので、
<b>OAuth だけで運用している環境では働きません</b>。認証設定が空のまま「意図的に認証なし」と解釈され、
<b>認証がかかっていない状態で立ち上がります</b>。
<br><br>
そのとき Caddy はもう HTTPS の口を開けています。<b>過去のデータをすべて抱えたまま、誰でも入れる状態</b>になり、
エージェントは Bash ツールを持っているので、実質的に遠隔からの任意コード実行になります。
<b>重ねる前に、必ず設定ファイルをホストへ退避してください。</b>
<br><br>
<b>この先の 4-2 を飛ばした場合も、行き着く先は同じです。</b>ホスト側にファイルを置かずに起動すると
Docker が同名のディレクトリを作り、MAESTRO はそれを読もうとして失敗しますが、
<b>その失敗は起動を止めません</b>(設定を読めなかったという警告が 1 行出るだけで、設定が空のまま先へ進みます)。
OAuth だけの環境では、そこから上と同じ経路に落ちます。
</div>
<h3>4-1. 上書きファイル側の指定(確認だけ・作業なし)</h3>
<p>
<code>docker-compose.caddy.yml</code> には、設定ファイルをホストに置くための指定がすでに入っています。書き足す作業はありません。
</p>
<pre><code>services:
maestro:
volumes:
- ./config.yaml:/app/config.yaml</code></pre>
<p>
リポジトリのルートの <code>config.yaml</code> がコンテナの中の設定ファイルになります。
以降は設定画面からの保存も、手で書いた変更も、コンテナを作り直しても残ります。
<b><code>:ro</code> は付けないでください</b>(設定画面からの保存が書き込むため)。
</p>
<div class="warn">
<b>ベースの <code>docker-compose.yml</code> で設定ファイルを別のパスから渡している場合は、その指定が捨てられます</b><br>
受け渡しはコンテナ側のパス(<code>/app/config.yaml</code>)を鍵に合成されるため、
ホスト側に <code>./config-prod.yaml</code> のような別名を書いていると、<b>上書きファイル側の <code>./config.yaml</code> が勝ちます</b>。
警告もエラーも出ないまま、<b>いままで使っていた設定が読まれなくなります</b>。
自分のパスに揃えるか、<code>docker-compose.caddy.yml</code> の該当行を自分のパスに書き換えてください。
そのまま重ねると、標準の名前のファイルがホストに無い限り、下の 4-2 の警告と同じ結末になります。
</div>
<h3>4-2. ホスト側の <code>config.yaml</code> を用意する</h3>
<div class="warn">
<b>起動より先に、実体のファイルを置くこと</b><br>
ホストに <code>./config.yaml</code> が無いまま起動すると、Docker が <b><code>config.yaml</code> という名前のディレクトリを作ります</b>。
MAESTRO はそれを設定ファイルとして読もうとして失敗しますが、<b>この失敗では止まりません</b>。
設定は<b>認証設定を持たない状態で読み込まれます</b>。
<br><br>
そこから先は環境で割れます。<b>パスワードを持つローカル管理者がいる環境と、新規インストールは起動を拒否します</b>
(認証を書き戻そうとしてディレクトリに書けず、その旨を名指しして止まります)。
しかし <b>OAuth(Google / Gitea)だけで運用している環境は、認証なしで起動してしまいます</b>。
書き戻しの分岐がどれも成立せず、空の設定が「意図的に認証なし」と解釈されるためです。
<b>この節をやってから起動してください。</b>やり直しの手当てではなく、これが唯一の防ぎ方です。
</div>
<p><b>すでに MAESTRO を動かしている場合</b> — 重ねる前に、動いているコンテナから取り出します。</p>
<pre><code>docker cp maestro:/app/config.yaml ./config.yaml</code></pre>
<p><b>これから始める場合</b> — イメージが同梱する初期状態と同じ 1 行だけのファイルを作ります。</p>
<pre><code>printf 'config_version: 2\n' &gt; ./config.yaml</code></pre>
<p>
<b>持ち主と書き込み権限</b> — コンテナの中の MAESTRO は root ではなく <code>node</code>(UID 1000)で動くので、
ホスト側のファイルは<b>その UID から書ける状態</b>にしておきます。上の 2 通りはどちらも、
実行した人の持ち物になります。ホストの利用者が UID 1000 でないなら、所有者を合わせてください。
</p>
<pre><code>sudo chown 1000:1000 ./config.yaml</code></pre>
<p>
食い違ったままだと、<b>すでに動いている環境では設定画面からの保存が静かに失敗します</b>(起動は通るので気づきにくい)。
新規インストールでは起動を拒否し、書き込めない旨を名指しするメッセージが出ます。
</p>
<p>
<b>配布用のドキュメントとの食い違いについて</b> — 配布版の <code>docs/docker.md</code>(この repo では <code>oss/overlay/docs/docker.md</code>)には
<code>cp config.yaml.example config.yaml</code> で作る案内がありますが、そちらは完成済みの見本なので、
<b>インストール済みと判定されてセットアップウィザードが出ません</b>(さらに、動いていない localhost の LLM を指した状態になります)。
イメージが同梱する初期状態は 1 行だけのファイルなので、<b>ここに書いたほうに従ってください</b>。
</p>
<p>
どちらの場合も、この先の編集はすべて<b>この、ホスト側の <code>config.yaml</code></b> に対して行います。
コンテナの中に入って書く必要はありません。
</p>
<h2>ステップ 5 — MAESTRO 側の設定を 2 か所直す</h2>
<p>
ステップ4で用意した<b>ホスト側の <code>config.yaml</code></b> を編集します。
<b>この 2 つを外すと、動かないか、危ない状態になります。</b>
</p>
<h3>5-1. MAESTRO 自身の TLS は切る</h3>
<p>
Caddy が TLS を終端するので、MAESTRO 側でも終端すると二重になって噛み合いません。
<code>config.yaml</code> の <code>server.tls</code> ブロックを<b>書かない</b>か、<code>enabled: false</code> にします。
</p>
<pre><code># config.yaml — プロキシ構成では server ブロックごと省くのが確実
# server:
# tls:
# enabled: false</code></pre>
<p>
Docker のイメージが同梱する初期 <code>config.yaml</code> には <code>server</code> ブロックが入っていないため、
Docker で始めた環境は既定で TLS が切れています。自分で有効にした覚えがなければ、この項目の作業は不要です。
</p>
<h3>5-2. 安全なクッキーを有効にする</h3>
<p>
<code>auth.secure_cookie: true</code> にします。効き目は 2 つです。
</p>
<pre><code>auth:
secure_cookie: true</code></pre>
<table>
<thead><tr><th>入れると起きること</th><th>入れないとどうなるか</th></tr></thead>
<tbody>
<tr>
<td>セッションのクッキーに <b>secure 属性</b>が付く</td>
<td>クッキーが平文の HTTP でも送られる状態のままになります</td>
</tr>
<tr>
<td><b>転送元の情報を信頼する設定(trust proxy)が連動して入り</b>、接続元アドレスの判定が転送ヘッダを見るようになる</td>
<td>
<b>これが本題です。</b>Caddy を挟むと、MAESTRO からは<b>全利用者が同じ接続元アドレスに見えます</b>。
ログイン試行の制限は接続元アドレスごとに数えているので、全員が<b>ひとつの枠を共有</b>することになり、
<b>全員合わせて 15 分間に 10 回失敗すると、15 分間まとめて締め出されます</b>。
1 人が 10 回間違える必要はありません。別々の 10 人が 1 回ずつ打ち間違えても同じ枠に積まれます。
<b>発動する前なら</b>、誰か 1 人がログインに成功した時点で積んだ回数は消えます。
発動したあとは成功では戻らず(締め出しの判定はパスワードの照合より手前で打ち切ります)、15 分が明けるのを待つことになります。
</td>
</tr>
</tbody>
</table>
<p>
この締め出しは、メールとパスワードでログインする構成のときに起きます(OAuth だけの環境には、この制限そのものがありません)。
なお <b>この設定が無くても HTTPS 越しのログイン自体は通ります</b>。プロトコルによって行き先が変わるリダイレクトは MAESTRO のコードにありません。
「入れないと繋がらない」ではなく「入れないと、全員が道連れで締め出される」と考えてください。
</p>
<div class="warn">
<b>この設定を入れると、Caddy を通さない直接アクセスではログインできなくなります</b><br>
secure 属性の付いたクッキーは平文の HTTP では送られません。切り分けのために
<code>http://&lt;host&gt;:9876</code> へ直接繋いで確かめる、という使い方はできなくなります(そもそもステップ6でポートを閉じます)。
</div>
<h2>ステップ 6 — 起動する</h2>
<h3>6-1. 2 つのファイルを既定にする(推奨)</h3>
<p>
先に <code>.env</code>(<code>.env.caddy</code> ではありません)に次の 1 行を足してください。
以降は <code>docker compose</code> のどのコマンドでも 2 つのファイルが読まれます。
</p>
<pre><code>COMPOSE_FILE=docker-compose.yml:docker-compose.caddy.yml</code></pre>
<div class="warn">
<b>これを設定しないと、<code>-f</code> を付け忘れた起動が 9876 番を開き直します</b><br>
重ねがけを始めたあとに、習慣で <code>docker compose up -d</code> とだけ打つと、Compose は<b>ベースのファイルだけ</b>を見ます。
MAESTRO のコンテナが作り直され、<b>平文の 9876 番がホストに再び公開されます</b>。
Caddy のコンテナは孤児として検出されるだけで止まらないので、<b>HTTPS の口と平文の口が同時に開いた状態</b>になります。
ブラウザから見ると何も変わっていないので、気づきません。
</div>
<p>
<code>COMPOSE_FILE</code> は <code>.env</code> に書いて構いません。ステップ3で「トークンは <code>.env</code> に書くな」と書いたのは、
<code>.env</code> が MAESTRO のコンテナの環境変数として読み込まれ、エージェントから読めてしまうからです。
<code>COMPOSE_FILE</code> は秘密ではなく、Compose 自身がどのファイルを読むかの指定なので、読まれても実害がありません。
<br>
<small>(<code>COMPOSE_FILE</code> は Compose の実行時に解釈されるため、<code>.env</code> に置く必要があります。<code>.env.caddy</code> に書いても効きません。)</small>
</p>
<h3>6-2. 起動する</h3>
<pre><code>docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d --build</code></pre>
<p>(6-1 を設定済みなら <code>docker compose up -d --build</code> だけで同じです。)</p>
<p>
初回は Caddy のイメージをビルドします。公式の配布物には Cloudflare の DNS 対応が入っていないため、
<code>xcaddy</code> で組み込んだものを作ります(<code>deploy/caddy/Dockerfile</code>)。数分かかりますが、初回だけです。
</p>
<p>ログで発行を確認します。</p>
<pre><code>docker compose -f docker-compose.yml -f docker-compose.caddy.yml logs -f caddy</code></pre>
<p>
<code>certificate obtained successfully</code> が出れば取れています。
あとは <code>https://maestro.example.com</code> を開くだけです。
</p>
<div class="tip">
<b>初回は検証用の発行先で試し撃ちしておくと安全です</b><br>
Let's Encrypt は<b>発行の失敗にも回数制限</b>があり、設定を直したあとしばらく取れない状態に入ることがあります。
<code>deploy/caddy/Caddyfile</code> の先頭に置く global options block に
<code>acme_ca https://acme-staging-v02.api.letsencrypt.org/directory</code> を 1 行足すと、検証用の発行先に向きます。
通ることを確かめたらこの行を消して起動し直せば、本番の発行先から取り直します(検証用の証明書はブラウザに信頼されないので、そのままでは使えません)。
検証用と本番の証明書は <code>caddy-data</code> の中で別々に置かれるので、<b>ボリュームを消す必要はありません</b>。
</div>
<h2>何が変わったか</h2>
<table>
<thead><tr><th></th><th>前</th><th>後</th></tr></thead>
<tbody>
<tr><td>外に出ているポート</td><td>MAESTRO の 9876(全ネットワーク面)</td><td>Caddy の 80 / 443 のみ</td></tr>
<tr><td>MAESTRO の 9876</td><td>ホストに公開</td><td><b>公開しない</b>。Docker の内側から Caddy だけが届く</td></tr>
<tr><td>TLS の終端</td><td>MAESTRO 自身(自己署名)または平文</td><td>Caddy(正式な証明書)</td></tr>
<tr><td><code>config.yaml</code></td><td>イメージの中。コンテナを作り直すと初期状態に戻る</td><td>ホスト側の <code>./config.yaml</code>。作り直しても残る</td></tr>
</tbody>
</table>
<p>
ポートを閉じているのは <code>docker-compose.caddy.yml</code> の <code>ports: !reset []</code> という 1 行です。
Compose の <code>profiles</code> ではこれができません。<code>ports</code> は
<code>{ip, target, published, protocol}</code> を一意キーとして<b>追記</b>されるだけで、
元のファイルが書いた公開を取り消す手段が無いためです。
重ねたファイルから元の値を消すには <code>!reset</code> を使います(<code>!override []</code> と書いても同じ結果になります)。
</p>
<div class="note">
<b>Compose の版について</b><br>
<code>!reset</code> 自体は v2.24 より前から使えます。<b>v2.24 以上</b>を要求しているのは
ベースの <code>docker-compose.yml</code> が使っている <code>env_file</code> の新しい書き方(<code>path:</code> / <code>required:</code>)のほうです。
そのため v2.24 未満の環境では、重ねる以前にベースのファイルが読めません。<b>Compose を上げてください。</b>
</div>
<h2>ゲートウェイ(AAO Gateway)も HTTPS で出す場合</h2>
<p>
<b>ゲートウェイを使っていないなら、この節は読み飛ばして構いません。</b>
</p>
<p>
ゲートウェイ(この MAESTRO 自身を OpenAI 互換の中継口として公開する仕組み)は、
<b>MAESTRO 本体とは別のポートで待ち受けます</b>。同じプロセスで動かしていても口は別です(既定 4000)。
プロキシを通すなら、そちら向けの設定も要ります。
</p>
<ol class="steps">
<li>Cloudflare に <code>gateway</code> の A レコードを追加する(ステップ2と同じ要領。同じアドレスでよい)。</li>
<li><code>.env.caddy</code> の <code>GATEWAY_HOSTNAME</code> のコメントを外して値を入れる。<b>コメントを外したら必ず値を書くこと</b>(等号だけの空の行は、Caddy を起動しなくします)。</li>
<li><code>config.yaml</code> の <code>gateway.listen_port</code> を既定(4000)から変えている場合<b>だけ</b>、<code>GATEWAY_UPSTREAM_PORT</code> のコメントを外して同じ値を書く。既定のままなら<b>コメントアウトのまま</b>にする(外して空にすると、黙って 80 番へ中継されます)。</li>
<li>再起動する(<code>up -d</code> をもう一度)。</li>
</ol>
<p>証明書は本体と同じワイルドカード 1 枚で覆われるので、追加の発行はありません。</p>
<div class="warn">
<b>ゲートウェイのホスト名も <code>MAESTRO_BASE_DOMAIN</code> の 1 つ下の階層にすること</b><br>
別のドメインにしたり、2 階層下(<code>gw.sub.example.com</code> のような形)にしたりすると、
ワイルドカード証明書もサイトの照合も覆いません。そのホスト名は<b>無反応になり、Caddy は何も言いません</b>。
本体のホスト名と同じ制約です。
</div>
<table>
<thead><tr><th>気にしなくてよいこと</th><th>理由</th></tr></thead>
<tbody>
<tr>
<td>応答の溜め込み</td>
<td>ゲートウェイは生成されたトークンを 1 つずつ流します。Caddy は中継する応答を溜め込まないので、そのまま届きます(nginx は既定で溜め込むため、そちらを選んだ場合は明示的に切る必要がありました)。</td>
</tr>
<tr>
<td>長い生成の時間切れ</td>
<td>ゲートウェイは 1 リクエストに最大 600 秒(<code>gateway.request_timeout_sec</code>)を認めています。Caddy は中継に時間制限を設けないので、途中で切られません。</td>
</tr>
<tr>
<td>流量制限が一緒くたになる</td>
<td>
ゲートウェイの制限は<b>発行した鍵ごと</b>に数えており、接続元のアドレスは見ていません。プロキシを挟んで全員が同じアドレスに見えても、混ざりません。
<br><b>これはゲートウェイの流量制限の話です。ログイン試行の制限は別物で、そちらは接続元のアドレスで数えます</b>(ステップ5-2 の安全なクッキーを入れること)。
</td>
</tr>
</tbody>
</table>
<div class="warn">
<b>ゲートウェイのポートを自分でホストに公開していた場合</b><br>
<code>docker-compose.yml</code> に <code>4000:4000</code> のような行を足していたなら、その行は消してください。
Caddy 経由にしたのにポートも出したままだと、平文の口が残ります。
</div>
<h2>よくある詰まり</h2>
<table>
<thead><tr><th>症状</th><th>原因 / 対処</th></tr></thead>
<tbody>
<tr>
<td><code>env file .env.caddy not found</code> で起動しない</td>
<td>見本をコピーしていない。<code>cp .env.caddy.example .env.caddy</code> を実行する。<b>意図的に失敗させています</b>(トークン無しで黙って起動しても証明書は取れないため)。</td>
</tr>
<tr>
<td><code>API token ... appears invalid</code></td>
<td>
<code>CLOUDFLARE_API_TOKEN</code> の<b>値の形式が合っていない</b>——空、引用符や波括弧で囲んでいる、貼り付けが途中で欠けている、など。値だけを裸で書く。
<b>この検査は設定の読み込みの早い段階で走るので、これが出ている間はほかの設定の誤りは表示されません。</b>
トークンを直して初めて、下の行のようなエラーが出てくることがあります。
</td>
</tr>
<tr>
<td><code>module value cannot be null</code> で Caddy が起動しない</td>
<td>
<code>MAESTRO_HOSTNAME</code> / <code>GATEWAY_HOSTNAME</code> のどれかが<b>空文字</b>になっている。
とくに <code>GATEWAY_HOSTNAME=</code> と等号だけ書いた行が原因になりがちです(使わないなら<b>行ごとコメントアウト</b>に戻す)。
<code>MAESTRO_BASE_DOMAIN</code> を空にしてもこの文言は出ません(下の行を参照)。
</td>
</tr>
<tr>
<td>証明書が取れず、ログに <code>*.</code> という対象が出る</td>
<td>
<code>MAESTRO_BASE_DOMAIN</code> が<b>空文字</b>。Caddy の設定検証は通ってしまうため、エラーは出ません。
成立しない名前で発行を要求し続けるので、気づかないと Let's Encrypt の失敗回数の制限に当たります。値を書く。
</td>
</tr>
<tr>
<td>ゲートウェイのホスト名だけ 502 になる</td>
<td>
<code>GATEWAY_UPSTREAM_PORT=</code> と等号だけ書いていて、中継先が <code>maestro:80</code> になっている。
<b>Caddy は何も言いません。</b>既定(4000)のままなら行ごとコメントアウトに戻す。変えているなら値を書く。
</td>
</tr>
<tr>
<td>ゲートウェイのホスト名が何も返さない/繋がらない</td>
<td><code>GATEWAY_HOSTNAME</code> が <code>MAESTRO_BASE_DOMAIN</code> の 1 つ下の階層になっていない。別ドメインや 2 階層下は覆われません。</td>
</tr>
<tr>
<td><code>expected 1 zone, got 0</code></td>
<td>
Cloudflare にゾーンを問い合わせた結果が 0 件。原因はふつう次のどれかです。
<ul>
<li>トークンの Zone Resources を<b>別のゾーン</b>に絞っている</li>
<li>トークンに <b>Zone / Zone / Read</b> を付けていない</li>
<li>そのアカウントにゾーンとして載っていない(ネームサーバーが Cloudflare を向いていない)</li>
</ul>
</td>
</tr>
<tr>
<td><code>timed out waiting for record to fully propagate</code></td>
<td>手元の名前解決が古い答えを返している。<code>deploy/caddy/Caddyfile</code> の <code>resolvers 1.1.1.1 1.0.0.1</code> が効くはずですが、外向きの DNS が塞がれている環境では通りません。</td>
</tr>
<tr>
<td><code>bind: address already in use</code></td>
<td>ホストの 80 / 443 を他のものが使っている。そちらを止めるか、<code>docker-compose.caddy.yml</code> の <code>ports</code> を空いている番号に変える(443 を変えると URL にポート番号が必要になります)。</td>
</tr>
<tr>
<td>全員が急にログインできなくなった(15 分ほどで戻る)</td>
<td>
<code>auth.secure_cookie: true</code> を入れていない。Caddy を挟むと全利用者が同じ接続元アドレスに見えるため、
<b>ログイン試行の制限がひとつの枠を共有</b>します。<b>全員合わせて</b> 15 分間に 10 回失敗すると、15 分間まとめて締め出されます。
発動する前なら誰か 1 人の成功で積んだ回数は消えますが、発動したあとは成功では戻りません。
ステップ5-2 を確認する(メールとパスワードでログインする構成のときに起きます)。
</td>
</tr>
<tr>
<td>ログインしても弾かれる/ログイン画面に戻る</td>
<td><code>auth.secure_cookie: true</code> を入れた状態で、平文の口に直接繋いでいる。secure 属性の付いたクッキーは HTTP では送られません。<code>https://</code> のホスト名で開く。</td>
</tr>
<tr>
<td>設定を書いたのに、起動したら元に戻っている</td>
<td>ホスト側の <code>config.yaml</code> を用意せずに重ねがけを始めた。ステップ4 をやり直す。<b>OAuth だけで運用している場合は認証ごと消えている</b>ので、まずポートを閉じてから復旧すること。</td>
</tr>
<tr>
<td>設定ファイルが読めないという警告が出る/MAESTRO が起動を拒否する</td>
<td>
ホストに <code>./config.yaml</code> が無いまま起動し、Docker が同名の<b>ディレクトリ</b>を作っている。
そのディレクトリを消し、ステップ4-2 のとおりファイルを置き直す。
<b>読めなかったこと自体では起動は止まりません</b>——設定は認証設定を持たない状態で読み込まれ、
そのあと環境によって割れます。パスワードを持つローカル管理者がいる環境と新規インストールは起動を拒否しますが、
<b>OAuth だけの環境は認証なしで立ち上がっています</b>。その場合はまずポートを閉じてから復旧すること。
</td>
</tr>
<tr>
<td>OAuth(Google / Gitea)でログインできない</td>
<td>コールバック先が古いまま。<code>auth.providers.*.callback_url</code> と、プロバイダ側に登録した URL を <code>https://maestro.example.com/...</code> に直す。</td>
</tr>
<tr>
<td>証明書を取り直してばかりで制限に当たった</td>
<td><code>caddy-data</code> ボリュームを消している。ここに証明書と ACME アカウント鍵が入るので、消さないこと。</td>
</tr>
</tbody>
</table>
<h2>手動で組む場合(Docker を使わない)</h2>
<p>
Docker を使わず、ホストに直接 Caddy を入れる場合も考え方は同じです。違いは 2 点だけです。
</p>
<ol class="steps">
<li>
<b>Caddy を自分でビルドする。</b>公式の配布物には Cloudflare 対応が入っていません。
<code>xcaddy build --with github.com/caddy-dns/cloudflare</code> で作るか、
<a href="https://caddyserver.com/download">公式のダウンロードページ</a>で <code>caddy-dns/cloudflare</code> を選んで取得します。
</li>
<li>
<b>中継先をコンテナ名からホストの口に変える。</b><code>deploy/caddy/Caddyfile</code> の
<code>reverse_proxy maestro:9876</code> を <code>reverse_proxy 127.0.0.1:9876</code> にします。
合わせて MAESTRO 側は <code>HOST=127.0.0.1</code> で起動し、外から直接届かないようにします。
</li>
</ol>
<p>
環境変数(<code>CLOUDFLARE_API_TOKEN</code> など)は、systemd で動かすなら
<code>systemctl edit caddy</code> で <code>Environment=</code> として渡します。Caddyfile に直接書かないでください。
ステップ5 の MAESTRO 側の設定(TLS を切る・安全なクッキー)は Docker の場合と同じです。
ステップ4(<code>config.yaml</code> をホスト側に置く)は<b>不要</b>です。Docker を使わない構成では設定ファイルは最初からホストにあり、消えません。
</p>
<h2>関連ファイル</h2>
<table>
<thead><tr><th>パス</th><th>役割</th></tr></thead>
<tbody>
<tr><td><code>docker-compose.caddy.yml</code></td><td>ベースに重ねる上書きファイル。Caddy を足し、MAESTRO のポート公開を消す</td></tr>
<tr><td><code>deploy/caddy/Dockerfile</code></td><td>Cloudflare 対応を組み込んだ Caddy を作る 2 段ビルド</td></tr>
<tr><td><code>deploy/caddy/Caddyfile</code></td><td>証明書の取り方と中継先の定義</td></tr>
<tr><td><code>.env.caddy.example</code></td><td>トークンとホスト名の見本。実物(<code>.env.caddy</code>)は追跡していない</td></tr>
</tbody>
</table>
<footer class="doc">MAESTRO — docs/operations/caddy-cloudflare-tls.html / 完成版(2026-08-10, issue #1094)</footer>
</div>
</body>
</html>
+6
View File
@@ -29,6 +29,12 @@
<td>2026-06-10</td>
<td>ローカル認証を有効化し、初回管理者アカウントを起動時 seed で作る手順。ログインID 運用も。</td>
</tr>
<tr>
<td><a href="caddy-cloudflare-tls.html">Caddy + Cloudflare でワイルドカード証明書</a></td>
<td><span class="badge done">完成</span></td>
<td>2026-08-10</td>
<td>Caddy をリバースプロキシとして前に置き、Cloudflare の DNS-01 で HTTPS 化する手順。Docker Compose の重ねがけと手動構成の両方。</td>
</tr>
</tbody>
</table>
@@ -0,0 +1,169 @@
# 依存パッケージの外向き通信インベントリ
調査日: 2026-09-08
基準コミット: `cf2bc52d6725da17b207202bdc6eaf7ac9f0cab7`
この文書は、root と UI の lockfile を全件機械集計し、展開済み依存のランタイム JavaScript/JSON をテレメトリ関連語彙で絞り込んだ初段の棚卸しである。通常利用時に依存が自動収集・送信する telemetry、analytics、crash report、usage report を主対象にする。利用者が明示した API 接続、WebFetch、通知、MCP、OAuth、LLM 接続などは対象外である。悪意ある文書や入力で通信を起こす攻撃経路も、この文書では扱わない。
## 結論
確認した依存候補から、通常利用時に自動送信される telemetry、analytics、crash report は見つからなかった。`@opentelemetry/api` と `@univerjs/telemetry` は lockfile に存在するが、前者には exporter、後者には送信実装がなく、アプリにも送信 provider の登録がない。
以前、通常 UI 操作で発生していた Google Fonts、jsDelivr の Monaco、unpkg の Leaflet 資産取得は、現在の変更でローカル npm 資産へ切り替えた。地図を開いた際の OpenStreetMap タイル取得は残る。これは telemetry ではないが、通常 UI 操作で第三者へ出る通信なので記録する。
この結論は下記の「確認済み」の経路に限る。語彙走査にヒットした全ファイルを手作業で監査したという意味ではなく、動的な全通信先監視や全依存の制御フロー監査も実施していない。
## Lockfile 集計
対象は lockfile v3 の `packages` である。空キーの root record、`link: true` のローカル workspace link、その link の実体を表す `node_modules/` 外の workspace record を各 lockfile の package 件数から除外した。`dev !== true` を production install に含まれる record、`dev === true` を development-only record とした。同一パッケージの複数版やネスト配置は別 record として数える。
| 対象 | 直接 production 宣言 | 直接 development 宣言 | package records | production に含む | development-only | 推移依存 records | 推移 production | 推移 development-only | 一意な名前 | 一意な名前+版 |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| root [`package-lock.json`](../../package-lock.json) | 44 | 22 | 633 | 446 | 187 | 568 | 400 | 168 | 551 | 598 |
| UI [`ui/package-lock.json`](../../ui/package-lock.json) | 44 | 12 | 497 | 436 | 61 | 442 | 389 | 53 | 407 | 444 |
2 lockfile の合計は 1,130 package records で、同じ名前+版を両方の lockfile 間でも重複除外すると 984 組である。ローカル workspace link の `@aao/answer-worker-contract` とその実体 record は表から除外した。
集計時の SHA-256 は次の通り。
- `package-lock.json`: `c3a1e69f7160b1bcc29969d249ab7b9f34b7ea55b725409a7a3efb6dc0d8dad5`
- `ui/package-lock.json`: `4c489f8f91a933a3cff6223b91a76dee570d29504c704139b47dbd4c57a49f2d`
直接依存の数は manifest の宣言数であり、package record の分類とは別軸である。例えば development に直接宣言されたパッケージが production 推移依存としても必要な場合、その record の `dev` は `true` にならない。
## 通常 runtime の外向き経路
| 状態 | 依存・経路 | 判定と根拠 |
|---|---|---|
| 確認済み・外向き通信あり | OpenStreetMap tiles | 地図詳細を表示し、場所が 1 件以上ある場合に `https://tile.openstreetmap.org/{z}/{x}/{y}.png` を取得する。タイル座標と通常のブラウザ要求メタデータが送られる。[`MapPlacesDetail.tsx`](../../ui/src/components/embed/MapPlacesDetail.tsx#L36-L55) |
| 確認済み・ローカル化 | Leaflet 1.9.4 | JS、CSS、marker 画像を npm 依存から動的 import する。以前の unpkg script/CSS は使わない。[`MapPlacesDetail.tsx`](../../ui/src/components/embed/MapPlacesDetail.tsx#L1-L27) |
| 確認済み・ローカル化 | Monaco Editor 0.55.1 | Monaco 本体と各 worker を npm 依存から import し、`loader.config({ monaco })` へ渡す。`@monaco-editor/loader` の既定 jsDelivr 経路は使わない。[`monaco-loader.ts`](../../ui/src/lib/monaco-loader.ts#L1-L32) |
| 確認済み・ローカル化 | IBM Plex / JetBrains Mono | Fontsource の CSS とフォントファイルをビルド資産に含める。以前の Google Fonts CSS は使わない。[`index.css`](../../ui/src/index.css#L1-L10) |
| 確認済み・送信なし | `prom-client` / `@opentelemetry/api` | `@opentelemetry/api` 1.9.1 は `prom-client` 15.1.3 の推移依存で、lockfile に SDK/exporter/instrumentation package はない。アプリは process metrics をローカル registry に収集し、受信した `/metrics` scrape に返す pull 型である。[`registry.ts`](../../src/metrics/registry.ts#L26-L48) [`metrics-subsystem.ts`](../../src/bridge/metrics-subsystem.ts#L31-L43) |
| 確認済み・送信なし | `@univerjs/telemetry` 0.25.1 | 展開済み package は `ITelemetryService` identifier のみを公開する。`@univerjs/sheets-ui` は `sheetId`、`unitId`、描画時間統計を optional service へ `capture` できるが、アプリの登録プラグインに telemetry provider はない。[`UniverGrid.tsx`](../../ui/src/components/files/spreadsheet/UniverGrid.tsx#L263-L288) |
| 確認済み・ローカル処理 | Mermaid / PDF | Mermaid は npm bundle を直接 import し、server renderer も `vendor/` または `ui/node_modules` の bundle を読む。[`FilePreviewContent.tsx`](../../ui/src/components/files/FilePreviewContent.tsx#L25-L28) [`mermaid.ts`](../../src/engine/tools/mermaid.ts#L123-L142) PDF はローカルファイルの Buffer を `PDFParse` に渡す。[`office.ts`](../../src/engine/tools/office.ts#L1673-L1679) |
## アプリ機能による通常の外向き通信
以下は自動 telemetry ではなく、設定済み機能を動かすための通信である。依存 package の存在だけでは発生せず、表の条件を満たしたときに有効になる。
| 機能 | 宛先と有効化条件 | 送信する主なデータ | 状態 |
|---|---|---|---|
| LLM 呼び出し | 設定した OpenAI 互換 endpoint。タスクがモデルを呼ぶと `POST <base>/chat/completions`。[`openai-compat.ts`](../../src/llm/openai-compat.ts#L1273-L1283) | API key、会話 messages、model、tools、生成設定 | 確認済み |
| 設定画面の model 一覧取得 | model 設定画面で endpoint が入力されると、ブラウザから直接 `GET <endpoint>/models`。literal API key は Bearer header に入る。`env_ref` / masked key は取得を行わず、key を cleared にした場合は無認証で取得する。[`ModelSelect.tsx`](../../ui/src/components/settings/ModelSelect.tsx#L69-L96) | 設定中の endpoint、Accept header、literal の場合は API key | 確認済み |
| LLM health / capacity probe | 設定した direct worker の `/slots`、`/health`、`/metrics`、proxy worker の `/health`。稼働中は約 5 秒ごと。direct probe は worker API key を送らず、proxy `/health` は設定された key を送る。[`backend-probes.ts`](../../src/engine/backend-probes.ts#L420-L480) [`backend-probes.ts`](../../src/engine/backend-probes.ts#L932-L944) | GET、Accept、proxy の場合は Authorization。応答から稼働状態・slot・throughput を読む | 確認済み |
| OAuth と avatar | 管理者が Google または Gitea provider を有効にし、利用者がログインした場合。Gitea は設定 base URL の authorize/token/user/orgs、Google は Passport strategy の provider endpoint を使う。[`auth.ts`](../../src/bridge/auth.ts#L577-L672) provider が返した avatar URL は画面の `<img src>` からブラウザが取得する。[`UserAccountMenu.tsx`](../../ui/src/components/layout/UserAccountMenu.tsx#L67-L68) | OAuth client情報、authorization code/token、user profile/org照会。avatar取得時はURLと通常のbrowser metadata | 確認済み |
| Web Push | 利用者が通知を有効にし push subscription がある場合、その subscription endpoint。[`push-service.ts`](../../src/push-service.ts#L176-L212) | 既定はevent種別、task ID、汎用文言。`includeDetails` opt-in時はtask titleとpiece/summaryも含む。VAPID署名とsubscription keysを使用 | 確認済み |
| Space webhook | workspaceでDiscord/Slack/Teams webhookを登録・有効化し、対象eventが発生した場合、登録したHTTPS URL。[`webhook-service.ts`](../../src/webhook-service.ts#L419-L440) [`webhook-service.ts`](../../src/webhook-service.ts#L483-L527) | event、task ID、task URL。`includeDetails` 設定によりtask title、piece、space名。JSON POST | 確認済み |
| MCP | 暗号化キーを設定し、HTTP MCP serverを登録して接続・tool list・tool callを行う場合、登録した server URL。Bearer または指定headerへtokenを付ける。[`client-factory.ts`](../../src/mcp/client-factory.ts#L34-L65) [`tool-executor.ts`](../../src/mcp/tool-executor.ts#L47-L62) | MCP初期化/capability、tool名と引数、認証token | 確認済み |
| 起動時の不足資産補完 | `scripts/server.sh start` は毎回 `prepare.sh` を実行する。Chromium が無い場合は Playwright CDN から取得し、共有library不足時は `playwright install-deps` がOS package repositoryを使い得る。[`server.sh`](../../scripts/server.sh#L120-L127) [`prepare.sh`](../../scripts/prepare.sh#L27-L64) | Playwright/npm/OS標準のdownload request metadata。アプリのtask内容は送らない | 確認済み・条件付き |
`npm ci`、Docker build、`setup.sh`、`upgrade.sh` に伴う registry、Git、OS package、Playwright browser の取得は管理者が明示的に行う install/build/update 通信であり、通常 runtime の telemetry 判定には含めない。
## ローカル UI 資産の回帰検査
次の検査をリポジトリ root で再現できる。
```bash
npm run security:ui-assets
```
[`check-local-ui-assets.mjs`](../../scripts/security/check-local-ui-assets.mjs) は実際の `MonacoFileEditor`、`MapPlacesDetail`、`index.css` を一時 Vite アプリへ読み込み、production build を Chromium で実行する。2026-09-08 の検査は exit 0 で成功し、3 font family のローカル読込、Monaco の編集・保存と same-origin worker、Leaflet の 2 marker の表示・画像 decode を確認した。OpenStreetMap の tile request は検査内でローカル応答し、それ以外の cross-origin request と不要な WebSocket 接続は実接続前に遮断して失敗にする。画面表示後に Google Fonts を意図的に追加する negative fixture で、遅れて発生する要求も検出した。
フォントと地図タイルの要求件数は描画条件で変わるため、各実行の集計をコマンド末尾へ出力する。
この動的検査の範囲は上記 UI component と資産に限る。アプリ全画面の操作や server workload を動かして全通信を監視する検査ではない。
## テレメトリ語彙の候補確認
展開済み依存の `.js`、`.cjs`、`.mjs`、`.json` を走査し、source map は除外した。使用した語彙は `telemetr`、`analytics`、`sentry`、`datadog`、`newrelic`、`mixpanel`、`amplitude`、`posthog`、`rollbar`、`bugsnag`、`phone.?home`、`usage.?report`、`update.?check`、`license.?check`、`opentelemetry` である。
| 対象 | 展開済み lock records | lock と同一版 | 不一致 | 未展開 | 走査ファイル | 走査量 |
|---|---:|---:|---:|---:|---:|---:|
| root | 550 / 633 | 550 | 0 | 83 | 9,879 | 130,972,307 bytes |
| UI | 469 / 497 | 469 | 0 | 28 | 15,076 | 258,538,747 bytes |
root と UI はこの worktree で `npm ci --ignore-scripts --no-audit --no-fund` 後の展開物を使った。存在した root 550 records と UI 469 records はすべて lockfile と同一版だった。未展開の root 83 records と UI 28 records はすべて `optional: true` で、現在の OS/CPU 以外の native package などを含む。
手動で送信経路まで確認した候補は次の通り。
- `@opentelemetry/api` とその導入元 `prom-client`: API とローカル registry だけで、exporter package と外向き送信 sink は確認されなかった。
- `@univerjs/telemetry` と `@univerjs/sheets-ui`: identifier と optional `capture` 呼び出しはあるが、送信 provider の実装・登録は確認されなかった。
- `monaco-editor`: telemetry という名前の内部型・service は含むが、MAESTRO が collector や転送先を登録する箇所は確認されなかった。現在は editor code と worker もローカル配信する。
`pdfjs-dist`、`pdf-parse`、Playwright、Mermaid、TypeScript、highlight.js、React、各種 CSS/parser package などにも語彙ヒットがあった。エラーサービス名を入力データとして扱う parser、CSS の `scrollbar` に含まれる文字列、テスト・開発コード、一般的な型名などが混在するため、これらは **keyword candidate のまま未検証** である。語彙ヒットだけを telemetry 実装の証拠にはしていない。
## Install script
lockfile の `hasInstallScript: true` を全件抽出した。これは install 時のコード実行面であり、通常 runtime の自動 telemetry とは別である。
| lock | package | 区分 | 展開・確認状態 | install 時の挙動 |
|---|---|---|---|---|
| root | `[email protected]` | 直接 production | 確認済み | `prebuild-install || node-gyp rebuild --release`。prebuild が無ければ GitHub release asset の取得を試み、失敗時はローカル build。 |
| root | `[email protected]` | 直接 production | 確認済み | `node install.js` から optional crypto binding を `node-gyp rebuild`。script 自身に HTTP client はない。node-gyp はローカル header が無い環境では header 取得を行い得る。 |
| root | `[email protected]` | 推移 production・optional | 確認済み | buildcheck 後に `node-gyp rebuild`。script 自身に HTTP client はない。 |
| root | `playwright/node_modules/[email protected]` | 推移 production・optional | Linux では未展開、未検証 | lockfile flag のみ確認。macOS 向け optional package。 |
| root | `[email protected]` | 推移 development-only | 確認済み | `node install.js`。対応 platform の optional binary が無ければ npm を起動し、それも失敗すると `https://registry.npmjs.org/@esbuild/*` の tarball を直接取得する。 |
| root | `[email protected]` | 推移 development-only・optional | Linux では未展開、未検証 | lockfile flag のみ確認。macOS 向け optional package。 |
| UI | `[email protected]` | 推移 production | 確認済み | `node scripts/postinstall` は親 manifest の semver 記法を検査して warning を出すだけで、通信しない。 |
| UI | `[email protected]` | 推移 production・optional | Linux では未展開、未検証 | lockfile flag のみ確認。macOS 向け optional package。 |
通常の [`build-all.sh`](../../scripts/build-all.sh#L26-L31) は plain `npm ci` を使うため、該当 platform では lifecycle script が動く。Docker build は builder と runtime の両方で `npm ci --ignore-scripts` を使い、その後に検査済みの native rebuild を行う。[`Dockerfile`](../../Dockerfile#L15) [`Dockerfile`](../../Dockerfile#L126-L134)
## 再現方法
件数は次の Node.js スクリプトで再現できる。
```bash
node <<'NODE'
const fs = require('node:fs');
for (const file of ['package-lock.json', 'ui/package-lock.json']) {
const lock = JSON.parse(fs.readFileSync(file, 'utf8'));
const root = lock.packages[''];
const direct = new Set([
...Object.keys(root.dependencies ?? {}),
...Object.keys(root.devDependencies ?? {}),
...Object.keys(root.optionalDependencies ?? {}),
]);
const rows = Object.entries(lock.packages)
.filter(([path, value]) => path.includes('node_modules/') && value.link !== true);
const isDirect = ([path]) => {
const name = path.replace(/^node_modules\//, '');
return direct.has(name) && path === `node_modules/${name}`;
};
const count = (predicate) => rows.filter(predicate).length;
console.log(file, {
directProduction: Object.keys(root.dependencies ?? {}).length,
directDevelopment: Object.keys(root.devDependencies ?? {}).length,
records: rows.length,
productionIncluded: count(([, v]) => v.dev !== true),
developmentOnly: count(([, v]) => v.dev === true),
transitive: count((row) => !isDirect(row)),
transitiveProduction: count((row) => !isDirect(row) && row[1].dev !== true),
transitiveDevelopment: count((row) => !isDirect(row) && row[1].dev === true),
});
}
NODE
```
install script 候補は次で再現できる。
```bash
node -e 'for (const f of ["package-lock.json","ui/package-lock.json"]) { const l=require("./"+f); console.log(f,Object.entries(l.packages).filter(([p,v])=>p&&v.hasInstallScript).map(([p,v])=>({path:p,version:v.version,dev:!!v.dev,optional:!!v.optional}))) }'
```
語彙候補は、同じ拡張子・除外条件で次のように再抽出できる。
```bash
rg -l -i 'telemetr|analytics?|sentry|datadog|new\s?relic|mixpanel|amplitude|posthog|rollbar|bugsnag|phone.?home|usage.?report|update.?check|license.?check|opentelemetry' node_modules ui/node_modules --glob '*.js' --glob '*.cjs' --glob '*.mjs' --glob '*.json' --glob '!**/*.map'
```
この調査で使った機械集計結果は `/tmp/dependency-egress-inventory.json`、語彙走査結果は `/tmp/dependency-egress-scan.json` に残した。これらは一時成果物であり、再現の基準は上記 lockfile hash と手順である。
## 限界と次段
- lockfile の全 records は集計したが、全依存の全関数を手作業で監査してはいない。
- root 83 records と UI 28 records は、この OS/CPU では展開されない optional package だった。これらは lockfile の package metadata と名前だけを棚卸しし、実コードは走査していない。
- keyword scan は難読化、動的 URL 組み立て、語彙を含まない独自収集を検出できない。逆に、多数の false positive を含む。
- 通常 telemetry と別の、利用者設定による外部 API 通信、外部コンテンツ取得、悪意ある入力を使った egress はこの文書の結論に含めない。
- 強い保証が必要な次段では、隔離ネットワーク上で主要 UI 操作と server workload を実行し、DNS・HTTP(S)・WebSocket を process/browser 単位で記録する。未展開 optional package は対象 OS ごとに `--ignore-scripts` で展開して静的確認し、必要な lifecycle script だけを別の隔離環境で観測する。
+58
View File
@@ -0,0 +1,58 @@
# 通常通信の観測とブラウザの送信先制限
## 有効化
管理者用 Settings → Network → 通信の観測・制限から、既存の config.yaml へ `egress` を保存できる。未設定の既定値は観測・CSPとも無効。UIからの保存は設定変更通知で直ちにサーバーへ反映され、サーバー再起動は不要。ブラウザのCSPは次の文書レスポンスから適用されるため、既存の画面・タブは再読み込みが必要。
UIでの変更を禁止したい環境では、管理者だけが書き換えられる JSON ファイルを用意し、`AAO_EGRESS_POLICY=/etc/maestro/egress.json` を起動環境へ指定する。変更には再起動が必要。指定した場合は config.yaml より優先し、UIとAPIで通信設定の変更をロックする。外部ファイル未指定なら保存済みのUI設定を使い、明示したファイルが存在しない・不正・64 KiB 超ならサービス起動前に失敗する。ワーカーと Gateway の標準エントリーポイント、および旧 `dist/index.js` で読み込む。
```json
{
"version": 1,
"observeNode": true,
"browser": {
"mode": "report-only",
"connectOrigins": [],
"imageOrigins": ["https://tile.openstreetmap.org"],
"frameOrigins": []
}
}
```
用途ごとの配列は最大128件、各要素はスキーム・ホスト・ポートのみ。同じサイトの通信は既定で許可される。接続先には必要に応じて `wss://...` も指定する。空配列は追加許可なし。パス、資格情報、ワイルドカード、未知の設定項目を拒否する。この外部ファイルはアプリの管理画面や設定変更APIから編集できない場所へ配置する。外部ロック中でも、無関係な設定の保存は可能。同値の通信設定を含む全体保存では、外部ファイルや保存済みのegress値を書き換えない。
ブラウザの `off` は無効、`report-only` は報告のみ、`enforce` は遮断。まず起動・待機・使用する全機能を report-only で検査し、必要な宛先をレビューしてから enforce へ切り替える。特に地図タイル、外部アバター、Markdown画像、直接モデル一覧を取得する推論サーバー、埋め込みページを確認する。既存のファイル閲覧用 `sandbox` CSP は追加ポリシーとして保持される。
## 記録と限界
Node は `diagnostics_channel` の HTTP client、Undici、TCP socket チャンネルを観測する。HTTP は論理的な origin、TCP は接続成功時のリモートIP・ポート。WebSocket の handshake はHTTPとして数える。HTTP と TCP は同一リクエストの別層なので合算しない。Node 22 の実通信で検証している。UIから観測を無効にすると購読・定期集計を停止し、有効に戻すと新しい集計を開始する。
`[egress]` に新規の接続先、`[egress-summary]` に60秒間隔の累積件数を標準ログへ出力する。既定のログレベル info が必要。保持上限は256件で、上限以降の新規宛先の件数を `overflow` に計上する。再起動で集計は消える。本文、Cookie、Authorization、URLのパス・クエリ・フラグメントは収集しない。originのホスト名・IPも機密となり得るため、ログのアクセス権と保存期間を別途管理する。
**この観測はサーバー側の遮断でも、全通信の証明でもない。** 子プロセス、ネイティブ通信、UDP、DNS問い合わせ、接続失敗したTCPなどは網羅しない。詳細は[サーバーのネットワーク境界](./server-egress-boundary.md)を参照する。エージェントが生成したコードや子プロセスにも制限を効かせるにはOS側の境界が必要。
`[security:csp]` は最大256種類の違反の初回だけを記録する(同じ違反の再送や集計上限以降はログに追加しない)。受信は1分1000要求、本文16 KiBを上限とし、圧縮本文を拒否する。
ブラウザのCSP違反報告は未認証・未検証のクライアント入力であり、監査証跡や自動許可判断に使わない。許可した外部iframe内部の通信、利用者による別ページへの移動、ブラウザ拡張機能、既に開いている旧ポリシーのページはこのCSPだけでは管理できない。適用後は画面を再読み込みする。同一originサーバーのプロキシAPIや許可済みサービスを通じた送信も、CSPだけでは排除できない。
## 回帰検証
```sh
npm run build:server
npm run build:ui
npm run security:egress
npm run security:egress-settings
npm run security:ui-assets -- --csp
```
Playwright Chromiumが必要。検査は一時DB・一時設定・最小限の子プロセス環境で実ワーカーを起動し、ループバック上の偽LLM・許可先・拒否先だけを使う。report-only/enforceでHTTP・画像・WebSocketを検査し、同一originのsandbox iframe内の画像遮断と不正ポリシーによる起動拒否も検証し、enforceで65秒待機して定期観測を確認する。本番の接続設定や資格情報は継承しない。さらに同じCSPを実UI部品の検査に適用し、フォント・Monaco編集保存・worker・Leaflet描画と未許可CDNの遮断を確認する。設定画面からの保存・即時反映・再起動後の保持・外部ポリシーのロックも検査する。CIでも実行する。
この検査の成功は、テストで通した起動・画面・待機経路の結果であり、全依存ライブラリの全機能の無通信を保証しない。OS境界の遮断試験は別途、実際の配備先で実施する。
## 参照
- [Node.js diagnostics_channel](https://nodejs.org/api/diagnostics_channel.html)
- [Undici diagnostics channels](https://github.com/nodejs/undici/blob/main/docs/docs/api/DiagnosticsChannel.md)
- [CSP: 複数ポリシーの適用](https://www.w3.org/TR/CSP3/#multiple-policies)
HTTPヘッダーの送信直前にCSPを合成するため、既存の推移的依存 `on-headers` を直接依存として明示した。`@types/on-headers` は開発時の型定義のみ。
+131
View File
@@ -0,0 +1,131 @@
# サーバー外向き通信の境界制御
更新日: 2026-09-08
## 適用状態
このリポジトリは、Linux systemd と既存 Squid を使う配布用 template を提供する。
- [`maestro-egress.service.example`](../../deploy/egress/systemd/maestro-egress.service.example) は、MAESTRO service cgroupへ適用する IP 制限の staging 用設計例である。対象OSでの強制を検証するまで本番へ配備しない。
- [`squid.conf`](../../deploy/egress/squid.conf) は許可した hostname と port の組だけを転送する。
- [`check-egress-boundary.mjs`](../../scripts/security/check-egress-boundary.mjs) は外部へ接続せず、template と proxy policy の回帰検査を行う。
**ネットワーク制御の設計例の配布までで、実 OS へ隔離を適用した試験は未実施である。** 調査環境には Docker CLI、Squid、nftables、tcpdump が無いため、Docker Compose 構成や実 host の cgroup-BPF 動作を完成扱いしない。template を install・enable する前に、対象 host の staging 環境で後述の確認が必要である。
## 制御モデル
```mermaid
graph LR
Client[利用者] --> Ingress[専用 ingress proxy<br/>source 127.0.0.3]
Ingress --> App[MAESTRO<br/>127.0.0.4]
App -->|HTTP_PROXY / HTTPS_PROXY| Proxy[Squid<br/>127.0.0.2:3128]
Proxy -->|exact hostname + port のみ| Approved[承認済み接続先]
App -. cgroup で拒否する設計・要実測 .-> Direct[直接 HTTP / WS / DNS / raw TCP]
```
設計例は systemd の `IPAddressDeny=any` と次の 2 address の `IPAddressAllow` を組み合わせる。kernel、systemd、container managerによってcgroup-BPF対応が異なるため、unit記述だけでは強制を証明しない。
| address | 用途 | 条件 |
|---|---|---|
| `127.0.0.2/32` | 外向き Squid | この address には Squid 以外を bind しない。systemd の許可は port を限定できない。 |
| `127.0.0.3/32` | ingress proxy の接続元 | listening service を置かず、ingress proxy が MAESTRO 向け接続の source address としてだけ使う。 |
`127.0.0.3` はプロセスの identity や認証を保証せず、同一 host 上の別プロセスも source address として bind できる。この設計例は host 上の local user と process を信頼境界内とし、信頼できない local workload がある場合は network namespace や host firewall/cgroup identity による追加分離が必要である。
MAESTRO は `127.0.0.4` で listen する。systemd の IP filter は outbound の destination と inbound の source の両方へ同じ allow list を使う。このため、通常の `127.0.0.1` client や LAN subnet を `IPAddressAllow` へ足すと、その address/subnet は同時に外向き通信先としても許可される。`localhost`、`127.0.0.0/8`、`::1/128`、利用者 LAN 全体を便宜的に許可しない。ingress proxy に専用 source address を設定できない環境では、この template をそのまま有効化できない。
template に IPv6 の許可値はない。これは対象 host で IP policy が有効な場合にだけIPv6拒否になる。IPv6を使う場合も専用proxyとingress sourceの個別 `/128` だけを追加し、別canaryで実測する。
## Squid policy
初期設定の `allowed.egress.invalid:443` は公開 DNS で解決しない canary であり、意図せず外部へ出ない。運用時は次の 3 行を接続先ごとに作り、hostname と port を置き換える。
```squidconf
acl provider_a_host dstdomain -n api.provider.example
acl provider_a_port port 443
http_access allow provider_a_host provider_a_port
```
exact hostname として扱うため、先頭に `.` を付けない。`.example.com` は subdomain も許可するため、この policy の既定には使わない。`dstdomain -n` はIP literalに対するreverse DNS検索を行わない。IP literalはhostname ACLに一致せず、末尾の `http_access deny all` で拒否される。許可 hostname が DNS rebinding で別 IP を返す可能性は proxy 側の名前解決・接続 policy として別途評価する。private、link-local、metadata CIDR へ解決した結果を禁止できる Squid ACL も staging で追加確認する。
`access_log none` と `cache_store_log none` が既定で、URL path、query、Authorization header、body、DNS query name を保存しない。DNS query name 自体に秘密が埋め込まれる場合があるため、packet capture や resolver の query log は既定の観測手段にしない。
設定画面で LLM、MCP、OAuth、webhook などの endpoint を追加しても、Squid policy は自動更新しない。アプリ自身が編集できる設定から境界の許可を自動生成すると、アプリ権限を得た主体が境界も広げられるためである。手順は「アプリ設定を保存する → default deny を確認する → 別権限の運用者が hostname+port を追加する → Squid を検証・reloadする」とする。
## 導入手順
1. `maestro` 専用 user と、MAESTRO、ingress proxy、Squid が専用に使う address を staging host で用意する。
2. Squid template の `.invalid` ACL を、承認済み exact hostname+port に置き換える。
3. `squid -k parse -f <path>` で構文を確認し、Squid を `127.0.0.2:3128` に bind する。
4. systemd unit の `/opt/maestro`、user/group、EnvironmentFile を実環境へ合わせる。
5. ingress proxy の MAESTRO 向け source address を `127.0.0.3` に固定する。MAESTRO は `127.0.0.4` で listen する。
6. `systemd-analyze verify <unit>` に加え、service cgroup内から独立したlistening canaryへ接続し、IPv4/IPv6/direct DNS/raw TCP/子processが拒否されることを確認してからenableする。接続拒否やtimeoutだけでBPFの有効性を断定せず、systemd journalとkernel側のpolicy attach状態も照合する。少数のcanary成功だけを全経路の証明にしない。
unit は [`server.sh`](../../scripts/server.sh#L123-L147) を呼ばず、migration 後に `dist/main.js` を直接起動する。`server.sh` は起動ごとに `prepare.sh` と build を実行し、資産不足時には download が発生し得るためである。dependency準備とbuildは管理者が runtime service の外で明示的に行う。`AAO_MODE=worker` 用 template であり、gateway mode は別 unit と policy を作る。
`.env` 相当は `/etc/maestro/maestro.env` から読むが、内容をaudit outputに出さない。`EnvironmentFile`は`Environment=`を上書きできるため、proxy変数・`HOST`・modeは`ExecStart`の`/usr/bin/env`引数で最終指定し、小文字proxy変数、`ALL_PROXY`、`NODE_OPTIONS`を先に除去する。`NO_PROXY` に外部 hostname、LAN、localhost を追加すると proxy を迂回するため空のままにする。
## 秘密を保存しない観測
unit の `IPAccounting=yes` では、service と子プロセスを合わせた ingress/egress packet・byte の集計を得られる。
```bash
systemctl show maestro-egress.service \
-p IPIngressBytes -p IPIngressPackets \
-p IPEgressBytes -p IPEgressPackets
```
この集計は URL、header、body、DNS name、API key を記録しない。一方、宛先や protocol は判別できず、拒否された試行の完全な台帳にもならない。
接続単位の短期観測が必要な場合、Squid template にある `maestro_meta` logformat を staging だけで有効にできる。記録項目は時刻、HTTP method、接続先 IP/port、status、bytes、時間で、URL/path/query/header/body/DNS name は含めない。file mode、retention、閲覧権限を先に決める。hostname が必要な場合も平文保存せず、運用側 collector で session ごとの鍵付き HMAC と policy rule ID に置き換える。
proxy経由のHTTP(S)はSquidが名前解決する。IP policyが有効ならAF_INET/AF_INET6でresolverへ直接送る通信もallow list外になるが、`AF_UNIX`はアプリ動作のため許可しており、`nss-resolve`、system bus、host socket上の代理serviceへ委譲する経路はこのtemplateだけでは遮断を保証できない。resolverのquery logは有効にせず、必要ならquery数とallow/deny数だけを集計する。
## 現在の通信との互換性
Node.js 要件は [`package.json`](../../package.json#L12-L14) の `>=22` で、調査環境の Node 22.22.3 はexperimentalな `--use-env-proxy` を持つ。unit例はmain Node processだけにこのflagと`HTTP_PROXY` / `HTTPS_PROXY`を設定する。`NODE_OPTIONS`は子processへ強制継承しない。これは global `fetch` を proxy に通す補助策であり、境界そのものではない。
| 経路 | 境界有効時の見込み | 根拠・対応 |
|---|---|---|
| LLM request と backend probe の global `fetch` | Squid 経由で許可可能 | LLM は [`openai-compat.ts`](../../src/llm/openai-compat.ts#L1277-L1283)、probe は [`backend-probes.ts`](../../src/engine/backend-probes.ts#L410-L448) で global fetch を使う。 |
| `node:http` / `node:https` を直接使う依存 | OS 境界で拒否され得る | env proxyだけでは全 transport を覆わない。transport固有agentを確認してから許可する。 |
| Discord REST / resident WebSocket | 現状は拒否され得る | active binding は起動時にREST照会後、resident WSへ接続する。[`discord-gateway.ts`](../../src/bridge/chat/discord-gateway.ts#L189-L210) [`discord-gateway.ts`](../../src/bridge/chat/discord-gateway.ts#L214-L266) |
| Web Push | 現状は拒否され得る | `web-push` が独自 HTTPS transport を使うため、proxy対応を個別検証する。[`push-service.ts`](../../src/push-service.ts#L206-L212) |
| MCP | transportごとに確認が必要 | 接続はSDK transportへ委譲される。[`client-factory.ts`](../../src/mcp/client-factory.ts#L34-L65) |
| SSRF strict fetch | 現状は拒否され得る | DNS pinning用の明示的 Undici Agentを渡すため、global dispatcherを使わない。[`ssrf-strict.ts`](../../src/net/ssrf-strict.ts#L128-L170) |
| SSH | 拒否 | raw TCPであり、HTTP proxy対象外。専用bastion/proxy設計なしにIP allowを追加しない。 |
| Chromium / user script / LibreOffice / Python helper | systemd IP policyが実効なら同じservice cgroupのIP filter対象 | Chromiumにproxyを渡す追加対応なしでは外部閲覧が失敗する。AF_UNIX経由の代理serviceや別cgroupへの処理委譲は別途監査する。子プロセス例は [`script-runner.ts`](../../src/user-folder/script-runner.ts#L1-L16)、[`office-preview.ts`](../../src/bridge/office-preview.ts#L537-L561)。 |
| Bash sandbox | より内側の別制御 | `safety.bash_allow_network` はbwrap内だけを切り替える。[`sandbox.ts`](../../src/engine/tools/sandbox.ts#L81-L87) 外側のservice境界を緩めない。 |
未設定の fresh config では外部接続が無いことを期待する。LLM worker を設定すると、backend registry は起動し、UI未購読時は既定30秒、購読中は5秒周期でprobeする。[`server.ts`](../../src/bridge/server.ts#L674-L687) [`backend-status-registry.ts`](../../src/engine/backend-status-registry.ts#L176-L190) 許可 policy に worker endpoint が無ければoffline表示になる。この失敗は境界が機能している結果として運用監視で区別する。
## オフライン回帰検査
```bash
node scripts/security/check-egress-boundary.mjs
```
検査はすべて loopback と `.invalid` hostnameを使い、外部本番接続を行わない。
- systemd template が `IPAddressDeny=any`、個別 `/32`、空の `NO_PROXY` を維持すること
- Squid template が exact host+port、末尾 default deny、access log無効を維持すること
- 最小環境の実Node 22 childを`--use-env-proxy`で起動し、global `fetch`がlocal proxyへ実際にCONNECTし、proxyが見た`allowed.egress.invalid:443`を許可、`denied.egress.invalid:443`を拒否すること
- local policy model がHTTP、CONNECT、WebSocket Upgradeをexact host+portでallow/denyすること
- secret canaryが保存するmetadataと最終出力に含まれないこと
- `node:http`、raw TCP、spawn child、IPv4、IPv6、直接DNSがenv proxyだけでは止まらないこと
実Node childには親processのproxy設定、`NODE_OPTIONS`、資格情報を継承せず、proxyはtunnel内HTTPにもloopbackで応答して外部へ転送しない。最後の項目は、OS境界を適用した検査ではない。HTTP proxy変数だけでは不足することをローカルcanaryで再現し、systemd等のOS境界が必要な理由を固定する検査である。実OSでの強制を証明しない。
## 実環境で残る検証
staging host では次を完了するまで本番適用済みと判定しない。
- kernel/systemdが `IPAddressDeny` / `IPAddressAllow` を実際にload・attachし、service子processのAF_INET/AF_INET6 socketにも作用すること
- HTTP fetchの許可先成功と未許可先拒否
- IPv4 literal、IPv6 literal、AF_INET/AF_INET6の直接DNS、raw TCP、spawn childの拒否。AF_UNIX代理経路は別に確認
- proxy停止時にfail closedとなり、直接接続へfallbackしないこと
- ingress proxyからUI/APIへ到達でき、他sourceからは到達できないこと
- LLM probe・job、OAuth、MCP、Discord、Web Push、webhook、Chromiumなど、利用する機能ごとの互換性
- audit metadataへAPI key、cookie、Authorization、URL query、request/response body、DNS query nameが入らないこと
Docker Compose版は別段階とする。現行 [`docker-compose.yml`](../../docker-compose.yml#L25-L39) は `host.docker.internal:host-gateway` と通常の外部routeを持つため、env proxyを足すだけでは境界にならない。実装する場合は MAESTROを `internal: true` networkだけへ置き、egress proxyだけをinternal/external両networkへ接続し、`extra_hosts`を削除した上でDocker環境のDNS迂回を実測する。
+387
View File
@@ -0,0 +1,387 @@
# MAESTRO タスク実行アーキテクチャ
この文書は、MAESTROでタスク依頼を受け付けてから、Worker、Piece、Movement、LLM、ツール、承認、再試行、通知、UI反映を経て完了するまでの現行処理を示す。機能追加やリファクタリング時の影響確認に使用する。
## 文書の扱い
- 実装を正とし、処理経路を変更したPRではこの文書も更新する。
- 通常経路だけでなく、ASK、承認待ち、サブタスク待ち、再試行、キャンセル、失敗を維持する。
- 図中の名称は主要なファイル、関数、状態名に対応させる。
- 特定機能の将来設計は含めず、現行コードの動作だけを記載する。
## 調査対象コミット
- 基準ブランチ: `origin/main`
- PR #935実装コミット: `a86fea5523d9f8245cd42c53e3c1c167ccb26388`
- 最終照合HEAD: `2ba26737742464f9ef7ccfd4459012e00175fd88`
- 対象変更: PR #935「systemプロンプト静的化 + 動的コンテキスト末尾注入」
- 調査日: 2026-07-24
## 主要な実装境界
| 境界 | 主なファイル・関数 |
|---|---|
| タスク作成 | src/bridge/local-tasks-crud-api.ts |
| コメント・interjection・ASK再開 | src/bridge/local-tasks-comments-api.ts |
| 継続・キャンセル | src/bridge/local-tasks-control-api.ts |
| Tool承認 | src/bridge/local-tasks-tool-requests-api.ts |
| Package承認 | src/bridge/local-tasks-package-requests-api.ts |
| Job永続化・claim・再開 | src/db/repositories/jobs.ts |
| Worker実行・終了処理 | src/worker.ts |
| Piece・Movement制御 | src/engine/piece-runner.ts |
| Agent iteration | src/engine/agent-loop.ts |
| LLM stream | src/engine/agent-loop/llm-iteration.ts、src/engine/llm-stream.ts |
| Tool dispatch | src/engine/agent-loop/tool-dispatcher.ts、tool-execution.ts |
| チャット表示 | ui/src/components/chat/ChatPane.tsx、ChatMessageFeed.tsx |
| 承認UI | ui/src/components/chat/ToolRequestApproval.tsx、PackageRequestApproval.tsx |
| 通知・監査 | src/notifications/*、src/progress/*、ui/src/components/activity/* |
## 1. タスク依頼から完了までの全体フロー
```mermaid
flowchart TD
U[ユーザー / 外部クライアント]
UI[React UI<br/>CreateTaskDialog / ChatComposer]
API_CREATE[POST /api/local/tasks]
API_COMMENT[POST /api/local/tasks/:id/comments]
API_CONTINUE[POST /api/local/tasks/:id/continue]
OTHER[Scheduler / A2A / Reflection / Subtask]
U --> UI
UI --> API_CREATE
UI --> API_COMMENT
UI --> API_CONTINUE
API_CREATE --> VALIDATE[入力・可視性・Space・LLM選択・添付検証]
VALIDATE --> CLASSIFY[Piece選択 / Grounded固定 / LLM Router]
CLASSIFY --> TASKDB[(local_tasks / comments)]
CLASSIFY --> WORKSPACE[Space解決<br/>persistent/ephemeral workspace・runtime dir]
WORKSPACE --> JOBDB[(jobs: queued)]
API_COMMENT --> WRITE_GATE[Task write gate]
WRITE_GATE --> PREV{最新Job状態}
PREV -->|running / dispatching / waiting_subtasks| INTERJECT[interjection comment保存<br/>既存Jobへ次iterationで投入]
PREV -->|tool/package approval待ち| CONFLICT[409: 承認を先に要求]
PREV -->|queued / retry| REUSE[既存pending Jobを再利用]
PREV -->|ASK待ち・終端・Jobなし| FOLLOWUP[resumeMovement/askCount継承<br/>Job作成]
REUSE --> JOBDB
FOLLOWUP --> JOBDB
API_CONTINUE --> CONTINUE_GATE[所有権・Piece存在・前Job終端確認]
CONTINUE_GATE --> HANDOFF[continuedFromJobIdとhandoff comment<br/>新Jobは transcript replay で前会話を引き継ぐ]
HANDOFF --> JOBDB
OTHER --> JOBDB
JOBDB --> POLL[Worker.processNext polling]
POLL --> CLAIM[retry期限優先→queued<br/>worker/model/profile適合<br/>issue lockなし]
CLAIM --> RUNNING[原子的にstatus=running<br/>worker_id設定]
RUNNING --> EXEC[Worker.executeJob]
EXEC --> PREP[workspace / reporter / Space / Piece / model / MCP / browser / context準備]
PREP --> RUNPIECE[runPiece]
RUNPIECE --> MOVEMENT[Movement loop]
MOVEMENT --> RESULT{PieceRunResult}
RESULT -->|completed| SUCCESS[succeeded<br/>workspace commit・最終結果]
RESULT -->|waiting_human: ASK| ASKWAIT[waiting_human<br/>回答コメントで再開]
RESULT -->|waiting_human: tool_request| TOOLWAIT[waiting_human<br/>承認APIで再開]
RESULT -->|waiting_human: package_request| PKGWAIT[waiting_human<br/>承認・install後に再開]
RESULT -->|waiting_subtasks| SUBWAIT[子Job完了待ち<br/>全完了で親queued]
RESULT -->|cancelled| CANCELLED[cancelled<br/>子Job取消]
RESULT -->|error / aborted| RETRY{再試行可能か}
RETRY -->|yes| RETRYDB[retry + next_retry_at]
RETRY -->|fatal / 上限| FAILED[failed]
ASKWAIT --> API_COMMENT
TOOLWAIT --> APPROVE_TOOL[tool request decide API]
APPROVE_TOOL -->|approve/deny| JOBDB
PKGWAIT --> APPROVE_PKG[package request decide API]
APPROVE_PKG -->|approve/deny| JOBDB
SUBWAIT --> JOBDB
RETRYDB --> POLL
SUCCESS --> EVENTS[Audit / events.jsonl / activity / metrics]
ASKWAIT --> EVENTS
TOOLWAIT --> EVENTS
PKGWAIT --> EVENTS
SUBWAIT --> EVENTS
CANCELLED --> EVENTS
FAILED --> EVENTS
EVENTS --> NOTIFY[Push / Webhook / Notification projection]
EVENTS --> UIREFRESH[REST polling + SSE stream]
UIREFRESH --> UI
```
## 2. WorkerとPiece実行
```mermaid
flowchart TD
A[Worker.processNext]
A --> B[claimNextRetryJob または claimNextJob]
B --> C[runJobTracked]
C --> D[executeJob]
D --> E[issue lock取得 / running監査 / Push]
E --> F{Reflection Jobか}
F -->|yes| FR[Reflection専用経路]
F -->|no| G[prepareJobWorkspace]
G --> H[Space・folderContext・runtimeDir解決]
H --> I[Piece読込・定義検証]
I --> J{worker/model適合}
J -->|不一致| JQ[queuedへ戻す]
J -->|適合| K{required MCP tokenあり}
K -->|不足| KH[waiting_human: mcp_auth_required]
K -->|OK| L[LLM client生成]
L --> M[Tool Policy・SSH・grant・package解決]
M --> N[Mission / conversation / provenance / browser / skill context]
N --> O[runPiece]
O --> P[EventLogger・Conversation・ContextManager初期化]
P --> P2{continuedFromJobIdあり<br/>= Continue経由}
P2 -->|yes| REPLAY[transcript.jsonl を loadFrom → allReplayableThreads<br/>全スレッドを古い順に連結(system行が境界)<br/>掃除はスレッド毎・重複call_idは付け替え<br/>transition保持は最終スレッドかつ同一pieceのときだけ<br/>seedContinuation で新会話の頭に再生]
P2 -->|no| Q
REPLAY --> Q[開始Movement選択<br/>resume > override > piece start]
Q --> R[loop/revisit/cancel guard]
R --> S[prepareMovementContext]
S --> T[executeMovement]
T --> U[MovementResult mapping]
U --> V{next}
V -->|別Movement| Q
V -->|COMPLETE / ASK / WAIT / ABORT| W[PieceRunResult]
W --> X[handlePieceResult]
X --> Y[DB状態・コメント・監査・通知・親子Job処理]
Y --> Z[heartbeat/guard停止・metrics・unlock]
```
`prepareMovementContext`では、Workspace Tool Policyを唯一のtool/edit/SSH権限源とし、タスク内承認済みtoolをunionする。task instructionへ、Movement feedback、follow-up notice、subtask resume notice、checklist、lessonsを加える。
transcript(`runs/{taskId}/transcript.jsonl`)はタスクのruntime directoryに置く正規化された会話記録で、通常はturnを追記し、Continuation seedやprompt guardによる整形時には全体を再構築する。**transcript replayが走るのはContinue経由(`continuedFromJobId`あり)のみ**。コメントやASK回答による新Jobではreplayせず、コメント文脈をtask instructionへ含める。この場合に再利用可能なのは条件が一致する静的接頭辞までで、実際の再利用量は保証しない。replayの整形規則は`Conversation.replayableTurns`を参照。
## 3. Movement・LLM・ツール内部
```mermaid
flowchart TD
M0[executeMovement] --> M1[prepareMovement]
M1 --> TOOLS[getToolDefs + complete<br/>遷移先があるPieceではtransitionも追加]
M1 --> STATIC[static preamble構築<br/>AGENTS・Memory・Skills・tool説明]
M1 --> DYNAMIC[dynamic context構築<br/>時刻・files・Mission・handoff・console]
M1 --> GUIDE[Movement guidance + task instruction]
M1 --> SEED[プロンプト要素をConversationへ順序どおり配置]
STATIC --> SEED
DYNAMIC --> SEED
GUIDE --> SEED
SEED --> DCFG{delegate許可かつ<br/>再帰深度上限未満}
DCFG -->|yes| DINJECT[runDelegateをtoolCtxへ注入]
DCFG -->|no| LOOP[iteration loop]
DINJECT --> LOOP
TOOLS --> REQUEST[LLM request入力<br/>toolsはConversationの外で保持]
LOOP --> CANCEL{cancel/deadline signal}
CANCEL -->|yes| MCANCEL[cancel result]
CANCEL -->|no| INJECT[未処理interjectionをuser messageへ追加]
INJECT --> WATCH[watchdog / loop guard]
WATCH --> PGUARD[prompt guard<br/>estimate・dedup・compact・summarize]
PGUARD -->|overflow復旧不能| ABORT[context overflow result]
PGUARD -->|変更あり| REWRITE[Conversation.rewrite<br/>正規化後transcriptを再構築]
PGUARD -->|変更なし| LLM[runLlmIteration]
REWRITE --> LLM
REQUEST --> LLM
LLM --> CLIENT_GUARD[LLM client側の<br/>最終token上限検査]
CLIENT_GUARD --> STREAM[OpenAI-compatible streaming<br/>text/tool deltas/usage/retry/thinking/backend]
STREAM --> ERR{LLM error}
ERR -->|recoverable| LOOP
ERR -->|terminal| LERR[llm error result]
ERR -->|no error| CALLS{tool callsあり}
CALLS -->|なし| TEXT[text-only warning/retry]
TEXT --> LOOP
CALLS -->|あり| SPLIT[regular と transition/completeを分類]
SPLIT --> TLOOP[重複tool loop判定]
TLOOP -->|上限| TABORT[tool loop abort]
TLOOP --> DISPATCH[dispatchRegularToolCalls]
DISPATCH --> PAR{read-only parallel可能か}
PAR -->|yes| PEXEC[Promise.all]
PAR -->|no| SEXEC[sequential]
PEXEC --> TOOL[executeRegularToolCallCached]
SEXEC --> TOOL
TOOL --> AUTH[tool存在・引数・権限・sandbox・cache]
AUTH --> TOOL_KIND{delegateか}
TOOL_KIND -->|yes| DELEGATE[runDelegateSubAgent<br/>隔離した子ConversationでexecuteMovement]
TOOL_KIND -->|no| IMPL[各tool execute]
DELEGATE --> TResult[tool_resultをconversation・UI・eventへ記録]
IMPL --> TResult
TResult --> WAIT{待機フラグ}
WAIT -->|browser| WB[waiting_human]
WAIT -->|tool request| WT[waiting_human tool_request]
WAIT -->|package request| WP[waiting_human package_request]
WAIT -->|subtask| WS[waiting_subtasks]
WAIT -->|なし| TERM{complete / transition winner}
TERM -->|complete success + pending child| WS
TERM -->|complete| COMPLETE[completed / needs_user_input / abort mapping]
TERM -->|transition valid| TRANS[next Movement]
TERM -->|invalid| RET[tool_result errorを追加してiteration再試行]
TERM -->|none| LOOP
```
`tools`はConversationへ格納されない。`prepareMovement`が`messages`と並ぶ別の戻り値として返し、`runLlmIteration`へ渡す。`delegate`は通常tool dispatchの一種だが、実行前に`runDelegate`を`toolCtx`へ注入し、呼び出された場合は隔離した子Conversationで再帰的に`executeMovement`を実行する。
### 3.1 LLMリクエストのキャッシュ境界
PR #935以降は、単にプロンプトの内容を分割するだけでなく、LLMへ渡す`tools`と`messages`の順序を次の形に固定する。図中の「キャッシュ再利用可能」は、同一モデル・同一チャットテンプレート・同一Pieceなど、先行要素がバイト一致し、バックエンド側に対象キャッシュが残っている場合を表す。再利用量そのものを保証するものではない。
```mermaid
flowchart TD
REGULAR[Workspace Tool Policyで解決した<br/>regular tools] --> TOOLS[tools配列を構築]
TRANSITION[transition<br/>遷移先がある場合のみ<br/>enumはPiece全体の和集合] --> TOOLS
COMPLETE[complete] --> TOOLS
TOOLS --> TOOLS_STABLE[同一Piece内でtools配列を<br/>バイト単位で安定化]
STATIC[static preamble<br/>共通ルール・tool概要・AGENTS・Memory Index<br/>Skills Index・Working Directory] --> SYSTEM[先頭の単一system message]
FIRST_GUIDANCE[開始Movement guidance] --> SYSTEM
SYSTEM --> REPLAY{Continueかつ<br/>再生可能transcriptあり}
REPLAY -->|yes| PRIOR[過去の非system turnを順番に再生]
REPLAY -->|no| NO_PRIOR[過去turnなし]
PRIOR --> USER[末尾の単一user message]
NO_PRIOR --> USER
DYNAMIC[dynamic context<br/>日時・Mission・files・console<br/>handoffは再生可能turnがないContinue時のみ] --> USER
INSTRUCTION[task instruction] --> USER
USER --> MESSAGES[messages配列完成]
TOOLS_STABLE --> REQUEST[LLM request]
MESSAGES --> REQUEST
TOOLS_STABLE -. キャッシュの先行条件 .-> SYSTEM
SYSTEM -. 同一なら静的接頭辞を再利用 .-> PRIOR
PRIOR -. 過去requestと一致する範囲を再利用 .-> USER
USER -. ここから新規評価 .-> GENERATE[生成開始]
```
実際のメッセージ列は次の形になる。`dynamic context`と`task instruction`は、厳密なrole交互制約を持つモデルでも扱えるよう、別々の`user` messageではなく一つに結合する。
```text
tools = [regular tools..., transition(遷移先がある場合), complete]
messages = [
system(static preamble + 開始Movement guidance),
...replayable prior turns, // Continue時のみ
user(dynamic context + task instruction),
]
```
Movement 2以降のguidance、interjection、watchdog警告などは、既存会話の末尾へ`user` messageとして追加する。先頭へ挿入しないため、そこまでのキャッシュ済み接頭辞を壊さない。
### 3.2 初回・遷移・再開経路ごとのプロンプト構築
```mermaid
flowchart TD
START[Job開始] --> KIND{開始・再開の種類}
KIND -->|新規Job| NEW[Conversation.seed]
NEW --> NEW_MESSAGES["system: static + 開始guidance<br/>user: dynamic + instruction"]
NEW_MESSAGES --> NEW_CACHE[過去turnの再利用なし]
KIND -->|同一Job内のMovement遷移| MOVE[Conversation.enterMovement]
MOVE --> MOVE_MESSAGES["既存messages末尾へ<br/>user: 次Movement guidance"]
MOVE_MESSAGES --> MOVE_CACHE["toolsはPiece内で安定<br/>既存prefixをそのまま再利用可能"]
KIND -->|同一PieceをContinue| SAME[loadFrom → replayableTurns<br/>keepTransitions=true]
SAME --> SAME_MESSAGES["systemを新しく構築<br/>過去の非system turnを再生<br/>Error回答でないcompleteを除去<br/>handoffを省略して末尾へdynamic + instruction"]
SAME_MESSAGES --> SAME_CACHE["static・tools・再生turnが一致する範囲を再利用可能"]
KIND -->|別PieceをContinue| CROSS[loadFrom → replayableTurns<br/>keepTransitions=false]
CROSS --> CROSS_MESSAGES["旧Pieceのtransitionを除去<br/>新Pieceのsystem・toolsを使用<br/>再生turnがあればhandoffを省略"]
CROSS_MESSAGES --> CROSS_CACHE["Piece固有のguidance/toolsが変わるため<br/>同一Piece相当の再利用は保証しない"]
KIND -->|コメント・ASK回答による新Job| COMMENT[transcript replayなし]
COMMENT --> COMMENT_MESSAGES["system: static + 開始guidance<br/>user: dynamic + コメント文脈を含むinstruction"]
COMMENT_MESSAGES --> COMMENT_CACHE["条件が一致する静的接頭辞のみ再利用可能<br/>再利用量は保証せず、過去turnも再生しない"]
```
### 3.3 transcript replayの整形
```mermaid
flowchart TD
LOAD[transcript.jsonlをloadFrom] --> THREAD[system行を境界にスレッド分割<br/>全スレッドを古い順に連結<br/>掃除はスレッド毎・重複call_idは付け替え]
THREAD --> DROP_SYSTEM[スレッド先頭のsystemを除外]
DROP_SYSTEM --> COMPLETE_CHECK{assistantにcompleteがあるか}
COMPLETE_CHECK -->|Errorで始まる結果あり| KEEP_COMPLETE[拒否された中間completeを保持]
COMPLETE_CHECK -->|結果なし、またはError以外| DROP_COMPLETE[completeを除外]
DROP_COMPLETE --> DROP_COEMITTED[同じassistant messageで<br/>同時出力されたtransitionも除外]
COMPLETE_CHECK -->|completeなし| TRANSITION_CHECK{transitionか}
KEEP_COMPLETE --> TRANSITION_CHECK
DROP_COEMITTED --> VALIDATE
TRANSITION_CHECK -->|回答済みtransition かつ keepTransitions=true<br/>(最終スレッドかつ同一PieceのContinue)| KEEP_TRANSITION[call/resultペアを保持]
TRANSITION_CHECK -->|keepTransitions=false<br/>(別PieceのContinue、および古いスレッドでは一括除去)| DROP_TRANSITION[除外]
TRANSITION_CHECK -->|通常tool call| KEEP_REGULAR[call/resultペアを保持]
KEEP_TRANSITION --> VALIDATE[未回答call・孤立result・空assistantを除外]
DROP_TRANSITION --> VALIDATE
KEEP_REGULAR --> VALIDATE
VALIDATE --> SEED_CONTINUATION[seedContinuation]
```
判定基準は厳密には「成功済みか」ではなく、対応するtool resultが`Error`で始まるかである。`Error`回答された`complete`は、その後も会話が続いた中間履歴として保持する。結果がない、または`Error`以外の`complete`は除外し、同じassistant messageで同時出力された`transition`も一緒に除外する。
transcriptはタスクのruntime directoryに置く会話記録で、コメント追いメッセージのJobは新しいスレッド(先頭はsystem)を追記するため複数スレッドが蓄積し得る。replayは蓄積された全スレッドを古い順に連結して使う(`Conversation.allReplayableThreads`。issue #1343。以前は最後の再生可能スレッドだけを採っていたが、質問への回答で前の話が落ちる形で表に出たため既定から外した。経緯は docs/design/2026-07-26-transcript-last-thread-replay.html の冒頭の注記)。掃除(`replayableTurns`)はスレッド毎にかける——まとめてかけると、別スレッドが同じ`tool_call`のidを使い回したときに対応付けが狂い、終了済みの`complete`が生き残る。連結後にスレッドをまたいで重なったidは決定的に付け替える(`gen_call_{n}`はジョブ毎に1番から振り直されるため実際に重なる)。`keepTransitions`は最終スレッドにだけ当てる——その値の元になる`samePieceContinuation`は直前のジョブ1件から決まる判断で、古いスレッドがどのpieceで走ったかは記録に無いため。**最終スレッドが空でない限り**、この形でも連結前より再生量は増える(古いスレッドは以前は捨てていた)。例外は最終スレッドが空のとき——クラッシュや書き込み途中で切れてsystem行しか無いジョブがそれで、以前の`lastReplayableThread`は空スレッドを飛ばして1つ前を採っていたため、そこに`keepTransitions`が当たっていた。いまは1つ前が「古いスレッド」扱いになり遷移が落ちるので、この形では再生量が減る。遷移を落とす向きは安全側なので意図してそのままにしている。通常のturn追加では追記するが、`seedContinuation`と、prompt guardのdedup・compact・summarizeによってmessagesが変わった場合は、`Conversation.rewrite()`でファイル全体を再構築する。
### 3.4 キャッシュを維持するための変更時ルール
| 対象 | 維持すべき条件 | 変更時の影響 |
|---|---|---|
| `tools`配列 | Workspace Tool Policy、task内grant、動的tool module条件が不変なら、同一Piece内で名前、順序、schema、descriptionを安定させる | 1バイトでも変わるとllama.cppで全prompt cache missになり得る |
| transition | 遷移先があるPieceだけ追加し、enumはPiece全体の和集合にする | Movementごとの差分がtools配列に入ると全prompt cache missになり得る |
| static preamble | 日時、Mission、files、handoff、consoleを入れない | Jobごとの差分が先頭に入り、継続時の共有prefixを失う |
| system message | index 0の一つだけにする | 厳格なchat templateで拒否される可能性がある |
| dynamic context | task instructionと一つの末尾`user` messageに結合する | 連続した`user` roleに対応しないモデルで失敗し得る |
| Movement guidance | 2番目以降は会話末尾へ`user`として追記する | 先頭を書き換えると既存prefixを無効化する |
| 同一Piece transition | replay時に回答済みcall/resultを保持する | 中間履歴を削ると、その位置以降のcacheが分岐する |
| Error以外のcomplete | replayから除外し、同時出力transitionも除外する | 再生すると継続Jobが即時完了を模倣する可能性がある |
キャッシュ再利用量は`llm-stream`の`prompt_cache reused=... prompt=... pct=...`ログと、LLMイベントの`cachedPromptTokens`で確認する。構造を変更した場合は、単体テストだけでなく実バックエンドでMovement遷移直後とContinue直後の再利用率を測定する。
## 4. 待機・再開・通知・UI
```mermaid
sequenceDiagram
participant Agent as Agent Loop
participant Worker
participant DB
participant API
participant UI as Chat UI
participant Notify as Push/Webhook/Inbox
Agent->>Worker: PieceRunResult(waiting_human)
Worker->>DB: status=waiting_human, waitReason, resumeMovement
Worker->>Notify: waiting_human event
UI->>API: task/tool/package request polling
API->>DB: pending request取得
API-->>UI: 承認カード / ASK表示
alt ASK
UI->>API: POST comment
API->>DB: resumeMovementとaskCountを継承したJob作成
else Tool Request
UI->>API: approve / deny
API->>DB: grantまたはdeny + parked Jobをqueuedへ戻す
else Package Request
UI->>API: approve / deny
API->>DB: CAS決定、必要なら隔離install、parked Jobをqueuedへ戻す
end
DB-->>Worker: 次回pollでclaim
Worker->>Agent: 同一Movementから再実行
Agent->>Worker: completed / failed / cancelled
Worker->>DB: 終端status・comment・audit・event
Worker->>Notify: succeeded / failed
DB-->>UI: task/comments/activity更新
```
チャットUIでは `ChatPane → ChatMessageFeed → ChatMessage` が永続コメントを表示し、`ChatComposerPanel`がTool RequestとPackage Requestの承認カードを表示する。`useJobStream`は生成中text、tool call、LLM状態、delegate streamを反映する。Activityは`ActivityTimeline`、短期通知はToast、要対応通知はNotification Bell/Centerが担う。
### 4.1 Auto Prompterの透明性境界
Auto Prompterが有効なJobでは、ツール実行前に固定済みPolicy Snapshotを評価し、追加指示、拒否、引数に結び付いた一回限りの承認を行う。発火結果は許可リスト形式のPolicy Eventとして記録し、チャットへ通知する。無効なJobではこの経路を短絡し、従来のツール実行経路を変えない。
Policy Eventは追跡可能性を支援するbest-effort監査であり、hidden chain-of-thought、生のツール引数、認証情報、prompt全文を保存しない。記録失敗だけを理由にエージェント処理を停止する保証型台帳でもない。
この機構はEU AI Actその他の法令への準拠を自己宣言しない。Article 50だけでchain-of-thought保存や一律5年保持が要求されるとは解釈せず、適用範囲、事業者の役割、保持期間は法務確認の対象とする。保持期間の設定は将来のCompliance Phaseで扱う。詳細は [EU AI Act透明性対応 設計付録](./auto-prompter-eu-ai-act-transparency-design.md) を参照する。
一次資料: [Regulation (EU) 2024/1689](https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng)、[European Commission: AI Act](https://digital-strategy.ec.europa.eu/en/policies/regulatory-framework-ai)
+44
View File
@@ -0,0 +1,44 @@
# AskUser
ユーザーに選択肢つきで質問し、回答があるまでタスクを停車する park ツール。
## いつ使うか
- 指示が曖昧で、**進め方の分岐を自分で列挙できる**とき(デプロイ先、対象範囲、形式の選択など)
- Mission Brief と今回の指示がずれて見え、どちらに従うべきか確認したいとき
- 複数の論点をまとめて確認したいとき(1 回の停車で最大 4 問)
## いつ使わないか
- 選択肢を作れない開放的な質問(「何をお望みですか?」)→ `complete({status:"needs_user_input", missing_info})`
- 自明な既定値があり、進めてから報告すれば足りる場合 → 質問せず進める
- subtask 実行 → 利用不可(degrade メッセージが返る)。自明な選択肢で進めるか complete で報告
## 書き方
```json
{
"questions": [
{
"question": "デプロイ先はどちらにしますか?",
"header": "デプロイ先",
"options": [
{ "label": "本番", "description": "即時反映。ロールバック可" },
{ "label": "ステージング", "description": "検証後に手動昇格" }
],
"multiSelect": false
}
]
}
```
- 選択肢は **2〜6 個・相互排他**に。網羅しようとせず、迷う候補は入れずに自由記述(UI が常に追加する「その他」)に任せる
- `question` は文脈を含め単体で意味が通るように。ユーザーは会話全体を読み直さない
- `header` はカードの短いラベル。省略可
## 挙動
- 呼ぶとタスクは停車(`waiting_human`)し、ワーカーは解放される。ツール内で回答を待たない
- 回答は次の再開時に**ユーザーメッセージ**として届く(「見出し: 選択」の平文)
- ASK 上限は停車 1 回で 1 消費(質問数ではない)。上限は askPolicy 設定に従う
- 回答を受けたら、決定事項を `MissionUpdate` の `decisions` / `clarifications` に記録すること
+4 -2
View File
@@ -58,7 +58,7 @@ Bash 実行の隔離方式を選ぶ。**2 つの設定は直交する**:
| `bash_sandbox` | 挙動 |
|----------------|------|
| `auto`(既定) | bwrap があれば bwrap サンドボックス、無ければ hardened(whitelist + パススコープ + env スクラブ)にフォールバック |
| `auto`(既定) | bwrap があれば bwrap サンドボックス、無ければ hardened(whitelist + パススコープ + env スクラブ)にフォールバック。ただし外向き通信の遮断(`bash_allow_network: false`、既定)を選んだままフォールバックする組み合わせは**起動失敗**——hardened には遮断する仕組みが無いため (#1235) |
| `always` | bwrap を強制。bwrap 不在なら**起動失敗**(本番推奨) |
| `off` | bwrap を使わず exec(**env スクラブと、unrestricted でなければ whitelist + パススコープは維持**)。デバッグ用・非推奨 |
@@ -84,4 +84,6 @@ Bash 実行の隔離方式を選ぶ。**2 つの設定は直交する**:
- コンテナ/ホストで **user namespace** が有効であること (PVE: `features: nesting=1`)
- `bwrap` バイナリがインストール済みであること
- `bash_sandbox: always`(または `bash_unrestricted: true`)では起動時に bwrap の動作確認を行い、失敗時はエラー終了する。`auto` では失敗時に警告ログを出し hardened へフォールバック
- `bash_sandbox: always`(または `bash_unrestricted: true`)では起動時に bwrap の動作確認を行い、失敗時はエラー終了する。`auto` は失敗時の挙動が `bash_allow_network` で変わる (#1235):
- `false`(既定・遮断する) → **エラー終了**。設定は「遮断する」なのに hardened では遮断されない、という食い違いを抱えたまま走らせない
- `true`(遮断しない) → 警告ログを出して hardened へフォールバック
+69 -6
View File
@@ -304,15 +304,78 @@ BrowseWithSession({
ダウンロードを認証付きで行いたい場合は、Browser Sessions 機能で対象サイトのログインセッションを保存し、タスクで bind した状態で BrowseWeb を呼ぶこと。
## ソースライブラリへの蓄積(スペースタスク)
## ページの魚拓(本文+画像)
スペースのタスクで BrowseWeb がページ本文(スナップショット / セレクタ抽出 / dumpHtml 等の抽出テキスト)を取得すると、その本文が Markdown として `source/` に保存され、出典メタ(URL・取得日時・サイズ・`tool: "BrowseWeb"`)が `source/index.jsonl` に追記される。WebFetch / DownloadFile と同じソースライブラリに合流するので、「そのスペースのソース(資料)」が自動で溜まり、後から追加調査やグラウンディングに使える。
BrowseWeb が開いたページは、本文とそのページに載っていた画像が `source/` に自動で溜まる。出典メタ(URL・取得日時・サイズ・`tool: "BrowseWeb"`)は `source/index.jsonl` に 1 行ずつ追記される。WebFetch / DownloadFile と同じソースライブラリに合流するので、後から追加調査やグラウンディングに使える。
- 同一 URL は重複保存しない(既に index にあればスキップ)。同じ URL を 2 回閲覧しても 1 件のまま
- スペース文脈でないタスク(ephemeral / gitea issue 等)では従来どおり蓄積しない(`ctx.spaceId` が無いとき)
**属している場所で挙動は変わらない。**スペースのタスクでも、タスク一覧から直接作ったタスクでも、定期実行・サブタスクでも同じように溜まる(issue #1097 / 由来 #1093)。
### 何が・どこに
| | |
|---|---|
| ページ本文 | `source/<ページ名>.md` |
| ページに載っていた画像 | `source/images/<名前>.<拡張子>` |
| 索引 | `source/index.jsonl`(1 行 1 件) |
索引の画像行は `kind: "image"` を持ち、`page_url` / `page_file` で**どのページに載っていた画像か**を指す。`sha256`(中身の指紋)・`width` / `height` も入る。ページ本文の行は従来どおり `kind` を持たない。
保存された画像は `ReadImage({ file_path: "source/images/xxx.png" })` でそのまま読める。**画像のための新しいツールは無い。**
### 取り方
画像は **開いているページの文脈**で取りに行く(`page.request`。Cookie・User-Agent はブラウザコンテキストのもの、Referer は開いている URL)。
**画像の URL を別のツール(DownloadFile 等)に渡して取り直してはいけない。**ログインが要るページ・リンク元を見て弾くサイト・期限つきの署名が付いた URL がいずれも取れなくなり、この機能の価値が消える。ここがスクリーンショット依存を外せる理由。
候補は 2 つの経路から並べる。
1. `document.images` の `currentSrc`(srcset / picture で実際に選ばれた 1 枚)。実寸(`naturalWidth` / `naturalHeight`)が取れるので寸法の下限が効く
2. Resource Timing(`performance.getEntriesByType('resource')`)のうち `initiatorType` が `css` / `img` のもの。**スタイルシートの背景として敷かれた画像はここにしか出ない**——記事の主役の写真が背景で敷かれている作りのサイトでは、`<img>` だけ見ていると図が丸ごと落ちる。実寸は取れないので、下限の判定は見送って残す側に倒す
並び順は `<img>` が先。取りに行く回数には上限があるので、フォントや装飾で枠を食い潰させない。資源の一覧には画像でないもの(フォント等)も混じるが、**種類で選り分けない**——画像かどうかは取ってきた中身の先頭バイトで決める。
`data:` URL はその場で復号する。`blob:` は取りに行けないので対象外。インラインの `<svg>` 要素と `<canvas>` は、そもそも取りに行く URL が無いので入らない。
`page.request` はページの route 傍受を通らないので、**取得前に SSRF の関所(`ssrfCheck`)を通す**。ページに `<img src="http://169.254.169.254/...">` を仕込まれても内部へ取りに行かせない。許可ホスト(`tools.webfetch_allowed_hosts`)は BrowseWeb 本体と同じものを使う。
### 歯止め(意味は見ない)
「これは飾りだから捨てる」という判定は書かない。書けば必ず漏れるし、漏れて困るのは「あとから欲しかったのに無い」側。歯止めは意味を見ないものだけ。
| 歯止め | 既定 | config.yaml(`tools` 配下) |
|---|---|---|
| 中身の指紋で畳む | 常時 | (設定なし) |
| 1 ページあたりの枚数 | 20 | `browse_image_max_per_page` |
| 1 枚あたりの容量 | 5MB | `browse_image_max_size_mb` |
| 容量の下限 | 1024 バイト | `browse_image_min_bytes` |
| 寸法の下限 | 32 ピクセル | `browse_image_min_dimension` |
| 取りに行く回数 | 枚数の上限 × 3(既定 60 回) | (設定なし。枚数の上限に連動) |
寸法の下限は **実寸が取れたときだけ**効く。取れていない画像は残す側に倒す(飾りは入る前提)。
**回数の上限は「残した枚数」とは別に数える。**残した枚数だけを見ていると、1 枚も残らないページ(配信元が落ちている・下限を下回る画像ばかり)で候補の全件に取りに行ってしまう。1 枚あたり 15 秒待つので、候補が数百あるページを開いただけでブラウザ操作が何分も返らなくなる。
溜まったものを自動で削除する仕組みは入れない。索引に参照が残るので、「どのページからも参照が無い」「1 ページでしか使われず小さい」といった条件で後から掃除する道は残る。
### その他
- 同一 URL のページ本文は重複保存しない(既に index にあればスキップ)。**ただし本文が既出でも画像は取りに行く**——WebFetch で本文を読んでから、図が要ると気づいてブラウザで開き直す流れを塞がないため。再訪の抑止は 3 段。①このプロセスでこのページの収集を試したか(`hasPageArchiveAttempt`。索引に痕跡が残らない「1 枚も溜まらなかったページ」がここに当たる。中断で切ったときは記憶しない)、②索引にこのページの画像行があるか、③すり抜けた分は指紋の判定が止める
- 中身が同じ画像は実ファイルを 1 枚に畳み、索引の行だけ足す(ロゴ・アイコンが何ページぶんも積み上がらない)
- 空・エラー結果、`http(s)` 以外(`file://` 等)の URL はソース化しない
- スクリーンショットはソースではないので `output/` に残り、`source/` には入らない(ソースはあくまでテキスト)
- これは純粋な追記サイドライトで、BrowseWeb の戻り値・スクショ・`logs/` 出力は一切変わらない
- スクリーンショットはソースではないので `output/` に残り、`source/` には入らない
- 魚拓は副作用。失敗してもブラウザ操作そのものは通る。戻り値には `[archive] source/images/ に 7 枚(索引: source/index.jsonl)` の形で 1 行だけ足る。落ちた枚数は理由ごとに分けて出す(`上限・下限で対象外` / `画像として読めない中身` / `取得できず`)
- 本文を取らない操作列(`goto` → `screenshot` 等)でも `goto` 直後に魚拓する。`BrowseWithSession` も同じ経路を通る
### 形式の判別
拡張子と `content-type` は**見ない**。どちらも当てにならないため——オブジェクトストレージの署名つき URL は拡張子を持たず `application/octet-stream` で配られることが多く、逆に `content-type: image/png` でも中身が HTML のエラーページということがある。
判別は**中身の先頭バイト**で行う(`sniffImageExtension`)。PNG / JPEG / GIF / WebP / BMP / TIFF / ICO / AVIF / SVG を見分け、**どれにも当たらないものだけ捨てる**。形式の判別であって、意味の判定ではない。AVIF は主ブランドが `mif1` で `avif` が互換ブランド側に載る配り方があるので、`ftyp` ボックスの互換ブランドの並びまで見る。
保存した画像は `ReadImage` がそのまま読める。SVG / AVIF / TIFF / ICO はモデルが直接受け取れないので、`ReadImage` 側が PNG に起こしてから渡す。唯一の例外は **BMP を格納した古い形式の `.ico`**(sharp が読めない)で、この場合は理由付きのエラーが返る。溜まってはいるので、必要なら別途変換できる。
動画・音声は対象外(`<img>` にも Resource Timing の `img` / `css` にも出ない)。フォントは候補には入りうるが、先頭バイトで画像でないと分かるので保存されない。
## SSRF 保護
+34 -1
View File
@@ -1,4 +1,4 @@
# カレンダーツール(AddCalendarEvent / ListCalendarEvents)
# カレンダーツール(AddCalendarEvent / ListCalendarEvents / DeleteCalendarEvent)
スペース別カレンダーに「予定」を読み書きするツール。予定は受動的なエントリで、
cron の定期タスク(`scheduled_tasks`)とは別概念(予定は実行しない)。
@@ -39,9 +39,42 @@ AddCalendarEvent({ title: "現地出張", date: "2026-07-10", end_date: "2026-07
範囲を省略すると全期間。結果は日付→時刻順(終日は各日の先頭)で返る。
## DeleteCalendarEvent
スペースの予定を id で 1 件削除する。
引数:
- `id`(必須): `ListCalendarEvents` が返す予定の id(正の整数)
**削除できるのは `created_by` が `agent` の予定だけ。**利用者が画面から入れた予定
(`created_by` = `user`。この列が無かった頃の古い行も `user` として扱われる)は、
id を渡されても削除せず `isError: true` を返す。
```
ListCalendarEvents({ from: "2026-07-01", to: "2026-07-31" })
// - 2026-07-01 10:00 「定例MTG」(id=12, agent)
// - 2026-07-03 (終日)「健康診断」(id=13, user)
DeleteCalendarEvent({ id: 12 }) // → 削除される
DeleteCalendarEvent({ id: 13 }) // → isError。利用者が作成した予定
```
返り値(`isError: true` のもの):
| 渡した id | 返る内容 |
|---|---|
| 利用者が作成した予定 | この予定は利用者が作成したため削除できません。削除できるのはエージェントが作成した予定(created_by=agent)だけです |
| 別スペースの予定 | 指定された id の予定はこのスペースに見つかりません |
| 存在しない・すでに削除済み | 指定された id の予定はこのスペースに見つかりません |
別スペースの id と存在しない id で**文面を同一にしている**のは意図的。文面が違うと、
その id の予定が他のスペースにあることを差分で漏らす。
## gotcha
- `date` / `from` / `to` は `YYYY-MM-DD`、`time` / `end_time` は `HH:MM` 厳守。形式違反はエラー。
- `end_time` は `time` なしには指定できない(終日の予定に終了時刻は持てない)。
- スペース未所属のタスクからは呼べない(`ctx.spaceId` 必須)。
- 予定はスペースの可視性に従う。イベント単独の可視性は持たない。
- `DeleteCalendarEvent` は利用者が作成した予定を消せない。一覧の行末に出る `agent` / `user` が判定に使われる値そのもの。
- 予定に紐づくリマインダーは予定と一緒に消える(予定の行に載っているため)。画面からの削除と同じ挙動。
- 予定の書き換えはツールとして用意していない(削除のみ)。
+1 -1
View File
@@ -2,7 +2,7 @@
サブエージェントを同期的にインライン実行して、重い処理を委譲し本体のコンテキストを節約する。サブエージェントの中間ターンは親に見えないため、複雑な作業の最終結果だけを取得できる。
**タスク分解の既定手段**。research / general / brainstorming など調査・分析系のピースは、重いサブ調査を Delegate で **1 件ずつ直列に** こなす設計になっている。並列の別ジョブ(SpawnSubTask)はデフォルト無効で、本当に並列が必要なワークスペースだけが **設定 → ツール** タブで明示的に有効化する。ほとんどのタスクは Delegate で分解が完結するため、まず Delegate を検討する。
**タスク分解の既定手段**。research / general など調査・分析系のピースは、重いサブ調査を Delegate で **1 件ずつ直列に** こなす設計になっている。並列の別ジョブ(SpawnSubTask)はデフォルト無効で、本当に並列が必要なワークスペースだけが **設定 → ツール** タブで明示的に有効化する。ほとんどのタスクは Delegate で分解が完結するため、まず Delegate を検討する。
## 基本
+17 -2
View File
@@ -28,18 +28,33 @@ If there is no record (a file that pre-dates the ledger), you get an
`unknown`-equivalent message — treat the file as possibly belonging to another
task and verify its contents before overwriting.
## ListWorkspaceFiles({ path?, sourceKind?, createdByTaskId?, lastModifiedByTaskId?, includeUnknown?, limit? })
## ListWorkspaceFiles({ path?, sourceKind?, createdByTaskId?, lastModifiedByTaskId?, since?, until?, includeUnknown?, limit? })
Returns a bounded listing (default 50, max 200 rows) of known files with a
one-line provenance summary each. It never returns file contents.
one-line provenance summary each. It never returns file contents. Each line
starts with the file's **date**.
Filters:
- `path` — path prefix, e.g. `output/`.
- `sourceKind` — filter by source kind.
- `createdByTaskId` / `lastModifiedByTaskId` — filter by task.
- `since` / `until` — date range, inclusive on both ends. Accepts `YYYY-MM-DD`
or a full ISO timestamp; a bare `until` date covers that whole day.
- `includeUnknown` — set `false` to hide `unknown` / `imported_existing` files.
### The date
A file's date is `last_modified_at`, falling back to `first_seen_at`. Display,
ordering and `since` / `until` all use that same one date, so a date you read
off a listing can be pasted straight back into a range filter.
### Selecting only this task's files
The job's context block carries `このタスクの番号: <id>`. Pass it as
`createdByTaskId` to see only what this task made, or use it to tell your own
files apart from ones that were already in the workspace.
## Guidance
- Do NOT blindly edit files whose `source_kind` is `user_input` or whose
+15 -5
View File
@@ -6,25 +6,35 @@ MAESTRO のプロジェクト内ドキュメントを **カテゴリ別に一覧
なし。
## 出力(3 カテゴリの Markdown)
## 出力(4 カテゴリの Markdown)
| セクション | 内容 | 読み込み形式 |
|-----------|------|------------|
| 利用者向けヘルプ | `ui/src/content/help/*.md` の frontmatter `title` | `help/<name>` |
| Piece 一覧 | `pieces/*.yaml` の名前+ description 1 行 | `piece/<name>` |
| ドキュメント | `docs/` 配下の `.md`(後述の除外を除く) | `docs/<path>` |
| ツール参照 | `docs/tools/*.md` 全件 | `tool/<name>`(`ReadToolDoc` と同等) |
実際の出力から抜粋(各行は 1 件ずつ、見出しの直後に空行が入る):
```
ListAppDocs()
→
# 利用者向けヘルプ (`help/<name>` で読み込み — アプリ内ヘルプと同じ内容)
- `help/06-schedules` — 自動実行を設定する
...
# Piece 一覧 (`piece/<name>` で読み込み)
- `piece/chat` — 雑多な依頼に答える汎用ピース
- `piece/chat` — 汎用デフォルト piece。質問・調査・コード生成・文書作成・データ処理など、
...
# ドキュメント (`docs/<path>` で読み込み)
- `docs/mcp` — MCP 連携の概要
- `docs/mcp` — The orchestrator can call tools hosted on external **MCP servers** (OAuth-secured
...
# ツール参照 (`tool/<name>` で読み込み — ReadToolDoc と同等)
- `tool/browseweb` — ヘッドレスブラウザで…
- `tool/browseweb` — ヘッドレスブラウザで Web ページを操作するツール。同一ジョブ内ではブラウザコンテキスト(Cookie・ログイン状態)が永続化される。
```
## 一覧から除外されるもの
@@ -38,7 +48,7 @@ ListAppDocs()
## 制約
- description は各ファイルの frontmatter を飛ばした最初の見出し/段落から最大 140 字を自動抽出。
- description は各ファイルの frontmatter を飛ばした最初の見出し/段落から最大 140 字を自動抽出。利用者向けヘルプだけは frontmatter の `title` を使う(無ければファイル名)。
- 合計エントリが 150 件を超えると、末尾に「個別取得を促す注記」が付く。
## 関連
+70
View File
@@ -0,0 +1,70 @@
# ListFileStoreLocations / ListFileStoreFiles
利用者が SharePoint / OneDrive / Box のうち「ここだけ見てよい」と決めて登録した
フォルダを、エージェント側から一覧するための 2 つの道具。
**中身は取得できない。** 取れるのは、そのフォルダの直下にある項目の
名前・フォルダかどうか・大きさ・更新日時だけ。ファイルを開いて読むのは別の段で、
この道具にはそのための引数も戻り値もない。
## 使う順番
1. `ListFileStoreLocations()` — 登録済みの場所を一覧する。引数なし。
2. 出てきた `id` を `ListFileStoreFiles({ locationId })` に渡して、その直下を見る。
`id` は 1 の一覧から得たものだけが使える。当てずっぽうの id は「見つかりません」に
なる(他人の場所を当てても同じ答えなので、存在の有無は分からない)。
## 例
```
ListFileStoreLocations()
→ ## 外部ファイル保存の場所 — 2 件
- id: 7f3c…
service: SharePoint / OneDrive
path: /Shared Documents/請求書
library: Shared Documents
site: https://contoso.sharepoint.com/sites/sales
connection: 営業部 SharePoint
- id: 91ab…
service: Box
path: /経理/請求書
site: https://app.box.com/folder/123456789
connection: 経理部 Box
ListFileStoreFiles({ locationId: "7f3c…" })
→ ## /Shared Documents/請求書(Shared Documents)— 3 件
- 2026-07.xlsx size=20.0 KB updated=2026-07-31T09:00:00Z
- 2026-06.xlsx size=19.2 KB updated=2026-06-30T09:00:00Z
- [フォルダ] 控え updated=2026-05-02T04:11:00Z
```
`service` は保存先の種類。Box にはライブラリ(文書ライブラリ)に当たる階層が
無いので `library` の行が出ない。`site` の行は、SharePoint ならサイトの URL、
Box ならそのフォルダのページの URL になる。
## 制限
| 事項 | 実際の動き |
|------|-----------|
| 深さ | 登録された 1 フォルダの**直下のみ**。中のフォルダには入れない |
| 場所の指定 | 登録時に決まっている。SharePoint はサイトとライブラリとフォルダ、Box はフォルダ番号ひとつ |
| 中身 | 取得できない。ダウンロードも本文の抽出もできない |
| 件数 | 既定 500 件。超えた場合は「これで全部ではありません」と出力に付く |
| 誰の資格情報か | **タスクの持ち主**のもの。共有されたタスクを別の人が動かしても、使われるのは持ち主の接続 |
| 書き込み | できない。要求している権限は読み取りのみ |
## 出ないとき
出力の文面がそのまま次の一手になる。
| 出力の趣旨 | 状況 | できること |
|-----------|------|-----------|
| この実行では使えません | 機能が有効になっていない実行 | 手元のワークスペースのファイルで進める |
| 登録されている保存場所はありません | 接続はあるがフォルダ未登録 | 利用者に登録を頼む |
| 見つかりません | id が違う・他人の場所 | `ListFileStoreLocations` を取り直す |
| 接続がまだ済んでいません | 持ち主が未接続 | 利用者に設定画面からの接続を頼む |
| 認可の期限が切れています | 更新できなくなった | 利用者に接続し直しを頼む |
管理者がワークスペースの設定でツール分類 `filestore` を切っている場合、この 2 つは
そもそもツール一覧に出ない。
+333 -9
View File
@@ -1,4 +1,4 @@
# Office ファイル系ツール(ReadPdf / ReadExcel / ReadDocx / ReadPPTX / ReadMsg / PdfToImages / SplitExcelSheets / SplitDocxSections)
# Office ファイル系ツール(ReadPdf / ReadExcel / ReadDocx / ReadPPTX / ReadMsg / PdfToImages / SplitExcelSheets / SplitDocxSections / EditExcel / EditDocx / EditPptx)
ローカル workspace の Office 文書・PDF を読み込むツール群。
@@ -108,9 +108,9 @@ merges: B2:D4
`max_style_ranges`(デフォルト 250)で全シートを通じた range 出力数を制限する。上限に達した場合は `[styles truncated: max_style_ranges=N reached]` を表示し、以降のスタイルは省略される。値テーブルが先に出力されるため、スタイルセクションがトークン上限で切り捨てられても値は失われない。
**編集には python + openpyxl を推奨:**
**編集は EditExcel を使う:**
ReadExcel はスタイルを読むだけで、書き込みは行わない。既存スタイルを保持したまま値を編集する場合は `Bash` ツールで python + openpyxl を使うこと(openpyxl は触れていないセルのスタイルを保持する)。
値・数式だけでなく、セル結合、書式、シート追加、ガントの着色にも対応する。独自のPythonスクリプトやopenpyxlより先に `EditExcel` を使うこと。詳細編集では保存前の構造検査と数式再計算まで一括で行う。
### ReadDocx
@@ -200,11 +200,281 @@ SplitDocxSections({ file_path: "input/long-spec.docx" })
| Word(長文・章構成) | SplitDocxSections → Read | - |
| PowerPoint | ReadPPTX | - |
### EditExcel
`.xlsx` を編集する。**`edits` と `operations` は別モードであり、同時に指定しない。**
#### 軽量なセル編集(edits / rows / merges / columns)
値・数式・基本的な書式・セル結合・列幅を直し、複雑なブックの未対応部品を保持したい場合に使う。
```js
EditExcel({
path: "output/sales.xlsx",
edits: [
{ cell: "B2", value: 250 },
{ sheet: "集計", cell: "C10", formula: "SUM(C2:C9)" },
{ cell: "D5", value: null },
// 値はそのまま、書式だけを変える
{ cell: "A1", style: { bold: true, fill_color: "#FFFF00", alignment: { horizontal: "center" } } },
// 表示形式(数値の見せ方)
{ cell: "B3", style: { number_format: "#,##0" } },
{ cell: "C3", style: { number_format: "yyyy/mm/dd" } }
],
rows: [{ row: 1, height: 28 }],
merges: [{ range: "A1:C1", merged: true }],
columns: [{ columns: "A:C", width: 18 }]
})
```
対象シートのOOXMLだけを差し替えるため、書式・条件付き書式・入力規則・結合セル・図・グラフ・ピボット・マクロ・他シートを保持する。数式は `fullCalcOnLoad=1` を付け、古い `calcChain.xml` を除去する。計算結果の検証は行わない。
**書式(`edits[].style`)**
指定した項目だけが、そのセルの今の書式に重なる。書かなかった項目は元のまま残る。
`value` も `formula` も省くと、値には触れず書式だけを変える。
重ねる土台は**セル → 行 → 列**の順に探す。セルに書式が付いていなくても、行(`customFormat="1"` の付いた行の書式)や列(`<col style="...">`)から受け継いだ見た目があればそれを土台にする。どこにも無ければ既定の書式から組み立てる。
| 項目 | 意味 |
|---|---|
| `bold` | 太字。`false` で解除 |
| `font_color` | 文字色 `"#RRGGBB"` または `"#AARRGGBB"`。`null` で既定色に戻す |
| `fill_color` | 背景色 `"#RRGGBB"` または `"#AARRGGBB"`。`null` で塗りなし |
| `border` | 罫線。`{ edges, style, color }`。`edges` 省略時は 4 辺。`style` 省略時は `thin`。`color` 省略時と `null` は既定色。`null` で 4 辺とも消す |
| `alignment` | 揃え。`{ horizontal, vertical, wrap_text }`。`null` で揃えの指定を消す。`{}`(中身なし)は断る |
| `number_format` | 表示形式。Excel の書式コードをそのまま渡す(`"#,##0"` `"0.0%"` `"yyyy/mm/dd"` `'"¥"#,##0'`)。`null` か `"General"` で標準に戻す。255 文字まで |
**行の高さ(`rows`)**
`{ sheet, row, height }`。`row` は 1 始まり、`height` はポイント(0 より大きく 409.5 以下= Excel の上限と同じ)。`null` で自動に戻す。
書式表(`xl/styles.xml`)へは**末尾にだけ**組を足す。既存セルが指す番号は 1 つも動かないので、触っていないセルの見た目は変わらない。同じ書式を何セルに、何回の保存にまたいで掛けても、書式表は増えない(元からある組も突き合わせて使い回す)。
表示形式の番号は、規格が決めた組み込みの席(0〜163)に載っているものはそのまま使い、それ以外は 164 から採番して `numFmts` に載せる。日付・時刻・通貨は組み込みの番号に**寄せない**——組み込みの番号は見え方が表示側の地域設定で変わり、規格と Excel で中身が違う番号もあるため、渡した書式コードをそのまま載せる。
ブックが `numFmts` で組み込みの席を自分の書式コードに**上書きしている**ことがある(変換ツールを通ったファイルなど)。その番号には寄せず、164 以上へ新しく採番する。上書きされた番号へ寄せると、頼んだ見せ方と違うものが読み手に見える。
**セルの結合(`merges`)**
`{ sheet, range, merged }`。`range` は `A1:C1`(左上:右下)、`merged` は `true` で結合・`false` で解除。
| 断るもの | 例 | 理由 |
|---|---|---|
| 範囲になっていない | `A1` | 1 セルの結合は Excel にも無い |
| 逆順 | `C1:A1` | 左上と右下が入れ替わっている |
| シートの外 | `A1:XFE1` / `A1:A1048577` | Excel の列数(16384)・行数(1048576)を超える |
| 重なる結合 | `A1:C1` と `B1:D1` | 重なった結合はブックが開けなくなる。既にある結合との重なりも、同じ呼び出しの中の重なりも断る |
| 二重の結合 | 既に `A1:C1` があるところへ `A1:C1` | 同上 |
| 結合されていない範囲の解除 | 結合の無い `D1:E1` を `merged: false` | 黙って何もしないと、解除できたと誤解する |
| 中身が入ったセルを巻き込む結合 | `B1` に値があるまま `A1:C1` | **Excel は結合すると左上以外の中身を捨てる**。黙って消さず、どのセルが該当するかを挙げて断る |
| 位置を並び順で書いているシートの結合 | `<c>` に `r` が無い行がある | どのセルが範囲に入るか読めない。何が消えるか分からないまま結合するより断る(その行にかかる結合だけ) |
最後の 1 件は、同じ呼び出しの `edits` でそのセルを空にすれば通る(`edits` → `columns` → `merges` の順に当てるため)。
```js
EditExcel({
path: "output/sales.xlsx",
edits: [{ cell: "B1", value: null }, { cell: "C1", value: null }],
merges: [{ range: "A1:C1", merged: true }]
})
```
解除と結合を同じ呼び出しに書いた場合は**解除が先**。`A1:C1` を解いて `A1:B1` に結び直す、が 1 回で書ける。
`<mergeCells count="N">` の N は実際の件数に合わせて書き直す。最後の 1 件を解いたときは `<mergeCells>` という入れ物ごと消す(空の入れ物は ECMA-376 違反)。
**列の幅(`columns`)**
`{ sheet, columns, width }`。`columns` は `"A:C"`(範囲)または `"B"`(1 列)。`width` の単位は**標準書体の「0」の文字幅**で、ピクセルでもポイントでもない(ECMA-376 の `CT_Col/@width`)。0 より大きく 255 以下。`null` で幅の指定を外す(既定幅に戻る)。
`<col>` の `min` / `max` は範囲なので、1 本が複数の列をまとめていることがある。3 列目だけ幅を変える指定は、1-5 をまとめた 1 本を 1-2 / 3-3 / 4-5 の 3 本に割ってから当てる(範囲が重なった `<col>` を並べると Excel の挙動が定まらない)。割った先には列の書式(`style`)・非表示(`hidden`)などの属性をすべて引き継ぐ。
幅を書くときは `customWidth="1"` も一緒に書く。付けないと Excel は幅を無視して既定幅で描く。逆に `bestFit`(自動で決めた幅、という印)は落とす。
**`worksheet` の子要素の並び順**
`<cols>` は `sheetData` の**前**、`<mergeCells>` は**後ろ**にあり、ECMA-376 の `CT_Worksheet` は子要素の順番を決めている(`sheetPr` → `dimension` → `sheetViews` → `sheetFormatPr` → `cols` → `sheetData` → `sheetCalcPr` → `sheetProtection` → `protectedRanges` → `scenarios` → `autoFilter` → `sortState` → `dataConsolidate` → `customSheetViews` → `mergeCells` → `phoneticPr` → `conditionalFormatting` → … → `extLst`)。順番を破ると Excel が修復を求める。要素が無いシートへ足すときは、`<cols>` は `sheetData` の直前、`<mergeCells>` は**後ろに来る要素のうちいちばん手前にあるもの**の直前へ入れる。並びの根拠は `src/office/xlsx-sheet-layout.ts` の冒頭に全 39 個を列挙してある(数えたのは ECMA-376 4th edition の **transitional** 版。`legacyDrawingHF` と `picture` の間に `drawingHF` が入る。strict 版はこれを持たず 38 個)。
数式の計算結果の検証、シート追加、ガントの着色は引き続き `operations` を使う。
**コピーされた式のおおもとを書き換えるとき(#1351 段 2)**
Excel で式を下へコピーした列は、ファイルの中では 1 つのセルだけが式の文面と範囲を持ち(`<f t="shared" ref="C2:C4" si="0">`)、残りはその番号を指すだけになる(`<f t="shared" si="0"/>`)。この**本体**のセルへ値や数式を書くと、そのままでは同じかたまりの残りのセルから式も計算結果も消える。
そこで、本体へ書く前に**追随するセルの式を実体化する**。本体の式を各セルの位置に合わせて訳し(相対参照はずれ、`$` の付いた側は動かない)、`<f>` に全文で書き込んでから本体を書き換える。計算済みの値(`<v>`)はそのまま残す。`edits` と `operations` のどちらから来ても同じ扱いで、扱いは `src/office/shared-formula.ts` の 1 か所に寄せてある。
訳せない式は、段 1 と同じく断ってファイルに手を付けない。
| 断る場面 | 例 |
|---|---|
| 列や行をまるごと指す参照が混じっている | `SUM(A:A)` / `SUM(1:1)` |
| 訳した結果が表の外(行 1 や列 A より手前)へ出る | 本体が範囲の左上に無い書類 |
- `edits`: `Cannot rewrite the copied formula from C2 (the block C2:C4) for C3 ...` を返す
- `operations`: `violations[].code` が `SHARED_FORMULA_MASTER`、`sheet` と `cell` に場所が入る
これとは別に、**壊れた書類**として断る場面が 3 つある。どれも Excel 本体は書かない形で、外から来た書類への歯止め。
| 断る場面 | なぜ決められないか |
|---|---|
| 本体が範囲と番号を持ちながら式の文面を持たず(`<f t="shared" ref="C2:C4" si="0"/>`)、その番号を指すセルが残っている | 配る中身が無い。通すと本体の `ref` と `si` の定義ごと消え、残りのセルの `si` が指す先を失う(規格 ISO/IEC 29500 18.3.1.40 は本体が文面を持つと定めている) |
| 番号を名乗るセルが、その番号のどの本体の `ref` 範囲にも入っていない | どちらのかたまりへ配るのが正しいか決まらない |
| 同じ番号の本体が 2 つあり、`ref` 範囲が重なっている | 重なりの中のセルがどちらの持ち物か決まらない |
**追随するセルの持ち主は、番号(`si`)だけでは決まらない。**番号と `ref` 範囲の両方で決める。番号だけで決めると、番号がぶつかった 2 つのかたまりが混線する——`C2:C3` と `E2:E3` がどちらも `si="0"` の書類で `C2` だけを書き換えると、`E3` に C のかたまりの式が入り、触っていない E のかたまりが黙って壊れる。判定は `src/office/xlsx-sheet-edit.ts` の `assignSharedFormulaFollowers` の 1 か所にある。
**番号がぶつかった書類は `edits` なら通るが、`operations` は 1 マスも書けない。**扱いが分かれるのは、`operations` が exceljs を通すため。exceljs は読み込みの時点で追随するセルを 1 つの本体へ結びつけてしまい、**番号がぶつかっていた事実そのものがモデルから消える**。しかも結び先の選び方が読み手ごとに違う(exceljs は**あとに**見た本体が勝ち、Excel / LibreOffice は**先に**見た本体が勝つ)。`operations` はブックを丸ごと読み込んで丸ごと書き戻すので、**書き換えたマスと壊れるマスは別**になる。コピー式と関係のない `A1` に値を 1 つ入れるだけで、触っていないかたまりの式が別のかたまりの式に置き換わる(実測: `C3` が 33 から 90、`E3` が 40 から 630。別のシートを編集しても同じ)。
そこで `operations` は、**ファイルを開いた時点で**生のシート XML をシートごとに 1 回ずつ見て、下の 5 つのどれかがあれば `SHARED_FORMULA_MASTER` で断る。書き込み先を組み立てる前に断るので、ファイルには手を付けない(#1378 / #1376)。
| 断る並び | どうなるはずだったか | `edits` は |
|---|---|---|
| 同じ番号の本体が 2 つ以上ある | 触っていないかたまりの式が化ける | 通る(`ref` 範囲で持ち主を決める) |
| `ref` 範囲からも持ち主が決まらない(上の表の 2 つ) | 別のかたまりの式が流れ込む | その本体を書く指示だけ断る |
| 番号を名乗るセルが残っているのに、その番号の本体がシートに 1 つも無い | 中身の無い式(`<f/>`)が書かれ、Excel の表示が `Err:520` になる | 通る(触らないセルは書き換えない) |
| 本体が、その番号を名乗るセルより**後ろ**に並んでいる | 同上。exceljs は読みながら採番表を作るので、まだ登録されていない番号を引いたセルの持ち主が `undefined` になる | 通る(`ref` 範囲で持ち主を決めるので並び順に依らない) |
| 本体が式の文面を持たず、その番号を指すセルが残っている | exceljs が式を平行移動しようとして例外を出す(保存できない) | その本体を書く指示だけ断る |
**並び順まで見る。**シートにその番号の本体が「居るか」だけを見ると、後ろに並んだ本体の書類が素通りして値が消える(#1378 レビュー P1-1)。判定は `<c>` を文書順に 1 回歩き、本体を見た時点でその番号を登録済みにして、**その時点で登録済みの番号だけ**を親ありと見なす。
判定は `src/office/xlsx-sheet-edit.ts` の `findSharedFormulaSheetDoubts` の 1 か所。番号のぶつかりと持ち主の判定はさらに `findSharedFormulaOwnershipDoubts` / `assignSharedFormulaFollowers` に相乗りしていて、`edits` 側と同じ判定を使う。断り文句は「`edits` なら通る」のか「本体に触らなければ通る」のかで書き分けている。
**共有式の番号はシートごとに閉じている。**普通のブックは、シートが何枚あっても素で「どのシートも `si="0"`」になる。だから断る判定は**シート 1 枚ぶんの XML**を見る。複数シートのブックがどれも `si="0"` を使っていても通る。
| 経路 | 番号がぶつかった書類 |
|---|---|
| `edits` | 通る。`ref` 範囲で持ち主を決めて実体化する(範囲からも決まらない書き方なら、こちらも断る) |
| `operations` | どのマスを書く指示でも `SHARED_FORMULA_MASTER` で断る。ファイルには手を付けない |
配列数式(`t="array"`)のもとになっているセルは、この扱いの対象外(#1363)。
#### 検証つき詳細編集(operations)
色・レイアウト・シート追加・ガント、または数式の計算結果検証が必要な場合に使う(セルの結合と列の幅は上の `merges` / `columns` で足りる)。既存ファイルを直接上書きせず、まず別名へ出すのを推奨。
```js
EditExcel({
path: "input/plan.xlsx",
output_path: "output/plan-checked.xlsx",
operations: [
{ op: "set_values", sheet: "計画", range: "A2:B3",
values: [["設計", 0.5], ["実装", 0.25]] },
{ op: "set_formula", sheet: "計画", cell: "C2",
formula: "B2*100", expect: { equals: 50, tolerance: 0.001 } },
{ op: "merge", sheet: "計画", range: "A1:C1" },
{ op: "style_range", sheet: "計画", range: "A1:C1",
style: { fill: "#1F4E78", font_color: "#FFFFFF", bold: true } },
{ op: "set_column_width", sheet: "計画", columns: "A:C", width: 18 },
{ op: "freeze_panes", sheet: "計画", rows: 1, columns: 0 }
]
})
```
主な `op`:
| op | 必須項目 | 用途 |
|---|---|---|
| `add_sheet` | `name` | シート追加 |
| `set_values` | `sheet, range, values` | 二次元配列を範囲へ書く |
| `set_formula` | `sheet, cell, formula` | 1セルへ数式。任意の `expect` で結果検証 |
| `fill_formula` | `sheet, range, formula` | 相対参照をずらしながら数式を範囲へコピー |
| `merge` / `unmerge` | `sheet, range` | セル結合・解除(保持したまま直したいときは上の `merges`) |
| `style_range` | `sheet, range, style` | 背景・文字・罫線・太字・表示形式・配置 |
| `set_column_width` | `sheet, columns, width` | 列幅(保持したまま直したいときは上の `columns`。上限 255 は共通) |
| `set_row_height` | `sheet, rows, height` | 行高(`height` は 0 より大きく 409.5 以下。上の `rows` と同じ上限) |
| `freeze_panes` | `sheet, rows, columns` | ウィンドウ枠固定 |
色は `#RRGGBB` / `#AARRGGBB`。数式先頭の `=` は省略可能。`expect` は単一値、または `{ equals, tolerance }`。不一致なら保存しない。
ガントは通常の `operations` で日付・開始日・終了日・進捗を書いたうえで `gantt` を指定する。
```js
gantt: {
sheet: "計画",
timeline_header: "D1:AH1",
task_rows: "2:20",
start_column: "A",
end_column: "B",
progress_column: "C",
progress_scale: "fraction",
holidays: ["2026-08-11"],
roles: {
weekend: { fill: "#EEEEEE" },
holiday: { fill: "#FFD9D9" },
planned: { fill: "#9DC3E6" },
completed: { fill: "#70AD47" }
}
}
```
保存前に、入力OOXMLの危険・非対応機能検査、操作競合、セル数上限、数式の静的検査、LibreOffice再計算、`#VALUE!` 等の数式エラー、期待値、数式と結合構造の保持を検証する。どれかが失敗すると候補を削除し、JSONの `phase` / `violations[].code` / `sheet` / `cell` に理由を返す。`dry_run: true` なら検証のみで保存しない。
`violations[].code` は原因の所在で読み分ける。`SHARED_FORMULA_MASTER` は入力ファイル(コピーされた式のかたまりが扱えない形になっている。おおもとへ書こうとして残りのセルの式を訳せなかった場合と、**書き込み先とは関係なくファイルを開いた時点で断った**場合の両方がある。上記)、`INPUT_INVALID` は渡した引数、`PACKAGE_INVALID` / `UNSUPPORTED_WORKBOOK_FEATURE` は入力ファイル、`INTERNAL_ERROR` は**こちら側の実行時例外**(再計算プロセスが起動できない等)で、ファイルが壊れていることを意味しない。`INTERNAL_ERROR` を「ファイルを直してから再実行」の根拠に使わないこと。ただし、以前はファイル側が原因でも `INTERNAL_ERROR` になる形が 1 つあった——式の文面を持たない共有式の本体(上記)で、exceljs が式を平行移動しようとして例外を出していた。いまはファイルを開いた時点で `SHARED_FORMULA_MASTER` として断るので、この形は `INTERNAL_ERROR` にならない(#1376)。
新規保存では `output_path` が既に存在すると拒否する。同じ入力を上書きする場合だけ `overwrite: true` と読取時点の `expected_source_sha256` が必要。編集中に元ファイルが変わった場合も確定しない。
**詳細編集の制約**: `.xlsx` のみ。マクロ、外部リンク、ピボット、スライサー、グラフ、OLE、Excelテーブル、署名、動的配列などを含むブックはfail closedで拒否する。複雑なブックの値だけを変える場合は `edits` を使う。`readonly/` 配下は常に書き換え不可。
### EditDocx
既存の .docx の**指定した段落だけ**を書き換える。
```
EditDocx({ path: "output/report.docx", list_paragraphs: true }) // まず番号を知る
EditDocx({
path: "output/report.docx",
edits: [{ paragraph: 3, text: "書き直した本文です。" }]
})
```
| 引数 | 意味 |
|---|---|
| `path` | 対象の .docx(workspace 相対) |
| `list_paragraphs` | `true` なら書き換えず、番号つきの段落一覧だけ返す |
| `edits[].paragraph` | 0 始まりの段落番号(空段落も 1 件として数える) |
| `edits[].text` | 差し替える文字列。空文字なら段落を残して中身だけ消す |
| `output_path` | 別名保存先。省略時は上書き |
**何が保たれるか**: 本文の項目(`word/document.xml`)だけを差し替える。スタイル定義・表・画像・ヘッダー / フッター・変更履歴・埋め込みオブジェクトは元のバイト列のまま残る。段落の書式(見出しなど)と、その段落の先頭の文字書式も引き継ぐ。
**制約**: 段落まるごとの差し替えなので、1 段落の中で途中から書式が変わっていた場合、その変化は先頭の書式に統一される。引き継ぐのは**最初の run の書式**で、run が 1 つも無い段落のときだけ段落記号の書式を引き継ぐ(そのとき、段落記号にしか置けない挿入・削除・移動・書式変更の印は落とす)。**書き換えた段落の中にあった変更履歴(挿入・削除・移動の印)・コメントの錨・しおり・ハイパーリンク・コンテンツコントロールは、出力から消える**(コメントや脚注の本体は別ファイルに残るが、本文からの参照は失われる)。校閲中の文書を書き換えるときは、消してよいかを先に確認すること。段落の追加・削除、表の行・列・セルの追加や削除・結合はできない。**表の中の段落は段落番号に入り、書き換えられる**(`list_paragraphs` が返した番号をそのまま指定する)。`.doc`(旧形式)は非対応。
### EditPptx
既存の .pptx の**指定した段落だけ**を書き換える。
```
EditPptx({ path: "output/deck.pptx", list_paragraphs: true }) // まずスライドと段落の番号を知る
EditPptx({
path: "output/deck.pptx",
edits: [{ slide: 2, paragraph: 0, text: "第2四半期の実績" }]
})
```
| 引数 | 意味 |
|---|---|
| `path` | 対象の .pptx(workspace 相対) |
| `list_paragraphs` | `true` なら書き換えず、スライドごとの段落一覧だけ返す |
| `edits[].slide` | 1 始まりのスライド番号(**見た目の並び順**) |
| `edits[].paragraph` | 0 始まりの段落番号(空段落も 1 件として数える) |
| `edits[].text` | 差し替える文字列。空文字なら段落を残して中身だけ消す |
| `output_path` | 別名保存先。省略時は上書き |
**スライド番号は並び順で数える**。項目名の番号(`slide3.xml`)とは一致しないことがある——スライドを並べ替えてもファイル名は変わらず、順序だけが変わるため。`list_paragraphs` が返す番号をそのまま使うこと。
段落番号は図形をまたいだ通し番号。タイトルも本文も表の中の文字も、同じ 1 本の並びに入る。
**何が保たれるか**: 指定したスライドの項目(`ppt/slides/slideN.xml`)だけを差し替える。配置・図形・画像・アニメーション・スライドマスター・ノート・埋め込みオブジェクトは元のバイト列のまま残る。段落の書式と、その段落の先頭の文字書式も引き継ぐ。
**制約**: 段落まるごとの差し替えなので、1 段落の中で途中から書式が変わっていた場合、その変化は先頭の書式に統一される。段落・スライドの追加や削除、図形の移動はできない(新規作成は AddSlide / BuildPptx)。`.ppt`(旧形式)は非対応。
## 注意
- すべて workspace 内のローカルファイル(`input/` または `output/`)が対象
- URL 指定不可 → DownloadFile で先にローカル保存
- 全ツール read-only(書き込まない)
- EditExcel / EditDocx / EditPptx 以外は read-only(書き込まない)
## ファイルサイズ上限
@@ -212,12 +482,66 @@ Read 系ツールは悪意あるファイル / リソース枯渇対策として
| ツール | デフォルト | config キー |
|---|---|---|
| ReadExcel | 10 MB | `tools.office_excel_max_size_mb` |
| ReadDocx | 10 MB | `tools.office_docx_max_size_mb` |
| ReadExcel / EditExcel | 10 MB | `tools.office_excel_max_size_mb` |
| ReadDocx / EditDocx | 10 MB | `tools.office_docx_max_size_mb` |
| ReadPdf | 10 MB | `tools.office_pdf_max_size_mb` |
| ReadPPTX | 50 MB | `tools.office_pptx_max_size_mb` |
| ReadPPTX 展開後 | 200 MB | `tools.office_pptx_max_uncompressed_mb` |
| ReadPPTX / EditPptx | 50 MB | `tools.office_pptx_max_size_mb` |
| SplitExcelSheets | 10 MB | `tools.office_excel_max_size_mb` |
| SplitDocxSections | 10 MB | `tools.office_docx_max_size_mb` |
| 画面の Excel セル保存 | 10 MB | `tools.office_excel_max_size_mb` |
| 画面の Excel プレビュー | 50 MB | `tools.office_preview_max_size_mb` |
| 展開後(下記 10 経路すべて) | 200 MB | `tools.office_pptx_max_uncompressed_mb` |
PPTX の「展開後」は ZIP bomb 検知用で、ZIP 内の全エントリの非圧縮合計サイズに対する閾値。超過時は `ZIP bomb detected: ...` エラーとなる。
「展開後」は ZIP bomb 検知用で、ZIP 内の全エントリの非圧縮合計サイズに対する閾値。超過時は、エージェント向けの経路では `ZIP bomb detected: ...` エラーとなる(画面のプレビューだけは 413 と数値を返し、文言は UI が翻訳して組む)。キー名は ReadPPTX 由来だが、**OOXML を開く経路すべてで共有する**。判定は ZIP ヘッダが申告するサイズの合計で行う。`EditExcel` の `operations` モードだけは共通の関所ではなく preflight を通り、実際に展開して測るが、上限は同じ config キーを読む。
| 展開後の上限が掛かる経路 | 備考 |
|---|---|
| `Read` → ReadExcel / ReadDocx / ReadPPTX | 読む口。ReadExcel / ReadDocx は #1084 段階 2 で追加 |
| SplitExcelSheets / SplitDocxSections | #1084 段階 2 で追加(それまでファイルサイズ上限すら無かった) |
| EditExcel / EditDocx / EditPptx | 書き換える口 |
| 画面の Excel プレビュー(`GET .../files/office-preview`) | #1084 段階 2 で追加(それまで上限が 1 つも無かった) |
| 画面の Excel セル保存(`PUT .../files/xlsx-cells`) | |
以前この関所が無かった 5 経路(読む口 2 つ・分割ツール 2 つ・画面のプレビュー)は、**嘘の申告をしなくても増幅した**——正当なファイルに大きなエントリを正直に足すだけで通っていた(issue #1084 段階 2 で塞いだ)。
**画面のプレビューだけ、ファイルサイズのつまみが別**(`tools.office_preview_max_size_mb`、既定 50MB)。エージェント側の 10MB は「読んで LLM に渡す量」を想定した値で、人が眺めるだけの経路には小さい——この本の前まで上限が無く映せていた実務ブック(30 万行の受注明細で約 20MB)が、共有すると今日から映らなくなる(#1084 レビュー P2-1)。展開後サイズと項目数はエージェントと共有のまま。実メモリを縛っているのはそちらなので、片方だけ動かす理由が無い。
**既定値で残る量**: ファイル 10MB・展開後の申告 61MB・項目 10 件のブックが、全部の関所を通ったうえで常駐を約 588MB 増やす(嘘の申告は無し)。実質的に効いている歯止めはファイルサイズで、展開後 200MB の関所は実メモリを縛れていない(展開後サイズと実メモリの比が約 9.6 倍あるため)。ここを実測で縛るのは #1084 段階 3 の担当。
**上げるときの副作用**: `operations` モードは申告値ではなく実際に展開して測るため、このキーはその経路が**展開に使ってよいメモリの実質的な上限**でもある。値を 200MB から大きく上げると、1 回の編集が確保しうるメモリの上限も同じだけ上がる(以前は経路側の固定値 200MB で頭打ちだった)。同時実行数と積んでいるメモリを見て決めること。
**まだ関所が乗っていない経路**もある。次の 2 つは対象外か、別の守りに任せている。
| 関所の無い経路 | 守り |
|---|---|
| `Read` から呼ばれる PDF / メール(`.msg`)の解析 | ZIP ではないので対象外。ファイルサイズ上限で守る |
| 画面の PowerPoint / Word プレビュー変換 | 変換を別プロセス(LibreOffice)に出すので、本体プロセスは膨らまない |
展開後サイズとあわせて **ZIP エントリ数**も見る。中身が空のエントリを大量に並べたファイルは申告合計が 0 バイトになるため、サイズの閾値だけでは止まらない。超過時は `ZIP bomb detected: N entries exceeds limit of 10000` を返す(画面のプレビューは同じ判定で 413 と件数を返す)。
| 上限 | デフォルト | config キー |
|---|---|---|
| ZIP エントリ数(展開後サイズと同じ 10 経路 + `operations` モードの preflight) | 10,000 件 | `tools.office_max_entries` |
件数の判定は 2 段構えになっている(#1084 段階 1)。
1. **ZIP 末尾の中央ディレクトリが申告するエントリ数**で先に断る。エントリを 1 件も構築せずに済むので、断ると決まっているファイルにメモリを払わない。
2. 申告が上限内なら、**中央ディレクトリを自前で最後まで歩き**(`src/office/zip-central-directory.ts`)、実際に並んでいる件数と申告を突き合わせる。食い違えば `Refusing to open this package: its index lists N entries but the trailer declares M...` で断る。
2 が要るのは、**中央ディレクトリの読み方が層によって非対称**だから。adm-zip は申告件数で止まり(`zipFile.js` の `readEntries` は `new Array(mainHeader.diskEntries)` から始まる)、ExcelJS が使う JSZip はエントリの並びが署名の続く限り読む。申告を小さく偽ると、関所からは 5 項目に見え、読み込み層からは 60,005 項目すべてが見えた——511KB のファイル 1 本で常駐 1.1GB、増幅は約 2,300 倍(issue #1084 の実測)。歩く部品は JSZip と同じ歩き方(末尾の記録はファイル全体の最後の署名、目録は署名が続く限り)に揃えてある。**adm-zip に件数を無視して読む口は無い**ので自前で書いている。
**歩けない形は断る(fail closed)。** 断ると決めた形は次のとおりで、いずれも実在の作り手が書く形ではない。
| 断る形 | 理由 |
|---|---|
| 実際の件数と申告件数が食い違う | 穴の本体。隠したエントリを持つ包み |
| 末尾の記録(EOCD)が見つからない | ZIP ではない。または末尾が壊れている |
| 中央ディレクトリが申告位置に無い(前にデータが付いた形=自己解凍形式など) | adm-zip は開けずに例外、JSZip は位置をずらして読み切る。**層ごとに見えるものが変わる**ので数えられない |
| 中央ディレクトリが途中で切れている | 同上 |
| ZIP64 だと申告しているのに、その記録が無い/表現できない大きさ | 同上 |
ZIP64(件数欄が 0xFFFF に張り付いて別の記録に載る形)は**正しく読んで通す**。ExcelJS / adm-zip / JSZip / LibreOffice / Info-ZIP / Python zipfile が書いた包みと、70,000 件の本物の ZIP64 が通ることを実測済み(`src/office/zip-central-directory.test.ts`)。
**まだ止まらないもの**: エントリの**申告サイズ**を小さく偽る形は、この関所では止まらない(申告値の合計で判定しているため)。そちらは実際に展開して測る形へ上げる段階 3 で塞ぐ(issue #1084)。
マクロ付きファイル(`.xlsm` / `.docm` / `.pptm` / `.xlsb`)は警告付きで読み込まれる(実行はされない)。
+91 -13
View File
@@ -33,19 +33,56 @@
| パラメータ | 説明 |
|-----------|------|
| `file_path` | workspace 内の相対 / 絶対パス(必須) |
| `offset` | 読み始める行番号(0-indexed)。**テキスト読みのみ有効**(PDF/Office では無視される) |
| `limit` | 読む最大行数。**テキスト読みのみ有効**(PDF/Office では無視される) |
| `byte_offset` | 読み始めるバイト位置。改行の無い巨大ファイル向け。`offset` / `limit` と排他。テキスト読みのみ有効 |
| `byte_length` | 読むバイト数。`byte_offset` と併用。テキスト読みのみ有効 |
| `page_range` | **PDF のページ範囲**(例 `5-10` / `3`)。PDF を途中から / 一部だけ読むときは `offset` ではなくこれを使う |
| `offset` | 読み始める行番号(0-indexed)。**行数以上を指定するとエラー**。**テキストと `.msg` 本文のみ有効**(PDF/Office に渡すと**エラーになり本文は返らない**) |
| `limit` | 読む最大行数。**テキストと `.msg` 本文のみ有効**(PDF/Office に渡すと**エラーになり本文は返らない**) |
| `byte_offset` | 読み始めるバイト位置。改行の無い巨大ファイル向け。**ファイルサイズ以上を指定するとエラー**。`offset` / `limit` と排他。テキスト読みのみ有効(PDF/Office/`.msg` に渡すとエラー) |
| `byte_length` | 読むバイト数。`byte_offset` と併用。テキスト読みのみ有効(PDF/Office/`.msg` に渡すとエラー) |
| `page_range` | **PDF のページ範囲 / PowerPoint のスライド範囲**(例 `5-10` / `3`)。途中から / 一部だけ読むときは `offset` ではなくこれを使う。**実在しないページ・スライドを指すとエラー**。PDF と PowerPoint 専用(テキストファイルに渡すとエラー) |
大きすぎる出力は残コンテキスト予算に収まるよう自動で切り詰められ、続きの読み方が
注記される。
> **PDF/Office をずらして読むとき**: `offset` / `limit` / `byte_*` は**テキスト専用**で、
> PDF・Excel・Word・PowerPoint では黙って無視される(=常に先頭から返る)。PDF は
> `page_range`、Excel は `sheet` / `range` を使うこと。これらのフォーマットに
> `offset` / `limit` を渡すと、出力の先頭に正しいパラメータへの誘導注記が付く。
> **範囲指定は形式ごとに専用**。合わない組み合わせは **エラーになり、本文は一切返らない**。
>
> | 読むもの | 使うパラメータ |
> |---|---|
> | テキスト | `offset` / `limit`(行)、`byte_offset` / `byte_length`(バイト) |
> | PDF | `page_range` / `query` |
> | Excel | `sheet` / `range` |
> | Word | `max_paragraphs` |
> | PowerPoint | `page_range`(PDF と同じ名前) |
> | `.msg` | `offset` / `limit`(**抽出した本文の行**) |
>
> 以前は合わない指定でも注記を添えて本文を返していたが、注記が読み飛ばされて
> 先頭部分だけを「全部」と扱う事故が起きていたため、拒否に変えた(#1009)。
> テキストにドキュメント用の指定を渡す逆向きは注記すら出なかった(#1041)。
> どちらもエラー本文に、その形式で使うべきパラメータが書いてある。
### 範囲外の位置を指したとき(#1170)
`offset` がファイルの行数以上、または `byte_offset` がファイルサイズ以上のとき、
`Read` は **エラーを返す**。以前は行指定なら空文字列を、バイト指定なら「範囲外」という
文を、どちらも成功として返していたため、「読めたが中身が空だった=まだ続きがある」と
誤解する事故が起きていた。
エラー本文には **指定した位置** と **そのファイルの実際の行数 / バイト数** が入る。
続きが要る場合の手順はこの通り。
| 何が起きたか | 次にやること |
|---|---|
| `offset` が行数以上 | エラー本文の行数を上限として、`0` 以上その値未満で指定し直す |
| `byte_offset` がサイズ以上 | エラー本文のバイト数未満で指定し直す |
| そもそも全体を読みたい | `offset` / `byte_offset` を外して呼び直す(自動切り詰めが効く) |
境界の扱いも書いておく。
| 場合 | 結果 |
|---|---|
| `offset` = 行数(末尾ちょうど。読める行が 1 行も無い) | エラー |
| 空ファイル(0 行)に `offset: 0` | エラー(`0 行` と返る) |
| 空ファイルを `offset` 無しで読む | 成功(空文字列) |
| `offset` は範囲内で `limit` が余る | 成功(読めた分だけ返る) |
| 末尾が改行のファイル(`a\nb\n` は **2 行**)に `offset: 2` | エラー |
## フォーマット固有の詳細オプション(inline schema には無いが実行時に受理される)
@@ -57,10 +94,19 @@
|-----------|------|
| `sheet` | シート名(省略時は全シート) |
| `range` | セル範囲(例 `A1:D10`、省略時はシート全体) |
| `max_cells` | 最大セル数(デフォルト 1000) |
| `max_cells` | 最大セル数(デフォルト 1000)。**ブック全体の合計**で数える |
| `include_styles` | `true` で背景色 / フォント / 罫線 / 書式 / 結合を `### Styles` として追記(デフォルト false) |
| `max_style_ranges` | `include_styles` 時の style range 上限(デフォルト 250) |
出力の先頭には、ブックにあるシートの一覧(名前・行数・列数・読めたかどうか)が
`## Sheets` として必ず載る。`max_cells` はブック全体の合計なので、1 枚目で使い切ると
2 枚目以降は 1 セルも読まれない。その場合、一覧のそのシートは `NOT READ` になり、
途中で切れたシートには最後に読めたセルの番地が付く(#1165)。
> 一覧を先頭に置くのは、末尾が出力の文字数上限とトークン持ち分の両方で
> 切られるため。以前は落ちたシートが出力に一切現れず、受け取った側からは
> 1 枚しか無いブックに見えていた。
### Word(.docx)
| オプション | 説明 |
|-----------|------|
@@ -82,11 +128,42 @@
### PowerPoint(.pptx)
| オプション | 説明 |
|-----------|------|
| `slideRange` | スライド範囲(例 `1-5` / `3`、省略時は全スライド) |
| `page_range` | スライド範囲(例 `2-4` / `3`、省略時は全スライド)。inline schema にも公開済み |
スライドの範囲は PDF のページ範囲と**同じ `page_range`** で指定する。形式ごとに別の
名前を覚えなくてよい。
`page_range` を外せば全スライドが返る。外した場合を除いて**黙って全体を返すことはない**。
| 渡したもの | 結果 |
|---|---|
| 実在するスライドを含む範囲(`2-3`、`4`) | その範囲だけ返す |
| 実在しないスライドだけを指す範囲(5 枚のファイルに `9-10`) | **エラー**。本文に実際の枚数(`presentation has 5 slide(s)`)が入る |
| 書式が読めない範囲(`abc` / `2-` / 空文字列 / 数値) | **エラー**(`Invalid page_range: ...`)。以前は黙って**全スライド**が返っていた |
| `offset` / `limit` / `byte_offset` / `byte_length` | **エラー**。`page_range` を使う |
範囲外・書式不正のときは本文を一切返さない。先頭だけ返して成功にすると、受け取った
側が「これで全部」と扱う(#1009 と同じ壊れ方)。
### Outlook メール(.msg)
| オプション | 説明 |
|-----------|------|
| `offset` | **抽出した本文**の読み始め行(0-indexed)。本文の行数以上を指定するとエラー |
| `limit` | 本文から読む最大行数 |
絞る単位を本文の行にしたのは、件名・送信者・宛先・添付の一覧が出力の骨格で、量を
食っているのが本文だけだからである(自動切り詰めも本文にしか掛かっていない)。
行なのでテキスト読みと同じ `offset` / `limit` をそのまま使う。
| 渡したもの | 結果 |
|---|---|
| `offset` / `limit`(本文の行数の内側) | ヘッダーと添付一覧は全件、本文はその範囲だけ返す |
| 本文の行数以上の `offset` | **エラー**。本文に実際の行数が入る。**添付も保存しない** |
| `byte_offset` / `byte_length` | **エラー**。`.msg` は OLE2 の複合ファイルで、ファイル上のバイト位置は抽出した本文と対応しない |
- 件名・送信者・宛先・本文を抽出する。
- 添付ファイルは `input/` に保存し、保存先パスを出力に列挙する。
- 添付ファイルは `input/` に保存し、保存先パスを出力に列挙する。**範囲を絞っても
添付の一覧は全件残す**(保存先パスは呼び出し側が必要とするため)。
- 読み取り専用フェーズ(verify 等、edit 不可)では添付を保存せず、その旨を注記する。
- ファイル名衝突時は `name-1.ext` のように連番で退避し、既存ファイルは上書きしない。
@@ -98,8 +175,9 @@ Read({ file_path: "input/sales.xlsx", sheet: "Q1", range: "A1:D20" })
Read({ file_path: "input/spec.docx", mode: "text" })
Read({ file_path: "input/manual.pdf", page_range: "5-10" }) // PDF の 5-10 ページ目
Read({ file_path: "input/manual.pdf", query: "認証", context_lines: 3 })
Read({ file_path: "input/deck.pptx", slideRange: "2-4" })
Read({ file_path: "input/deck.pptx", page_range: "2-4" }) // 2-4 枚目のスライド
Read({ file_path: "input/mail.msg" }) // 添付は input/ へ
Read({ file_path: "input/mail.msg", offset: 100, limit: 50 }) // 本文の 100 行目から 50 行
Read({ file_path: "logs/huge.log", byte_offset: 0, byte_length: 4096 })
```
+9 -3
View File
@@ -1,6 +1,8 @@
# ReadAppDoc
MAESTRO のプロジェクト内ドキュメント(`docs/` / `pieces/`)を **symbolic name** で読み込むメタツール(常時利用可能)。主に Help アシスタントが、概念や操作手順をユーザーに答える前のリファレンス参照に使う。
MAESTRO のプロジェクト内ドキュメント(`docs/` / `pieces/` / アプリ内ヘルプ)を **symbolic name** で読み込むメタツール(常時利用可能)。主に Help アシスタントが、概念や操作手順をユーザーに答える前のリファレンス参照に使う。
「定期実行はできますか」「共有はどうやるのですか」のような**製品の機能・使い方**の質問には、`help/<name>` で利用者向けヘルプ(アプリ内の Help パネルと同じ Markdown)を引く。
ワークスペース外の固定パスを読むため、通常の `Read` ツールでは到達できない。
@@ -17,6 +19,7 @@ MAESTRO のプロジェクト内ドキュメント(`docs/` / `pieces/`)を *
| `docs/<path>` | `docs/<path>.md`(`.md` は省略可) | `docs/mcp`, `docs/architecture` |
| `piece/<name>` | `pieces/<name>.yaml` | `piece/chat`, `piece/research` |
| `tool/<name>` | `docs/tools/<name>.md`(`ReadToolDoc` と同等) | `tool/browseweb` |
| `help/<name>` | `ui/src/content/help/<name>.md`(`.md` は省略可) | `help/06-schedules`, `help/01-intro` |
利用可能な doc が分からないときは、先に `ListAppDocs()` で一覧を取得する。
@@ -24,18 +27,21 @@ MAESTRO のプロジェクト内ドキュメント(`docs/` / `pieces/`)を *
ListAppDocs() // まず一覧を見る
ReadAppDoc({ name: "docs/mcp" }) // 該当 doc を読む
ReadAppDoc({ name: "tool/browse-sessions" })
ReadAppDoc({ name: "help/06-schedules" }) // 製品の機能・使い方
```
## 読めないもの(allow-list / block)
セキュリティのため、開けるのは `docs/` `pieces/` `docs/tools/` 配下に限られ、以下は拒否される:
セキュリティのため、開けるのは `docs/` `pieces/` `docs/tools/` `ui/src/content/help/` 配下に限られ、以下は拒否される:
- 内部向けトップレベル doc: `CLAUDE.md` / `AGENTS.md` / `README.md` / `architecture`
- パストラバーサル(`..` を含む name)や allow-list 外の絶対パス
- `help/` はサブディレクトリを掘れない(`help/<file>` の 1 段のみ)
## 制約
- 1 doc あたり **32KB** で打ち切り(超過分は末尾に omitted バイト数を注記)。
- 1 doc あたりの打ち切りは、アプリ内ヘルプ(`help/<name>`)だけ **64KB**、それ以外(`docs/` / `piece/` / `tool/`)は **32KB**。超過分は末尾に omitted バイト数を注記する。
- ヘルプが打ち切られたときは、注記に「続きはアプリ内のヘルプ(画面上部の「ヘルプ」→ 該当ページ)を開いて確認してください」と利用者へ案内するよう書いてある。読めなかった後半を推測で埋めないこと。
- 存在しない name や不正な形式はエラーを返す。エラー時は `ListAppDocs()` で正しい名前を確認すること。
## 関連
+51 -5
View File
@@ -1,6 +1,6 @@
# ReadImage
画像ファイルを LLM に直接渡して内容を認識・説明させる。VLM(Vision Language Model)対応 worker でのみ使用可能。
画像対応ワーカーが画像を直接受け取るか、対応ワーカーに問い合わせて説明文を受け取る。
## 基本
@@ -9,10 +9,48 @@ ReadImage({ file_path: "input/screenshot.png" })
// → 画像内の文字・図表・物体について自然言語の説明が返る
```
BrowseWeb で開いたページに載っていた画像は `source/images/` に自動で溜まる。そのまま渡せば読める。ページを開き直してスクリーンショットを撮る必要はない。索引 `source/index.jsonl` の `kind: "image"` の行に、どのページに載っていた画像かが入っている(詳細は `ReadToolDoc({ name: "BrowseWeb" })`)。
```js
ReadImage({ file_path: "source/images/example.com-figure.png" })
```
## 動作要件
- 呼び出し時の worker が `vlm: true` で設定されている必要がある
- 設定がない場合、このツールはワークスペースで許可されていても利用不可(function definition から自動除外される)
このツールが使える条件は 2 通りある。
| 条件 | 何が返るか |
|---|---|
| 実行中の worker が `capabilities.image: true` | 画像そのものが会話に注入される。以後の発言で画像を見ながら考えられる |
| 実行中の worker は非対応だが `llm.offload.image: true` かつ `capabilities.image: true` の worker が居る | 対応 worker が書いた**説明文だけ**が返る。画像は会話に載らない |
どちらにも当てはまらないとき、このツールはワークスペースで許可されていても
function definition から自動除外される。
### 回した実行(委譲モード)の性質
- **1 回きりの問い合わせ**。画像はエージェントのコンテキストに載らない
- 「さっきの画像の右下は何色だった?」のような**追い質問はできない**。知りたいことは
`prompt` に全部書く。`prompt` を省くと汎用の説明が返るだけで、価値が大きく落ちる
- 観点を変えて聞きたくなったら、`prompt` を変えてもう一度呼ぶ
- 呼び出し先は `capabilities.image: true` の worker から空きのある台が自動で選ばれる。全台が埋まって
いれば空くまで待つ(タスクを中断すれば待ちからも抜ける)
- ブラウザ操作の分割スクリーンショット(既定で最大 10 枚)をこのモードで読むと、
1 枚ずつ独立した説明が 10 個返る。「この表の続きは次の画像」といった**横断した
判断はできない**。必要なら 1 枚ずつ観点を指定して呼ぶ
### prompt の書き方
```js
// 悪い例(委譲モードでは特に価値が低い)
ReadImage({ file_path: "input/chart.png" })
// 良い例
ReadImage({
file_path: "input/chart.png",
prompt: "縦軸の単位と、2024 年のバーの値を読み取って。凡例の項目名も全部挙げて。",
})
```
## 用途
@@ -29,6 +67,14 @@ ReadImage({ file_path: "input/screenshot.png" })
## 入力ファイル
- `input/` または `output/` 配下のローカル画像ファイル
- `input/` / `output/` / `source/images/` 配下のローカル画像ファイル
- URL 指定は不可(DownloadFile で先にローカル保存する)
- 対応形式: png, jpg, jpeg, gif, webp, bmp
対応形式は 2 通りある。
| | 形式 | 渡し方 |
|---|---|---|
| そのまま渡す | png, jpg, jpeg, gif, webp, bmp | ファイルの中身をそのままモデルへ |
| PNG に起こして渡す | svg, avif, tiff, tif, ico | こちら側で PNG に変換してからモデルへ。ファイル自体は元の形式のまま残る |
後者はページの魚拓が溜める形式(`source/images/`)に合わせたもの。**BMP を格納した古い形式の `.ico` だけは読めず**、理由付きのエラーが返る(いまの web のアイコンはほぼ PNG を格納しているので通常は当たらない)。
+75
View File
@@ -0,0 +1,75 @@
# RenderMermaid
Mermaid 図の構文を検査し、PNG 画像に描画する。HTML ファイルを渡した場合は、図そのものを描画済みの SVG に置き換える。
**図を書いたら、必ずこのツールで確認してから完了すること。** 構文の誤りは実行するまで分からず、そのまま納品すると読み手の画面には図ではなくエラーが出る。
## 使い方
### 書いた図をその場で確かめる
```
RenderMermaid({ code: "graph TD\n A[開始] --> B[終了]" })
```
`output/diagrams/diagram.png` に画像が出る。**`ReadImage` でその画像を開き、意図した図になっているか目で確認すること。** 構文が通ることと、図が意図どおりであることは別の話で、後者は画像を見ないと分からない。
### ファイルの中の図をまとめて確かめる
```
RenderMermaid({ path: "output/report.html" })
```
ファイル内の Mermaid ブロックを全部検査する。`.md` と `.html` に対応。
HTML の場合は、成功した図を**描画済みの SVG に差し替える**(`embed: false` で抑制できる)。差し替え後は次のようになる:
- **その図を出すのに**スクリプトも外部通信も要らなくなる
- ファイルプレビュー(スクリプトを止めている)でも図が出る
- 元の Mermaid コードは `data-mermaid-source` 属性に残るので、後から直して作り直せる
**書き換えるのは図のブロックだけで、ファイルの他の部分には一切触れない。** `<script>` も同じで、図が SVG になったあと使われなくなった Mermaid の読み込みが残っていても、それは消さない。ページの中身はそれを書いた側のもので、消してよいかどうかをこのツールは判断できない。消したいなら自分で `Edit` する。
Markdown は差し替えない。プレビューが Mermaid をそのまま描画するので、画像にすると拡大や文字選択ができなくなり劣化する。
## 出力の読み方
```
mermaid 11.15.0 — 2 diagram(s)
OK #1: output/diagrams/output_report-1.png — open it with ReadImage to check the diagram is what you meant.
FAILED #2: Parse error on line 3:
line 3: A[設定を読む (config.yaml)] --> B[起動]
```
画像の名前は元ファイルのパスから作る(`output/report.html` なら `output_report`)。同じ名前のファイルが別のフォルダにあっても、後から描いた図が前の図を上書きしない。
PNG は図の見た目の大きさの **2 倍の画素数**で書き出す(例: 見た目 126×195 の図なら 252×390 ピクセル)。拡大しても印刷しても文字がつぶれない。資料に貼るときは表示幅を実寸の半分に指定すると、意図した大きさで鮮明に出る。
ただし **120 万画素を超える図は、縦横の比を保ったままそこまで縮めて書き出す**(縦に十数段以上つながる図が目安。ラベルが長いほど早く届く)。画像は縮小されずにそのまま読み込まれるため、上限を置かないと 1 枚が大きすぎて `ReadImage` で開けなくなり、同時に開こうとした他の画像まで巻き添えで落ちる。長い図は縦に切って複数枚にすると、1 枚あたりが小さくなって鮮明さも保てる。
失敗した図は、エラー本文・行番号・その行の中身が返る。**その行を直して呼び直す**。
## よくある失敗
ラベルの中に括弧・角括弧・波括弧をそのまま書くと落ちる。**引用符で囲めば通る。**
```
A[設定を読む (config.yaml)] ← 落ちる
A["設定を読む (config.yaml)"] ← 通る
```
これが最も多い原因。他に、日本語・長いラベル・多層のフローチャート・シーケンス図・クラス図・ガントは問題なく描画される(実測)。
## HTML を書くときの注意
生成する HTML に CDN の `<script src="...mermaid...">` を書かないこと。読み込むバージョンによって通る構文が変わるうえ、閲覧側に通信できない環境だと図がまったく出ない。
**最初から書かず、このツールに差し替えさせる。** 検査したバージョンと画面に出るものが同じになり、図のための通信は要らなくなる。書いてしまった読み込みはツールが消さないので、要らないと判断したら自分で消す(残っていても図の表示には影響しない)。
## 制限
- 1 回に扱える図は 30 個まで。1 図あたり 20,000 文字まで
- 1 回の呼び出し全体で 60 秒まで。1 つの図が時間切れになった時点で残りは打ち切る(描画が止まったページは待っても戻らない)
- 書き込みが許されていないタスクでは使えない(画像を保存できないため)
- `readonly/` 配下のファイルは書き換えない。検査だけしたい場合は `embed: false` を付けて呼ぶ
- 描画にはブラウザを使うため、1 回あたり数秒かかる。図ごとに呼び分けず、ファイル単位でまとめて呼ぶ方が速い
+55
View File
@@ -0,0 +1,55 @@
# RequestCaptchaHelp
検索が「人かどうかの確認(CAPTCHA)」で**完全に塞がれた**ときに、人が確認を済ませるまで作業を止めて待つためのツール(issue #1199 段階 2)。
## いつ使うか
`WebSearch` がこの形で失敗したときだけ:
```
WebSearch error: ブラウザ検索がすべて CAPTCHA で止められ、SearXNG も結果を返しませんでした。
検索そのものが実行できていません。
```
そのうえで、**検索なしではその依頼を達成できない**ときに呼ぶ。
呼ぶ前に確かめること:
- URL が分かっているなら `WebFetch` で直接開けば済まないか
- すでに手元にある資料や `Read` で足りないか
- 検索がそもそも本筋でない依頼ではないか(雑談・要約・書き換えなど)
止まっているあいだ、利用者は確認画面を開いて操作する必要がある。**避けられるなら避けたい手間**なので、他に道が無いときだけ呼ぶこと。
## 引数
| 引数 | 必須 | 説明 |
|------|------|------|
| `reason` | いいえ | 検索が通らないと進められない理由。カードを見る人が読む |
## 呼んだあと何が起きるか
1. その movement が畳まれ、ジョブが「人の確認待ち」で**停車**する(ワーカーは解放される)
2. 確認カードが出るのは**元のタスクのチャット**。サブタスクの中で呼んだ場合も、親をたどった先の元のタスクへ出る(#1225)
3. 誰かが確認を済ませて「解決した」を押すと、**その確認画面で止まっているジョブがまとめて再開**する
4. 「スキップ」を押した場合は、押したタスクのジョブと**その配下で確認待ちのサブタスク**だけが、検索が使えないまま再開する
5. 再開すると同じステップの頭からやり直しになるので、もう一度 `WebSearch` を呼ぶこと
## 止まらないことがある
次のときは停車せず、エラーとして「続けてください」が返る。**呼べば必ず止まるツールではない**。
| 返る内容 | 意味 |
|---|---|
| この実行では人の確認を待てません | 親をたどっても元のタスクに行き着かない実行(チャットに紐づかない実行)。確認カードを出す先が無いので、止めても押す相手が居ない。検索抜きで組み立てるか `complete` で中断する |
| まだ検索が塞がっていません | この実行の `WebSearch` が上のエラーで失敗していない。SearXNG 経由で結果が出ているなら塞がっていない |
| 未解決の求めは残っていません | 確認画面に立っていた旗がもう下りている(誰かが解いた・画面が無い)。もう一度 `WebSearch` を試す |
待てるかどうかの判断は「サブタスクかどうか」では決まらない(#1225)。サブタスクでも、親をたどって元のタスクに行き着くなら待てる。行き着かない実行だけが上の 1 行目に当たる。
確認画面はワークスペース単位で共有される。同じワークスペースの誰かが一度解けば、そのあとの検索は通る。
## 関連
- 検索そのものは [WebSearch](websearch.md)
- 停車と再開の仕組み(sentinel)は [movement 制御フローツール](../movement-control-flow-tools.md)
+18 -2
View File
@@ -27,7 +27,8 @@
| 名前 | 必須 | 説明 |
|------|------|------|
| `query` | ○(`around_ref` と排他) | 検索キーワード |
| `query` | ○(`around_ref` と排他) | 検索キーワード。1 語または少数の語を推奨 |
| `match_mode` | | 複数語の一致方法。`any`(既定)はいずれかの語、`all` はすべての語を含む結果に限定 |
| `around_ref` | ○(`query` と排他) | `"comment:<id>"` 形式。指定した comment の前後を読む |
| `context` | | 各ヒットに付す前後コメント件数(grep -C 相当)。既定 0、最大 5 |
| `limit` | | 最大ヒット件数。既定 10、最大 30(`query` モードのみ) |
@@ -41,7 +42,9 @@
日本語も含めた部分一致検索を trigram tokenize の FTS5 インデックスで行う。**3 文字未満の語は trigram で MATCH できないため、そのような語が含まれるクエリは自動的に LIKE(部分一致)にフォールバックする**(挙動は同じだが遅い)。
**複数語はスペース区切りの暗黙 AND(Google 的)**。`ログイン 認証` と入力すると「ログイン」と「認証」を**両方含む**コメントにヒットする(語順・位置は問わない)。フレーズ一致ではないので、離れて出現していても両語があればヒットする。各語は内部で個別に引用してから AND で結合するため、`AND`/`OR`/`NEAR`/`*`/`"` などの記号が語に混ざっても FTS5 演算子としては解釈されない(=ユーザー側から任意の演算子検索や `OR` 検索を差し込むことはできない)。広く探したいときは 1 語で、絞りたいときは語を足す。
1 語または少数のキーワードで検索する。複数語の既定は `match_mode: "any"` で、いずれかの語を含むコメントを対象に、FTS5 の関連度順で返す。`ログイン 認証` なら「ログイン」だけ、または「認証」だけを含むコメントも候補になり、両方を強く含むコメントほど上位になりやすい。すべての語を含むコメントだけに絞る場合は `match_mode: "all"` を指定する(語順・位置は問わない)。各語は内部で個別に引用してから OR または AND で結合するため、`AND`/`OR`/`NEAR`/`*`/`"` などが語に混ざっても FTS5 演算子としては解釈されない。
短い語を含むため LIKE にフォールバックした場合も、`any` は OR、`all` は AND として一致する。LIKE 経路では関連度を計算できないため、結果は新しいコメント順になる。
SQLite のビルドが FTS5 に対応していない環境では、検索機能自体が使えない旨のメッセージを返す(エラーにはしない)。
@@ -85,3 +88,16 @@ SQLite のビルドが FTS5 に対応していない環境では、検索機能
## 関連ツール
- `SearchTaskConversation` / `ReadTaskConversation` — 現在タスク自身の会話(コメント+transcript)を検索・閲覧する。他タスクへは到達できない。こちらは同じスペースの他タスクを横断検索する対
## ListWorkspaceTasks(一覧の相棒ツール)
内容の検索ではなく「同じワークスペースにどんなタスクがあるか」を俯瞰したいときは `ListWorkspaceTasks` を使う。
- `ListWorkspaceTasks({})` — 他タスクを新しい順に一覧(ID・状態・タイトル・piece・最終更新)。既定 20 件、`limit` で最大 50 件
- `ListWorkspaceTasks({ state: "open" })` — 進行中のみ / `"closed"` — 完了のみ
- スコープは SearchWorkspaceTasks と同一(同じスペースのみ・現在のタスクは除外)
- 一覧で目星をつけたら `SearchWorkspaceTasks({ query: "...", task_id: <id> })` で中身を検索する使い方が典型
## 0 件時の複合句自動分割(2026-07-24)
スペースを含まない日本語の複合句(例: `満月の周期の計算`)は 1 語=フレーズ完全一致として扱われ、構成語が索引にあっても 0 件になることがある。`match_mode: "any"`(既定)で 0 件だった場合、助詞・句読点で分割した語群(例: `満月 周期 計算`)による再検索が自動で 1 回走り、結果の先頭に分割再検索した旨の注記が付く。最初からスペース区切りの単語で query を書けばこのフォールバックは不要。
+10 -8
View File
@@ -4,7 +4,7 @@
> **デフォルト無効(opt-in)**。SpawnSubTask は Bash などと同じセンシティブ扱いのツールで、初期状態ではエージェントに提示されない。使うにはワークスペースの **設定 → ツール** タブで明示的に有効化する。有効化していない場合、SpawnSubTask は呼べず、分解は [`Delegate`](./delegate.md)(直列・インプロセス)で行う。
>
> タスク分解の既定手段は Delegate(直列)。research / general / brainstorming などのピースは Delegate でサブ調査を 1 件ずつこなす。SpawnSubTask は「壁時計時間の短縮のために独立テーマを本当に並列で走らせたい」ワークスペースだけが有効化する。
> タスク分解の既定手段は Delegate(直列)。research / general などのピースは Delegate でサブ調査を 1 件ずつこなす。SpawnSubTask は「壁時計時間の短縮のために独立テーマを本当に並列で走らせたい」ワークスペースだけが有効化する。
## 基本
@@ -16,7 +16,7 @@ SpawnSubTask({
})
```
呼び出すと `subtasks/{index}/` にサブタスクのワークスペースが作られ、結果はそこに集約される。
呼び出すと `subtasks/{連番}-{ジョブID先頭8桁}/`(例: `subtasks/1-9f0c1a2b/`)にサブタスクのワークスペースが作られ、結果はそこに集約される。ワークスペースはスペース内の全タスクで共有されるため、連番だけでは別タスクのサブタスクと同じディレクトリになってしまう。ジョブ ID を含めることで踏み合いを防いでいる。
## いつ使うか
@@ -63,18 +63,19 @@ SpawnSubTask({ title: "A の調査", instruction: "..." })
SpawnSubTask({ title: "B の調査", instruction: "..." })
SpawnSubTask({ title: "C の調査", instruction: "..." })
WaitSubTask() // ここで全部の完了を待つ
// 再開後、subtasks/*/result.md を Read して集約 → complete
// 再開後、自分が起動したサブタスクの result.md を Read して集約 → complete
```
- `WaitSubTask` は **同期的にブロックしない**。呼ぶとジョブはいったん停止(park)して worker を解放し、全サブタスクが終わると **同じ movement のまま再開** する
- 再開時、各サブタスクの結果は `subtasks/{index}/result.md` に書き出されている。Read で読んで集約レポートを作る
- 起動した分の成果物は `subtasks/{index}/output/` 以下にも残る
- 再開時、各サブタスクの結果は そのサブタスクのワークスペース直下の `result.md` に書き出されている。再開時のシステム通知が自分のサブタスクのパスを列挙するので、そこに挙がったものだけを Read して集約レポートを作る
- 起動した分の成果物は 同じワークスペースの `output/` 以下にも残る
- `subtasks/` 直下には**別タスクのサブタスクも並ぶ**。通知に挙がっていないディレクトリは自分のものではないので読まない
### ワークフロー
1. `SpawnSubTask` を必要な数だけ呼ぶ(並列に起動される)
2. `WaitSubTask()` を 1 回呼ぶ
3. 再開後、`subtasks/*/result.md` を読んで集約する
3. 再開後、通知に挙がった result.md を読んで集約する
4. `complete` で終了する
### auto-park(取りこぼし防止)
@@ -112,8 +113,9 @@ subtasks:
## 結果の参照
サブタスク完了後、親タスクは:
- `subtasks/{index}/result.md` にサブタスクの要約結果がある(Read で読む)
- `subtasks/{index}/output/` 以下にサブタスクの成果物がある
- 各サブタスクのワークスペース(`subtasks/{連番}-{ジョブID先頭8桁}/`)直下の `result.md` に要約結果がある(Read で読む)
- 同じワークスペースの `output/` 以下に成果物がある
- どのディレクトリが自分のものかは、再開時のシステム通知が列挙する
## 制限
+80
View File
@@ -0,0 +1,80 @@
# Speak
文章を読み上げて音声ファイルにする。外部の読み上げサーバー(OpenAI 互換の `/audio/speech`)に文字を送り、返ってきた音声を workspace の `output/` に保存する。
## 基本
```js
Speak({
text: "おはようございます。今日の予定は 3 件です。",
file_name: "morning-brief", // 省略時 speak-{日時}.{形式}
voice: "jf_alpha" // 省略時 config の speak_voice
})
```
返り値は保存したパス。そのまま利用者に示せる。
```
音声を保存しました -> output/morning-brief.mp3 (mp3, 48213 bytes, 24 文字, voice: jf_alpha)
```
## サーバー設定(必須)
設定 → ツール → Speech の「読み上げ」の欄で設定する。**文字起こし(TranscribeAudio)とは別の接続先**で、片方だけ設定しても構わない。
| 欄 | 設定キー | 既定 |
|---|---|---|
| 読み上げの接続先 | `speak_server_url` | なし(未設定なら使えない) |
| 読み上げモデル | `speak_model` | なし(送らない) |
| 声 | `speak_voice` | なし(送らない) |
| 出力の形式 | `speak_format` | `mp3` |
| 読み上げのタイムアウト | `speak_timeout` | 120 秒 |
| 文字数の上限 | `speak_max_chars` | 4096 文字 |
接続先が未設定のときは、送信せずに断る。
## ツールの入り切り
読み上げは分類 `speak` として、ワークスペースごとに入り切りを決める(設定 → ツール)。**既定は切**。接続先を設定していない状態では動かないため。
## 声の名前について
声の名前は**自由入力**で、こちらは選択肢を持たない。OpenAI 互換の仕様が声の語彙を定めておらず、サーバーの実装ごとに違うため。決め打ちすると、接続先を替えた瞬間に通らなくなる。
名前が通らなかったときは、サーバーが返した文をそのまま返す。
```
読み上げサーバーエラー (400): {"error":{"message":"voice 'alloy' is not available"}}
```
この文が「声の名前が違う」と気づける唯一の手がかりなので、加工しない。
## 出力の形式
仕様上は `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm` があるが、**全部を持つサーバーとは限らない**。既定の `mp3` がいちばん通りやすい。指定した形式がそのまま拡張子になる。
`pcm` は画面のプレビューでは再生できない(ダウンロードはできる)。アプリの中で聴くなら `mp3` などにする。
## 文字数の上限
上限を超える文章は**断る。黙って切り詰めることはしない**。切ると、途中で終わった音声が「できた」として返り、最後まで聴いて初めて気づくことになる。
```
文字数が上限を超えています(5200 文字 / 上限 4096 文字)。切り詰めてはいません。…
```
長い文章は段落ごとに分けて複数回呼ぶか、設定で上限を上げる。
## 立てられるサーバーの例
OpenAI 互換の `/audio/speech` を持つ実装であれば何でもよい。
- [openedai-speech](https://github.com/matatonic/openedai-speech)
- [openai-edge-tts](https://github.com/travisvn/openai-edge-tts)
音の品質・話速・言語の対応はサーバー側の責務で、このアプリでは扱わない。
## 関連
- 文字起こしは [TranscribeAudio](transcribeaudio.md)(逆向き。音声 → 文字)
- 保存した音声はファイル窓のプレビューでそのまま再生できる
+83
View File
@@ -0,0 +1,83 @@
# SpinOffTask
いまの話とは別に取っておきたい話題を、**新しいチャット**として立てる。立てたチャットは独立して走り、結果はここへ戻ってこない。いま作業しているワークスペースへはそのまま立ち、**別のワークスペースへ立てるときは人の承認を待つ**。
> **デフォルト無効(opt-in)**。Bash や SpawnSubTask と同じセンシティブ扱いのツールで、初期状態ではエージェントに提示されない。使うにはワークスペースの **設定 → ツール** タブで明示的に有効化する。
## SpawnSubTask との違い(ここを取り違えない)
一文でいうと、**サブタスク起動はいまの作業を速く終わらせるための分割、切り出しはいまの作業とは別に取っておくための保存**。前者は親の文脈に戻ってくるが、後者は戻ってこない。
| 観点 | SpawnSubTask(サブタスク起動) | SpinOffTask(切り出し) |
|---|---|---|
| 作られるもの | ジョブだけ。チャットは作られない | チャット+ジョブ |
| 一覧に出るか | 出ない。親タスクのサブタスクタブからしか見えない | 出る。ワークスペースのチャット一覧に並ぶ |
| 実行先 | 親の作業ディレクトリ配下の隔離ディレクトリ | ワークスペースの共有ツリー |
| 親との関係 | 親が完了を待って停車し、結果を読んで統合する | 親は待たない。独立して走る |
| 上限 | 入れ子の深さ上限と親あたりの本数上限がある | 1 回の実行で 3 件まで(承認待ちのカードも数える)。切り出しで立ったチャットからは切り出せない |
| 会話・タイトル | 持たない | 持つ。あとから続きを話せる |
**判断のしかた**——その結果がいまの回答に必要なら SpawnSubTask(または `delegate`)。必要ないが取っておきたいなら SpinOffTask。
## 基本
```js
SpinOffTask({
title: "ローカル LLM の量子化方式を掘り下げる",
instruction: "GGUF の量子化方式(Q4_K_M / Q5_K_M / Q8_0)の精度と速度の違いを調べて、選び方の目安を output/quantization.md にまとめる。",
excerpt: "…(引用したい元の文章)…", // 任意
source: "ファイル output/report.md", // 任意
piece: "research", // 任意。省略すると chat
spaceId: "sp-xxxx" // 任意。別のワークスペースへ立てるときだけ(承認待ちになる)
})
```
## いつ使うか
- 会話の途中で別の話題が立ち上がったが、**いまの回答には要らない**とき
- 利用者が「それは別で調べておいて」と言ったとき
- 読んでいた資料の一部を、あとで別途扱いたいとき
呼んだあとは待たない。結果は戻ってこないので、そのままいまの作業を続ける。
## 引数
| 引数 | 必須 | 中身 |
|---|---|---|
| `title` | ○ | 新しいチャットの題名。あとから一覧で見分けるためのもの |
| `instruction` | ○ | 何をしてほしいかの指示。独立して走るので背景を自己完結で書く |
| `excerpt` | | 引き渡したい引用テキスト。**指示ではなく資料**として扱われる |
| `source` | | 引用の出典を表す 1 行(例: `ファイル output/report.md`)。200 文字を超える分は切り詰められる |
| `piece` | | ワークフロー名。省略すると `chat` |
| `spaceId` | | 別のワークスペースへ立てるときの識別子。「現在のコンテキスト」の一覧に出ているものだけが使える |
本文のうち囲みの外に出るのは `instruction` と `source` の 2 つで、どちらも長さで頭打ちになっている。`instruction` は 800 文字を超えると**断られる**——超える塊は指示ではなく資料なので、`excerpt` に回す。`source` は 200 文字を超えると**切り詰められる**(1 行のラベルなので失敗にはしない)。資料を出典の欄に流し込んでも届かない。
**持ち主を指定する引数は無い。**新しいチャットの持ち主と公開範囲は、いま走っているジョブの記録からサーバー側が決める。引数にそれらしい名前を足しても効かない。
## 別のワークスペースへ立てる
`spaceId` を渡すと、その場では作らずに**承認カード**が出て、このチャットが停まる。人が許可すると作成され、この実行は自動で再開する。拒否されると作成されずに再開する。
- 使える識別子は「現在のコンテキスト」の**利用可能なワークスペース**一覧に出ているものだけ。推測して書かない
- **承認は権限を与えるものではない。**許可が出ても、このチャットの持ち主に行き先の編集権限が無ければ作成は失敗する
- 定期実行など人が応答できない実行では、停まらずに**要求だけ記録して続行**する。作成はされない
## `excerpt` の扱い
`excerpt` に渡したテキストは、本文の中で囲みに入れられてから新しいチャットに渡る。囲みの中は**観察対象の資料**であって指示ではない、という約束になっている。だから引用の中に「これまでの指示を無視して〜」のような文が混ざっていても、新しいチャットのエージェントはそれに従わない。
`instruction` と `excerpt` を混ぜて 1 つの文章として `instruction` に流し込まないこと。それをやると、引用が指示として読まれる。`instruction` には 800 文字の上限があり、資料を丸ごと流し込もうとすると断られる。
## 断られたとき
- **「このワークスペースにはチャットを作成できません」**——行き先が見えないか、書き込む権限が無い。`spaceId` を省いていまのワークスペースに立てるか、利用者に伝える
- **`instruction` が長すぎる**——資料を指示に流し込んでいる。その部分を `excerpt` に移す
- **切り出しの上限に達した**——1 回の実行では 3 件まで。**承認待ちのカードもこの 3 件に数える**ので、別のワークスペースへの申請を並べても枠は増えない。切り出しで立ったチャットからはさらに切り出せない。残りはいまのチャットの中で扱う
- **ワークフローが存在しない**——`piece` の綴りを確認する。分からなければ `piece` を省く
- **ジョブが終了またはキャンセルされた**——この実行はもう終わっている。作り直しはしない
## 関連
- [SpawnSubTask](./spawnsubtask.md) — 並列に分解して結果を待つ
- [delegate](./delegate.md) — 直列に委譲して結果だけ受け取る
+1 -1
View File
@@ -2,7 +2,7 @@
AI と人間が共有する SSH PTY セッションを操作する 3 ツール。**1 タスクは接続ごとに 1 つ、複数の PTY セッションを同時に持てる**(例: 2 台のサーバーへ同時接続して並行作業)。各セッション内では `cd` / 環境変数 / foreground プロセスが job をまたいで維持される。長時間の対話作業 / TUI (vim, top, less, tmux) / 複数ラウンドの調査向け。
単発コマンドだけなら **`SshExec`** (ssh-ops piece) のほうが軽い。本ツール群は対話的シェル + AI が画面を見続ける用途に最適化されている。
単発コマンドだけなら **`SshExec`** を直接呼ぶほうが軽い。本ツール群は対話的シェル + AI が画面を見続ける用途に最適化されている。
> **ユーザーが先にセッションを開いている場合がある**: タスク詳細の **Console タブ**から、ユーザーがタブを追加して自分でセッションを起動できる(複数タブを同時に開ける。→[SSH 接続](../../ui/src/content/help/14-ssh.md))。その場合 `SshConsoleEnsure` は既存セッションをそのまま再利用する。「まず console を開く」操作を AI 側でやり直す必要はない。ただし**タスクに複数セッションが同時に開いている場合、`connection_id` を省略して自動採用されるのは「直前にこのツール群で操作した接続 (current-connection pointer)」か「セッションが 1 つしかない」ときだけ**。2 つ以上開いていて pointer も無い状態で省略すると `ambiguous` エラーになる(詳細は後述)。
+8 -7
View File
@@ -12,7 +12,7 @@ WebFetch({ url: "https://example.com/article", timeout: 30 })
- JSON / XML / プレーンテキストもそのまま取得可能
- リダイレクトは自動で追従
返却テキストは context 節約のため約 10,000 文字で切り詰められる。**スペースタスクでは全文(未切り詰め)が `source/` に保存される**ので、切り詰めが起きると末尾に `... (truncated; full version saved to source/<file>)` という案内が付く。全文を読みたいときは保存された `source/` のファイルを `Read` で開けばよい。
返却テキストは context 節約のため約 10,000 文字で切り詰められる。**全文(未切り詰め)は `source/` に保存される**ので、切り詰めが起きると末尾に `... (truncated; full version saved to source/<file>)` という案内が付く。全文を読みたいときは保存された `source/` のファイルを `Read` で開けばよい。
## いつ使うか
@@ -43,7 +43,7 @@ WebFetch は軽量で速い。BrowseWeb はブラウザ起動コストがかか
### 全文は source へ、切り詰め版は agent へ
- agent のコンテキストに返るのは約 10,000 文字に切り詰めた版(context 膨張を防ぐため)
- **スペースタスクでは、切り詰める前の全文クリーン Markdown が `source/` に保存される**
- **切り詰める前の全文クリーン Markdown が `source/` に保存される**(タスクがワークスペースに属していなくても同じ)
- 切り詰めが起きた場合、返却テキスト末尾に `... (truncated; full version saved to source/<file>)` が付く
- 病的に巨大なページ対策として、`source/` 保存も 2MB で打ち切る(その旨ノートを付す)
@@ -99,12 +99,13 @@ WebFetch は取得 body の先頭 8KB を sniff し(magic byte / NUL / 不正
`DownloadFile` で `input/` に保存し、`ReadExcel` / `ReadPdf` 等を使う。テキスト body は
5MB で打ち切られる(末尾に `[truncated: body exceeded 5MB]`)。
## ソースライブラリへの蓄積(スペースタスク)
## ソースライブラリへの蓄積
スペースのタスクで WebFetch がページ本文を取得すると、**クリーン抽出した全文 Markdown**(agent への
WebFetch がページ本文を取得すると、**クリーン抽出した全文 Markdown**(agent への
返却で切り詰める前のもの)が `source/` に保存され、出典メタ(URL・タイトル・取得日時・サイズ)が
`source/index.jsonl` に追記される。
これにより「そのスペースのソース(資料)」が自動で溜まり、後から追加調査やグラウンディングに使える。
同一 URL は重複保存しない(既に index にあればスキップ)。スペース文脈でないタスク
(ephemeral / gitea issue 等)では従来どおり蓄積しない。`logs/raw/` への生データ保存は従来どおり。
これにより「そのタスクのソース(資料)」が自動で溜まり、後から追加調査やグラウンディングに使える。
同一 URL は重複保存しない(既に index にあればスキップ)。
**属している場所で挙動は変えない**——スペースのタスクでも、タスク一覧から直接作ったタスクでも、
定期実行・サブタスクでも同じように溜まる(issue #1097 / 由来 #1093)。`logs/raw/` への生データ保存は従来どおり。
BrowseWeb(ブラウザ閲覧)や DownloadFile(`section: "source"`)も同じ `source/index.jsonl` に合流する。
+8
View File
@@ -82,6 +82,14 @@ await call({ type: 'deleteFile', path: 'output/old.txt' }); // 削
`apps/{name}/app.json`(任意)で一覧の表示名・説明・エントリを指定できる(無ければフォルダ名)。
利用者が「アプリ」タブの「アプリを追加」で手元の HTML を持ち込んだアプリも同じ `apps/` に並ぶ。
その場合 `app.json` は追加時に `{ title, description: "", entry: "index.html" }` の形で自動生成される
(`title` は HTML の `<title>`、無ければフォルダ名)。手で置いたアプリには `app.json` が無いこともあるので、
一覧・起動のどちらも `app.json` の有無に依存しないこと。持ち込みは自動 E2E テストを通らないため、
ブラウザ内の保存(localStorage 等)・ダウンロード・ファイル選択・新窓に頼る HTML はサンドボックスで
そのままでは動かない。持ち込まれたアプリの修正を頼まれたら、ブラウザ内の保存は上のブリッジ API
(`apps/{name}/data/` への `writeFile` / `readFile`)に置き換える。
## E2E テスト(verify ステップ)
`workspace-app` ピースの verify ステップは、作成したアプリを **`TestWorkspaceApp` ツールで自動 E2E テスト** する。
+7
View File
@@ -199,6 +199,13 @@ text-only tweet には呼ばないこと (14 秒を無駄にする)。
## トラブルシューティング
- **エラーは出ないのに常に 0 件**: X がウェブバンドル構成を変え、リクエスト署名
(`x-client-transaction-id`) を作れなくなった状態。署名なしのリクエストに X は
黙って空を返すため、認証切れとは症状が異なる。アダプタは
`scripts/x-adapter/x_adapter.py` の `find_indices_url_deep` で署名材料
(`sign.o-*.js`) を探しており、X 側の構成変更で再発しうる。再発時はこの関数の
探索段数を実際のバンドル構造に合わせて調整する。現在はこの状態を検知したら
空ではなく明示エラーで落とす (2026-07-21 / 上流 twscrape issue #320)
- **認証エラー**: cookie の有効期限切れ。ブラウザで取得し直して Settings に再投入
- **rate limit**: しばらく待ってから再実行。検索回数を絞る
- **twitter-cli not found**: `scripts/install-twitter-cli.sh` で導入が必要
+129
View File
@@ -0,0 +1,129 @@
# AAO から VibeVoice-ASR-Streaming を試す
## ASR サーバー
`https://gitea.example.com/swallow/asr-server` の main にある
`docs/vibevoice-streaming.md` に従い、GPU側の公式推論サービスと接続アダプターを起動します。
アダプターは `ASR_BACKEND=vibevoice-streaming`、`VIBEVOICE_URL=http://公式推論サービス:8001` を設定します。
AAO の接続先は **アダプター** の `/v1/audio/stream` です。
アダプターの compose は既定で localhost:8000 に公開します。AAO が別ホストなら、
AAO から届く内部アドレスへの公開、または内部リバースプロキシを設定してください。
ホストの通信制限を利用している場合は AAO→アダプターの通信を許可します。
## AAO の設定
1. この変更を含む AAO をビルドして起動します(通常の配布・起動手順)。
2. 管理者でログインし、設定 → メディア・文書を開きます。
3. 「リアルタイム文字起こし接続先」に `ws://ASR_HOST:8000/v1/audio/stream` を入力します。
4. 「保存して反映」を押します。再起動は不要です。
5. ワークスペースでチャットを開き、右側の文字起こしタブで、まずマイクのみの開始・停止を試します。
6. 次に PC 音声も有効にし、Windows Chrome/Edge の共有選択画面でシステム音声を含めます。
HTTPSでAAOを利用してください(localhostはブラウザの例外)。TLSをリバースプロキシで終端する場合は、
既存の `auth.secure_cookie: true` の構成を利用し、Hostを維持してWebSocket Upgradeを転送します。
ブラウザは同じAAO originにのみ接続し、ASRのURLや認証cookieを外部へ転送しません。
## 確認項目
- 入力元ごとのメーターと途中結果が動く。
- 他の AAO タブへ移動して戻っても録音を継続する。
- 停止すると末尾を確定して保存する。
- ブラウザ更新後、保存済みから開ける。本文を編集して保存できる。
- 別アカウントでは記録が見えない。
- 共有停止、ASR停止、ネットワーク切断時にエラーが表示され、未確定本文を回収できる。
両入力を有効にするとASR側では2接続を使います。複数ユーザーの利用時は
ASR側の `MAX_WS_CONNECTIONS` とGPU性能を確認してください。
録音の再開は新しいASRセッションになるため、話者番号は入力元・区間ごとの識別です。
公式モデルには会議長に応じたコンテキストと音声窓の上限があります。
## 今回の検証範囲
CPUのAPI・保存・WSテスト、模擬音声と模擬ASRを使ったChromiumの画面操作を実施しました。
GPU推論、日本語の精度、Windows実機のシステム音声共有は未実測です。
ローカルの統合チェックは、サーバーとUIのビルド後に `npm run check:transcription` で実行できます。
実際のブラウザと音声処理を使い、模擬ASRへの送信、設定変更、画面遷移、保存・再読込を確認します。
外部ASRへ音声を送信するテストではありません。
## TranslateGemma の逐次翻訳を追加する
翻訳は ASR とは別のサーバーです。未設定でも文字起こしを利用できます。
以下は [vLLM 公式 TranslateGemma recipe](https://docs.vllm.ai/projects/recipes/en/stable/Google/TranslateGemma.html)
の最適化モデルを使う構成例です。Google の元モデルを通常のチャットモデルとして起動する手順とは異なります。
GPU のドライバー・メモリー容量に適した vLLM バージョンを選び、必要なモデルの利用条件を確認してください。
```bash
docker run --rm --gpus all --ipc=host \
-p 8002:8002 \
-v "$HOME/.cache/huggingface:/root/.cache/huggingface" \
vllm/vllm-openai:v0.14.1-cu130 \
Infomaniak-AI/vllm-translategemma-4b-it \
--served-model-name translategemma-4b-it \
--host 0.0.0.0 --port 8002 \
--gpu-memory-utilization 0.8
```
ASR と同じ GPU を使う場合は、両モデルの必要メモリーを合わせて調整してください。
例のポートは AAO が接続できる内部ネットワークへ公開し、必要に応じてサーバー側に API キー認証を設定します。
管理者は **設定 → メディア・文書 → 文字起こしの翻訳(TranslateGemma)** に入力して保存します。
| 設定 | 例 |
| --- | --- |
| 翻訳サーバー URL | `http://GPU_HOST:8002/v1` |
| 翻訳モデル | `translategemma-4b-it`(served-model-name と一致) |
| 翻訳 API キー | サーバーで認証を設定した場合のキー |
| 翻訳の送信形式 | **vLLM 向け区切り文字形式** |
| 翻訳のタイムアウト | `120` 秒 |
vLLM recipe の形式では、AAO が `/chat/completions` に以下の user メッセージを送信します。
```json
{"role":"user","content":"<<<source>>>en<<<target>>>ja<<<text>>>Hello."}
```
**公式モデル向け構造化形式** は、次の content 配列を受け付ける専用サーバーを用意した場合だけ選びます。
標準の vLLM/OpenAI チャット API がこの独自フィールドを扱えるとは限りません。
```json
{"role":"user","content":[{"type":"text","source_lang_code":"en","target_lang_code":"ja","text":"Hello."}]}
```
vLLM 向け形式では、モデル側テンプレートの予約文字列 `<<<custom>>>` を含む原文は、
意図せず通常プロンプトへ切り替わらないよう翻訳を拒否します。原文は保存でき、構造化形式ではこの制限はありません。
一般的なチャット用 system 指示は追加しません。ブラウザーから翻訳サーバーへ直接接続せず、
AAO の認証済み API を経由します。一般ユーザーには接続 URL と API キーを公開しません。
### 使い方と確認
1. 文字起こしで **逐次翻訳** を ON にし、原文言語と翻訳先言語を選びます。初期値は英語 → 日本語です。
2. 原文と訳文がブロックごとに対応し、翻訳待ち・翻訳中・翻訳済みが表示されることを確認します。
3. 原文を編集したとき、古い訳が更新待ちと表示され、最新の原文に対応する訳へ置き換わることを確認します。
4. 翻訳に失敗した場合は **翻訳を再試行** を使います。原文の保存・ダウンロードは引き続き利用できます。
5. 録音停止時は、その時点の原文と訳を保存します。停止後に届いた訳は未保存となるため、**保存 / 再試行** で保存してください。
6. 保存済みを開いて原文と訳を確認し、TXT / JSON に対応関係が含まれることを確認します。保存済みを開くだけでは翻訳を再開しません。
GPU 上の TranslateGemma 推論、翻訳精度、同時 ASR 利用時の速度とメモリー消費は未実測です。
模擬翻訳サーバーの検証結果と実機の性能を区別し、利用環境で確認してください。
逐次処理は同時1件・依頼開始の間隔は最低2.5秒です。途中原文は1.5秒変化しないブロックを対象にし、
録音停止時は安定待ちを省略します。ブロックは最大400文字、変更のない訳は再利用します。
設定識別子はDBに保持した秘密値で生成するため、AAOの再起動だけでは再翻訳しません。
## チャット保存の確認
文字起こしはグローバルナビゲーションではなく、各チャットの右側タブにあります。
`?page=transcription` の旧URLはワークスペースへ移動し、新しい入口を案内します。
- チャットAで開始し、Bや設定へ移動しても、上部の帯がAの録音中と示すことを確認します。
- 帯からAに戻って停止し、Aの履歴に保存され、Bの履歴に現れないことを確認します。
- Aのワークスペース内の `output/transcriptions/<記録ID>.txt` と `.json` に原文と訳が出力されることを確認します。
- 旧未関連の記録はチャットの文字起こしタブから開き、現在チャットへの保存で関連づけます。
- ファイル出力に失敗した場合は「履歴には保存済み」とファイル保存エラーが区別され、再試行できることを確認します。
ファイルはチャット/ワークスペースの既存アクセス権に従います。読み取り専用のチャットには保存できません。
録音中のチャットを切り替えても保存先は変わりません。この変更に音声コマンドやチャット自動送信は含まれません。
+6
View File
@@ -56,8 +56,12 @@ GUI です。ワークスペースの **「アプリ」タブ**に一覧表示
```js
// 読み取り
window.parent.postMessage({ id, type: 'readFile', path: 'output/data.json' }, '*');
// 安全な画像・PDF・音声・動画を表示する一時 URL(1ファイル10MBまで)
window.parent.postMessage({ id, type: 'readFileUrl', path: 'output/chart.png' }, '*');
// 一覧(dir 省略でワークスペース直下)
window.parent.postMessage({ id, type: 'listFiles', dir: 'output' }, '*');
// 再帰一覧(ファイルのみ、path順。最大1000件・深さ10、打ち切り時 truncated=true)
window.parent.postMessage({ id, type: 'listFilesRecursive', dir: 'source' }, '*');
// 書き込み(content は文字列)
window.parent.postMessage({ id, type: 'writeFile', path: 'output/請求書.html', content: '<html>…' }, '*');
// 削除(常に確認ダイアログが出る)
@@ -79,7 +83,9 @@ window.addEventListener('message', (e) => {
| type | 成功時 `data` |
|-------------|-------------------------------------------------|
| `readFile` | `{ path, content }`(content は UTF-8 テキスト)|
| `readFileUrl` | `{ path, url, mime, bytes }`(表示用の一時 URL。HTML/SVG/未知形式は拒否)|
| `listFiles` | `{ dir, entries }`(entries はファイル/フォルダ)|
| `listFilesRecursive` | `{ dir, entries, truncated }`(entries はファイルのみ)|
| `writeFile` | `{ path, bytes }` |
| `deleteFile`| `{ deleted, skipped }` |