sync: update from private repo (edc775f2)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-07-06 01:04:12 +00:00
parent 747377bef9
commit b1292e34b2
322 changed files with 28001 additions and 4686 deletions

31
.gitattributes vendored Normal file
View File

@ -0,0 +1,31 @@
# Normalize line endings so Windows / WSL checkouts don't break the build.
#
# With git's default `core.autocrlf=true` on Windows, text files are checked out
# with CRLF. The Docker builder runs `bash scripts/*.sh` (with `set -o pipefail`)
# and Node executes `scripts/*.mjs` — CRLF turns the shebang/`\r` into errors and
# the build fails with a confusing exit 127. Pin build- and runtime-critical text
# files to LF regardless of the client's git config.
# Let git detect text vs binary and normalize to LF in the repository.
* text=auto
# Always LF in the working tree, on every platform.
*.sh text eol=lf
*.bash text eol=lf
Dockerfile text eol=lf
*.mjs text eol=lf
*.cjs text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
# Binary assets — never normalize.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary

View File

@ -4,6 +4,23 @@ All notable changes to MAESTRO are documented here. The format is loosely based
on [Keep a Changelog](https://keepachangelog.com/), and the project aims to
follow semantic versioning.
## Unreleased
### Fixed
- Docker: a clean `docker compose up --build` now reliably installs Chromium for
the browser tools. The Playwright browser CLI is invoked directly
(`node node_modules/playwright/cli.js install`) to avoid an npm bin-name
collision that left a from-scratch build failing with `playwright: not found`
(exit 127).
- Docker on Windows (WSL2): a fresh `docker compose up --build` no longer fails
when `.env` is absent — `.env` is now optional. A new `.gitattributes` pins
shell scripts and other build-critical files to LF so a Windows checkout
(CRLF) builds cleanly.
### Added
- Docs: Windows/WSL quickstart notes in the README and `docs/docker.md`. The
browser tools run entirely inside the container (no host X server or WSLg).
## v0.1.0 — Initial public release (2026-06-02)
First open-source release of MAESTRO, an agent orchestration platform:

View File

@ -86,16 +86,26 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
COPY package.json package-lock.json* ./
# build-essential compiles better-sqlite3's native addon (removed afterward to
# stay lean). `npx playwright install --with-deps chromium` downloads the
# Chromium binary INTO PLAYWRIGHT_BROWSERS_PATH and installs its shared-library
# apt deps in one step. NOTE: `npm ci` alone does NOT download the browser —
# Playwright dropped the npm-install postinstall browser download, so relying on
# it left /ms-playwright absent and the chmod below failing on a clean build
# (issue #528). chmod makes the browser readable by the non-root `node` user.
# stay lean). `playwright install --with-deps chromium` downloads the Chromium
# binary INTO PLAYWRIGHT_BROWSERS_PATH and installs its shared-library apt deps
# in one step. NOTE: `npm ci` alone does NOT download the browser — Playwright
# dropped the npm-install postinstall browser download, so relying on it left
# /ms-playwright absent and the chmod below failing on a clean build (issue
# #528). chmod makes the browser readable by the non-root `node` user.
#
# We invoke the CLI as `node node_modules/playwright/cli.js` rather than
# `npx playwright`: prod `playwright` and dev `@playwright/test` both declare a
# `playwright` bin, and lockfile generation resolves that collision in favour of
# `@playwright/test`. That ownership is baked into package-lock.json, so even a
# CLEAN `npm ci --omit=dev` (with @playwright/test absent) does NOT relink
# node_modules/.bin/playwright to the prod package — the bin is simply missing
# and `npx playwright` fails with exit 127 (issue #007). Calling the package's
# own cli.js sidesteps bin resolution entirely and is version-matched to the
# installed prod `playwright`.
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& npm ci --omit=dev \
&& npx playwright install --with-deps chromium \
&& node node_modules/playwright/cli.js install --with-deps chromium \
&& chmod -R go+rX /ms-playwright \
&& npm cache clean --force \
&& apt-get purge -y build-essential \

View File

@ -45,12 +45,15 @@ OpenAI 互換の LLM エンドポイント([Ollama](https://ollama.com/) / vLL
### Docker最短
Linux でも、Windows の WSL2Docker Desktop の WSL 統合)でも動く。
```bash
cp .env.example .env # OLLAMA_BASE_URL / OLLAMA_MODEL を設定
docker compose up -d
docker compose up -d # 初回はビルドしてから起動
# http://localhost:9876 を開く
```
`.env``config.yaml` は用意しなくても起動する。未設定のまま起動すると UI にセットアップウィザードが開き、そこから LLM の接続先を設定できる。先に指定しておきたい場合は `cp .env.example .env` して `OLLAMA_BASE_URL` / `OLLAMA_MODEL` を設定する。
Compose は安全のため `127.0.0.1:9876` のみに公開する。別ホストからアクセス可能にする前に OAuth 認証を設定し、TLS 対応のリバースプロキシを配置すること。LLM エンドポイントは `.env` / `config.yaml` で指定する。
Docker の詳細ガイドLinux のネットワーク・データ永続化・サンドボックス・トラブルシューティング)は **[docs/docker.md](docs/docker.ja.md)**。

View File

@ -45,12 +45,15 @@ Settings: every `config.yaml` section as a form — LLM workers, sandbox, auth,
### Docker (fastest)
Works on Linux and on Windows via WSL2 (Docker Desktop's WSL integration).
```bash
cp .env.example .env # set OLLAMA_BASE_URL / OLLAMA_MODEL
docker compose up -d
docker compose up -d # builds on first run, then starts
# open http://localhost:9876
```
No `.env` or `config.yaml` is needed to start: a fresh `docker compose up` opens a setup wizard in the UI where you point MAESTRO at your LLM. To preset it instead, `cp .env.example .env` and set `OLLAMA_BASE_URL` / `OLLAMA_MODEL`.
For safety, Compose exposes only `127.0.0.1:9876`. Before making it reachable from another host, configure OAuth authentication and place a TLS-enabled reverse proxy in front. Specify the LLM endpoint in `.env` / `config.yaml`.
Full Docker guide (Linux networking, data persistence, the sandbox, troubleshooting): **[docs/docker.md](docs/docker.md)**.

View File

@ -38,9 +38,8 @@ prompt: |
- 出力は `output/report.md` のみ、他のファイルを作らない
expected:
must_use_tools: [ReadExcel, WebFetch, Read, Write, CreateChecklist, CheckItem, GetChecklist]
forbidden_tool_for_ext:
Read: ['.xlsx', '.docx', '.pptx', '.xls', '.doc', '.ppt']
# Read は拡張子で Office/PDF を自動判定・抽出する統合ツール。旧 ReadExcel は廃止。
must_use_tools: [WebFetch, Read, Write, CreateChecklist, CheckItem, GetChecklist]
must_produce_files: [output/report.md]
completion_status: [succeeded]

View File

@ -198,6 +198,19 @@ subtasks:
max_per_parent: 10 # 1 ジョブが生成できるサブタスクの最大数
spawn_stagger_ms: 1000 # SpawnSubTask の連続発火を間引く遅延(ms)。一斉着地で GPU 優先順位を飛び越えるのを防ぐ。0 で無効
# ─── Python パッケージ(ワークスペース単位の wheel オーバーレイ)────────
# 既定 OFF。有効化すると、各ワークスペースの設定 → Python から admin/オーナーが
# wheel を追加でき、そのワークスペースのエージェントだけが import できる。
# インストールはネットワークを許可した分離 bwrap で out-of-band 実行(ワークスペース /
# シークレットには触れない。wheels のみ(--only-binary=:all:)で sdist の任意コード
# 実行を回避。エージェント自身の sandbox は従来どおりネットワーク遮断のまま。
# python_packages:
# enabled: false # true で機能を有効化
# dir: ./data/python-packages # オーバーレイの保存先ルート
# index_url: https://pypi.org/simple # 固定 indexリクエストで上書き不可
# install_timeout_sec: 180 # 1 回のインストールの上限秒(最大 1800
# max_packages_per_space: 30 # 1 ワークスペースが固定できるパッケージ数
# ─── Context (LLM コンテキスト管理) ───────────────────────────
# context:
# limit_tokens: 128000 # 省略時は Ollama API で自動取得、それも失敗なら 128000
@ -436,6 +449,8 @@ tools:
# max_session_duration_seconds: 14400 # 4h hard cap
# scrollback_bytes: 524288 # 512KB scrollback / session
# max_sessions_per_connection: 3
# max_sessions_per_task: 5 # 同一タスクが開ける同時コンソール数の上限
# max_sessions_per_user: 20 # ユーザー単位の同時コンソール数の上限 (0 = 無制限)
# max_input_bytes_per_send: 16384
# auto_inject_screen_lines: 24
# default_cols: 120
@ -456,3 +471,11 @@ tools:
#
# 起動時に vapid_current_path に鍵が無ければ自動生成、mode 0600 で保存。
# 鍵をローテーションする場合: npm run vapid-rotate
# ── A2A OAuth2 Authorization Server ──────────────────────────────────────────
# Requires auth to be active (consent screen uses req.user).
#
# a2a:
# enabled: false
# issuer: "https://localhost:8080/oidc" # 認可サーバーの issuer公開 URL
# resource_audience: "https://localhost:8080/a2a" # トークン audienceA2A endpoint

View File

@ -1,25 +1,53 @@
# MAESTRO systemd unit — TEMPLATE.
#
# Do not install this file verbatim: the @TOKENS@ below are placeholders.
# Use the installer, which fills them in from the current checkout and enables
# boot autostart in one step:
#
# scripts/install-systemd.sh # user service (no root, runs as you) [default]
# scripts/install-systemd.sh --print # preview the generated unit, install nothing
# scripts/install-systemd.sh --mode system --run-as <user>
#
# See docs/systemd-autostart.md for details.
#
# The service runs `node dist/main.js` directly (systemd supervises it). It does
# NOT build — build first with `scripts/server.sh start` (or `npm run build`) so
# dist/ is fresh, then let systemd own the running process. Do not run
# `server.sh start` and the systemd service at the same time (double-start).
[Unit]
Description=MAESTRO
After=network.target
Description=MAESTRO agent orchestrator
Documentation=file://@PROJECT_DIR@/docs/systemd-autostart.md
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=maestro
WorkingDirectory=/opt/maestro
ExecStart=/usr/bin/node dist/index.js
User=@RUN_USER@
WorkingDirectory=@PROJECT_DIR@
# Default run mode (worker = full orchestrator). Override via .env if needed.
Environment=AAO_MODE=worker
# Optional project .env (KEY=value per line). `-` = ignore if missing.
# NOTE: systemd parses this file itself; keep values simple KEY=value. The rich
# quoting server.sh understands is not applied here.
EnvironmentFile=-@PROJECT_DIR@/.env
ExecStart=@NODE@ dist/main.js
Restart=on-failure
RestartSec=10
EnvironmentFile=/opt/maestro/.env
TimeoutStopSec=30
# Logging
# Logging → journald. Tail with: journalctl -u maestro -f
StandardOutput=journal
StandardError=journal
SyslogIdentifier=maestro
# Security hardening
# Security hardening. ProtectSystem=full keeps /usr, /boot, /efi and /etc
# read-only while leaving the checkout, /home, /tmp and /var writable — enough
# for the default layout (data/, logs/, data/workspaces all live under the
# checkout). Tighten to ProtectSystem=strict + explicit ReadWritePaths= if your
# deployment writes outside the checkout.
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/maestro/data /var/lib/maestro/workspaces
ProtectSystem=full
[Install]
WantedBy=multi-user.target

View File

@ -15,8 +15,14 @@ services:
- "127.0.0.1:9876:9876"
extra_hosts:
- "host.docker.internal:host-gateway"
# .env is optional: a bare `docker compose up --build` works with zero setup.
# Put overrides here (OLLAMA_*, auth secrets, HOST, ...) to inject them into
# the container — copy .env.example to .env and edit. `required: false` stops
# a fresh clone (no .env) from failing with "env file .env not found".
# Needs Docker Compose v2.24+ (Docker Desktop / recent WSL installs have it).
env_file:
- .env
- path: .env
required: false
environment:
- NODE_ENV=production
- PORT=9876

View File

@ -6,14 +6,21 @@ MAESTRO を最短で動かす方法が Docker Compose です。`git clone` か
## まずはこれだけ
Linux でも、Windows の WSL2Docker Desktop の WSL 統合)でも動きます。
```bash
cp .env.example .env # OLLAMA_BASE_URL / OLLAMA_MODEL を自分の LLM に向ける
docker compose up -d # 初回はイメージをビルドしてから起動
# http://localhost:9876 を開く
```
`.env``config.yaml` は用意しなくても起動します。未設定のまま起動すると UI にセットアップウィザードが開き、そこから LLM の接続先を設定できます。先に指定しておきたい場合は `cp .env.example .env` して `OLLAMA_BASE_URL` / `OLLAMA_MODEL` を設定します([LLM エンドポイント](#llm-エンドポイント)参照)。
Compose は UI を `127.0.0.1:9876` だけに公開するので、初期状態では LAN から到達できません。外部公開は[ローカル以外へ出す](#ローカル以外へ出す)を参照。
### WindowsWSL2
Docker Desktop の WSL 統合を有効にした WSL2 ディストロ内で、同じコマンドを実行するだけです。ヘッド付きブラウザ一式はコンテナ内で完結するため、Windows 側に X サーバーや WSLg は不要です。またリポジトリの `.gitattributes` がビルド用のシェルスクリプトを LF に固定するので、Windows でチェックアウトしても(本来 CRLF になってしまう場合でも)ビルドが壊れません。
## コンテナがやること・やらないこと
- MAESTRO 本体Web UI・ワーカー・ツール・任意の Gatewayを動かす。

View File

@ -8,15 +8,29 @@ people up (Linux networking, the LLM endpoint, data persistence, the sandbox).
## TL;DR
Works on Linux and on Windows via WSL2 (Docker Desktop's WSL integration).
```bash
cp .env.example .env # point OLLAMA_BASE_URL/OLLAMA_MODEL at your LLM
docker compose up -d # builds the image on first run, then starts
# open http://localhost:9876
```
No `.env` or `config.yaml` is required to start: a fresh `docker compose up`
opens a setup wizard in the UI where you point MAESTRO at your LLM. To preset the
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 `127.0.0.1:9876` only, so a fresh instance is not
reachable from your LAN. See [Going beyond localhost](#going-beyond-localhost).
### Windows (WSL2)
Run the same command inside a WSL2 distro with Docker Desktop's WSL integration
enabled — nothing else is needed. The headed-browser stack runs entirely inside
the container (no host X server or WSLg), and the repository's `.gitattributes`
keeps the build's shell scripts LF-only, so a Windows checkout (which would
otherwise convert them to CRLF) still builds cleanly.
## What the container is (and is not)
- It runs the **MAESTRO app** (web UI, workers, tools, optional gateway).

View File

@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AIエージェント設計20原則 × MAESTRO — コード実測による再評価</title>
<style>
:root { --green:#16a34a; --blue:#2563eb; --amber:#d97706; --red:#dc2626; --ink:#1f2937; --muted:#6b7280; --line:#e5e7eb; --bg:#f9fafb; }
* { box-sizing: border-box; }
body { font-family: -apple-system, "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif; color: var(--ink); line-height: 1.7; max-width: 1000px; margin: 0 auto; padding: 24px; background: #fff; }
h1 { font-size: 1.6rem; margin: .2em 0; }
h2 { font-size: 1.25rem; margin-top: 2em; border-bottom: 2px solid var(--line); padding-bottom: .3em; }
h3 { font-size: 1.05rem; margin-top: 1.4em; }
.badge { display: inline-block; padding: 3px 12px; border-radius: 999px; font-size: .8rem; font-weight: 700; color: #fff; background: var(--green); }
.meta { color: var(--muted); font-size: .85rem; margin: .5em 0 1.5em; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: .9rem; }
th, td { border: 1px solid var(--line); padding: 7px 10px; text-align: left; vertical-align: top; }
th { background: var(--bg); font-weight: 700; }
code { background: var(--bg); padding: 1px 5px; border-radius: 4px; font-size: .85em; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
.ok { color: var(--green); font-weight: 700; }
.part { color: var(--amber); font-weight: 700; }
.no { color: var(--red); font-weight: 700; }
.wrong { background: #fef2f2; }
.summary-box { background: var(--bg); border-left: 4px solid var(--blue); padding: 12px 18px; border-radius: 0 8px 8px 0; margin: 1em 0; }
.tally { display: flex; gap: 16px; flex-wrap: wrap; margin: 1em 0; }
.tally div { flex: 1; min-width: 120px; text-align: center; border: 1px solid var(--line); border-radius: 8px; padding: 12px; }
.tally .num { font-size: 1.8rem; font-weight: 800; }
.note { color: var(--muted); font-size: .85rem; }
.pri { font-weight: 700; padding: 2px 8px; border-radius: 4px; color:#fff; }
.p1 { background: var(--red); } .p2 { background: var(--amber); } .p3 { background: var(--muted); }
</style>
</head>
<body>
<span class="badge">完成 / 検証済み</span>
<h1>Anthropic「AIエージェント設計20の原則」× MAESTRO — コード実測による再評価</h1>
<p class="meta">
評価日: 2026-07-01 対象: <code>origin/main</code> commit <code>be4e98d7</code>(全ソースを実測)<br>
基にした資料: Zenn記事「Anthropic公式に学ぶAIエージェント設計20の原則」ながたく, 2026/06/23の MAESTRO 評価表<br>
手法: 3チームでコード横断監査各原則→該当 <code>file:line</code>→適合判定)。推測(記事の「⚠️不明」)を実コードで置換。
</p>
<div class="summary-box">
<strong>結論。</strong> 20の原則そのものは妥当Anthropicの実指針。しかし元記事の<strong>MAESTRO評価は精度が低い</strong> — 表の大半が「⚠不明確認必要」で、コードを読まずに書かれている。コード実測の結果、MAESTROは20原則に<strong>強く整合</strong>しており適合17部分適合3非適合0、記事が「改善余地大」とした項目のうち<strong>7件は実装済み機能を見落とした事実誤認</strong>だった。実質的な改善余地は<strong>3件</strong>に絞られる。
</div>
<div class="tally">
<div><div class="num ok">17</div>適合</div>
<div><div class="num part">3</div>部分適合</div>
<div><div class="num no">0</div>非適合</div>
<div><div class="num">7</div>記事の事実誤認を反証</div>
</div>
<h2>1. 記事の主張 vs 実コード(是正一覧)</h2>
<p>元記事が否定的・不明とした主要項目のうち、実コードと矛盾するものを列挙する。</p>
<table>
<tr><th>原則</th><th>記事の主張</th><th>実測判定</th><th>根拠file:line</th></tr>
<tr class="wrong"><td>1 注意予算/トークン計測</td><td>「トークン計測<strong>未実装</strong></td><td class="ok">誤り→適合</td><td><code>context-manager.ts:68-83</code><code>update(usage.prompt_tokens)</code>+比率閾値 0.7/0.85/0.95)/配線 <code>context-control.ts:64-116</code></td></tr>
<tr class="wrong"><td>4 サブエージェント</td><td><strong>並列化に偏り</strong>、汚染防止を文書化できていない」</td><td class="ok">誤り→適合</td><td><code>delegate-runner.ts:25-29</code>「isolation invariant」独立Conversation<code>orchestration.ts:7-11</code>「中間作業は文脈に残らない」。隔離型<code>delegate</code>と並列型<code>SpawnSubTask</code>が別実装</td></tr>
<tr class="wrong"><td>5 長タスク3戦略</td><td>「仕組みがあるか<strong>不明</strong>・改善余地大」</td><td class="ok">半分誤り→適合</td><td>圧縮=<code>history-compactor.ts</code><code>prompt-guard.ts</code>、検索=<code>task-conversation.ts</code>Search/ReadTaskConversationtranscript replay、ート=<code>mission.ts</code>MissionBrief今回 user_constraints/decisions/current_focus 拡張。3戦略とも実在</td></tr>
<tr class="wrong"><td>7 相対パス問題</td><td>「相対パス問題を避けるため<strong>絶対パス必須化</strong></td><td class="no">推奨が有害</td><td>逆。MAESTROは<strong>ワークスペース相対</strong>を強制しjail外の絶対パスを拒否<code>core.ts:457/494/729</code>, <code>browser.ts:118</code>)。絶対パス必須化はサンドボックスを壊す</td></tr>
<tr class="wrong"><td>14 マルチエージェント</td><td>「コスト対効果の<strong>基準不明</strong></td><td class="ok">誤り→適合</td><td><code>MAX_DELEGATION_DEPTH=2</code><code>agent-loop.ts:179</code>)+直列契約+<code>research.yaml</code> dig に opt-in判断基準複数独立対象/overflowリスク時のみ</td></tr>
<tr class="wrong"><td>17-18 CLAUDE.md</td><td>「フックの有無不明200行超で肥大化」</td><td class="part">カテゴリ違い</td><td>記事はリポジトリの<code>CLAUDE.md</code>Claude-Code向け手引き, 277行とMAESTRO製品の実行時プロンプトを混同。製品側は<code>allowed_tools</code>機械ゲート+<code>MISSION_TOTAL_CHAR_BUDGET=3200</code>で境界を機械強制(<code>movement-setup.ts:36</code>, <code>prompt.ts:102-108</code></td></tr>
<tr class="wrong"><td>20 訂正回数</td><td>「訂正回数の<strong>管理不明</strong></td><td class="ok">誤り→適合</td><td>3層のループ検出: Progressive Pressure<code>prompt.ts:315</code>連続再訪ABORT<code>piece-runner.ts:1115</code>toolLoopTracker<code>loop-safety.ts</code>, 同一バッチ5回で中断</td></tr>
</table>
<h2>2. 全20原則 — 証拠ベース判定</h2>
<h3>コンテキストエンジニアリング1〜6</h3>
<table>
<tr><th>#</th><th>原則</th><th>判定</th><th>根拠</th></tr>
<tr><td>1</td><td>コンテキストは有限の注意予算</td><td class="ok">適合</td><td><code>ContextManager</code>が実usageからトークン追跡閾値アクションcontext-manager.ts / context-control.ts</td></tr>
<tr><td>2</td><td>文脈はジャストインタイムで引く</td><td class="ok">適合</td><td><code>ReadToolDoc</code>遅延ロードdocs.ts:1101行ツールカタログ自動注入prompt.ts:290skill indexRead offset/limit</td></tr>
<tr><td>3</td><td>コード実行で中間データ処理</td><td class="ok">適合</td><td>Bashcore.ts:807, cwdWS・パス検証スクリプト作業をprompt自動推奨prompt.ts:157RunUserScriptMCP</td></tr>
<tr><td>4</td><td>サブエージェント=文脈汚染の隔離</td><td class="ok">適合</td><td>隔離型<code>delegate</code>独立Conversation, 結果のみ返却)と並列型<code>SpawnSubTask</code>が別々に実装</td></tr>
<tr><td>5</td><td>長タスクは3戦略を使い分け</td><td class="ok">適合</td><td>圧縮検索task-conversation構造化ートMissionBriefすべて実在</td></tr>
<tr><td>6</td><td>長文は文書冒頭・クエリ末尾</td><td class="ok">適合</td><td><code>Conversation.seed</code>: index0=system(preamble+guidance)、index1=user(タスク本文)。参照材料が先・クエリが後</td></tr>
</table>
<h3>ツール設計・ACI7〜12</h3>
<table>
<tr><th>#</th><th>原則</th><th>判定</th><th>根拠</th></tr>
<tr><td>7</td><td>ツール設計は一次作業</td><td class="ok">適合</td><td>1文description<code>docs/tools/*.md</code>39本の二層設計。CLAUDE.md/maintenance-checklistに明文規律。※記事の絶対パス推奨は不採用有害</td></tr>
<tr><td>8</td><td>タスクの自然な単位</td><td class="ok">適合</td><td>SplitExcelSheets/delegate/BrowseWeb(batched actions)/CreateChecklist 等、薄いラッパーでなくワークフロー単位。今回tool-dispatcher分割で内部の関心分離も改善(#702)</td></tr>
<tr><td>9</td><td>人間が即答できる粒度</td><td class="part">部分適合<br>(記事が正しい)</td><td><strong>89ツール</strong>と多い。重複クラスタ: delegate/SpawnSubTask/WaitSubTask、WebFetch/BrowseWeb/InteractiveBrowse/BrowseWithSession、ssh/ssh-console、Read系。allowed_tools/META_TOOLSで提示は絞るが棚卸し価値あり</td></tr>
<tr><td>10</td><td>応答識別子は自然言語名</td><td class="ok">適合</td><td>WS相対パスsavedRelPath等、サブタスク#N、checklist item_id、browser ref(e1/f1.e3)、comment:&lt;id&gt;/transcript:&lt;index&gt;。不透明UUIDをLLMに晒さない</td></tr>
<tr><td>11</td><td>エラー応答は改善指示</td><td class="ok">適合</td><td>invalid transition→有効な遷移先列挙、Read binary→Bashでの確認手順、大出力→Grep/offset提案 等。弱点: Edit old_string not foundSSH command rejected は原因のみ</td></tr>
<tr><td>12</td><td>現実的複雑度で評価</td><td class="part">部分適合</td><td>bench 5軸100点LLM-judge、composite-mini-reportは3ソース融合・現実的。<strong>だがbenchタスクが2本のみ</strong>。89ツールの大半は未網羅unitテスト46本で部分補完</td></tr>
</table>
<h3>ワークフロー選択・プロンプト・運用13〜20</h3>
<table>
<tr><th>#</th><th>原則</th><th>判定</th><th>根拠</th></tr>
<tr><td>13</td><td>事前特定でWF/Agentを選ぶ</td><td class="ok">適合</td><td>ハイブリッド: movementグラフは静的rules[].next、終端値はlint拒否 piece-runner.ts:92、movement内はReActエージェント最大200反復</td></tr>
<tr><td>14</td><td>並列価値がコストを上回る時だけ</td><td class="ok">適合</td><td>depth=2上限直列delegateresearch.ymlのopt-in判断基準</td></tr>
<tr><td>15</td><td>ルール列挙より3〜5例</td><td class="part">部分適合</td><td>chat/research/ssh-opsに具体テンプレ・few-shotあり。ただし一部はまだルール列挙。例示ファーストに寄せる余地</td></tr>
<tr><td>16</td><td>指示に理由を添える</td><td class="ok">適合</td><td>ssh-ops「ローカルで回避してはいけない」「MITM疑い→自動リトライしない」等の理由付き。<code>why_no_default</code>必須フィールドで構造的にも強制</td></tr>
<tr><td>17</td><td>重要境界は機械的ブロック</td><td class="ok">適合</td><td><code>getToolDefs</code>がallowed_tools外を提示しないフィルタで強制。editフラグ、rules終端値lint、SSH allowlist。記事はCLAUDE.md混同のカテゴリ違い</td></tr>
<tr><td>18</td><td>CLAUDE.mdは200行未満</td><td class="ok">適合(runtime)</td><td>製品ランタイムは<code>MISSION_TOTAL_CHAR_BUDGET=3200</code>で切詰め。リポジトリCLAUDE.md 277行はClaude-Code向けの軽微なnitで製品所見ではない</td></tr>
<tr><td>19</td><td>検証コマンドで完了定義</td><td class="ok">適合</td><td><code>vitest run</code><code>npm run bench</code><code>validate-help-docs.mjs</code>Stopフックchangelog警告</td></tr>
<tr><td>20</td><td>同じ修正2回でリセット</td><td class="ok">適合</td><td>Progressive Pressure連続再訪ABORT(loop_detected)toolLoopTrackercontext force_transitiontext-only上限。カウントは管理されている</td></tr>
</table>
<h2>3. 実質的な改善余地コード実測で残った3件</h2>
<p>記事の「優先改善トップ5」のP0CLAUDE.md肥大化・トークン予算未実装は前提が誤りのため除外。実測で残る改善余地は次の通り。</p>
<table>
<tr><th>優先</th><th>改善点</th><th>原則</th><th>根拠</th></tr>
<tr><td><span class="pri p1">P1</span></td><td><strong>ツール棚卸し・統合</strong>。89ツールを「人間が1秒で使い分けを言えるか」で監査し、重複クラスタサブエージェント3種ブラウザ4種SSH2系統を整理・description明確化。CLAUDE.mdのツール表も実態と乖離棚卸し漏れの症状</td><td>8,9</td><td>実測89ツール、重複4クラスタ特定</td></tr>
<tr><td><span class="pri p2">P2</span></td><td><strong>bench網羅の拡充</strong>。現在2タスクのみ。主要ツール群を現実的タスクで評価する bench を追加</td><td>12</td><td>bench/tasks/ に2本のみ</td></tr>
<tr><td><span class="pri p3">P3</span></td><td>細部: (a) pieceの一部ルール列挙を例示に寄せる15、(b) Edit/SSHの一部エラーに次の一手を追記11</td><td>11,15</td><td>該当箇所を特定済み</td></tr>
</table>
<h2>4. 総評</h2>
<p>
MAESTROは、記事が「基礎はある」とした以上に、20原則の<strong>大部分を既に実装済み</strong>である — トークン予算管理、JITコンテキスト、隔離型サブエージェント、3層ループ検出、機械的ツールゲート、char-budget化された実行時プロンプトは、いずれもコードに実在する。記事の否定的評価の多くは、コードを読まずアーキテクチャ要約から推測したことによる事実誤認だった。
</p>
<p>
唯一、記事が正しく指摘し実測でも残るのは<strong>ツールカタログの肥大原則9と評価網羅原則12</strong>である。特に89ツールという規模は「人間が使い分けを即答できる」水準を超えており、重複クラスタの統合が最も費用対効果の高い改善となる。
</p>
<hr style="margin:2em 0; border:none; border-top:1px solid var(--line);">
<p class="note">
トレーサビリティ: 本評価は 2026-07-01 に <code>origin/main</code>(be4e98d7) の全ソースを3チームで横断監査した結果。各判定は該当 <code>file:line</code> を根拠とする。元記事の評価は本番コードへのアクセスなしに書かれており、本書はそれを実測で全面的に置換したもの。<br>
設計と実装の差分: 元記事の「⚠不明」20項目中17項目が「実装済み・適合」と判明。すなわち<strong>設計意図(原則)と実装は大きく乖離しておらず、乖離していたのは記事の推測と実コードの間</strong>だった。
</p>
</body>
</html>

View File

@ -0,0 +1,193 @@
# Agent loop follow-up issues
## Issue 1: Fix dangling control tool calls across movement transitions
### Problem
`transition` and `complete` tool calls can be appended to the live conversation
without a matching tool result message. When the same `Conversation` is reused by
the next movement, strict OpenAI-compatible providers may reject the next request
because every assistant `tool_call` must have a corresponding `tool` message.
### Evidence
- `src/engine/agent-loop.ts`: assistant messages are recorded with all pending
tool calls, including control calls.
- `src/engine/agent-loop/terminal-control.ts`: valid `transition` returns a
`MovementResult` without a tool result message.
- `src/engine/agent-loop.ts`: valid `complete` returns immediately without a
tool result message.
- `src/engine/context/conversation.ts`: `replayableTurns()` sanitizes persisted
transcript replay, but live `conversation.messages` shared across movements is
not sanitized.
### Expected behavior
After a movement exits via `transition` or `complete`, the next model request
must not contain unresolved control `tool_call`s.
### Suggested fix
Normalize live conversation state before entering the next movement, or avoid
recording terminal/control tool calls without corresponding tool messages.
### Acceptance criteria
- Add a regression test that uses a shared `Conversation` across a real
`transition`.
- The second movement's model input contains no dangling `transition` or
`complete` tool calls.
- Existing agent-loop tests continue to pass.
---
## Issue 2: Make context overflow terminal defaults consistently abort
### Problem
Context overflow handling has two different policies:
- `buildContextOverflowResult()` converts terminal defaults (`COMPLETE`, `ASK`)
to `ABORT`.
- `applyContextManagerUpdate()` with `force_transition` uses
`movement.defaultNext ?? 'ABORT'` directly.
This means context pressure can falsely complete or ask from a movement whose
default next step is terminal, even though the safer policy is to abort on
context loss.
### Evidence
- `src/engine/agent-loop/context-control.ts`: `buildContextOverflowResult()`
normalizes `COMPLETE` and `ASK` to `ABORT`.
- `src/engine/agent-loop/context-control.ts`: `applyContextManagerUpdate()`
force transition uses `movement.defaultNext` directly.
### Expected behavior
All context-overflow forced exits should use the same terminal-default policy.
When context is compromised, terminal defaults should not be treated as
successful completion.
### Suggested fix
Share one helper for context-overflow movement results, or apply the same
terminal-default normalization in `applyContextManagerUpdate()`.
### Acceptance criteria
- Add a regression test for `force_transition` with `defaultNext: "COMPLETE"`.
- The result is an abort-style movement result, not a successful completion.
- Existing context overflow tests continue to pass.
---
## Issue 3: Enforce `why_no_default` for `needs_user_input`
### Problem
The prompt and tool description require `why_no_default` when calling
`complete({ status: "needs_user_input" })`, but runtime validation only requires
`missing_info`.
This lets the agent ask the user without documenting why it could not choose a
reasonable default, which weakens the intended "avoid unnecessary user
questions" behavior.
### Evidence
- `src/engine/agent-loop/prompt.ts`: instructs the model to provide
`why_no_default`.
- `src/engine/agent-loop/terminal-control.ts`: tool description says
`why_no_default` is required.
- `src/engine/agent-loop/terminal-control.ts`: `validateCompleteArgs()` only
checks `missing_info`.
- `src/engine/agent-loop/terminal-control.ts`: movement output ignores
`why_no_default`.
### Expected behavior
`needs_user_input` should be rejected unless both `missing_info` and
`why_no_default` are non-empty strings.
### Suggested fix
Validate `why_no_default` in `validateCompleteArgs()` and include it in the
result/debug output if useful.
### Acceptance criteria
- Add a negative test for missing `why_no_default`.
- Add a positive test for `needs_user_input` with both fields.
- Existing `complete` behavior remains unchanged for other statuses.
---
## Issue 4: Validate interactive browse waiting-human session id
### Problem
`parseInteractiveBrowseWaitingHuman()` casts `sessionId` to `string` without
checking that it is present and actually a string.
If the tool returns malformed `waiting_human` output, the movement result can
carry `browserSessionId: undefined` despite the type expecting a string.
### Evidence
- `src/engine/agent-loop/tool-dispatcher.ts`: `sessionId` is read via
`parsed["sessionId"] as string`.
- The function validates `action` and `waitReason`, but not `sessionId`.
### Expected behavior
Malformed waiting-human output should not produce a typed waiting-human movement
result with an undefined session id.
### Suggested fix
Require `typeof parsed["sessionId"] === "string"` and a non-empty value before
returning a waiting-human result.
### Acceptance criteria
- Add a test for malformed `waiting_human` output without `sessionId`.
- Add a test for valid `waiting_human` output.
- Existing interactive browse behavior remains unchanged for valid tool output.
---
## Issue 5: Split `tool-dispatcher.ts` before it becomes the next agent-loop
### Problem
`tool-dispatcher.ts` now owns several distinct responsibilities:
- cache routing
- cache hit/miss event logging
- tool execution
- memory checkpoint behavior
- waiting-human parsing
- batched result recording
This file is becoming the next high-coupling module after the agent-loop split.
### Expected behavior
Tool dispatch should be decomposed into focused modules so bugs in cache
routing, execution, and special tool outputs are easier to test independently.
### Suggested fix
Split along behavioral boundaries, for example:
- `tool-cache-routing.ts`
- `tool-execution.ts`
- `tool-result-recorder.ts`
- `interactive-browse-result.ts`
### Acceptance criteria
- No behavior change.
- Existing agent-loop and tool-loop tests pass.
- New modules expose narrow, testable functions.

View File

@ -0,0 +1,324 @@
# Improve task memory and conversation recall
## Summary
Long-running local tasks can lose early user instructions, constraints, and
decisions after several exchanges. The codebase already has partial mechanisms:
- `MissionUpdate` / Mission Brief pins `goal`, `done`, `open`, and
`clarifications`.
- `Conversation` persists `logs/transcript.jsonl` and can replay prior turns on
continuation.
- `buildLocalConversationContext()` injects recent task comments.
- `handoffContext` carries the previous piece result.
However, these do not give the agent a reliable way to preserve task state or
actively search older conversation history.
This issue proposes three related improvements:
1. Make Mission Brief a stronger task-state ledger.
2. Add agent-facing conversation search/read tools.
3. Update prompts so the agent proactively revisits prior conversation when
task context is ambiguous or long-running.
## Problem
In multi-turn tasks, the agent often appears to forget early conversation
details, especially:
- original user constraints
- decisions made after clarification
- "do not change X" instructions
- previous failed attempts
- which files were already inspected or changed
- why a plan was chosen
Current behavior is understandable from the implementation:
- `MissionUpdate` is optional and model-driven; it is not enforced at movement
boundaries.
- Mission Brief fields are too coarse for long implementation tasks.
- local task context only injects the recent conversation window.
- transcript replay is automatic context carry-over, not searchable retrieval.
- prompt compaction/summarization can remove or compress important details.
## Existing Mechanisms To Reuse
### Mission Brief
Source: `src/engine/tools/mission.ts`
Mission Brief is the best existing place for pinned task state because it is:
- per local task
- visible at the top of every movement prompt
- editable by the agent through `MissionUpdate`
- editable by the user in the UI
- intended to prevent long-conversation drift
This should be extended rather than replaced.
### Conversation transcript
Source: `src/engine/context/conversation.ts`
`logs/transcript.jsonl` is already written during execution and replayed on
continuation. It should remain the raw audit source. New search tools should
read from it instead of stuffing the whole transcript into prompt context.
### Local task comments
Source: `src/engine/local-context.ts`
Task comments are the user-facing conversation layer. They should be searchable
alongside transcript entries because user instructions often live in comments,
requests, and interjections.
## Proposed Design
### 1. Extend Mission Brief schema
Add fields that separate task facts from progress:
```ts
interface MissionBrief {
goal?: string;
user_constraints?: string;
decisions?: string;
done?: string;
open?: string;
current_focus?: string;
touched_files?: string;
risks?: string;
clarifications?: string;
last_updated_by_movement?: string;
}
```
Keep backwards compatibility with existing `goal`, `done`, `open`, and
`clarifications`.
### 2. Make movement-boundary updates explicit
At the end of each movement, encourage or enforce a Mission Brief update when
the movement made meaningful progress.
Suggested behavior:
- On movement start, render the current Mission Brief as today.
- During movement, the model can still call `MissionUpdate`.
- Before `transition` or `complete`, prompt guidance should require the agent to
update `done`, `open`, `current_focus`, and relevant `decisions` if stale.
- Optionally add a lightweight stale-check:
- if a movement used tools or edited files
- and no `MissionUpdate` occurred
- inject a reminder before accepting terminal control calls
Do not make this a hard blocker initially; use a reminder first to avoid
creating loops.
### 3. Add `SearchTaskConversation`
Add a META tool available to all local-task pieces.
Suggested signature:
```ts
SearchTaskConversation({
query: string;
source?: "comments" | "transcript" | "both";
author?: "user" | "agent" | "system";
kind?: "request" | "comment" | "interjection" | "result" | "ask" | "progress" | "handoff";
limit?: number;
})
```
Search sources:
- DB local task comments
- `runtimeDir/transcript.jsonl` when available
Return compact excerpts only:
```md
## Conversation Search Results
- comment:123 user/comment 2026-06-30T...
excerpt: ...
- transcript:42 user 2026-06-30T...
excerpt: ...
```
Constraints:
- cap result count
- cap excerpt length
- never return full transcript by default
- search only the current task's conversation
- respect existing task/space authorization boundaries
### 4. Add `ReadTaskConversation`
Add a companion tool to read context around a known hit.
Suggested signature:
```ts
ReadTaskConversation({
ref: "comment:123" | "transcript:42";
before?: number;
after?: number;
})
```
Return nearby entries with strict caps. This keeps search cheap and lets the
agent inspect the exact conversation around an old decision.
### 5. Wire conversation retrieval into Mission Brief refresh
Update prompt guidance for long tasks:
- If user constraints or decisions are unclear, use `SearchTaskConversation`
before asking the user.
- When updating `user_constraints` or `decisions`, prefer citing old comments or
transcript refs in the brief.
- Before asking the user a clarification, search the task conversation when the
missing information may already have been stated earlier.
- Before changing previously touched files or revising a prior decision, search
for older constraints/decisions related to the file or topic.
Example Mission Brief snippet:
```md
### User constraints
- Keep existing auth flow unchanged. Source: comment:17
- Do not rewrite unrelated UI. Source: transcript:42
### Decisions
- Extend Mission Brief instead of creating a second TaskState store. Source: comment:23
```
### 6. Add proactive recall guidance to system/movement prompts
The new tools should not be passive. Update prompt guidance so the agent
actively uses them when forgetting early conversation is likely.
Suggested rules:
- At the start of a follow-up task, review Mission Brief first.
- If Mission Brief is missing, stale, or too vague, call
`SearchTaskConversation` before proceeding with assumptions.
- If the task has multiple user turns or interjections, search conversation
history for constraints before making broad edits.
- If the agent is about to ask the user something, first search prior comments
and transcript for the answer unless the question is truly new.
- If a movement resumes after context compaction, search for relevant older
decisions before modifying files.
- When search results reveal durable constraints or decisions, update
Mission Brief immediately.
Candidate prompt copy:
```md
## Conversation recall
For long-running or follow-up tasks, do not rely only on the visible recent
context. If an earlier user constraint, decision, or clarification may affect
your next action, use SearchTaskConversation / ReadTaskConversation before
proceeding. Prefer retrieving prior context over asking the user to repeat it.
When you find durable task facts, update MissionUpdate so they remain pinned.
```
Keep this guidance concise so it does not crowd out movement-specific
instructions.
## Files Likely Involved
- `src/db/repository.ts`
- extend `MissionBrief`
- update parse/update tests
- add query helpers for task comments if needed
- `src/engine/tools/mission.ts`
- extend `MissionUpdate` schema
- clamp/render new fields
- tests for partial updates
- `src/engine/agent-loop/prompt.ts`
- render new Mission Brief fields
- update movement guidance around state refresh
- add concise proactive recall guidance for long/follow-up tasks
- `src/engine/agent-loop/watchdogs.ts`
- optionally add stale Mission Brief reminder logic
- optionally remind the agent to search conversation history when Mission Brief
is empty/stale during later iterations
- `src/engine/tools/index.ts`
- add new conversation tools to META_TOOLS
- `src/engine/tools/conversation.ts` or `src/engine/tools/task-conversation.ts`
- implement `SearchTaskConversation`
- implement `ReadTaskConversation`
- `src/engine/tools/core.ts`
- expose safe context fields needed by conversation tools, e.g. taskId,
runtimeDir, workspacePath, repo access if needed
- `src/worker.ts`
- ensure ToolContext has enough current-task context for the new tools
- `ui/src/components/detail/tabs/OverviewTab.tsx`
- expose new Mission Brief fields for user editing
- `ui/src/content/help/*.md`
- update help docs if behavior/UI changes
- `ui/src/content/help/00-changelog.md`
- add a user-facing changelog entry
## Acceptance Criteria
- Mission Brief supports new fields while preserving existing data.
- Existing tasks with old Mission Brief JSON still load correctly.
- `MissionUpdate` can update new fields independently.
- Mission Brief rendering keeps a bounded prompt size.
- Movement guidance encourages updating Mission Brief before transitions.
- Prompt guidance tells the agent to search prior conversation before asking the
user to repeat information or making assumptions in long/follow-up tasks.
- Prompt guidance tells the agent to search prior conversation before asking the
user to repeat information or making assumptions in long/follow-up tasks.
- `SearchTaskConversation` can find old user comments by keyword.
- `SearchTaskConversation` can find transcript entries when `transcript.jsonl`
exists.
- `ReadTaskConversation` can return bounded context around a search result.
- Tools are scoped to the current task and cannot read other task logs.
- Tests cover:
- old Mission Brief compatibility
- new Mission Brief fields
- conversation search over comments
- conversation search over transcript
- prompt text includes proactive recall guidance
- bounded output and excerpt truncation
- authorization/task scoping
## Non-goals
- Do not put task-specific state into `UpdateUserMemory`; that is user-wide
memory and would pollute future unrelated tasks.
- Do not inject the full transcript into every prompt.
- Do not replace `Conversation` replay; retrieval should complement it.
- Do not make Mission Brief update a hard terminal blocker until reminder-only
behavior has been observed.
## Suggested Implementation Order
1. Extend Mission Brief schema and UI rendering.
2. Extend `MissionUpdate` and prompt rendering.
3. Add reminder-only movement-boundary guidance.
4. Implement `SearchTaskConversation` for DB comments.
5. Add transcript search.
6. Add `ReadTaskConversation`.
7. Add proactive recall prompt guidance and tests.
8. Update help docs and changelog.

View File

@ -0,0 +1,229 @@
# Add workspace file provenance so agents can identify which task created or owns files
## Summary
Persistent workspaces make it harder to tell which task created, uploaded, or
modified a file. This is especially confusing when multiple tasks share the same
workspace tree. The agent may see a file and assume it belongs to the current
task, even though it was an input or output from a different task.
Add a file provenance ledger so both users and agents can answer:
- Which task created this file?
- Was this uploaded by the user or generated by an agent?
- Which task last modified it?
- Is it safe for the current task to edit, or should it be treated as read-only
context?
## Problem
In persistent workspace mode, files can outlive a single task. That is useful,
but it creates ambiguity:
- old input files remain visible to later tasks
- previous task outputs look like current task artifacts
- generated files and uploaded files are not clearly distinguished
- agents may edit or rely on files that belong to unrelated tasks
- users cannot easily audit why a file exists
Current workspace/runtime separation helps logs:
- `workspace_path` points to the shared files tree
- `runtime_dir` separates per-task logs/checklists/raw outputs
But shared workspace files themselves do not have provenance metadata.
## Proposed Design
Add a sidecar provenance ledger for files in persistent workspaces.
Prefer a DB-backed table for queryability, with optional JSONL export for
debugging. Do not write task tags into file contents.
### Schema
Suggested table:
```sql
CREATE TABLE workspace_file_provenance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
space_id TEXT,
workspace_path TEXT NOT NULL,
rel_path TEXT NOT NULL,
created_by_task_id INTEGER,
created_by_job_id TEXT,
created_by_piece TEXT,
created_by_movement TEXT,
source_kind TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
last_modified_by_task_id INTEGER,
last_modified_by_job_id TEXT,
last_modified_at TEXT,
checksum TEXT,
note TEXT,
UNIQUE(workspace_path, rel_path)
);
```
Suggested `source_kind` values:
- `user_input`
- `agent_output`
- `agent_edit`
- `bash_generated`
- `subtask_output`
- `imported_existing`
- `unknown`
### Recording Rules
Record provenance at these boundaries:
- UI upload to `input/`
- `source_kind=user_input`
- `created_by_task_id=current task`
- `Write`
- new file: `source_kind=agent_output`
- existing file: update `last_modified_by_task_id`
- `Edit`
- update `last_modified_by_task_id`
- keep original `created_by_task_id`
- `Bash`
- compare workspace file snapshot before/after command
- new files: `source_kind=bash_generated`
- changed files: update `last_modified_by_task_id`
- subtask outputs copied/visible to parent
- `source_kind=subtask_output`
- include parent task and subtask job id
- existing files in a persistent space before this feature ships
- backfill as `source_kind=imported_existing` or `unknown`
### Agent-Facing Tools
Add META tools:
```ts
GetFileProvenance({ path: string })
```
Returns a compact provenance record for one file.
```ts
ListWorkspaceFiles({
path?: string;
sourceKind?: string;
createdByTaskId?: number;
lastModifiedByTaskId?: number;
includeUnknown?: boolean;
})
```
Returns bounded file listings with provenance summaries.
### Prompt Guidance
Update the system prompt / workspace guidance so the agent uses provenance
before editing ambiguous files.
Candidate prompt copy:
```md
## Workspace file provenance
Persistent workspaces may contain files from older tasks. Before editing a file
whose provenance shows `source_kind=user_input` or `created_by_task_id` differs
from the current task, verify that it is relevant. Prefer creating a new output
file when unsure. Use GetFileProvenance / ListWorkspaceFiles to inspect file
origin.
```
### UI
Show provenance in the file browser / preview:
- created by task id/title
- source kind
- last modified by task id/title
- timestamp
This can be a small metadata row or tooltip; avoid cluttering the file list.
## Files Likely Involved
- `src/db/schema.sql`
- add provenance table
- `src/db/migrate.ts`
- migration for existing installations
- `src/db/repository.ts`
- read/write/query provenance records
- optional backfill helpers
- `src/engine/tools/core.ts`
- hook `Write`, `Edit`, and `Bash` file changes
- expose task/job/piece/movement context needed for provenance
- `src/engine/piece-runner.ts`
- thread task id, job id, piece, movement into `ToolContext`
- `src/engine/tools/index.ts`
- register provenance tools as META tools
- `src/engine/tools/file-provenance.ts`
- implement `GetFileProvenance`
- implement `ListWorkspaceFiles`
- `src/bridge/local-tasks-api.ts` / file APIs
- record UI uploads
- return provenance metadata with file listing/detail responses
- `ui/src/components/files/*`
- display provenance metadata
- `src/engine/agent-loop/prompt.ts`
- add concise workspace provenance guidance
- `ui/src/content/help/*.md`
- document how provenance labels work
- `ui/src/content/help/00-changelog.md`
- add user-facing changelog entry
## Acceptance Criteria
- New files created by `Write` record current task provenance.
- Files changed by `Edit` preserve original creator and update last modifier.
- Files created or changed by `Bash` are detected and recorded.
- User-uploaded input files are recorded as `user_input`.
- File listing or preview can show provenance metadata.
- Agent can call `GetFileProvenance` for a file.
- Agent can list files filtered by provenance.
- Prompt guidance tells the agent to avoid editing unrelated/user-input files
without checking relevance.
- Existing persistent workspace files remain usable after migration.
- Provenance output is bounded and does not dump large file contents.
## Non-goals
- Do not embed task tags into file contents.
- Do not make files from other tasks globally read-only; the agent may still
need to use shared workspace artifacts.
- Do not block all edits to `user_input` files immediately. Start with warnings
and guidance to avoid breaking existing workflows.
- Do not use `UpdateUserMemory` for per-file task metadata.
## Suggested Implementation Order
1. Add DB schema and repository helpers.
2. Record UI uploads and `Write`/`Edit` provenance.
3. Add `Bash` before/after file change detection.
4. Implement `GetFileProvenance`.
5. Implement `ListWorkspaceFiles` with filters and caps.
6. Add prompt guidance.
7. Surface metadata in the file browser/preview.
8. Update help docs and changelog.

View File

@ -0,0 +1,114 @@
# Add segmented screenshots to BrowseWeb
> **Status: Implemented (2026-07-03).** 設計との主な差分は下記「Implementation notes」を参照。
## Implementation notes (設計との差分)
- **既定を分割に変更**: 当初案は `screenshotSegments: true` のオプトインだったが、実際のユーザー意図は「スクショは既定で 1 画面ぶんずつ区切る」だったため、**BrowseWeb では分割を既定挙動**にした。フルページ 1 枚が欲しい場合は `screenshotSegments: false`(基本モード)/ アクションの `segments: false` でオプトアウトする。
- **短ページは連番なし**: 1 画面に収まるページは連番を付けず `output/<name>.png` の 1 枚のまま後方互換。2 画面ぶん以上のときだけ `-001` / `-002` の連番になる。
- **分割単位・撮影方式**: ビューポート高さ1 画面ぶん)を単位に、`fullPage: true` + `clip` でページを縦に切り出す。スクロールしないため、(1) `position:fixed`/`sticky` なヘッダーが各セグメント先頭に重複して本文を隠さない、(2) 撮影後にページのスクロール位置を変えないので後続アクションref/selector クリック等)に副作用が出ない。最後のセグメントは残り高さぶんだけ切り出し、下端に余白を作らない。
- **枚数の絶対上限**: `maxSegments` はユーザーLLM指定でも `ABSOLUTE_MAX_SEGMENTS`50を超えない。巨大な `scrollHeight` を返すページや極端な指定値による撮影ループの暴走worker 時間・ディスク枯渇を防ぐ最終防波堤。非有限値NaN/Infinityは既定値にフォールバック。
- **TestWorkspaceApp は現状維持**: 共通ハンドラ(`runPageActions`)の既定を分割にしたため、`TestWorkspaceApp` の screenshot アクションには `segments: false` を明示して単一フルページ撮影を維持(本 issue の scope 指示どおり)。
- **打ち切り通知**: `maxSegments`(既定 10で打ち切った場合は戻り値にその旨を出力無音打ち切りにしない
- 実装: `src/engine/tools/browser.ts``planScreenshotSegments` / `segmentFilename` / `captureScreenshots`)、テスト: `browser.screenshot-segments.test.ts`(純関数)・`browser.screenshot-capture.test.ts`(実ブラウザ、`CONTAINER=1` ゲート)。
## Summary
Long HTML pages are hard for agents to verify from a single screenshot. A full-page
capture can become extremely tall, causing image understanding to miss details or
compress important UI states. Viewport-only screenshots avoid giant images, but they
only show the first visible area.
Add a segmented screenshot mode to `BrowseWeb` so agents can capture a page as a
sequence of viewport-sized images.
## Problem
For generated HTML verification and UI review, agents often need to inspect the
whole rendered page. Current screenshot modes are not ideal:
- viewport screenshot: readable, but only captures one screen
- full-page screenshot: complete, but can be too tall and information-dense
This makes visual verification unreliable for long reports, dashboards, and other
HTML outputs.
## Proposed Design
Keep existing screenshot behavior and add an explicit segmented mode.
### Basic BrowseWeb mode
```js
BrowseWeb({
url: "output/report.html",
screenshot: "report.png",
screenshotSegments: true
})
```
Expected output files:
```text
output/report-001.png
output/report-002.png
output/report-003.png
```
### Action mode
```js
BrowseWeb({
actions: [
{ type: "goto", url: "output/report.html" },
{ type: "screenshot", value: "report.png", segments: true, maxSegments: 8 }
]
})
```
### Behavior
- Scroll the page from top to bottom and capture one viewport-sized screenshot
per segment.
- Use stable generated names based on the requested screenshot filename:
`name-001.ext`, `name-002.ext`, etc.
- Return the saved file list in the tool output.
- Default to a bounded number of segments, for example `maxSegments: 10`, to
avoid runaway captures on infinite-scroll pages.
- Allow callers to override the cap with `maxSegments`.
- Preserve existing modes:
- default screenshot remains a single viewport image
- full-page screenshot remains available via the explicit full-page option
- Do not change `TestWorkspaceApp` iframe screenshots unless a separate need is
identified.
## Files Likely Involved
- `src/engine/tools/browser.ts`
- extend `BrowseWebAction` with `segments?: boolean` and `maxSegments?: number`
- add basic-mode inputs `screenshotSegments` and `screenshotMaxSegments`
- implement shared segmented capture helper
- `src/engine/tools/browser.runpageactions.test.ts`
- add regression coverage for segmented screenshots on a tall local HTML page
- assert filenames and image heights/counts
- `docs/tools/browseweb.md`
- document segmented screenshot usage
- `ui/src/content/help/00-changelog.md`
- add a user-facing changelog entry when implemented
- `ui/src/content/help/16-tools.md`
- mention that browser screenshots can be captured as viewport, full-page, or
segmented images
## Acceptance Criteria
- `BrowseWeb({ url, screenshot, screenshotSegments: true })` saves multiple
viewport-sized screenshots for a tall page.
- Action-mode `screenshot` supports `segments: true`.
- The tool output lists all saved segment paths.
- Segment count is capped by default and configurable.
- Existing viewport and full-page screenshot behavior continues to work.
- Tests cover viewport, full-page, and segmented capture behavior.

View File

@ -54,29 +54,29 @@ grep -c "engine/tools/" src/bridge/tools-api.ts
## 2. 既存モジュールにツールを追加した場合
**対象ファイル:**
- `pieces/*.yaml` — 必要な piece の `allowed_tools` にツール名を追加
- `src/engine/tools/tool-categories.ts` — 新ツールを適切なカテゴリに割り当てるsensitive は既定 OFF。ツールの利用可否は workspace tool policy設定→ツールが決めるので、**piece 側に追加する必要はない**
- `CLAUDE.md` — 「ツールモジュール構成」テーブルの該当モジュール行に新ツール名を追加
- `docs/tools/{name}.md` — 新ツールの詳細ドキュメント(推奨)
- ツール `description` — 1 文 + 「詳細は ReadToolDoc({ name: "XXX" })」を末尾に記述
**なぜ必要か:**
`allowed_tools` に載っていないツールは LLM に提示されない。ツールを実装しても piece に追加しなければエージェントが使えない。
ツールの提示は workspace tool policy が決めるカテゴリ単位。sensitive 以外は既定 ON。カテゴリ未割り当てだとどのワークスペースでも出ない。
CLAUDE.md のテーブルが古いと、Claude Code 自身が既存ツールを認識せずに新規実装してしまうリスクがある。
ツール description は毎 LLM 呼び出しに乗るため 1 文に絞り、詳細は ReadToolDoc 経由で必要時のみ読み込むagent-loop が movement 開始時に description サマリを自動カタログ化する)。
**確認方法:**
新ツールが使われるべき piece を特定し、`allowed_tools` に含まれているか確認する。
`tool-categories.ts` で新ツールがカテゴリに割り当てられているか確認する。
CLAUDE.md のモジュールテーブルに新ツール名が含まれているか確認する。
**実例: TestWorkspaceApp2026-06-24:**
`src/engine/tools/app-test.ts` に追加 → `tools/index.ts``tryLoadModule` 追加 → `src/bridge/tools-api.ts` のモジュール一覧に追加 → `pieces/workspace-app.yaml` の verify movement `allowed_tools` に追加`src/engine/tools/docs.ts``TOOL_DOC_ALIASES``testworkspaceapp: 'testworkspaceapp'` を追加 → `docs/tools/testworkspaceapp.md` を新規作成。
`src/engine/tools/app-test.ts` に追加 → `tools/index.ts``tryLoadModule` 追加 → `src/bridge/tools-api.ts` のモジュール一覧に追加 → `tool-categories.ts` でカテゴリ割り当て`src/engine/tools/docs.ts``TOOL_DOC_ALIASES``testworkspaceapp: 'testworkspaceapp'` を追加 → `docs/tools/testworkspaceapp.md` を新規作成。旧手順の「piece の allowed_tools に追加」は撤去済み)
---
## 3. ツールをリネーム・削除した場合
**対象ファイル:**
- `pieces/*.yaml` — 全 piece の `allowed_tools` から旧名を削除/リネーム
- `src/engine/tools/tool-categories.ts` — カテゴリ定義・`SENSITIVE_TOOLS` から旧名を削除/リネーム
- `src/engine/tools/raw-save.ts``RAW_SAVE_TOOLS` に旧名が残っていないか
- `ui/src/components/settings/ToolsForm.tsx` — ヘルプテキスト等にツール名の言及がないか
- `CLAUDE.md` — ツールモジュール構成テーブル
@ -205,7 +205,7 @@ grep -A 20 'LEGACY_SECTION_REDIRECT' ui/src/components/settings/SettingsSidebar.
**なぜ必要か:**
Tool description は毎回 LLM のコンテキストに乗るため肥大化させたくない。詳細手順・ワークフロー例は `docs/tools/{name}.md` に置き、`ReadToolDoc` ツールで必要時に取得する設計。
ReadToolDoc は META_TOOLS として常時利用可能なので、piece の `allowed_tools` に追加する必要はない
ReadToolDoc は META_TOOLS として常時利用可能workspace tool policy でも常時 ON
関連ツールCheckItem / CreateChecklist / GetChecklist 等は1つの doc にまとめてエイリアス経由で引けるようにする。
**確認方法:**
@ -465,23 +465,26 @@ grep -n "BEGIN\|COMMIT\|prepare(" src/ssh/*.ts | head -30
- ssh-types.ts と API レスポンス shape (`SshConnection`, `SshGrant`, `SshAuditRow`) が一致しているか
- 禁止フォントサイズ (`text-[11px]` 等) を導入していないか — 既存セクションの「UI フォントサイズスケール」参照
### 12-E. piece schema (`allowed_ssh_connections`) を変更したとき
### 12-E. SSH 接続の認可 (workspace connection scoping) を変更したとき
SSH 接続の認可は **piece ではなくワークスペース**で決まる設定→SSH の登録接続)。
piece の `allowed_ssh_connections` は撤去済み。接続スコープは worker が解決して
`workspaceSshConnections` として piece-runner に渡す。
**対象ファイル:**
- `src/engine/piece-runner.ts``allowed_ssh_connections` の lint (validateMovement)
- `src/engine/types.ts` (or piece schema 定義箇所) — `allowed_ssh_connections?: string[]`
- `pieces/*.yaml` — SSH ツールを `allowed_tools` に含む movement は **必ず** `allowed_ssh_connections` 宣言が必要 (空配列 `[]` でも可)
- `docs/ssh.md` §"Per-piece `allowed_ssh_connections`"
- `src/worker.ts` — `parsedPolicy.enabledSensitive``ssh` があるとき `listBySpace(spaceId)` で接続 ID を解決(`workspaceSshConnections`
- `src/engine/piece-runner.ts` — `workspaceSshConnections ?? []``ctx.allowedSshConnections` / `movement.allowedSshConnections` に流す
- `src/engine/tools/ssh.ts` — preflight で `ctx.allowedSshConnections` に対して接続 ID を検証
- `docs/ssh.md` §"接続の認可"
**lint 規約:**
- `allowed_tools` に SSH ツール名が含まれる場合、`allowed_ssh_connections` の宣言が必須 (`undefined` は reject)
- 値は配列、各要素は `*` または lowercase hex + ハイフン UUID (≥ 8 chars)
- 空配列 `[]` は "deny all" として明示扱い
**規約:**
- 接続が 1 つも登録されていない(空配列)→ SSH ツールはクリーンに reject
- `'*'` ワイルドカードは worker からは渡さない(常に明示 ID リスト = クロススペース分離保証)
**確認方法:**
```bash
# 既存 piece に SSH 使用宣言があるか
grep -l 'Ssh\(Exec\|Upload\|Download\)' pieces/*.yaml | xargs -I {} grep -l 'allowed_ssh_connections' {}
# worker が ssh 接続を解決している経路
grep -n 'workspaceSshConnections' src/worker.ts src/engine/piece-runner.ts
```
### 12-F. config.yaml の SSH セクション (`SshRuntimeConfig`) を変更したとき
@ -517,7 +520,7 @@ SSH Console は SshExec/Upload/Download とは別系統の対話的 PTY 経路
## 13. Scheduler から呼ばない手動オペレーション endpoint
以下の endpoint は **UI からの手動操作専用** で、scheduler / Routine / 自動化経路から起動できない設計になっている。Routine 側の payload schema にこれらの override を追加してはいけない (scheduled task の `allowed_tools` 境界が想定外に拡大するリスクのため)。
以下の endpoint は **UI からの手動操作専用** で、scheduler / Routine / 自動化経路から起動できない設計になっている。Routine 側の payload schema にこれらの override を追加してはいけない (scheduled task のツール境界〈ワークスペースのツールポリシーで決まる〉が想定外に拡大するリスクのため)。
- `POST /api/local/tasks/:id/continue` — 別 piece で task を続ける (handoff)
- 実装: `src/bridge/local-tasks-api.ts``/continue` ハンドラ
@ -557,8 +560,67 @@ bwrap のマウント構成を変えた場合、セキュリティ境界が変
---
## 15. A2A 認可サーバー (`src/bridge/a2a/`)
外部エージェント連携A2Aの OAuth2 認可サーバー関連を変更するときに連動が必要な箇所。
### DB テーブル・スキーマを変更するとき
- `src/db/schema.sql``CREATE TABLE``oidc_models` / `a2a_clients`)を更新(初期スキーマ)
- `src/db/migrate.ts` に冪等 `ALTER TABLE ADD COLUMN` を追加(既存 DB 用)。**dual-path 必須**(どちらか片方だけ更新するとテストが大量に落ちる)
- `src/db/repository.a2a.ts` / `src/bridge/a2a/oidc-adapter.ts` のクエリを合わせて修正
### `config.yaml``a2a` セクションを変更するとき
- YAML キーはスネークケース (`client_ttl_seconds`)、コード内はキャメルケース (`clientTtlSeconds`)
- `src/config.ts``transformKeys` が自動変換するため、読み取り側では キャメルケースのみ使う
- `config.yaml.example` にコメント付きで追記する
- `A2aConfig` interface`src/config.ts`)に対応フィールドを追加
### A2A ルーターを変更するとき
- `createConsentRouter` / `createA2aClientsAdminRouter` / `mountA2aOidc` はいずれも `src/bridge/server.ts` でマウントされる。新しい router を追加した場合は `server.ts` の配線を忘れずに
- 管理者専用エンドポイントは `requireAdmin` ミドルウェアを必ず通すこと
- ユーザー向け同意エンドポイントは `requireAuth` を通し、操作対象の `sub`subjectをセッションの userId と照合する
### A2A 公開スキル(スペース許可リスト)を変更するとき
- 公開スキルは `spaces.a2a_skills`JSON 配列カラム)に保存される。スキーマ変更時は `schema.sql``migrate.ts` の**両方**dual-path 必須)を更新すること
- 委任は `a2a_delegations` テーブルで管理し、`grant_id` で OAuth grant`oidc_models`)にリンクする。`grant_id` の整合性は外部キーではなくアプリ層で保証しているため、grant 削除時は対応する delegation も削除すること
- 委任フィルタは **`buildVisibilityWhere` の OR 句に追加しない**。タスク/ジョブの公開スコープprivate/org/publicとは独立したフィルタとして AND 交差で適用することOR に混ぜると cross-space IDOR の原因になる)
- Agent Card の公開エンドポイント(`GET /.well-known/agent.json`)とスペースオーナー向け API公開スキル設定 PATCHは、いずれも `a2a.enabled` ゲートを通過した場合のみ有効。ゲートを外したルートを追加しないこと
### A2A スキル実行executorを変更するとき
- `a2a_tasks` テーブルは `schema.sql``migrate.ts` の**両方**dual-path 必須)を更新すること
- executor は `createLocalTask``createJob` の順に呼ぶ。`createLocalTask` 単体はジョブをエンキューしない(`createJob` で初めて Worker がポーリングで拾える状態になる)
- ジョブ完了の検知はポーリング(`a2a_tasks``status` カラムを定期参照)で行い、進捗は SSE で外部エージェントに返す
- 実行完了後、`output/` 以下のファイルは A2A Artifact として返却する
- 委任スコープは executor でも `computeEffectiveScope` を通じて再強制する。トークンの委任スコープがスペースの公開スキル設定を上回ることは許容しないfail-closed
- `buildVisibilityWhere` の OR 句に委任条件を追加しないことAND 交差で適用する)
### 非ブロッキング送信 + reconciler`reconciler.ts` / `task-finalize.ts`)を変更するとき
- **非ブロッキング契約**: `message/send``params.configuration.blocking: false` は SDK ネイティブ機能。executor が初期 Task`submitted`)を publish した時点で SDK が即座にレスポンスを返す(完了を待たない)。外部エージェントは後から `tasks/get``params.id`で最新状態を取得する。executor 自体を非ブロッキング化する改造は不要SDK 側で完結)。
- **reconciler は単一起動**: `A2aTaskReconciler``server.ts``a2a.enabled` ゲート内から 1 インスタンスだけ `start()` する(多重起動は state churn の原因)。テストでは `start()` の interval を使わず `reconcileOnce()` を明示的に呼んで決定論化すること。
- **DB が source of truth**: reconciler は `a2a_tasks`(非 terminal 行)を `listNonTerminalA2aTasks` で走査し、リンク先の MAESTRO ジョブ状態(`getJob`と突き合わせて収束させる。live executor が切断・再起動で消えても、reconciler だけでタスクを terminal 化できる再起動耐性。in-memory の per-task detached promise には依存しない。
- **終端化ロジックは共有**: `enumerateOutputArtifacts` / `finalizeStatusFromJob` / `TERMINAL_JOB_STATUSES``task-finalize.ts`)を executor と reconciler の**両方**が使う。ジョブ状態 → A2A state の写像や artifact 列挙を変えるときは、この共有ヘルパだけを直せば両経路に反映される(ロジックを片方に複製しないこと)。
- **冪等な状態一致スキップ**: 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 箇所を揃えること。
**確認方法:**
```bash
# A2A 関連テスト一括実行
npx vitest run src/bridge/a2a/ src/db/migrate.test.ts src/db/repository.a2a.test.ts src/db/repository.a2a-deleg.test.ts
# router のマウント漏れを確認
grep -n "mountA2aOidc\|createA2aClients\|createConsent" src/bridge/server.ts
# buildVisibilityWhere に委任条件が混入していないか確認
grep -n "a2a_delegations\|a2a_skills" src/db/repository.ts
```
---
## 自動検知の可能性
- **ツールモジュール登録漏れ**: `index.ts``tools-api.ts` のモジュール一覧を比較するスクリプトで CI チェック可能
- **piece の allowed_tools 不整合**: 全 piece の `allowed_tools` に含まれるツール名が実際の `TOOL_DEFS` に存在するか検証するスクリプトで CI チェック可能
- **code-review-graph**: `importers_of` で各ツールモジュールの参照元を列挙できるが、「tools-api.ts にも登録すべき」というルールの自動適用は困難。変更時の `detect_changes` + `get_impact_radius` で影響範囲の見落としを防ぐ用途が現実的

View File

@ -2,8 +2,10 @@
The orchestrator can call tools hosted on external **MCP servers** (OAuth-secured
SaaS like Canva, or self-hosted servers with static API keys). Connected MCP
tools are exposed to pieces via `mcp__<server>__<tool>` names, and can be
allowlisted with `mcp__<server>__*` wildcards in `piece.allowed_tools`.
tools surface to tasks as `mcp__<server>__<tool>` names. Availability is
governed by connecting a server to the workspace (per-space), not by any piece
allowlist — the workspace tool policy always carries the `mcp__*` pattern and
the aggregator scopes results to the job's space and a valid token.
This document is the **operator runbook** for setting up, troubleshooting, and
maintaining MCP integrations. For internal design notes, see
@ -107,29 +109,24 @@ sections — global at top (admin only), user's own below.
4. If using a private IP, ensure `mcp.allow_private_addresses: true` is set
(see Prerequisites).
## How tools flow into pieces
## How tools flow into tasks
The orchestrator caches `tools/list` results in `mcp_server_tools`, refreshed
on registration and on explicit admin refresh (no automatic TTL today). Piece
authors expose them via `allowed_tools`:
on registration and on explicit admin refresh (no automatic TTL today). There
is no per-piece allowlist: the workspace tool policy always carries the
`mcp__*` pattern, so every tool from a server connected to the job's space is
offered automatically. The aggregator scopes results to that space and a valid
token, so a task only ever sees tools from servers connected to its own
workspace.
```yaml
movements:
- name: design
allowed_tools:
- Read
- Write
- mcp__canva__* # all tools from server `canva`
- mcp__my-tools__lint # a specific tool from `my-tools`
```
The wildcard `mcp__<server>__*` expands to all currently-cached tools for that
server.
To expose a server's tools, connect the server to the workspace (**Settings →
MCP**). To *require* one, list it in the piece's `required_mcp` frontmatter —
that only controls job parking when no connection exists (see below).
## Job parking and resume
When a piece requires an MCP server (via `required_mcp` frontmatter or
discovered from `allowed_tools`) and the user has no connection, the worker
When a piece requires an MCP server (via `required_mcp` frontmatter) and the
user has no connection, the worker
parks the job:
- `jobs.status = 'waiting_human'`

View File

@ -6,13 +6,13 @@
| | Piece | Skill |
|---|-------|-------|
| 役割 | **実行テンプレート**ツール権限・遷移フローを制御 | **参照知識** — 手順・規約・ガイドを提供 |
| 役割 | **実行テンプレート**movement の手順・遷移フローを制御 | **参照知識** — 手順・規約・ガイドを提供 |
| フォーマット | YAMLMAESTRO 固有) | MarkdownClaude Code / Codex 互換) |
| 選択 | piece-classifier が自動選択 | エージェントが ReadSkill で任意に参照 |
| 実行制御 | `allowed_tools` でツールを制限 | 制御なし(エージェントの判断に委ねる) |
| 実行制御 | movement の遷移フローを定義(ツール可否はワークスペースのツールポリシー側) | 制御なし(エージェントの判断に委ねる) |
**判断基準:**
- ツールの許可・禁止や movement フローを定義したい → **Piece**
- movement の手順・遷移フローを定義したい → **Piece**
- 手順書・コーディング規約・デバッグガイドをエージェントに参照させたい → **Skill**
## スキルの構造

View File

@ -141,23 +141,9 @@ Then in the UI:
3. Optionally set `remote_path_prefix` (default `/`) — restricts upload/download paths
4. Click **Test** → first call returns `host_key_first_observe` with a fingerprint
5. **Verify** in the dialog (compare fingerprint with what you expect from `ssh-keyscan <host>`)
6. Add the connection's UUID to a piece's `allowed_ssh_connections`:
6. Enable the `ssh` tool category for the workspace in **Settings → Tools**. Once enabled, every connection registered to that workspace becomes reachable from any task in it — there is no per-piece opt-in and no piece YAML to edit.
```yaml
# pieces/example.yaml
name: ssh-example
movements:
- name: deploy
allowed_tools: [SshExec, SshUpload]
allowed_ssh_connections: ["abcd1234-..."]
rules:
- condition: done
next: COMPLETE
instruction: |
Use SshExec to ...
```
7. Test the piece via the normal task UI.
7. Test via the normal task UI. The worker resolves the workspace's SSH connections once per job (`connectionRepo.listBySpace(spaceId)`) and exposes that exact set to every movement.
## `config.yaml` Reference
@ -361,60 +347,31 @@ an allowlist. SHA1-RSA and other weak algorithms are rejected before
the key is recorded (`host_key_alg_not_allowed`). This is hard-coded
in `src/ssh/session.ts` to avoid misconfiguration.
## Per-piece `allowed_ssh_connections`
## Workspace-scoped SSH connections
A piece's movement must explicitly opt in to SSH usage. The
piece-runner enforces three invariants:
SSH reachability is a **workspace** property, not a piece property. The
per-piece `allowed_ssh_connections` field was removed in the tool-consolidation
work; if it still appears on a submitted piece it is tolerated but ignored.
1. If a movement's `allowed_tools` contains any SSH tool name
(`SshExec`/`SshUpload`/`SshDownload`), `allowed_ssh_connections`
**must be declared** on that movement (even if empty)
2. The field must be an array of strings
3. Each entry must be `*` or a lowercase hex+hyphen UUID (≥ 8 chars)
The gate now works like this:
Lint failures abort piece load.
1. The workspace's tool policy must enable the `ssh` category
(**Settings → Tools**). If it does not, the SSH tools are not offered to any
movement.
2. When `ssh` is enabled, the worker resolves the connections registered to the
job's space (`connectionRepo.listBySpace(spaceId)`) once per job and applies
that set uniformly to every movement. This space-scoped list is the **sole**
connection gate.
3. The list is always an explicit set of connection IDs — never a `*` wildcard —
so a job can only ever reach connections registered in its own workspace.
Cross-space isolation is guaranteed.
4. The [access grant check](#access-grants) still applies on top: a user without
a grant for a given connection cannot use it even when the workspace exposes
it.
### Forms
```yaml
# Explicit allowlist (most common)
allowed_ssh_connections: ["abcd1234-...", "ef567890-..."]
# Wildcard (admin-style — use sparingly)
allowed_ssh_connections: ["*"]
# Deny-all (still allows SSH tool in allowed_tools but refuses every UUID)
allowed_ssh_connections: []
```
The `*` form skips the per-piece check but **does not** skip the
[access grant check](#access-grants). A user without a grant for a
given connection still cannot use it even when the piece says `*`.
### Example
```yaml
name: backup-rotation
description: Daily backup rotation on prod servers
movements:
- name: list
allowed_tools: [SshExec]
allowed_ssh_connections: ["abcd1234-...", "ef567890-..."]
instruction: |
List the existing backup files on each server.
rules:
- condition: ready to rotate
next: rotate
- name: rotate
allowed_tools: [SshExec, SshUpload]
allowed_ssh_connections: ["abcd1234-...", "ef567890-..."]
instruction: |
Rotate the oldest backup ...
rules:
- condition: done
next: COMPLETE
```
If SSH is enabled but the SSH subsystem is not initialised (e.g. a test
environment without the bridge), the resolved list is empty and every SSH call
is rejected (fail-closed).
## Access Grants
@ -870,7 +827,7 @@ curl -X DELETE 'http://localhost:3000/api/ssh/admin/audit?older_than_days=90'
| `access denied (no_grant)` | User lacks grant for connection | Admin creates a grant, or user uses an owned connection |
| `access denied (disabled)` | Admin disabled the connection | Admin re-enables, or use different connection |
| `access denied (abuse_locked)` | Counter triggered | Wait for lock window, or admin force-unlocks |
| `piece "X" does not list connection Y` | `allowed_ssh_connections` missing UUID | Add UUID to the movement's `allowed_ssh_connections` |
| `connection Y is not registered to this workspace` | Connection not registered to the job's workspace | Register it under **Settings → SSH** and enable the `ssh` category in **Settings → Tools** |
| `host_key_first_observe` | First time exercising connection | Verify fingerprint in UI |
| `host_key_not_verified` | Key recorded but never verified | Click Verify in UI |
| `host_key_mismatch` | Server key changed | Investigate (legitimate rotation? MITM?), then Replace via UI |

105
docs/systemd-autostart.md Normal file
View File

@ -0,0 +1,105 @@
# OS 起動時の自動起動systemd
MAESTRO を systemd サービスとして登録し、マシンの起動時に自動で立ち上げる手順。
プロセス監督・異常終了時の自動再起動・ログ集約journaldも systemd が担う。
## 前提と役割分担
- **`scripts/server.sh`** … ビルド・手動運用・開発用。`start` は毎回ビルドしてから
`dist/main.js` を起動する。
- **systemd サービス** … boot 自動起動と本番のプロセス監督用。ビルドはしない
`node dist/main.js` を直接監督する)。
> **同時起動しないこと**`server.sh start` が動かしているプロセスと systemd の
> サービスを両方立てると、同じ DB に対して二重起動になる(`instance-lock` が最悪の
> 破損は防ぐが、正しくない状態。systemd に任せると決めたら、運用中の起動・停止は
> `systemctl start/stop` に統一し、`server.sh start` は使わない。
## インストール
まずビルドして `dist/` を最新にする(**root では実行しない**。アプリを動かすユーザーで)。
```bash
scripts/server.sh start # ビルド+起動を確認。この後 stop して systemd に委ねる
scripts/server.sh stop
```
生成されるユニットを先に確認したいときは `--print`(何もインストールしない)。
```bash
scripts/install-systemd.sh --print
```
### user サービス(既定・推奨)
root 不要。スクリプトを叩いたユーザー自身のサービスとして動く。引数なしがこれ。
```bash
scripts/install-systemd.sh
```
やっていること: `deploy/maestro.service`(テンプレート)を現在のチェックアウトから
埋めて `~/.config/systemd/user/maestro.service` に配置し、`systemctl --user enable
--now` する。ユニットに `User=` は付かない(起動ユーザーで動く)。
boot 時にログインなしで動かすため **linger** を有効化する(インストーラが自動で試す)。
自分のユーザーの linger は通常 root なしで有効化できる。ポリシー上できなかった場合だけ
警告が出るので、そのときは一度 `sudo loginctl enable-linger <user>` を実行する。
```bash
systemctl --user status maestro # 状態
journalctl --user -u maestro -f # ログ
systemctl --user restart maestro # 再起動(ビルドし直した後など)
```
### system サービスマシン全体・root が使える場合)
ログインユーザーに依らずマシン起動時に立ち上げたいとき。インストールに sudo が要る。
```bash
scripts/install-systemd.sh --mode system --run-as <app-user>
```
`/etc/systemd/system/maestro.service` に配置し、`User=<app-user>` で動く。
状態・ログは `systemctl status maestro` / `journalctl -u maestro -f`
## よく使うオプション
| オプション | 意味 |
|---|---|
| `--print` / `--dry-run` | 生成されるユニットを表示するだけ。インストールしない |
| `--mode system` | マシン全体の system サービスとして入れる(既定は `user` |
| `--run-as <user>` | system モードのアプリ実行ユーザー |
| `--name <n>` | サービス名(既定 `maestro`)。複数インスタンス用 |
| `--no-start` | boot 用に enable するが、いますぐ起動はしない |
## デプロイ後の更新
新しいコードを反映するときuser サービスの場合):
```bash
git pull
npm run build # または scripts/server.sh を使わず build のみ
systemctl --user restart maestro # system サービスなら sudo systemctl restart maestro
```
## 設定・環境変数
- ポートやモードなどはリポジトリ内 `config.yaml` を読む(アプリが自前で読み込む)。
- `.env` があれば `EnvironmentFile` として読み込む(任意)。ただし systemd の
`.env` 解釈は素朴な `KEY=value` 前提。`server.sh` が対応している凝ったクォートは
効かないので、systemd 経由で使う値は単純な形式にすること。
- 既定の実行モードは `AAO_MODE=worker`フルオーケストレータ。gateway として
動かすなら `.env``AAO_MODE=gateway` を書く。
## アンインストール
```bash
# system
sudo systemctl disable --now maestro
sudo rm /etc/systemd/system/maestro.service && sudo systemctl daemon-reload
# user
systemctl --user disable --now maestro
rm ~/.config/systemd/user/maestro.service && systemctl --user daemon-reload
```

View File

@ -31,7 +31,7 @@
## なぜインストールが禁止か
- ジョブ実行環境はサンドボックス化されており、永続化されない
- 必要な機能は専用ツール(`allowed_tools` に列挙されたもの)で提供される
- 必要な機能は専用ツール(ワークスペースのツール設定で許可されたもの)で提供される
- インストールが必要 = ツールの設計が足りないので、ユーザーに報告して機能追加を依頼する
## 代替

View File

@ -27,7 +27,9 @@ BrowseWeb({ url: "output/viewer.html" })
オプション:
- `waitFor`: 待機する CSS セレクタ(省略時は load イベント完了まで待機)
- `extractSelector`: 特定要素のテキストだけ抽出する CSS セレクタ
- `screenshot`: スクリーンショットを保存するファイル名(例: `"page.png"``output/page.png`
- `screenshot`: スクリーンショットを保存するファイル名(例: `"page.png"``output/page.png`)。縦長ページは既定で分割保存(下記)
- `screenshotSegments`: `false` で分割せずフルページ 1 枚(既定: 分割あり)
- `screenshotMaxSegments`: 分割の最大枚数(既定 10
- `timeout`: タイムアウトms、デフォルト 60000
### 2. アクションモード — 連続操作
@ -47,11 +49,29 @@ BrowseWeb({
- `goto``url` で指定したページに遷移
- `click``selector` または `ref` で要素をクリック
- `fill``selector` または `ref` の input/textarea に `value` を入力
- `screenshot``value` で指定したファイル名で保存(省略時 `screenshot.png`
- `screenshot``value` で指定したファイル名で保存(省略時 `screenshot.png`。縦長ページは既定で分割保存(下記)。`segments: false` でフルページ 1 枚、`maxSegments` で枚数上限を調整
- `getText` — 全ページのスナップショットref 注釈付き)または `selector` 内のテキストを取得
- `wait``ms` ミリ秒待機(最大 30000
- `dumpHtml``ref` または `selector`(省略時 bodyの outerHTML を取得(脱出口、後述)
### スクリーンショットの分割保存
縦長ページを 1 枚で撮ると画像が極端に縦長になり、細部が潰れて読み取りづらくなる。BrowseWeb は既定で、ページを **1 画面ぶん(ビューポート高さ)ごとに区切って複数枚**に分割保存する。
- 1 画面に収まるページはそのまま `output/page.png` の 1 枚(連番なし)
- 2 画面ぶん以上あるページは `output/page-001.png`, `output/page-002.png` … と連番で保存し、戻り値に全ファイルを列挙する
- 無限スクロールなどの暴走を防ぐため、既定で最大 10 枚まで(`screenshotMaxSegments` / アクションの `maxSegments` で変更)。上限で打ち切った場合は戻り値にその旨を表示する
- 分割せずフルページ 1 枚が欲しいときは `screenshotSegments: false`(基本モード)またはアクションの `segments: false`
```js
// 縦長レポートを 1 画面ぶんずつ分割して撮る(既定)
BrowseWeb({ url: "output/report.html", screenshot: "report.png" })
// → output/report-001.png, output/report-002.png, ...
// フルページ 1 枚で撮りたいとき
BrowseWeb({ url: "output/report.html", screenshot: "report.png", screenshotSegments: false })
```
## 長文ページの取得preview + ファイル保存)
`getText` (selector 有無問わず) およびスナップショットの戻り値が **5000 文字を超える** 場合、フルテキストはワークスペースの `logs/browse/{ISO-timestamp}-{hash}.txt` に保存され、戻り値は **先頭 5000 文字 + 続きの取得方法案内** になる:

View File

@ -0,0 +1,50 @@
# GetFileProvenance / ListWorkspaceFiles
Workspace file provenance tools. In a persistent (shared) workspace, files can
outlive a single task: an old task's inputs and outputs stay visible to later
tasks. These tools let you tell WHO a file belongs to before you edit or rely on
it.
Both are META tools — always available regardless of the workspace tool policy.
## When to use
- Before editing a file whose relevance to the current task is unclear.
- When you see a file in `input/` or `output/` that you did not create this run.
- To find which files the current task actually produced vs. inherited.
## GetFileProvenance({ path })
Returns a compact record for one file (workspace-relative path, e.g.
`output/report.md`):
- `source_kind` — one of `user_input`, `agent_output`, `agent_edit`,
`bash_generated`, `subtask_output`, `imported_existing`, `unknown`.
- `created_by_task_id` — the task that first created/uploaded the file.
- `created_by_piece` / movement — the piece + movement that created it.
- `last_modified_by_task_id` + `last_modified_at`.
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? })
Returns a bounded listing (default 50, max 200 rows) of known files with a
one-line provenance summary each. It never returns file contents.
Filters:
- `path` — path prefix, e.g. `output/`.
- `sourceKind` — filter by source kind.
- `createdByTaskId` / `lastModifiedByTaskId` — filter by task.
- `includeUnknown` — set `false` to hide `unknown` / `imported_existing` files.
## Guidance
- Do NOT blindly edit files whose `source_kind` is `user_input` or whose
`created_by_task_id` differs from the current task. Prefer creating a new file
under `output/`.
- These tools report task/job IDs only — never task titles or user identities.
- The tools are scoped to the current run's workspace; you cannot inspect other
workspaces.

View File

@ -34,7 +34,6 @@ movements:
- name: gather
persona: ...
instruction: ...
allowed_tools: [Read, Write, ...]
rules:
- condition: ...
next: ...

View File

@ -50,4 +50,4 @@ RunUserScript({ name: 'foo', params: { date: '2026-05-01' } })
- Scripts without a frontmatter block are listed with an empty description and no params.
- A parse error in one script is reported inline for that entry; other scripts are still listed.
- The tool is a META_TOOL — no need to add it to `allowed_tools` in piece YAML.
- The tool is a META_TOOL — always available regardless of the workspace tool policy.

View File

@ -1,6 +1,6 @@
# MissionUpdate
タスクの **Mission Brief**`goal` / `done` / `open` / `clarifications`)を更新するメタツール。`allowed_tools` に書かなくても常時利用可能META_TOOL
タスクの **Mission Brief**`goal` / `done` / `open` / `clarifications` / `user_constraints` / `decisions` / `current_focus`)を更新するメタツール。ワークスペースのツール設定に関係なく常時利用可能META_TOOL
Mission Brief は毎 movement のシステムプロンプト冒頭に常に描画され、会話が長くなった後やステップをまたいでも消えない「参照点」になる。ユーザーも Overview タブから直接編集できる。
@ -18,8 +18,11 @@ Mission Brief は毎 movement のシステムプロンプト冒頭に常に描
| `done` | これまでに完了した主要マイルストーン。箇条書き推奨 |
| `open` | 残っている作業・未解決のブロッカー。箇条書き推奨 |
| `clarifications` | ユーザーから追加された補足・制約。Markdown 可 |
| `user_constraints` | ユーザーが明示した恒久的な制約「X は変えないで」「認証フローは維持」等)。可能なら `comment:N` / `transcript:N` を出典として添える |
| `decisions` | 検討の末に確定した設計判断とその理由。後で蒸し返さないための記録 |
| `current_focus` | いま取り組んでいる作業の焦点。movement をまたいで現在地を見失わないため |
すべて任意だが、最低1つは指定する必要がある全フィールド未指定はエラー
すべて任意だが、最低1つは指定する必要がある全フィールド未指定はエラー`user_constraints` / `decisions``SearchTaskConversation` で掘り起こした古い制約・判断を pin しておく置き場所に向く。
## 部分置換セマンティクス

109
docs/tools/read.md Normal file
View File

@ -0,0 +1,109 @@
# Read
テキストでもドキュメントでも、あらゆるファイルを 1 つの `Read` で読む。拡張子から
フォーマットを自動判定し、Office / PDF / メールは内部の抽出ハンドラへ委譲する。
以前は `ReadExcel` / `ReadDocx` / `ReadPdf` / `ReadPPTX` / `ReadMsg` が独立ツールとして
存在したが、「どのリーダーを選ぶか」で誤りが起きやすかったため `Read` に統合した。
今はどのファイルでも `Read({ file_path })` を呼べばよい。
> 画像ファイル(.png / .jpg / .gif / .webp / .bmp / .tif 等)は **Read では開けない**
> VLM で内容を見る `ReadImage` を使う(意味的に別物のため統合していない)。
## フォーマット自動判定
| 拡張子 | 委譲先の抽出 | 返るもの |
|--------|--------------|----------|
| `.txt` / `.md` / `.csv` / コード等のテキスト | Read 本体(行 / バイト読み) | 生テキスト |
| `.xlsx` / `.xlsm` | Excel 抽出 | シートのセル値(+任意で装飾) |
| `.docx` | Word 抽出 | 本文+表 |
| `.pdf` | PDF 抽出 | ページ単位テキスト(+任意で検索) |
| `.pptx` | PowerPoint 抽出 | スライドのテキスト・表・ノート |
| `.msg` | Outlook メール抽出 | 件名 / 送受信者 / 本文、添付は `input/` に保存 |
> 旧バイナリ Office`.xls` / `.doc` / `.ppt` の CFB 形式)は**直接抽出できない**。
> Read すると「`.xlsx` / `.docx` / `.pptx` として保存し直してください」と案内されるので、
> 変換してから読む(`.xlsm` / `.docm` / `.pptm` のマクロ有効形式は読める)。
不透明なバイナリ(.zip / .exe / .db / 音声 / 動画 等)や、先頭バイトがバイナリと判定
されたファイルは拒否するLLM コンテキスト破壊を防ぐため)。
## 共通パラメータinline schema
| パラメータ | 説明 |
|-----------|------|
| `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` ではなくこれを使う |
大きすぎる出力は残コンテキスト予算に収まるよう自動で切り詰められ、続きの読み方が
注記される。
> **PDF/Office をずらして読むとき**: `offset` / `limit` / `byte_*` は**テキスト専用**で、
> PDF・Excel・Word・PowerPoint では黙って無視される常に先頭から返る。PDF は
> `page_range`、Excel は `sheet` / `range` を使うこと。これらのフォーマットに
> `offset` / `limit` を渡すと、出力の先頭に正しいパラメータへの誘導注記が付く。
## フォーマット固有の詳細オプションinline schema には無いが実行時に受理される)
`Read` はこれらを **raw のまま抽出ハンドラへ素通し**する。inline schema を軽く保つため
一覧には出していないので、必要なときはここを見て指定する。
### Excel.xlsx / .xlsm
| オプション | 説明 |
|-----------|------|
| `sheet` | シート名(省略時は全シート) |
| `range` | セル範囲(例 `A1:D10`、省略時はシート全体) |
| `max_cells` | 最大セル数(デフォルト 1000 |
| `include_styles` | `true` で背景色 / フォント / 罫線 / 書式 / 結合を `### Styles` として追記(デフォルト false |
| `max_style_ranges` | `include_styles` 時の style range 上限(デフォルト 250 |
### Word.docx
| オプション | 説明 |
|-----------|------|
| `mode` | `text` / `text+tables`(デフォルト `text+tables` |
| `max_paragraphs` | 最大段落数(デフォルト 200 |
### PDF.pdf
| オプション | 説明 |
|-----------|------|
| `page_range`(別名 `pageRange` | ページ範囲(例 `1-5` / `3`、省略時は全ページ。inline schema にも公開済み |
| `max_pages` | 抽出する最大ページ数 |
| `max_chars` | 返却する最大文字数(デフォルト 8000 |
| `query` | 指定するとマッチしたページのみを `grep -n` 風(周辺行付き)で返す |
| `query_mode` | `substring`(既定・大小無視の部分一致)/ `regex`(大小区別)/ `iregex`(大小無視の正規表現) |
| `context_lines` | `query` マッチ時の前後コンテキスト行数(デフォルト 2、最大 20 |
スキャン PDFテキスト無し`PdfToImages` で PNG 化してから `ReadImage` で読む。
### PowerPoint.pptx
| オプション | 説明 |
|-----------|------|
| `slideRange` | スライド範囲(例 `1-5` / `3`、省略時は全スライド) |
### Outlook メール(.msg
- 件名・送信者・宛先・本文を抽出する。
- 添付ファイルは `input/` に保存し、保存先パスを出力に列挙する。
- 読み取り専用フェーズverify 等、edit 不可)では添付を保存せず、その旨を注記する。
- ファイル名衝突時は `name-1.ext` のように連番で退避し、既存ファイルは上書きしない。
## 使用例
```
Read({ file_path: "input/report.md" }) // テキスト
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/mail.msg" }) // 添付は input/ へ
Read({ file_path: "logs/huge.log", byte_offset: 0, byte_length: 4096 })
```
## 関連ツール
- `ReadImage` — 画像を VLM で読むRead とは別ツール)
- `PdfToImages` — PDF の各ページを PNG 化(スキャン PDF の前処理)
- `SplitExcelSheets` / `SplitDocxSections` — 巨大な表計算 / 文書をシート / 章単位に分割

View File

@ -12,7 +12,7 @@ ReadImage({ file_path: "input/screenshot.png" })
## 動作要件
- 呼び出し時の worker が `vlm: true` で設定されている必要がある
- 設定がない場合、このツールは `allowed_tools` に書いてあっても利用不可function definition から自動除外される)
- 設定がない場合、このツールはワークスペースで許可されていても利用不可function definition から自動除外される)
## 用途

View File

@ -0,0 +1,33 @@
# ReadTaskConversation
`SearchTaskConversation` が返した ref の**前後を数件だけ**読み、当時の文脈を確認する META ツール(全 piece で常時利用可能)。検索を安価に保ちつつ、古い決定の周辺だけを覗くために使う。
## パラメータ
| 名前 | 必須 | 説明 |
|------|------|------|
| `ref` | ○ | `comment:<id>` または `transcript:<index>``SearchTaskConversation` の出力からコピーする) |
| `before` | | 前に含める件数。既定 2、上限 5 |
| `after` | | 後に含める件数。既定 2、上限 5 |
## 挙動
- `comment:<id>`: その id のコメントを中心に、コメント列の前後を返す
- `transcript:<index>`: その行を中心に、transcript の前後行を返す
- 現在タスクの会話だけを対象にする(別タスクは読めない)
- 出力は常に上限付き。中心のエントリには `←` マーカーが付く
## 出力例
```md
## ReadTaskConversation — comment #17 (前 2 / 後 2)
- comment:15 user/comment 2026-06-30T09:58:00Z
先に前提を共有します。
- comment:17 user/request 2026-06-30T10:00:00Z ←
keep the existing auth flow unchanged.
- comment:18 agent/progress 2026-06-30T10:02:00Z
了解しました。認証フローは触りません。
```
典型的な流れ: `SearchTaskConversation` でヒットを見つける → 気になる ref を `ReadTaskConversation` で開く → 恒久的な制約・判断なら `MissionUpdate` で pin する。

View File

@ -1,10 +1,10 @@
# RequestTool
この movement で提示されていないツールがどうしても必要なときに、その要求を**記録**するためのメタツール(`allowed_tools` に書かなくても常時利用可能)。
この movement で提示されていないツールがどうしても必要なときに、その要求を**記録**し、可能ならユーザー承認を求めるためのメタツール(常時利用可能)。
## いつ使うか
- 依頼を達成するのに必要なツールが、現在の movement `allowed_tools` に無いと気づいたとき。
- 依頼を達成するのに必要なツールが、現在の movement に無いと気づいたとき。
- まず「本当にそのツールが要るか」を検討すること。多くの作業は既存のツール(`Bash` / `Read` / `WebSearch` 等)で代替できる。
## 引数
@ -12,13 +12,16 @@
| 引数 | 必須 | 説明 |
|------|------|------|
| `name` | はい | 必要なツール名(例: `WebSearch`, `Bash`, `mcp__foo__bar` |
| `reason` | はい | なぜそのツールが必要かを具体的に。これがピース作者への記録に残る |
| `reason` | はい | なぜそのツールが必要かを具体的に。これが記録に残る |
## 重要: これは「要求の記録」であって「即時付与」ではない
## 承認フローと即時利用の可否
RequestTool を呼んでも、そのツールが**その場で使えるようにはならない**。要求は記録され、タスク詳細とピース集計に表示される。ピース作者が `allowed_tools` / `shared_tools` に追加すれば次回から使える。
ツールの可否は**ワークスペースのツールポリシー(設定 → ツールSSH**で決まる。RequestTool の挙動は実行環境で変わる:
要求したあとの進め方:
- **ユーザーが応答できる実行**(対話承認が有効): 承認を求めて停車し、**承認されればそのまま続行してそのツールを使える**。拒否されればツール無しで進む。
- **それ以外の実行**: その場では使えない。要求が記録され、タスク詳細とツール要求の集計に表示される。運用者がワークスペースの設定でそのツールを有効化すれば、次回から使える。
その場で使えない場合の進め方:
1. そのツール無しで達成できないか、もう一度考える。
2. どうしても無理なら `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` でユーザーに依頼する。
@ -27,9 +30,9 @@ RequestTool を呼んでも、そのツールが**その場で使えるように
## 分類(記録される `category`
- **既に利用可能**: そのツールはこの movement で使える → 記録せず「そのまま呼んでください」と返る。
- **`requested`**: カタログに存在するがこの movement では未許可 → 設定漏れ候補として記録。
- **`requested`**: カタログに存在するがこの movement では未許可 → 対話承認が有効なら承認待ちに、無効なら設定漏れ候補として記録。
- **`unknown`**: そんなツールは存在しない(名前の誤り・能力ギャップ)→ **エラーを返す**(実在ツール名のみ要求可)。診断のため記録は残るが、承認待ちにはならない。エラーを受けたら実在するツールで進めること。
## 関連
足りないツールを呼んで弾かれた場合も、同じ記録に「受動捕捉(`blocked`)」として残る。ピース側の `shared_tools`(全 movement 共通ツール)も参照(`pieces/SCHEMA.md`
足りないツールを呼んで弾かれた場合も、同じ記録に「受動捕捉(`blocked`)」として残る。ワークスペースのツール許可の考え方は「[ツール](../../ui/src/content/help/16-tools.md)」も参照

View File

@ -86,7 +86,7 @@ On task complete, a candidate patch will be saved as browser-macros/{name}.next.
## Notes
- The tool is a META_TOOL — it is available in every movement without listing it in `allowed_tools`.
- The tool is a META_TOOL — available in every movement regardless of the workspace tool policy.
- Use `ListUserAssets` first to discover available macros and their param specs.
- On macro failure, use `BrowseWeb` as a manual fallback.
- To run Python or other ad-hoc code, use the **Bash** tool (pip packages pre-baked).

View File

@ -0,0 +1,42 @@
# SearchTaskConversation
このタスクの過去の会話を検索し、出典 ref 付きの短い抜粋を返す META ツール(全 piece で常時利用可能)。長い/継続タスクで「前に何を言われたか」を思い出すために使う。**全文は返さない** — ヒットの一覧だけを返し、詳細は `ReadTaskConversation` で辿る。
## 検索対象
- **comments**: タスクのコメントユーザーの依頼・割り込み・エージェントの進捗など。ref は `comment:<id>`
- **transcript**: `logs/transcript.jsonl`ReAct スレッドの生ログ。ref は `transcript:<行index>`
現在のタスクの会話だけを検索する。別タスクのログには到達できない(ツールは現在タスクに束縛されている)。
## パラメータ
| 名前 | 必須 | 説明 |
|------|------|------|
| `query` | ○ | 検索キーワード。部分一致・大文字小文字は無視 |
| `source` | | `comments` / `transcript` / `both`(既定 `both` |
| `author` | | `user` / `agent` / `system` で発言者を絞る |
| `kind` | | コメント種別で絞るrequest/comment/interjection/result/ask/progress/handoff。transcript には適用されない |
| `limit` | | 最大件数。既定 10、上限 50 |
## 使いどころ
- ユーザーに聞き直す前に、その情報が過去に述べられていないか確認する
- 以前に触れたファイル・下した決定を変更する前に、関連する制約を探す
- 文脈圧縮のあと、古い決定を思い出してから作業を再開する
## 出力例
```md
## Conversation Search Results (query: "auth", 2 hit(s))
- comment:17 user/request 2026-06-30T10:00:00Z
…keep the existing auth flow unchanged…
- transcript:42 user
…never touch the payment/auth code…
前後の文脈は ReadTaskConversation({ ref }) で確認できます。
```
ヒットで判明した恒久的な事実(制約・判断)は `MissionUpdate``user_constraints` / `decisions` に pin して、以降の movement でも見失わないようにする。

View File

@ -0,0 +1,87 @@
# SearchWorkspaceTasks
同じワークスペース(スペース)内の**他タスク**の会話を横断検索する META ツール(全 piece で常時利用可能)。`SearchTaskConversation` が現在タスクの会話だけを検索するのに対し、こちらは「同じスペースの他タスクで前に何を頼まれたか/どう進めたか」を思い出すための対になるツール。**全文は返さない** — ヒットの一覧(抜粋)だけを返す。前後の全文が必要なら `around_ref` で辿る。
## スコープ
- 検索対象は**同じスペースの他タスクのみ**。タスクの状態(実行中/完了/中断など)は問わない
- 呼び出し元がそのスペースの owner またはメンバーであることを前提に、DB クエリ自体がスペース境界で絞り込む。他スペースのタスクには物理的に到達できない
- no-authサインインしていない owner に紐づかない)実行や subtask 実行では、このツール自体が利用できない文脈になる。その場合はエラーではなく「このコンテキストでは利用できません」という案内文を返す
## 索引される内容(検索でヒットするテキスト)
検索結果の抜粋は、コメント本文そのものではなく**索引用に正規化・墨消しされたテキスト**。索引されるのは次のみ:
- ユーザーの依頼・割り込みrequest / interjection
- エージェントの成果・確認result / ask、handoff
- エージェントの思考progress の thinking
- movement の要約progress の summary
- ツール実行は**ツール名のみ**(例: `tool: WebFetch`)。引数の値やツールの実行結果は一切索引されない
- 添付ファイルは**ファイル名のみ**を本文末尾に追記して索引する(中身は索引しない)
上記に当てはまらない種別(通常の comment、interjection_ack、未知の progress type など)は索引されない=検索にヒットしない。
## パラメータ
`query``around_ref` はどちらか一方のみ指定する(排他・両方またはどちらも未指定はエラー)。
| 名前 | 必須 | 説明 |
|------|------|------|
| `query` | ○(`around_ref` と排他) | 検索キーワード |
| `around_ref` | ○(`query` と排他) | `"comment:<id>"` 形式。指定した comment の前後を読む |
| `context` | | 各ヒットに付す前後コメント件数grep -C 相当)。既定 0、最大 5 |
| `limit` | | 最大ヒット件数。既定 10、最大 30`query` モードのみ) |
| `kind` | | コメント種別で絞り込むrequest/comment/interjection/result/ask/progress/handoff |
| `author` | | 発言者で絞り込む |
| `task_id` | | 特定タスク ID に絞り込む |
出力の ref は `task:<id>` / `comment:<id>` の形式。
## 検索マッチング
日本語も含めた部分一致検索を trigram tokenize の FTS5 インデックスで行う。**3 文字未満の語は trigram で MATCH できないため、そのような語が含まれるクエリは自動的に LIKE部分一致にフォールバックする**(挙動は同じだが遅い)。
**複数語はスペース区切りの暗黙 ANDGoogle 的)**。`ログイン 認証` と入力すると「ログイン」と「認証」を**両方含む**コメントにヒットする(語順・位置は問わない)。フレーズ一致ではないので、離れて出現していても両語があればヒットする。各語は内部で個別に引用してから AND で結合するため、`AND`/`OR`/`NEAR`/`*`/`"` などの記号が語に混ざっても FTS5 演算子としては解釈されない(=ユーザー側から任意の演算子検索や `OR` 検索を差し込むことはできない)。広く探したいときは 1 語で、絞りたいときは語を足す。
SQLite のビルドが FTS5 に対応していない環境では、検索機能自体が使えない旨のメッセージを返す(エラーにはしない)。
## ドリルダウン(`around_ref`)と検索結果の違い
**検索結果の抜粋は索引用に墨消しされた要約だが、`around_ref`(および `context` 指定時に付随する前後コメント)は対象コメントの生の本文をそのまま返す。** 同じスペースのメンバーはもともと製品 UI 上でその兄弟タスクの全文を読めるため、同じ信頼境界の中で全文を返すことは越権にならない。つまり「検索は軽量で墨消しされた概観、ドリルダウンは同じ信頼境界内でのフル情報」という設計。
## 出力例
```md
## Workspace Task Search Results (query: "認証", 2 hit(s))
- task:12 "ログイン画面の改修" / comment:88 user/request 2026-06-20T10:00:00Z
…既存の認証フローは変更しないでください…
- task:12 "ログイン画面の改修" / comment:95 agent/result 2026-06-20T11:30:00Z
…認証まわりは touch せず、UI だけ差し替えました…
前後の文脈をさらに確認するには around_ref: "comment:<id>" を指定してください。
```
```md
## Workspace Task Search — task:12 "ログイン画面の改修" (comment:88 前後 2 件)
- comment:86 user/comment 2026-06-20T09:55:00Z
質問なんですが、既存の認証部分は今回のスコープに入りますか?
- comment:88 user/request 2026-06-20T10:00:00Z ←
既存の認証フローは変更しないでください。UI 差し替えのみでお願いします。
- comment:90 agent/progress 2026-06-20T10:02:00Z
了解しました。認証ロジックには触れずに進めます。
```
## 使いどころ
- 同じスペースの別タスクで、この件についてすでに依頼・決定が交わされていないか確認する
- ユーザーに「前にどう言いましたっけ」と聞き直す前に、まず自分で探す
- 過去タスクの成果物・進め方を参考にする(他タスクの ID や作業内容が分かれば `task_id` で絞り込める)
ヒットで判明した恒久的な事実(制約・判断)は `MissionUpdate``user_constraints` / `decisions` に pin しておくと、以降の movement でも見失わない。
## 関連ツール
- `SearchTaskConversation` / `ReadTaskConversation` — 現在タスク自身の会話コメントtranscriptを検索・閲覧する。他タスクへは到達できない。こちらは同じスペースの他タスクを横断検索する対

View File

@ -6,6 +6,11 @@ Piece の取得・編集には GetPiece / CreatePiece / UpdatePiece を使う。
利用可能なスキル一覧はシステムプロンプトの **Skills Index** に出る。本文を読むには
`ReadSkill({ name: "..." })` を呼ぶ。
> **重要: スキルを `Read` で直接読もうとしないこと。** スキルは workspace の外に
> 保存されており、`Read("skills/<name>/SKILL.md")` のようなパスは(ディレクトリ型を
> ReadSkill で展開する前は)存在しない。まず `ReadSkill({ name })` を呼ぶ。ディレクトリ型は
> それで `skills/<name>/` に展開され、以後は `skills/<name>/...``Read` で読める。
## ツール
- **InstallSkill** — スキルを保存する。通常は `content` に SKILL.md 全文YAML frontmatter + 本文を渡す。workspace 内に `SKILL.md``scripts/` 等を含むディレクトリを組み立て済みなら `sourcePath`workspace 内の絶対パス)を渡す。`scope``user`(個人 or 共有ワークスペース)か `system`全ユーザー共有・admin のみ)。

View File

@ -1,10 +1,10 @@
# SSH Console Tools (SshConsoleEnsure / SshConsoleSend / SshConsoleSnapshot)
AI と人間が共有する SSH PTY セッションを操作する 3 ツール。1 タスクに 1 PTY セッションが対応し、`cd` / 環境変数 / foreground プロセスは job をまたいで維持される。長時間の対話作業 / TUI (vim, top, less, tmux) / 複数ラウンドの調査向け。
AI と人間が共有する SSH PTY セッションを操作する 3 ツール。**1 タスクは接続ごとに 1 つ、複数の PTY セッションを同時に持てる**(例: 2 台のサーバーへ同時接続して並行作業)。各セッション内では `cd` / 環境変数 / foreground プロセスが job をまたいで維持される。長時間の対話作業 / TUI (vim, top, less, tmux) / 複数ラウンドの調査向け。
単発コマンドだけなら **`SshExec`** (ssh-ops piece) のほうが軽い。本ツール群は対話的シェル + AI が画面を見続ける用途に最適化されている。
> **ユーザーが先にセッションを開いている場合がある**: タスク詳細の **Console タブ**から、ユーザーが接続を選んで自分でセッションを起動できる。その場合 `SshConsoleEnsure` は既存セッションをそのまま再利用する (`connection_id` を省略すれば active session が採用される)。「まず console を開く」操作を AI 側でやり直す必要はない
> **ユーザーが先にセッションを開いている場合がある**: タスク詳細の **Console タブ**から、ユーザーがタブを追加して自分でセッションを起動できる(複数タブを同時に開ける。→[SSH 接続](../../ui/src/content/help/14-ssh.md))。その場合 `SshConsoleEnsure` は既存セッションをそのまま再利用する。「まず console を開く」操作を AI 側でやり直す必要はない。ただし**タスクに複数セッションが同時に開いている場合、`connection_id` を省略して自動採用されるのは「直前にこのツール群で操作した接続 (current-connection pointer)」か「セッションが 1 つしかない」ときだけ**。2 つ以上開いていて pointer も無い状態で省略すると `ambiguous` エラーになる(詳細は後述)
## 典型的な flow (まずこれを真似る)
@ -40,10 +40,10 @@ SshConsoleSnapshot({
| Param | Required | Description |
|---|---|---|
| `connection_id` | yes | UUID。piece の `allowed_ssh_connections` に含まれている必要がある。**label / hostname / 思い出した文字列で代用してはいけない** — 必ず `SshListConnections``id` を渡すこと |
| `connection_id` | yes | UUID。このワークスペースに登録された接続である必要がある。**label / hostname / 思い出した文字列で代用してはいけない** — 必ず `SshListConnections``id` を渡すこと |
| `cols` | no | 初回 open 時のターミナル幅。default `ssh.console.default_cols` (120) |
| `rows` | no | 初回 open 時のターミナル高さ。default `ssh.console.default_rows` (32) |
| `force_replace` | no | bool。default `false`既存 session が**別の** `connection_id` にある場合の挙動を制御 (下記参照) |
| `force_replace` | no | bool。default `false`**同じ** `connection_id` に既存セッションがあるとき、それを閉じて開き直すかどうかを制御 (下記参照)。**別の** `connection_id` を渡した場合は `force_replace` に関係なく新規セッションが追加される |
Return:
```json
@ -52,16 +52,28 @@ Return:
`reused: true` なら過去ターンから引き継いだ既存セッション (cd 等の state あり)。`false` なら今回新規 open。
### connection_id mismatch の挙動 (重要)
### 複数セッション: 別の接続を開くと「追加」される (重要)
同じ task で**別の** `connection_id` を渡した場合:
1 つのタスクは接続ごとに 1 つ、複数のセッションを同時に持てる。`SshConsoleEnsure` に**別の** `connection_id` を渡しても既存セッションは閉じられない — 単に新しい接続へのセッションが追加で開くだけ (add-not-replace)。**同じ** `connection_id` を渡した場合だけ「再利用か force_replace での再起動か」の分岐になる:
- `force_replace: false` (default) → エラー返却。レスポンスに **既存セッションの connection_id が含まれる** ので、それをそのまま使うか、本当に切り替えたければ次の呼び出しで `force_replace: true` を渡す
- `force_replace: true` → 旧セッションは `connection_change` 理由で閉じられ、新セッションが開く (旧 shell の state は失われる)
- 同じ `connection_id` + `force_replace: false` (default) → 既存セッションを再利用 (`alreadyActive: true`, `reused: true`)
- 同じ `connection_id` + `force_replace: true`**その接続のセッションだけ** `connection_change` 理由で閉じて開き直す (他の接続のセッションには影響しない。旧 shell の state は失われる)
**典型的なバグパターン**: ジョブをまたいで動作するエージェントが `connection_id` を覚えていなくて、
LLM の hallucination で適当な UUID を生成 → mismatch reject される、というケース。エラーメッセージの中に
正しい `connection_id` が出ているのでそれを使うか、Send/Snapshot で `connection_id` を省略する。
タスクあたりの上限 (既定 5、上限到達時は `task_session_cap`) やユーザー単位の上限 (`user_session_cap`、設定次第) に達すると新規オープンは拒否される。人間側も Console タブの「+ 接続」ボタンで同じように接続を追加でき、タブの ✕ で不要なセッションを閉じられる (→[SSH 接続](../../ui/src/content/help/14-ssh.md))。
### `connection_id` を省略できるのは 1 セッションのときだけ
`SshConsoleSend` / `SshConsoleRun` / `SshConsoleSnapshot``connection_id` を省略できるが、解決順は次のとおり:
1. `connection_id` を明示 → その接続のセッションを厳密に使う (無ければ `not found` エラー。勝手に新規 open はしない)
2. 省略時は、このツール群で直前に操作した接続 (current-connection pointer) が生きていればそれ
3. pointer も無く、タスクのセッションが**ちょうど 1 つ**ならそれを自動採用
4. タスクに**複数**セッションが同時に開いていて pointer も無い場合 → `ambiguous` エラー (`connection_id required (multiple sessions open: ...)`)。**この場合は `connection_id` を明示しないと動かない**
5. セッションが 1 つも無い場合 → `SshConsoleEnsure` で開くよう促すエラー
**複数接続で並行作業するときの鉄則**: 2 つ目以降のセッションを触るときは常に `connection_id` を明示する。省略に頼ってよいのは、セッションが 1 つしかないタスクか、直前にそのセッションを操作した直後だけ。
**典型的なバグパターン**: ジョブをまたいで動作するエージェントが `connection_id` を覚えておらず、複数セッションが開いている状態で省略 → `ambiguous` で reject される、というケース。エラーメッセージに出ている `connection_id` の一覧 (または `SshListConnections`) から正しいものを選んで明示する。
## SshConsoleSend
@ -77,7 +89,7 @@ raw のまま送りたい (改行を付けない) ケース:
| Param | Required | Description |
|---|---|---|
| `connection_id` | no | UUID。**省略時はこの task の active session を自動採用 (推奨)**。明示する場合は active session の id と一致する必要があり、不一致なら reject (active id が surface される) |
| `connection_id` | no | UUID。**タスクにセッションが 1 つだけ、または直前にこのツール群で操作した接続 (pointer) がある場合は省略可**。複数セッションが開いていて pointer も無いと `ambiguous` エラー (下記「`connection_id` を省略できるのは 1 セッションのときだけ」参照) |
| `input` | yes | raw 文字列。LF / CRLF / control 文字 (`\x03` Ctrl-C, `\x04` Ctrl-D, `\x1b` Esc, `\t` Tab) を透過 |
| `wait_ms` | no | 送信後の screen_after 取得までの待ち時間 (default 500ms, max 5000ms) |
@ -113,7 +125,7 @@ Return:
| Param | Required | Description |
|---|---|---|
| `connection_id` | no | UUID。**省略時はこの task の active session を自動採用 (推奨)**。明示する場合は active session の id と一致する必要があり、不一致なら reject |
| `connection_id` | no | UUID。**タスクにセッションが 1 つだけ、または直前にこのツール群で操作した接続 (pointer) がある場合は省略可**。複数セッションが開いていて pointer も無いと `ambiguous` エラー (上記「`connection_id` を省略できるのは 1 セッションのときだけ」参照) |
| `kind` | no | `screen` (デフォルト) — 現在の表示画面 / `scrollback` — それ以前を含む過去の出力 |
| `max_bytes` | no | scrollback の上限 (default 8192, max 65536)。tail から `max_bytes` バイト返す |
@ -138,7 +150,7 @@ text は ANSI escape strip 済み (色 / cursor 移動シーケンスを除去)
| Param | Required | Description |
|---|---|---|
| `command` | yes | 実行するシェルコマンド |
| `connection_id` | no | UUID。**省略時はこの task の active session を自動採用 (推奨)** |
| `connection_id` | no | UUID。**タスクにセッションが 1 つだけ、または直前にこのツール群で操作した接続 (pointer) がある場合は省略可**。複数セッションが開いていて pointer も無いと `ambiguous` エラー |
| `timeout_ms` | no | タイムアウト (ms)。デフォルト 120000 (2分)、最大 600000 (10分)。タイムアウト時もコマンドは kill されない |
| `idle_ms` | no | 出力が `idle_ms` ms 途切れたら早期終了と判定する。0=無効 (デフォルト) |
@ -176,12 +188,13 @@ Return:
| `host_key_*` | UI (Settings → User Folder → SSH Connections) で TOFU 検証してから再試行 |
| `command_rejected (builtin_deny / custom_deny)` | deny-list で reject。admin に許可パターン追加を相談 (ローカルで回避してはいけない) |
| `idle_timeout` / `duration_cap` | 古いセッションが閉じた。`SshConsoleEnsure` を再度呼んで開け直す |
| `connection_change` | 同 task で `force_replace: true` 付き Ensure が呼ばれた → 古いセッションが閉じた |
| `this task already has an active session on connection X (...)` | エラー文の中の **X が正しい id**。X を `connection_id` に使うか、Send/Snapshot で省略する。本当に切り替えたければ `force_replace: true` |
| `this task has an active session on connection X, not Y` | Send/Snapshot 側で id mismatch。X を使う or 省略する |
| `connection_change` | **同じ** `connection_id``force_replace: true` 付き Ensure が呼ばれた → その接続のセッションだけ閉じて開き直した (別の接続を開いても発生しない) |
| `connection_id required (multiple sessions open: ...)` (ambiguous) | Send/Run/Snapshot で `connection_id` を省略したが、このタスクに複数セッションが同時に開いていて pointer も無い。エラー文中の一覧から狙った `connection_id` を明示する |
| `this task already has the maximum of N open console sessions` (`task_session_cap`) | タスクあたりのセッション上限 (既定 5) に到達。使っていない接続を `SshConsoleSend/Run` の完了を確認してからユーザーに Console タブでタブを閉じてもらうか、既存接続を使い回す |
| `you already have the maximum of N open console sessions` (`user_session_cap`) | ユーザー単位の上限 (設定されている場合) に到達。他タスクのセッションを閉じるまで新規オープンは拒否される |
| `maintenance` | admin の対応を待つ。`complete({status: 'needs_user_input', missing_info: 'SSH maintenance window'})` で停止 |
| `not initialised` | `ssh.enabled` または `ssh.console.enabled` が false / `MCP_ENCRYPTION_KEY` 未設定。admin に依頼 |
| `does not declare allowed_ssh_connections` | piece YAML の movement に `allowed_ssh_connections: ['*']` 等を追加する必要あり |
| `not registered to this workspace` | 対象接続がこのワークスペースに未登録。Settings → SSH で登録し、Settings → Tools で `ssh` カテゴリを有効化する |
## deny-list の限界

View File

@ -6,12 +6,12 @@
| ツール | 用途 | 入力 |
|--------|------|------|
| `SshListConnections` | この movement で使える接続の UUID + label + host 一覧を取得 | (引数なし) |
| `SshListConnections` | このワークスペースで使える接続の UUID + label + host 一覧を取得 | (引数なし) |
| `SshExec` | リモートで shell 単一行を実行 | `connection_id`, `command`, (任意) `timeout_ms` |
| `SshUpload` | workspace → リモートへファイル転送 (SFTP) | `connection_id`, `local_path`, `remote_path`, (任意) `timeout_ms` |
| `SshDownload` | リモート → workspace へファイル取得 (SFTP) | `connection_id`, `remote_path`, `local_path`, (任意) `timeout_ms` |
転送系の 3 ツールは、接続側の `remote_path_prefix` 配下の絶対パスのみを受け付け、`workspace` 外への local パスは reject される。`connection_id` piece 側の `allowed_ssh_connections` に明示されている UUID のみ使用可能
転送系の 3 ツールは、接続側の `remote_path_prefix` 配下の絶対パスのみを受け付け、`workspace` 外への local パスは reject される。`connection_id`、このワークスペースに登録されている接続の UUID のみ使用可能(ツール可否・接続スコープはワークスペースのツールポリシーが決める)
タスク本文に `connection_id` が記されていないときは、まず `SshListConnections` を呼んで該当の host / label の UUID を取得すること。
@ -21,10 +21,10 @@
1. **`ssh.enabled: true`** が `config.yaml` で設定されている
2. **`MCP_ENCRYPTION_KEY`** 環境変数が 64 hex 文字 (= 32 バイト) で設定されている
3. **対象 connection の host key が verify 済**。新規作成直後は `host_key_verified_at IS NULL` 状態で SshExec/Upload/Download は `host_key_not_verified` で失敗する。SSH Connections パネル (Settings → User Folder → SSH Connections) で `/test` を実行 → 鍵 fingerprint を確認 → "Verify" ボタンで verify する
4. **piece の現在 movement で `allowed_ssh_connections` に当該 UUID が明示**されている (またはワイルドカード `*`)。空配列 `[]` は「SSH 使用するが許可なし」の deny 宣言とみなされ全 UUID が reject される
3. **対象 connection の host key が verify 済**。新規作成直後は `host_key_verified_at IS NULL` 状態で SshExec/Upload/Download は `host_key_not_verified` で失敗する。SSH 接続パネル (Settings → SSH) で `/test` を実行 → 鍵 fingerprint を確認 → "Verify" ボタンで verify する
4. **ワークスペースのツールポリシーで `ssh` カテゴリが有効化**され、対象 connection が **そのワークスペースに登録**されている (Settings → Tools で ssh を有効化、Settings → SSH で接続を登録)。接続スコープはワークスペース単位で、worker がジョブ開始時に解決して全 movement に一律適用する。piece 側の opt-in は不要(撤去済み)
不足時のエラーメッセージ例: `SshExec error: piece "ops" movement "exec" does not list connection abcd1234... in allowed_ssh_connections.`
不足時のエラーメッセージ例: `SshExec: connection abcd1234... is not registered to this workspace. Register it under Settings → SSH.`
## SshListConnections
@ -32,7 +32,7 @@
SshListConnections({})
```
引数なし。現在の movement の `allowed_ssh_connections` + ジョブ owner の access grant を満たす接続だけを返す (admin 無効化 / piece 除外 / grant 無しは filter out)。
引数なし。このワークスペースに登録された接続のうち、ジョブ owner の access grant を満たすものだけを返す (admin 無効化 / grant 無しは filter out)。
戻り値 (JSON 文字列):

View File

@ -2,7 +2,7 @@
ユーザーごとの常時指示書 `AGENTS.md` を読み書きするツール。`AGENTS.md` は各タスクのシステムプロンプトに自動注入される「このユーザーが常に守ってほしいこと」を書いた個人ファイル。memory`UpdateUserMemory`)が事実の断片を貯めるのに対し、`AGENTS.md` は振る舞いの方針そのもの。
両ツールは META_TOOL常時利用可能piece の `allowed_tools` に書かなくても使える。per-user 機能なので、認証済みユーザーのコンテキスト(`ctx.userId`)が必要。
両ツールは META_TOOL常時利用可能ワークスペースのツール設定に関係なく使える。per-user 機能なので、認証済みユーザーのコンテキスト(`ctx.userId`)が必要。
## ReadUserAgents

View File

@ -111,6 +111,6 @@ async function main({ context, params }) {
## Notes
- `WriteUserScript` is a META_TOOL — available in every movement without listing it in `allowed_tools`.
- `WriteUserScript` is a META_TOOL — available in every movement regardless of the workspace tool policy.
- After writing, use `RunUserScript` to immediately execute and verify the macro.
- Use `ListUserAssets` to see all macros currently in the folder.

447
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.13",
"@kenjiuno/msgreader": "^1.28.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0",
@ -25,8 +26,10 @@
"fast-xml-parser": "^5.4.2",
"gray-matter": "^4.0.3",
"http-proxy": "^1.18.1",
"iconv-lite": "^0.4.24",
"linkedom": "^0.18.12",
"mammoth": "^1.11.0",
"oidc-provider": "^9.8.6",
"p-queue": "^9.3.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
@ -75,6 +78,47 @@
"node": ">=22"
}
},
"node_modules/@a2a-js/sdk": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.13.tgz",
"integrity": "sha512-BZr0f9JVNQs3GKOM9xINWCh6OKIJWZFPyqqVqTym5mxO2Eemc6I/0zL7zWnljHzGdaf5aZQyQN5xa6PSH62q+A==",
"license": "Apache-2.0",
"dependencies": {
"uuid": "^11.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@bufbuild/protobuf": "^2.10.2",
"@grpc/grpc-js": "^1.11.0",
"express": "^4.21.2 || ^5.1.0"
},
"peerDependenciesMeta": {
"@bufbuild/protobuf": {
"optional": true
},
"@grpc/grpc-js": {
"optional": true
},
"express": {
"optional": true
}
}
},
"node_modules/@a2a-js/sdk/node_modules/uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
@ -937,6 +981,74 @@
"node": ">=0.10.0"
}
},
"node_modules/@koa/cors": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@koa/cors/-/cors-5.0.0.tgz",
"integrity": "sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==",
"license": "MIT",
"dependencies": {
"vary": "^1.1.2"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@koa/router": {
"version": "15.6.0",
"resolved": "https://registry.npmjs.org/@koa/router/-/router-15.6.0.tgz",
"integrity": "sha512-iEOXlvGIBqSNkGXrg0XtMARAOm5zA24oedXxiTGEkrD4JgwVjfRDddCQvW1s4WEcwDYvyecRbf8BikXsuEEj8w==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"koa-compose": "^4.1.0",
"path-to-regexp": "^8.4.2"
},
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"koa": "^2.0.0 || ^3.0.0"
},
"peerDependenciesMeta": {
"koa": {
"optional": false
}
}
},
"node_modules/@koa/router/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@koa/router/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@koa/router/node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
@ -2920,6 +3032,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookies": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
"integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"keygrip": "~1.1.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@ -3107,6 +3232,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-equal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz",
"integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==",
"license": "MIT"
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@ -3126,6 +3257,12 @@
"node": ">=0.4.0"
}
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"license": "MIT"
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -3415,6 +3552,18 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/eta": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz",
"integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/bgub/eta?sponsor=1"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@ -4103,6 +4252,53 @@
"node": ">=16"
}
},
"node_modules/http-assert": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz",
"integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==",
"license": "MIT",
"dependencies": {
"deep-equal": "~1.0.1",
"http-errors": "~1.8.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/http-assert/node_modules/depd": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
"integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/http-assert/node_modules/http-errors": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz",
"integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==",
"license": "MIT",
"dependencies": {
"depd": "~1.1.2",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": ">= 1.5.0 < 2",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/http-assert/node_modules/statuses": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
"integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -4370,6 +4566,18 @@
"node": ">=v12.22.7"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@ -4445,6 +4653,18 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/keygrip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
"integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==",
"license": "MIT",
"dependencies": {
"tsscmp": "1.0.6"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@ -4454,6 +4674,119 @@
"node": ">=0.10.0"
}
},
"node_modules/koa": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/koa/-/koa-3.2.1.tgz",
"integrity": "sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==",
"license": "MIT",
"dependencies": {
"accepts": "^1.3.8",
"content-disposition": "~1.0.1",
"content-type": "^1.0.5",
"cookies": "~0.9.1",
"delegates": "^1.0.0",
"destroy": "^1.2.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"fresh": "~0.5.2",
"http-assert": "^1.5.0",
"http-errors": "^2.0.0",
"koa-compose": "^4.1.0",
"mime-types": "^3.0.1",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/koa-compose": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz",
"integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==",
"license": "MIT"
},
"node_modules/koa/node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/koa/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/koa/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/koa/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/koa/node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/koa/node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@ -5199,6 +5532,99 @@
],
"license": "MIT"
},
"node_modules/oidc-provider": {
"version": "9.8.6",
"resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-9.8.6.tgz",
"integrity": "sha512-jodnMKbwfMbV5qUFnbCxtnrdRztJJubJbw/7HTokIJSiHm1JT7pU4G83sD/bPkvZnFDdv5POB/kJU5uGG3ek4g==",
"license": "MIT",
"dependencies": {
"@koa/cors": "^5.0.0",
"@koa/router": "^15.5.0",
"debug": "^4.4.3",
"eta": "^4.6.0",
"jose": "^6.2.3",
"jsesc": "^3.1.0",
"koa": "^3.2.1",
"nanoid": "^5.1.11",
"quick-lru": "^7.3.0",
"raw-body": "^3.0.2"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/oidc-provider/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/oidc-provider/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/oidc-provider/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/oidc-provider/node_modules/nanoid": {
"version": "5.1.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/oidc-provider/node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -5711,6 +6137,18 @@
"inherits": "~2.0.3"
}
},
"node_modules/quick-lru": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.3.0.tgz",
"integrity": "sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
@ -6602,6 +7040,15 @@
"license": "0BSD",
"optional": true
},
"node_modules/tsscmp": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
"integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==",
"license": "MIT",
"engines": {
"node": ">=0.6.x"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",

View File

@ -29,6 +29,7 @@
"vapid-rotate": "tsx scripts/vapid-rotate.ts"
},
"dependencies": {
"@a2a-js/sdk": "^0.3.13",
"@kenjiuno/msgreader": "^1.28.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0",
@ -45,8 +46,10 @@
"fast-xml-parser": "^5.4.2",
"gray-matter": "^4.0.3",
"http-proxy": "^1.18.1",
"iconv-lite": "^0.4.24",
"linkedom": "^0.18.12",
"mammoth": "^1.11.0",
"oidc-provider": "^9.8.6",
"p-queue": "^9.3.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",

View File

@ -5,7 +5,17 @@ This is the reference for the piece YAML format consumed by
`/api/pieces` HTTP layer (`src/bridge/pieces-api.ts` `validatePiece`).
Field names are snake_case in the YAML; the engine maps them to
camelCase internally (see `Movement` in `src/engine/agent-loop.ts`).
camelCase internally (see `Movement` in `src/engine/agent-loop/types.ts`).
> **Tool / edit / SSH availability is NOT declared in pieces.**
> A piece defines only the *movement flow* (persona, instruction, transition
> rules). Which tools an agent may call, whether `Write`/`Edit` are available,
> and which SSH connections it can reach are all decided by the **workspace tool
> policy** (Settings → Tools / SSH), resolved once per job by the worker
> (`resolveWorkspaceTools`) and applied uniformly to every movement. The legacy
> fields `allowed_tools`, `edit`, `shared_tools`, and `allowed_ssh_connections`
> were removed in the tool-consolidation work — if present on a submitted piece
> they are tolerated but ignored.
## Top-level
@ -17,89 +27,24 @@ camelCase internally (see `Movement` in `src/engine/agent-loop.ts`).
| `initial_movement` | string | yes | must reference a `movements[].name` |
| `triggers.keywords` | string[] | no | classifier hint only |
| `required_mcp` | string[] | no | `[a-z0-9_-]{1,64}` server slugs |
| `shared_tools` | string[] | no | piece-level tools merged into **every** movement's effective `allowed_tools` (union). See below. |
| `model` | string | no | preferred LLM model |
| `movements` | Movement[] | yes | non-empty array |
### `shared_tools`
Tools listed here are added to every movement's allowed set, so they don't have
to be repeated in each `movements[].allowed_tools`. The effective set for a
movement is:
```
shared_tools movements[].allowed_tools META_TOOLS
```
`prepareMovementContext` (`src/engine/piece-runner.ts`) unions the first two
via `mergeToolNames` (de-duplicates, preserves order) into
`movement.allowedTools`; the always-on `META_TOOLS` are added later, when
`getToolDefs` (`src/engine/tools/index.ts`) builds the tool list.
The per-movement gates still apply on top of the union:
- A movement with `edit: false` never exposes `Write`/`Edit`, even if listed in
`shared_tools`.
- SSH tools (`SshExec` etc.) still require the movement's own
`allowed_ssh_connections` — a shared SSH tool is rejected at runtime in any
movement that has not declared connections (defense in depth).
- Unknown tool names are silently dropped by `getToolDefs` (`name in allDefs`).
Handled by two complementary layers (mirrors `required_mcp`): file-backed
pieces are cleaned leniently at load time by `normalizeSharedTools`
(`piece-runner.ts`, drops bad entries + warns), while API writes
(`POST`/`PUT /api/pieces`, `CreatePiece`/`UpdatePiece`) are validated strictly
by `validatePiece` (`pieces-api.ts`, rejects a non-array or non-string entry).
## Movement
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | yes | unique within the piece |
| `edit` | boolean | yes | when true, Write/Edit are exposed |
| `persona` | string | yes | system-prompt persona |
| `instruction` | string | yes | the movement's task description |
| `allowed_tools` | string[] | yes | tool names; `'mcp__*'` wildcard allowed |
| `allowed_commands` | string[] | no | Bash command allowlist (overrides default) |
| `allowed_ssh_connections` | string[] | conditional | see below |
| `rules` | Rule[] | yes | transition rules; may be empty |
| `default_next` | string | no | engine-internal fallback (sentinel-friendly) |
| `max_consecutive_revisits` | number | no | loop-detection threshold override |
## `allowed_ssh_connections`
Per-movement SSH connection allowlist (Phase 4 of the SSH tool integration
| Value | Meaning |
|-------|---------|
| `undefined` (field omitted) | SSH tools reject with `no_allowed_connections_declared`. |
| `[]` (empty array) | SSH tools reject with `no_allowed_connections_declared`. The empty form is preferred over omission when the movement intentionally denies all connections (intent is explicit). |
| `['<connection-id>', ...]` | Only listed connection IDs may be passed to SSH tools. |
| `['*']` | Any registered connection may be passed. Still subject to ownership and grant checks (defense in depth). Use sparingly — typically only `ssh-ops`-style pieces. |
**Required**: If a movement's `allowed_tools` contains any of `SshExec`,
`SshUpload`, or `SshDownload`, then `allowed_ssh_connections` MUST be
present. `validatePieceDef` and `validatePiece` both reject pieces that
omit it for SSH-using movements.
**Format**: each entry must be `'*'` or a lowercase hex/hyphen id with
8+ characters (loose match against `randomUUID()` output).
Example:
```yaml
movements:
- name: ops
edit: false
persona: ops-operator
instruction: Run health checks on production hosts.
allowed_tools: [SshExec, Read]
allowed_ssh_connections:
- 6f9619ff-8b86-d011-b42d-00c04fc964ff
- 7a8b9cde-1234-4567-89ab-cdef12345678
rules:
- condition: all checks pass
next: COMPLETE
```
Tool availability, `Write`/`Edit` (edit) permission, and SSH connection scoping
are runtime values on the in-memory `Movement` (populated from the workspace
policy in `prepareMovementContext`), not YAML fields.
## Rule
@ -112,8 +57,7 @@ movements:
`COMPLETE` / `ABORT` / `ASK` — those are reachable only through the
`complete` tool (status: `success` / `aborted` / `needs_user_input`).
`default_next` does accept the terminal sentinels because it is an
engine-internal fallback (context overflow, ASK limit, SpawnSubTask
unavailable).
engine-internal fallback (context overflow, ASK limit).
## Validation paths

View File

@ -16,7 +16,6 @@ triggers:
movements:
- name: delegate_research
edit: true
persona: facilitator
instruction: |
課題を複数の視点に分解し、各視点を delegate で1件ずつ直列に検討させ、
@ -46,14 +45,12 @@ movements:
- リスクと緩和策
- 各視点の詳細へ相対リンク [perspective-{連番}](./research/perspective-{連番}.md) で繋ぐ
5. output/recommendation.md を書き終えたら verify へ遷移する
allowed_tools: [delegate, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, GetYouTubeTranscript, SearchYouTube, SearchAmazon, SearchPlaces, GetDirections, ReverseGeocode, XSearch, XUserPosts, XPostDetail, SearchMicrosoftLearn, FetchMicrosoftLearn, 'mcp__*']
default_next: verify
rules:
- condition: "output/recommendation.md に推奨方針をまとめた"
next: verify
- name: verify
edit: false
persona: reviewer
instruction: |
output/ の成果物を確認する。
@ -90,7 +87,6 @@ movements:
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "delegate_research", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep]
# default_next is the engine-internal fallback (context overflow / ASK
# limit / SpawnSubTask unavailable). Not exposed to the LLM.
default_next: COMPLETE

View File

@ -9,7 +9,6 @@ initial_movement: respond
movements:
- name: respond
edit: true
persona: assistant
instruction: |
ユーザーの依頼に対して必要な調査・作業を行い、最終回答を返す。
@ -72,7 +71,6 @@ movements:
- `result` がそのままユーザーに表示される最終出力。途中のメモや作業ログは入れない
- **ユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})`
allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadExcel, ReadDocx, ReadPPTX, ReadMsg, SQLite, Bash, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, ListPieces, GetPiece, CreatePiece, UpdatePiece, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, ReadToolDoc, AddCalendarEvent, ListCalendarEvents, 'mcp__*']
# default_next is the engine-internal fallback for context overflow / ASK
# limit reached / SpawnSubTask unavailable. It is NOT exposed to the LLM.
default_next: COMPLETE

View File

@ -10,7 +10,6 @@ initial_movement: process
movements:
- name: process
edit: true
persona: data-engineer
instruction: |
## 最初のステップ: 入力データの把握
@ -50,7 +49,7 @@ movements:
## スキャン PDF / 画像データの場合
レシートや帳票など画像由来のデータを扱う場合:
- テキスト PDF → ReadPdf で直接読む
- テキスト PDF → Read で直接読む
- スキャン PDF / 画像 → PdfToImages でページ画像化し、ReadImage で内容を読み取る
## 実行
@ -62,28 +61,24 @@ movements:
- **次の report へ**: `transition({next_step: "report"})`
- **処理対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **データが壊れている / 読み取れない / エラー発生で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, SQLite, WebSearch, WebFetch, DownloadFile, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, ReadMsg, SplitExcelSheets, PdfToImages, ReadImage, AnnotateImage, TranscribeAudio, ReadToolDoc, 'mcp__*']
default_next: report
rules:
- condition: output/ に結果を書き出した
next: report
- name: report
edit: true
persona: reporter
instruction: |
処理結果を元にレポートを作成し output/ にファイルとして書き出す。
表を含め、分かりやすくまとめる。
必ず Write ツールで output/ にレポートファイルを作成すること。
allowed_tools: [Read, Write, Bash, Glob, Grep, ReadImage, AnnotateImage, ReadPdf, ReadExcel, 'mcp__*']
default_next: verify
rules:
- condition: output/ にレポートを書き出した
next: verify
- name: verify
edit: false
persona: reviewer
instruction: |
output/ の成果物を確認する。
@ -121,7 +116,6 @@ movements:
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, ReadPdf, ReadImage, AnnotateImage, ReadExcel]
default_next: COMPLETE
rules:
- condition: output/ にファイルがない、または内容に不足・誤りがある

View File

@ -9,7 +9,6 @@ initial_movement: execute
movements:
- name: delegate_research
edit: true
persona: orchestrator
instruction: |
入力把握で立てた分割調査計画に従い、各テーマを delegate で1件ずつ直列に
@ -37,14 +36,12 @@ movements:
- 全体のまとめと結論を付ける
- 各テーマの詳細へ相対リンク [theme-{連番}](./research/theme-{連番}.md) で繋ぐ
5. output/report.md を書き終えたら verify へ遷移する
allowed_tools: [delegate, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, GetYouTubeTranscript, SearchYouTube, SearchAmazon, SearchPlaces, GetDirections, ReverseGeocode, XSearch, XUserPosts, XPostDetail, SearchMicrosoftLearn, FetchMicrosoftLearn, 'mcp__*']
default_next: verify
rules:
- condition: output/report.md に統合レポートを作成した
next: verify
- name: execute
edit: true
persona: worker
instruction: |
## 最初のステップ: 入力把握
@ -111,7 +108,6 @@ movements:
- **分割調査が効率的 → delegate_research へ**: `transition({next_step: "delegate_research"})`
- **必須情報が不足し確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadMsg, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, AddCalendarEvent, ListCalendarEvents, 'mcp__*']
default_next: verify
rules:
- condition: 2つ以上の独立したテーマがあり、分割して delegate に任せるのが効率的と判断した
@ -120,7 +116,6 @@ movements:
next: verify
- name: verify
edit: false
persona: reviewer
instruction: |
成果物を確認する。
@ -167,7 +162,6 @@ movements:
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "execute", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, ReadMsg, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache]
default_next: COMPLETE
rules:
- condition: 成果物がない、または内容に不足・誤りがある(追加質問への回答に検索根拠が不足している場合も含む)

View File

@ -19,7 +19,6 @@ triggers:
movements:
- name: respond
edit: false
persona: MAESTRO のヘルプアシスタント
instruction: |
あなたは MAESTRO というエージェント実行プラットフォームの使い方や設計についてユーザーの質問に答えるアシスタントです。
@ -83,14 +82,6 @@ movements:
`aborted` は「聞いても解消しない」ときだけ。
`complete({status: "aborted", abort_reason: "失敗の技術的理由"})`
allowed_tools:
- Read
- Grep
- Glob
- WebSearch
- WebFetch
- ListPieces
- GetPiece
# META_TOOLS が自動追加: ReadToolDoc, CreateChecklist, CheckItem, GetChecklist,
# MissionUpdate, ListUserAssets, RunUserScript, UpdateUserMemory, ReadUserMemory,
# ReadUserAgents, UpdateUserAgents, WriteUserScript,

View File

@ -11,36 +11,35 @@ initial_movement: process
movements:
- name: process
edit: true
persona: document-specialist
instruction: |
## 最初のステップ: ファイルの把握と前処理
加工に着手する前に、まずファイルを確認し前処理を行う:
1. Glob でワークスペース全体のファイル一覧を確認する(`**/*.xlsx`, `**/*.docx`, `**/*.pptx`, `**/*.pdf`。input/ だけでなくルート直下も含む)
2. ファイル種別ごとの読み取り戦略は ReadToolDoc({ name: "ReadPdf" }) などで確認
2. 読み取りは種別を問わず Read拡張子で Office/PDF/メールを自動判定)。詳細は ReadToolDoc({ name: "Read" }) で確認
## ファイルサイズに応じた前処理
**Excel (.xlsx)**:
- 小〜中規模 → ReadExcel で直接読む
- 小〜中規模 → Read で直接読むsheet / range も指定可)
- 巨大・複数シート → SplitExcelSheets でシート別ファイル + manifest を生成し、必要なシートだけ Read する
**Word (.docx)**:
- 短〜中規模 → ReadDocx で直接読む
- 短〜中規模 → Read で直接読む
- 長文・章構成あり → SplitDocxSections で見出し単位に分割し、関連セクションだけ Read する
**PowerPoint (.pptx)**:
- ReadPPTX で各スライドのテキスト・表・スピーカーノートを取得
- Read で各スライドのテキスト・表・スピーカーノートを取得
**PDF**:
- まず ReadPdf で読み取りを試みる
- まず Read で読み取りを試みる
- テキストが抽出できた場合 → そのまま加工に進む
- 全ページが空テキスト(スキャン PDFの場合 → PdfToImages でページ画像化し、ReadImage で内容を確認するReadImage は VLM 対応 worker でのみ利用可能)
**Outlook メール (.msg)**:
- ReadMsg で件名・差出人・宛先・本文を取得。添付は input/ に保存される
- 保存された添付は ReadPdf / ReadExcel / ReadImage など種別ごとのツールで開く
- Read で件名・差出人・宛先・本文を取得。添付は input/ に保存される
- 保存された添付は ReadOffice/PDF/テキスト)や ReadImage画像で開く
## Office ファイルの加工方針
@ -67,7 +66,6 @@ movements:
- **追加情報が必要で同じ process を続行**: `transition({next_step: "process", summary: "..."})`
- **対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **読み取り不能・対応外フォーマット等の技術的失敗**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, ReadMsg, SplitExcelSheets, SplitDocxSections, PdfToImages, ReadImage, WebSearch, WebFetch, DownloadFile, SQLite, TranscribeAudio, ReadToolDoc, 'mcp__*']
default_next: verify
rules:
- condition: output/ に成果物を書き出した(または既存ファイルを編集した)
@ -76,7 +74,6 @@ movements:
next: process
- name: verify
edit: false
persona: reviewer
instruction: |
output/ の成果物を確認する。
@ -84,7 +81,7 @@ movements:
確認手順:
1. まず Glob で output/ 内のファイル一覧を確認する(既存 Office ファイルの編集の場合はそのファイルも対象)
2. 成果物が1つもなければ「修正が必要」と判断し process に差し戻す
3. 成果物があれば適切なツールReadPdf / ReadExcel / ReadDocx / ReadPPTX / Read 等)で内容を確認し、指示通りか・品質は十分かをチェックする
3. 成果物があれば Readテキスト / Office / PDF を拡張子で自動判定)や ReadImage で内容を確認し、指示通りか・品質は十分かをチェックする
4. 不足や誤りがあれば、`transition({next_step: "process", summary: ...})` で差し戻す。summary は次の形式で書く:
[判定] needs_fix
## 問題点
@ -116,7 +113,6 @@ movements:
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, ReadPdf, ReadImage, ReadExcel, ReadDocx, ReadPPTX, ReadMsg, ReadToolDoc]
default_next: COMPLETE
rules:
- condition: 成果物がない、または内容に不足・誤りがある

View File

@ -13,7 +13,6 @@ triggers:
movements:
- name: design
edit: false
persona: architect
instruction: |
## Piece 設計フェーズ
@ -27,9 +26,11 @@ movements:
4. 新規作成が正当化される場合にのみ、以下を整理する:
- 目的(何を自動化するか)
- movement の構成とステップ間の遷移条件
- 各ステップで使うツール(`allowed_tools`
- 入力と出力の形式
ツールの利用可否は Piece では宣言しない。各ワークスペースの「設定 → ツール」
workspace tool policyで決まり、実行時に全 movement へ一律に適用される。
Piece は movement のフローpersona / instruction / rulesだけを定義する。
ツールの詳細仕様は ReadToolDoc で確認できる(例: `ReadToolDoc({ name: "delegate" })`)。
### YAML 構造の制約
@ -45,42 +46,39 @@ movements:
movements:
- name: ステップ名
edit: true/false # Write/Edit を許可するか
persona: 役割名
instruction: |
このステップで行うことWHAT を書く。HOW はツールドキュメントに委ねる)
allowed_tools: [使用するツール]
default_next: 次のステップ名 or COMPLETE
rules:
- condition: 遷移条件の説明
next: 遷移先
```
Piece にツール・編集可否(旧 `allowed_tools` / `edit`)や SSH 接続
(旧 `allowed_ssh_connections`)を書く必要はない。これらは「設定 → ツール / SSH」
で決まる。書いても無視される。
### Movement・Rules の設計指針
- `edit: true` にしないと Write/Edit が LLM に提示されない
- `allowed_tools` に載っていないツールは LLM に提示されない — 必要最小限に絞る
- `rules` に明示した遷移先のみ LLM が選択できる
- `default_next` はコンテキスト上限到達・ASK 上限フォールバックなど機械的用途のみLLM の選択肢にならない)
- verify movement を設けると品質チェックが可能
- ループ検出: 同じ movement への連続訪問が閾値超過で ABORT されるため、A→B→A の無限循環を避ける
### Persona / Instruction / Allowed_tools の使い分け
### Persona / Instruction の使い分け
- `persona`: そのステップの役割architect / builder / reviewer など。LLM の振る舞いのトーンに影響
- `instruction`: WHAT を行うかの指示。具体的・明確に書く。ツールの使い方HOWは書かない
- `allowed_tools`: そのステップで実際に必要なツールのみを列挙
## 終了 / 遷移方法
- **設計完了 → build へ**: `transition({next_step: "build", summary: "設計内容のサマリ"})`
- **ユーザーに確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [ListPieces, GetPiece, ReadToolDoc, Read, Glob, Grep, WebSearch, WebFetch]
default_next: build
rules:
- condition: 設計が完了した
next: build
- name: build
edit: false
persona: builder
instruction: |
## Piece 構築フェーズ
@ -96,7 +94,7 @@ movements:
### 注意事項
- name は英小文字・数字・ハイフンのみ
- instruction は WHAT を具体的に書く(曖昧な指示は避ける)
- allowed_tools には必要なツールを過不足なく列挙する
- ツールの可否は Piece に書かない(設定 → ツールで決まる)
- rules の condition は日本語で明確に書く
- `general`、`chat` は削除不可だが更新は可能
@ -105,7 +103,6 @@ movements:
- **設計レベルの見直しが必要 → design に戻る**: `transition({next_step: "design", summary: "..."})`
- **ユーザーに確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [ListPieces, GetPiece, CreatePiece, UpdatePiece, ReadToolDoc, Read, Glob, Grep]
default_next: verify
rules:
- condition: Piece の作成・更新が完了した
@ -114,7 +111,6 @@ movements:
next: design
- name: verify
edit: false
persona: reviewer
instruction: |
## Piece 検証フェーズ
@ -127,9 +123,7 @@ movements:
- name が英小文字・数字・ハイフンのみか
- description が具体的で、LLM が分類に使えるレベルか
- 各 movement の instruction が具体的で曖昧でないか
- allowed_tools に必要なツールが過不足なく含まれているか
- rules に全ての遷移先が明示されているかdefault_next だけに頼っていないか)
- edit: true/false が各 movement の用途に合っているか
- ループの可能性がないかA→B→A が無限に繰り返される構造でないか)
3. 類似の既存 Piece があれば ListPieces + GetPiece で比較し、一貫性を確認する
@ -148,7 +142,6 @@ movements:
- build に差し戻し: `transition({next_step: "build", summary: "指摘"})`
- design に戻す: `transition({next_step: "design", summary: "..."})`
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, ListPieces, GetPiece, ReadToolDoc]
default_next: COMPLETE
rules:
- condition: 修正が必要

View File

@ -11,7 +11,6 @@ initial_movement: dig
movements:
- name: delegate_research
edit: true
persona: orchestrator
instruction: |
dig で立てた並列調査計画に従い、各テーマを delegate で1件ずつ直列に深掘りさせ、
@ -38,14 +37,12 @@ movements:
- 全体のまとめと考察を付ける
- 各テーマの詳細へ相対リンク [theme-{連番}](./research/theme-{連番}.md) で繋ぐ
5. output/report.md を書き終えたら verify へ遷移する
allowed_tools: [delegate, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, GetYouTubeTranscript, SearchYouTube, SearchAmazon, SearchPlaces, GetDirections, ReverseGeocode, XSearch, XUserPosts, XPostDetail, SearchMicrosoftLearn, FetchMicrosoftLearn, 'mcp__*']
default_next: verify
rules:
- condition: output/report.md に統合レポートを作成した
next: verify
- name: dig
edit: true
persona: researcher
instruction: |
## 最初のステップ: 入力把握と調査計画
@ -108,7 +105,6 @@ movements:
- **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})`
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
default_next: analyze
rules:
- condition: 2つ以上の独立した調査テーマがあり、分割して delegate に任せるのが効率的と判断した
@ -119,7 +115,6 @@ movements:
next: dig
- name: analyze
edit: true
persona: analyst
instruction: |
収集した情報を分析し、調査レポートを output/ に作成する。
@ -141,7 +136,6 @@ movements:
画像があるのにテキストだけのレポートにしないこと。
レポート作成中に追加で必要な図・グラフを見つけた場合も DownloadFile で収集して埋め込む。
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
default_next: verify
rules:
- condition: output/ にレポートを書き出した
@ -150,7 +144,6 @@ movements:
next: dig
- name: verify
edit: false
persona: reviewer
instruction: |
output/ のレポートを確認する。
@ -194,7 +187,6 @@ movements:
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "analyze", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache]
default_next: COMPLETE
rules:
- condition: output/ にファイルがない、または内容に不足がある(追加質問への回答に検索根拠が不足している場合も含む)

View File

@ -24,7 +24,6 @@ initial_movement: process
movements:
- name: process
edit: true
persona: slide-designer
instruction: |
## 最初のステップ: 入力把握と構成立案
@ -100,26 +99,6 @@ movements:
- **追加情報が必要で同じ process を続行**: `transition({next_step: "process"})`
- **題材・構成が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗 (pptxgenjs エラー等)**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools:
- Read
- Write
- Edit
- Glob
- Grep
- SetTheme
- AddSlide
- BuildPptx
- ResetSlides
- WebSearch
- WebFetch
- DownloadFile
- ReadImage
- ReadPdf
- ReadDocx
- ReadExcel
- ReadPPTX
- ReadToolDoc
- 'mcp__*'
default_next: verify
rules:
- condition: SetTheme + AddSlide × N + BuildPptx を実行し output/slides.pptx を生成済み
@ -128,7 +107,6 @@ movements:
next: process
- name: verify
edit: false
persona: reviewer
instruction: |
output/ の成果物を確認する。
@ -178,11 +156,6 @@ movements:
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools:
- Read
- Glob
- Grep
- ReadToolDoc
default_next: COMPLETE
rules:
- condition: 成果物が不足または内容に誤りがある

View File

@ -13,7 +13,6 @@ initial_movement: orchestrate
movements:
- name: orchestrate
edit: true
persona: researcher
instruction: |
## このタスクの進め方(オーケストレーター)
@ -61,6 +60,5 @@ movements:
### 原則
- 1投稿につき delegate は1回。深掘りの実作業はサブに任せ、あなたは取得・選別・統合に徹する。
- 取得できなかった事実は正直に書く。データを捏造しない。
allowed_tools: [XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, WebFetch, WebSearch, Read, Write, Edit, Glob, Grep, DownloadFile, delegate, 'mcp__*']
default_next: COMPLETE
rules: []

View File

@ -10,7 +10,6 @@ initial_movement: gather
movements:
- name: gather
edit: true
persona: researcher
instruction: |
## 調査計画
@ -57,7 +56,6 @@ movements:
- **追加収集のため同じ gather を続行**: `transition({next_step: "gather"})`
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, WebFetch, WebSearch, Read, Write, Edit, Glob, Grep, DownloadFile, 'mcp__*']
default_next: analyze
rules:
- condition: 追加収集が必要別のSNS、追加クエリ等
@ -66,7 +64,6 @@ movements:
next: analyze
- name: analyze
edit: true
persona: analyst
instruction: |
output/raw/ の収集データを読み込み、分析してレポートを作成する。
@ -91,7 +88,6 @@ movements:
情報が不足している場合は gather に戻る(追加の検索クエリを明示すること)。
verify からの差し戻しがある場合は、指摘された不足点・期待する修正を優先的に解消すること。
allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, BatchReviewTextWithLLM, MergeReviewedResults, 'mcp__*']
default_next: verify
rules:
- condition: output/report.md にレポートを書き出した
@ -100,7 +96,6 @@ movements:
next: gather
- name: verify
edit: false
persona: supervisor
instruction: |
output/ のレポートを確認する。
@ -141,7 +136,6 @@ movements:
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "analyze", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep]
default_next: COMPLETE
rules:
- condition: output/ にファイルがない、または内容に不足がある

View File

@ -19,7 +19,6 @@ initial_movement: interact
movements:
- name: interact
edit: true
persona: ops-operator
instruction: |
## 詳細ドキュメント (必ず最初に読む)
@ -80,8 +79,6 @@ movements:
## 終了
- 完了: complete({status: "success", result: "..."})
- 中断: complete({status: "aborted", abort_reason: "..."})
- 確認待ち: complete({status: "needs_user_input", missing_info: "..."})
allowed_tools: [SshConsoleEnsure, SshConsoleSend, SshConsoleRun, SshConsoleSnapshot, SshUpload, SshDownload, SshListConnections, WebSearch, WebFetch, DownloadFile, BrowseWeb, Read, Write, Bash, Glob, Grep]
allowed_ssh_connections: ['*']
- 確認待ち: complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})
default_next: COMPLETE
rules: []

View File

@ -21,7 +21,6 @@ initial_movement: execute
movements:
- name: execute
edit: true
persona: ops-operator
instruction: |
## 最初のステップ: タスク把握と接続の選定
@ -32,7 +31,7 @@ movements:
- Config push: ローカルで作成・編集した設定を SshUpload で配信 → SshExec でリロード
- Log fetch: SshDownload でリモートのログを取得 → ローカルで grep / 集計 / 分析
3. タスクで指定された SSH 接続 ID を確認する。指定が無く接続候補が複数ある場合は
`complete({status: "needs_user_input", missing_info: "どの SSH 接続を使うか"})` で確認する
`complete({status: "needs_user_input", missing_info: "どの SSH 接続を使うか", why_no_default: "接続候補が複数あり、推測で選ぶと誤った host を操作する恐れがあるため"})` で確認する
## SshExec の使い方
@ -54,11 +53,11 @@ movements:
## エラーハンドリング (詳細は docs/tools/ssh-tools.md の error code 表)
- `host_key_not_verified`: TOFU 未完了。
`complete({status: "needs_user_input", missing_info: "SSH 接続 <id> の host key を UI で検証してください (Settings → User Folder → SSH Connections → Test)"})` で停止する
`complete({status: "needs_user_input", missing_info: "SSH 接続 <id> の host key を UI で検証してください (Settings → User Folder → SSH Connections → Test)", why_no_default: "host key 未検証のまま接続すると MITM のリスクがあり、エージェント側では信頼判断できないため"})` で停止する
- `host_key_mismatch`: MITM 疑い。**自動でリトライしない**。
`complete({status: "aborted", abort_reason: "host_key_mismatch: <details>"})` で停止する
- `abuse_locked`: 連続失敗で接続がロック。
`complete({status: "needs_user_input", missing_info: "接続が <until> までロックされています。admin に force-unlock を依頼してください"})` で停止する
`complete({status: "needs_user_input", missing_info: "接続が <until> までロックされています。admin に force-unlock を依頼してください", why_no_default: "ロック解除には admin 権限が必要で、エージェントでは解除も迂回もできないため"})` で停止する
- `no_grant` / `access_denied`: 権限不足。admin に grant 追加を依頼するよう user に報告して停止する
- `connect_timeout` / `auth_failed` 等の一時失敗: 同じ command を最大 2 回まで再試行。
それ以上は `complete({status: "aborted", abort_reason: "..."})`
@ -85,15 +84,12 @@ movements:
- **次の verify へ**: `transition({next_step: "verify"})`
- **必要情報不足で停止**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **致命的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [SshExec, SshUpload, SshDownload, SshListConnections, WebSearch, WebFetch, DownloadFile, BrowseWeb, Read, Write, Bash, Glob, Grep]
allowed_ssh_connections: ['*']
default_next: verify
rules:
- condition: output/report.md に ops 結果をまとめた
next: verify
- name: verify
edit: false
persona: reviewer
instruction: |
ops 結果を確認する。
@ -132,7 +128,6 @@ movements:
- 修正必要: `transition({next_step: "execute", summary: "差し戻し指摘"})`
- 機密漏れ: `complete({status: "aborted", abort_reason: "secret_leak: ..."})`
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep]
default_next: COMPLETE
rules:
- condition: ops 報告が不足している

View File

@ -14,7 +14,6 @@ triggers:
movements:
- name: build
edit: true
persona: builder
instruction: |
## ワークスペース・アプリ作成フェーズ
@ -42,14 +41,12 @@ movements:
- **作成・更新できた → verify へ**: `transition({next_step: "verify", summary: "アプリ名と概要"})`
- **依頼が曖昧で作れない**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Edit, Glob, Grep, Bash, ReadToolDoc, ReadImage, TestWorkspaceApp]
default_next: verify
rules:
- condition: アプリの作成・更新が完了した
next: verify
- name: verify
edit: true
persona: reviewer
instruction: |
## 検証フェーズE2E ゲート必須)
@ -97,7 +94,6 @@ movements:
(「作成しました」等のメタ説明ではなく、アプリの内容そのものを書く)。
- **E2E 不合格・設計上の問題 → build へ**: `transition({next_step: "build", summary: "具体的な失敗内容"})`
- **技術的失敗で続行不能**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Edit, Glob, Grep, Bash, ReadToolDoc, TestWorkspaceApp]
default_next: COMPLETE
rules:
- condition: 作り直しが必要E2E 不合格または設計上の問題)

View File

@ -14,7 +14,6 @@ max_movements: 999
initial_movement: collect
movements:
- name: collect
edit: true
persona: researcher
instruction: |
X (Twitter) から AI 技術関連のツイートを収集し、深掘り調査を行う。
@ -57,29 +56,11 @@ movements:
- **次の compose へ**: `transition({next_step: "compose"})`
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools:
- XSearch
- XUserPosts
- XPostDetail
- XTimeline
- XFetchCardMedia
- BrowseWeb
- WebFetch
- WebSearch
- Read
- Write
- Edit
- Glob
- Grep
- Bash
- DownloadFile
- 'mcp__*'
default_next: compose
rules:
- condition: 十分な情報を収集し output/raw/ に書き出した
next: compose
- name: compose
edit: true
persona: writer
instruction: |
output/raw/ の収集データから、articles JSON とヘッドライン記事 Markdown を生成する。
@ -117,14 +98,6 @@ movements:
## verify 由来の指摘がある場合
「これまでのレビュー指摘」がある場合は、指摘事項を漏れなく解消すること。
allowed_tools:
- Read
- Write
- Edit
- Glob
- Grep
- Bash
- 'mcp__*'
default_next: verify
rules:
- condition: 2ファイルarticles JSON + headline MDを書き出した
@ -132,7 +105,6 @@ movements:
- condition: 情報が不十分で追加収集が必要output/raw/ が空を含む)
next: collect
- name: verify
edit: false
persona: supervisor
instruction: |
出力ファイルの存在とフォーマットを確認する。
@ -183,10 +155,6 @@ movements:
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "compose", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools:
- Read
- Glob
- Grep
default_next: COMPLETE
rules:
- condition: ファイルがない、またはフォーマットに不足がある

View File

@ -0,0 +1,33 @@
// Generates docs/superpowers/index.md from docs/superpowers/manifest.yaml.
// Usage: node scripts/gen-design-index.mjs
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
import { renderIndex } from './lib/design-index.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SP = resolve(__dirname, '../docs/superpowers');
// `yaml` lives in ui/node_modules — resolve relative to ui/package.json,
// exactly like scripts/validate-design-docs.mjs does. Guard the resolution so a
// missing `yaml` doesn't throw at import time (which would break any module that
// statically imports buildIndex, e.g. validate-design-docs.mjs --check).
let parseYaml = null;
try {
const require = createRequire(pathToFileURL(resolve(__dirname, '../ui/package.json')));
({ parse: parseYaml } = await import(pathToFileURL(require.resolve('yaml')).href));
} catch {
// resolved lazily-checked in buildIndex()
}
export function buildIndex() {
if (!parseYaml) throw new Error("design-index gen: cannot load 'yaml' from ui/node_modules — run `npm --prefix ui install`");
const manifest = parseYaml(readFileSync(join(SP, 'manifest.yaml'), 'utf-8')) ?? { docs: [] };
return renderIndex(manifest.docs ?? []);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
writeFileSync(join(SP, 'index.md'), buildIndex(), 'utf-8');
console.log('gen: wrote docs/superpowers/index.md');
}

156
scripts/install-systemd.sh Executable file
View File

@ -0,0 +1,156 @@
#!/usr/bin/env bash
#
# Install MAESTRO as a systemd service so it starts automatically on boot.
#
# Fills in deploy/maestro.service (a template) from the current checkout — node
# path, project directory and run user are auto-detected — then installs and
# enables the unit. systemd supervises `node dist/main.js` directly; it does not
# build, so build first (`scripts/server.sh start` or `npm run build`).
#
# scripts/install-systemd.sh # user service (no root, runs as you) [default]
# scripts/install-systemd.sh --print # preview generated unit, install nothing
# scripts/install-systemd.sh --mode system # system-wide service (needs sudo)
# scripts/install-systemd.sh --run-as bot # run as a specific user (system mode only)
# scripts/install-systemd.sh --name maestro # override service name
# scripts/install-systemd.sh --no-start # enable for boot but don't start now
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
TEMPLATE="$PROJECT_DIR/deploy/maestro.service"
MODE="user" # user | system (default: user — no root, runs as you)
NAME="maestro"
RUN_AS="" # system mode only; default resolved below
PRINT_ONLY=0
NO_START=0
die() { echo "error: $*" >&2; exit 1; }
# Consume the value of a `--opt value` flag, erroring cleanly if it's missing
# (a bare `shift 2` on the last arg would abort under `set -e` with no message).
need_val() { [[ $# -ge 2 ]] || die "$1 requires a value"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--mode) need_val "$@"; MODE="$2"; shift 2 ;;
--mode=*) MODE="${1#*=}"; shift ;;
--name) need_val "$@"; NAME="$2"; shift 2 ;;
--name=*) NAME="${1#*=}"; shift ;;
--run-as) need_val "$@"; RUN_AS="$2"; shift 2 ;;
--run-as=*) RUN_AS="${1#*=}"; shift ;;
--print|--dry-run) PRINT_ONLY=1; shift ;;
--no-start) NO_START=1; shift ;;
-h|--help)
# Print the leading comment block (after the shebang) as usage text.
awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"
exit 0 ;;
*) die "unknown argument: $1" ;;
esac
done
[[ "$MODE" == "system" || "$MODE" == "user" ]] || die "--mode must be 'system' or 'user' (got '$MODE')"
[[ "$NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || die "invalid --name '$NAME'"
[[ -f "$TEMPLATE" ]] || die "template not found: $TEMPLATE"
# --- Auto-detect substitution values ------------------------------------------
NODE_BIN="$(command -v node || true)"
[[ -n "$NODE_BIN" ]] || die "node not found in PATH"
if [[ "$MODE" == "system" ]]; then
# Run user: explicit flag > invoking user under sudo > current user.
# Never default to root (the app should not build/run as root).
RUN_USER="${RUN_AS:-${SUDO_USER:-$USER}}"
[[ "$RUN_USER" != "root" ]] || echo "warning: run user resolves to 'root'; pass --run-as <user> to run the app as a non-root user." >&2
# Existence is enforced at install time (below), not for --print previews.
else
# user mode: the service belongs to the invoking user's systemd manager.
# Running under sudo would resolve $USER to root and install a stray unit
# under /root — almost never intended. Steer to the right path instead.
if [[ $EUID -eq 0 ]]; then
die "user mode as root would install under /root. Run WITHOUT sudo, or for a machine-wide service use: --mode system --run-as <user>"
fi
[[ -z "$RUN_AS" ]] || echo "warning: --run-as is ignored in user mode (the service always runs as '$USER'); use --mode system to run as another user." >&2
RUN_USER="$USER"
fi
# --- Preflight (skipped for --print) ------------------------------------------
if [[ "$PRINT_ONLY" -eq 0 ]]; then
[[ -f "$PROJECT_DIR/dist/main.js" ]] || die "dist/main.js missing — build first: scripts/server.sh start (or npm run build)"
if [[ "$MODE" == "system" ]]; then
id "$RUN_USER" >/dev/null 2>&1 || die "run user '$RUN_USER' does not exist (pass --run-as <user>)"
fi
fi
# --- Generate the unit from the template --------------------------------------
generate_unit() {
# Substitute placeholders. PROJECT_DIR/NODE_BIN are absolute paths without
# '|' or '&', safe as sed replacements.
sed \
-e "s|@PROJECT_DIR@|$PROJECT_DIR|g" \
-e "s|@NODE@|$NODE_BIN|g" \
-e "s|@RUN_USER@|$RUN_USER|g" \
"$TEMPLATE" | grep -v '^#' | grep -v '^$' \
| if [[ "$MODE" == "user" ]]; then
# A user manager owns the process — drop User=, use user-session targets,
# and don't wait on the system-only network-online.target.
sed \
-e '/^User=/d' \
-e '/^Wants=network-online.target/d' \
-e 's|^After=network-online.target|After=default.target|' \
-e 's|^WantedBy=multi-user.target|WantedBy=default.target|'
else
cat
fi
}
UNIT_TEXT="$(generate_unit)"
if [[ "$PRINT_ONLY" -eq 1 ]]; then
echo "# --- generated ${NAME}.service (mode=${MODE}) ---"
echo "$UNIT_TEXT"
exit 0
fi
# --- Install ------------------------------------------------------------------
if [[ "$MODE" == "system" ]]; then
UNIT_PATH="/etc/systemd/system/${NAME}.service"
if [[ $EUID -eq 0 ]]; then
priv() { "$@"; }
else
command -v sudo >/dev/null 2>&1 || die "system install needs root; re-run with sudo"
priv() { sudo "$@"; }
fi
echo "Installing $UNIT_PATH (run user: $RUN_USER)…"
printf '%s\n' "$UNIT_TEXT" | priv tee "$UNIT_PATH" >/dev/null
priv systemctl daemon-reload
if [[ "$NO_START" -eq 1 ]]; then
priv systemctl enable "$NAME"
echo "Enabled for boot. Start later with: sudo systemctl start $NAME"
else
priv systemctl enable --now "$NAME"
fi
echo "Done. Status: systemctl status $NAME"
echo " Logs: journalctl -u $NAME -f"
else
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
UNIT_PATH="$UNIT_DIR/${NAME}.service"
mkdir -p "$UNIT_DIR"
echo "Installing $UNIT_PATH"
printf '%s\n' "$UNIT_TEXT" > "$UNIT_PATH"
systemctl --user daemon-reload
# Linger lets the user service run at boot without an active login session.
if ! loginctl enable-linger "$USER" 2>/dev/null; then
echo "warning: could not enable linger for '$USER'; the service may not start until you log in." >&2
echo " Retry with: sudo loginctl enable-linger $USER" >&2
fi
if [[ "$NO_START" -eq 1 ]]; then
systemctl --user enable "$NAME"
echo "Enabled for boot. Start later with: systemctl --user start $NAME"
else
systemctl --user enable --now "$NAME"
fi
echo "Done. Status: systemctl --user status $NAME"
echo " Logs: journalctl --user -u $NAME -f"
fi

View File

@ -0,0 +1,136 @@
export const STATUSES = ['design', 'in-progress', 'shipped', 'superseded', 'abandoned', 'unknown'];
const DATE_RE = /^(\d{4}-\d{2}-\d{2})-/;
export function docParts(relPath) {
const archived = relPath.startsWith('archived/');
const type = /(^|\/)specs\//.test(relPath) ? 'spec' : 'plan';
const file = relPath.split('/').pop();
const base = file.replace(/\.md$/, '');
const m = base.match(DATE_RE);
return { type, date: m ? m[1] : null, base, archived };
}
export function deriveTopicGuess(relPath) {
const { type, base } = docParts(relPath);
let s = base.replace(DATE_RE, '');
// spec のみ、末尾がちょうど -design / -plan のときだけ除去redesign を守る)
if (type === 'spec') s = s.replace(/-(design|plan)$/, '');
return s;
}
export function extractTitle(mdText) {
const line = mdText.split('\n').find((l) => l.startsWith('# '));
if (!line) return '';
let t = line.slice(2).trim();
t = t.replace(/\s*(—\s*設計書|実装計画|設計書)\s*$/, '').trim();
return t;
}
export function hasSupersededBy(v) {
return typeof v === 'string' && v.trim() !== '';
}
export function scaffoldEntryObject(relPath, mdText) {
const { type, archived } = docParts(relPath);
const topic = deriveTopicGuess(relPath);
const title = extractTitle(mdText) || topic;
return { path: relPath, title, type, topic, status: archived ? 'shipped' : 'unknown', pr: [], superseded_by: null };
}
export function findUnregistered(existingRelPaths, manifestPaths) {
return existingRelPaths.filter((p) => !manifestPaths.has(p));
}
const REQUIRED = ['path', 'title', 'type', 'topic', 'status'];
export function validateManifest(entries, existingRelPaths) {
const errors = [];
const warnings = [];
const manifestPaths = new Set(entries.map((e) => e.path));
const existing = new Set(existingRelPaths);
for (const e of entries) {
for (const k of REQUIRED) {
if (e[k] === undefined || e[k] === null || e[k] === '') errors.push(`${e.path ?? '(no path)'}: missing required field '${k}'`);
}
if (e.status && !STATUSES.includes(e.status)) errors.push(`${e.path}: invalid status '${e.status}'`);
if (e.type && e.type !== 'spec' && e.type !== 'plan') errors.push(`${e.path}: invalid type '${e.type}'`);
if (e.path && !existing.has(e.path)) errors.push(`${e.path}: manifest entry does not exist on disk (欠落)`);
if (hasSupersededBy(e.superseded_by) && !manifestPaths.has(e.superseded_by)) errors.push(`${e.path}: superseded_by '${e.superseded_by}' not in manifest`);
if (hasSupersededBy(e.superseded_by) && e.status !== 'superseded') warnings.push(`${e.path}: has superseded_by but status is '${e.status}' (expected superseded)`);
if (e.status === 'superseded' && !hasSupersededBy(e.superseded_by)) warnings.push(`${e.path}: status superseded but superseded_by empty`);
}
for (const p of existingRelPaths) {
if (!manifestPaths.has(p)) errors.push(`${p}: file exists but is not registered in manifest (孤児)`);
}
const unknownCount = entries.filter((e) => e.status === 'unknown').length;
if (unknownCount > 0) warnings.push(`${unknownCount} entries still have status 'unknown' (curation pending)`);
return { errors, warnings };
}
// ─── renderIndex and helpers ──────────────────────────────────────────────────
export const PR_BASE = 'https://gitea.example.com/your-org/maestro/pulls/';
export const BADGE = {
design: '🔵',
'in-progress': '🟡',
shipped: '🟢',
superseded: '⚪',
abandoned: '⚫',
unknown: '❔',
};
export function escapeCell(s) {
return String(s ?? '')
.replace(/\r?\n/g, ' ')
.replace(/\|/g, '\\|')
.replace(/\[/g, '')
.replace(/\]/g, '');
}
export function prUrl(n) {
return PR_BASE + n;
}
function row(e) {
const { date } = docParts(e.path);
const link = `[${escapeCell(e.title)}](./${e.path})`;
const prs = (e.pr ?? []).map((n) => `[#${n}](${prUrl(n)})`).join(' ');
const succ = hasSupersededBy(e.superseded_by) ? `[→](./${e.superseded_by})` : '';
return `| ${BADGE[e.status] ?? '❔'} | ${e.type} | ${link} | ${date ?? ''} | ${prs} | ${succ} |`;
}
function groupSection(title, entries) {
const byTopic = new Map();
for (const e of entries) {
const k = e.topic && e.topic.trim() ? e.topic : '(未分類)';
if (!byTopic.has(k)) byTopic.set(k, []);
byTopic.get(k).push(e);
}
const topics = [...byTopic.keys()].sort();
let out = `\n## ${title}\n`;
for (const t of topics) {
const rows = byTopic
.get(t)
.slice()
.sort((a, b) => {
const d = (docParts(a.path).date ?? '').localeCompare(docParts(b.path).date ?? '');
return d !== 0 ? d : a.path.localeCompare(b.path);
});
out += `\n### ${escapeCell(t)}\n\n| 段階 | 種別 | 資料 | 日付 | PR | 後継 |\n|---|---|---|---|---|---|\n`;
out += rows.map(row).join('\n') + '\n';
}
return out;
}
export function renderIndex(entries) {
const active = entries.filter((e) => !docParts(e.path).archived);
const archived = entries.filter((e) => docParts(e.path).archived);
const counts = STATUSES.map((s) => `${BADGE[s]} ${entries.filter((e) => e.status === s).length}`).join(' ・ ');
let out = `# 設計資料インデックス\n\n> 生成物。編集は \`docs/superpowers/manifest.yaml\` を直し \`node scripts/gen-design-index.mjs\` で再生成。\n\n${counts}active ${active.length} / archived ${archived.length}\n`;
out += groupSection('Active', active);
out += groupSection('Archived', archived);
return out;
}

View File

@ -0,0 +1,167 @@
import { describe, it, expect } from 'vitest';
import { docParts, deriveTopicGuess, extractTitle, hasSupersededBy, STATUSES, scaffoldEntryObject, findUnregistered } from './design-index.mjs';
describe('docParts', () => {
it('spec のパスを分解', () => {
expect(docParts('specs/2026-07-02-foo-design.md')).toEqual({ type: 'spec', date: '2026-07-02', base: '2026-07-02-foo-design', archived: false });
});
it('archived plan', () => {
expect(docParts('archived/plans/2026-03-14-bar.md')).toEqual({ type: 'plan', date: '2026-03-14', base: '2026-03-14-bar', archived: true });
});
});
describe('deriveTopicGuess', () => {
it('spec の -design を除く', () => {
expect(deriveTopicGuess('specs/2026-07-01-workspace-task-search-design.md')).toBe('workspace-task-search');
});
it('plan は接尾なし', () => {
expect(deriveTopicGuess('plans/2026-07-02-workspace-task-search.md')).toBe('workspace-task-search');
});
it('redesign を誤って削らない', () => {
expect(deriveTopicGuess('specs/2026-03-25-memory-redesign.md')).toBe('memory-redesign');
});
});
describe('extractTitle', () => {
it('見出しから接尾を除く', () => {
expect(extractTitle('# ワークスペース内タスク横断検索 実装計画\n\n本文')).toBe('ワークスペース内タスク横断検索');
expect(extractTitle('# A2A プロトコル対応 — 設計書\n')).toBe('A2A プロトコル対応');
});
it('見出し無しは空', () => { expect(extractTitle('本文だけ')).toBe(''); });
});
describe('hasSupersededBy', () => {
it('null/空はなし', () => { expect(hasSupersededBy(null)).toBe(false); expect(hasSupersededBy('')).toBe(false); });
it('文字列はあり', () => { expect(hasSupersededBy('specs/x.md')).toBe(true); });
});
it('STATUSES は6値', () => { expect(STATUSES).toHaveLength(6); });
describe('scaffoldEntryObject', () => {
it('active は unknown, archived は shipped', () => {
const a = scaffoldEntryObject('plans/2026-07-02-foo.md', '# Foo 実装計画\n');
expect(a).toMatchObject({ path: 'plans/2026-07-02-foo.md', type: 'plan', topic: 'foo', status: 'unknown', pr: [], superseded_by: null });
expect(a.title).toBe('Foo');
const b = scaffoldEntryObject('archived/specs/2026-03-14-bar-design.md', '# Bar — 設計書\n');
expect(b.status).toBe('shipped');
expect(b.type).toBe('spec');
});
});
describe('findUnregistered', () => {
it('manifest に無いものだけ返す', () => {
expect(findUnregistered(['a', 'b', 'c'], new Set(['b']))).toEqual(['a', 'c']);
});
});
import { renderIndex, escapeCell, prUrl, BADGE } from './design-index.mjs';
describe('escapeCell', () => {
it('パイプと改行を無害化', () => {
expect(escapeCell('a|b\nc')).toBe('a\\|b c');
});
});
describe('renderIndex', () => {
const entries = [
{ path: 'specs/2026-07-01-x-design.md', title: 'X', type: 'spec', topic: 'x', status: 'design', pr: [], superseded_by: null },
{ path: 'plans/2026-07-02-x.md', title: 'X 計画', type: 'plan', topic: 'x', status: 'design', pr: [712], superseded_by: null },
{ path: 'archived/plans/2026-03-14-y.md', title: 'Y', type: 'plan', topic: 'y', status: 'shipped', pr: [], superseded_by: null },
];
const md = renderIndex(entries);
it('サマリに件数', () => { expect(md).toMatch(/🔵\s*2/); expect(md).toMatch(/🟢\s*1/); });
it('topic 見出しで spec/plan が同居', () => {
const xSection = md.slice(md.indexOf('x'));
expect(xSection).toContain('X');
expect(xSection).toContain('X 計画');
});
it('PR リンクを描く', () => { expect(md).toContain(prUrl(712)); });
it('archived は別セクション', () => { expect(md).toMatch(/archived|アーカイブ/i); });
it('決定的2回同じ', () => { expect(renderIndex(entries)).toBe(md); });
it('superseded_by があると後継リンクを描く', () => {
const supersededEntry = {
path: 'specs/2026-01-01-old-design.md',
title: 'Old',
type: 'spec',
topic: 'old',
status: 'superseded',
pr: [],
superseded_by: 'specs/2026-01-01-z-design.md',
};
const successor = {
path: 'specs/2026-01-01-z-design.md',
title: 'Z',
type: 'spec',
topic: 'old',
status: 'shipped',
pr: [],
superseded_by: null,
};
const out = renderIndex([supersededEntry, successor]);
expect(out).toContain('[→](./specs/2026-01-01-z-design.md)');
});
it('同日・同トピック 2エントリを逆順に渡しても同じ出力C1 order-independence', () => {
const e1 = { path: 'specs/2026-05-01-alpha-design.md', title: 'Alpha', type: 'spec', topic: 'tie', status: 'design', pr: [], superseded_by: null };
const e2 = { path: 'specs/2026-05-01-beta-design.md', title: 'Beta', type: 'spec', topic: 'tie', status: 'design', pr: [], superseded_by: null };
expect(renderIndex([e1, e2])).toBe(renderIndex([e2, e1]));
});
it('未知の status は ❔ バッジにフォールバック', () => {
const weirdEntry = { path: 'specs/2026-06-01-foo-design.md', title: 'Foo', type: 'spec', topic: 'foo', status: 'weird', pr: [], superseded_by: null };
const out = renderIndex([weirdEntry]);
expect(out).toContain('❔');
});
});
import { validateManifest } from './design-index.mjs';
describe('validateManifest', () => {
const ok = { path: 'specs/2026-01-01-a-design.md', title: 'A', type: 'spec', topic: 'a', status: 'shipped', pr: [], superseded_by: null };
it('整合していれば error 無し', () => {
const r = validateManifest([ok], ['specs/2026-01-01-a-design.md']);
expect(r.errors).toEqual([]);
});
it('孤児(ファイルはあるが manifest に無い)を error に', () => {
const r = validateManifest([ok], ['specs/2026-01-01-a-design.md', 'plans/2026-01-02-b.md']);
expect(r.errors.some((e) => e.includes('2026-01-02-b.md'))).toBe(true);
});
it('欠落manifest にあるがファイルが無い)を error に', () => {
const r = validateManifest([ok], []);
expect(r.errors.some((e) => e.includes('does not exist') || e.includes('欠落'))).toBe(true);
});
it('不正 status を error に', () => {
const bad = { ...ok, status: 'done' };
const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
expect(r.errors.some((e) => e.includes('status'))).toBe(true);
});
it('superseded_by あり + status!=superseded は warning', () => {
const s = { ...ok, superseded_by: 'specs/2026-01-01-a-design.md' };
const r = validateManifest([s], ['specs/2026-01-01-a-design.md']);
expect(r.warnings.some((w) => w.includes('superseded'))).toBe(true);
});
it('必須フィールド欠如title が空)を error に', () => {
const bad = { ...ok, title: '' };
const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
expect(r.errors.some((e) => e.includes("'title'"))).toBe(true);
});
it('不正 type を error に', () => {
const bad = { ...ok, type: 'note' };
const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
expect(r.errors.some((e) => e.includes('type'))).toBe(true);
});
it('superseded_by が manifest 内に無い path を error に', () => {
const bad = { ...ok, status: 'superseded', superseded_by: 'specs/9999-nonexistent.md' };
const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
expect(r.errors.some((e) => e.includes('superseded_by') && e.includes('9999-nonexistent'))).toBe(true);
});
it('status=superseded かつ superseded_by 空は warning', () => {
const bad = { ...ok, status: 'superseded', superseded_by: null };
const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
expect(r.warnings.some((w) => w.includes('superseded_by'))).toBe(true);
});
it('status=unknown のエントリは warning に件数を含む', () => {
const u1 = { ...ok, path: 'specs/2026-01-01-a-design.md', status: 'unknown' };
const u2 = { ...ok, path: 'plans/2026-01-03-c.md', status: 'unknown' };
const r = validateManifest([u1, u2], ['specs/2026-01-01-a-design.md', 'plans/2026-01-03-c.md']);
expect(r.warnings.some((w) => w.includes('2') && w.includes('unknown'))).toBe(true);
});
});

View File

@ -0,0 +1,62 @@
// Appends unregistered docs/superpowers/**/*.md entries to manifest.yaml.
// Uses yaml.parseDocument for comment-preserving round-trip.
// Idempotent: re-running adds 0 entries when all docs are already registered.
// Usage: node scripts/scaffold-manifest.mjs
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
import { dirname, resolve, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
import { scaffoldEntryObject, findUnregistered } from './lib/design-index.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SP = resolve(__dirname, '../docs/superpowers');
// `yaml` lives in ui/node_modules — same resolution pattern as validate-design-docs.mjs
const require = createRequire(pathToFileURL(resolve(__dirname, '../ui/package.json')));
const YAML = await import(pathToFileURL(require.resolve('yaml')).href);
const SUBDIRS = ['specs', 'plans', 'archived/specs', 'archived/plans'];
function listDocs() {
const out = [];
for (const d of SUBDIRS) {
const abs = join(SP, d);
if (!existsSync(abs)) continue;
for (const f of readdirSync(abs)) {
if (f.endsWith('.md')) out.push(`${d}/${f}`);
}
}
return out.sort();
}
const mfPath = join(SP, 'manifest.yaml');
const rawYaml = existsSync(mfPath) ? readFileSync(mfPath, 'utf-8') : 'docs:\n';
const doc = YAML.parseDocument(rawYaml);
// Ensure `docs` key exists as a sequence (handle null scalar from 'docs:\n' seed)
const existingSeq = doc.get('docs', true);
if (!(existingSeq instanceof YAML.YAMLSeq)) {
doc.set('docs', new YAML.YAMLSeq());
}
const seq = doc.get('docs', true); // true = return the node itself, not the JS value
seq.flow = false; // force block style for human curability
// Build the set of already-registered paths
const registered = new Set(
(seq.items ?? []).map((n) => n.get('path'))
);
const unregistered = findUnregistered(listDocs(), registered);
for (const rel of unregistered) {
const md = readFileSync(join(SP, rel), 'utf-8');
const entry = scaffoldEntryObject(rel, md);
const node = doc.createNode(entry);
// Attach # TODO: verify topic comment to the topic scalar node
const topicItem = node.get('topic', true);
if (topicItem) topicItem.comment = ' TODO: verify topic';
seq.add(node);
}
writeFileSync(mfPath, String(doc), 'utf-8');
console.log(`scaffold: added ${unregistered.length} entries (total ${seq.items.length})`);

View File

@ -0,0 +1,58 @@
// Validates docs/superpowers/manifest.yaml against files on disk.
// Usage: node scripts/validate-design-docs.mjs [--check]
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { dirname, resolve, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
import { validateManifest } from './lib/design-index.mjs';
import { buildIndex } from './gen-design-index.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SP = resolve(__dirname, '../docs/superpowers');
// `yaml` lives in ui/node_modules — resolve relative to ui/package.json,
// exactly like scripts/validate-help-docs.mjs does.
let parseYaml;
try {
const require = createRequire(pathToFileURL(resolve(__dirname, '../ui/package.json')));
({ parse: parseYaml } = await import(pathToFileURL(require.resolve('yaml')).href));
} catch {
console.error("design-docs validation: cannot load 'yaml' from ui/node_modules — run `npm --prefix ui install`");
process.exit(1);
}
const SUBDIRS = ['specs', 'plans', 'archived/specs', 'archived/plans'];
export function listDocs() {
const out = [];
for (const d of SUBDIRS) {
const abs = join(SP, d);
if (!existsSync(abs)) continue;
for (const f of readdirSync(abs)) if (f.endsWith('.md')) out.push(`${d}/${f}`);
}
return out.sort();
}
// Main guard: only run validation side-effects when executed directly.
// Importing this module (e.g. to use listDocs) will NOT trigger the block below.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
// FIX I-1: guard against missing manifest.yaml (Task 3 hasn't generated it yet)
const manifestRaw = existsSync(join(SP, 'manifest.yaml'))
? readFileSync(join(SP, 'manifest.yaml'), 'utf-8')
: 'docs: []';
const manifest = parseYaml(manifestRaw) ?? { docs: [] };
const { errors, warnings } = validateManifest(manifest.docs ?? [], listDocs());
for (const w of warnings) console.warn(`[warn] ${w}`);
for (const e of errors) console.error(`[error] ${e}`);
if (process.argv.includes('--check')) {
const cur = existsSync(join(SP, 'index.md')) ? readFileSync(join(SP, 'index.md'), 'utf-8') : '';
if (cur !== buildIndex()) {
console.error('[error] index.md is stale — run node scripts/gen-design-index.mjs');
process.exit(1);
}
}
if (errors.length) { console.error(`design-docs validation FAILED (${errors.length} errors)`); process.exit(1); }
console.log(`design-docs validation OK (${(manifest.docs ?? []).length} entries, ${warnings.length} warnings)`);
}

View File

@ -0,0 +1,64 @@
/**
* A2A OIDC e2e
*
* oidc-provider e2eoidc-e2e.test.ts / a2a-card-e2e.test.ts
* cookie HTTP base64url
* import
*/
export function b64url(b: Buffer): string {
return b.toString('base64url');
}
/**
* fetch cookie cookie jar HTTP
* oidc-provider `_interaction` / `_session` / `_interaction_resume`+ `.legacy` / `.sig`
* set-cookie name=value Cookie
*/
export class Client {
private jar = new Map<string, string>();
private storeCookies(res: Response): void {
const setCookies = res.headers.getSetCookie?.() ?? [];
for (const sc of setCookies) {
const first = sc.split(';')[0];
const eq = first.indexOf('=');
if (eq < 0) continue;
const name = first.slice(0, eq).trim();
const value = first.slice(eq + 1).trim();
// 値が空 = サーバが cookie を消去している → jar からも削除
if (value === '') this.jar.delete(name);
else this.jar.set(name, value);
}
}
private cookieHeader(): string {
return [...this.jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
async get(url: string, extraHeaders: Record<string, string> = {}): Promise<Response> {
const headers: Record<string, string> = { ...extraHeaders };
if (this.jar.size) headers.cookie = this.cookieHeader();
const res = await fetch(url, {
redirect: 'manual',
headers,
});
this.storeCookies(res);
return res;
}
async postForm(url: string, body: Record<string, string>): Promise<Response> {
const headers: Record<string, string> = {
'content-type': 'application/x-www-form-urlencoded',
};
if (this.jar.size) headers.cookie = this.cookieHeader();
const res = await fetch(url, {
method: 'POST',
redirect: 'manual',
headers,
body: new URLSearchParams(body).toString(),
});
this.storeCookies(res);
return res;
}
}

View File

@ -0,0 +1,123 @@
// @vitest-environment node
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../../db/repository.js';
import { createA2aRouter, createSpaceA2aAdminRouter } from './a2a-api.js';
function fakeProvider(token: string, payload: { accountId: string; grantId: string; scope?: string }) {
return { AccessToken: { async find(v: string) { return v === token ? payload : undefined; } } } as any;
}
describe('a2a-api', () => {
let repo: Repository;
beforeEach(() => {
repo = new Repository(':memory:');
// Seed owner user u1
(repo as any).db.prepare(
"INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) " +
"VALUES ('u1','u1@test','User One',NULL,'user','active','2026-01-01','2026-01-01')",
).run();
// Seed intruder user u2
(repo as any).db.prepare(
"INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) " +
"VALUES ('u2','u2@test','User Two',NULL,'user','active','2026-01-01','2026-01-01')",
).run();
// Seed private space s1 owned by u1 (default visibility='private')
(repo as any).db.prepare(
"INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES ('s1','S','case','u1','private')",
).run();
// Seed public space s2 owned by u1 (intruder can see it but can't manage)
(repo as any).db.prepare(
"INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES ('s2','S2','case','u1','public')",
).run();
repo.setSpaceA2aSkills('s1', ['research']);
repo.createA2aDelegation({
id: 'd1', userId: 'u1', clientId: 'c', grantId: 'g1',
grantedSpaceIds: ['s1'], grantedSkills: ['research'],
audience: 'https://m/a2a', expiresAt: null, revokedAt: null,
});
});
function appWithCard() {
const app = express();
app.use(createA2aRouter({
provider: fakeProvider('tok', { accountId: 'u1', grantId: 'g1', scope: 'openid a2a.read' }),
repo,
baseUrl: 'https://m',
issuer: 'https://m/oidc',
version: '1.0.0',
}));
return app;
}
it('serves the base card unauthenticated', async () => {
const res = await request(appWithCard()).get('/.well-known/agent-card.json');
expect(res.status).toBe(200);
expect(res.body.skills).toEqual([]);
expect(res.body.supportsAuthenticatedExtendedCard).toBe(true);
});
it('401 on extended card without bearer', async () => {
const res = await request(appWithCard()).get('/a2a/agent-card/extended');
expect(res.status).toBe(401);
});
it('extended card scoped to delegation with valid token', async () => {
const res = await request(appWithCard())
.get('/a2a/agent-card/extended')
.set('Authorization', 'Bearer tok');
expect(res.status).toBe(200);
expect(res.body.skills.map((s: { id: string }) => s.id)).toEqual(['research']);
});
describe('owner allowlist API', () => {
function makeApp(userId: string) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as any).user = { id: userId, role: 'user' }; next(); });
app.use('/api/local/spaces', createSpaceA2aAdminRouter(repo, true));
return app;
}
it('404 (existence hidden) when space is private and caller cannot see it', async () => {
// s1 is private, owned by u1; u2 (intruder) cannot see it → 404 not 403
const res = await request(makeApp('u2'))
.put('/api/local/spaces/s1/a2a-skills')
.send({ skills: ['research', 'summarize'] });
expect(res.status).toBe(404);
});
it('403 (forbidden) when space is public but caller is not the manager', async () => {
// s2 is public, owned by u1; u2 can see it but cannot manage → 403
const res = await request(makeApp('u2'))
.put('/api/local/spaces/s2/a2a-skills')
.send({ skills: ['research'] });
expect(res.status).toBe(403);
});
it('200 for owner, skills persisted and deduped', async () => {
const res = await request(makeApp('u1'))
.put('/api/local/spaces/s1/a2a-skills')
.send({ skills: ['research', 'summarize', 'research'] });
expect(res.status).toBe(200);
// dedup: 'research' appears once
expect(res.body.skills).toEqual(['research', 'summarize']);
expect(repo.getSpaceA2aSkills('s1')).toEqual(['research', 'summarize']);
});
it('200 GET for owner', async () => {
const res = await request(makeApp('u1'))
.get('/api/local/spaces/s1/a2a-skills');
expect(res.status).toBe(200);
expect(res.body.skills).toEqual(['research']);
});
it('404 GET when private space not visible to caller', async () => {
const res = await request(makeApp('u2'))
.get('/api/local/spaces/s1/a2a-skills');
expect(res.status).toBe(404);
});
});
});

162
src/bridge/a2a/a2a-api.ts Normal file
View File

@ -0,0 +1,162 @@
/**
* A2A Agent Card + API
*
* createA2aRouter GET /.well-known/agent-card.json ()
* GET /a2a/agent-card/extended (Bearer )
* createSpaceA2aAdminRouter GET/PUT /:id/a2a-skills (owner/admin )
*/
import { Router } from 'express';
import express from 'express';
import type Provider from 'oidc-provider';
import type { Repository } from '../../db/repository.js';
import { authenticateA2aRequest } from './token-auth.js';
import { buildBaseCard, buildExtendedCard } from './agent-card.js';
import { computeEffectiveScope } from './delegation.js';
import { canManageSpace } from '../visibility.js';
import { viewerOf } from '../space-viewer.js';
import { logger } from '../../logger.js';
export interface A2aRouterDeps {
provider: Provider;
repo: Repository;
baseUrl: string;
issuer: string;
version: string;
}
/**
* Base card (unauthenticated public)
* extended card (A2A Bearer token )
*
* /api requireAuth
* Base card Extended card authenticateA2aRequest
*/
export function createA2aRouter(deps: A2aRouterDeps): Router {
const router = Router();
// ── Base card: 未認証で取得可能 ──────────────────────────────────────
router.get('/.well-known/agent-card.json', (_req, res) => {
const card = buildBaseCard({
baseUrl: deps.baseUrl,
issuer: deps.issuer,
version: deps.version,
});
res.json(card);
});
// ── Extended card: valid A2A Bearer token 必須 ────────────────────────
router.get('/a2a/agent-card/extended', async (req, res) => {
try {
const nowIso = new Date().toISOString();
// IncomingHttpHeaders は Record<string,unknown> にキャスト可能
const principal = await authenticateA2aRequest(
deps.provider,
deps.repo,
{ headers: req.headers as unknown as Record<string, unknown> },
nowIso,
);
if (!principal) {
res.status(401).json({ error: 'unauthorized' });
return;
}
// acting user の可視スペース・スキルを delegation の AND 交差で算出IDOR 防止)
const scope = await computeEffectiveScope(deps.repo, principal);
if (!scope) {
res.status(401).json({ error: 'unknown or inactive principal' });
return;
}
// 委任スコープと可視スペースの AND 交差で extended card を構築
const card = buildExtendedCard(principal, {
baseUrl: deps.baseUrl,
issuer: deps.issuer,
version: deps.version,
userVisibleSpaceIds: scope.userVisibleSpaceIds,
allowlistsBySpace: scope.allowlistsBySpace,
});
// 監査ログbest-effort — 失敗してもカードは返す)
try {
await deps.repo.addAuditLog(null, 'a2a.card.extended', principal.actingUserId, {
clientId: principal.clientId,
});
} catch {
// intentionally swallowed
}
res.json(card);
} catch (err) {
logger.warn(`[a2a] extended card failed: ${err}`);
res.status(500).json({ error: 'internal error' });
}
});
return router;
}
/**
* A2A API
* /api/local/spaces requireAuth
*
* GET /:id/a2a-skills
* PUT /:id/a2a-skills
*
* owner admin
*/
export function createSpaceA2aAdminRouter(repo: Repository, authActive: boolean): Router {
const router = Router();
const jsonParser = express.json();
// GET /:id/a2a-skills
router.get('/:id/a2a-skills', async (req, res) => {
const viewer = viewerOf(req, authActive);
// space-api.ts と同様: getSpace({viewer}) でアクセス不可 or 非存在 → 404,
// 見えるが管理権限なし → 403 (existence hidden)
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) {
res.status(404).json({ error: 'not found' });
return;
}
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
res.status(403).json({ error: 'forbidden' });
return;
}
res.json({ skills: repo.getSpaceA2aSkills(space.id) });
});
// PUT /:id/a2a-skills
router.put('/:id/a2a-skills', jsonParser, async (req, res) => {
const viewer = viewerOf(req, authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) {
res.status(404).json({ error: 'not found' });
return;
}
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
res.status(403).json({ error: 'forbidden' });
return;
}
const { skills: rawSkills } = (req.body ?? {}) as { skills?: unknown };
if (!Array.isArray(rawSkills) || rawSkills.some(s => typeof s !== 'string')) {
res.status(400).json({ error: 'skills must be an array of strings' });
return;
}
const skills = [...new Set(rawSkills as string[])];
repo.setSpaceA2aSkills(space.id, skills);
try {
await repo.addAuditLog(null, 'a2a.space.skills.set', viewer.id, { spaceId: req.params.id, skills });
} catch {
// best-effort — failure must not block the response
}
res.json({ skills });
});
return router;
}

View File

@ -0,0 +1,391 @@
// @vitest-environment node
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import express from 'express';
import http from 'http';
import type Provider from 'oidc-provider';
import { createHash, randomBytes, randomUUID } from 'crypto';
import { mkdtempSync, mkdirSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { DefaultRequestHandler } from '@a2a-js/sdk/server';
import { jsonRpcHandler } from '@a2a-js/sdk/server/express';
import { Repository, localTaskRepoName } from '../../db/repository.js';
import { createA2aOidcProvider, mountA2aOidc } from './oidc-provider.js';
import { buildBaseCard } from './agent-card.js';
import { makeExtendedCardProvider } from './request-handler.js';
import { SqliteA2aTaskStore } from './task-store.js';
import { MaestroA2aExecutor } from './executor.js';
import { makeA2aUserBuilder } from './user-builder.js';
import { A2aTaskReconciler } from './reconciler.js';
import { b64url, Client } from './__e2e-helpers.js';
/**
* Plan 2C-1 Task 6 A2A e2e
*
* oidc-providerPlan 1+ consentPlan 2A+ DefaultRequestHandlerPlan 2B+
* A2aTaskReconcilerPlan 2C-1 http server
* authorization_code + PKCE JSON-RPC Worker / LLM 使
* DB succeeded a2a-exec-e2e
*
* request-handler.ts mountA2aRequestHandler executor poll
* buildBaseCard / makeExtendedCardProvider / SqliteA2aTaskStore /
* MaestroA2aExecutor / makeA2aUserBuilder handler executor sleep
* `executorParked = true` executor Tasksubmitted
* publish park /executor
* reconciler
* case 4blocking:true park poll completed
*
* :
* 1. blocking:false terminal Task
* 2. job succeeded + output reconcileOnce() tasks/get completed + artifact
* 3. 再起動シナリオ: executor working task reconcileOnce()
* 4. blocking:true completed
*/
describe('A2A async e2e (blocking:false + tasks/get + reconciler convergence + restart)', () => {
let server: http.Server;
let base: string;
let audience: string;
let repo: Repository;
let provider: Provider;
let reconciler: A2aTaskReconciler;
const clientId = 'a2a_async_e2e';
const redirectUri = 'https://client.test/cb';
const userId = 'user-1';
const spaceId = 'space-research';
const RESULT_TEXT = 'A2A async round-trip artifact OK — 非ブロッキングの結果ファイル';
let outDir: string; // tmp workspace with output/result.txt
/** executor の poll を park永久 sleepにするか通常 poll にするかを切り替える可変フラグ。 */
let executorParked = true;
/** ジョブを succeeded に反転し、worktree_path を outDir に向ける(成果物解決用)。 */
function completeJob(jobId: string): void {
(repo as any).db
.prepare("UPDATE jobs SET status = 'succeeded', worktree_path = ? WHERE id = ?")
.run(outDir, jobId);
}
/** case 4 用の一時 completer: queued ジョブを succeeded へ反転し続けるexec-e2e と同技法)。 */
function startCompleter(): () => void {
let stop = false;
const db = (repo as any).db;
const selQueued = db.prepare("SELECT id, issue_number FROM jobs WHERE status = 'queued'");
const updJob = db.prepare("UPDATE jobs SET status = 'succeeded', worktree_path = ? WHERE id = ?");
const updTask = db.prepare("UPDATE local_tasks SET workspace_path = ? WHERE id = ?");
(async () => {
while (!stop) {
const rows = selQueued.all() as Array<{ id: string; issue_number: number }>;
for (const r of rows) {
updJob.run(outDir, r.id);
updTask.run(outDir, r.issue_number);
}
await new Promise(res => setTimeout(res, 5));
}
})();
return () => { stop = true; };
}
beforeAll(async () => {
repo = new Repository(':memory:');
repo.createA2aClient({
clientId, name: 'Async E2E', redirectUris: [redirectUri],
secretHash: null, tokenEndpointAuthMethod: 'none',
agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'admin',
});
(repo as any).db.prepare(
"INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) " +
"VALUES (?, ?, ?, NULL, 'user', 'active', '2026-01-01', '2026-01-01')",
).run(userId, 'u1@test', 'User One');
(repo as any).db.prepare(
"INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Research', 'case', ?, 'private')",
).run(spaceId, userId);
repo.setSpaceA2aSkills(spaceId, ['research']);
outDir = mkdtempSync(join(tmpdir(), 'a2a-async-ws-'));
mkdirSync(join(outDir, 'output'), { recursive: true });
writeFileSync(join(outDir, 'output', 'result.txt'), RESULT_TEXT);
const secretsDir = mkdtempSync(join(tmpdir(), 'a2a-async-e2e-'));
const app = express();
app.use((req, _res, next) => { (req as any).user = { id: userId, role: 'user' }; next(); });
server = http.createServer(app);
await new Promise<void>(r => server.listen(0, '127.0.0.1', r));
const port = (server.address() as any).port;
base = `http://127.0.0.1:${port}`;
audience = `${base}/a2a`;
provider = createA2aOidcProvider({
repo, secretsDir,
issuer: `${base}/oidc`,
resourceAudience: audience,
cookieKeys: ['test-cookie-key'],
});
(provider as any).proxy = false;
mountA2aOidc(app, provider, { repo, resourceAudience: audience });
// ── request handler を inline で組むexecutor の sleep を制御可能にするため)──
// executorParked=true の間、sleep は永久に解決しない → executor は submitted を
// publish した直後に park し、poll でジョブ状態を触らないreconciler が唯一の収束者)。
const cardDeps = { baseUrl: base, issuer: `${base}/oidc`, version: '1.0.0-test' };
const baseCard = buildBaseCard(cardDeps);
const taskStore = new SqliteA2aTaskStore(repo);
const parkingSleep = (ms: number): Promise<void> =>
executorParked
? new Promise<void>(() => { /* never resolves — executor parks */ })
: new Promise<void>(res => setTimeout(res, Math.min(ms, 5)));
const executor = new MaestroA2aExecutor(repo, {
pollMs: 5,
sleep: parkingSleep,
maxPollIterations: 100,
});
const extendedCardProvider = makeExtendedCardProvider(repo, baseCard, cardDeps);
const handler = new DefaultRequestHandler(
baseCard, taskStore, executor,
undefined, undefined, undefined, extendedCardProvider,
);
const userBuilder = makeA2aUserBuilder(provider, repo);
app.use('/a2a', jsonRpcHandler({ requestHandler: handler, userBuilder }));
// reconciler は interval を張らず、テスト内で reconcileOnce() を明示的に呼ぶ。
reconciler = new A2aTaskReconciler({ repo });
});
afterAll(() => {
server?.close();
});
/** authorize → consent → code → token を実フローで通す。 */
async function obtainToken(selectedSpaces: string, selectedSkills: string): Promise<string> {
const client = new Client();
const verifier = b64url(randomBytes(32));
const challenge = b64url(createHash('sha256').update(verifier).digest());
const authUrl = `${base}/oidc/auth?` + new URLSearchParams({
client_id: clientId, response_type: 'code', redirect_uri: redirectUri,
scope: 'openid a2a.read', resource: audience,
code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz',
});
const authRes = await client.get(authUrl);
expect([302, 303]).toContain(authRes.status);
let loc = authRes.headers.get('location')!;
for (let i = 0; i < 5; i++) {
if (loc.startsWith(redirectUri)) break;
expect(loc).toContain('/oidc/interaction/');
const uid = new URL(loc, base).pathname.split('/').pop()!;
const confirmRes = await client.postForm(`${base}/oidc/interaction/${uid}/confirm`, {
selectedSpaces, selectedSkills,
});
expect(confirmRes.status).toBe(200);
const { redirectTo } = await confirmRes.json() as { redirectTo: string };
const resumeRes = await client.get(redirectTo);
expect([302, 303]).toContain(resumeRes.status);
loc = resumeRes.headers.get('location')!;
}
expect(loc.startsWith(redirectUri)).toBe(true);
const code = new URL(loc).searchParams.get('code');
expect(code).toBeTruthy();
const tokenRes = await client.postForm(`${base}/oidc/token`, {
grant_type: 'authorization_code',
code: code!, code_verifier: verifier, client_id: clientId, redirect_uri: redirectUri,
});
expect(tokenRes.status).toBe(200);
const tokenJson = await tokenRes.json() as { access_token: string };
expect(tokenJson.access_token).toBeTruthy();
return tokenJson.access_token;
}
function rpc(body: unknown, headers: Record<string, string> = {}) {
return fetch(`${base}/a2a`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(body),
});
}
function sendEnvelope(
text: string,
opts: { blocking?: boolean; metadata?: Record<string, unknown> } = {},
) {
return {
jsonrpc: '2.0',
id: 1,
method: 'message/send',
params: {
message: {
kind: 'message',
messageId: randomUUID(),
role: 'user',
parts: [{ kind: 'text', text }],
...(opts.metadata ? { metadata: opts.metadata } : {}),
},
...(opts.blocking !== undefined ? { configuration: { blocking: opts.blocking } } : {}),
},
};
}
function getTaskEnvelope(id: string) {
return { jsonrpc: '2.0', id: 2, method: 'tasks/get', params: { id } };
}
type TaskResult = {
kind: string;
id: string;
status: { state: string };
metadata?: Record<string, unknown>;
artifacts?: Array<{ name?: string; parts: Array<{ kind: string; text?: string }> }>;
};
// ── Case 1: 非ブロッキング送信は完了を待たず即座に返る ─────────────────────
it('blocking:false → returns immediately with a non-terminal Task (does not wait for job)', async () => {
executorParked = true;
const token = await obtainToken(spaceId, 'research');
const res = await rpc(sendEnvelope('do research', { blocking: false }), {
authorization: `Bearer ${token}`,
});
expect(res.status).toBe(200);
const json = await res.json() as { jsonrpc: string; result?: TaskResult; error?: unknown };
expect(json.jsonrpc).toBe('2.0');
expect(json.error).toBeUndefined();
expect(json.result).toBeDefined();
expect(json.result!.kind).toBe('task');
// 完了を待っていない証拠: 非 terminal 状態で早期リターンしている。
expect(json.result!.status.state).not.toBe('completed');
expect(['submitted', 'working']).toContain(json.result!.status.state);
// executor が enqueue した MAESTRO ジョブへのリンクが Task メタデータに載っている。
expect(typeof json.result!.metadata?.maestroJobId).toBe('string');
}, 30_000);
// ── Case 2: job succeeded → reconcileOnce → tasks/get が completed + artifact ──
it('tasks/get converges to completed via reconciler after job succeeds', async () => {
executorParked = true; // executor は park 中 → 収束は reconciler のみが行う
const token = await obtainToken(spaceId, 'research');
const sendRes = await rpc(sendEnvelope('do research', { blocking: false }), {
authorization: `Bearer ${token}`,
});
const sendJson = await sendRes.json() as { result: TaskResult };
const taskId = sendJson.result.id;
const jobId = sendJson.result.metadata!.maestroJobId as string;
expect(sendJson.result.status.state).not.toBe('completed');
// ジョブ完了をシミュレート(実 worker の代わり)。
completeJob(jobId);
// reconciler が非 terminal タスクを 1 回走査して terminal 化する。
const { finalized } = await reconciler.reconcileOnce();
expect(finalized).toBeGreaterThanOrEqual(1);
// tasks/get で最新状態を取得 → completed + artifact に result.txt の中身。
const getRes = await rpc(getTaskEnvelope(taskId), { authorization: `Bearer ${token}` });
expect(getRes.status).toBe(200);
const getJson = await getRes.json() as { result?: TaskResult; error?: unknown };
expect(getJson.error).toBeUndefined();
expect(getJson.result!.status.state).toBe('completed');
const artifacts = getJson.result!.artifacts ?? [];
expect(artifacts.length).toBeGreaterThan(0);
const allText = artifacts.flatMap(a => a.parts).map(p => p.text ?? '').join('\n');
expect(allText).toContain(RESULT_TEXT);
}, 30_000);
// ── Case 3: 再起動シナリオexecutor を通さず reconciler だけで収束)──────────
it('restart scenario: seeded working task converges via reconcileOnce() alone', async () => {
// executor を一切通さず、working な a2a_task + 対応する succeeded ジョブを直接仕込む。
// =ブリッジ再起動で live executor が消えた状況の等価物。
const seededTaskId = `restart-task-${randomUUID()}`;
const localTaskId = 987654; // 存在しないローカルタスク → reconciler は job.worktreePath にフォールバック
const job = await repo.createJob({
repo: localTaskRepoName(localTaskId),
issueNumber: localTaskId,
instruction: 'restart research',
pieceName: 'research',
ownerId: userId,
visibility: 'private',
spaceId,
});
completeJob(job.id); // succeeded + worktree_path=outDir
// シード用の委任行を挿入reconciler の revocation sweep が fail-closed でキャンセルしないよう live にする)。
repo.createA2aDelegation({
id: 'deleg-restart-001',
userId,
clientId,
grantId: 'grant-restart',
grantedSpaceIds: [spaceId],
grantedSkills: ['research'],
audience: null,
expiresAt: null,
revokedAt: null,
});
repo.saveA2aTask({
id: seededTaskId,
contextId: seededTaskId,
jobId: job.id,
localTaskId: null,
payload: {
id: seededTaskId,
contextId: seededTaskId,
kind: 'task',
status: { state: 'working', timestamp: '2026-07-02T00:00:00.000Z' },
metadata: {
maestroJobId: job.id,
maestroLocalTaskId: null,
a2aDelegationId: 'deleg-restart-001',
a2aGrantId: 'grant-restart',
a2aActingUserId: userId,
},
},
delegationId: 'deleg-restart-001',
grantId: 'grant-restart',
actingUserId: userId,
});
const { finalized } = await reconciler.reconcileOnce();
expect(finalized).toBeGreaterThanOrEqual(1);
const token = await obtainToken(spaceId, 'research');
const getRes = await rpc(getTaskEnvelope(seededTaskId), { authorization: `Bearer ${token}` });
const getJson = await getRes.json() as { result?: TaskResult; error?: unknown };
expect(getJson.error).toBeUndefined();
expect(getJson.result!.status.state).toBe('completed');
const allText = (getJson.result!.artifacts ?? [])
.flatMap(a => a.parts).map(p => p.text ?? '').join('\n');
expect(allText).toContain(RESULT_TEXT);
}, 30_000);
// ── Case 4: blocking:true既定は完了まで待って completed を返す(回帰確認)──
it('blocking:true still blocks to completion and returns a completed Task', async () => {
executorParked = false; // executor を通常 poll に戻す
const stopCompleter = startCompleter();
try {
const token = await obtainToken(spaceId, 'research');
const res = await rpc(sendEnvelope('do research', { blocking: true }), {
authorization: `Bearer ${token}`,
});
expect(res.status).toBe(200);
const json = await res.json() as { result?: TaskResult; error?: unknown };
expect(json.error).toBeUndefined();
expect(json.result!.kind).toBe('task');
expect(json.result!.status.state).toBe('completed');
const allText = (json.result!.artifacts ?? [])
.flatMap(a => a.parts).map(p => p.text ?? '').join('\n');
expect(allText).toContain(RESULT_TEXT);
} finally {
stopCompleter();
}
}, 30_000);
});

View File

@ -0,0 +1,236 @@
// @vitest-environment node
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import express from 'express';
import http from 'http';
import type Provider from 'oidc-provider';
import { createHash, randomBytes } from 'crypto';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { Repository } from '../../db/repository.js';
import { createA2aOidcProvider, mountA2aOidc } from './oidc-provider.js';
import { createA2aRouter } from './a2a-api.js';
import { b64url, Client } from './__e2e-helpers.js';
/**
* Plan 1 oidc-provider+ Plan 2Ainbound trust core + agent card e2e
*
* http server oidc provider + consentA2A agent card
* authorization_code + PKCE extended card
* consent /
* Bearer / /
*/
describe('A2A card e2e (real token → scoped extended card + negative paths)', () => {
let server: http.Server;
let base: string;
let audience: string;
let repo: Repository;
let provider: Provider;
const clientId = 'a2a_card_e2e';
const redirectUri = 'https://client.test/cb';
const userId = 'user-1';
const spaceId = 'space-research';
beforeAll(async () => {
repo = new Repository(':memory:');
// 公開 A2A クライアントPKCE / token_endpoint_auth_method=none
repo.createA2aClient({
clientId, name: 'Card E2E', redirectUris: [redirectUri],
secretHash: null, tokenEndpointAuthMethod: 'none',
agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'admin',
});
// acting userextended card ルートが getUserById で実ロール/組織を引くため実在が必要)。
(repo as any).db.prepare(
"INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) " +
"VALUES (?, ?, ?, NULL, 'user', 'active', '2026-01-01', '2026-01-01')",
).run(userId, 'u1@test', 'User One');
// user-1 が所有する案件スペース + A2A 公開許可リストresearch / summarize
(repo as any).db.prepare(
"INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Research', 'case', ?, 'private')",
).run(spaceId, userId);
repo.setSpaceA2aSkills(spaceId, ['research', 'summarize']);
// クロススペース AND 交差テスト用スペースspace-a に research / space-b に analyze
// 同意は space-a のみに絞ることで、space-b の analyze がカードに漏れないことを検証する。
(repo as any).db.prepare(
"INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Space A', 'case', ?, 'private')",
).run('space-a', userId);
repo.setSpaceA2aSkills('space-a', ['research']);
(repo as any).db.prepare(
"INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Space B', 'case', ?, 'private')",
).run('space-b', userId);
repo.setSpaceA2aSkills('space-b', ['analyze']);
const secretsDir = mkdtempSync(join(tmpdir(), 'a2a-card-e2e-'));
const app = express();
// 擬似ログイン: 全リクエストに user を注入consent の req.user 用。mount より前に置く。
app.use((req, _res, next) => { (req as any).user = { id: userId, role: 'user' }; next(); });
// listen → port 確定 → issuer/audience 確定 → provider 生成 → mount の順(テストのみ並び替え)
server = http.createServer(app);
await new Promise<void>(r => server.listen(0, '127.0.0.1', r));
const port = (server.address() as any).port;
base = `http://127.0.0.1:${port}`;
audience = `${base}/a2a`;
provider = createA2aOidcProvider({
repo, secretsDir,
issuer: `${base}/oidc`,
resourceAudience: audience,
cookieKeys: ['test-cookie-key'],
});
// テスト専用: http/127.0.0.1 で cookie を non-secure にして cookie jar が機能するようにする。
(provider as any).proxy = false;
// 実 provider + consent を /oidc に、A2A card ルータを同じ app にマウント。
mountA2aOidc(app, provider, { repo, resourceAudience: audience });
app.use(createA2aRouter({
provider, repo,
baseUrl: base,
issuer: `${base}/oidc`,
version: '1.0.0-test',
}));
});
afterAll(() => { server?.close(); });
/**
* authorize consentspace/skill code token
* access_token grantId
* consent confirm consent-api.ts `selectedSpaces` / `selectedSkills`
*/
async function obtainToken(
selectedSpaces: string,
selectedSkills: string,
): Promise<{ accessToken: string; grantId: string }> {
const client = new Client();
const verifier = b64url(randomBytes(32));
const challenge = b64url(createHash('sha256').update(verifier).digest());
const authUrl = `${base}/oidc/auth?` + new URLSearchParams({
client_id: clientId, response_type: 'code', redirect_uri: redirectUri,
scope: 'openid a2a.read', resource: audience,
code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz',
});
const authRes = await client.get(authUrl);
expect([302, 303]).toContain(authRes.status);
let loc = authRes.headers.get('location')!;
// login / consent interaction を redirect_uri に着くまでループcookie jar 経由)。
// confirm のたびに space/skill 選択を送る(最終 grant に委任が紐づく)。
for (let i = 0; i < 5; i++) {
if (loc.startsWith(redirectUri)) break;
expect(loc).toContain('/oidc/interaction/');
const uid = new URL(loc, base).pathname.split('/').pop()!;
const confirmRes = await client.postForm(`${base}/oidc/interaction/${uid}/confirm`, {
selectedSpaces, selectedSkills,
});
expect(confirmRes.status).toBe(200);
const { redirectTo } = await confirmRes.json() as { redirectTo: string };
expect(redirectTo).toContain('/oidc/auth/');
const resumeRes = await client.get(redirectTo);
expect([302, 303]).toContain(resumeRes.status);
loc = resumeRes.headers.get('location')!;
}
expect(loc.startsWith(redirectUri)).toBe(true);
const code = new URL(loc).searchParams.get('code');
expect(code).toBeTruthy();
const tokenRes = await client.postForm(`${base}/oidc/token`, {
grant_type: 'authorization_code',
code: code!, code_verifier: verifier, client_id: clientId, redirect_uri: redirectUri,
});
expect(tokenRes.status).toBe(200);
const tokenJson = await tokenRes.json() as { access_token: string; scope: string };
expect(tokenJson.access_token).toBeTruthy();
expect(tokenJson.scope.split(' ')).toContain('a2a.read');
// opaque access token から grantId を引く(失効テストで委任を特定するため)。
const at = await (provider as any).AccessToken.find(tokenJson.access_token);
expect(at?.grantId).toBeTruthy();
return { accessToken: tokenJson.access_token, grantId: at.grantId as string };
}
it('happy path: real token → extended card scoped to the single consented skill', async () => {
const { accessToken } = await obtainToken(spaceId, 'research');
const res = await fetch(`${base}/a2a/agent-card/extended`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(res.status).toBe(200);
const card = await res.json() as { skills: Array<{ id: string }> };
// research のみ consent → summarize は許可リストにあっても出ない
expect(card.skills.map(s => s.id)).toEqual(['research']);
});
it('base card is public (no auth): skills [] + supportsAuthenticatedExtendedCard', async () => {
const res = await fetch(`${base}/.well-known/agent-card.json`);
expect(res.status).toBe(200);
const card = await res.json() as { skills: unknown[]; supportsAuthenticatedExtendedCard: boolean };
expect(card.skills).toEqual([]);
expect(card.supportsAuthenticatedExtendedCard).toBe(true);
});
it('no bearer → 401 on the extended card', async () => {
const res = await fetch(`${base}/a2a/agent-card/extended`);
expect(res.status).toBe(401);
});
it('revoked delegation → 401 with the same previously-working token', async () => {
const { accessToken, grantId } = await obtainToken(spaceId, 'research');
// まず動くことを確認
const okRes = await fetch(`${base}/a2a/agent-card/extended`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(okRes.status).toBe(200);
// grantId から委任を特定して失効
const delegation = repo.getA2aDelegationByGrantId(grantId);
expect(delegation).toBeTruthy();
repo.revokeA2aDelegation(delegation!.id, new Date().toISOString());
// 同じトークンは委任が live でないため 401
const res = await fetch(`${base}/a2a/agent-card/extended`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(res.status).toBe(401);
});
it('out-of-scope skill dropped: summarize allowlisted but not consented is absent', async () => {
// research だけ同意。summarize はスペース許可リストにあるが委任に含めない。
const { accessToken } = await obtainToken(spaceId, 'research');
const res = await fetch(`${base}/a2a/agent-card/extended`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(res.status).toBe(200);
const card = await res.json() as { skills: Array<{ id: string }> };
const ids = card.skills.map(s => s.id);
expect(ids).toContain('research');
expect(ids).not.toContain('summarize');
});
it('cross-space AND-intersection: non-granted space skills are absent from extended card', async () => {
// space-aresearchと space-banalyzeが両方ユーザーから見えるが、
// space-a のみに同意 → resolveEffectiveSpaces が space-b を AND 交差で排除し、
// analyze がカードに漏れないことを end-to-end で実証する(最高 IDOR リスク経路)。
const { accessToken } = await obtainToken('space-a', 'research');
const res = await fetch(`${base}/a2a/agent-card/extended`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(res.status).toBe(200);
const card = await res.json() as { skills: Array<{ id: string }> };
const ids = card.skills.map(s => s.id);
// space-a の research は同意済み → 出現する
expect(ids).toEqual(['research']);
// space-b の analyze は未同意スペースにのみ存在 → 漏れてはならない
expect(ids).not.toContain('analyze');
});
});

View File

@ -0,0 +1,143 @@
// @vitest-environment node
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { randomUUID } from 'crypto';
import { Repository } from '../../db/repository.js';
import { createA2aClientsAdminRouter } from './a2a-clients-admin-api.js';
const ADMIN = { id: 'admin1', role: 'admin', orgIds: [] };
function makeApp(repo: Repository) {
const app = express();
app.use(express.json());
// authActive=true → viewerOf reads req.user (same pattern as delegations-api.test.ts)
app.use((req, _res, next) => { (req as any).user = ADMIN; next(); });
app.use('/api/admin/a2a/clients', createA2aClientsAdminRouter(repo, true));
return app;
}
// ── Seed helpers ──────────────────────────────────────────────────────────────
function seedClient(repo: Repository): string {
const clientId = `a2a_${randomUUID()}`;
repo.createA2aClient({
clientId, name: 'Rogue Agent',
redirectUris: ['https://rogue.example/cb'],
secretHash: null, tokenEndpointAuthMethod: 'none',
agentCardUrl: null, issuer: null, keyFingerprint: null,
status: 'active', createdBy: ADMIN.id,
});
return clientId;
}
async function seedRunningJob(repo: Repository, issueNumber: number): Promise<string> {
const job = await repo.createJob({ repo: 'test/repo', issueNumber, instruction: `task ${issueNumber}` });
// createJob inserts 'queued'; cancelA2aLinkedJob matches queued/running/etc.
return job.id;
}
function seedDelegationWithJob(
repo: Repository,
clientId: string,
userId: string,
grantId: string,
jobId: string,
): string {
const delId = randomUUID();
repo.createA2aDelegation({
id: delId, userId, clientId,
grantId,
grantedSpaceIds: ['space-1'], grantedSkills: [],
audience: null, expiresAt: null, revokedAt: null,
});
// Link a non-terminal a2a_task (payload={} → status.state IS NULL → non-terminal)
repo.saveA2aTask({
id: randomUUID(), contextId: null,
jobId, localTaskId: null,
payload: {},
delegationId: delId,
grantId,
});
return delId;
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('POST /:id/disable — cascade revocation', () => {
let repo: Repository;
beforeEach(() => { repo = new Repository(':memory:'); });
it('disables the client, revokes two live delegations, cancels two linked jobs, and leaves the pre-revoked delegation untouched', async () => {
const clientId = seedClient(repo);
// Two live delegations, each linked to a queued (cancellable) job
const job1Id = await seedRunningJob(repo, 1);
const job2Id = await seedRunningJob(repo, 2);
const delId1 = seedDelegationWithJob(repo, clientId, 'user1', 'grant-1', job1Id);
const delId2 = seedDelegationWithJob(repo, clientId, 'user2', 'grant-2', job2Id);
// One already-revoked delegation for the same client
const preRevokedAt = '2024-01-01T00:00:00.000Z';
const delId3 = randomUUID();
repo.createA2aDelegation({
id: delId3, userId: 'user3', clientId,
grantId: null,
grantedSpaceIds: [], grantedSkills: [],
audience: null, expiresAt: null,
revokedAt: preRevokedAt,
});
const res = await request(makeApp(repo))
.post(`/api/admin/a2a/clients/${clientId}/disable`);
// ── Response shape ────────────────────────────────────────────────────────
expect(res.status).toBe(200);
expect(res.body.status).toBe('disabled');
expect(res.body.revokedDelegations).toBe(2);
expect(res.body.cancelledJobs).toBe(2);
// ── Client status ─────────────────────────────────────────────────────────
expect(repo.getA2aClient(clientId)?.status).toBe('disabled');
// ── Live delegations are now revoked ──────────────────────────────────────
expect(repo.getA2aDelegationById(delId1)?.revokedAt).toBeTruthy();
expect(repo.getA2aDelegationById(delId2)?.revokedAt).toBeTruthy();
// ── Pre-revoked delegation is untouched (revokedAt unchanged) ─────────────
expect(repo.getA2aDelegationById(delId3)?.revokedAt).toBe(preRevokedAt);
// ── Linked jobs are now cancelled ─────────────────────────────────────────
const db = (repo as any).db;
const j1 = db.prepare('SELECT status FROM jobs WHERE id = ?').get(job1Id) as { status: string };
const j2 = db.prepare('SELECT status FROM jobs WHERE id = ?').get(job2Id) as { status: string };
expect(j1.status).toBe('cancelled');
expect(j2.status).toBe('cancelled');
});
it('returns 404 when disabling a nonexistent client', async () => {
const res = await request(makeApp(repo))
.post('/api/admin/a2a/clients/no-such-client/disable');
expect(res.status).toBe(404);
});
it('returns { status:disabled, revokedDelegations:0, cancelledJobs:0 } when the client has no live delegations', async () => {
const clientId = seedClient(repo);
const res = await request(makeApp(repo))
.post(`/api/admin/a2a/clients/${clientId}/disable`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('disabled');
expect(res.body.revokedDelegations).toBe(0);
expect(res.body.cancelledJobs).toBe(0);
});
it('returns 500 when the repo throws during delegation enumeration', async () => {
const clientId = seedClient(repo);
(repo as any).listLiveA2aDelegationsForClient = () => { throw new Error('db error'); };
const res = await request(makeApp(repo))
.post(`/api/admin/a2a/clients/${clientId}/disable`);
expect(res.status).toBe(500);
expect(res.body.error).toBe('internal');
});
});

View File

@ -0,0 +1,70 @@
// @vitest-environment node
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../../db/repository.js';
import { createA2aClientsAdminRouter } from './a2a-clients-admin-api.js';
function appWith(repo: Repository) {
const app = express();
app.use(express.json());
// テストでは admin ユーザーを直接注入(本番は requireAdmin が前段)
app.use((req, _res, next) => { (req as any).user = { id: 'admin1', role: 'admin' }; next(); });
app.use('/api/admin/a2a/clients', createA2aClientsAdminRouter(repo, true));
return app;
}
describe('a2a clients admin API', () => {
let repo: Repository;
beforeEach(() => { repo = new Repository(':memory:'); });
it('registers a public client and returns the generated client_id', async () => {
const app = appWith(repo);
const res = await request(app).post('/api/admin/a2a/clients')
.send({ name: 'Partner', redirect_uris: ['https://partner.example/cb'] });
expect(res.status).toBe(201);
expect(res.body.client_id).toBeTruthy();
expect(res.body.token_endpoint_auth_method).toBe('none');
expect(repo.getA2aClient(res.body.client_id)?.name).toBe('Partner');
});
it('rejects a registration with no redirect_uris', async () => {
const app = appWith(repo);
const res = await request(app).post('/api/admin/a2a/clients').send({ name: 'X', redirect_uris: [] });
expect(res.status).toBe(400);
});
it('rejects a non-https redirect uri', async () => {
const app = appWith(repo);
const res = await request(app).post('/api/admin/a2a/clients')
.send({ name: 'X', redirect_uris: ['http://insecure/cb'] });
expect(res.status).toBe(400);
});
it('disables a client', async () => {
const app = appWith(repo);
const reg = await request(app).post('/api/admin/a2a/clients')
.send({ name: 'P', redirect_uris: ['https://p/cb'] });
const id = reg.body.client_id;
const res = await request(app).post(`/api/admin/a2a/clients/${id}/disable`);
expect(res.status).toBe(200);
expect(repo.getA2aClient(id)?.status).toBe('disabled');
});
it('returns 404 when disabling a nonexistent client', async () => {
const app = appWith(repo);
const res = await request(app).post('/api/admin/a2a/clients/nonexistent/disable');
expect(res.status).toBe(404);
});
it('lists all clients without secretHash', async () => {
const app = appWith(repo);
await request(app).post('/api/admin/a2a/clients')
.send({ name: 'C1', redirect_uris: ['https://c1/cb'] });
const res = await request(app).get('/api/admin/a2a/clients');
expect(res.status).toBe(200);
expect(res.body.clients).toHaveLength(1);
expect(res.body.clients[0].secretHash).toBeUndefined();
expect(res.body.clients[0].name).toBe('C1');
});
});

View File

@ -0,0 +1,73 @@
import { Router, type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import type { Repository } from '../../db/repository.js';
import { logger } from '../../logger.js';
import { viewerOf } from '../space-viewer.js';
import { revokeDelegationCascade } from './revoke.js';
function isHttpsUri(u: string): boolean {
try { return new URL(u).protocol === 'https:'; } catch { return false; }
}
/** admin ゲートの A2A クライアント登録。requireAdmin を server.ts 側で前段に置く前提。 */
export function createA2aClientsAdminRouter(repo: Repository, authActive: boolean): Router {
const router = Router();
router.post('/', (req: Request, res: Response) => {
const { name, redirect_uris: redirectUris } = req.body ?? {};
if (typeof name !== 'string' || !name.trim()) { res.status(400).json({ error: 'name required' }); return; }
if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
res.status(400).json({ error: 'redirect_uris required' }); return;
}
if (!redirectUris.every((u: unknown) => typeof u === 'string' && isHttpsUri(u))) {
res.status(400).json({ error: 'redirect_uris must all be https' }); return;
}
const clientId = `a2a_${randomUUID()}`;
const actor = (req.user as Express.User | undefined)?.id ?? null;
repo.createA2aClient({
clientId, name: name.trim(), redirectUris,
secretHash: null, tokenEndpointAuthMethod: 'none',
agentCardUrl: null, issuer: null, keyFingerprint: null,
status: 'active', createdBy: actor,
});
logger.info(`[a2a-oidc] client registered client_id=${clientId} by=${actor}`);
res.status(201).json({ client_id: clientId, token_endpoint_auth_method: 'none' });
});
router.get('/', (_req: Request, res: Response) => {
res.json({ clients: repo.listA2aClients().map(c => ({ ...c, secretHash: undefined })) });
});
router.post('/:id/disable', async (req: Request, res: Response) => {
try {
const c = repo.getA2aClient(req.params.id);
if (!c) { res.status(404).json({ error: 'not found' }); return; }
repo.setA2aClientStatus(req.params.id, 'disabled');
const now = new Date().toISOString();
const live = repo.listLiveA2aDelegationsForClient(req.params.id, now);
const admin = viewerOf(req, authActive);
let revokedDelegations = 0;
let cancelledJobs = 0;
for (const d of live) {
const out = await revokeDelegationCascade(repo, d.id, { userId: admin.id, isAdmin: true }, now);
if (out.status === 'revoked') {
revokedDelegations++;
cancelledJobs += out.cancelledJobs;
}
}
logger.info(
`[a2a-admin] client disabled client_id=${req.params.id} revokedDelegations=${revokedDelegations} cancelledJobs=${cancelledJobs} by=${admin.id}`,
);
res.json({ status: 'disabled', revokedDelegations, cancelledJobs });
} catch (err) {
logger.error(`[a2a-admin] disable client failed: ${err}`);
res.status(500).json({ error: 'internal' });
}
});
return router;
}

View File

@ -0,0 +1,295 @@
// @vitest-environment node
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import express from 'express';
import http from 'http';
import type Provider from 'oidc-provider';
import { createHash, randomBytes, randomUUID } from 'crypto';
import { mkdtempSync, mkdirSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { Repository } from '../../db/repository.js';
import { createA2aOidcProvider, mountA2aOidc } from './oidc-provider.js';
import { mountA2aRequestHandler } from './request-handler.js';
import { b64url, Client } from './__e2e-helpers.js';
/**
* Task 8 A2A e2e message/send Artifact
*
* Plan 1 oidc-provider+ Plan 2Aconsent+ Plan 2BmountA2aRequestHandler
* http server authorization_code + PKCE
* JSON-RPC `message/send` Worker / LLM 使
* executor enqueue queued worker succeeded
* output/result.txt Artifact A2A Artifact
*
* Bearer / fail-closed
*/
describe('A2A exec e2e (real token → message/send → job → artifact + negatives)', () => {
let server: http.Server;
let base: string;
let audience: string;
let repo: Repository;
let provider: Provider;
const clientId = 'a2a_exec_e2e';
const redirectUri = 'https://client.test/cb';
const userId = 'user-1';
const spaceId = 'space-research';
const RESULT_TEXT = 'A2A round-trip artifact OK — 実行結果ファイルの中身';
let outDir: string; // tmp workspace dir with output/result.txt
let stopCompleter: (() => void) | null = null;
function jobCountForUser(): number {
const row = (repo as any).db
.prepare("SELECT COUNT(*) AS n FROM jobs WHERE owner_id = ?")
.get(userId) as { n: number };
return row.n;
}
/**
* "worker": queued succeeded
* jobs.worktree_path local_tasks.workspace_path outDir
* executor getLocalTask(id).workspacePath ?? job.worktreePath
* / status path
*/
function startCompleter(): () => void {
let stop = false;
const db = (repo as any).db;
const selQueued = db.prepare("SELECT id, issue_number FROM jobs WHERE status = 'queued'");
const updJob = db.prepare("UPDATE jobs SET status = 'succeeded', worktree_path = ? WHERE id = ?");
const updTask = db.prepare("UPDATE local_tasks SET workspace_path = ? WHERE id = ?");
(async () => {
while (!stop) {
const rows = selQueued.all() as Array<{ id: string; issue_number: number }>;
for (const r of rows) {
updJob.run(outDir, r.id);
updTask.run(outDir, r.issue_number);
}
await new Promise(res => setTimeout(res, 10));
}
})();
return () => { stop = true; };
}
beforeAll(async () => {
repo = new Repository(':memory:');
// 公開 A2A クライアントPKCE / token_endpoint_auth_method=none
repo.createA2aClient({
clientId, name: 'Exec E2E', redirectUris: [redirectUri],
secretHash: null, tokenEndpointAuthMethod: 'none',
agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'admin',
});
// acting user実在が必要: computeEffectiveScope が getUserById で実ロール/組織を引く)。
(repo as any).db.prepare(
"INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) " +
"VALUES (?, ?, ?, NULL, 'user', 'active', '2026-01-01', '2026-01-01')",
).run(userId, 'u1@test', 'User One');
// user-1 が所有する案件スペース + A2A 公開許可リストresearch / summarize
// summarize は許可リストにあるが consent では委任しない → 委任外スキルの否定経路に使う。
(repo as any).db.prepare(
"INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Research', 'case', ?, 'private')",
).run(spaceId, userId);
repo.setSpaceA2aSkills(spaceId, ['research', 'summarize']);
// 決定論的ジョブ完了用の出力ファイルworker が書く成果物を模す)。
outDir = mkdtempSync(join(tmpdir(), 'a2a-exec-ws-'));
mkdirSync(join(outDir, 'output'), { recursive: true });
writeFileSync(join(outDir, 'output', 'result.txt'), RESULT_TEXT);
const secretsDir = mkdtempSync(join(tmpdir(), 'a2a-exec-e2e-'));
const app = express();
// 擬似ログイン: 全リクエストに user を注入consent の req.user 用。mount より前に置く。
// /a2a は Bearer ベースuserBuilderなのでこの注入は無害。
app.use((req, _res, next) => { (req as any).user = { id: userId, role: 'user' }; next(); });
server = http.createServer(app);
await new Promise<void>(r => server.listen(0, '127.0.0.1', r));
const port = (server.address() as any).port;
base = `http://127.0.0.1:${port}`;
audience = `${base}/a2a`;
provider = createA2aOidcProvider({
repo, secretsDir,
issuer: `${base}/oidc`,
resourceAudience: audience,
cookieKeys: ['test-cookie-key'],
});
// テスト専用: http/127.0.0.1 で cookie を non-secure にして cookie jar が機能するように。
(provider as any).proxy = false;
// 実 provider + consent を /oidc に、本番 request handler を /a2a にマウント。
mountA2aOidc(app, provider, { repo, resourceAudience: audience });
mountA2aRequestHandler(app, {
provider, repo,
baseUrl: base,
issuer: `${base}/oidc`,
version: '1.0.0-test',
});
stopCompleter = startCompleter();
});
afterAll(() => {
stopCompleter?.();
server?.close();
});
/**
* authorize consentspace/skill code token
* consent confirm consent-api.ts `selectedSpaces` / `selectedSkills`
*/
async function obtainToken(
selectedSpaces: string,
selectedSkills: string,
): Promise<string> {
const client = new Client();
const verifier = b64url(randomBytes(32));
const challenge = b64url(createHash('sha256').update(verifier).digest());
const authUrl = `${base}/oidc/auth?` + new URLSearchParams({
client_id: clientId, response_type: 'code', redirect_uri: redirectUri,
scope: 'openid a2a.read', resource: audience,
code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz',
});
const authRes = await client.get(authUrl);
expect([302, 303]).toContain(authRes.status);
let loc = authRes.headers.get('location')!;
for (let i = 0; i < 5; i++) {
if (loc.startsWith(redirectUri)) break;
expect(loc).toContain('/oidc/interaction/');
const uid = new URL(loc, base).pathname.split('/').pop()!;
const confirmRes = await client.postForm(`${base}/oidc/interaction/${uid}/confirm`, {
selectedSpaces, selectedSkills,
});
expect(confirmRes.status).toBe(200);
const { redirectTo } = await confirmRes.json() as { redirectTo: string };
expect(redirectTo).toContain('/oidc/auth/');
const resumeRes = await client.get(redirectTo);
expect([302, 303]).toContain(resumeRes.status);
loc = resumeRes.headers.get('location')!;
}
expect(loc.startsWith(redirectUri)).toBe(true);
const code = new URL(loc).searchParams.get('code');
expect(code).toBeTruthy();
const tokenRes = await client.postForm(`${base}/oidc/token`, {
grant_type: 'authorization_code',
code: code!, code_verifier: verifier, client_id: clientId, redirect_uri: redirectUri,
});
expect(tokenRes.status).toBe(200);
const tokenJson = await tokenRes.json() as { access_token: string };
expect(tokenJson.access_token).toBeTruthy();
return tokenJson.access_token;
}
function sendMessage(body: unknown, headers: Record<string, string> = {}) {
return fetch(`${base}/a2a`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(body),
});
}
function messageSendEnvelope(text: string, metadata?: Record<string, unknown>) {
return {
jsonrpc: '2.0',
id: 1,
method: 'message/send',
params: {
message: {
kind: 'message',
messageId: randomUUID(),
role: 'user',
parts: [{ kind: 'text', text }],
...(metadata ? { metadata } : {}),
},
},
};
}
it('happy path: real token → message/send → completed Task with artifact (result.txt) + scoped job', async () => {
const token = await obtainToken(spaceId, 'research');
const before = jobCountForUser();
const res = await sendMessage(
messageSendEnvelope('do research'),
{ authorization: `Bearer ${token}` },
);
expect(res.status).toBe(200);
const json = await res.json() as {
jsonrpc: string;
result?: {
kind: string;
status: { state: string };
artifacts?: Array<{ name?: string; parts: Array<{ kind: string; text?: string }> }>;
};
error?: unknown;
};
// JSON-RPC resulterror ではない)で、完了 Task が返る。
expect(json.jsonrpc).toBe('2.0');
expect(json.error).toBeUndefined();
expect(json.result).toBeDefined();
expect(json.result!.kind).toBe('task');
expect(json.result!.status.state).toBe('completed');
// Artifact に output/result.txt の中身が含まれる。
const artifacts = json.result!.artifacts ?? [];
expect(artifacts.length).toBeGreaterThan(0);
const allText = artifacts.flatMap(a => a.parts).map(p => p.text ?? '').join('\n');
expect(allText).toContain(RESULT_TEXT);
// セキュリティ: acting user 名義で、委任したスペース + piece research のジョブが作られた。
expect(jobCountForUser()).toBe(before + 1);
const jobRow = (repo as any).db
.prepare("SELECT owner_id, space_id, piece_name FROM jobs WHERE owner_id = ? ORDER BY created_at DESC LIMIT 1")
.get(userId) as { owner_id: string; space_id: string; piece_name: string };
expect(jobRow.owner_id).toBe(userId);
expect(jobRow.space_id).toBe(spaceId);
expect(jobRow.piece_name).toBe('research');
// local_tasks 側も同じスコープで作られている。
const taskRow = (repo as any).db
.prepare("SELECT owner_id, space_id, piece_name FROM local_tasks WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
.get(userId) as { owner_id: string; space_id: string; piece_name: string };
expect(taskRow.owner_id).toBe(userId);
expect(taskRow.space_id).toBe(spaceId);
expect(taskRow.piece_name).toBe('research');
}, 30_000);
it('no bearer → JSON-RPC error, no job created', async () => {
const before = jobCountForUser();
const res = await sendMessage(messageSendEnvelope('do research'));
expect(res.status).toBe(200); // jsonRpcHandler は HTTP 200 + JSON-RPC error
const json = await res.json() as { jsonrpc: string; error?: { code: number; message: string }; result?: unknown };
expect(json.jsonrpc).toBe('2.0');
// 未認証 → executor は principal 無しで failed publish → SDK は finalResult 無しで error 化。
expect(json.error).toBeDefined();
expect(json.result).toBeUndefined();
// ジョブは作られない(最重要のセキュリティ不変条件)。
expect(jobCountForUser()).toBe(before);
}, 30_000);
it('out-of-scope skill (summarize, allowlisted but not consented) → fail-closed, no job created', async () => {
const token = await obtainToken(spaceId, 'research'); // research のみ委任
const before = jobCountForUser();
const res = await sendMessage(
messageSendEnvelope('please summarize', { skillId: 'summarize' }),
{ authorization: `Bearer ${token}` },
);
expect(res.status).toBe(200);
const json = await res.json() as { jsonrpc: string; error?: unknown; result?: { status?: { state: string } } };
expect(json.jsonrpc).toBe('2.0');
// selectPieceForMessage が null → executor は createLocalTask/createJob 前に failed → error 化。
expect(json.error).toBeDefined();
expect(json.result).toBeUndefined();
// 委任外スキルでジョブは作られないfail-closed
expect(jobCountForUser()).toBe(before);
}, 30_000);
});

View File

@ -0,0 +1,440 @@
/**
* Task 5 executor in-flight revocation recheck (+ 2C-1 metadata fix).
*
* Uses MaestroA2aExecutor directly with injectable sleep/now/pollMs deps,
* a minimal fake Repository, and a fake ExecutionEventBus no HTTP server needed.
*
* Tests:
* A1. Delegation revoked after first poll executor publishes terminal `canceled`,
* calls eventBus.finished(), calls cancelA2aLinkedJob(jobId).
* A2. Happy path: live delegation job completes normally (no premature cancel).
* B. Initial submitted Task metadata: a2aDelegationId = delegation.id,
* a2aGrantId = grantId (different values, different semantics).
*/
import { describe, it, expect } from 'vitest';
import { MaestroA2aExecutor } from './executor.js';
import { A2aTaskReconciler } from './reconciler.js';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
import type { TaskStatusUpdateEvent } from '@a2a-js/sdk';
// ── Fake helpers ──────────────────────────────────────────────────────────────
function makeFakeRepo(opts: {
delegationId: string;
grantId: string;
/** number of getJob calls after which delegation switches to revoked (0 = revoked from the start of poll 1) */
revokeAfterPollN?: number;
/** poll number after which job status becomes 'succeeded' */
succeedAfterPollN?: number;
}) {
const {
delegationId,
grantId,
revokeAfterPollN = Infinity,
succeedAfterPollN = Infinity,
} = opts;
let pollCount = 0;
const cancelCalls: string[] = [];
const baseDelegRow = {
id: delegationId,
userId: 'user-1',
clientId: 'client-1',
grantId,
grantedSpaceIds: ['space-1'],
grantedSkills: ['research'],
audience: null as string | null,
expiresAt: null as string | null,
revokedAt: null as string | null,
};
const repo = {
// computeEffectiveScope → resolveOrgIds needs these
getUserById: (_id: string) => ({
id: 'user-1', status: 'active', role: 'user' as const, name: 'Test User',
}),
listUserGiteaOrgs: (_userId: string) => [] as Array<{ orgId: string }>,
listUserLocalOrgs: (_userId: string) => [] as Array<{ orgId: string }>,
listSpaces: async (_opts: unknown) => [{ id: 'space-1' }],
getSpaceA2aSkills: (_spaceId: string) => ['research'],
// Job creation
createLocalTask: async (_opts: unknown) => ({ id: 1, workspacePath: null }),
createJob: async (_opts: unknown) => undefined,
getLatestJobForIssue: async (_repoName: string, _issueNum: number) => ({
id: 'job-test-1',
status: 'queued',
currentActivity: null,
errorSummary: null,
abortReason: null,
worktreePath: null,
}),
addAuditLog: async (..._args: unknown[]) => undefined,
// Poll loop: getJob
getJob: async (_id: string) => {
pollCount++;
const status = pollCount > succeedAfterPollN ? 'succeeded' : 'running';
return {
id: 'job-test-1',
status,
currentActivity: null,
errorSummary: null,
abortReason: null,
worktreePath: null,
};
},
// Poll loop: revocation recheck (called right after getJob in the same iteration)
getA2aDelegationByGrantId: (_grantId: string) => {
// pollCount was just incremented by getJob in the same iteration.
// After revokeAfterPollN polls, delegation is revoked.
if (pollCount > revokeAfterPollN) {
return { ...baseDelegRow, revokedAt: '2026-07-02T00:00:00.000Z' };
}
return { ...baseDelegRow };
},
// Job cancel — executor uses cancelA2aLinkedJob (covers queued/retry/running/etc.)
cancelA2aLinkedJob: (jobId: string) => {
cancelCalls.push(jobId);
return true;
},
// handleTerminal (succeeded path)
getLocalTask: async (_id: number) => ({ id: 1, workspacePath: null }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
return {
repo,
cancelCalls,
getPollCount: () => pollCount,
};
}
/** Fake eventBus that collects all published events. Uses object reference to avoid stale closure. */
function makeFakeEventBus() {
const events: unknown[] = [];
const tracker = { finishedCount: 0 };
const eventBus: ExecutionEventBus = {
publish: (event: unknown) => { events.push(event); },
finished: () => { tracker.finishedCount++; },
};
const statusEvents = () =>
events.filter(
(e): e is TaskStatusUpdateEvent =>
typeof e === 'object' &&
e !== null &&
(e as Record<string, unknown>).kind === 'status-update',
);
return { eventBus, events, tracker, statusEvents };
}
function makeFakeRequestContext(opts: {
grantId: string;
delegationId: string;
taskId?: string;
contextId?: string;
}) {
const { grantId, delegationId, taskId = 'a2a-task-1', contextId = 'ctx-1' } = opts;
const principal = {
actingUserId: 'user-1',
clientId: 'client-1',
grantId,
delegation: {
id: delegationId,
userId: 'user-1',
clientId: 'client-1',
grantId,
grantedSpaceIds: ['space-1'],
grantedSkills: ['research'],
expiresAt: null,
revokedAt: null,
},
};
return {
taskId,
contextId,
context: { user: { a2aPrincipal: principal } },
userMessage: {
parts: [{ kind: 'text', text: 'do research' }],
metadata: { skillId: 'research' },
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('A2A executor revocation recheck (Task 5)', () => {
// Fixed clock for determinism
const nowFn = () => '2026-07-02T00:00:00.000Z';
it('A1: revoked delegation after first poll → executor cancels and publishes terminal canceled', async () => {
const GRANT_ID = 'grant-revoke-test';
const DELEG_ID = 'deleg-pk-001';
const { repo, cancelCalls } = makeFakeRepo({
delegationId: DELEG_ID,
grantId: GRANT_ID,
// delegation is live for poll #1 (pollCount=1 <= 1),
// revoked starting poll #2 (pollCount=2 > 1)
revokeAfterPollN: 1,
});
const { eventBus, tracker, statusEvents } = makeFakeEventBus();
const requestContext = makeFakeRequestContext({ grantId: GRANT_ID, delegationId: DELEG_ID });
const executor = new MaestroA2aExecutor(repo, {
pollMs: 0,
sleep: async () => {},
now: nowFn,
maxPollIterations: 10,
});
await executor.execute(requestContext, eventBus);
// Must have called cancelA2aLinkedJob (covers all non-terminal statuses including retry)
expect(cancelCalls).toContain('job-test-1');
// Must have published a terminal 'canceled' status update
const allStatusEvents = statusEvents();
const terminalEvents = allStatusEvents.filter(e => e.final === true);
expect(terminalEvents.length).toBeGreaterThanOrEqual(1);
const canceledEvent = terminalEvents.find(e => (e.status as Record<string, unknown>)?.state === 'canceled');
expect(canceledEvent).toBeDefined();
expect((canceledEvent!.status as Record<string, unknown>).state).toBe('canceled');
// Must have called eventBus.finished() exactly once
expect(tracker.finishedCount).toBe(1);
});
it('A2: live delegation throughout → job completes normally without premature cancel', async () => {
const GRANT_ID = 'grant-live-test';
const DELEG_ID = 'deleg-pk-002';
const { repo, cancelCalls } = makeFakeRepo({
delegationId: DELEG_ID,
grantId: GRANT_ID,
revokeAfterPollN: Infinity, // delegation stays live forever
succeedAfterPollN: 2, // job succeeds after 2 polls
});
const { eventBus, tracker, statusEvents } = makeFakeEventBus();
const requestContext = makeFakeRequestContext({ grantId: GRANT_ID, delegationId: DELEG_ID });
const executor = new MaestroA2aExecutor(repo, {
pollMs: 0,
sleep: async () => {},
now: nowFn,
maxPollIterations: 10,
});
await executor.execute(requestContext, eventBus);
// Must NOT have called cancelA2aLinkedJob — delegation is still live
expect(cancelCalls).toHaveLength(0);
// Must have published 'completed' terminal event (not 'canceled')
const terminalEvents = statusEvents().filter(e => e.final === true);
expect(terminalEvents.length).toBeGreaterThanOrEqual(1);
const completedEvent = terminalEvents.find(
e => (e.status as Record<string, unknown>)?.state === 'completed',
);
expect(completedEvent).toBeDefined();
expect((completedEvent!.status as Record<string, unknown>).state).toBe('completed');
// No 'canceled' event in the terminal events
const canceledEvent = terminalEvents.find(
e => (e.status as Record<string, unknown>)?.state === 'canceled',
);
expect(canceledEvent).toBeUndefined();
// eventBus.finished() must have been called exactly once
expect(tracker.finishedCount).toBe(1);
});
it('B: initial submitted Task metadata has a2aDelegationId = delegation.id and a2aGrantId = grantId', async () => {
const GRANT_ID = 'grant-metadata-test';
const DELEG_ID = 'deleg-pk-real-003'; // deliberately different from GRANT_ID
const { repo } = makeFakeRepo({
delegationId: DELEG_ID,
grantId: GRANT_ID,
// revoke immediately so execute() exits quickly after the first poll
revokeAfterPollN: 0,
});
const { eventBus, events } = makeFakeEventBus();
const requestContext = makeFakeRequestContext({ grantId: GRANT_ID, delegationId: DELEG_ID });
const executor = new MaestroA2aExecutor(repo, {
pollMs: 0,
sleep: async () => {},
now: nowFn,
maxPollIterations: 10,
});
await executor.execute(requestContext, eventBus);
// The first published event must be the initial 'submitted' Task (kind: 'task')
const taskEvents = events.filter(
(e): e is Record<string, unknown> =>
typeof e === 'object' && e !== null && (e as Record<string, unknown>).kind === 'task',
);
expect(taskEvents.length).toBeGreaterThanOrEqual(1);
const initialTask = taskEvents[0];
const metadata = initialTask.metadata as Record<string, unknown>;
// a2aDelegationId must equal the real delegation PK (not grantId)
expect(metadata.a2aDelegationId).toBe(DELEG_ID);
// a2aGrantId must equal the OAuth grant id
expect(metadata.a2aGrantId).toBe(GRANT_ID);
// Both values must differ — verifies we're not using grantId for both
expect(metadata.a2aDelegationId).not.toBe(metadata.a2aGrantId);
});
});
// ── Task 6: Reconciler revocation sweep helpers ───────────────────────────────
const RECONCILER_TERMINAL_STATES = new Set(['completed', 'failed', 'canceled', 'rejected']);
function makeReconcilerFakeRepo(opts: {
grantId: string;
delegationRevoked: boolean;
initialJobStatus?: string;
initialTaskState?: string;
}) {
let currentJobStatus = opts.initialJobStatus ?? 'running';
let currentPayload: Record<string, unknown> = {
contextId: 'ctx-r',
status: { state: opts.initialTaskState ?? 'working' },
metadata: { a2aGrantId: opts.grantId },
};
const cancelCalls: string[] = [];
const delegRow = {
id: 'deleg-r-001',
userId: 'user-1',
clientId: 'client-1',
grantId: opts.grantId,
grantedSpaceIds: ['space-1'],
grantedSkills: ['research'],
audience: null as string | null,
expiresAt: null as string | null,
revokedAt: opts.delegationRevoked ? '2026-07-01T00:00:00.000Z' : null as string | null,
};
const repo = {
listNonTerminalA2aTasks: (_limit: number) => {
const state = (currentPayload.status as Record<string, unknown> | undefined)?.state as string | undefined;
if (state && RECONCILER_TERMINAL_STATES.has(state)) return [];
return [{ id: 'a2a-task-r1', jobId: 'job-r1', payload: { ...currentPayload } }];
},
getA2aDelegationByGrantId: (_grantId: string) => delegRow,
cancelA2aLinkedJob: (jobId: string) => {
cancelCalls.push(jobId);
currentJobStatus = 'cancelled';
return true;
},
saveA2aTask: (row: { payload: object }) => {
currentPayload = { ...(row.payload as Record<string, unknown>) };
},
getJob: async (_id: string) => ({
id: 'job-r1',
status: currentJobStatus,
currentActivity: null,
errorSummary: null,
abortReason: null,
worktreePath: null,
}),
getLocalTask: async (id: number) => ({ id, workspacePath: null }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
return {
repo,
cancelCalls,
getCurrentPayload: () => currentPayload,
getCurrentJobStatus: () => currentJobStatus,
};
}
// ── Task 6: Reconciler revocation sweep tests ─────────────────────────────────
describe('A2A reconciler revocation sweep (Task 6)', () => {
const nowFn = () => '2026-07-02T00:00:00.000Z';
it('R1: revoked delegation + running linked job → job cancelled and task finalized to canceled', async () => {
const GRANT_ID = 'grant-revoked-r1';
const { repo, cancelCalls, getCurrentPayload } = makeReconcilerFakeRepo({
grantId: GRANT_ID,
delegationRevoked: true,
initialJobStatus: 'running',
});
const reconciler = new A2aTaskReconciler({ repo, now: nowFn });
const result = await reconciler.reconcileOnce();
// Job must have been cancelled via cancelA2aLinkedJob
expect(cancelCalls).toContain('job-r1');
// Task payload must be finalized to A2A 'canceled' state
const savedState = (getCurrentPayload().status as Record<string, unknown>)?.state;
expect(savedState).toBe('canceled');
// Reconciler must report at least one finalization
expect(result.finalized).toBeGreaterThanOrEqual(1);
});
it('R2: live delegation → task left untouched (still working, job not cancelled)', async () => {
const GRANT_ID = 'grant-live-r2';
const { repo, cancelCalls, getCurrentPayload } = makeReconcilerFakeRepo({
grantId: GRANT_ID,
delegationRevoked: false,
initialJobStatus: 'running',
});
const reconciler = new A2aTaskReconciler({ repo, now: nowFn });
await reconciler.reconcileOnce();
// cancelA2aLinkedJob must NOT have been called
expect(cancelCalls).toHaveLength(0);
// Task must remain in its original non-terminal state (saveA2aTask was not called)
const state = (getCurrentPayload().status as Record<string, unknown>)?.state;
expect(state).toBe('working');
});
it('R3: idempotency — second reconcileOnce() after R1 makes no further changes', async () => {
const GRANT_ID = 'grant-revoked-r3';
const { repo, cancelCalls, getCurrentPayload } = makeReconcilerFakeRepo({
grantId: GRANT_ID,
delegationRevoked: true,
initialJobStatus: 'running',
});
const reconciler = new A2aTaskReconciler({ repo, now: nowFn });
// First run — equivalent to R1
await reconciler.reconcileOnce();
expect(cancelCalls).toHaveLength(1);
expect((getCurrentPayload().status as Record<string, unknown>)?.state).toBe('canceled');
// Second run — task is now terminal so listNonTerminalA2aTasks returns nothing
const result = await reconciler.reconcileOnce();
// No additional cancel calls
expect(cancelCalls).toHaveLength(1);
// Payload stays canceled
expect((getCurrentPayload().status as Record<string, unknown>)?.state).toBe('canceled');
// No new rows scanned or finalized
expect(result.finalized).toBe(0);
});
});

View File

@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { buildBaseCard, buildExtendedCard } from './agent-card.js';
const base = { baseUrl: 'https://m', issuer: 'https://m/oidc', version: '1.0.0' };
describe('agent card', () => {
it('base card has no sensitive skills and advertises oauth2 + extended support', () => {
const c = buildBaseCard(base);
expect(c.name).toBeTruthy();
expect(c.protocolVersion).toBeTruthy();
expect(c.skills).toEqual([]);
expect(c.supportsAuthenticatedExtendedCard).toBe(true);
expect(c.securitySchemes && Object.values(c.securitySchemes)[0]).toMatchObject({ type: 'oauth2' });
expect(c.capabilities).toMatchObject({ streaming: true });
});
it('extended card lists only effective (granted ∩ visible ∩ allowlist) skills', () => {
const principal = {
actingUserId: 'u1', clientId: 'c', grantId: 'g',
delegation: { userId: 'u1', clientId: 'c', grantId: 'g', grantedSpaceIds: ['s1', 's2'], grantedSkills: ['research', 'admin'], expiresAt: null, revokedAt: null },
};
const c = buildExtendedCard(principal, {
...base,
userVisibleSpaceIds: ['s1'], // s2 not visible → dropped
allowlistsBySpace: { s1: ['research'], s2: ['admin'] },
});
const ids = c.skills.map(s => s.id);
expect(ids).toEqual(['research']); // admin dropped (s2 not visible)
});
});

View File

@ -0,0 +1,55 @@
import type { AgentCard } from '@a2a-js/sdk';
import type { A2aPrincipal } from './token-auth.js';
import { resolveEffectiveSpaces, resolveEffectiveSkills } from './delegation.js';
const PROTOCOL_VERSION = '0.3.0';
interface BaseDeps { baseUrl: string; issuer: string; version: string; }
export function buildBaseCard(deps: BaseDeps): AgentCard {
return {
name: 'MAESTRO',
description: 'MAESTRO agent orchestration — A2A inbound interface',
url: `${deps.baseUrl}/a2a`,
version: deps.version,
protocolVersion: PROTOCOL_VERSION,
capabilities: { streaming: true, pushNotifications: false },
defaultInputModes: ['text/plain'],
defaultOutputModes: ['text/plain'],
skills: [],
supportsAuthenticatedExtendedCard: true,
securitySchemes: {
maestro_oauth: {
type: 'oauth2',
flows: {
authorizationCode: {
authorizationUrl: `${deps.issuer}/auth`,
tokenUrl: `${deps.issuer}/token`,
scopes: { 'a2a.read': 'Read-oriented A2A skills within the consented scope' },
},
},
},
},
security: [{ maestro_oauth: ['a2a.read'] }],
} as AgentCard;
}
interface ExtendedDeps extends BaseDeps {
userVisibleSpaceIds: string[];
allowlistsBySpace: Record<string, string[]>;
}
export function buildExtendedCard(principal: A2aPrincipal, deps: ExtendedDeps): AgentCard {
const effectiveSpaces = resolveEffectiveSpaces(principal.delegation.grantedSpaceIds, deps.userVisibleSpaceIds);
const effectiveSkills = resolveEffectiveSkills(principal.delegation.grantedSkills, deps.allowlistsBySpace, effectiveSpaces);
const base = buildBaseCard(deps);
return {
...base,
skills: effectiveSkills.map(id => ({
id,
name: id,
description: `MAESTRO piece "${id}" exposed as an A2A skill`,
tags: ['maestro'],
})),
} as AgentCard;
}

View File

@ -0,0 +1,151 @@
// @vitest-environment node
import { describe, it, expect } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../../db/repository.js';
import { createConsentRouter } from './consent-api.js';
const RESOURCE_AUDIENCE = 'https://m/a2a';
function fakeProvider(captured: any, overrideParams?: Record<string, unknown>) {
class Grant {
constructor(public a: any) { captured.grant = { ...a, scopes: [] as string[], resources: {} as any }; }
addOIDCScope(s: string) { captured.grant.scopes.push(s); }
addResourceScope(r: string, s: string) { captured.grant.resources[r] = s; }
async save() { return 'grant-xyz'; }
}
const params = overrideParams ?? { client_id: 'cli1', resource: RESOURCE_AUDIENCE, scope: 'openid a2a.read' };
return {
Grant,
async interactionDetails() { return { uid: 'u1', params }; },
async interactionResult() { return '/oidc/auth/u1'; },
} as any;
}
function fakeProviderAbortThrows() {
class Grant {
constructor(public a: any) {}
addOIDCScope() {}
addResourceScope() {}
async save() { return 'grant-xyz'; }
}
return {
Grant,
async interactionDetails() { return { uid: 'u1', params: { client_id: 'cli1', scope: 'openid a2a.read' } }; },
async interactionResult() { throw new Error('SessionNotFound'); },
} as any;
}
describe('consent abort', () => {
it('returns 500 and does NOT crash when interactionResult rejects (SessionNotFound)', async () => {
const repo = new Repository(':memory:');
const app = express();
app.use(express.json());
app.use('/oidc/interaction', createConsentRouter(fakeProviderAbortThrows(), repo, RESOURCE_AUDIENCE));
const res = await request(app).post('/oidc/interaction/u1/abort').send({});
expect(res.status).toBe(500);
expect(res.body.error).toBe('abort error');
});
});
describe('consent confirm', () => {
it('saves a grant with the resource scope, binds accountId from session user, and audit-logs it', async () => {
const repo = new Repository(':memory:');
const captured: any = {};
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as any).user = { id: 'user-9', role: 'user' }; next(); });
app.use('/oidc/interaction', createConsentRouter(fakeProvider(captured), repo, RESOURCE_AUDIENCE));
const res = await request(app).post('/oidc/interaction/u1/confirm').send({});
expect(res.status).toBe(200);
expect(res.body.redirectTo).toBe('/oidc/auth/u1');
// accountId must come from the session user, not from any client param (IDOR guard)
expect(captured.grant.accountId).toBe('user-9');
// resource scope must be recorded
expect(captured.grant.resources[RESOURCE_AUDIENCE]).toBe('a2a.read');
// audit_log row must have been written
const rows = (repo as any).db.prepare("SELECT action, actor FROM audit_log WHERE action='a2a.consent.granted'").all();
expect(rows).toHaveLength(1);
expect(rows[0].actor).toBe('user-9');
});
it('defaults to resourceAudience when client omits resource param', async () => {
const repo = new Repository(':memory:');
const captured: any = {};
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as any).user = { id: 'user-9', role: 'user' }; next(); });
// params WITHOUT a resource key
const paramsNoResource = { client_id: 'cli1', scope: 'openid a2a.read' };
app.use('/oidc/interaction', createConsentRouter(fakeProvider(captured, paramsNoResource), repo, RESOURCE_AUDIENCE));
const res = await request(app).post('/oidc/interaction/u1/confirm').send({});
expect(res.status).toBe(200);
// must fall back to the configured audience
expect(captured.grant.resources[RESOURCE_AUDIENCE]).toBe('a2a.read');
});
it('creates an a2a_delegations row with server-validated spaces/skills and drops client-sent skills not in allowlist', async () => {
const repo = new Repository(':memory:');
// Seed a space owned by the test user with two allowlisted skills.
(repo as any).db.prepare(
"INSERT INTO spaces (id, kind, title, owner_id) VALUES ('sp1', 'case', 'My Space', 'user-9')"
).run();
repo.setSpaceA2aSkills('sp1', ['skill-a', 'skill-b']);
const captured: any = {};
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as any).user = { id: 'user-9', role: 'user' }; next(); });
app.use('/oidc/interaction', createConsentRouter(fakeProvider(captured), repo, RESOURCE_AUDIENCE));
// Client selects sp1 + skill-a + a bad skill not in the allowlist.
const res = await request(app).post('/oidc/interaction/u1/confirm').send({
selectedSpaces: ['sp1'],
selectedSkills: ['skill-a', 'not-allowlisted-skill'],
});
expect(res.status).toBe(200);
// The delegation must exist, linked to the grantId returned by grant.save().
const delegation = repo.getA2aDelegationByGrantId('grant-xyz');
expect(delegation).toBeDefined();
expect(delegation!.grantedSpaceIds).toEqual(['sp1']);
// 'not-allowlisted-skill' must have been dropped; 'skill-a' kept.
expect(delegation!.grantedSkills).toEqual(['skill-a']);
expect(delegation!.userId).toBe('user-9');
expect(delegation!.clientId).toBe('cli1');
});
it('drops a space the consent user cannot see (private space owned by another user)', async () => {
const repo = new Repository(':memory:');
// sp-own: owned by user-9 (visible to user-9)
(repo as any).db.prepare(
"INSERT INTO spaces (id, kind, title, owner_id) VALUES ('sp-own', 'case', 'My Space', 'user-9')"
).run();
repo.setSpaceA2aSkills('sp-own', ['skill-x']);
// sp-other: owned by a different user, private (NOT visible to user-9)
(repo as any).db.prepare(
"INSERT INTO spaces (id, kind, title, owner_id) VALUES ('sp-other', 'case', 'Other Space', 'user-other')"
).run();
repo.setSpaceA2aSkills('sp-other', ['skill-y']);
const captured: any = {};
const app = express();
// intentionally omit app-level express.json() to prove the router self-mounts body parsers
app.use((req, _res, next) => { (req as any).user = { id: 'user-9', role: 'user' }; next(); });
app.use('/oidc/interaction', createConsentRouter(fakeProvider(captured), repo, RESOURCE_AUDIENCE));
// user selects BOTH spaces
const res = await request(app)
.post('/oidc/interaction/u1/confirm')
.type('form')
.send('selectedSpaces=sp-own&selectedSpaces=sp-other&selectedSkills=skill-x&selectedSkills=skill-y');
expect(res.status).toBe(200);
const delegation = repo.getA2aDelegationByGrantId('grant-xyz');
expect(delegation).toBeDefined();
// sp-other must have been dropped by listSpaces({viewer}) visibility check
expect(delegation!.grantedSpaceIds).toEqual(['sp-own']);
expect(delegation!.grantedSkills).toEqual(['skill-x']);
});
});

View File

@ -0,0 +1,167 @@
import express, { Router, type Request, type Response } from 'express';
import type Provider from 'oidc-provider';
import type { Repository } from '../../db/repository.js';
import { A2A_READ_SCOPE } from './oidc-config.js';
import { logger } from '../../logger.js';
import { randomUUID } from 'crypto';
/**
* interaction Passport req.user
* GET /:uid HTML
* POST /:uid/confirm Grant
* POST /:uid/abort access_denied
*/
export function createConsentRouter(provider: Provider, repo: Repository, resourceAudience: string): Router {
const router = Router();
// マウント先の body-parser 構成によらず確実に動作するよう自前で登録する。
// HTML フォーム (application/x-www-form-urlencoded) にも対応。
router.use(express.urlencoded({ extended: false }));
router.use(express.json());
router.get('/:uid', async (req: Request, res: Response) => {
try {
const details = await provider.interactionDetails(req, res);
const user = req.user as Express.User | undefined;
if (!user) {
// 未ログイン: 既存ログインへ。戻り先に interaction を指定。
res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`);
return;
}
const client = repo.getA2aClient(details.params.client_id as string);
const clientName = client?.name ?? (details.params.client_id as string);
const scopes = String(details.params.scope ?? '').split(' ').filter(Boolean);
// ユーザーが閲覧可能でかつ A2A スキルが 1 件以上あるスペースを列挙する。
const allSpaces = await repo.listSpaces({ viewer: user });
const spacesWithSkills: Array<{ id: string; title: string; skills: string[] }> = [];
for (const space of allSpaces) {
const skills = repo.getSpaceA2aSkills(space.id);
if (skills.length > 0) spacesWithSkills.push({ id: space.id, title: space.title, skills });
}
res.type('html').send(renderConsent(details.uid, clientName, scopes, spacesWithSkills));
} catch (err) {
logger.error(`[a2a-oidc] interaction render error: ${err}`);
res.status(500).send('interaction error');
}
});
router.post('/:uid/confirm', async (req: Request, res: Response) => {
try {
const details = await provider.interactionDetails(req, res);
const user = req.user as Express.User | undefined;
if (!user) { res.status(401).json({ error: 'login required' }); return; }
const accountId = user.id;
const clientId = details.params.client_id as string;
const resource = (details.params.resource as string | undefined) ?? resourceAudience;
// ボディから要求されたスペース/スキルを取り出すJSON or URL-encoded 両対応)。
// 単一チェックボックスは string で届くため concat で配列に正規化する。
const toStringArray = (v: unknown): string[] =>
([] as string[]).concat(v as any ?? []).filter((x): x is string => typeof x === 'string');
const requestedSpaces = toStringArray(req.body?.selectedSpaces);
const requestedSkillSet = new Set<string>(toStringArray(req.body?.selectedSkills));
// サーバー側で再検証: ユーザーが実際に閲覧できるかつスキルを持つスペースのみ許可。
const visibleSpaces = await repo.listSpaces({ viewer: user });
const visibleSpaceIds = new Set(visibleSpaces.map(s => s.id));
const grantedSpaceIds: string[] = [];
const grantedSkillsSet = new Set<string>();
for (const spaceId of requestedSpaces) {
if (!visibleSpaceIds.has(spaceId)) continue;
const allowlisted = repo.getSpaceA2aSkills(spaceId);
if (allowlisted.length === 0) continue;
grantedSpaceIds.push(spaceId);
for (const skill of allowlisted) {
if (requestedSkillSet.has(skill)) grantedSkillsSet.add(skill);
}
}
// Grant を作って scope/resource を付与。
// a2a.read は oidc-config の top-level `scopes` に載る OP スコープでもあるため、
// resource スコープだけでなく OP スコープとしても付与しないと、consent prompt の
// `op_scopes_missing` が解消されず認可コードフローが consent ループに陥る。
const GrantCtor = (provider as any).Grant;
const grant = new GrantCtor({ accountId, clientId });
grant.addOIDCScope('openid');
grant.addOIDCScope(A2A_READ_SCOPE);
grant.addResourceScope(resource, A2A_READ_SCOPE);
const grantId: string = await grant.save();
// 委任行を書き込むgrant.save() 直後、interactionResult より前)。
// スコープ外のみ選択された場合は grantedSpaceIds が空になる → 孤立行を作らない。
if (grantedSpaceIds.length > 0) {
repo.createA2aDelegation({
id: randomUUID(),
userId: accountId,
clientId,
grantId,
grantedSpaceIds,
grantedSkills: [...grantedSkillsSet],
audience: resource,
expiresAt: null,
revokedAt: null,
});
}
const result = { login: { accountId }, consent: { grantId } };
const redirectTo = await provider.interactionResult(req, res, result, { mergeWithLastSubmission: false });
try {
await repo.addAuditLog(null, 'a2a.consent.granted', accountId, { clientId, resource, grantId });
} catch (auditErr) {
logger.warn(`[a2a-oidc] audit log failed (non-fatal): ${auditErr}`);
}
res.json({ redirectTo });
} catch (err) {
logger.error(`[a2a-oidc] interaction confirm error: ${err}`);
res.status(500).json({ error: 'confirm error' });
}
});
router.post('/:uid/abort', async (req: Request, res: Response) => {
try {
const result = { error: 'access_denied', error_description: 'user aborted' };
const redirectTo = await provider.interactionResult(req, res, result, { mergeWithLastSubmission: false });
res.json({ redirectTo });
} catch (err) {
logger.error(`[a2a-oidc] interaction abort error: ${err}`);
res.status(500).json({ error: 'abort error' });
}
});
return router;
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
}
type SpaceOption = { id: string; title: string; skills: string[] };
function renderConsent(uid: string, clientName: string, scopes: string[], spaces: SpaceOption[]): string {
const scopeItems = scopes.map(s => `<li>${escapeHtml(s)}</li>`).join('');
const spaceSection = spaces.length === 0
? '<p><em>委任可能なスペースがありません。</em></p>'
: spaces.map(sp => {
const skillBoxes = sp.skills.map(sk =>
`<label style="display:block;margin-left:16px"><input type="checkbox" name="selectedSkills" value="${escapeHtml(sk)}" checked> ${escapeHtml(sk)}</label>`
).join('');
return `<div style="margin:8px 0;padding:8px;border:1px solid #ccc;border-radius:4px"><label><input type="checkbox" name="selectedSpaces" value="${escapeHtml(sp.id)}" checked> <strong>${escapeHtml(sp.title)}</strong></label>${skillBoxes}</div>`;
}).join('');
return `<!doctype html><meta charset="utf-8"><title>A2A 委任の同意</title>
<body style="font-family:sans-serif;max-width:480px;margin:40px auto">
<h1></h1>
<p><strong>${escapeHtml(clientName)}</strong> </p>
<ul>${scopeItems}</ul>
<form method="post" action="/oidc/interaction/${encodeURIComponent(uid)}/confirm">
<h2></h2>
${spaceSection}
<button type="submit"></button>
</form>
<form method="post" action="/oidc/interaction/${encodeURIComponent(uid)}/abort" style="margin-top:8px">
<button type="submit"></button>
</form>
</body>`;
}

View File

@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { isDelegationLive, resolveEffectiveSpaces, resolveEffectiveSkills } from './delegation.js';
describe('a2a delegation AND-intersection', () => {
it('isDelegationLive: false when revoked or expired', () => {
expect(isDelegationLive({ expiresAt: null, revokedAt: null }, '2026-06-27T00:00:00Z')).toBe(true);
expect(isDelegationLive({ expiresAt: null, revokedAt: '2026-06-26T00:00:00Z' }, '2026-06-27T00:00:00Z')).toBe(false);
expect(isDelegationLive({ expiresAt: '2026-06-26T00:00:00Z', revokedAt: null }, '2026-06-27T00:00:00Z')).toBe(false);
expect(isDelegationLive({ expiresAt: '2026-06-28T00:00:00Z', revokedAt: null }, '2026-06-27T00:00:00Z')).toBe(true);
});
it('isDelegationLive: boundary case when expiresAt equals nowIso', () => {
expect(isDelegationLive({ expiresAt: '2026-06-27T00:00:00Z', revokedAt: null }, '2026-06-27T00:00:00Z')).toBe(false);
});
it('isDelegationLive: non-UTC offset comparison (Important fix)', () => {
// expiresAt with +05:00 offset: 2026-06-27T02:00:00+05:00 = 2026-06-26T21:00:00Z
// nowIso in Z: 2026-06-27T00:00:00Z
// expiresAt (21:00Z) < nowIso (00:00Z on next day) → expired
expect(isDelegationLive({ expiresAt: '2026-06-27T02:00:00+05:00', revokedAt: null }, '2026-06-27T00:00:00Z')).toBe(false);
});
it('resolveEffectiveSpaces: intersection only (no grant of unseen space)', () => {
expect(resolveEffectiveSpaces(['s1', 's2', 's3'], ['s2', 's3', 's9'])).toEqual(['s2', 's3']);
expect(resolveEffectiveSpaces(['s1'], ['s2'])).toEqual([]); // out-of-scope → empty (fail-closed)
});
it('resolveEffectiveSpaces: empty-input fail-closed', () => {
expect(resolveEffectiveSpaces([], ['s2'])).toEqual([]);
expect(resolveEffectiveSpaces(['s1'], [])).toEqual([]);
});
it('resolveEffectiveSpaces: deduplication of grantedSpaceIds', () => {
expect(resolveEffectiveSpaces(['s1', 's1'], ['s1'])).toEqual(['s1']);
expect(resolveEffectiveSpaces(['s1', 's1', 's2', 's2'], ['s1', 's2'])).toEqual(['s1', 's2']);
});
it('resolveEffectiveSkills: granted ∩ union of allowlists of effective spaces', () => {
const allow = { s2: ['research', 'summarize'], s3: ['translate'], s9: ['admin'] };
// effective spaces s2,s3; granted skills research,translate,admin
expect(resolveEffectiveSkills(['research', 'translate', 'admin'], allow, ['s2', 's3']).sort())
.toEqual(['research', 'translate']); // admin only allowed in s9 which is not effective
});
it('resolveEffectiveSkills: empty when no effective spaces', () => {
expect(resolveEffectiveSkills(['research'], { s1: ['research'] }, [])).toEqual([]);
});
it('resolveEffectiveSkills: reverse exclusion (skill in allowlist but not in grantedSkills)', () => {
// grantedSkills: ['research']
// s1 allowlist: ['research', 'summarize']
// effective spaces: ['s1']
// result: only 'research' (because 'summarize' is not granted)
const allow = { s1: ['research', 'summarize'] };
expect(resolveEffectiveSkills(['research'], allow, ['s1']).sort()).toEqual(['research']);
});
});

View File

@ -0,0 +1,102 @@
import type { Repository } from '../../db/repository.js';
import { resolveOrgIds } from '../auth.js';
export interface A2aDelegation {
/** Primary key of the a2a_delegations row. */
id: string;
userId: string;
clientId: string;
grantId: string;
grantedSpaceIds: string[];
grantedSkills: string[];
expiresAt: string | null;
revokedAt: string | null;
}
/** 委任が有効か(失効していない & 期限内。nowIso は呼び出し側が渡す(テスト決定論のため)。 */
export function isDelegationLive(
d: { expiresAt: string | null; revokedAt: string | null },
nowIso: string,
): boolean {
if (d.revokedAt) return false;
if (d.expiresAt && new Date(d.expiresAt).getTime() <= new Date(nowIso).getTime()) return false;
return true;
}
/**
* computeEffectiveScope principal
* A2aPrincipal (token-auth.ts)
* import
*/
export interface A2aPrincipalLike {
actingUserId: string;
/** OAuth クライアント識別子。audit log 記録用(省略可)。 */
clientId?: string;
delegation: {
grantedSpaceIds: string[];
grantedSkills: string[];
};
}
export interface EffectiveScope {
viewer: Express.User;
userVisibleSpaceIds: string[];
allowlistsBySpace: Record<string, string[]>;
effectiveSpaceIds: string[];
effectiveSkills: string[];
}
/**
* acting user delegation AND
* user active null fail-closed
*/
export async function computeEffectiveScope(
repo: Repository,
principal: A2aPrincipalLike,
): Promise<EffectiveScope | null> {
const u = repo.getUserById(principal.actingUserId);
if (!u || u.status !== 'active') return null;
const viewer = {
id: u.id,
role: u.role,
orgIds: resolveOrgIds(repo, u.id),
status: u.status,
name: u.name,
} as Express.User;
const visible = await repo.listSpaces({ viewer });
const userVisibleSpaceIds = visible.map(s => s.id);
const allowlistsBySpace: Record<string, string[]> = {};
for (const s of visible) {
allowlistsBySpace[s.id] = repo.getSpaceA2aSkills(s.id);
}
const effectiveSpaceIds = resolveEffectiveSpaces(
principal.delegation.grantedSpaceIds,
userVisibleSpaceIds,
);
const effectiveSkills = resolveEffectiveSkills(
principal.delegation.grantedSkills,
allowlistsBySpace,
effectiveSpaceIds,
);
return { viewer, userVisibleSpaceIds, allowlistsBySpace, effectiveSpaceIds, effectiveSkills };
}
/** AND 交差: 委任が許可したスペース ∩ acting user が実際に見えるスペース。 */
export function resolveEffectiveSpaces(grantedSpaceIds: string[], userVisibleSpaceIds: string[]): string[] {
const visible = new Set(userVisibleSpaceIds);
return [...new Set(grantedSpaceIds)].filter(id => visible.has(id));
}
/** AND 交差: 委任スキル ∩ (有効スペースの A2A 許可リストの和集合)。重複排除。 */
export function resolveEffectiveSkills(
grantedSkills: string[],
spaceAllowlistsBySpace: Record<string, string[]>,
effectiveSpaceIds: string[],
): string[] {
const allowed = new Set<string>();
for (const sid of effectiveSpaceIds) {
for (const skill of (spaceAllowlistsBySpace[sid] ?? [])) allowed.add(skill);
}
const grantedSet = new Set(grantedSkills);
return [...allowed].filter(s => grantedSet.has(s));
}

View File

@ -0,0 +1,237 @@
// @vitest-environment node
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { randomUUID } from 'crypto';
import { Repository } from '../../db/repository.js';
import { createUserDelegationsRouter, createAdminDelegationsRouter } from './delegations-api.js';
// ── Stub principals ───────────────────────────────────────────────────────────
const USER1 = { id: 'user1', role: 'user', orgIds: [] };
const USER2 = { id: 'user2', role: 'user', orgIds: [] };
const ADMIN = { id: 'admin1', role: 'admin', orgIds: [] };
// ── App factories ─────────────────────────────────────────────────────────────
function makeUserApp(repo: Repository, actingUser: object) {
const app = express();
// authActive=true → viewerOf() reads req.user
app.use((req, _res, next) => { (req as any).user = actingUser; next(); });
app.use('/api/local/a2a/delegations', express.json(), createUserDelegationsRouter(repo, true));
return app;
}
function makeAdminApp(repo: Repository, actingAdmin: object) {
const app = express();
// authActive=true → viewerOf() reads req.user (same pattern as user router)
app.use((req, _res, next) => { (req as any).user = actingAdmin; next(); });
app.use('/api/admin/a2a/delegations', express.json(), createAdminDelegationsRouter(repo, true));
return app;
}
// ── Seed helper ───────────────────────────────────────────────────────────────
interface SeedOpts {
clientId?: string;
grantId?: string | null;
grantedSpaceIds?: string[];
grantedSkills?: string[];
audience?: string | null;
expiresAt?: string | null;
revokedAt?: string | null;
}
function seedDelegation(repo: Repository, userId: string, opts: SeedOpts = {}): string {
const id = randomUUID();
repo.createA2aDelegation({
id,
userId,
clientId: opts.clientId ?? 'client-x',
grantId: opts.grantId ?? null,
grantedSpaceIds: opts.grantedSpaceIds ?? ['space-1'],
grantedSkills: opts.grantedSkills ?? ['skill-a'],
audience: opts.audience ?? null,
expiresAt: opts.expiresAt ?? null,
revokedAt: opts.revokedAt ?? null,
});
return id;
}
// ── User router tests ─────────────────────────────────────────────────────────
describe('createUserDelegationsRouter', () => {
let repo: Repository;
beforeEach(() => { repo = new Repository(':memory:'); });
it('GET / returns only the viewer\'s own delegations, not other users\'', async () => {
const d1 = seedDelegation(repo, 'user1');
const d2 = seedDelegation(repo, 'user2');
const res = await request(makeUserApp(repo, USER1)).get('/api/local/a2a/delegations');
expect(res.status).toBe(200);
const ids: string[] = res.body.delegations.map((d: any) => d.id);
expect(ids).toContain(d1);
expect(ids).not.toContain(d2);
});
it('GET / response shape includes required fields', async () => {
seedDelegation(repo, 'user1', { grantedSpaceIds: ['s1'], grantedSkills: ['sk1'] });
const res = await request(makeUserApp(repo, USER1)).get('/api/local/a2a/delegations');
expect(res.status).toBe(200);
const d = res.body.delegations[0];
expect(d).toHaveProperty('id');
expect(d).toHaveProperty('clientId');
expect(d).toHaveProperty('clientName');
expect(d).toHaveProperty('grantedSpaceIds');
expect(d).toHaveProperty('grantedSkills');
expect(d).toHaveProperty('expiresAt');
expect(d).toHaveProperty('revokedAt');
expect(d).toHaveProperty('createdAt');
expect(d).toHaveProperty('live');
});
it('GET / live=true for active delegation', async () => {
const id = seedDelegation(repo, 'user1');
const res = await request(makeUserApp(repo, USER1)).get('/api/local/a2a/delegations');
expect(res.status).toBe(200);
const d = res.body.delegations.find((x: any) => x.id === id);
expect(d.live).toBe(true);
});
it('GET / live=false for revoked delegation', async () => {
const id = seedDelegation(repo, 'user1', { revokedAt: '2020-01-01T00:00:00.000Z' });
const res = await request(makeUserApp(repo, USER1)).get('/api/local/a2a/delegations');
expect(res.status).toBe(200);
const d = res.body.delegations.find((x: any) => x.id === id);
expect(d.live).toBe(false);
});
it('GET / live=false for expired delegation', async () => {
const id = seedDelegation(repo, 'user1', { expiresAt: '2020-01-01T00:00:00.000Z' });
const res = await request(makeUserApp(repo, USER1)).get('/api/local/a2a/delegations');
const d = res.body.delegations.find((x: any) => x.id === id);
expect(d.live).toBe(false);
});
it('POST /:id/revoke with owner → 200 and row is now revoked', async () => {
const id = seedDelegation(repo, 'user1');
const res = await request(makeUserApp(repo, USER1))
.post(`/api/local/a2a/delegations/${id}/revoke`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('revoked');
expect(repo.getA2aDelegationById(id)?.revokedAt).toBeTruthy();
});
it('POST /:id/revoke by non-owner → 404 and row untouched', async () => {
const id = seedDelegation(repo, 'user1');
const res = await request(makeUserApp(repo, USER2))
.post(`/api/local/a2a/delegations/${id}/revoke`);
expect(res.status).toBe(404);
expect(res.body.error).toBe('not_found');
// row must be untouched
expect(repo.getA2aDelegationById(id)?.revokedAt).toBeNull();
});
it('POST /:id/revoke with unknown id → 404 (same body as forbidden)', async () => {
const res = await request(makeUserApp(repo, USER1))
.post('/api/local/a2a/delegations/no-such-id/revoke');
expect(res.status).toBe(404);
expect(res.body.error).toBe('not_found');
});
it('POST /:id/revoke on already-revoked delegation → 200 with already-revoked status', async () => {
const id = seedDelegation(repo, 'user1', { revokedAt: '2020-01-01T00:00:00.000Z' });
const res = await request(makeUserApp(repo, USER1))
.post(`/api/local/a2a/delegations/${id}/revoke`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('already-revoked');
});
});
// ── Admin router tests ────────────────────────────────────────────────────────
describe('createAdminDelegationsRouter', () => {
let repo: Repository;
beforeEach(() => { repo = new Repository(':memory:'); });
it('GET / returns all delegations from all users', async () => {
const d1 = seedDelegation(repo, 'user1');
const d2 = seedDelegation(repo, 'user2');
const res = await request(makeAdminApp(repo, ADMIN)).get('/api/admin/a2a/delegations');
expect(res.status).toBe(200);
const ids: string[] = res.body.delegations.map((d: any) => d.id);
expect(ids).toContain(d1);
expect(ids).toContain(d2);
});
it('GET / response includes userId per row', async () => {
seedDelegation(repo, 'user1');
const res = await request(makeAdminApp(repo, ADMIN)).get('/api/admin/a2a/delegations');
expect(res.body.delegations[0]).toHaveProperty('userId');
});
it('POST /:id/revoke admin can revoke another user\'s delegation → 200', async () => {
const id = seedDelegation(repo, 'user1');
const res = await request(makeAdminApp(repo, ADMIN))
.post(`/api/admin/a2a/delegations/${id}/revoke`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('revoked');
expect(repo.getA2aDelegationById(id)?.revokedAt).toBeTruthy();
});
it('POST /:id/revoke unknown id → 404', async () => {
const res = await request(makeAdminApp(repo, ADMIN))
.post('/api/admin/a2a/delegations/unknown-id/revoke');
expect(res.status).toBe(404);
expect(res.body.error).toBe('not_found');
});
it('POST /:id/revoke already-revoked → 200', async () => {
const id = seedDelegation(repo, 'user2', { revokedAt: '2020-01-01T00:00:00.000Z' });
const res = await request(makeAdminApp(repo, ADMIN))
.post(`/api/admin/a2a/delegations/${id}/revoke`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('already-revoked');
});
});
// ── Error handling — repo/cascade throws → 500 ────────────────────────────────
describe('error handling: repo throws → 500', () => {
it('user GET / returns 500 when repo throws', async () => {
const repo = new Repository(':memory:');
(repo as any).listA2aDelegationsForUserWithClient = () => { throw new Error('simulated db error'); };
const res = await request(makeUserApp(repo, USER1)).get('/api/local/a2a/delegations');
expect(res.status).toBe(500);
expect(res.body.error).toBe('internal');
});
it('user POST /:id/revoke returns 500 when cascade throws (repo.getA2aDelegationById throws)', async () => {
const repo = new Repository(':memory:');
// revokeDelegationCascade calls repo.getA2aDelegationById first — make it throw
(repo as any).getA2aDelegationById = () => { throw new Error('simulated db error'); };
const res = await request(makeUserApp(repo, USER1))
.post('/api/local/a2a/delegations/any-id/revoke');
expect(res.status).toBe(500);
expect(res.body.error).toBe('internal');
});
it('admin GET / returns 500 when repo throws', async () => {
const repo = new Repository(':memory:');
(repo as any).listA2aDelegationsAllWithClient = () => { throw new Error('simulated db error'); };
const res = await request(makeAdminApp(repo, ADMIN)).get('/api/admin/a2a/delegations');
expect(res.status).toBe(500);
expect(res.body.error).toBe('internal');
});
it('admin POST /:id/revoke returns 500 when cascade throws (repo.getA2aDelegationById throws)', async () => {
const repo = new Repository(':memory:');
// revokeDelegationCascade calls repo.getA2aDelegationById first — make it throw
(repo as any).getA2aDelegationById = () => { throw new Error('simulated db error'); };
const res = await request(makeAdminApp(repo, ADMIN))
.post('/api/admin/a2a/delegations/any-id/revoke');
expect(res.status).toBe(500);
expect(res.body.error).toBe('internal');
});
});

View File

@ -0,0 +1,150 @@
/**
* delegations-api.ts A2A
*
* createUserDelegationsRouter: /api/local/a2a/delegations (requireAuth )
* createAdminDelegationsRouter: /api/admin/a2a/delegations (requireAdmin )
*/
import { Router, type Request, type Response } from 'express';
import type { Repository } from '../../db/repository.js';
import { logger } from '../../logger.js';
import { viewerOf } from '../space-viewer.js';
import { revokeDelegationCascade } from './revoke.js';
import { isDelegationLive } from './delegation.js';
// ── User router ───────────────────────────────────────────────────────────────
/**
*
* /api/local server.ts requireAuth
*/
export function createUserDelegationsRouter(repo: Repository, authActive: boolean): Router {
const router = Router();
// GET / — viewer の委任一覧
router.get('/', (req: Request, res: Response) => {
try {
const viewer = viewerOf(req, authActive);
const nowIso = new Date().toISOString();
const rows = repo.listA2aDelegationsForUserWithClient(viewer.id);
const delegations = rows.map(d => ({
id: d.id,
clientId: d.clientId,
clientName: d.clientName,
grantedSpaceIds: d.grantedSpaceIds,
grantedSkills: d.grantedSkills,
expiresAt: d.expiresAt ?? null,
revokedAt: d.revokedAt ?? null,
createdAt: d.createdAt ?? null,
live: isDelegationLive(d, nowIso),
}));
logger.info(`[a2a-api] listDelegations userId=${viewer.id} count=${delegations.length}`);
res.json({ delegations });
} catch (err) {
logger.error(`[a2a-api] listDelegations failed: ${err}`);
res.status(500).json({ error: 'internal' });
}
});
// POST /:id/revoke — viewer 自身の委任を取消
// not-found と forbidden は両方 404 で返す(存在リーク防止)
router.post('/:id/revoke', async (req: Request, res: Response) => {
try {
const viewer = viewerOf(req, authActive);
const nowIso = new Date().toISOString();
const outcome = await revokeDelegationCascade(
repo,
req.params.id,
{ userId: viewer.id, isAdmin: false },
nowIso,
);
if (outcome.status === 'revoked' || outcome.status === 'already-revoked') {
logger.info(
`[a2a-api] revoke delegationId=${req.params.id} status=${outcome.status} userId=${viewer.id}`,
);
const body: Record<string, unknown> = { status: outcome.status };
if ('cancelledJobs' in outcome) body.cancelledJobs = outcome.cancelledJobs;
res.json(body);
} else {
// 'not-found' | 'forbidden' → 同一 404存在リーク防止
logger.info(
`[a2a-api] revoke delegationId=${req.params.id} outcome=${outcome.status} userId=${viewer.id} → 404`,
);
res.status(404).json({ error: 'not_found' });
}
} catch (err) {
logger.error(`[a2a-api] revoke failed delegationId=${req.params.id}: ${err}`);
res.status(500).json({ error: 'internal' });
}
});
return router;
}
// ── Admin router ──────────────────────────────────────────────────────────────
/**
*
* server.ts requireAdmin
*/
export function createAdminDelegationsRouter(repo: Repository, authActive: boolean): Router {
const router = Router();
// GET / — 全ユーザーの委任一覧
router.get('/', (_req: Request, res: Response) => {
try {
const nowIso = new Date().toISOString();
const rows = repo.listA2aDelegationsAllWithClient();
const delegations = rows.map(d => ({
id: d.id,
userId: d.userId,
clientId: d.clientId,
clientName: d.clientName,
grantedSpaceIds: d.grantedSpaceIds,
grantedSkills: d.grantedSkills,
expiresAt: d.expiresAt ?? null,
revokedAt: d.revokedAt ?? null,
createdAt: d.createdAt ?? null,
live: isDelegationLive(d, nowIso),
}));
logger.info(`[a2a-api] adminListDelegations count=${delegations.length}`);
res.json({ delegations });
} catch (err) {
logger.error(`[a2a-api] adminListDelegations failed: ${err}`);
res.status(500).json({ error: 'internal' });
}
});
// POST /:id/revoke — 管理者が任意ユーザーの委任を取消
// revoked/already-revoked → 200not-found → 404その他 → 500防衛的
router.post('/:id/revoke', async (req: Request, res: Response) => {
try {
const admin = viewerOf(req, authActive);
const nowIso = new Date().toISOString();
const outcome = await revokeDelegationCascade(
repo,
req.params.id,
{ userId: admin.id, isAdmin: true },
nowIso,
);
if (outcome.status === 'revoked' || outcome.status === 'already-revoked') {
logger.info(
`[a2a-api] adminRevoke delegationId=${req.params.id} status=${outcome.status} adminId=${admin.id}`,
);
const body: Record<string, unknown> = { status: outcome.status };
if ('cancelledJobs' in outcome) body.cancelledJobs = outcome.cancelledJobs;
res.json(body);
} else if (outcome.status === 'not-found') {
res.status(404).json({ error: 'not_found' });
} else {
// defensive: 'forbidden' or any future unknown status → 500
logger.error(`[a2a-api] adminRevoke unexpected status=${outcome.status} delegationId=${req.params.id}`);
res.status(500).json({ error: 'internal' });
}
} catch (err) {
logger.error(`[a2a-api] adminRevoke failed delegationId=${req.params.id}: ${err}`);
res.status(500).json({ error: 'internal' });
}
});
return router;
}

View File

@ -0,0 +1,527 @@
// @vitest-environment node
/**
* MaestroA2aExecutor
* Worker / LLM 使fake repo + fake eventBus //Artifact
*/
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import { describe, it, expect, vi } from 'vitest';
import { MaestroA2aExecutor } from './executor.js';
// ── fake helpers ─────────────────────────────────────────────────────────────
function makeFakeEventBus() {
const published: unknown[] = [];
let isFinished = false;
const bus = {
published,
get finishedCalled() { return isFinished; },
publish(event: unknown) { published.push(event); },
finished() { isFinished = true; },
on() { return bus; },
off() { return bus; },
once() { return bus; },
removeAllListeners() { return bus; },
};
return bus;
}
/** RequestContext のモック。 */
function makeRequestContext(opts: {
taskId?: string;
contextId?: string;
a2aPrincipal?: unknown;
messageText?: string;
skillId?: string;
} = {}) {
const {
taskId = 'task-1',
contextId = 'ctx-1',
a2aPrincipal,
messageText = 'research something',
skillId,
} = opts;
return {
taskId,
contextId,
userMessage: {
kind: 'message',
messageId: 'msg-1',
role: 'user',
parts: [{ kind: 'text', text: messageText }],
metadata: skillId ? { skillId } : {},
},
context: a2aPrincipal
? { user: { a2aPrincipal, isAuthenticated: true, userName: 'u1' } }
: undefined,
};
}
/**
* Fake repo
* jobSequence: getJob
*
* Fix 1: workspacePath createLocalTask 使 null
* getLocalTask 使re-fetch
*/
function makeFakeRepo(opts: {
spaceSkills?: string[];
jobSequence?: Array<{ status: string; currentActivity?: string | null; errorSummary?: string | null; abortReason?: string | null; worktreePath?: string | null }>;
workspacePath?: string | null;
userStatus?: string;
} = {}) {
const {
spaceSkills = ['research'],
jobSequence = [
{ status: 'queued', currentActivity: null },
{ status: 'running', currentActivity: 'step1' },
{ status: 'succeeded', currentActivity: null, errorSummary: null, abortReason: null },
],
workspacePath = null,
userStatus = 'active',
} = opts;
let callCount = 0;
const space = { id: 's1', ownerId: 'u1', status: 'open', name: 'Space 1', visibility: 'private' };
const user = { id: 'u1', role: 'user', status: userStatus, name: 'User One' };
return {
getUserById: vi.fn().mockReturnValue(user.status === 'active' ? user : { ...user, status: userStatus }),
listSpaces: vi.fn().mockResolvedValue([space]),
getSpaceA2aSkills: vi.fn().mockReturnValue(spaceSkills),
listUserGiteaOrgs: vi.fn().mockReturnValue([]),
listUserLocalOrgs: vi.fn().mockReturnValue([]),
getUserGiteaOrgs: vi.fn().mockReturnValue([]),
// Fix 1: createLocalTask always returns workspacePath: null (matches production behaviour —
// Worker backfills workspacePath at dispatch time, not at creation time).
createLocalTask: vi.fn().mockImplementation(async (params: unknown) => ({
id: 1,
workspacePath: null,
...(params as object),
})),
// Fix 1: getLocalTask returns the backfilled workspacePath (simulates Worker dispatch).
getLocalTask: vi.fn().mockImplementation(async () => ({
id: 1,
workspacePath,
})),
createJob: vi.fn().mockResolvedValue({ id: 'job-1' }),
getLatestJobForIssue: vi.fn().mockResolvedValue({ id: 'job-1' }),
getJob: vi.fn().mockImplementation(async () => {
const idx = Math.min(callCount, jobSequence.length - 1);
callCount++;
return jobSequence[idx];
}),
requestJobCancel: vi.fn().mockReturnValue(true),
cancelA2aLinkedJob: vi.fn().mockReturnValue(true),
addAuditLog: vi.fn(),
// Task 5: revocation recheck — return a live delegation by default so existing tests are unaffected
getA2aDelegationByGrantId: vi.fn().mockReturnValue({
id: 'deleg-1',
userId: 'u1',
clientId: 'client-1',
grantId: 'grant-1',
grantedSpaceIds: ['s1'],
grantedSkills: ['research'],
audience: null,
expiresAt: null,
revokedAt: null,
}),
};
}
// デフォルトの A2A principal空間 s1 で research スキルを委任)
const DEFAULT_PRINCIPAL = {
actingUserId: 'u1',
clientId: 'client-1',
grantId: 'grant-1',
delegation: {
// Task 5: id は委任行の PKa2aDelegationId に使用)
id: 'deleg-1',
grantedSpaceIds: ['s1'],
grantedSkills: ['research'],
expiresAt: null,
revokedAt: null,
},
};
// ── テスト ────────────────────────────────────────────────────────────────────
describe('MaestroA2aExecutor', () => {
describe('execute — happy path', () => {
it('working イベント + artifact + completed が publish され finished() が呼ばれる', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'a2a-exec-test-'));
try {
const outputDir = path.join(tmpDir, 'output');
fs.mkdirSync(outputDir);
fs.writeFileSync(path.join(outputDir, 'result.txt'), 'Hello result');
// Fix 1: workspacePath is passed so getLocalTask returns it (createLocalTask returns null).
const repo = makeFakeRepo({ workspacePath: tmpDir });
const eventBus = makeFakeEventBus();
const executor = new MaestroA2aExecutor(repo as any, {
pollMs: 0,
sleep: async () => {},
});
const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });
await executor.execute(ctx as any, eventBus as any);
expect(eventBus.finishedCalled).toBe(true);
// Fix 1 verification: createLocalTask returned workspacePath=null; getLocalTask
// returned the real path, and the artifact was still found via the re-fetch.
expect(repo.createLocalTask).toHaveBeenCalled();
const createCallResult = await repo.createLocalTask.mock.results[0].value;
expect(createCallResult.workspacePath).toBeNull();
expect(repo.getLocalTask).toHaveBeenCalledWith(1);
// 初期 Tasksubmittedが先頭に publish されている
const initialTask = eventBus.published.find((e: any) => e.kind === 'task') as any;
expect(initialTask).toBeDefined();
expect(initialTask.id).toBe('task-1');
expect(initialTask.metadata?.maestroJobId).toBe('job-1');
expect(initialTask.metadata?.maestroLocalTaskId).toBe(1);
// Task 3: 委任 metadata が初期 Task に含まれる
expect(initialTask.metadata?.a2aActingUserId).toBe('u1');
expect(initialTask.metadata?.a2aGrantId).toBe('grant-1');
// Task 5 (2C-1): a2aDelegationId は委任行の PKdelegation.id、a2aGrantId とは別物
expect(initialTask.metadata?.a2aDelegationId).toBe('deleg-1');
expect(initialTask.status.state).toBe('submitted');
// working 進捗が少なくとも 1 回 publish されている
const workingEvents = eventBus.published.filter(
(e: any) => e.kind === 'status-update' && e.status?.state === 'working',
);
expect(workingEvents.length).toBeGreaterThan(0);
// final: false であること
for (const ev of workingEvents) {
expect((ev as any).final).toBe(false);
}
// result.txt の artifact-update が publish されているre-fetch 経路で取得)
const artifactEvents = eventBus.published.filter(
(e: any) => e.kind === 'artifact-update',
);
expect(artifactEvents.length).toBe(1);
const art = (artifactEvents[0] as any).artifact;
expect(art.name).toBe('result.txt');
expect(art.parts[0].kind).toBe('text');
expect(art.parts[0].text).toBe('Hello result');
// 最終 completedfinal: trueが publish されている
const completedEvent = eventBus.published.find(
(e: any) => e.kind === 'status-update' && e.status?.state === 'completed' && e.final === true,
);
expect(completedEvent).toBeDefined();
// T8: A2A enqueue 経路で audit log が書き込まれること
expect(repo.addAuditLog).toHaveBeenCalledWith(
'job-1',
'a2a_job_queued',
'a2a',
expect.objectContaining({ taskId: 1, actingUserId: 'u1' }),
);
// createLocalTask が正しい引数で呼ばれた
expect(repo.createLocalTask).toHaveBeenCalledWith(
expect.objectContaining({
pieceName: 'research',
spaceId: 's1',
ownerId: 'u1',
visibility: 'private',
workspaceMode: 'persistent',
}),
);
} finally {
fs.rmSync(tmpDir, { recursive: true });
}
});
});
describe('execute — out-of-scope', () => {
it('effectiveSkills が空 → failed publish + finished、createLocalTask は呼ばれない', async () => {
// spaceSkills = [] → effectiveSkills が空 → selectPieceForMessage は null
const repo = makeFakeRepo({ spaceSkills: [] });
const eventBus = makeFakeEventBus();
const executor = new MaestroA2aExecutor(repo as any, {
pollMs: 0,
sleep: async () => {},
});
const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });
await executor.execute(ctx as any, eventBus as any);
expect(eventBus.finishedCalled).toBe(true);
expect(repo.createLocalTask).not.toHaveBeenCalled();
const failedEvent = eventBus.published.find(
(e: any) => e.kind === 'status-update' && e.status?.state === 'failed',
);
expect(failedEvent).toBeDefined();
expect((failedEvent as any).final).toBe(true);
});
});
describe('execute — no principal', () => {
it('context に a2aPrincipal が無い → failed + finished', async () => {
const repo = makeFakeRepo();
const eventBus = makeFakeEventBus();
const executor = new MaestroA2aExecutor(repo as any, {
pollMs: 0,
sleep: async () => {},
});
// a2aPrincipal を渡さない
const ctx = makeRequestContext();
await executor.execute(ctx as any, eventBus as any);
expect(eventBus.finishedCalled).toBe(true);
const failedEvent = eventBus.published.find(
(e: any) => e.kind === 'status-update' && e.status?.state === 'failed',
);
expect(failedEvent).toBeDefined();
// createLocalTask は呼ばれない
expect(repo.createLocalTask).not.toHaveBeenCalled();
});
});
describe('execute — job failed', () => {
it('job が failed → failed status-update が publish される', async () => {
const repo = makeFakeRepo({
jobSequence: [
{ status: 'queued', currentActivity: null },
{ status: 'failed', currentActivity: null, errorSummary: 'something broke', abortReason: null },
],
});
const eventBus = makeFakeEventBus();
const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });
await executor.execute(ctx as any, eventBus as any);
expect(eventBus.finishedCalled).toBe(true);
const failedEvent = eventBus.published.find(
(e: any) => e.kind === 'status-update' && e.status?.state === 'failed' && e.final === true,
);
expect(failedEvent).toBeDefined();
expect((failedEvent as any).status.message.parts[0].text).toBe('something broke');
});
});
// Fix 2: execute() が内部エラーを throw → failed status-update + finished()(ストリームハングなし)
describe('execute — internal error (Fix 2)', () => {
it('createLocalTask が throw → failed status-update が publish され finished() が呼ばれる', async () => {
const baseRepo = makeFakeRepo();
const repo = {
...baseRepo,
createLocalTask: vi.fn().mockRejectedValue(new Error('DB connection failed')),
};
const eventBus = makeFakeEventBus();
const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });
await executor.execute(ctx as any, eventBus as any);
// SSE ストリームがハングしないこと
expect(eventBus.finishedCalled).toBe(true);
// generic failed event が publish されること(内部詳細を漏らさない)
const failedEvent = eventBus.published.find(
(e: any) => e.kind === 'status-update' && e.status?.state === 'failed' && e.final === true,
);
expect(failedEvent).toBeDefined();
// 内部エラーの詳細("DB connection failed")はクライアントに漏れない
const msg = (failedEvent as any).status?.message?.parts?.[0]?.text ?? '';
expect(msg).not.toContain('DB connection failed');
});
});
// Fix 3: バイナリ / サイズ超過ファイルの安全な Artifact 処理
describe('execute — artifact safety (Fix 3)', () => {
it('バイナリファイル → descriptive artifact、通常テキストはそのままインライン', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'a2a-exec-test-'));
try {
const outputDir = path.join(tmpDir, 'output');
fs.mkdirSync(outputDir);
// null byte を含むバイナリファイル
const binaryBuf = Buffer.alloc(100, 0x41); // 'A' × 100
binaryBuf[10] = 0x00; // null byte
fs.writeFileSync(path.join(outputDir, 'data.bin'), binaryBuf);
// 通常テキストファイル
fs.writeFileSync(path.join(outputDir, 'readme.txt'), 'Hello text');
const repo = makeFakeRepo({ workspacePath: tmpDir });
const eventBus = makeFakeEventBus();
const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });
await executor.execute(ctx as any, eventBus as any);
const artifactEvents = eventBus.published.filter(
(e: any) => e.kind === 'artifact-update',
) as any[];
expect(artifactEvents.length).toBe(2);
const binArt = artifactEvents.find(e => e.artifact.name === 'data.bin');
expect(binArt).toBeDefined();
// バイナリは descriptive メッセージになるnull byte を含まない)
expect(binArt.artifact.parts[0].text).toMatch(/binary file data\.bin/);
expect(binArt.artifact.parts[0].text).toMatch(/not inlined/);
expect(binArt.artifact.parts[0].text).not.toContain('\0');
const txtArt = artifactEvents.find(e => e.artifact.name === 'readme.txt');
expect(txtArt).toBeDefined();
// 通常テキストはそのままインライン
expect(txtArt.artifact.parts[0].text).toBe('Hello text');
} finally {
fs.rmSync(tmpDir, { recursive: true });
}
});
it('サイズ超過ファイル(> 1 MiB→ descriptive artifactインラインしない', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'a2a-exec-test-'));
try {
const outputDir = path.join(tmpDir, 'output');
fs.mkdirSync(outputDir);
// 1 MiB + 1 byte のファイル(上限超過)
const bigBuf = Buffer.alloc(1024 * 1024 + 1, 0x78); // 'x' × (1MiB+1)
fs.writeFileSync(path.join(outputDir, 'bigfile.txt'), bigBuf);
const repo = makeFakeRepo({ workspacePath: tmpDir });
const eventBus = makeFakeEventBus();
const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });
await executor.execute(ctx as any, eventBus as any);
const artifactEvents = eventBus.published.filter(
(e: any) => e.kind === 'artifact-update',
) as any[];
expect(artifactEvents.length).toBe(1);
const art = artifactEvents[0].artifact;
expect(art.name).toBe('bigfile.txt');
// サイズ超過は descriptive メッセージ
expect(art.parts[0].text).toMatch(/omitted/);
expect(art.parts[0].text).toMatch(/exceeds inline limit/);
// ファイル全体の内容はインラインされない('x' が大量に出てこない)
expect(art.parts[0].text.length).toBeLessThan(200);
} finally {
fs.rmSync(tmpDir, { recursive: true });
}
});
});
// Task 3: poll-cap タイムアウト時に cancelA2aLinkedJob が呼ばれ、failed が publish される
describe('execute — poll-cap cancel (Task 3)', () => {
it('poll cap 到達 → cancelA2aLinkedJob(jobId) が呼ばれ failed(final) が publish される', async () => {
// getJob が常に非 terminal を返す(ループが上限まで回る)
const repo = makeFakeRepo({
jobSequence: [
{ status: 'running', currentActivity: 'stuck' },
],
});
const eventBus = makeFakeEventBus();
const executor = new MaestroA2aExecutor(repo as any, {
pollMs: 0,
sleep: async () => {},
maxPollIterations: 2,
});
const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });
await executor.execute(ctx as any, eventBus as any);
expect(eventBus.finishedCalled).toBe(true);
// cancelA2aLinkedJob が jobId='job-1' で呼ばれていることqueued/retry 含む全非終端対象)
expect(repo.cancelA2aLinkedJob).toHaveBeenCalledWith('job-1');
// failed(final) が publish されていること
const failedEvent = eventBus.published.find(
(e: any) => e.kind === 'status-update' && e.status?.state === 'failed' && e.final === true,
);
expect(failedEvent).toBeDefined();
expect((failedEvent as any).status.message.parts[0].text).toMatch(/timeout/);
});
});
describe('cancelTask', () => {
it('execute 後に job が cancelled → A2A canceled イベントが publish されるFix 5', async () => {
const repo = makeFakeRepo({
// running → cancelled のシーケンス
jobSequence: [
{ status: 'running', currentActivity: 'step1' },
{ status: 'cancelled', currentActivity: null, errorSummary: null, abortReason: null },
],
});
const eventBus = makeFakeEventBus();
const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });
await executor.execute(ctx as any, eventBus as any);
expect(eventBus.finishedCalled).toBe(true);
// Fix 5: job が 'cancelled' になったら A2A 'canceled''failed' ではない)
const canceledEvent = eventBus.published.find(
(e: any) => e.kind === 'status-update' && e.status?.state === 'canceled' && e.final === true,
);
expect(canceledEvent).toBeDefined();
// 'failed' イベントは出ないこと
const failedEvent = eventBus.published.find(
(e: any) => e.kind === 'status-update' && e.status?.state === 'failed',
);
expect(failedEvent).toBeUndefined();
});
it('cancelTask 単独で呼ぶと canceled イベント + finished が来る', async () => {
const repo = makeFakeRepo();
const eventBus = makeFakeEventBus();
const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
await executor.cancelTask('task-999', eventBus as any);
expect(eventBus.finishedCalled).toBe(true);
const canceledEvent = eventBus.published.find(
(e: any) => e.kind === 'status-update' && e.status?.state === 'canceled',
);
expect(canceledEvent).toBeDefined();
expect((canceledEvent as any).final).toBe(true);
});
// Fix 4: map cleanup verification
it('execute 完了後にマップが掃除されるunbounded leak なし)', async () => {
const repo = makeFakeRepo();
const eventBus = makeFakeEventBus();
const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });
await executor.execute(ctx as any, eventBus as any);
// execute 後はマップから削除されている — cancelTask を呼んでもジョブ cancel は呼ばれない
const cancelBus = makeFakeEventBus();
await executor.cancelTask('task-1', cancelBus as any);
// jobId が消えているので cancel 系は呼ばれない
expect(repo.cancelA2aLinkedJob).not.toHaveBeenCalled();
expect(repo.requestJobCancel).not.toHaveBeenCalled();
});
// sonnet review Finding 1: cancelTask は in-flight ジョブを broad cancel すべき
it('cancelTask は in-flight ジョブを cancelA2aLinkedJob で取消すqueued/waiting も対象)', async () => {
const repo = makeFakeRepo();
const eventBus = makeFakeEventBus();
const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
// in-flight タスクを再現: マップに jobId を直接 seedexecute 進行中と同状態)
(executor as any).jobIdByA2aTaskId.set('task-x', 'job-x');
await executor.cancelTask('task-x', eventBus as any);
// narrow な requestJobCancel ではなく全非終端対象の cancelA2aLinkedJob を使う
expect(repo.cancelA2aLinkedJob).toHaveBeenCalledWith('job-x');
expect(repo.requestJobCancel).not.toHaveBeenCalled();
});
});
});

407
src/bridge/a2a/executor.ts Normal file
View File

@ -0,0 +1,407 @@
/**
* MaestroA2aExecutor inbound A2A message MAESTRO job A2A events + artifacts
*
* implements AgentExecutor from @a2a-js/sdk/server:
* execute(requestContext, eventBus): enqueue MAESTRO job, poll, publish SSE, emit artifacts
* cancelTask(taskId, eventBus): best-effort job cancel + publish canceled event
*/
import type { AgentExecutor, ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
import type { Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent } from '@a2a-js/sdk';
import type { Repository } from '../../db/repository.js';
import { localTaskRepoName } from '../../db/repository.js';
import { computeEffectiveScope, isDelegationLive, type A2aPrincipalLike } from './delegation.js';
import type { A2aPrincipal } from './token-auth.js';
import { selectPieceForMessage } from './skill-router.js';
import { logger } from '../../logger.js';
import { enumerateOutputArtifacts, finalizeStatusFromJob, TERMINAL_JOB_STATUSES } from './task-finalize.js';
export interface MaestroA2aExecutorDeps {
/** ポーリング間隔 (ms)。デフォルト 1000。テストでは 0 を注入。 */
pollMs?: number;
/** sleep 実装。テストでは no-op を注入してループを同期的に回す。 */
sleep?: (ms: number) => Promise<void>;
/** 現在時刻 ISO 文字列。テスト決定論化のため注入可能。 */
now?: () => string;
/** ポーリング上限イテレーション数。デフォルト 6001s×600 = 10 分)。 */
maxPollIterations?: number;
}
// TERMINAL_STATUSES は task-finalize.ts の TERMINAL_JOB_STATUSES を使用(ドリフト防止)
const TERMINAL_STATUSES = TERMINAL_JOB_STATUSES;
export class MaestroA2aExecutor implements AgentExecutor {
private readonly pollMs: number;
private readonly sleep: (ms: number) => Promise<void>;
private readonly now: () => string;
private readonly maxPollIterations: number;
/** A2A taskId → Maestro jobId のマッピングcancelTask に使用)。 */
private readonly jobIdByA2aTaskId = new Map<string, string>();
/** A2A taskId → contextId のマッピングcancelTask でイベントを組み立てるために保持)。 */
private readonly contextIdByA2aTaskId = new Map<string, string>();
constructor(
private readonly repo: Repository,
deps: MaestroA2aExecutorDeps = {},
) {
this.pollMs = deps.pollMs ?? 1000;
this.sleep = deps.sleep ?? ((ms) => new Promise(r => setTimeout(r, ms)));
this.now = deps.now ?? (() => new Date().toISOString());
this.maxPollIterations = deps.maxPollIterations ?? 600;
}
// ── helpers ──────────────────────────────────────────────────────────────
/**
* agent
* SDK TaskStatus.message Message2 ()
* Message Message2 any
*/
private makeStatusMessage(text: string, contextId: string, taskId: string): any {
return {
kind: 'message',
messageId: crypto.randomUUID(),
role: 'agent',
parts: [{ kind: 'text', text }],
contextId,
taskId,
};
}
private publishFailedFinal(
eventBus: ExecutionEventBus,
taskId: string,
contextId: string,
reason: string,
): void {
const event: TaskStatusUpdateEvent = {
kind: 'status-update',
taskId,
contextId,
status: {
state: 'failed',
message: this.makeStatusMessage(reason, contextId, taskId),
timestamp: this.now(),
} as TaskStatusUpdateEvent['status'],
final: true,
};
eventBus.publish(event);
}
/** Parallel to publishFailedFinal — publishes a terminal `canceled` event (A2A state, one 'l'). */
private publishCanceledFinal(
eventBus: ExecutionEventBus,
taskId: string,
contextId: string,
reason: string,
): void {
const event: TaskStatusUpdateEvent = {
kind: 'status-update',
taskId,
contextId,
status: {
state: 'canceled',
message: this.makeStatusMessage(reason, contextId, taskId),
timestamp: this.now(),
} as TaskStatusUpdateEvent['status'],
final: true,
};
eventBus.publish(event);
}
// ── AgentExecutor.execute ─────────────────────────────────────────────────
execute = async (requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void> => {
const { taskId, contextId } = requestContext;
// Fix 2: guard finished() so it is called exactly once regardless of path
let finishedCalled = false;
const safeFinished = () => {
if (!finishedCalled) {
finishedCalled = true;
eventBus.finished();
}
};
try {
// 1. principal を取得Task 4 が ServerCallContext.user に注入する)
const principal = (requestContext.context?.user as Record<string, unknown> | undefined)
?.a2aPrincipal as A2aPrincipal | undefined;
if (!principal) {
this.publishFailedFinal(eventBus, taskId, contextId, 'unauthorized: no A2A principal');
safeFinished();
return;
}
// 2. acting user の実際の可視スコープを算出IDOR 防止・fail-closed
const scope = await computeEffectiveScope(this.repo, principal);
if (!scope) {
this.publishFailedFinal(eventBus, taskId, contextId, 'unauthorized: unknown or inactive acting user');
safeFinished();
return;
}
// 3. メッセージ本文と要求スキルを抽出
const parts = (requestContext.userMessage.parts ?? []) as Array<{ kind: string; text?: string }>;
const messageText = parts
.filter(p => p.kind === 'text' && typeof p.text === 'string')
.map(p => p.text as string)
.join('\n');
const requestedSkillId = requestContext.userMessage.metadata?.skillId as string | undefined;
// 4. piece/space を選択fail-closed
const sel = selectPieceForMessage({
messageText,
effectiveSkills: scope.effectiveSkills,
effectiveSpaceIds: scope.effectiveSpaceIds,
requestedSkillId,
});
if (!sel) {
this.publishFailedFinal(eventBus, taskId, contextId, 'out of scope: no matching skill or space');
safeFinished();
return;
}
// 5. MAESTRO ローカルタスクを作成
const localTask = await this.repo.createLocalTask({
title: (messageText.slice(0, 80) || 'A2A task'),
body: messageText,
pieceName: sel.pieceName,
ownerId: principal.actingUserId,
spaceId: sel.spaceId,
visibility: 'private',
workspaceMode: 'persistent',
});
// 5b. ジョブをキューに積む。createLocalTask は local_tasks 行のみを作るため、
// 通常の作成 API (local-tasks-crud-api.ts) と同じく queued ジョブを明示的に
// enqueue しないと Worker が拾うジョブが存在せず、直後の getLatestJobForIssue が
// null を返して即 fail するA2A 経路のジョブブリッジ結線点)。
await this.repo.createJob({
repo: localTaskRepoName(localTask.id),
issueNumber: localTask.id,
instruction: messageText || `A2A task (${sel.pieceName})`,
pieceName: sel.pieceName,
ownerId: principal.actingUserId,
visibility: 'private',
spaceId: sel.spaceId,
});
// 6. 作成直後のジョブを取得
const job = await this.repo.getLatestJobForIssue(
localTaskRepoName(localTask.id),
localTask.id,
);
if (!job) {
this.publishFailedFinal(eventBus, taskId, contextId, 'internal error: job not created');
safeFinished();
return;
}
const jobId = job.id;
// T8: best-effort audit log — 外部経路のジョブ enqueue を記録forensics
try {
await this.repo.addAuditLog(jobId, 'a2a_job_queued', 'a2a', {
taskId: localTask.id,
actingUserId: principal.actingUserId,
clientId: principal.clientId,
pieceName: sel.pieceName,
spaceId: sel.spaceId,
});
} catch { /* best-effort audit */ }
// キャンセル用にマッピングを保持
this.jobIdByA2aTaskId.set(taskId, jobId);
this.contextIdByA2aTaskId.set(taskId, contextId);
// 7. 初期 Task イベントを publishsubmitted → ResultManager が TaskStore に保存)
const initialTask: Task = {
id: taskId,
contextId,
kind: 'task',
status: {
state: 'submitted',
timestamp: this.now(),
},
metadata: {
maestroJobId: jobId,
maestroLocalTaskId: localTask.id,
// 委任トレーサビリティ: Task 2 の task-store.save が a2a_tasks 列へ写す
// a2aDelegationId = 委任行の PK (row.id); a2aGrantId = OAuth grant id (別物)
a2aDelegationId: principal.delegation.id,
a2aGrantId: principal.grantId,
a2aActingUserId: principal.actingUserId,
},
};
eventBus.publish(initialTask);
// 8. ポーリングループ
let lastStatus: string | null = null;
let lastActivity: string | null = null;
for (let i = 0; i < this.maxPollIterations; i++) {
await this.sleep(this.pollMs);
const j = await this.repo.getJob(jobId);
if (!j) break;
// 委任の revocation チェックfail-closed: 行が見つからない場合も revoked 扱い)
const deleg = this.repo.getA2aDelegationByGrantId(principal.grantId);
if (!isDelegationLive(deleg ?? { expiresAt: null, revokedAt: 'revoked' }, this.now())) {
logger.info(`[a2a-exec] delegation revoked mid-flight jobId=${jobId} grant=${principal.grantId}`);
this.repo.cancelA2aLinkedJob(jobId);
this.publishCanceledFinal(eventBus, taskId, contextId, 'delegation revoked');
safeFinished();
return;
}
const currentActivity = j.currentActivity ?? null;
const statusChanged = j.status !== lastStatus;
const activityChanged = currentActivity !== lastActivity;
// status/activity 変化があれば working イベントを publishterminal 手前まで)
if ((statusChanged || activityChanged) && !TERMINAL_STATUSES.has(j.status)) {
const workingEvent: TaskStatusUpdateEvent = {
kind: 'status-update',
taskId,
contextId,
status: {
state: 'working',
message: this.makeStatusMessage(currentActivity ?? j.status, contextId, taskId),
timestamp: this.now(),
} as TaskStatusUpdateEvent['status'],
final: false,
};
eventBus.publish(workingEvent);
}
lastStatus = j.status;
lastActivity = currentActivity;
if (TERMINAL_STATUSES.has(j.status)) {
await this.handleTerminal(j, localTask, taskId, contextId, eventBus);
safeFinished();
return;
}
}
// 上限超過 → 実行中 Maestro ジョブを orphan にしないよう best-effort でキャンセル
logger.warn(`[a2a-executor] poll cap exceeded taskId=${taskId} jobId=${jobId}`);
try { this.repo.cancelA2aLinkedJob(jobId); } catch { /* best-effort */ }
this.publishFailedFinal(eventBus, taskId, contextId, 'timeout: job exceeded max poll duration');
safeFinished();
} catch (err) {
// Fix 2: catch unhandled DB / internal errors so the SSE stream never hangs
logger.warn(`[a2a] executor error: ${err}`);
if (!finishedCalled) {
this.publishFailedFinal(eventBus, taskId, contextId, 'internal error');
}
} finally {
// T6: per-task map cleanup — 全終了経路terminal/timeout/throwで確実に実行
this.jobIdByA2aTaskId.delete(taskId);
this.contextIdByA2aTaskId.delete(taskId);
// Fix 2: guarantee finished() is called exactly once on every exit path
safeFinished();
}
};
// ── 終端処理 ──────────────────────────────────────────────────────────────
private async handleTerminal(
job: {
status: string;
errorSummary: string | null;
abortReason: string | null;
/** Fix 1: terminal job's worktree path — used as fallback when getLocalTask still returns null. */
worktreePath?: string | null;
},
localTask: { id: number; workspacePath: string | null },
taskId: string,
contextId: string,
eventBus: ExecutionEventBus,
): Promise<void> {
// Fix 1 (succeeded only): re-fetch to get Worker-backfilled workspacePath and enumerate artifacts.
if (job.status === 'succeeded') {
const fresh = await this.repo.getLocalTask(localTask.id);
const workspacePath = fresh?.workspacePath ?? job.worktreePath ?? null;
for (const art of enumerateOutputArtifacts(workspacePath)) {
const artifactEvent: TaskArtifactUpdateEvent = {
kind: 'artifact-update',
taskId,
contextId,
artifact: {
artifactId: art.artifactId,
name: art.name,
parts: [{ kind: 'text', text: art.text }],
},
};
eventBus.publish(artifactEvent);
}
}
// Determine terminal A2A state via shared helper (Fix 3/5: handles all statuses).
const { state, message } = finalizeStatusFromJob(job);
if (state === 'failed') {
this.publishFailedFinal(eventBus, taskId, contextId, message ?? 'job failed');
} else if (message) {
// input-required: wrap the text in a Message object
const inputReqEvent: TaskStatusUpdateEvent = {
kind: 'status-update',
taskId,
contextId,
status: {
state,
message: this.makeStatusMessage(message, contextId, taskId),
timestamp: this.now(),
} as TaskStatusUpdateEvent['status'],
final: true,
};
eventBus.publish(inputReqEvent);
} else {
// completed or canceled — no message payload
const finalEvent: TaskStatusUpdateEvent = {
kind: 'status-update',
taskId,
contextId,
status: { state, timestamp: this.now() },
final: true,
};
eventBus.publish(finalEvent);
}
}
// ── AgentExecutor.cancelTask ──────────────────────────────────────────────
cancelTask = async (taskId: string, eventBus: ExecutionEventBus): Promise<void> => {
const jobId = this.jobIdByA2aTaskId.get(taskId);
// Fix 4: read contextId before deleting from the map
const contextId = this.contextIdByA2aTaskId.get(taskId) ?? taskId;
if (jobId) {
try {
// 全非終端ステータス (queued/dispatching/running/waiting_human/waiting_subtasks/retry)
// を対象にする。requestJobCancel は running/dispatching のみで queued/waiting を取りこぼす。
this.repo.cancelA2aLinkedJob(jobId);
} catch (err) {
logger.warn(`[a2a-executor] cancelJob best-effort failed: ${err}`);
}
}
// Fix 4: clean up maps after cancel to avoid unbounded growth
this.jobIdByA2aTaskId.delete(taskId);
this.contextIdByA2aTaskId.delete(taskId);
const event: TaskStatusUpdateEvent = {
kind: 'status-update',
taskId,
contextId,
status: { state: 'canceled', timestamp: this.now() },
final: true,
};
eventBus.publish(event);
eventBus.finished();
};
}

View File

@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Repository } from '../../db/repository.js';
import { makeOidcAdapterFactory } from './oidc-adapter.js';
describe('oidc SQLite adapter', () => {
let repo: Repository;
let factory: (name: string) => any;
beforeEach(() => { repo = new Repository(':memory:'); factory = makeOidcAdapterFactory(repo); });
it('upsert/find round-trips a payload and respects expiry semantics', async () => {
const a = factory('AccessToken');
await a.upsert('t1', { jti: 't1', grantId: 'g1' }, 3600);
expect(await a.find('t1')).toMatchObject({ jti: 't1' });
});
it('find returns undefined after expiry', async () => {
const a = factory('AccessToken');
// expiresIn 0 は「無期限」ではなく即時。adapter は expiresIn 秒後を expires_at に入れる。
await a.upsert('t2', { jti: 't2' }, -1); // 過去
expect(await a.find('t2')).toBeUndefined();
});
it('consume marks payload.consumed', async () => {
const a = factory('AuthorizationCode');
await a.upsert('c1', { foo: 1 }, 600);
await a.consume('c1');
expect((await a.find('c1')).consumed).toBeTypeOf('number');
});
it('revokeByGrantId removes grant artifacts', async () => {
const at = factory('AccessToken');
await at.upsert('t3', { jti: 't3', grantId: 'g9' }, 600);
await at.revokeByGrantId('g9');
expect(await at.find('t3')).toBeUndefined();
});
it('Client model resolves from a2a_clients into oidc client metadata', async () => {
repo.createA2aClient({
clientId: 'cli1', name: 'T', redirectUris: ['https://x/cb'],
secretHash: 'h', tokenEndpointAuthMethod: 'client_secret_basic',
agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'a',
});
const c = factory('Client');
const meta = await c.find('cli1');
expect(meta).toMatchObject({
client_id: 'cli1',
redirect_uris: ['https://x/cb'],
grant_types: ['authorization_code'],
response_types: ['code'],
});
});
it('Client model returns undefined for disabled client', async () => {
repo.createA2aClient({
clientId: 'cli2', name: 'T', redirectUris: ['https://x/cb'],
secretHash: 'h', tokenEndpointAuthMethod: 'client_secret_basic',
agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'disabled', createdBy: 'a',
});
expect(await factory('Client').find('cli2')).toBeUndefined();
});
it('findByUid returns payload for fresh Session', async () => {
const s = factory('Session');
await s.upsert('sess1', { uid: 'uid-fresh', data: { foo: 'bar' } }, 3600);
const result = await s.findByUid('uid-fresh');
expect(result).toMatchObject({ uid: 'uid-fresh', data: { foo: 'bar' } });
});
it('findByUid returns undefined for expired Session', async () => {
const s = factory('Session');
await s.upsert('sess2', { uid: 'uid-expired', data: { foo: 'baz' } }, -1);
const result = await s.findByUid('uid-expired');
expect(result).toBeUndefined();
});
it('findByUserCode returns payload for fresh entry', async () => {
const ac = factory('AuthorizationCode');
await ac.upsert('code1', { userCode: 'uc-fresh', data: { sub: 'user123' } }, 600);
const result = await ac.findByUserCode('uc-fresh');
expect(result).toMatchObject({ userCode: 'uc-fresh', data: { sub: 'user123' } });
});
it('findByUserCode returns undefined for expired entry', async () => {
const ac = factory('AuthorizationCode');
await ac.upsert('code2', { userCode: 'uc-expired', data: { sub: 'user456' } }, -1);
const result = await ac.findByUserCode('uc-expired');
expect(result).toBeUndefined();
});
});

View File

@ -0,0 +1,116 @@
import type { Repository } from '../../db/repository.js';
/** oidc-provider が要求する Adapter の構造的型(@types/oidc-provider に依存しないため最小定義)。 */
export interface OidcAdapter {
upsert(id: string, payload: Record<string, unknown>, expiresIn: number): Promise<void>;
find(id: string): Promise<Record<string, unknown> | undefined>;
findByUserCode(userCode: string): Promise<Record<string, unknown> | undefined>;
findByUid(uid: string): Promise<Record<string, unknown> | undefined>;
consume(id: string): Promise<void>;
destroy(id: string): Promise<void>;
revokeByGrantId(grantId: string): Promise<void>;
}
const nowSec = (): number => Math.floor(Date.now() / 1000);
/**
* `oidc-provider` adapter factorymodel 1
* `Client` a2a_clients oidc client admin
* AccessToken/AuthorizationCode/Grant/Session/Interaction/... oidc_models
*/
export function makeOidcAdapterFactory(repo: Repository): (name: string) => OidcAdapter {
return (name: string): OidcAdapter => {
if (name === 'Client') return new ClientAdapter(repo);
return new ModelAdapter(repo, name);
};
}
class ModelAdapter implements OidcAdapter {
constructor(private repo: Repository, private model: string) {}
async upsert(id: string, payload: Record<string, unknown>, expiresIn: number): Promise<void> {
const expiresAt = expiresIn ? nowSec() + expiresIn : null;
const payloadWithExp: Record<string, unknown> = { ...payload };
if (expiresAt !== null) {
payloadWithExp.exp = expiresAt;
}
this.repo.oidcUpsert(this.model, id, payloadWithExp, {
expiresAt,
grantId: (payload.grantId as string) ?? null,
userCode: (payload.userCode as string) ?? null,
uid: (payload.uid as string) ?? null,
});
}
async find(id: string): Promise<Record<string, unknown> | undefined> {
const row = this.repo.oidcFind(this.model, id);
if (!row) return undefined;
if (row.consumedAt) row.payload.consumed = row.consumedAt;
return this.notExpired(id, row.payload);
}
async findByUserCode(userCode: string): Promise<Record<string, unknown> | undefined> {
const payload = this.repo.oidcFindByUserCode(userCode)?.payload;
if (!payload) return undefined;
const exp = payload.exp as number | undefined;
if (typeof exp === 'number' && exp <= nowSec()) return undefined;
return payload;
}
async findByUid(uid: string): Promise<Record<string, unknown> | undefined> {
const payload = this.repo.oidcFindByUid(uid)?.payload;
if (!payload) return undefined;
const exp = payload.exp as number | undefined;
if (typeof exp === 'number' && exp <= nowSec()) return undefined;
return payload;
}
async consume(id: string): Promise<void> {
this.repo.oidcConsume(this.model, id, nowSec());
}
async destroy(id: string): Promise<void> {
this.repo.oidcDestroy(this.model, id);
}
async revokeByGrantId(grantId: string): Promise<void> {
this.repo.oidcRevokeByGrantId(grantId);
}
/** payload.expoidc が書く)で失効判定。期限切れは undefined を返し、行も掃除する。 */
private notExpired(id: string, payload: Record<string, unknown>): Record<string, unknown> | undefined {
const exp = payload.exp as number | undefined;
if (typeof exp === 'number' && exp <= nowSec()) {
this.repo.oidcDestroy(this.model, id);
return undefined;
}
return payload;
}
}
/** a2a_clients → oidc client メタデータ。Adapter インターフェースの find 以外は no-op。 */
class ClientAdapter implements OidcAdapter {
constructor(private repo: Repository) {}
async find(id: string): Promise<Record<string, unknown> | undefined> {
const c = this.repo.getA2aClient(id);
if (!c || c.status !== 'active') return undefined;
return {
client_id: c.clientId,
client_name: c.name,
redirect_uris: c.redirectUris,
token_endpoint_auth_method: c.tokenEndpointAuthMethod,
grant_types: ['authorization_code'],
response_types: ['code'],
// Plan 1 registers PUBLIC clients only; no client_secret is provided.
// Plan 2 confidential-client support will be added separately when needed.
};
}
async upsert(): Promise<void> { /* admin API 経由でのみ登録。oidc 動的登録は使わない */ }
async findByUserCode(): Promise<undefined> { return undefined; }
async findByUid(): Promise<undefined> { return undefined; }
async consume(): Promise<void> {}
async destroy(): Promise<void> {}
async revokeByGrantId(): Promise<void> {}
}

View File

@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { Repository } from '../../db/repository.js';
import { buildA2aOidcConfig } from './oidc-config.js';
function deps() {
const dir = mkdtempSync(join(tmpdir(), 'a2a-cfg-'));
return { repo: new Repository(':memory:'), secretsDir: dir, resourceAudience: 'https://maestro.test/a2a', cookieKeys: ['k1'] };
}
describe('buildA2aOidcConfig', () => {
it('declares the a2a.read scope and enables introspection/revocation', () => {
const cfg = buildA2aOidcConfig(deps());
expect(cfg.scopes).toContain('a2a.read');
expect(cfg.features?.introspection?.enabled).toBe(true);
expect(cfg.features?.revocation?.enabled).toBe(true);
expect(cfg.features?.resourceIndicators?.enabled).toBe(true);
});
it('requires PKCE for all clients', () => {
const cfg = buildA2aOidcConfig(deps());
expect(cfg.pkce?.required?.({} as any, {} as any)).toBe(true);
});
it('getResourceServerInfo returns the configured audience with a2a.read scope', () => {
const d = deps();
const cfg = buildA2aOidcConfig(d);
const info = cfg.features!.resourceIndicators!.getResourceServerInfo!(
{} as any, 'https://maestro.test/a2a', {} as any,
);
expect(info).toMatchObject({ audience: 'https://maestro.test/a2a', scope: expect.stringContaining('a2a.read') });
});
it('findAccount echoes the accountId set during the login interaction', async () => {
const cfg = buildA2aOidcConfig(deps());
const acct = await cfg.findAccount!({} as any, 'user-123');
expect(acct?.accountId).toBe('user-123');
expect(await acct?.claims()).toMatchObject({ sub: 'user-123' });
});
});

View File

@ -0,0 +1,82 @@
import type { Repository } from '../../db/repository.js';
import { makeOidcAdapterFactory } from './oidc-adapter.js';
import { loadOrCreateJwks } from './oidc-keys.js';
/** oidc-provider の Configuration の構造的最小型(@types に依存しない)。 */
export interface A2aOidcConfig {
adapter: (name: string) => unknown;
jwks: { keys: object[] };
scopes: string[];
clientDefaults?: Record<string, unknown>;
cookies?: { keys: string[] };
pkce?: { required?: (ctx: unknown, client: unknown) => boolean };
features?: {
introspection?: { enabled: boolean };
revocation?: { enabled: boolean };
resourceIndicators?: {
enabled: boolean;
defaultResource?: (ctx: unknown) => string;
getResourceServerInfo?: (ctx: unknown, resourceIndicator: string, client: unknown) => Record<string, unknown>;
useGrantedResource?: () => boolean;
};
devInteractions?: { enabled: boolean };
};
interactions?: { url: (ctx: unknown, interaction: { uid: string }) => string };
findAccount?: (ctx: unknown, sub: string) => Promise<{ accountId: string; claims: () => Record<string, unknown> } | undefined>;
ttl?: Record<string, number>;
}
export interface BuildA2aOidcConfigDeps {
repo: Repository;
secretsDir: string;
resourceAudience: string; // A2A endpoint 絶対 URL。audience として固定
cookieKeys: string[]; // 既存 session secret 由来
}
export const A2A_READ_SCOPE = 'a2a.read';
export function buildA2aOidcConfig(deps: BuildA2aOidcConfigDeps): A2aOidcConfig {
const { repo, secretsDir, resourceAudience, cookieKeys } = deps;
return {
adapter: makeOidcAdapterFactory(repo),
jwks: loadOrCreateJwks(secretsDir),
scopes: ['openid', A2A_READ_SCOPE],
clientDefaults: {
grant_types: ['authorization_code'],
response_types: ['code'],
token_endpoint_auth_method: 'none', // public client + PKCETask 3 メモ参照)
},
cookies: { keys: cookieKeys },
pkce: { required: () => true },
features: {
introspection: { enabled: true },
revocation: { enabled: true },
devInteractions: { enabled: false }, // 既定の dev 画面を無効化し自前 consent を使う
resourceIndicators: {
enabled: true,
defaultResource: () => resourceAudience,
useGrantedResource: () => true,
getResourceServerInfo: (_ctx, _resourceIndicator, _client) => ({
audience: resourceAudience,
scope: A2A_READ_SCOPE,
accessTokenFormat: 'opaque', // introspection で検証する。JWT にしない
accessTokenTTL: 10 * 60, // 10 分
}),
},
},
interactions: {
url: (_ctx, interaction) => `/oidc/interaction/${interaction.uid}`,
},
findAccount: async (_ctx, sub) => ({
accountId: sub,
claims: () => ({ sub }),
}),
ttl: {
AccessToken: 10 * 60,
AuthorizationCode: 5 * 60,
Grant: 14 * 24 * 60 * 60,
Interaction: 60 * 60,
Session: 14 * 24 * 60 * 60,
},
};
}

View File

@ -0,0 +1,199 @@
// @vitest-environment node
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import express from 'express';
import http from 'http';
import { createHash, randomBytes } from 'crypto';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { Repository } from '../../db/repository.js';
import { createA2aOidcProvider, mountA2aOidc } from './oidc-provider.js';
import { b64url, Client } from './__e2e-helpers.js';
describe('A2A oidc e2e (authorization_code + PKCE)', () => {
let server: http.Server;
let base: string;
let audience: string;
let repo: Repository;
const clientId = 'a2a_test';
const redirectUri = 'https://client.test/cb';
beforeAll(async () => {
repo = new Repository(':memory:');
repo.createA2aClient({
clientId, name: 'E2E', redirectUris: [redirectUri],
secretHash: null, tokenEndpointAuthMethod: 'none',
agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'admin',
});
// disabled なクライアント(否定経路用)。最初から disabled なので Client キャッシュにも乗らない。
repo.createA2aClient({
clientId: 'a2a_disabled', name: 'Disabled', redirectUris: [redirectUri],
secretHash: null, tokenEndpointAuthMethod: 'none',
agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'admin',
});
repo.setA2aClientStatus('a2a_disabled', 'disabled');
const secretsDir = mkdtempSync(join(tmpdir(), 'a2a-e2e-'));
const app = express();
// 擬似ログイン: 全リクエストに user を注入consent の req.user 用。mount より前に置く。
app.use((req, _res, next) => { (req as any).user = { id: 'user-1', role: 'user' }; next(); });
// listen → port 確定 → issuer/audience 確定 → provider 生成 → mount の順(テストのみ並び替え)
server = http.createServer(app);
await new Promise<void>(r => server.listen(0, '127.0.0.1', r));
const port = (server.address() as any).port;
base = `http://127.0.0.1:${port}`;
audience = `${base}/a2a`;
const provider = createA2aOidcProvider({
repo, secretsDir,
issuer: `${base}/oidc`,
resourceAudience: audience,
cookieKeys: ['test-cookie-key'],
});
// テスト専用: http/127.0.0.1 で cookie を non-secure にして cookie jar が機能するようにする。
// 本番は逆プロキシ + https で proxy=true のまま。
(provider as any).proxy = false;
mountA2aOidc(app, provider, { repo, resourceAudience: audience });
});
afterAll(() => { server?.close(); });
/** authorize → consent → code 取得まで通し、認可コードを返す。 */
async function obtainCode(client: Client, verifier: string): Promise<string> {
const challenge = b64url(createHash('sha256').update(verifier).digest());
const authUrl = `${base}/oidc/auth?` + new URLSearchParams({
client_id: clientId, response_type: 'code', redirect_uri: redirectUri,
scope: 'openid a2a.read', resource: audience,
code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz',
});
const authRes = await client.get(authUrl);
expect([302, 303]).toContain(authRes.status);
let loc = authRes.headers.get('location')!;
// oidc-provider は login と consent を別々の interaction として要求しうるので、
// interaction → confirm → resume を redirect_uri に着くまでループするcookie jar 経由)。
for (let i = 0; i < 5; i++) {
if (loc.startsWith(redirectUri)) break;
expect(loc).toContain('/oidc/interaction/');
const uid = new URL(loc, base).pathname.split('/').pop()!;
const confirmRes = await client.postForm(`${base}/oidc/interaction/${uid}/confirm`, {});
expect(confirmRes.status).toBe(200);
const { redirectTo } = await confirmRes.json() as { redirectTo: string };
expect(redirectTo).toContain('/oidc/auth/');
const resumeRes = await client.get(redirectTo);
expect([302, 303]).toContain(resumeRes.status);
loc = resumeRes.headers.get('location')!;
}
expect(loc.startsWith(redirectUri)).toBe(true);
const code = new URL(loc).searchParams.get('code');
expect(code).toBeTruthy();
return code!;
}
it('issues a scoped token, introspection reflects active + aud, then revocation flips to inactive', async () => {
const client = new Client();
const verifier = b64url(randomBytes(32));
const code = await obtainCode(client, verifier);
// token 交換public client → client_id をボディに)
const tokenRes = await client.postForm(`${base}/oidc/token`, {
grant_type: 'authorization_code',
code, code_verifier: verifier, client_id: clientId, redirect_uri: redirectUri,
});
expect(tokenRes.status).toBe(200);
const tokenJson = await tokenRes.json() as { access_token: string; scope: string; token_type: string };
expect(tokenJson.access_token).toBeTruthy();
expect(tokenJson.scope.split(' ')).toContain('a2a.read');
// introspection: active + aud
const introRes = await client.postForm(`${base}/oidc/token/introspection`, {
token: tokenJson.access_token, client_id: clientId,
});
expect(introRes.status).toBe(200);
const introBefore = await introRes.json() as { active: boolean; aud?: string | string[]; scope?: string };
expect(introBefore.active).toBe(true);
const audValue = Array.isArray(introBefore.aud) ? introBefore.aud : [introBefore.aud];
expect(audValue).toContain(audience);
expect(String(introBefore.scope ?? '').split(' ')).toContain('a2a.read');
// revocation
const revRes = await client.postForm(`${base}/oidc/token/revocation`, {
token: tokenJson.access_token, client_id: clientId,
});
expect(revRes.status).toBe(200);
// revoke 後の introspection は inactive
const introAfterRes = await client.postForm(`${base}/oidc/token/introspection`, {
token: tokenJson.access_token, client_id: clientId,
});
expect(introAfterRes.status).toBe(200);
const introAfter = await introAfterRes.json() as { active: boolean };
expect(introAfter.active).toBe(false);
});
it('rejects an unregistered client_id at the authorize endpoint', async () => {
const client = new Client();
const verifier = b64url(randomBytes(32));
const challenge = b64url(createHash('sha256').update(verifier).digest());
const authUrl = `${base}/oidc/auth?` + new URLSearchParams({
client_id: 'no_such_client', response_type: 'code', redirect_uri: redirectUri,
scope: 'openid a2a.read', resource: audience,
code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz',
});
const res = await client.get(authUrl);
// 未登録 client は redirect_uri を信頼できないため、リダイレクトせず 400 エラーを返す
expect(res.status).toBe(400);
const body = await res.text();
expect(body).toMatch(/invalid_client|unrecognized|client/i);
});
it('rejects authorize when code_challenge is missing (PKCE required)', async () => {
const client = new Client();
const authUrl = `${base}/oidc/auth?` + new URLSearchParams({
client_id: clientId, response_type: 'code', redirect_uri: redirectUri,
scope: 'openid a2a.read', resource: audience, state: 'xyz',
// code_challenge を意図的に省略
});
const res = await client.get(authUrl);
// 既知 client + 既知 redirect_uri なので、エラーは redirect_uri に返される303 + error=invalid_request
expect([302, 303]).toContain(res.status);
const loc = res.headers.get('location')!;
const err = new URL(loc).searchParams.get('error');
expect(err).toBe('invalid_request');
expect(new URL(loc).searchParams.get('error_description') ?? '').toMatch(/code_challenge|pkce/i);
});
it('cannot start an authorize flow with a disabled client', async () => {
const client = new Client();
const verifier = b64url(randomBytes(32));
const challenge = b64url(createHash('sha256').update(verifier).digest());
const authUrl = `${base}/oidc/auth?` + new URLSearchParams({
client_id: 'a2a_disabled', response_type: 'code', redirect_uri: redirectUri,
scope: 'openid a2a.read', resource: audience,
code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz',
});
const res = await client.get(authUrl);
// disabled client は adapter.find が undefined を返す → 未知 client 扱い → 400
expect(res.status).toBe(400);
const body = await res.text();
expect(body).toMatch(/invalid_client|unrecognized|client/i);
});
it('rejects token exchange with a tampered code_verifier (invalid_grant)', async () => {
const client = new Client();
const verifier = b64url(randomBytes(32));
const code = await obtainCode(client, verifier);
const tampered = b64url(randomBytes(32)); // 正しい verifier と一致しない
const tokenRes = await client.postForm(`${base}/oidc/token`, {
grant_type: 'authorization_code',
code, code_verifier: tampered, client_id: clientId, redirect_uri: redirectUri,
});
expect(tokenRes.status).toBe(400);
const body = await tokenRes.json() as { error: string };
expect(body.error).toBe('invalid_grant');
});
});

View File

@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { mkdtempSync, existsSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { loadOrCreateJwks } from './oidc-keys.js';
describe('loadOrCreateJwks', () => {
it('creates a persistent RSA JWKS and reloads the same key', () => {
const dir = mkdtempSync(join(tmpdir(), 'a2a-jwks-'));
const first = loadOrCreateJwks(dir);
expect(first.keys).toHaveLength(1);
expect(first.keys[0]).toMatchObject({ kty: 'RSA' });
expect(first.keys[0]).toHaveProperty('d');
expect(existsSync(join(dir, 'a2a-jwks.json'))).toBe(true);
const second = loadOrCreateJwks(dir);
expect((second.keys[0] as any).kid).toBe((first.keys[0] as any).kid);
rmSync(dir, { recursive: true, force: true });
});
});

View File

@ -0,0 +1,25 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
import { join } from 'path';
import { generateKeyPairSync, randomUUID } from 'crypto';
import { logger } from '../../logger.js';
export interface Jwks { keys: object[]; }
/** data/secrets/a2a-jwks.json に署名鍵(RSA)を永続。session-secret.key と同じ運用。 */
export function loadOrCreateJwks(secretsDir: string): Jwks {
const file = join(secretsDir, 'a2a-jwks.json');
if (existsSync(file)) {
return JSON.parse(readFileSync(file, 'utf-8')) as Jwks;
}
mkdirSync(secretsDir, { recursive: true });
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = privateKey.export({ format: 'jwk' }) as Record<string, unknown>;
jwk.kid = randomUUID();
jwk.use = 'sig';
jwk.alg = 'RS256';
const jwks: Jwks = { keys: [jwk] };
writeFileSync(file, JSON.stringify(jwks), { mode: 0o600 });
try { chmodSync(file, 0o600); } catch { /* best-effort */ }
logger.info(`[a2a-oidc] generated jwks file=${file} kid=${jwk.kid}`);
return jwks;
}

33
src/bridge/a2a/oidc-provider-shim.d.ts vendored Normal file
View File

@ -0,0 +1,33 @@
declare module 'oidc-provider' {
import type { IncomingMessage, ServerResponse } from 'http';
export interface InteractionDetails {
uid: string;
prompt: { name: string; [k: string]: unknown };
params: Record<string, unknown>;
session?: { accountId?: string };
grantId?: string;
}
export class Grant {
constructor(props: { accountId: string; clientId: string });
addOIDCScope(scope: string): void;
addResourceScope(resource: string, scope: string): void;
save(): Promise<string>;
}
export default class Provider {
constructor(issuer: string, configuration?: unknown);
proxy: boolean;
Grant: typeof Grant;
AccessToken: {
find(value: string, opts?: { ignoreExpiration?: boolean }): Promise<{
accountId?: string;
grantId?: string;
scope?: string;
aud?: string | string[];
} | undefined>;
};
callback(): (req: IncomingMessage, res: ServerResponse) => void;
interactionDetails(req: unknown, res: unknown): Promise<InteractionDetails>;
interactionResult(req: unknown, res: unknown, result: unknown, opts?: { mergeWithLastSubmission?: boolean }): Promise<string>;
interactionFinished(req: unknown, res: unknown, result: unknown, opts?: { mergeWithLastSubmission?: boolean }): Promise<void>;
}
}

View File

@ -0,0 +1,36 @@
import Provider from 'oidc-provider';
import type { Express } from 'express';
import type { Repository } from '../../db/repository.js';
import { buildA2aOidcConfig } from './oidc-config.js';
import { createConsentRouter } from './consent-api.js';
import { logger } from '../../logger.js';
export interface CreateA2aOidcDeps {
repo: Repository;
secretsDir: string;
issuer: string; // 例 https://maestro.example/oidc
resourceAudience: string; // 例 https://maestro.example/a2a
cookieKeys: string[];
}
export function createA2aOidcProvider(deps: CreateA2aOidcDeps): Provider {
const config = buildA2aOidcConfig({
repo: deps.repo,
secretsDir: deps.secretsDir,
resourceAudience: deps.resourceAudience,
cookieKeys: deps.cookieKeys,
});
// oidc-provider の型は緩いので as any で渡す(構造的に互換)。
const provider = new Provider(deps.issuer, config as any);
// プロキシ配下本番は逆プロキシ。X-Forwarded-* を信頼。
provider.proxy = true;
return provider;
}
/** /oidc に provider、/oidc/interaction に consent を載せる。 */
export function mountA2aOidc(app: Express, provider: Provider, deps: { repo: Repository; resourceAudience: string }): void {
// consent は provider.callback より前に置くinteraction パスを provider に食わせない)
app.use('/oidc/interaction', createConsentRouter(provider, deps.repo, deps.resourceAudience));
app.use('/oidc', provider.callback());
logger.info('[a2a-oidc] mounted oidc provider at /oidc');
}

View File

@ -0,0 +1,100 @@
/**
* reconciler-wiring.test.ts
*
* A2aTaskReconciler start/stop fake timers
* server.ts Task 6 e2e reconciler
*
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { A2aTaskReconciler } from './reconciler.js';
afterEach(() => {
vi.useRealTimers();
});
describe('A2aTaskReconciler lifecycle', () => {
it('start() triggers an immediate reconcile tick', async () => {
vi.useFakeTimers();
let callCount = 0;
const fakeRepo = {
listNonTerminalA2aTasks: () => { callCount++; return []; },
} as any;
const reconciler = new A2aTaskReconciler({ repo: fakeRepo, intervalMs: 1000 });
reconciler.start();
// Drain the microtask queue so the immediate async tick completes.
await Promise.resolve();
expect(callCount).toBeGreaterThanOrEqual(1);
});
it('periodic interval fires after intervalMs', async () => {
vi.useFakeTimers();
let callCount = 0;
const fakeRepo = {
listNonTerminalA2aTasks: () => { callCount++; return []; },
} as any;
const reconciler = new A2aTaskReconciler({ repo: fakeRepo, intervalMs: 1000 });
reconciler.start();
// Drain immediate tick.
await Promise.resolve();
const afterStart = callCount;
// Advance one full interval.
await vi.advanceTimersByTimeAsync(1000);
expect(callCount).toBeGreaterThan(afterStart);
});
it('stop() prevents further ticks after interval advances', async () => {
vi.useFakeTimers();
let callCount = 0;
const fakeRepo = {
listNonTerminalA2aTasks: () => { callCount++; return []; },
} as any;
const reconciler = new A2aTaskReconciler({ repo: fakeRepo, intervalMs: 1000 });
reconciler.start();
// Drain immediate tick.
await Promise.resolve();
reconciler.stop();
const countAfterStop = callCount;
// Advance several intervals — the cleared timer must not fire.
await vi.advanceTimersByTimeAsync(5000);
expect(callCount).toBe(countAfterStop);
});
it('double start() does not leak a second interval', async () => {
vi.useFakeTimers();
let callCount = 0;
const fakeRepo = {
listNonTerminalA2aTasks: () => { callCount++; return []; },
} as any;
const reconciler = new A2aTaskReconciler({ repo: fakeRepo, intervalMs: 1000 });
reconciler.start();
reconciler.start(); // should replace, not add
await Promise.resolve();
const afterDoubleStart = callCount;
await vi.advanceTimersByTimeAsync(1000);
// Only one interval should fire; if two leaked, callCount would jump by 2.
expect(callCount).toBe(afterDoubleStart + 1);
reconciler.stop();
});
});

View File

@ -0,0 +1,438 @@
// @vitest-environment node
/**
* A2aTaskReconciler
* TDD: fake repo + tmp workspace real DBreal worker
*/
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { A2aTaskReconciler } from './reconciler.js';
import type { Repository } from '../../db/repository.js';
// ─── fake repo ───────────────────────────────────────────────────────────────
type A2aTaskRow = { id: string; jobId: string | null; payload: Record<string, unknown> };
type SavedA2aTask = {
id: string;
contextId: string | null;
jobId: string | null;
localTaskId: number | null;
payload: object;
delegationId?: string;
grantId?: string;
actingUserId?: string;
};
function makeJobStub(status: string, extra: Record<string, unknown> = {}) {
return {
id: 'job-1',
status,
errorSummary: null,
abortReason: null,
worktreePath: null,
...extra,
};
}
function buildFakeRepo(opts: {
nonTerminalRows: A2aTaskRow[];
jobs: Map<string, ReturnType<typeof makeJobStub> | null>;
localTasks?: Map<number, { workspacePath: string | null }>;
}) {
const store = new Map<string, SavedA2aTask>();
// Pre-fill store from initial nonTerminalRows (so loadA2aTask works)
for (const row of opts.nonTerminalRows) {
store.set(row.id, {
id: row.id,
contextId: (row.payload.contextId as string | null) ?? null,
jobId: row.jobId,
localTaskId: null,
payload: row.payload,
});
}
const repo = {
listNonTerminalA2aTasks: vi.fn((): A2aTaskRow[] => {
// Return only tasks whose saved payload status is non-terminal
const terminal = new Set(['completed', 'failed', 'canceled', 'rejected']);
return opts.nonTerminalRows.filter(row => {
const saved = store.get(row.id);
if (!saved) return true;
const status = (saved.payload as Record<string, unknown>).status as
| Record<string, unknown>
| undefined;
return !status?.state || !terminal.has(status.state as string);
});
}),
getJob: vi.fn(async (id: string) => {
const j = opts.jobs.get(id);
return j === undefined ? null : j;
}),
getLocalTask: vi.fn(async (id: number) => {
return opts.localTasks?.get(id) ?? null;
}),
saveA2aTask: vi.fn((row: SavedA2aTask) => {
store.set(row.id, row);
// Also update the nonTerminalRows payload so subsequent listNonTerminalA2aTasks sees it
const orig = opts.nonTerminalRows.find(r => r.id === row.id);
if (orig) {
orig.payload = row.payload as Record<string, unknown>;
}
}),
// Revocation sweep: return a live delegation by default so existing tests are unaffected.
// Tests that want to exercise revocation should use a2a-revocation-e2e.test.ts instead.
getA2aDelegationByGrantId: vi.fn((grantId: string) => ({
id: 'deleg-fake',
userId: 'user-1',
clientId: 'client-fake',
grantId,
grantedSpaceIds: [] as string[],
grantedSkills: [] as string[],
audience: null as string | null,
expiresAt: null as string | null,
revokedAt: null as string | null,
})),
// Revocation sweep: cancel linked job (not exercised by existing tests)
cancelA2aLinkedJob: vi.fn((_jobId: string) => false),
// Expose store for assertions
_store: store,
} as unknown as Repository & { _store: Map<string, SavedA2aTask> };
return repo;
}
// ─── helpers ─────────────────────────────────────────────────────────────────
function makeWorkingPayload(jobId: string, extra: Record<string, unknown> = {}) {
return {
id: 'task-1',
contextId: 'ctx-1',
kind: 'task',
status: { state: 'working', timestamp: '2024-01-01T00:00:00.000Z' },
metadata: {
maestroJobId: jobId,
maestroLocalTaskId: 42,
a2aDelegationId: null,
a2aGrantId: null,
a2aActingUserId: null,
},
...extra,
};
}
const FIXED_NOW = '2026-07-02T10:00:00.000Z';
// ─── tests ───────────────────────────────────────────────────────────────────
describe('A2aTaskReconciler.reconcileOnce', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'reconciler-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// ── Case 1: succeeded job + output file → completed + artifact ──────────
it('succeeded job + output/result.txt → completed with artifact', async () => {
// Arrange: create output/result.txt in a tmp workspace
const workspacePath = path.join(tmpDir, 'ws1');
fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
fs.writeFileSync(path.join(workspacePath, 'output', 'result.txt'), 'hello world');
const payload = makeWorkingPayload('job-1');
const row: A2aTaskRow = { id: 'task-1', jobId: 'job-1', payload };
const repo = buildFakeRepo({
nonTerminalRows: [row],
jobs: new Map([['job-1', makeJobStub('succeeded')]]),
localTasks: new Map([[42, { workspacePath }]]),
});
const reconciler = new A2aTaskReconciler({
repo,
now: () => FIXED_NOW,
});
// Act
const result = await reconciler.reconcileOnce();
// Assert counts
expect(result.scanned).toBe(1);
expect(result.finalized).toBe(1);
// Assert saved payload
expect(repo.saveA2aTask).toHaveBeenCalledOnce();
const saved = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('task-1')!;
const savedPayload = saved.payload as Record<string, unknown>;
// Status → completed
const status = savedPayload.status as Record<string, unknown>;
expect(status.state).toBe('completed');
expect(status.timestamp).toBe(FIXED_NOW);
// Artifacts → contains result.txt
const artifacts = savedPayload.artifacts as Array<Record<string, unknown>>;
expect(artifacts).toHaveLength(1);
expect(artifacts[0].name).toBe('result.txt');
const parts = artifacts[0].parts as Array<Record<string, unknown>>;
expect(parts[0].text).toBe('hello world');
});
// ── Case 2: getJob returns null → failed ───────────────────────────────
it('getJob returns null → task finalized as failed with "job not found"', async () => {
const payload = makeWorkingPayload('job-missing');
const row: A2aTaskRow = { id: 'task-2', jobId: 'job-missing', payload };
const repo = buildFakeRepo({
nonTerminalRows: [row],
jobs: new Map([['job-missing', null]]),
});
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
const result = await reconciler.reconcileOnce();
expect(result.finalized).toBe(1);
const saved = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('task-2')!;
const status = (saved.payload as Record<string, unknown>).status as Record<string, unknown>;
expect(status.state).toBe('failed');
// message should mention 'job not found'
const msg = status.message as Record<string, unknown>;
expect((msg.parts as Array<Record<string, unknown>>)[0].text).toContain('job not found');
});
// ── Case 3: running job → untouched ────────────────────────────────────
it('running job → task unchanged, finalized=0', async () => {
const payload = makeWorkingPayload('job-running');
const row: A2aTaskRow = { id: 'task-3', jobId: 'job-running', payload };
const repo = buildFakeRepo({
nonTerminalRows: [row],
jobs: new Map([['job-running', makeJobStub('running')]]),
});
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
const result = await reconciler.reconcileOnce();
expect(result.finalized).toBe(0);
expect(repo.saveA2aTask).not.toHaveBeenCalled();
});
// ── Case 4: null jobId → skipped ───────────────────────────────────────
it('null jobId (just enqueued) → skipped', async () => {
const payload = makeWorkingPayload('irrelevant');
const row: A2aTaskRow = { id: 'task-4', jobId: null, payload };
const repo = buildFakeRepo({
nonTerminalRows: [row],
jobs: new Map(),
});
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
const result = await reconciler.reconcileOnce();
expect(result.scanned).toBe(1);
expect(result.finalized).toBe(0);
expect(repo.getJob).not.toHaveBeenCalled();
});
// ── Case 5: idempotent — completed task not revisited ──────────────────
it('reconcileOnce twice → completed task not finalized again (idempotent)', async () => {
const workspacePath = path.join(tmpDir, 'ws5');
fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
fs.writeFileSync(path.join(workspacePath, 'output', 'out.txt'), 'done');
const payload = makeWorkingPayload('job-5');
const row: A2aTaskRow = { id: 'task-5', jobId: 'job-5', payload };
const repo = buildFakeRepo({
nonTerminalRows: [row],
jobs: new Map([['job-5', makeJobStub('succeeded')]]),
localTasks: new Map([[42, { workspacePath }]]),
});
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
// First tick — should finalize
const r1 = await reconciler.reconcileOnce();
expect(r1.finalized).toBe(1);
// After first tick, the task's payload.status.state is 'completed'
// listNonTerminalA2aTasks mock will exclude it
const r2 = await reconciler.reconcileOnce();
expect(r2.scanned).toBe(0); // task no longer in non-terminal list
expect(r2.finalized).toBe(0);
// saveA2aTask called exactly once (first tick only)
expect(repo.saveA2aTask).toHaveBeenCalledTimes(1);
});
// ── Case 6: delegation columns preserved ───────────────────────────────
it('delegation columns from payload.metadata are passed through to saveA2aTask', async () => {
const payload = makeWorkingPayload('job-deleg', {
metadata: {
maestroJobId: 'job-deleg',
maestroLocalTaskId: null,
a2aDelegationId: 'deleg-id-123',
a2aGrantId: 'grant-id-456',
a2aActingUserId: 'user-789',
},
});
const row: A2aTaskRow = { id: 'task-6', jobId: 'job-deleg', payload };
const repo = buildFakeRepo({
nonTerminalRows: [row],
jobs: new Map([['job-deleg', makeJobStub('failed', { errorSummary: 'oops' })]]),
});
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
await reconciler.reconcileOnce();
expect(repo.saveA2aTask).toHaveBeenCalledOnce();
const call = (repo.saveA2aTask as ReturnType<typeof vi.fn>).mock.calls[0][0] as SavedA2aTask;
expect(call.delegationId).toBe('deleg-id-123');
expect(call.grantId).toBe('grant-id-456');
expect(call.actingUserId).toBe('user-789');
// Status should be failed with errorSummary
const savedPayload = call.payload as Record<string, unknown>;
const status = savedPayload.status as Record<string, unknown>;
expect(status.state).toBe('failed');
const msg = status.message as Record<string, unknown>;
expect((msg.parts as Array<Record<string, unknown>>)[0].text).toBe('oops');
});
// ── Case 7: failed job → failed A2A task ───────────────────────────────
it('failed job → task finalized as failed', async () => {
const payload = makeWorkingPayload('job-fail');
const row: A2aTaskRow = { id: 'task-7', jobId: 'job-fail', payload };
const repo = buildFakeRepo({
nonTerminalRows: [row],
jobs: new Map([['job-fail', makeJobStub('failed', { errorSummary: 'something broke' })]]),
});
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
const result = await reconciler.reconcileOnce();
expect(result.finalized).toBe(1);
const saved = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('task-7')!;
const status = (saved.payload as Record<string, unknown>).status as Record<string, unknown>;
expect(status.state).toBe('failed');
const msg = status.message as Record<string, unknown>;
expect((msg.parts as Array<Record<string, unknown>>)[0].text).toBe('something broke');
});
// ── Case 8: per-row error doesn't abort full scan ──────────────────────
it('one bad row (throws) does not abort the rest', async () => {
const goodPayload = makeWorkingPayload('job-good');
const badPayload = makeWorkingPayload('job-bad');
const rows: A2aTaskRow[] = [
{ id: 'bad-task', jobId: 'job-bad', payload: badPayload },
{ id: 'good-task', jobId: 'job-good', payload: goodPayload },
];
const repo = buildFakeRepo({
nonTerminalRows: rows,
jobs: new Map([
['job-bad', makeJobStub('succeeded')], // will throw in getLocalTask
['job-good', makeJobStub('failed', { errorSummary: 'err' })],
]),
localTasks: new Map(), // no workspace → enumerateOutputArtifacts returns []
});
// Override getLocalTask to throw for bad-task
let callCount = 0;
(repo.getLocalTask as ReturnType<typeof vi.fn>).mockImplementation(async (_id: number) => {
callCount++;
if (callCount === 1) throw new Error('simulated DB error');
return null;
});
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
// Should not throw
const result = await reconciler.reconcileOnce();
// good-task should still be finalized
expect(result.scanned).toBe(2);
// bad-task errored, good-task succeeded
expect(result.finalized).toBe(1);
const goodSaved = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('good-task')!;
const status = (goodSaved.payload as Record<string, unknown>).status as Record<string, unknown>;
expect(status.state).toBe('failed');
});
// ── Case 9: waiting_human → input-required churn prevention ────────────────
it('waiting_human job: second tick does NOT re-write input-required (state-equality skip)', async () => {
const payload = makeWorkingPayload('job-waiting');
const rows: A2aTaskRow[] = [{ id: 'task-wh', jobId: 'job-waiting', payload }];
const repo = buildFakeRepo({
nonTerminalRows: rows,
jobs: new Map([['job-waiting', makeJobStub('waiting_human')]]),
localTasks: new Map(),
});
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
// First tick: working → input-required
const r1 = await reconciler.reconcileOnce();
expect(r1.finalized).toBe(1);
const saved1 = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('task-wh')!;
expect((saved1.payload as Record<string, unknown>).status).toMatchObject({ state: 'input-required' });
// Second tick: job still waiting_human, but A2A state is already input-required → skip
const r2 = await reconciler.reconcileOnce();
expect(r2.finalized).toBe(0);
// saveA2aTask was called exactly once across both ticks
expect((repo.saveA2aTask as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
});
});
// ─── start / stop mechanics ─────────────────────────────────────────────────
describe('A2aTaskReconciler start/stop', () => {
it('start() calls reconcileOnce immediately and then on interval', async () => {
const repo = buildFakeRepo({ nonTerminalRows: [], jobs: new Map() });
const reconciler = new A2aTaskReconciler({ repo, intervalMs: 50, now: () => FIXED_NOW });
const spy = vi.spyOn(reconciler, 'reconcileOnce');
reconciler.start();
// Immediate call should have been triggered (async, small wait)
await new Promise(r => setTimeout(r, 10));
expect(spy).toHaveBeenCalledTimes(1);
// Wait for at least one interval tick
await new Promise(r => setTimeout(r, 80));
expect(spy.mock.calls.length).toBeGreaterThanOrEqual(2);
reconciler.stop();
});
it('stop() prevents further reconcileOnce calls', async () => {
const repo = buildFakeRepo({ nonTerminalRows: [], jobs: new Map() });
const reconciler = new A2aTaskReconciler({ repo, intervalMs: 50, now: () => FIXED_NOW });
const spy = vi.spyOn(reconciler, 'reconcileOnce');
reconciler.start();
await new Promise(r => setTimeout(r, 10));
reconciler.stop();
const callsAfterStop = spy.mock.calls.length;
// Wait past one interval to confirm no more calls
await new Promise(r => setTimeout(r, 80));
expect(spy.mock.calls.length).toBe(callsAfterStop);
});
});

View File

@ -0,0 +1,243 @@
/**
* A2aTaskReconciler terminal a2a_tasks MAESTRO
*
* :
* - stale working terminal start() tick
* - live executor tick
*
* :
* - listNonTerminalA2aTasks terminal
* - saveA2aTask upsert terminal
*/
import * as crypto from 'crypto';
import { logger } from '../../logger.js';
import type { Repository } from '../../db/repository.js';
import {
enumerateOutputArtifacts,
finalizeStatusFromJob,
TERMINAL_JOB_STATUSES,
} from './task-finalize.js';
import { isDelegationLive } from './delegation.js';
export interface A2aReconcilerDeps {
repo: Repository;
/** Tick 間隔 (ms)。デフォルト 5000。 */
intervalMs?: number;
/** 現在時刻 ISO 文字列。テスト決定論化のため注入可能。 */
now?: () => string;
}
export class A2aTaskReconciler {
private readonly repo: Repository;
private readonly intervalMs: number;
private readonly now: () => string;
private intervalHandle: ReturnType<typeof setInterval> | null = null;
/** 再入ガード: 前の tick が終わっていない間は次の tick をスキップ */
private running = false;
constructor(deps: A2aReconcilerDeps) {
this.repo = deps.repo;
this.intervalMs = deps.intervalMs ?? 5000;
this.now = deps.now ?? (() => new Date().toISOString());
}
/**
* terminal a2a_tasks 1
* per-row try/catch 1
* 再入ガード付き: 前の tick
*/
async reconcileOnce(): Promise<{ scanned: number; finalized: number }> {
if (this.running) return { scanned: 0, finalized: 0 };
this.running = true;
try {
return await this._reconcileOnceInner();
} finally {
this.running = false;
}
}
private async _reconcileOnceInner(): Promise<{ scanned: number; finalized: number }> {
const rows = this.repo.listNonTerminalA2aTasks(500);
let finalized = 0;
for (const row of rows) {
try {
// enqueue 直後で jobId がまだ無い — 次 tick で拾える
if (row.jobId === null) continue;
const payload = row.payload as Record<string, unknown>;
const meta = (payload.metadata as Record<string, unknown> | undefined) ?? {};
// ── Task 6: 委任失効スイープ (restart-durable revocation sweep) ──────
// grantId がある場合、委任が今も有効かを確認するfail-closed
// row が DB から消えている場合も失効扱いにするfail-closed
// 失効済みなら: リンクジョブをキャンセルし、タスクを canceled に収束させる。
const grantId = meta.a2aGrantId as string | undefined;
if (grantId) {
const deleg = this.repo.getA2aDelegationByGrantId(grantId);
// fail-closed: deleg が undefinedrow 消失)は失効扱い
if (!isDelegationLive(deleg ?? { expiresAt: null, revokedAt: 'revoked' }, this.now())) {
const taskId = row.id;
const currentState = ((payload.status as Record<string, unknown> | undefined)?.state) as string | undefined;
// 状態一致スキップ (2C-1 churn 防止): すでに canceled なら再書き込み不要
if (currentState === 'canceled') continue;
// cancelA2aLinkedJob は queued/dispatching/running/waiting_human/waiting_subtasks/retry
// のすべての非終端ステータスを対象とする。revoked 委任にリンクされたジョブは、
// 現在の状態にかかわらず確実にキャンセルされる。A2A タスク自体もこの下で即
// canceled に確定するため、二重スイープは発生しない(状態一致スキップが防ぐ)。
let jobCancelled = false;
if (row.jobId) {
jobCancelled = this.repo.cancelA2aLinkedJob(row.jobId);
}
logger.info(`[a2a-reconciler] revoked-sweep taskId=${taskId} grant=${grantId} jobCancelled=${jobCancelled}`);
payload.status = {
state: 'canceled',
timestamp: this.now(),
};
this.persistTask(row, payload, meta);
finalized++;
continue; // 既存の job-terminal 収束ロジックはスキップ
}
}
// ─────────────────────────────────────────────────────────────────────
const job = await this.repo.getJob(row.jobId);
if (!job) {
// ジョブが DB から消えている → failed で収束
const taskId = row.id;
const contextId = (payload.contextId as string | undefined) ?? taskId;
// 状態一致スキップ: すでに failed なら再書き込み不要
const currentState = ((payload.status as Record<string, unknown> | undefined)?.state) as string | undefined;
if (currentState === 'failed') continue;
payload.status = {
state: 'failed',
message: this.makeMessage('job not found', contextId, taskId),
timestamp: this.now(),
};
this.persistTask(row, payload, meta);
finalized++;
logger.warn(`[a2a-reconciler] taskId=${taskId} job=${row.jobId} not found → finalized as failed`);
continue;
}
// Live executor がまだ処理中 — 何もしない
if (!TERMINAL_JOB_STATUSES.has(job.status)) continue;
// Terminal ジョブ → A2A 終端状態に収束
const { state, message } = finalizeStatusFromJob(job);
const taskId = row.id;
const contextId = (payload.contextId as string | undefined) ?? taskId;
// 状態一致スキップ: すでに同じ状態なら再書き込み不要
// waiting_human→input-required のような非 terminal A2A 状態の churn を防止)
const currentState = ((payload.status as Record<string, unknown> | undefined)?.state) as string | undefined;
if (currentState === state) continue;
if (state === 'completed') {
// succeeded の場合: workspace を解決して artifacts を列挙
const localTaskId = (meta.maestroLocalTaskId as number | undefined) ?? null;
let workspacePath: string | null = null;
if (localTaskId != null) {
const localTask = await this.repo.getLocalTask(localTaskId);
workspacePath = localTask?.workspacePath ?? (job.worktreePath ?? null);
} else {
workspacePath = job.worktreePath ?? null;
}
const rawArtifacts = enumerateOutputArtifacts(workspacePath);
payload.artifacts = rawArtifacts.map(art => ({
artifactId: art.artifactId,
name: art.name,
parts: [{ kind: 'text', text: art.text }],
}));
}
// status フィールドを上書きmessage は完了 or キャンセルでは不要)
payload.status = {
state,
...(message ? { message: this.makeMessage(message, contextId, taskId) } : {}),
timestamp: this.now(),
};
this.persistTask(row, payload, meta);
finalized++;
logger.info(`[a2a-reconciler] taskId=${taskId} jobStatus=${job.status} → a2aState=${state}`);
} catch (err) {
logger.warn(`[a2a-reconciler] row=${row.id} error: ${err}`);
}
}
return { scanned: rows.length, finalized };
}
/**
* tick + intervalMs
* start() tick re-sync
* double-start ガード: 既存インターバルがあればクリアしてから張り直す
*/
start(): void {
if (this.intervalHandle !== null) {
clearInterval(this.intervalHandle);
this.intervalHandle = null;
}
void this.reconcileOnce().catch(err =>
logger.warn(`[a2a-reconciler] start tick error: ${err}`),
);
this.intervalHandle = setInterval(() => {
void this.reconcileOnce().catch(err =>
logger.warn(`[a2a-reconciler] tick error: ${err}`),
);
}, this.intervalMs);
}
/** インターバルを停止する。 */
stop(): void {
if (this.intervalHandle !== null) {
clearInterval(this.intervalHandle);
this.intervalHandle = null;
}
}
// ── private helpers ───────────────────────────────────────────────────────
/**
* executor.ts makeStatusMessage Message
* A2A SDK Message2
*/
private makeMessage(text: string, contextId: string, taskId: string): Record<string, unknown> {
return {
kind: 'message',
messageId: crypto.randomUUID(),
role: 'agent',
parts: [{ kind: 'text', text }],
contextId,
taskId,
};
}
/**
* saveA2aTask delegation_id / grant_id / acting_user_id
* payload.metadata task-store.ts
*/
private persistTask(
row: { id: string; jobId: string | null },
payload: Record<string, unknown>,
meta: Record<string, unknown>,
): void {
this.repo.saveA2aTask({
id: row.id,
contextId: (payload.contextId as string | null) ?? null,
jobId: row.jobId,
localTaskId: (meta.maestroLocalTaskId as number | null) ?? null,
payload: payload as object,
delegationId: (meta.a2aDelegationId as string | undefined) ?? undefined,
grantId: (meta.a2aGrantId as string | undefined) ?? undefined,
actingUserId: (meta.a2aActingUserId as string | undefined) ?? undefined,
});
}
}

View File

@ -0,0 +1,168 @@
// @vitest-environment node
/**
* AuthGatedRequestHandler unit + E2E tests.
*
* What is tested:
* - getTask / cancelTask / resubscribe reject UnauthenticatedUser (or missing context)
* with A2AError code -32600 (INVALID_REQUEST).
* - Authenticated contexts pass the auth gate and reach the underlying store
* (which throws taskNotFound -32001, not the auth error).
* - E2E via supertest: unauthenticated `tasks/get` HTTP request proper JSON-RPC
* error response (not 500).
*
* Test approach: unit-test AuthGatedRequestHandler directly with InMemoryTaskStore
* and a stub executor (fastest, no HTTP round-trip needed for unit assertions).
* One E2E supertest case covers the full HTTP path.
*/
import { describe, it, expect } from 'vitest';
import express from 'express';
import request from 'supertest';
import {
A2AError,
InMemoryTaskStore,
ServerCallContext,
UnauthenticatedUser,
} from '@a2a-js/sdk/server';
import { Repository } from '../../db/repository.js';
import { buildBaseCard } from './agent-card.js';
import { AuthGatedRequestHandler, mountA2aRequestHandler } from './request-handler.js';
import { A2aAuthenticatedUser } from './user-builder.js';
import type { A2aPrincipal } from './token-auth.js';
// ─── fixtures ─────────────────────────────────────────────────────────────────
const BASE_DEPS = { baseUrl: 'https://m', issuer: 'https://m/oidc', version: '1.0.0' };
const FAKE_PRINCIPAL: A2aPrincipal = {
actingUserId: 'u1', clientId: 'cli1', grantId: 'g1',
delegation: {
id: 'd1', userId: 'u1', clientId: 'cli1', grantId: 'g1',
grantedSpaceIds: ['s1'], grantedSkills: ['research'],
expiresAt: null, revokedAt: null,
},
};
/** Helper — construct AuthGatedRequestHandler with an in-memory store. */
function makeHandler() {
const store = new InMemoryTaskStore();
const fakeExecutor = { execute: async () => {} } as any;
const baseCard = buildBaseCard(BASE_DEPS);
return new AuthGatedRequestHandler(baseCard, store, fakeExecutor);
}
const UNAUTH_CTX = new ServerCallContext(undefined, new UnauthenticatedUser());
const AUTH_CTX = new ServerCallContext(undefined, new A2aAuthenticatedUser(FAKE_PRINCIPAL));
// ─── getTask ──────────────────────────────────────────────────────────────────
describe('AuthGatedRequestHandler.getTask', () => {
it('rejects UnauthenticatedUser with A2AError', async () => {
const h = makeHandler();
await expect(h.getTask({ id: 'x' }, UNAUTH_CTX)).rejects.toBeInstanceOf(A2AError);
});
it('rejected error carries INVALID_REQUEST code (-32600)', async () => {
const h = makeHandler();
await expect(h.getTask({ id: 'x' }, UNAUTH_CTX))
.rejects.toMatchObject({ code: -32600 });
});
it('fail-closed: undefined context also rejects', async () => {
const h = makeHandler();
await expect(h.getTask({ id: 'x' }, undefined)).rejects.toMatchObject({ code: -32600 });
});
it('authenticated context passes auth gate (taskNotFound, not auth error)', async () => {
const h = makeHandler();
const err = await h.getTask({ id: 'nonexistent' }, AUTH_CTX).catch(e => e);
expect(err).toBeInstanceOf(A2AError);
// -32001 = TASK_NOT_FOUND (auth gate was cleared, underlying store was reached)
expect(err.code).not.toBe(-32600);
});
});
// ─── cancelTask ───────────────────────────────────────────────────────────────
describe('AuthGatedRequestHandler.cancelTask', () => {
it('rejects UnauthenticatedUser with INVALID_REQUEST (-32600)', async () => {
const h = makeHandler();
await expect(h.cancelTask({ id: 'x' }, UNAUTH_CTX))
.rejects.toMatchObject({ code: -32600 });
});
it('fail-closed: undefined context rejects', async () => {
const h = makeHandler();
await expect(h.cancelTask({ id: 'x' }, undefined)).rejects.toMatchObject({ code: -32600 });
});
it('authenticated context passes auth gate (taskNotFound, not auth error)', async () => {
const h = makeHandler();
const err = await h.cancelTask({ id: 'nonexistent' }, AUTH_CTX).catch(e => e);
expect(err).toBeInstanceOf(A2AError);
expect(err.code).not.toBe(-32600);
});
});
// ─── resubscribe ──────────────────────────────────────────────────────────────
describe('AuthGatedRequestHandler.resubscribe', () => {
it('rejects UnauthenticatedUser synchronously (throws before returning generator)', () => {
const h = makeHandler();
expect(() => h.resubscribe({ id: 'x' }, UNAUTH_CTX)).toThrow(A2AError);
});
it('thrown error carries INVALID_REQUEST code (-32600)', () => {
const h = makeHandler();
let caught: unknown;
try { h.resubscribe({ id: 'x' }, UNAUTH_CTX); } catch (e) { caught = e; }
expect(caught).toBeInstanceOf(A2AError);
expect((caught as A2AError).code).toBe(-32600);
});
it('fail-closed: undefined context throws synchronously', () => {
const h = makeHandler();
expect(() => h.resubscribe({ id: 'x' }, undefined)).toThrow(A2AError);
});
it('authenticated context returns an AsyncGenerator without throwing', () => {
const h = makeHandler();
let gen: ReturnType<typeof h.resubscribe> | undefined;
expect(() => { gen = h.resubscribe({ id: 'x' }, AUTH_CTX); }).not.toThrow();
expect(gen).toBeDefined();
expect(typeof gen![Symbol.asyncIterator]).toBe('function');
gen!.return?.(undefined); // clean up
});
});
// ─── E2E: tasks/get via HTTP → JSON-RPC error when unauthenticated ───────────
describe('mountA2aRequestHandler tasks/get auth gate (E2E)', () => {
function seedRepo(repo: Repository) {
(repo as any).db.prepare(
"INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) " +
"VALUES ('u1','u1@test','User One',NULL,'user','active','2026-01-01','2026-01-01')",
).run();
}
function fakeProvider() {
return { AccessToken: { async find(_v: string) { return undefined; } } } as any;
}
it('unauthenticated tasks/get returns JSON-RPC error (not 500, code=-32600)', async () => {
const repo = new Repository(':memory:');
seedRepo(repo);
const app = express();
mountA2aRequestHandler(app, { provider: fakeProvider(), repo, ...BASE_DEPS });
const res = await request(app)
.post('/a2a')
.set('Content-Type', 'application/json')
.send({ jsonrpc: '2.0', id: 99, method: 'tasks/get', params: { id: 'some-task-id' } });
expect(res.status).not.toBe(500);
expect(res.headers['content-type']).toMatch(/json/);
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body).toHaveProperty('error');
expect(res.body.error.code).toBe(-32600); // INVALID_REQUEST = unauthorized
});
});

View File

@ -0,0 +1,154 @@
// @vitest-environment node
/**
* request-handler.ts
*
* :
* 1. makeExtendedCardProvider principal extended card / base card
* 2. mountA2aRequestHandler supertest smoke Bearer POST /a2a 500
*
* Task 8 token round-trip e2e
*/
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../../db/repository.js';
import { buildBaseCard } from './agent-card.js';
import { makeExtendedCardProvider, mountA2aRequestHandler } from './request-handler.js';
import type { A2aPrincipal } from './token-auth.js';
// ─── seed helpers ─────────────────────────────────────────────────────────────
function seedRepo(repo: Repository) {
(repo as any).db.prepare(
"INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) " +
"VALUES ('u1','u1@test','User One',NULL,'user','active','2026-01-01','2026-01-01')",
).run();
(repo as any).db.prepare(
"INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES ('s1','S1','case','u1','private')",
).run();
repo.setSpaceA2aSkills('s1', ['research']);
repo.createA2aDelegation({
id: 'd1', userId: 'u1', clientId: 'cli1', grantId: 'g1',
grantedSpaceIds: ['s1'], grantedSkills: ['research'],
audience: 'https://m/a2a', expiresAt: null, revokedAt: null,
});
}
/** Full A2aPrincipal that matches the seeded delegation. */
const FAKE_PRINCIPAL: A2aPrincipal = {
actingUserId: 'u1',
clientId: 'cli1',
grantId: 'g1',
delegation: {
userId: 'u1', clientId: 'cli1', grantId: 'g1',
grantedSpaceIds: ['s1'], grantedSkills: ['research'],
expiresAt: null, revokedAt: null,
},
};
/** Minimal fake OIDC provider (no tokens → always unauthenticated). */
function fakeProvider() {
return { AccessToken: { async find(_v: string) { return undefined; } } } as any;
}
const BASE_DEPS = { baseUrl: 'https://m', issuer: 'https://m/oidc', version: '1.0.0' };
// ─── makeExtendedCardProvider ─────────────────────────────────────────────────
describe('makeExtendedCardProvider', () => {
let repo: Repository;
beforeEach(() => {
repo = new Repository(':memory:');
seedRepo(repo);
});
it('context=undefined (no call context) → base card with empty skills', async () => {
const baseCard = buildBaseCard(BASE_DEPS);
const provider = makeExtendedCardProvider(repo, baseCard, BASE_DEPS);
const card = await provider(undefined);
expect(card.skills).toEqual([]);
});
it('context.user has no a2aPrincipal (UnauthenticatedUser) → base card', async () => {
const baseCard = buildBaseCard(BASE_DEPS);
const provider = makeExtendedCardProvider(repo, baseCard, BASE_DEPS);
const ctx = { user: { isAuthenticated: false, userName: '' } } as any;
const card = await provider(ctx);
expect(card.skills).toEqual([]);
});
it('authenticated principal with live delegation + space allowlist → extended card with skills', async () => {
const baseCard = buildBaseCard(BASE_DEPS);
const provider = makeExtendedCardProvider(repo, baseCard, BASE_DEPS);
const ctx = {
user: { a2aPrincipal: FAKE_PRINCIPAL, isAuthenticated: true, userName: 'u1' },
} as any;
const card = await provider(ctx);
// space s1 has allowlist ['research'], delegation grants ['research'] → intersection ['research']
expect(card.skills.map((s: { id: string }) => s.id)).toEqual(['research']);
});
it('principal whose acting user does not exist → falls back to base card', async () => {
const baseCard = buildBaseCard(BASE_DEPS);
const provider = makeExtendedCardProvider(repo, baseCard, BASE_DEPS);
const ghost: A2aPrincipal = { ...FAKE_PRINCIPAL, actingUserId: 'ghost' };
const ctx = { user: { a2aPrincipal: ghost, isAuthenticated: true, userName: 'ghost' } } as any;
const card = await provider(ctx);
expect(card.skills).toEqual([]);
});
it('principal whose granted skill is not in any space allowlist → empty skills on extended card', async () => {
const baseCard = buildBaseCard(BASE_DEPS);
const provider = makeExtendedCardProvider(repo, baseCard, BASE_DEPS);
const restrictedPrincipal: A2aPrincipal = {
...FAKE_PRINCIPAL,
delegation: { ...FAKE_PRINCIPAL.delegation, grantedSkills: ['admin'] }, // 'admin' not in s1 allowlist
};
const ctx = { user: { a2aPrincipal: restrictedPrincipal, isAuthenticated: true, userName: 'u1' } } as any;
const card = await provider(ctx);
expect(card.skills).toEqual([]);
});
});
// ─── supertest smoke ──────────────────────────────────────────────────────────
describe('mountA2aRequestHandler smoke', () => {
it('POST /a2a without Bearer token does not crash (no 500)', async () => {
const repo = new Repository(':memory:');
seedRepo(repo);
const app = express();
mountA2aRequestHandler(app, { provider: fakeProvider(), repo, ...BASE_DEPS });
const res = await request(app)
.post('/a2a')
.set('Content-Type', 'application/json')
.send({
jsonrpc: '2.0',
id: 1,
method: 'message/send',
params: {
message: {
messageId: 'm1',
role: 'user',
parts: [{ kind: 'text', text: 'hello' }],
},
},
});
// Handler must not 500-crash; SDK returns a JSON-RPC error for unauthenticated
expect(res.status).not.toBe(500);
expect(res.headers['content-type']).toMatch(/json/);
// Assert JSON-RPC error envelope structure
expect(res.body).toHaveProperty('error');
expect(res.body.error).toHaveProperty('code');
expect(res.body.error).toHaveProperty('message');
expect(res.body.jsonrpc).toBe('2.0');
});
});

View File

@ -0,0 +1,136 @@
/**
* mountA2aRequestHandler DefaultRequestHandler + jsonRpcHandler /a2a
*
* Plan 2A base card route (`/.well-known/agent-card.json`)
* extended card route (`/a2a/agent-card/extended`)
* SDK DefaultRequestHandler `agent/getAuthenticatedExtendedCard` RPC
* Plan 2A REST route path
* `buildExtendedCard` + `computeEffectiveScope`
*/
import type { Express } from 'express';
import { A2AError, DefaultRequestHandler, type ServerCallContext } from '@a2a-js/sdk/server';
import { jsonRpcHandler } from '@a2a-js/sdk/server/express';
import type Provider from 'oidc-provider';
import type { Repository } from '../../db/repository.js';
import { logger } from '../../logger.js';
import { SqliteA2aTaskStore } from './task-store.js';
import { MaestroA2aExecutor } from './executor.js';
import { buildBaseCard, buildExtendedCard } from './agent-card.js';
import { computeEffectiveScope } from './delegation.js';
import { makeA2aUserBuilder } from './user-builder.js';
import type { A2aPrincipal } from './token-auth.js';
export interface MountA2aRequestHandlerDeps {
provider: Provider;
repo: Repository;
baseUrl: string;
issuer: string;
version: string;
}
type CardDeps = { baseUrl: string; issuer: string; version: string };
/**
* DefaultRequestHandler
* `tasks/get``tasks/resubscribe``tasks/cancel` 3 RPC
*
*
* ID 使fail-closed
* context.user isAuthenticated !== true A2AError.invalidRequest
* JsonRpcTransportHandler -32600 JSON-RPC
*
* sendMessage / sendMessageStream executor.execute()
* principal unauthorized failed
*/
export class AuthGatedRequestHandler extends DefaultRequestHandler {
override async getTask(params: Parameters<DefaultRequestHandler['getTask']>[0], context?: ServerCallContext) {
this.requireAuthenticated(context);
return super.getTask(params, context);
}
override async cancelTask(params: Parameters<DefaultRequestHandler['cancelTask']>[0], context?: ServerCallContext) {
this.requireAuthenticated(context);
return super.cancelTask(params, context);
}
/**
* super.resubscribe()
* throw JsonRpcTransportHandler
* SSE JSON-RPC
*/
override resubscribe(params: Parameters<DefaultRequestHandler['resubscribe']>[0], context?: ServerCallContext) {
this.requireAuthenticated(context);
return super.resubscribe(params, context);
}
private requireAuthenticated(context?: ServerCallContext): void {
const user = context?.user;
if (!user || user.isAuthenticated !== true) {
throw A2AError.invalidRequest(
'unauthorized: caller not authenticated or delegation has been revoked',
);
}
}
}
/**
* Extended card providerDefaultRequestHandler 7th
* ServerCallContext.user.a2aPrincipal scope extended card
* principal scope nullunknown/inactive user
* base card fail-closed
*
* named export
*/
export function makeExtendedCardProvider(
repo: Repository,
baseCard: ReturnType<typeof buildBaseCard>,
deps: CardDeps,
) {
return async (context?: ServerCallContext) => {
const principal = (context?.user as any)?.a2aPrincipal as A2aPrincipal | undefined;
if (!principal) return baseCard; // 未認証 → base cardno scope leak
try {
const scope = await computeEffectiveScope(repo, principal);
if (!scope) return baseCard; // unknown/inactive user → base card
return buildExtendedCard(principal, {
baseUrl: deps.baseUrl,
issuer: deps.issuer,
version: deps.version,
userVisibleSpaceIds: scope.userVisibleSpaceIds,
allowlistsBySpace: scope.allowlistsBySpace,
});
} catch (err) {
logger.warn(`[a2a] extended card provider failed: ${err}`);
return baseCard;
}
};
}
/**
* DefaultRequestHandler + jsonRpcHandler Express app /a2a
* server.ts `if (a2aCfg.enabled)`
* /a2a /api requireAuth
* makeA2aUserBuilderUserBuilder
*/
export function mountA2aRequestHandler(app: Express, deps: MountA2aRequestHandlerDeps): void {
const taskStore = new SqliteA2aTaskStore(deps.repo);
const executor = new MaestroA2aExecutor(deps.repo);
const cardDeps: CardDeps = {
baseUrl: deps.baseUrl,
issuer: deps.issuer,
version: deps.version,
};
const baseCard = buildBaseCard(cardDeps);
const extendedCardProvider = makeExtendedCardProvider(deps.repo, baseCard, cardDeps);
const handler = new AuthGatedRequestHandler(
baseCard,
taskStore,
executor,
undefined, // eventBusManager (default)
undefined, // pushNotificationStore (not used)
undefined, // pushNotificationSender (not used)
extendedCardProvider, // 7th arg: extended card via JWT principal
);
const userBuilder = makeA2aUserBuilder(deps.provider, deps.repo);
app.use('/a2a', jsonRpcHandler({ requestHandler: handler, userBuilder }));
}

View File

@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest';
import { revokeDelegationCascade } from './revoke.js';
function fakeRepo(over: Partial<ReturnType<typeof makeBase>> = {}) {
const calls: { revoked: any[]; oidcRevoked: string[]; cancelled: string[]; audit: any[] } = {
revoked: [],
oidcRevoked: [],
cancelled: [],
audit: [],
};
const baseRow = {
id: 'd1',
userId: 'u1',
clientId: 'c1',
grantId: 'g1',
revokedAt: null as string | null,
grantedSpaceIds: [],
grantedSkills: [],
audience: null,
expiresAt: null,
};
function makeBase() {
return {
getA2aDelegationById(id: string) {
return id === 'd1' ? { ...baseRow } : undefined;
},
revokeA2aDelegation(id: string, iso: string) {
calls.revoked.push([id, iso]);
},
oidcRevokeByGrantId(g: string) {
calls.oidcRevoked.push(g);
},
listNonTerminalA2aTasksByGrant(_g: string) {
return [
{ id: 't1', jobId: 'j1', payload: {} },
{ id: 't2', jobId: null, payload: {} },
];
},
cancelA2aLinkedJob(j: string) {
calls.cancelled.push(j);
return true;
},
async addAuditLog(jobId: string | null, action: string, actor: string, detail: object) {
calls.audit.push({ jobId, action, actor, detail });
},
};
}
const base = makeBase();
return { repo: { ...base, ...over }, calls };
}
const NOW = '2026-07-02T00:00:00.000Z';
describe('revokeDelegationCascade', () => {
it('revokes, kills the grant, cancels non-terminal jobs, audits', async () => {
const { repo, calls } = fakeRepo();
const out = await revokeDelegationCascade(repo as any, 'd1', { userId: 'u1', isAdmin: false }, NOW);
expect(out).toEqual({ status: 'revoked', delegationId: 'd1', grantId: 'g1', cancelledJobs: 1 });
expect(calls.revoked).toEqual([['d1', NOW]]);
expect(calls.oidcRevoked).toEqual(['g1']);
expect(calls.cancelled).toEqual(['j1']); // t2 has no jobId
expect(calls.audit).toHaveLength(1);
expect(calls.audit[0]).toMatchObject({
jobId: null,
action: 'a2a.delegation.revoked',
actor: 'u1',
detail: { delegationId: 'd1', clientId: 'c1', cancelledJobs: 1, byAdmin: false },
});
});
it('non-owner non-admin is forbidden and touches nothing', async () => {
const { repo, calls } = fakeRepo();
const out = await revokeDelegationCascade(repo as any, 'd1', { userId: 'someone-else', isAdmin: false }, NOW);
expect(out).toEqual({ status: 'forbidden' });
expect(calls.revoked).toEqual([]);
expect(calls.oidcRevoked).toEqual([]);
});
it("admin may revoke another user's delegation", async () => {
const { repo } = fakeRepo();
const out = await revokeDelegationCascade(repo as any, 'd1', { userId: 'admin', isAdmin: true }, NOW);
expect(out.status).toBe('revoked');
});
it('unknown id → not-found', async () => {
const { repo } = fakeRepo();
expect(
await revokeDelegationCascade(repo as any, 'ghost', { userId: 'u1', isAdmin: false }, NOW),
).toEqual({ status: 'not-found' });
});
it('already revoked → idempotent, no job cancels', async () => {
const { repo, calls } = fakeRepo({
getA2aDelegationById: (id: string) =>
id === 'd1'
? { id: 'd1', userId: 'u1', clientId: 'c1', grantId: 'g1', revokedAt: NOW }
: undefined,
});
const out = await revokeDelegationCascade(repo as any, 'd1', { userId: 'u1', isAdmin: false }, NOW);
expect(out).toEqual({ status: 'already-revoked', delegationId: 'd1' });
expect(calls.cancelled).toEqual([]);
expect(calls.audit).toEqual([]);
});
});

94
src/bridge/a2a/revoke.ts Normal file
View File

@ -0,0 +1,94 @@
import { logger } from '../../logger.js';
// ── Public types ─────────────────────────────────────────────────────────────
export type RevokeOutcome =
| { status: 'revoked'; delegationId: string; grantId: string | null; cancelledJobs: number }
| { status: 'already-revoked'; delegationId: string }
| { status: 'not-found' }
| { status: 'forbidden' };
export interface RevokeActor {
userId: string;
isAdmin: boolean;
}
/** Structural subset of Repository used by revokeDelegationCascade. */
export interface RevokeRepo {
getA2aDelegationById(
id: string,
): { id: string; userId: string; clientId: string; grantId: string | null; revokedAt: string | null } | undefined;
revokeA2aDelegation(id: string, revokedAtIso: string): void;
oidcRevokeByGrantId(grantId: string): void;
listNonTerminalA2aTasksByGrant(
grantId: string,
limit?: number,
): Array<{ id: string; jobId: string | null; payload: Record<string, unknown> }>;
cancelA2aLinkedJob(jobId: string): boolean;
addAuditLog(jobId: string | null, action: string, actor: string, detail: object): Promise<void>;
}
// ── Core service ─────────────────────────────────────────────────────────────
/**
* Revoke an A2A delegation with cascading effects:
* - marks the delegation row as revoked
* - destroys the OIDC grant artifacts (opaque token stops resolving)
* - cancels all non-terminal A2A-linked jobs for the grant
* - writes an audit log entry
*
* Idempotent: an already-revoked delegation returns `already-revoked` with no
* side effects. The HTTP layer maps `not-found` and `forbidden` both to 404 to
* avoid existence leaks; the distinct statuses exist for admin-path auditing.
*/
export async function revokeDelegationCascade(
repo: RevokeRepo,
delegationId: string,
actor: RevokeActor,
nowIso: string,
): Promise<RevokeOutcome> {
// 1. Lookup
const row = repo.getA2aDelegationById(delegationId);
if (!row) return { status: 'not-found' };
// 2. Authorisation
if (!actor.isAdmin && row.userId !== actor.userId) return { status: 'forbidden' };
// 3. Idempotency guard
if (row.revokedAt) return { status: 'already-revoked', delegationId };
// 4. Mark revoked
repo.revokeA2aDelegation(delegationId, nowIso);
// 56. OIDC grant tear-down + job cancellation
let cancelledJobs = 0;
if (row.grantId) {
repo.oidcRevokeByGrantId(row.grantId);
const tasks = repo.listNonTerminalA2aTasksByGrant(row.grantId);
for (const task of tasks) {
if (task.jobId) {
const ok = repo.cancelA2aLinkedJob(task.jobId);
if (ok) cancelledJobs++;
}
}
}
// 7. Audit (non-fatal)
try {
await repo.addAuditLog(null, 'a2a.delegation.revoked', actor.userId, {
delegationId,
clientId: row.clientId,
cancelledJobs,
byAdmin: actor.isAdmin,
});
} catch (err) {
logger.warn(`[a2a-revoke] audit log failed (non-fatal): ${err}`);
}
logger.info(
`[a2a-revoke] revoked delegation=${delegationId} grant=${row.grantId} cancelledJobs=${cancelledJobs} byAdmin=${actor.isAdmin}`,
);
// 8. Return outcome
return { status: 'revoked', delegationId, grantId: row.grantId, cancelledJobs };
}

Some files were not shown because too many files have changed in this diff Show More