sync: update from private repo (edc775f2)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
747377bef9
commit
b1292e34b2
31
.gitattributes
vendored
Normal file
31
.gitattributes
vendored
Normal 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
|
||||||
17
CHANGELOG.md
17
CHANGELOG.md
@ -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
|
on [Keep a Changelog](https://keepachangelog.com/), and the project aims to
|
||||||
follow semantic versioning.
|
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)
|
## v0.1.0 — Initial public release (2026-06-02)
|
||||||
|
|
||||||
First open-source release of MAESTRO, an agent orchestration platform:
|
First open-source release of MAESTRO, an agent orchestration platform:
|
||||||
|
|||||||
24
Dockerfile
24
Dockerfile
@ -86,16 +86,26 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
|||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
# build-essential compiles better-sqlite3's native addon (removed afterward to
|
# build-essential compiles better-sqlite3's native addon (removed afterward to
|
||||||
# stay lean). `npx playwright install --with-deps chromium` downloads the
|
# stay lean). `playwright install --with-deps chromium` downloads the Chromium
|
||||||
# Chromium binary INTO PLAYWRIGHT_BROWSERS_PATH and installs its shared-library
|
# binary INTO PLAYWRIGHT_BROWSERS_PATH and installs its shared-library apt deps
|
||||||
# apt deps in one step. NOTE: `npm ci` alone does NOT download the browser —
|
# in one step. NOTE: `npm ci` alone does NOT download the browser — Playwright
|
||||||
# Playwright dropped the npm-install postinstall browser download, so relying on
|
# dropped the npm-install postinstall browser download, so relying on it left
|
||||||
# it left /ms-playwright absent and the chmod below failing on a clean build
|
# /ms-playwright absent and the chmod below failing on a clean build (issue
|
||||||
# (issue #528). chmod makes the browser readable by the non-root `node` user.
|
# #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 \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends build-essential \
|
&& apt-get install -y --no-install-recommends build-essential \
|
||||||
&& npm ci --omit=dev \
|
&& 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 \
|
&& chmod -R go+rX /ms-playwright \
|
||||||
&& npm cache clean --force \
|
&& npm cache clean --force \
|
||||||
&& apt-get purge -y build-essential \
|
&& apt-get purge -y build-essential \
|
||||||
|
|||||||
@ -45,12 +45,15 @@ OpenAI 互換の LLM エンドポイント([Ollama](https://ollama.com/) / vLL
|
|||||||
|
|
||||||
### Docker(最短)
|
### Docker(最短)
|
||||||
|
|
||||||
|
Linux でも、Windows の WSL2(Docker Desktop の WSL 統合)でも動く。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # OLLAMA_BASE_URL / OLLAMA_MODEL を設定
|
docker compose up -d # 初回はビルドしてから起動
|
||||||
docker compose up -d
|
|
||||||
# http://localhost:9876 を開く
|
# 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` で指定する。
|
Compose は安全のため `127.0.0.1:9876` のみに公開する。別ホストからアクセス可能にする前に OAuth 認証を設定し、TLS 対応のリバースプロキシを配置すること。LLM エンドポイントは `.env` / `config.yaml` で指定する。
|
||||||
|
|
||||||
Docker の詳細ガイド(Linux のネットワーク・データ永続化・サンドボックス・トラブルシューティング)は **[docs/docker.md](docs/docker.ja.md)**。
|
Docker の詳細ガイド(Linux のネットワーク・データ永続化・サンドボックス・トラブルシューティング)は **[docs/docker.md](docs/docker.ja.md)**。
|
||||||
|
|||||||
@ -45,12 +45,15 @@ Settings: every `config.yaml` section as a form — LLM workers, sandbox, auth,
|
|||||||
|
|
||||||
### Docker (fastest)
|
### Docker (fastest)
|
||||||
|
|
||||||
|
Works on Linux and on Windows via WSL2 (Docker Desktop's WSL integration).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # set OLLAMA_BASE_URL / OLLAMA_MODEL
|
docker compose up -d # builds on first run, then starts
|
||||||
docker compose up -d
|
|
||||||
# open http://localhost:9876
|
# 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`.
|
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)**.
|
Full Docker guide (Linux networking, data persistence, the sandbox, troubleshooting): **[docs/docker.md](docs/docker.md)**.
|
||||||
|
|||||||
@ -38,9 +38,8 @@ prompt: |
|
|||||||
- 出力は `output/report.md` のみ、他のファイルを作らない
|
- 出力は `output/report.md` のみ、他のファイルを作らない
|
||||||
|
|
||||||
expected:
|
expected:
|
||||||
must_use_tools: [ReadExcel, WebFetch, Read, Write, CreateChecklist, CheckItem, GetChecklist]
|
# Read は拡張子で Office/PDF を自動判定・抽出する統合ツール。旧 ReadExcel は廃止。
|
||||||
forbidden_tool_for_ext:
|
must_use_tools: [WebFetch, Read, Write, CreateChecklist, CheckItem, GetChecklist]
|
||||||
Read: ['.xlsx', '.docx', '.pptx', '.xls', '.doc', '.ppt']
|
|
||||||
must_produce_files: [output/report.md]
|
must_produce_files: [output/report.md]
|
||||||
completion_status: [succeeded]
|
completion_status: [succeeded]
|
||||||
|
|
||||||
|
|||||||
@ -198,6 +198,19 @@ subtasks:
|
|||||||
max_per_parent: 10 # 1 ジョブが生成できるサブタスクの最大数
|
max_per_parent: 10 # 1 ジョブが生成できるサブタスクの最大数
|
||||||
spawn_stagger_ms: 1000 # SpawnSubTask の連続発火を間引く遅延(ms)。一斉着地で GPU 優先順位を飛び越えるのを防ぐ。0 で無効
|
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 (LLM コンテキスト管理) ───────────────────────────
|
||||||
# context:
|
# context:
|
||||||
# limit_tokens: 128000 # 省略時は Ollama API で自動取得、それも失敗なら 128000
|
# limit_tokens: 128000 # 省略時は Ollama API で自動取得、それも失敗なら 128000
|
||||||
@ -436,6 +449,8 @@ tools:
|
|||||||
# max_session_duration_seconds: 14400 # 4h hard cap
|
# max_session_duration_seconds: 14400 # 4h hard cap
|
||||||
# scrollback_bytes: 524288 # 512KB scrollback / session
|
# scrollback_bytes: 524288 # 512KB scrollback / session
|
||||||
# max_sessions_per_connection: 3
|
# max_sessions_per_connection: 3
|
||||||
|
# max_sessions_per_task: 5 # 同一タスクが開ける同時コンソール数の上限
|
||||||
|
# max_sessions_per_user: 20 # ユーザー単位の同時コンソール数の上限 (0 = 無制限)
|
||||||
# max_input_bytes_per_send: 16384
|
# max_input_bytes_per_send: 16384
|
||||||
# auto_inject_screen_lines: 24
|
# auto_inject_screen_lines: 24
|
||||||
# default_cols: 120
|
# default_cols: 120
|
||||||
@ -456,3 +471,11 @@ tools:
|
|||||||
#
|
#
|
||||||
# 起動時に vapid_current_path に鍵が無ければ自動生成、mode 0600 で保存。
|
# 起動時に vapid_current_path に鍵が無ければ自動生成、mode 0600 で保存。
|
||||||
# 鍵をローテーションする場合: npm run vapid-rotate
|
# 鍵をローテーションする場合: 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" # トークン audience(A2A endpoint)
|
||||||
|
|||||||
@ -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]
|
[Unit]
|
||||||
Description=MAESTRO
|
Description=MAESTRO agent orchestrator
|
||||||
After=network.target
|
Documentation=file://@PROJECT_DIR@/docs/systemd-autostart.md
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=maestro
|
User=@RUN_USER@
|
||||||
WorkingDirectory=/opt/maestro
|
WorkingDirectory=@PROJECT_DIR@
|
||||||
ExecStart=/usr/bin/node dist/index.js
|
# 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
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
EnvironmentFile=/opt/maestro/.env
|
TimeoutStopSec=30
|
||||||
|
|
||||||
# Logging
|
# Logging → journald. Tail with: journalctl -u maestro -f
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
SyslogIdentifier=maestro
|
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
|
NoNewPrivileges=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=full
|
||||||
ReadWritePaths=/opt/maestro/data /var/lib/maestro/workspaces
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@ -15,8 +15,14 @@ services:
|
|||||||
- "127.0.0.1:9876:9876"
|
- "127.0.0.1:9876:9876"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "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_file:
|
||||||
- .env
|
- path: .env
|
||||||
|
required: false
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=9876
|
- PORT=9876
|
||||||
|
|||||||
@ -6,14 +6,21 @@ MAESTRO を最短で動かす方法が Docker Compose です。`git clone` か
|
|||||||
|
|
||||||
## まずはこれだけ
|
## まずはこれだけ
|
||||||
|
|
||||||
|
Linux でも、Windows の WSL2(Docker Desktop の WSL 統合)でも動きます。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # OLLAMA_BASE_URL / OLLAMA_MODEL を自分の LLM に向ける
|
|
||||||
docker compose up -d # 初回はイメージをビルドしてから起動
|
docker compose up -d # 初回はイメージをビルドしてから起動
|
||||||
# http://localhost:9876 を開く
|
# 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 から到達できません。外部公開は[ローカル以外へ出す](#ローカル以外へ出す)を参照。
|
Compose は UI を `127.0.0.1:9876` だけに公開するので、初期状態では LAN から到達できません。外部公開は[ローカル以外へ出す](#ローカル以外へ出す)を参照。
|
||||||
|
|
||||||
|
### Windows(WSL2)
|
||||||
|
|
||||||
|
Docker Desktop の WSL 統合を有効にした WSL2 ディストロ内で、同じコマンドを実行するだけです。ヘッド付きブラウザ一式はコンテナ内で完結するため、Windows 側に X サーバーや WSLg は不要です。またリポジトリの `.gitattributes` がビルド用のシェルスクリプトを LF に固定するので、Windows でチェックアウトしても(本来 CRLF になってしまう場合でも)ビルドが壊れません。
|
||||||
|
|
||||||
## コンテナがやること・やらないこと
|
## コンテナがやること・やらないこと
|
||||||
|
|
||||||
- MAESTRO 本体(Web UI・ワーカー・ツール・任意の Gateway)を動かす。
|
- MAESTRO 本体(Web UI・ワーカー・ツール・任意の Gateway)を動かす。
|
||||||
|
|||||||
@ -8,15 +8,29 @@ people up (Linux networking, the LLM endpoint, data persistence, the sandbox).
|
|||||||
|
|
||||||
## TL;DR
|
## TL;DR
|
||||||
|
|
||||||
|
Works on Linux and on Windows via WSL2 (Docker Desktop's WSL integration).
|
||||||
|
|
||||||
```bash
|
```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
|
docker compose up -d # builds the image on first run, then starts
|
||||||
# open http://localhost:9876
|
# 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
|
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).
|
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)
|
## What the container is (and is not)
|
||||||
|
|
||||||
- It runs the **MAESTRO app** (web UI, workers, tools, optional gateway).
|
- It runs the **MAESTRO app** (web UI, workers, tools, optional gateway).
|
||||||
|
|||||||
128
docs/evaluations/2026-07-01-agent-design-20-principles.html
Normal file
128
docs/evaluations/2026-07-01-agent-design-20-principles.html
Normal 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/ReadTaskConversation)+transcript 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:110)+1行ツールカタログ自動注入(prompt.ts:290)+skill index+Read offset/limit</td></tr>
|
||||||
|
<tr><td>3</td><td>コード実行で中間データ処理</td><td class="ok">適合</td><td>Bash(core.ts:807, cwd=WS・パス検証)+スクリプト作業をprompt自動推奨(prompt.ts:157)+RunUserScript+MCP</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>ツール設計・ACI(7〜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:<id>/transcript:<index>。不透明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 found/SSH 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上限+直列delegate+research.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)+toolLoopTracker+context force_transition+text-only上限。カウントは管理されている</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>3. 実質的な改善余地(コード実測で残った3件)</h2>
|
||||||
|
<p>記事の「優先改善トップ5」のP0(CLAUDE.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>
|
||||||
193
docs/issues/2026-06-30-agent-loop-followups.md
Normal file
193
docs/issues/2026-06-30-agent-loop-followups.md
Normal 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.
|
||||||
324
docs/issues/2026-06-30-task-memory-and-conversation-search.md
Normal file
324
docs/issues/2026-06-30-task-memory-and-conversation-search.md
Normal 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.
|
||||||
229
docs/issues/2026-06-30-workspace-file-provenance.md
Normal file
229
docs/issues/2026-06-30-workspace-file-provenance.md
Normal 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.
|
||||||
114
docs/issues/2026-07-01-browseweb-segmented-screenshots.md
Normal file
114
docs/issues/2026-07-01-browseweb-segmented-screenshots.md
Normal 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.
|
||||||
@ -54,29 +54,29 @@ grep -c "engine/tools/" src/bridge/tools-api.ts
|
|||||||
## 2. 既存モジュールにツールを追加した場合
|
## 2. 既存モジュールにツールを追加した場合
|
||||||
|
|
||||||
**対象ファイル:**
|
**対象ファイル:**
|
||||||
- `pieces/*.yaml` — 必要な piece の `allowed_tools` にツール名を追加
|
- `src/engine/tools/tool-categories.ts` — 新ツールを適切なカテゴリに割り当てる(sensitive は既定 OFF)。ツールの利用可否は workspace tool policy(設定→ツール)が決めるので、**piece 側に追加する必要はない**
|
||||||
- `CLAUDE.md` — 「ツールモジュール構成」テーブルの該当モジュール行に新ツール名を追加
|
- `CLAUDE.md` — 「ツールモジュール構成」テーブルの該当モジュール行に新ツール名を追加
|
||||||
- `docs/tools/{name}.md` — 新ツールの詳細ドキュメント(推奨)
|
- `docs/tools/{name}.md` — 新ツールの詳細ドキュメント(推奨)
|
||||||
- ツール `description` — 1 文 + 「詳細は ReadToolDoc({ name: "XXX" })」を末尾に記述
|
- ツール `description` — 1 文 + 「詳細は ReadToolDoc({ name: "XXX" })」を末尾に記述
|
||||||
|
|
||||||
**なぜ必要か:**
|
**なぜ必要か:**
|
||||||
`allowed_tools` に載っていないツールは LLM に提示されない。ツールを実装しても piece に追加しなければエージェントが使えない。
|
ツールの提示は workspace tool policy が決める(カテゴリ単位。sensitive 以外は既定 ON)。カテゴリ未割り当てだとどのワークスペースでも出ない。
|
||||||
CLAUDE.md のテーブルが古いと、Claude Code 自身が既存ツールを認識せずに新規実装してしまうリスクがある。
|
CLAUDE.md のテーブルが古いと、Claude Code 自身が既存ツールを認識せずに新規実装してしまうリスクがある。
|
||||||
ツール description は毎 LLM 呼び出しに乗るため 1 文に絞り、詳細は ReadToolDoc 経由で必要時のみ読み込む(agent-loop が movement 開始時に description サマリを自動カタログ化する)。
|
ツール description は毎 LLM 呼び出しに乗るため 1 文に絞り、詳細は ReadToolDoc 経由で必要時のみ読み込む(agent-loop が movement 開始時に description サマリを自動カタログ化する)。
|
||||||
|
|
||||||
**確認方法:**
|
**確認方法:**
|
||||||
新ツールが使われるべき piece を特定し、`allowed_tools` に含まれているか確認する。
|
`tool-categories.ts` で新ツールがカテゴリに割り当てられているか確認する。
|
||||||
CLAUDE.md のモジュールテーブルに新ツール名が含まれているか確認する。
|
CLAUDE.md のモジュールテーブルに新ツール名が含まれているか確認する。
|
||||||
|
|
||||||
**実例: TestWorkspaceApp(2026-06-24):**
|
**実例: TestWorkspaceApp(2026-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. ツールをリネーム・削除した場合
|
## 3. ツールをリネーム・削除した場合
|
||||||
|
|
||||||
**対象ファイル:**
|
**対象ファイル:**
|
||||||
- `pieces/*.yaml` — 全 piece の `allowed_tools` から旧名を削除/リネーム
|
- `src/engine/tools/tool-categories.ts` — カテゴリ定義・`SENSITIVE_TOOLS` から旧名を削除/リネーム
|
||||||
- `src/engine/tools/raw-save.ts` — `RAW_SAVE_TOOLS` に旧名が残っていないか
|
- `src/engine/tools/raw-save.ts` — `RAW_SAVE_TOOLS` に旧名が残っていないか
|
||||||
- `ui/src/components/settings/ToolsForm.tsx` — ヘルプテキスト等にツール名の言及がないか
|
- `ui/src/components/settings/ToolsForm.tsx` — ヘルプテキスト等にツール名の言及がないか
|
||||||
- `CLAUDE.md` — ツールモジュール構成テーブル
|
- `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` ツールで必要時に取得する設計。
|
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 にまとめてエイリアス経由で引けるようにする。
|
関連ツール(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`) が一致しているか
|
- ssh-types.ts と API レスポンス shape (`SshConnection`, `SshGrant`, `SshAuditRow`) が一致しているか
|
||||||
- 禁止フォントサイズ (`text-[11px]` 等) を導入していないか — 既存セクションの「UI フォントサイズスケール」参照
|
- 禁止フォントサイズ (`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/worker.ts` — `parsedPolicy.enabledSensitive` に `ssh` があるとき `listBySpace(spaceId)` で接続 ID を解決(`workspaceSshConnections`)
|
||||||
- `src/engine/types.ts` (or piece schema 定義箇所) — `allowed_ssh_connections?: string[]`
|
- `src/engine/piece-runner.ts` — `workspaceSshConnections ?? []` を `ctx.allowedSshConnections` / `movement.allowedSshConnections` に流す
|
||||||
- `pieces/*.yaml` — SSH ツールを `allowed_tools` に含む movement は **必ず** `allowed_ssh_connections` 宣言が必要 (空配列 `[]` でも可)
|
- `src/engine/tools/ssh.ts` — preflight で `ctx.allowedSshConnections` に対して接続 ID を検証
|
||||||
- `docs/ssh.md` §"Per-piece `allowed_ssh_connections`"
|
- `docs/ssh.md` §"接続の認可"
|
||||||
|
|
||||||
**lint 規約:**
|
**規約:**
|
||||||
- `allowed_tools` に SSH ツール名が含まれる場合、`allowed_ssh_connections` の宣言が必須 (`undefined` は reject)
|
- 接続が 1 つも登録されていない(空配列)→ SSH ツールはクリーンに reject
|
||||||
- 値は配列、各要素は `*` または lowercase hex + ハイフン UUID (≥ 8 chars)
|
- `'*'` ワイルドカードは worker からは渡さない(常に明示 ID リスト = クロススペース分離保証)
|
||||||
- 空配列 `[]` は "deny all" として明示扱い
|
|
||||||
|
|
||||||
**確認方法:**
|
**確認方法:**
|
||||||
```bash
|
```bash
|
||||||
# 既存 piece に SSH 使用宣言があるか
|
# worker が ssh 接続を解決している経路
|
||||||
grep -l 'Ssh\(Exec\|Upload\|Download\)' pieces/*.yaml | xargs -I {} grep -l 'allowed_ssh_connections' {}
|
grep -n 'workspaceSshConnections' src/worker.ts src/engine/piece-runner.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### 12-F. config.yaml の SSH セクション (`SshRuntimeConfig`) を変更したとき
|
### 12-F. config.yaml の SSH セクション (`SshRuntimeConfig`) を変更したとき
|
||||||
@ -517,7 +520,7 @@ SSH Console は SshExec/Upload/Download とは別系統の対話的 PTY 経路
|
|||||||
|
|
||||||
## 13. Scheduler から呼ばない手動オペレーション endpoint
|
## 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)
|
- `POST /api/local/tasks/:id/continue` — 別 piece で task を続ける (handoff)
|
||||||
- 実装: `src/bridge/local-tasks-api.ts` の `/continue` ハンドラ
|
- 実装: `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 チェック可能
|
- **ツールモジュール登録漏れ**: `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` で影響範囲の見落としを防ぐ用途が現実的
|
- **code-review-graph**: `importers_of` で各ツールモジュールの参照元を列挙できるが、「tools-api.ts にも登録すべき」というルールの自動適用は困難。変更時の `detect_changes` + `get_impact_radius` で影響範囲の見落としを防ぐ用途が現実的
|
||||||
|
|||||||
35
docs/mcp.md
35
docs/mcp.md
@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
The orchestrator can call tools hosted on external **MCP servers** (OAuth-secured
|
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
|
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
|
tools surface to tasks as `mcp__<server>__<tool>` names. Availability is
|
||||||
allowlisted with `mcp__<server>__*` wildcards in `piece.allowed_tools`.
|
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
|
This document is the **operator runbook** for setting up, troubleshooting, and
|
||||||
maintaining MCP integrations. For internal design notes, see
|
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
|
4. If using a private IP, ensure `mcp.allow_private_addresses: true` is set
|
||||||
(see Prerequisites).
|
(see Prerequisites).
|
||||||
|
|
||||||
## How tools flow into pieces
|
## How tools flow into tasks
|
||||||
|
|
||||||
The orchestrator caches `tools/list` results in `mcp_server_tools`, refreshed
|
The orchestrator caches `tools/list` results in `mcp_server_tools`, refreshed
|
||||||
on registration and on explicit admin refresh (no automatic TTL today). Piece
|
on registration and on explicit admin refresh (no automatic TTL today). There
|
||||||
authors expose them via `allowed_tools`:
|
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
|
To expose a server's tools, connect the server to the workspace (**Settings →
|
||||||
movements:
|
MCP**). To *require* one, list it in the piece's `required_mcp` frontmatter —
|
||||||
- name: design
|
that only controls job parking when no connection exists (see below).
|
||||||
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.
|
|
||||||
|
|
||||||
## Job parking and resume
|
## Job parking and resume
|
||||||
|
|
||||||
When a piece requires an MCP server (via `required_mcp` frontmatter or
|
When a piece requires an MCP server (via `required_mcp` frontmatter) and the
|
||||||
discovered from `allowed_tools`) and the user has no connection, the worker
|
user has no connection, the worker
|
||||||
parks the job:
|
parks the job:
|
||||||
|
|
||||||
- `jobs.status = 'waiting_human'`
|
- `jobs.status = 'waiting_human'`
|
||||||
|
|||||||
@ -6,13 +6,13 @@
|
|||||||
|
|
||||||
| | Piece | Skill |
|
| | Piece | Skill |
|
||||||
|---|-------|-------|
|
|---|-------|-------|
|
||||||
| 役割 | **実行テンプレート** — ツール権限・遷移フローを制御 | **参照知識** — 手順・規約・ガイドを提供 |
|
| 役割 | **実行テンプレート** — movement の手順・遷移フローを制御 | **参照知識** — 手順・規約・ガイドを提供 |
|
||||||
| フォーマット | YAML(MAESTRO 固有) | Markdown(Claude Code / Codex 互換) |
|
| フォーマット | YAML(MAESTRO 固有) | Markdown(Claude Code / Codex 互換) |
|
||||||
| 選択 | piece-classifier が自動選択 | エージェントが ReadSkill で任意に参照 |
|
| 選択 | piece-classifier が自動選択 | エージェントが ReadSkill で任意に参照 |
|
||||||
| 実行制御 | `allowed_tools` でツールを制限 | 制御なし(エージェントの判断に委ねる) |
|
| 実行制御 | movement の遷移フローを定義(ツール可否はワークスペースのツールポリシー側) | 制御なし(エージェントの判断に委ねる) |
|
||||||
|
|
||||||
**判断基準:**
|
**判断基準:**
|
||||||
- ツールの許可・禁止や movement フローを定義したい → **Piece**
|
- movement の手順・遷移フローを定義したい → **Piece**
|
||||||
- 手順書・コーディング規約・デバッグガイドをエージェントに参照させたい → **Skill**
|
- 手順書・コーディング規約・デバッグガイドをエージェントに参照させたい → **Skill**
|
||||||
|
|
||||||
## スキルの構造
|
## スキルの構造
|
||||||
|
|||||||
91
docs/ssh.md
91
docs/ssh.md
@ -141,23 +141,9 @@ Then in the UI:
|
|||||||
3. Optionally set `remote_path_prefix` (default `/`) — restricts upload/download paths
|
3. Optionally set `remote_path_prefix` (default `/`) — restricts upload/download paths
|
||||||
4. Click **Test** → first call returns `host_key_first_observe` with a fingerprint
|
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>`)
|
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
|
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.
|
||||||
# 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.
|
|
||||||
|
|
||||||
## `config.yaml` Reference
|
## `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
|
the key is recorded (`host_key_alg_not_allowed`). This is hard-coded
|
||||||
in `src/ssh/session.ts` to avoid misconfiguration.
|
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
|
SSH reachability is a **workspace** property, not a piece property. The
|
||||||
piece-runner enforces three invariants:
|
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
|
The gate now works like this:
|
||||||
(`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)
|
|
||||||
|
|
||||||
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
|
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
|
||||||
```yaml
|
is rejected (fail-closed).
|
||||||
# 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Access Grants
|
## 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 (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 (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 |
|
| `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_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_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 |
|
| `host_key_mismatch` | Server key changed | Investigate (legitimate rotation? MITM?), then Replace via UI |
|
||||||
|
|||||||
105
docs/systemd-autostart.md
Normal file
105
docs/systemd-autostart.md
Normal 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
|
||||||
|
```
|
||||||
@ -31,7 +31,7 @@
|
|||||||
## なぜインストールが禁止か
|
## なぜインストールが禁止か
|
||||||
|
|
||||||
- ジョブ実行環境はサンドボックス化されており、永続化されない
|
- ジョブ実行環境はサンドボックス化されており、永続化されない
|
||||||
- 必要な機能は専用ツール(`allowed_tools` に列挙されたもの)で提供される
|
- 必要な機能は専用ツール(ワークスペースのツール設定で許可されたもの)で提供される
|
||||||
- インストールが必要 = ツールの設計が足りないので、ユーザーに報告して機能追加を依頼する
|
- インストールが必要 = ツールの設計が足りないので、ユーザーに報告して機能追加を依頼する
|
||||||
|
|
||||||
## 代替
|
## 代替
|
||||||
|
|||||||
@ -27,7 +27,9 @@ BrowseWeb({ url: "output/viewer.html" })
|
|||||||
オプション:
|
オプション:
|
||||||
- `waitFor`: 待機する CSS セレクタ(省略時は load イベント完了まで待機)
|
- `waitFor`: 待機する CSS セレクタ(省略時は load イベント完了まで待機)
|
||||||
- `extractSelector`: 特定要素のテキストだけ抽出する CSS セレクタ
|
- `extractSelector`: 特定要素のテキストだけ抽出する CSS セレクタ
|
||||||
- `screenshot`: スクリーンショットを保存するファイル名(例: `"page.png"` → `output/page.png`)
|
- `screenshot`: スクリーンショットを保存するファイル名(例: `"page.png"` → `output/page.png`)。縦長ページは既定で分割保存(下記)
|
||||||
|
- `screenshotSegments`: `false` で分割せずフルページ 1 枚(既定: 分割あり)
|
||||||
|
- `screenshotMaxSegments`: 分割の最大枚数(既定 10)
|
||||||
- `timeout`: タイムアウト(ms、デフォルト 60000)
|
- `timeout`: タイムアウト(ms、デフォルト 60000)
|
||||||
|
|
||||||
### 2. アクションモード — 連続操作
|
### 2. アクションモード — 連続操作
|
||||||
@ -47,11 +49,29 @@ BrowseWeb({
|
|||||||
- `goto` — `url` で指定したページに遷移
|
- `goto` — `url` で指定したページに遷移
|
||||||
- `click` — `selector` または `ref` で要素をクリック
|
- `click` — `selector` または `ref` で要素をクリック
|
||||||
- `fill` — `selector` または `ref` の input/textarea に `value` を入力
|
- `fill` — `selector` または `ref` の input/textarea に `value` を入力
|
||||||
- `screenshot` — `value` で指定したファイル名で保存(省略時 `screenshot.png`)
|
- `screenshot` — `value` で指定したファイル名で保存(省略時 `screenshot.png`)。縦長ページは既定で分割保存(下記)。`segments: false` でフルページ 1 枚、`maxSegments` で枚数上限を調整
|
||||||
- `getText` — 全ページのスナップショット(ref 注釈付き)または `selector` 内のテキストを取得
|
- `getText` — 全ページのスナップショット(ref 注釈付き)または `selector` 内のテキストを取得
|
||||||
- `wait` — `ms` ミリ秒待機(最大 30000)
|
- `wait` — `ms` ミリ秒待機(最大 30000)
|
||||||
- `dumpHtml` — `ref` または `selector`(省略時 body)の outerHTML を取得(脱出口、後述)
|
- `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 + ファイル保存)
|
## 長文ページの取得(preview + ファイル保存)
|
||||||
|
|
||||||
`getText` (selector 有無問わず) およびスナップショットの戻り値が **5000 文字を超える** 場合、フルテキストはワークスペースの `logs/browse/{ISO-timestamp}-{hash}.txt` に保存され、戻り値は **先頭 5000 文字 + 続きの取得方法案内** になる:
|
`getText` (selector 有無問わず) およびスナップショットの戻り値が **5000 文字を超える** 場合、フルテキストはワークスペースの `logs/browse/{ISO-timestamp}-{hash}.txt` に保存され、戻り値は **先頭 5000 文字 + 続きの取得方法案内** になる:
|
||||||
|
|||||||
50
docs/tools/file-provenance.md
Normal file
50
docs/tools/file-provenance.md
Normal 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.
|
||||||
@ -34,7 +34,6 @@ movements:
|
|||||||
- name: gather
|
- name: gather
|
||||||
persona: ...
|
persona: ...
|
||||||
instruction: ...
|
instruction: ...
|
||||||
allowed_tools: [Read, Write, ...]
|
|
||||||
rules:
|
rules:
|
||||||
- condition: ...
|
- condition: ...
|
||||||
next: ...
|
next: ...
|
||||||
|
|||||||
@ -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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# MissionUpdate
|
# 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 タブから直接編集できる。
|
Mission Brief は毎 movement のシステムプロンプト冒頭に常に描画され、会話が長くなった後やステップをまたいでも消えない「参照点」になる。ユーザーも Overview タブから直接編集できる。
|
||||||
|
|
||||||
@ -18,8 +18,11 @@ Mission Brief は毎 movement のシステムプロンプト冒頭に常に描
|
|||||||
| `done` | これまでに完了した主要マイルストーン。箇条書き推奨 |
|
| `done` | これまでに完了した主要マイルストーン。箇条書き推奨 |
|
||||||
| `open` | 残っている作業・未解決のブロッカー。箇条書き推奨 |
|
| `open` | 残っている作業・未解決のブロッカー。箇条書き推奨 |
|
||||||
| `clarifications` | ユーザーから追加された補足・制約。Markdown 可 |
|
| `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
109
docs/tools/read.md
Normal 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` — 巨大な表計算 / 文書をシート / 章単位に分割
|
||||||
@ -12,7 +12,7 @@ ReadImage({ file_path: "input/screenshot.png" })
|
|||||||
## 動作要件
|
## 動作要件
|
||||||
|
|
||||||
- 呼び出し時の worker が `vlm: true` で設定されている必要がある
|
- 呼び出し時の worker が `vlm: true` で設定されている必要がある
|
||||||
- 設定がない場合、このツールは `allowed_tools` に書いてあっても利用不可(function definition から自動除外される)
|
- 設定がない場合、このツールはワークスペースで許可されていても利用不可(function definition から自動除外される)
|
||||||
|
|
||||||
## 用途
|
## 用途
|
||||||
|
|
||||||
|
|||||||
33
docs/tools/readtaskconversation.md
Normal file
33
docs/tools/readtaskconversation.md
Normal 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 する。
|
||||||
@ -1,10 +1,10 @@
|
|||||||
# RequestTool
|
# RequestTool
|
||||||
|
|
||||||
この movement で提示されていないツールがどうしても必要なときに、その要求を**記録**するためのメタツール(`allowed_tools` に書かなくても常時利用可能)。
|
この movement で提示されていないツールがどうしても必要なときに、その要求を**記録**し、可能ならユーザー承認を求めるためのメタツール(常時利用可能)。
|
||||||
|
|
||||||
## いつ使うか
|
## いつ使うか
|
||||||
|
|
||||||
- 依頼を達成するのに必要なツールが、現在の movement の `allowed_tools` に無いと気づいたとき。
|
- 依頼を達成するのに必要なツールが、現在の movement に無いと気づいたとき。
|
||||||
- まず「本当にそのツールが要るか」を検討すること。多くの作業は既存のツール(`Bash` / `Read` / `WebSearch` 等)で代替できる。
|
- まず「本当にそのツールが要るか」を検討すること。多くの作業は既存のツール(`Bash` / `Read` / `WebSearch` 等)で代替できる。
|
||||||
|
|
||||||
## 引数
|
## 引数
|
||||||
@ -12,13 +12,16 @@
|
|||||||
| 引数 | 必須 | 説明 |
|
| 引数 | 必須 | 説明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `name` | はい | 必要なツール名(例: `WebSearch`, `Bash`, `mcp__foo__bar`) |
|
| `name` | はい | 必要なツール名(例: `WebSearch`, `Bash`, `mcp__foo__bar`) |
|
||||||
| `reason` | はい | なぜそのツールが必要かを具体的に。これがピース作者への記録に残る |
|
| `reason` | はい | なぜそのツールが必要かを具体的に。これが記録に残る |
|
||||||
|
|
||||||
## 重要: これは「要求の記録」であって「即時付与」ではない
|
## 承認フローと即時利用の可否
|
||||||
|
|
||||||
RequestTool を呼んでも、そのツールが**その場で使えるようにはならない**。要求は記録され、タスク詳細とピース集計に表示される。ピース作者が `allowed_tools` / `shared_tools` に追加すれば次回から使える。
|
ツールの可否は**ワークスペースのツールポリシー(設定 → ツール/SSH)**で決まる。RequestTool の挙動は実行環境で変わる:
|
||||||
|
|
||||||
要求したあとの進め方:
|
- **ユーザーが応答できる実行**(対話承認が有効): 承認を求めて停車し、**承認されればそのまま続行してそのツールを使える**。拒否されればツール無しで進む。
|
||||||
|
- **それ以外の実行**: その場では使えない。要求が記録され、タスク詳細とツール要求の集計に表示される。運用者がワークスペースの設定でそのツールを有効化すれば、次回から使える。
|
||||||
|
|
||||||
|
その場で使えない場合の進め方:
|
||||||
|
|
||||||
1. そのツール無しで達成できないか、もう一度考える。
|
1. そのツール無しで達成できないか、もう一度考える。
|
||||||
2. どうしても無理なら `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` でユーザーに依頼する。
|
2. どうしても無理なら `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` でユーザーに依頼する。
|
||||||
@ -27,9 +30,9 @@ RequestTool を呼んでも、そのツールが**その場で使えるように
|
|||||||
## 分類(記録される `category`)
|
## 分類(記録される `category`)
|
||||||
|
|
||||||
- **既に利用可能**: そのツールはこの movement で使える → 記録せず「そのまま呼んでください」と返る。
|
- **既に利用可能**: そのツールはこの movement で使える → 記録せず「そのまま呼んでください」と返る。
|
||||||
- **`requested`**: カタログに存在するがこの movement では未許可 → 設定漏れ候補として記録。
|
- **`requested`**: カタログに存在するがこの movement では未許可 → 対話承認が有効なら承認待ちに、無効なら設定漏れ候補として記録。
|
||||||
- **`unknown`**: そんなツールは存在しない(名前の誤り・能力ギャップ)→ **エラーを返す**(実在ツール名のみ要求可)。診断のため記録は残るが、承認待ちにはならない。エラーを受けたら実在するツールで進めること。
|
- **`unknown`**: そんなツールは存在しない(名前の誤り・能力ギャップ)→ **エラーを返す**(実在ツール名のみ要求可)。診断のため記録は残るが、承認待ちにはならない。エラーを受けたら実在するツールで進めること。
|
||||||
|
|
||||||
## 関連
|
## 関連
|
||||||
|
|
||||||
足りないツールを呼んで弾かれた場合も、同じ記録に「受動捕捉(`blocked`)」として残る。ピース側の `shared_tools`(全 movement 共通ツール)も参照(`pieces/SCHEMA.md`)。
|
足りないツールを呼んで弾かれた場合も、同じ記録に「受動捕捉(`blocked`)」として残る。ワークスペースのツール許可の考え方は「[ツール](../../ui/src/content/help/16-tools.md)」も参照。
|
||||||
|
|||||||
@ -86,7 +86,7 @@ On task complete, a candidate patch will be saved as browser-macros/{name}.next.
|
|||||||
|
|
||||||
## Notes
|
## 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.
|
- Use `ListUserAssets` first to discover available macros and their param specs.
|
||||||
- On macro failure, use `BrowseWeb` as a manual fallback.
|
- 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).
|
- To run Python or other ad-hoc code, use the **Bash** tool (pip packages pre-baked).
|
||||||
|
|||||||
42
docs/tools/searchtaskconversation.md
Normal file
42
docs/tools/searchtaskconversation.md
Normal 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 でも見失わないようにする。
|
||||||
87
docs/tools/searchworkspacetasks.md
Normal file
87
docs/tools/searchworkspacetasks.md
Normal 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(部分一致)にフォールバックする**(挙動は同じだが遅い)。
|
||||||
|
|
||||||
|
**複数語はスペース区切りの暗黙 AND(Google 的)**。`ログイン 認証` と入力すると「ログイン」と「認証」を**両方含む**コメントにヒットする(語順・位置は問わない)。フレーズ一致ではないので、離れて出現していても両語があればヒットする。各語は内部で個別に引用してから 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)を検索・閲覧する。他タスクへは到達できない。こちらは同じスペースの他タスクを横断検索する対
|
||||||
@ -6,6 +6,11 @@ Piece の取得・編集には GetPiece / CreatePiece / UpdatePiece を使う。
|
|||||||
利用可能なスキル一覧はシステムプロンプトの **Skills Index** に出る。本文を読むには
|
利用可能なスキル一覧はシステムプロンプトの **Skills Index** に出る。本文を読むには
|
||||||
`ReadSkill({ name: "..." })` を呼ぶ。
|
`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 のみ)。
|
- **InstallSkill** — スキルを保存する。通常は `content` に SKILL.md 全文(YAML frontmatter + 本文)を渡す。workspace 内に `SKILL.md` と `scripts/` 等を含むディレクトリを組み立て済みなら `sourcePath`(workspace 内の絶対パス)を渡す。`scope` は `user`(個人 or 共有ワークスペース)か `system`(全ユーザー共有・admin のみ)。
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
# SSH Console Tools (SshConsoleEnsure / SshConsoleSend / SshConsoleSnapshot)
|
# 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 が画面を見続ける用途に最適化されている。
|
単発コマンドだけなら **`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 (まずこれを真似る)
|
## 典型的な flow (まずこれを真似る)
|
||||||
|
|
||||||
@ -40,10 +40,10 @@ SshConsoleSnapshot({
|
|||||||
|
|
||||||
| Param | Required | Description |
|
| 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) |
|
| `cols` | no | 初回 open 時のターミナル幅。default `ssh.console.default_cols` (120) |
|
||||||
| `rows` | no | 初回 open 時のターミナル高さ。default `ssh.console.default_rows` (32) |
|
| `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:
|
Return:
|
||||||
```json
|
```json
|
||||||
@ -52,16 +52,28 @@ Return:
|
|||||||
|
|
||||||
`reused: true` なら過去ターンから引き継いだ既存セッション (cd 等の state あり)。`false` なら今回新規 open。
|
`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` を渡す
|
- 同じ `connection_id` + `force_replace: false` (default) → 既存セッションを再利用 (`alreadyActive: true`, `reused: true`)
|
||||||
- `force_replace: true` → 旧セッションは `connection_change` 理由で閉じられ、新セッションが開く (旧 shell の state は失われる)
|
- 同じ `connection_id` + `force_replace: true` → **その接続のセッションだけ** `connection_change` 理由で閉じて開き直す (他の接続のセッションには影響しない。旧 shell の state は失われる)
|
||||||
|
|
||||||
**典型的なバグパターン**: ジョブをまたいで動作するエージェントが `connection_id` を覚えていなくて、
|
タスクあたりの上限 (既定 5、上限到達時は `task_session_cap`) やユーザー単位の上限 (`user_session_cap`、設定次第) に達すると新規オープンは拒否される。人間側も Console タブの「+ 接続」ボタンで同じように接続を追加でき、タブの ✕ で不要なセッションを閉じられる (→[SSH 接続](../../ui/src/content/help/14-ssh.md))。
|
||||||
LLM の hallucination で適当な UUID を生成 → mismatch reject される、というケース。エラーメッセージの中に
|
|
||||||
正しい `connection_id` が出ているのでそれを使うか、Send/Snapshot で `connection_id` を省略する。
|
### `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
|
## SshConsoleSend
|
||||||
|
|
||||||
@ -77,7 +89,7 @@ raw のまま送りたい (改行を付けない) ケース:
|
|||||||
|
|
||||||
| Param | Required | Description |
|
| 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) を透過 |
|
| `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) |
|
| `wait_ms` | no | 送信後の screen_after 取得までの待ち時間 (default 500ms, max 5000ms) |
|
||||||
|
|
||||||
@ -113,7 +125,7 @@ Return:
|
|||||||
|
|
||||||
| Param | Required | Description |
|
| 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` — それ以前を含む過去の出力 |
|
| `kind` | no | `screen` (デフォルト) — 現在の表示画面 / `scrollback` — それ以前を含む過去の出力 |
|
||||||
| `max_bytes` | no | scrollback の上限 (default 8192, max 65536)。tail から `max_bytes` バイト返す |
|
| `max_bytes` | no | scrollback の上限 (default 8192, max 65536)。tail から `max_bytes` バイト返す |
|
||||||
|
|
||||||
@ -138,7 +150,7 @@ text は ANSI escape strip 済み (色 / cursor 移動シーケンスを除去)
|
|||||||
| Param | Required | Description |
|
| Param | Required | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `command` | yes | 実行するシェルコマンド |
|
| `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 されない |
|
| `timeout_ms` | no | タイムアウト (ms)。デフォルト 120000 (2分)、最大 600000 (10分)。タイムアウト時もコマンドは kill されない |
|
||||||
| `idle_ms` | no | 出力が `idle_ms` ms 途切れたら早期終了と判定する。0=無効 (デフォルト) |
|
| `idle_ms` | no | 出力が `idle_ms` ms 途切れたら早期終了と判定する。0=無効 (デフォルト) |
|
||||||
|
|
||||||
@ -176,12 +188,13 @@ Return:
|
|||||||
| `host_key_*` | UI (Settings → User Folder → SSH Connections) で TOFU 検証してから再試行 |
|
| `host_key_*` | UI (Settings → User Folder → SSH Connections) で TOFU 検証してから再試行 |
|
||||||
| `command_rejected (builtin_deny / custom_deny)` | deny-list で reject。admin に許可パターン追加を相談 (ローカルで回避してはいけない) |
|
| `command_rejected (builtin_deny / custom_deny)` | deny-list で reject。admin に許可パターン追加を相談 (ローカルで回避してはいけない) |
|
||||||
| `idle_timeout` / `duration_cap` | 古いセッションが閉じた。`SshConsoleEnsure` を再度呼んで開け直す |
|
| `idle_timeout` / `duration_cap` | 古いセッションが閉じた。`SshConsoleEnsure` を再度呼んで開け直す |
|
||||||
| `connection_change` | 同 task で `force_replace: true` 付き Ensure が呼ばれた → 古いセッションが閉じた |
|
| `connection_change` | **同じ** `connection_id` に `force_replace: true` 付き Ensure が呼ばれた → その接続のセッションだけ閉じて開き直した (別の接続を開いても発生しない) |
|
||||||
| `this task already has an active session on connection X (...)` | エラー文の中の **X が正しい id**。X を `connection_id` に使うか、Send/Snapshot で省略する。本当に切り替えたければ `force_replace: true` |
|
| `connection_id required (multiple sessions open: ...)` (ambiguous) | Send/Run/Snapshot で `connection_id` を省略したが、このタスクに複数セッションが同時に開いていて pointer も無い。エラー文中の一覧から狙った `connection_id` を明示する |
|
||||||
| `this task has an active session on connection X, not Y` | Send/Snapshot 側で id mismatch。X を使う or 省略する |
|
| `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'})` で停止 |
|
| `maintenance` | admin の対応を待つ。`complete({status: 'needs_user_input', missing_info: 'SSH maintenance window'})` で停止 |
|
||||||
| `not initialised` | `ssh.enabled` または `ssh.console.enabled` が false / `MCP_ENCRYPTION_KEY` 未設定。admin に依頼 |
|
| `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 の限界
|
## deny-list の限界
|
||||||
|
|
||||||
|
|||||||
@ -6,12 +6,12 @@
|
|||||||
|
|
||||||
| ツール | 用途 | 入力 |
|
| ツール | 用途 | 入力 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| `SshListConnections` | この movement で使える接続の UUID + label + host 一覧を取得 | (引数なし) |
|
| `SshListConnections` | このワークスペースで使える接続の UUID + label + host 一覧を取得 | (引数なし) |
|
||||||
| `SshExec` | リモートで shell 単一行を実行 | `connection_id`, `command`, (任意) `timeout_ms` |
|
| `SshExec` | リモートで shell 単一行を実行 | `connection_id`, `command`, (任意) `timeout_ms` |
|
||||||
| `SshUpload` | workspace → リモートへファイル転送 (SFTP) | `connection_id`, `local_path`, `remote_path`, (任意) `timeout_ms` |
|
| `SshUpload` | workspace → リモートへファイル転送 (SFTP) | `connection_id`, `local_path`, `remote_path`, (任意) `timeout_ms` |
|
||||||
| `SshDownload` | リモート → workspace へファイル取得 (SFTP) | `connection_id`, `remote_path`, `local_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 を取得すること。
|
タスク本文に `connection_id` が記されていないときは、まず `SshListConnections` を呼んで該当の host / label の UUID を取得すること。
|
||||||
|
|
||||||
@ -21,10 +21,10 @@
|
|||||||
|
|
||||||
1. **`ssh.enabled: true`** が `config.yaml` で設定されている
|
1. **`ssh.enabled: true`** が `config.yaml` で設定されている
|
||||||
2. **`MCP_ENCRYPTION_KEY`** 環境変数が 64 hex 文字 (= 32 バイト) で設定されている
|
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 する
|
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. **piece の現在 movement で `allowed_ssh_connections` に当該 UUID が明示**されている (またはワイルドカード `*`)。空配列 `[]` は「SSH 使用するが許可なし」の deny 宣言とみなされ全 UUID が reject される
|
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
|
## SshListConnections
|
||||||
|
|
||||||
@ -32,7 +32,7 @@
|
|||||||
SshListConnections({})
|
SshListConnections({})
|
||||||
```
|
```
|
||||||
|
|
||||||
引数なし。現在の movement の `allowed_ssh_connections` + ジョブ owner の access grant を満たす接続だけを返す (admin 無効化 / piece 除外 / grant 無しは filter out)。
|
引数なし。このワークスペースに登録された接続のうち、ジョブ owner の access grant を満たすものだけを返す (admin 無効化 / grant 無しは filter out)。
|
||||||
|
|
||||||
戻り値 (JSON 文字列):
|
戻り値 (JSON 文字列):
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ユーザーごとの常時指示書 `AGENTS.md` を読み書きするツール。`AGENTS.md` は各タスクのシステムプロンプトに自動注入される「このユーザーが常に守ってほしいこと」を書いた個人ファイル。memory(`UpdateUserMemory`)が事実の断片を貯めるのに対し、`AGENTS.md` は振る舞いの方針そのもの。
|
ユーザーごとの常時指示書 `AGENTS.md` を読み書きするツール。`AGENTS.md` は各タスクのシステムプロンプトに自動注入される「このユーザーが常に守ってほしいこと」を書いた個人ファイル。memory(`UpdateUserMemory`)が事実の断片を貯めるのに対し、`AGENTS.md` は振る舞いの方針そのもの。
|
||||||
|
|
||||||
両ツールは META_TOOL(常時利用可能)。piece の `allowed_tools` に書かなくても使える。per-user 機能なので、認証済みユーザーのコンテキスト(`ctx.userId`)が必要。
|
両ツールは META_TOOL(常時利用可能)。ワークスペースのツール設定に関係なく使える。per-user 機能なので、認証済みユーザーのコンテキスト(`ctx.userId`)が必要。
|
||||||
|
|
||||||
## ReadUserAgents
|
## ReadUserAgents
|
||||||
|
|
||||||
|
|||||||
@ -111,6 +111,6 @@ async function main({ context, params }) {
|
|||||||
|
|
||||||
## Notes
|
## 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.
|
- After writing, use `RunUserScript` to immediately execute and verify the macro.
|
||||||
- Use `ListUserAssets` to see all macros currently in the folder.
|
- Use `ListUserAssets` to see all macros currently in the folder.
|
||||||
|
|||||||
447
package-lock.json
generated
447
package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@a2a-js/sdk": "^0.3.13",
|
||||||
"@kenjiuno/msgreader": "^1.28.0",
|
"@kenjiuno/msgreader": "^1.28.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
@ -25,8 +26,10 @@
|
|||||||
"fast-xml-parser": "^5.4.2",
|
"fast-xml-parser": "^5.4.2",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"http-proxy": "^1.18.1",
|
"http-proxy": "^1.18.1",
|
||||||
|
"iconv-lite": "^0.4.24",
|
||||||
"linkedom": "^0.18.12",
|
"linkedom": "^0.18.12",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
|
"oidc-provider": "^9.8.6",
|
||||||
"p-queue": "^9.3.0",
|
"p-queue": "^9.3.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
@ -75,6 +78,47 @@
|
|||||||
"node": ">=22"
|
"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": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
"version": "5.1.11",
|
"version": "5.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||||
@ -937,6 +981,74 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/@mixmark-io/domino": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||||
@ -2920,6 +3032,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"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"
|
"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": {
|
"node_modules/deep-extend": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||||
@ -3126,6 +3257,12 @@
|
|||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@ -3415,6 +3552,18 @@
|
|||||||
"@types/estree": "^1.0.0"
|
"@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": {
|
"node_modules/etag": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
@ -4103,6 +4252,53 @@
|
|||||||
"node": ">=16"
|
"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": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@ -4370,6 +4566,18 @@
|
|||||||
"node": ">=v12.22.7"
|
"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": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
@ -4445,6 +4653,18 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"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": {
|
"node_modules/kind-of": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||||
@ -4454,6 +4674,119 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/lazystream": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
||||||
@ -5199,6 +5532,99 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@ -5711,6 +6137,18 @@
|
|||||||
"inherits": "~2.0.3"
|
"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": {
|
"node_modules/random-bytes": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||||
@ -6602,6 +7040,15 @@
|
|||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"optional": true
|
"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": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
|||||||
@ -29,6 +29,7 @@
|
|||||||
"vapid-rotate": "tsx scripts/vapid-rotate.ts"
|
"vapid-rotate": "tsx scripts/vapid-rotate.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@a2a-js/sdk": "^0.3.13",
|
||||||
"@kenjiuno/msgreader": "^1.28.0",
|
"@kenjiuno/msgreader": "^1.28.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
@ -45,8 +46,10 @@
|
|||||||
"fast-xml-parser": "^5.4.2",
|
"fast-xml-parser": "^5.4.2",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"http-proxy": "^1.18.1",
|
"http-proxy": "^1.18.1",
|
||||||
|
"iconv-lite": "^0.4.24",
|
||||||
"linkedom": "^0.18.12",
|
"linkedom": "^0.18.12",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
|
"oidc-provider": "^9.8.6",
|
||||||
"p-queue": "^9.3.0",
|
"p-queue": "^9.3.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
|||||||
@ -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`).
|
`/api/pieces` HTTP layer (`src/bridge/pieces-api.ts` `validatePiece`).
|
||||||
|
|
||||||
Field names are snake_case in the YAML; the engine maps them to
|
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
|
## 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` |
|
| `initial_movement` | string | yes | must reference a `movements[].name` |
|
||||||
| `triggers.keywords` | string[] | no | classifier hint only |
|
| `triggers.keywords` | string[] | no | classifier hint only |
|
||||||
| `required_mcp` | string[] | no | `[a-z0-9_-]{1,64}` server slugs |
|
| `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 |
|
| `model` | string | no | preferred LLM model |
|
||||||
| `movements` | Movement[] | yes | non-empty array |
|
| `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
|
## Movement
|
||||||
|
|
||||||
| Field | Type | Required | Notes |
|
| Field | Type | Required | Notes |
|
||||||
|-------|------|----------|-------|
|
|-------|------|----------|-------|
|
||||||
| `name` | string | yes | unique within the piece |
|
| `name` | string | yes | unique within the piece |
|
||||||
| `edit` | boolean | yes | when true, Write/Edit are exposed |
|
|
||||||
| `persona` | string | yes | system-prompt persona |
|
| `persona` | string | yes | system-prompt persona |
|
||||||
| `instruction` | string | yes | the movement's task description |
|
| `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_commands` | string[] | no | Bash command allowlist (overrides default) |
|
||||||
| `allowed_ssh_connections` | string[] | conditional | see below |
|
|
||||||
| `rules` | Rule[] | yes | transition rules; may be empty |
|
| `rules` | Rule[] | yes | transition rules; may be empty |
|
||||||
| `default_next` | string | no | engine-internal fallback (sentinel-friendly) |
|
| `default_next` | string | no | engine-internal fallback (sentinel-friendly) |
|
||||||
| `max_consecutive_revisits` | number | no | loop-detection threshold override |
|
| `max_consecutive_revisits` | number | no | loop-detection threshold override |
|
||||||
|
|
||||||
## `allowed_ssh_connections`
|
Tool availability, `Write`/`Edit` (edit) permission, and SSH connection scoping
|
||||||
|
are runtime values on the in-memory `Movement` (populated from the workspace
|
||||||
Per-movement SSH connection allowlist (Phase 4 of the SSH tool integration
|
policy in `prepareMovementContext`), not YAML fields.
|
||||||
|
|
||||||
| 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rule
|
## Rule
|
||||||
|
|
||||||
@ -112,8 +57,7 @@ movements:
|
|||||||
`COMPLETE` / `ABORT` / `ASK` — those are reachable only through the
|
`COMPLETE` / `ABORT` / `ASK` — those are reachable only through the
|
||||||
`complete` tool (status: `success` / `aborted` / `needs_user_input`).
|
`complete` tool (status: `success` / `aborted` / `needs_user_input`).
|
||||||
`default_next` does accept the terminal sentinels because it is an
|
`default_next` does accept the terminal sentinels because it is an
|
||||||
engine-internal fallback (context overflow, ASK limit, SpawnSubTask
|
engine-internal fallback (context overflow, ASK limit).
|
||||||
unavailable).
|
|
||||||
|
|
||||||
## Validation paths
|
## Validation paths
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,6 @@ triggers:
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: delegate_research
|
- name: delegate_research
|
||||||
edit: true
|
|
||||||
persona: facilitator
|
persona: facilitator
|
||||||
instruction: |
|
instruction: |
|
||||||
課題を複数の視点に分解し、各視点を delegate で1件ずつ直列に検討させ、
|
課題を複数の視点に分解し、各視点を delegate で1件ずつ直列に検討させ、
|
||||||
@ -46,14 +45,12 @@ movements:
|
|||||||
- リスクと緩和策
|
- リスクと緩和策
|
||||||
- 各視点の詳細へ相対リンク [perspective-{連番}](./research/perspective-{連番}.md) で繋ぐ
|
- 各視点の詳細へ相対リンク [perspective-{連番}](./research/perspective-{連番}.md) で繋ぐ
|
||||||
5. output/recommendation.md を書き終えたら verify へ遷移する
|
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
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: "output/recommendation.md に推奨方針をまとめた"
|
- condition: "output/recommendation.md に推奨方針をまとめた"
|
||||||
next: verify
|
next: verify
|
||||||
|
|
||||||
- name: verify
|
- name: verify
|
||||||
edit: false
|
|
||||||
persona: reviewer
|
persona: reviewer
|
||||||
instruction: |
|
instruction: |
|
||||||
output/ の成果物を確認する。
|
output/ の成果物を確認する。
|
||||||
@ -90,7 +87,6 @@ movements:
|
|||||||
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
||||||
- 修正必要: `transition({next_step: "delegate_research", summary: "差し戻し指摘"})` (上記形式で)
|
- 修正必要: `transition({next_step: "delegate_research", summary: "差し戻し指摘"})` (上記形式で)
|
||||||
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Glob, Grep]
|
|
||||||
# default_next is the engine-internal fallback (context overflow / ASK
|
# default_next is the engine-internal fallback (context overflow / ASK
|
||||||
# limit / SpawnSubTask unavailable). Not exposed to the LLM.
|
# limit / SpawnSubTask unavailable). Not exposed to the LLM.
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
|
|||||||
@ -9,7 +9,6 @@ initial_movement: respond
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: respond
|
- name: respond
|
||||||
edit: true
|
|
||||||
persona: assistant
|
persona: assistant
|
||||||
instruction: |
|
instruction: |
|
||||||
ユーザーの依頼に対して必要な調査・作業を行い、最終回答を返す。
|
ユーザーの依頼に対して必要な調査・作業を行い、最終回答を返す。
|
||||||
@ -72,7 +71,6 @@ movements:
|
|||||||
- `result` がそのままユーザーに表示される最終出力。途中のメモや作業ログは入れない
|
- `result` がそのままユーザーに表示される最終出力。途中のメモや作業ログは入れない
|
||||||
- **ユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})`
|
- **ユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})`
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})`
|
- **技術的失敗で打ち切り**: `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
|
# default_next is the engine-internal fallback for context overflow / ASK
|
||||||
# limit reached / SpawnSubTask unavailable. It is NOT exposed to the LLM.
|
# limit reached / SpawnSubTask unavailable. It is NOT exposed to the LLM.
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
|
|||||||
@ -10,7 +10,6 @@ initial_movement: process
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: process
|
- name: process
|
||||||
edit: true
|
|
||||||
persona: data-engineer
|
persona: data-engineer
|
||||||
instruction: |
|
instruction: |
|
||||||
## 最初のステップ: 入力データの把握
|
## 最初のステップ: 入力データの把握
|
||||||
@ -50,7 +49,7 @@ movements:
|
|||||||
## スキャン PDF / 画像データの場合
|
## スキャン PDF / 画像データの場合
|
||||||
|
|
||||||
レシートや帳票など画像由来のデータを扱う場合:
|
レシートや帳票など画像由来のデータを扱う場合:
|
||||||
- テキスト PDF → ReadPdf で直接読む
|
- テキスト PDF → Read で直接読む
|
||||||
- スキャン PDF / 画像 → PdfToImages でページ画像化し、ReadImage で内容を読み取る
|
- スキャン PDF / 画像 → PdfToImages でページ画像化し、ReadImage で内容を読み取る
|
||||||
|
|
||||||
## 実行
|
## 実行
|
||||||
@ -62,28 +61,24 @@ movements:
|
|||||||
- **次の report へ**: `transition({next_step: "report"})`
|
- **次の report へ**: `transition({next_step: "report"})`
|
||||||
- **処理対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **処理対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **データが壊れている / 読み取れない / エラー発生で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **データが壊れている / 読み取れない / エラー発生で打ち切り**: `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
|
default_next: report
|
||||||
rules:
|
rules:
|
||||||
- condition: output/ に結果を書き出した
|
- condition: output/ に結果を書き出した
|
||||||
next: report
|
next: report
|
||||||
|
|
||||||
- name: report
|
- name: report
|
||||||
edit: true
|
|
||||||
persona: reporter
|
persona: reporter
|
||||||
instruction: |
|
instruction: |
|
||||||
処理結果を元にレポートを作成し output/ にファイルとして書き出す。
|
処理結果を元にレポートを作成し output/ にファイルとして書き出す。
|
||||||
表を含め、分かりやすくまとめる。
|
表を含め、分かりやすくまとめる。
|
||||||
必ず Write ツールで output/ にレポートファイルを作成すること。
|
必ず Write ツールで output/ にレポートファイルを作成すること。
|
||||||
|
|
||||||
allowed_tools: [Read, Write, Bash, Glob, Grep, ReadImage, AnnotateImage, ReadPdf, ReadExcel, 'mcp__*']
|
|
||||||
default_next: verify
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: output/ にレポートを書き出した
|
- condition: output/ にレポートを書き出した
|
||||||
next: verify
|
next: verify
|
||||||
|
|
||||||
- name: verify
|
- name: verify
|
||||||
edit: false
|
|
||||||
persona: reviewer
|
persona: reviewer
|
||||||
instruction: |
|
instruction: |
|
||||||
output/ の成果物を確認する。
|
output/ の成果物を確認する。
|
||||||
@ -121,7 +116,6 @@ movements:
|
|||||||
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
||||||
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
|
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
|
||||||
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Glob, Grep, ReadPdf, ReadImage, AnnotateImage, ReadExcel]
|
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
rules:
|
rules:
|
||||||
- condition: output/ にファイルがない、または内容に不足・誤りがある
|
- condition: output/ にファイルがない、または内容に不足・誤りがある
|
||||||
|
|||||||
@ -9,7 +9,6 @@ initial_movement: execute
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: delegate_research
|
- name: delegate_research
|
||||||
edit: true
|
|
||||||
persona: orchestrator
|
persona: orchestrator
|
||||||
instruction: |
|
instruction: |
|
||||||
入力把握で立てた分割調査計画に従い、各テーマを delegate で1件ずつ直列に
|
入力把握で立てた分割調査計画に従い、各テーマを delegate で1件ずつ直列に
|
||||||
@ -37,14 +36,12 @@ movements:
|
|||||||
- 全体のまとめと結論を付ける
|
- 全体のまとめと結論を付ける
|
||||||
- 各テーマの詳細へ相対リンク [theme-{連番}](./research/theme-{連番}.md) で繋ぐ
|
- 各テーマの詳細へ相対リンク [theme-{連番}](./research/theme-{連番}.md) で繋ぐ
|
||||||
5. output/report.md を書き終えたら verify へ遷移する
|
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
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: output/report.md に統合レポートを作成した
|
- condition: output/report.md に統合レポートを作成した
|
||||||
next: verify
|
next: verify
|
||||||
|
|
||||||
- name: execute
|
- name: execute
|
||||||
edit: true
|
|
||||||
persona: worker
|
persona: worker
|
||||||
instruction: |
|
instruction: |
|
||||||
## 最初のステップ: 入力把握
|
## 最初のステップ: 入力把握
|
||||||
@ -111,7 +108,6 @@ movements:
|
|||||||
- **分割調査が効率的 → delegate_research へ**: `transition({next_step: "delegate_research"})`
|
- **分割調査が効率的 → delegate_research へ**: `transition({next_step: "delegate_research"})`
|
||||||
- **必須情報が不足し確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **必須情報が不足し確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **技術的失敗で打ち切り**: `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
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: 2つ以上の独立したテーマがあり、分割して delegate に任せるのが効率的と判断した
|
- condition: 2つ以上の独立したテーマがあり、分割して delegate に任せるのが効率的と判断した
|
||||||
@ -120,7 +116,6 @@ movements:
|
|||||||
next: verify
|
next: verify
|
||||||
|
|
||||||
- name: verify
|
- name: verify
|
||||||
edit: false
|
|
||||||
persona: reviewer
|
persona: reviewer
|
||||||
instruction: |
|
instruction: |
|
||||||
成果物を確認する。
|
成果物を確認する。
|
||||||
@ -167,7 +162,6 @@ movements:
|
|||||||
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
||||||
- 修正必要: `transition({next_step: "execute", summary: "差し戻し指摘"})` (上記形式で)
|
- 修正必要: `transition({next_step: "execute", summary: "差し戻し指摘"})` (上記形式で)
|
||||||
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
- 技術的失敗: `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
|
default_next: COMPLETE
|
||||||
rules:
|
rules:
|
||||||
- condition: 成果物がない、または内容に不足・誤りがある(追加質問への回答に検索根拠が不足している場合も含む)
|
- condition: 成果物がない、または内容に不足・誤りがある(追加質問への回答に検索根拠が不足している場合も含む)
|
||||||
|
|||||||
@ -19,7 +19,6 @@ triggers:
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: respond
|
- name: respond
|
||||||
edit: false
|
|
||||||
persona: MAESTRO のヘルプアシスタント
|
persona: MAESTRO のヘルプアシスタント
|
||||||
instruction: |
|
instruction: |
|
||||||
あなたは MAESTRO というエージェント実行プラットフォームの使い方や設計についてユーザーの質問に答えるアシスタントです。
|
あなたは MAESTRO というエージェント実行プラットフォームの使い方や設計についてユーザーの質問に答えるアシスタントです。
|
||||||
@ -83,14 +82,6 @@ movements:
|
|||||||
`aborted` は「聞いても解消しない」ときだけ。
|
`aborted` は「聞いても解消しない」ときだけ。
|
||||||
|
|
||||||
`complete({status: "aborted", abort_reason: "失敗の技術的理由"})`
|
`complete({status: "aborted", abort_reason: "失敗の技術的理由"})`
|
||||||
allowed_tools:
|
|
||||||
- Read
|
|
||||||
- Grep
|
|
||||||
- Glob
|
|
||||||
- WebSearch
|
|
||||||
- WebFetch
|
|
||||||
- ListPieces
|
|
||||||
- GetPiece
|
|
||||||
# META_TOOLS が自動追加: ReadToolDoc, CreateChecklist, CheckItem, GetChecklist,
|
# META_TOOLS が自動追加: ReadToolDoc, CreateChecklist, CheckItem, GetChecklist,
|
||||||
# MissionUpdate, ListUserAssets, RunUserScript, UpdateUserMemory, ReadUserMemory,
|
# MissionUpdate, ListUserAssets, RunUserScript, UpdateUserMemory, ReadUserMemory,
|
||||||
# ReadUserAgents, UpdateUserAgents, WriteUserScript,
|
# ReadUserAgents, UpdateUserAgents, WriteUserScript,
|
||||||
|
|||||||
@ -11,36 +11,35 @@ initial_movement: process
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: process
|
- name: process
|
||||||
edit: true
|
|
||||||
persona: document-specialist
|
persona: document-specialist
|
||||||
instruction: |
|
instruction: |
|
||||||
## 最初のステップ: ファイルの把握と前処理
|
## 最初のステップ: ファイルの把握と前処理
|
||||||
|
|
||||||
加工に着手する前に、まずファイルを確認し前処理を行う:
|
加工に着手する前に、まずファイルを確認し前処理を行う:
|
||||||
1. Glob でワークスペース全体のファイル一覧を確認する(`**/*.xlsx`, `**/*.docx`, `**/*.pptx`, `**/*.pdf`。input/ だけでなくルート直下も含む)
|
1. Glob でワークスペース全体のファイル一覧を確認する(`**/*.xlsx`, `**/*.docx`, `**/*.pptx`, `**/*.pdf`。input/ だけでなくルート直下も含む)
|
||||||
2. ファイル種別ごとの読み取り戦略は ReadToolDoc({ name: "ReadPdf" }) などで確認
|
2. 読み取りは種別を問わず Read(拡張子で Office/PDF/メールを自動判定)。詳細は ReadToolDoc({ name: "Read" }) で確認
|
||||||
|
|
||||||
## ファイルサイズに応じた前処理
|
## ファイルサイズに応じた前処理
|
||||||
|
|
||||||
**Excel (.xlsx)**:
|
**Excel (.xlsx)**:
|
||||||
- 小〜中規模 → ReadExcel で直接読む
|
- 小〜中規模 → Read で直接読む(sheet / range も指定可)
|
||||||
- 巨大・複数シート → SplitExcelSheets でシート別ファイル + manifest を生成し、必要なシートだけ Read する
|
- 巨大・複数シート → SplitExcelSheets でシート別ファイル + manifest を生成し、必要なシートだけ Read する
|
||||||
|
|
||||||
**Word (.docx)**:
|
**Word (.docx)**:
|
||||||
- 短〜中規模 → ReadDocx で直接読む
|
- 短〜中規模 → Read で直接読む
|
||||||
- 長文・章構成あり → SplitDocxSections で見出し単位に分割し、関連セクションだけ Read する
|
- 長文・章構成あり → SplitDocxSections で見出し単位に分割し、関連セクションだけ Read する
|
||||||
|
|
||||||
**PowerPoint (.pptx)**:
|
**PowerPoint (.pptx)**:
|
||||||
- ReadPPTX で各スライドのテキスト・表・スピーカーノートを取得
|
- Read で各スライドのテキスト・表・スピーカーノートを取得
|
||||||
|
|
||||||
**PDF**:
|
**PDF**:
|
||||||
- まず ReadPdf で読み取りを試みる
|
- まず Read で読み取りを試みる
|
||||||
- テキストが抽出できた場合 → そのまま加工に進む
|
- テキストが抽出できた場合 → そのまま加工に進む
|
||||||
- 全ページが空テキスト(スキャン PDF)の場合 → PdfToImages でページ画像化し、ReadImage で内容を確認する(ReadImage は VLM 対応 worker でのみ利用可能)
|
- 全ページが空テキスト(スキャン PDF)の場合 → PdfToImages でページ画像化し、ReadImage で内容を確認する(ReadImage は VLM 対応 worker でのみ利用可能)
|
||||||
|
|
||||||
**Outlook メール (.msg)**:
|
**Outlook メール (.msg)**:
|
||||||
- ReadMsg で件名・差出人・宛先・本文を取得。添付は input/ に保存される
|
- Read で件名・差出人・宛先・本文を取得。添付は input/ に保存される
|
||||||
- 保存された添付は ReadPdf / ReadExcel / ReadImage など種別ごとのツールで開く
|
- 保存された添付は Read(Office/PDF/テキスト)や ReadImage(画像)で開く
|
||||||
|
|
||||||
## Office ファイルの加工方針
|
## Office ファイルの加工方針
|
||||||
|
|
||||||
@ -67,7 +66,6 @@ movements:
|
|||||||
- **追加情報が必要で同じ process を続行**: `transition({next_step: "process", summary: "..."})`
|
- **追加情報が必要で同じ process を続行**: `transition({next_step: "process", summary: "..."})`
|
||||||
- **対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **読み取り不能・対応外フォーマット等の技術的失敗**: `complete({status: "aborted", abort_reason: "..."})`
|
- **読み取り不能・対応外フォーマット等の技術的失敗**: `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
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: output/ に成果物を書き出した(または既存ファイルを編集した)
|
- condition: output/ に成果物を書き出した(または既存ファイルを編集した)
|
||||||
@ -76,7 +74,6 @@ movements:
|
|||||||
next: process
|
next: process
|
||||||
|
|
||||||
- name: verify
|
- name: verify
|
||||||
edit: false
|
|
||||||
persona: reviewer
|
persona: reviewer
|
||||||
instruction: |
|
instruction: |
|
||||||
output/ の成果物を確認する。
|
output/ の成果物を確認する。
|
||||||
@ -84,7 +81,7 @@ movements:
|
|||||||
確認手順:
|
確認手順:
|
||||||
1. まず Glob で output/ 内のファイル一覧を確認する(既存 Office ファイルの編集の場合はそのファイルも対象)
|
1. まず Glob で output/ 内のファイル一覧を確認する(既存 Office ファイルの編集の場合はそのファイルも対象)
|
||||||
2. 成果物が1つもなければ「修正が必要」と判断し process に差し戻す
|
2. 成果物が1つもなければ「修正が必要」と判断し process に差し戻す
|
||||||
3. 成果物があれば適切なツール(ReadPdf / ReadExcel / ReadDocx / ReadPPTX / Read 等)で内容を確認し、指示通りか・品質は十分かをチェックする
|
3. 成果物があれば Read(テキスト / Office / PDF を拡張子で自動判定)や ReadImage で内容を確認し、指示通りか・品質は十分かをチェックする
|
||||||
4. 不足や誤りがあれば、`transition({next_step: "process", summary: ...})` で差し戻す。summary は次の形式で書く:
|
4. 不足や誤りがあれば、`transition({next_step: "process", summary: ...})` で差し戻す。summary は次の形式で書く:
|
||||||
[判定] needs_fix
|
[判定] needs_fix
|
||||||
## 問題点
|
## 問題点
|
||||||
@ -116,7 +113,6 @@ movements:
|
|||||||
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
||||||
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
|
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
|
||||||
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Glob, Grep, ReadPdf, ReadImage, ReadExcel, ReadDocx, ReadPPTX, ReadMsg, ReadToolDoc]
|
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
rules:
|
rules:
|
||||||
- condition: 成果物がない、または内容に不足・誤りがある
|
- condition: 成果物がない、または内容に不足・誤りがある
|
||||||
|
|||||||
@ -13,7 +13,6 @@ triggers:
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: design
|
- name: design
|
||||||
edit: false
|
|
||||||
persona: architect
|
persona: architect
|
||||||
instruction: |
|
instruction: |
|
||||||
## Piece 設計フェーズ
|
## Piece 設計フェーズ
|
||||||
@ -27,9 +26,11 @@ movements:
|
|||||||
4. 新規作成が正当化される場合にのみ、以下を整理する:
|
4. 新規作成が正当化される場合にのみ、以下を整理する:
|
||||||
- 目的(何を自動化するか)
|
- 目的(何を自動化するか)
|
||||||
- movement の構成とステップ間の遷移条件
|
- movement の構成とステップ間の遷移条件
|
||||||
- 各ステップで使うツール(`allowed_tools`)
|
|
||||||
- 入力と出力の形式
|
- 入力と出力の形式
|
||||||
|
|
||||||
|
ツールの利用可否は Piece では宣言しない。各ワークスペースの「設定 → ツール」
|
||||||
|
(workspace tool policy)で決まり、実行時に全 movement へ一律に適用される。
|
||||||
|
Piece は movement のフロー(persona / instruction / rules)だけを定義する。
|
||||||
ツールの詳細仕様は ReadToolDoc で確認できる(例: `ReadToolDoc({ name: "delegate" })`)。
|
ツールの詳細仕様は ReadToolDoc で確認できる(例: `ReadToolDoc({ name: "delegate" })`)。
|
||||||
|
|
||||||
### YAML 構造の制約
|
### YAML 構造の制約
|
||||||
@ -45,42 +46,39 @@ movements:
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: ステップ名
|
- name: ステップ名
|
||||||
edit: true/false # Write/Edit を許可するか
|
|
||||||
persona: 役割名
|
persona: 役割名
|
||||||
instruction: |
|
instruction: |
|
||||||
このステップで行うこと(WHAT を書く。HOW はツールドキュメントに委ねる)
|
このステップで行うこと(WHAT を書く。HOW はツールドキュメントに委ねる)
|
||||||
allowed_tools: [使用するツール]
|
|
||||||
default_next: 次のステップ名 or COMPLETE
|
default_next: 次のステップ名 or COMPLETE
|
||||||
rules:
|
rules:
|
||||||
- condition: 遷移条件の説明
|
- condition: 遷移条件の説明
|
||||||
next: 遷移先
|
next: 遷移先
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Piece にツール・編集可否(旧 `allowed_tools` / `edit`)や SSH 接続
|
||||||
|
(旧 `allowed_ssh_connections`)を書く必要はない。これらは「設定 → ツール / SSH」
|
||||||
|
で決まる。書いても無視される。
|
||||||
|
|
||||||
### Movement・Rules の設計指針
|
### Movement・Rules の設計指針
|
||||||
- `edit: true` にしないと Write/Edit が LLM に提示されない
|
|
||||||
- `allowed_tools` に載っていないツールは LLM に提示されない — 必要最小限に絞る
|
|
||||||
- `rules` に明示した遷移先のみ LLM が選択できる
|
- `rules` に明示した遷移先のみ LLM が選択できる
|
||||||
- `default_next` はコンテキスト上限到達・ASK 上限フォールバックなど機械的用途のみ(LLM の選択肢にならない)
|
- `default_next` はコンテキスト上限到達・ASK 上限フォールバックなど機械的用途のみ(LLM の選択肢にならない)
|
||||||
- verify movement を設けると品質チェックが可能
|
- verify movement を設けると品質チェックが可能
|
||||||
- ループ検出: 同じ movement への連続訪問が閾値超過で ABORT されるため、A→B→A の無限循環を避ける
|
- ループ検出: 同じ movement への連続訪問が閾値超過で ABORT されるため、A→B→A の無限循環を避ける
|
||||||
|
|
||||||
### Persona / Instruction / Allowed_tools の使い分け
|
### Persona / Instruction の使い分け
|
||||||
- `persona`: そのステップの役割(architect / builder / reviewer など)。LLM の振る舞いのトーンに影響
|
- `persona`: そのステップの役割(architect / builder / reviewer など)。LLM の振る舞いのトーンに影響
|
||||||
- `instruction`: WHAT を行うかの指示。具体的・明確に書く。ツールの使い方(HOW)は書かない
|
- `instruction`: WHAT を行うかの指示。具体的・明確に書く。ツールの使い方(HOW)は書かない
|
||||||
- `allowed_tools`: そのステップで実際に必要なツールのみを列挙
|
|
||||||
|
|
||||||
## 終了 / 遷移方法
|
## 終了 / 遷移方法
|
||||||
- **設計完了 → build へ**: `transition({next_step: "build", summary: "設計内容のサマリ"})`
|
- **設計完了 → build へ**: `transition({next_step: "build", summary: "設計内容のサマリ"})`
|
||||||
- **ユーザーに確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **ユーザーに確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [ListPieces, GetPiece, ReadToolDoc, Read, Glob, Grep, WebSearch, WebFetch]
|
|
||||||
default_next: build
|
default_next: build
|
||||||
rules:
|
rules:
|
||||||
- condition: 設計が完了した
|
- condition: 設計が完了した
|
||||||
next: build
|
next: build
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
edit: false
|
|
||||||
persona: builder
|
persona: builder
|
||||||
instruction: |
|
instruction: |
|
||||||
## Piece 構築フェーズ
|
## Piece 構築フェーズ
|
||||||
@ -96,7 +94,7 @@ movements:
|
|||||||
### 注意事項
|
### 注意事項
|
||||||
- name は英小文字・数字・ハイフンのみ
|
- name は英小文字・数字・ハイフンのみ
|
||||||
- instruction は WHAT を具体的に書く(曖昧な指示は避ける)
|
- instruction は WHAT を具体的に書く(曖昧な指示は避ける)
|
||||||
- allowed_tools には必要なツールを過不足なく列挙する
|
- ツールの可否は Piece に書かない(設定 → ツールで決まる)
|
||||||
- rules の condition は日本語で明確に書く
|
- rules の condition は日本語で明確に書く
|
||||||
- `general`、`chat` は削除不可だが更新は可能
|
- `general`、`chat` は削除不可だが更新は可能
|
||||||
|
|
||||||
@ -105,7 +103,6 @@ movements:
|
|||||||
- **設計レベルの見直しが必要 → design に戻る**: `transition({next_step: "design", summary: "..."})`
|
- **設計レベルの見直しが必要 → design に戻る**: `transition({next_step: "design", summary: "..."})`
|
||||||
- **ユーザーに確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **ユーザーに確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [ListPieces, GetPiece, CreatePiece, UpdatePiece, ReadToolDoc, Read, Glob, Grep]
|
|
||||||
default_next: verify
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: Piece の作成・更新が完了した
|
- condition: Piece の作成・更新が完了した
|
||||||
@ -114,7 +111,6 @@ movements:
|
|||||||
next: design
|
next: design
|
||||||
|
|
||||||
- name: verify
|
- name: verify
|
||||||
edit: false
|
|
||||||
persona: reviewer
|
persona: reviewer
|
||||||
instruction: |
|
instruction: |
|
||||||
## Piece 検証フェーズ
|
## Piece 検証フェーズ
|
||||||
@ -127,9 +123,7 @@ movements:
|
|||||||
- name が英小文字・数字・ハイフンのみか
|
- name が英小文字・数字・ハイフンのみか
|
||||||
- description が具体的で、LLM が分類に使えるレベルか
|
- description が具体的で、LLM が分類に使えるレベルか
|
||||||
- 各 movement の instruction が具体的で曖昧でないか
|
- 各 movement の instruction が具体的で曖昧でないか
|
||||||
- allowed_tools に必要なツールが過不足なく含まれているか
|
|
||||||
- rules に全ての遷移先が明示されているか(default_next だけに頼っていないか)
|
- rules に全ての遷移先が明示されているか(default_next だけに頼っていないか)
|
||||||
- edit: true/false が各 movement の用途に合っているか
|
|
||||||
- ループの可能性がないか(A→B→A が無限に繰り返される構造でないか)
|
- ループの可能性がないか(A→B→A が無限に繰り返される構造でないか)
|
||||||
3. 類似の既存 Piece があれば ListPieces + GetPiece で比較し、一貫性を確認する
|
3. 類似の既存 Piece があれば ListPieces + GetPiece で比較し、一貫性を確認する
|
||||||
|
|
||||||
@ -148,7 +142,6 @@ movements:
|
|||||||
- build に差し戻し: `transition({next_step: "build", summary: "指摘"})`
|
- build に差し戻し: `transition({next_step: "build", summary: "指摘"})`
|
||||||
- design に戻す: `transition({next_step: "design", summary: "..."})`
|
- design に戻す: `transition({next_step: "design", summary: "..."})`
|
||||||
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Glob, Grep, ListPieces, GetPiece, ReadToolDoc]
|
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
rules:
|
rules:
|
||||||
- condition: 修正が必要
|
- condition: 修正が必要
|
||||||
|
|||||||
@ -11,7 +11,6 @@ initial_movement: dig
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: delegate_research
|
- name: delegate_research
|
||||||
edit: true
|
|
||||||
persona: orchestrator
|
persona: orchestrator
|
||||||
instruction: |
|
instruction: |
|
||||||
dig で立てた並列調査計画に従い、各テーマを delegate で1件ずつ直列に深掘りさせ、
|
dig で立てた並列調査計画に従い、各テーマを delegate で1件ずつ直列に深掘りさせ、
|
||||||
@ -38,14 +37,12 @@ movements:
|
|||||||
- 全体のまとめと考察を付ける
|
- 全体のまとめと考察を付ける
|
||||||
- 各テーマの詳細へ相対リンク [theme-{連番}](./research/theme-{連番}.md) で繋ぐ
|
- 各テーマの詳細へ相対リンク [theme-{連番}](./research/theme-{連番}.md) で繋ぐ
|
||||||
5. output/report.md を書き終えたら verify へ遷移する
|
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
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: output/report.md に統合レポートを作成した
|
- condition: output/report.md に統合レポートを作成した
|
||||||
next: verify
|
next: verify
|
||||||
|
|
||||||
- name: dig
|
- name: dig
|
||||||
edit: true
|
|
||||||
persona: researcher
|
persona: researcher
|
||||||
instruction: |
|
instruction: |
|
||||||
## 最初のステップ: 入力把握と調査計画
|
## 最初のステップ: 入力把握と調査計画
|
||||||
@ -108,7 +105,6 @@ movements:
|
|||||||
- **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})`
|
- **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})`
|
||||||
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **技術的失敗で打ち切り**: `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
|
default_next: analyze
|
||||||
rules:
|
rules:
|
||||||
- condition: 2つ以上の独立した調査テーマがあり、分割して delegate に任せるのが効率的と判断した
|
- condition: 2つ以上の独立した調査テーマがあり、分割して delegate に任せるのが効率的と判断した
|
||||||
@ -119,7 +115,6 @@ movements:
|
|||||||
next: dig
|
next: dig
|
||||||
|
|
||||||
- name: analyze
|
- name: analyze
|
||||||
edit: true
|
|
||||||
persona: analyst
|
persona: analyst
|
||||||
instruction: |
|
instruction: |
|
||||||
収集した情報を分析し、調査レポートを output/ に作成する。
|
収集した情報を分析し、調査レポートを output/ に作成する。
|
||||||
@ -141,7 +136,6 @@ movements:
|
|||||||
画像があるのにテキストだけのレポートにしないこと。
|
画像があるのにテキストだけのレポートにしないこと。
|
||||||
レポート作成中に追加で必要な図・グラフを見つけた場合も DownloadFile で収集して埋め込む。
|
レポート作成中に追加で必要な図・グラフを見つけた場合も 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
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: output/ にレポートを書き出した
|
- condition: output/ にレポートを書き出した
|
||||||
@ -150,7 +144,6 @@ movements:
|
|||||||
next: dig
|
next: dig
|
||||||
|
|
||||||
- name: verify
|
- name: verify
|
||||||
edit: false
|
|
||||||
persona: reviewer
|
persona: reviewer
|
||||||
instruction: |
|
instruction: |
|
||||||
output/ のレポートを確認する。
|
output/ のレポートを確認する。
|
||||||
@ -194,7 +187,6 @@ movements:
|
|||||||
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
||||||
- 修正必要: `transition({next_step: "analyze", summary: "差し戻し指摘"})` (上記形式で)
|
- 修正必要: `transition({next_step: "analyze", summary: "差し戻し指摘"})` (上記形式で)
|
||||||
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
- 技術的失敗: `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
|
default_next: COMPLETE
|
||||||
rules:
|
rules:
|
||||||
- condition: output/ にファイルがない、または内容に不足がある(追加質問への回答に検索根拠が不足している場合も含む)
|
- condition: output/ にファイルがない、または内容に不足がある(追加質問への回答に検索根拠が不足している場合も含む)
|
||||||
|
|||||||
@ -24,7 +24,6 @@ initial_movement: process
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: process
|
- name: process
|
||||||
edit: true
|
|
||||||
persona: slide-designer
|
persona: slide-designer
|
||||||
instruction: |
|
instruction: |
|
||||||
## 最初のステップ: 入力把握と構成立案
|
## 最初のステップ: 入力把握と構成立案
|
||||||
@ -100,26 +99,6 @@ movements:
|
|||||||
- **追加情報が必要で同じ process を続行**: `transition({next_step: "process"})`
|
- **追加情報が必要で同じ process を続行**: `transition({next_step: "process"})`
|
||||||
- **題材・構成が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **題材・構成が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **技術的失敗 (pptxgenjs エラー等)**: `complete({status: "aborted", abort_reason: "..."})`
|
- **技術的失敗 (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
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: SetTheme + AddSlide × N + BuildPptx を実行し output/slides.pptx を生成済み
|
- condition: SetTheme + AddSlide × N + BuildPptx を実行し output/slides.pptx を生成済み
|
||||||
@ -128,7 +107,6 @@ movements:
|
|||||||
next: process
|
next: process
|
||||||
|
|
||||||
- name: verify
|
- name: verify
|
||||||
edit: false
|
|
||||||
persona: reviewer
|
persona: reviewer
|
||||||
instruction: |
|
instruction: |
|
||||||
output/ の成果物を確認する。
|
output/ の成果物を確認する。
|
||||||
@ -178,11 +156,6 @@ movements:
|
|||||||
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
||||||
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
|
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
|
||||||
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools:
|
|
||||||
- Read
|
|
||||||
- Glob
|
|
||||||
- Grep
|
|
||||||
- ReadToolDoc
|
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
rules:
|
rules:
|
||||||
- condition: 成果物が不足または内容に誤りがある
|
- condition: 成果物が不足または内容に誤りがある
|
||||||
|
|||||||
@ -13,7 +13,6 @@ initial_movement: orchestrate
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: orchestrate
|
- name: orchestrate
|
||||||
edit: true
|
|
||||||
persona: researcher
|
persona: researcher
|
||||||
instruction: |
|
instruction: |
|
||||||
## このタスクの進め方(オーケストレーター)
|
## このタスクの進め方(オーケストレーター)
|
||||||
@ -61,6 +60,5 @@ movements:
|
|||||||
### 原則
|
### 原則
|
||||||
- 1投稿につき delegate は1回。深掘りの実作業はサブに任せ、あなたは取得・選別・統合に徹する。
|
- 1投稿につき delegate は1回。深掘りの実作業はサブに任せ、あなたは取得・選別・統合に徹する。
|
||||||
- 取得できなかった事実は正直に書く。データを捏造しない。
|
- 取得できなかった事実は正直に書く。データを捏造しない。
|
||||||
allowed_tools: [XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, WebFetch, WebSearch, Read, Write, Edit, Glob, Grep, DownloadFile, delegate, 'mcp__*']
|
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
rules: []
|
rules: []
|
||||||
|
|||||||
@ -10,7 +10,6 @@ initial_movement: gather
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: gather
|
- name: gather
|
||||||
edit: true
|
|
||||||
persona: researcher
|
persona: researcher
|
||||||
instruction: |
|
instruction: |
|
||||||
## 調査計画
|
## 調査計画
|
||||||
@ -57,7 +56,6 @@ movements:
|
|||||||
- **追加収集のため同じ gather を続行**: `transition({next_step: "gather"})`
|
- **追加収集のため同じ gather を続行**: `transition({next_step: "gather"})`
|
||||||
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **技術的失敗で打ち切り**: `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
|
default_next: analyze
|
||||||
rules:
|
rules:
|
||||||
- condition: 追加収集が必要(別のSNS、追加クエリ等)
|
- condition: 追加収集が必要(別のSNS、追加クエリ等)
|
||||||
@ -66,7 +64,6 @@ movements:
|
|||||||
next: analyze
|
next: analyze
|
||||||
|
|
||||||
- name: analyze
|
- name: analyze
|
||||||
edit: true
|
|
||||||
persona: analyst
|
persona: analyst
|
||||||
instruction: |
|
instruction: |
|
||||||
output/raw/ の収集データを読み込み、分析してレポートを作成する。
|
output/raw/ の収集データを読み込み、分析してレポートを作成する。
|
||||||
@ -91,7 +88,6 @@ movements:
|
|||||||
情報が不足している場合は gather に戻る(追加の検索クエリを明示すること)。
|
情報が不足している場合は gather に戻る(追加の検索クエリを明示すること)。
|
||||||
verify からの差し戻しがある場合は、指摘された不足点・期待する修正を優先的に解消すること。
|
verify からの差し戻しがある場合は、指摘された不足点・期待する修正を優先的に解消すること。
|
||||||
|
|
||||||
allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, BatchReviewTextWithLLM, MergeReviewedResults, 'mcp__*']
|
|
||||||
default_next: verify
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: output/report.md にレポートを書き出した
|
- condition: output/report.md にレポートを書き出した
|
||||||
@ -100,7 +96,6 @@ movements:
|
|||||||
next: gather
|
next: gather
|
||||||
|
|
||||||
- name: verify
|
- name: verify
|
||||||
edit: false
|
|
||||||
persona: supervisor
|
persona: supervisor
|
||||||
instruction: |
|
instruction: |
|
||||||
output/ のレポートを確認する。
|
output/ のレポートを確認する。
|
||||||
@ -141,7 +136,6 @@ movements:
|
|||||||
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
||||||
- 修正必要: `transition({next_step: "analyze", summary: "差し戻し指摘"})` (上記形式で)
|
- 修正必要: `transition({next_step: "analyze", summary: "差し戻し指摘"})` (上記形式で)
|
||||||
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Glob, Grep]
|
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
rules:
|
rules:
|
||||||
- condition: output/ にファイルがない、または内容に不足がある
|
- condition: output/ にファイルがない、または内容に不足がある
|
||||||
|
|||||||
@ -19,7 +19,6 @@ initial_movement: interact
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: interact
|
- name: interact
|
||||||
edit: true
|
|
||||||
persona: ops-operator
|
persona: ops-operator
|
||||||
instruction: |
|
instruction: |
|
||||||
## 詳細ドキュメント (必ず最初に読む)
|
## 詳細ドキュメント (必ず最初に読む)
|
||||||
@ -80,8 +79,6 @@ movements:
|
|||||||
## 終了
|
## 終了
|
||||||
- 完了: complete({status: "success", result: "..."})
|
- 完了: complete({status: "success", result: "..."})
|
||||||
- 中断: complete({status: "aborted", abort_reason: "..."})
|
- 中断: complete({status: "aborted", abort_reason: "..."})
|
||||||
- 確認待ち: complete({status: "needs_user_input", missing_info: "..."})
|
- 確認待ち: complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})
|
||||||
allowed_tools: [SshConsoleEnsure, SshConsoleSend, SshConsoleRun, SshConsoleSnapshot, SshUpload, SshDownload, SshListConnections, WebSearch, WebFetch, DownloadFile, BrowseWeb, Read, Write, Bash, Glob, Grep]
|
|
||||||
allowed_ssh_connections: ['*']
|
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
rules: []
|
rules: []
|
||||||
|
|||||||
@ -21,7 +21,6 @@ initial_movement: execute
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: execute
|
- name: execute
|
||||||
edit: true
|
|
||||||
persona: ops-operator
|
persona: ops-operator
|
||||||
instruction: |
|
instruction: |
|
||||||
## 最初のステップ: タスク把握と接続の選定
|
## 最初のステップ: タスク把握と接続の選定
|
||||||
@ -32,7 +31,7 @@ movements:
|
|||||||
- Config push: ローカルで作成・編集した設定を SshUpload で配信 → SshExec でリロード
|
- Config push: ローカルで作成・編集した設定を SshUpload で配信 → SshExec でリロード
|
||||||
- Log fetch: SshDownload でリモートのログを取得 → ローカルで grep / 集計 / 分析
|
- Log fetch: SshDownload でリモートのログを取得 → ローカルで grep / 集計 / 分析
|
||||||
3. タスクで指定された SSH 接続 ID を確認する。指定が無く接続候補が複数ある場合は
|
3. タスクで指定された SSH 接続 ID を確認する。指定が無く接続候補が複数ある場合は
|
||||||
`complete({status: "needs_user_input", missing_info: "どの SSH 接続を使うか"})` で確認する
|
`complete({status: "needs_user_input", missing_info: "どの SSH 接続を使うか", why_no_default: "接続候補が複数あり、推測で選ぶと誤った host を操作する恐れがあるため"})` で確認する
|
||||||
|
|
||||||
## SshExec の使い方
|
## SshExec の使い方
|
||||||
|
|
||||||
@ -54,11 +53,11 @@ movements:
|
|||||||
## エラーハンドリング (詳細は docs/tools/ssh-tools.md の error code 表)
|
## エラーハンドリング (詳細は docs/tools/ssh-tools.md の error code 表)
|
||||||
|
|
||||||
- `host_key_not_verified`: TOFU 未完了。
|
- `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 疑い。**自動でリトライしない**。
|
- `host_key_mismatch`: MITM 疑い。**自動でリトライしない**。
|
||||||
`complete({status: "aborted", abort_reason: "host_key_mismatch: <details>"})` で停止する
|
`complete({status: "aborted", abort_reason: "host_key_mismatch: <details>"})` で停止する
|
||||||
- `abuse_locked`: 連続失敗で接続がロック。
|
- `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 に報告して停止する
|
- `no_grant` / `access_denied`: 権限不足。admin に grant 追加を依頼するよう user に報告して停止する
|
||||||
- `connect_timeout` / `auth_failed` 等の一時失敗: 同じ command を最大 2 回まで再試行。
|
- `connect_timeout` / `auth_failed` 等の一時失敗: 同じ command を最大 2 回まで再試行。
|
||||||
それ以上は `complete({status: "aborted", abort_reason: "..."})`
|
それ以上は `complete({status: "aborted", abort_reason: "..."})`
|
||||||
@ -85,15 +84,12 @@ movements:
|
|||||||
- **次の verify へ**: `transition({next_step: "verify"})`
|
- **次の verify へ**: `transition({next_step: "verify"})`
|
||||||
- **必要情報不足で停止**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **必要情報不足で停止**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **致命的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **致命的失敗で打ち切り**: `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
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: output/report.md に ops 結果をまとめた
|
- condition: output/report.md に ops 結果をまとめた
|
||||||
next: verify
|
next: verify
|
||||||
|
|
||||||
- name: verify
|
- name: verify
|
||||||
edit: false
|
|
||||||
persona: reviewer
|
persona: reviewer
|
||||||
instruction: |
|
instruction: |
|
||||||
ops 結果を確認する。
|
ops 結果を確認する。
|
||||||
@ -132,7 +128,6 @@ movements:
|
|||||||
- 修正必要: `transition({next_step: "execute", summary: "差し戻し指摘"})`
|
- 修正必要: `transition({next_step: "execute", summary: "差し戻し指摘"})`
|
||||||
- 機密漏れ: `complete({status: "aborted", abort_reason: "secret_leak: ..."})`
|
- 機密漏れ: `complete({status: "aborted", abort_reason: "secret_leak: ..."})`
|
||||||
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Glob, Grep]
|
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
rules:
|
rules:
|
||||||
- condition: ops 報告が不足している
|
- condition: ops 報告が不足している
|
||||||
|
|||||||
@ -14,7 +14,6 @@ triggers:
|
|||||||
|
|
||||||
movements:
|
movements:
|
||||||
- name: build
|
- name: build
|
||||||
edit: true
|
|
||||||
persona: builder
|
persona: builder
|
||||||
instruction: |
|
instruction: |
|
||||||
## ワークスペース・アプリ作成フェーズ
|
## ワークスペース・アプリ作成フェーズ
|
||||||
@ -42,14 +41,12 @@ movements:
|
|||||||
- **作成・更新できた → verify へ**: `transition({next_step: "verify", summary: "アプリ名と概要"})`
|
- **作成・更新できた → verify へ**: `transition({next_step: "verify", summary: "アプリ名と概要"})`
|
||||||
- **依頼が曖昧で作れない**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **依頼が曖昧で作れない**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Write, Edit, Glob, Grep, Bash, ReadToolDoc, ReadImage, TestWorkspaceApp]
|
|
||||||
default_next: verify
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: アプリの作成・更新が完了した
|
- condition: アプリの作成・更新が完了した
|
||||||
next: verify
|
next: verify
|
||||||
|
|
||||||
- name: verify
|
- name: verify
|
||||||
edit: true
|
|
||||||
persona: reviewer
|
persona: reviewer
|
||||||
instruction: |
|
instruction: |
|
||||||
## 検証フェーズ(E2E ゲート必須)
|
## 検証フェーズ(E2E ゲート必須)
|
||||||
@ -97,7 +94,6 @@ movements:
|
|||||||
(「作成しました」等のメタ説明ではなく、アプリの内容そのものを書く)。
|
(「作成しました」等のメタ説明ではなく、アプリの内容そのものを書く)。
|
||||||
- **E2E 不合格・設計上の問題 → build へ**: `transition({next_step: "build", summary: "具体的な失敗内容"})`
|
- **E2E 不合格・設計上の問題 → build へ**: `transition({next_step: "build", summary: "具体的な失敗内容"})`
|
||||||
- **技術的失敗で続行不能**: `complete({status: "aborted", abort_reason: "..."})`
|
- **技術的失敗で続行不能**: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Write, Edit, Glob, Grep, Bash, ReadToolDoc, TestWorkspaceApp]
|
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
rules:
|
rules:
|
||||||
- condition: 作り直しが必要(E2E 不合格または設計上の問題)
|
- condition: 作り直しが必要(E2E 不合格または設計上の問題)
|
||||||
|
|||||||
@ -14,7 +14,6 @@ max_movements: 999
|
|||||||
initial_movement: collect
|
initial_movement: collect
|
||||||
movements:
|
movements:
|
||||||
- name: collect
|
- name: collect
|
||||||
edit: true
|
|
||||||
persona: researcher
|
persona: researcher
|
||||||
instruction: |
|
instruction: |
|
||||||
X (Twitter) から AI 技術関連のツイートを収集し、深掘り調査を行う。
|
X (Twitter) から AI 技術関連のツイートを収集し、深掘り調査を行う。
|
||||||
@ -57,29 +56,11 @@ movements:
|
|||||||
- **次の compose へ**: `transition({next_step: "compose"})`
|
- **次の compose へ**: `transition({next_step: "compose"})`
|
||||||
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **技術的失敗で打ち切り**: `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
|
default_next: compose
|
||||||
rules:
|
rules:
|
||||||
- condition: 十分な情報を収集し output/raw/ に書き出した
|
- condition: 十分な情報を収集し output/raw/ に書き出した
|
||||||
next: compose
|
next: compose
|
||||||
- name: compose
|
- name: compose
|
||||||
edit: true
|
|
||||||
persona: writer
|
persona: writer
|
||||||
instruction: |
|
instruction: |
|
||||||
output/raw/ の収集データから、articles JSON とヘッドライン記事 Markdown を生成する。
|
output/raw/ の収集データから、articles JSON とヘッドライン記事 Markdown を生成する。
|
||||||
@ -117,14 +98,6 @@ movements:
|
|||||||
## verify 由来の指摘がある場合
|
## verify 由来の指摘がある場合
|
||||||
「これまでのレビュー指摘」がある場合は、指摘事項を漏れなく解消すること。
|
「これまでのレビュー指摘」がある場合は、指摘事項を漏れなく解消すること。
|
||||||
|
|
||||||
allowed_tools:
|
|
||||||
- Read
|
|
||||||
- Write
|
|
||||||
- Edit
|
|
||||||
- Glob
|
|
||||||
- Grep
|
|
||||||
- Bash
|
|
||||||
- 'mcp__*'
|
|
||||||
default_next: verify
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: 2ファイル(articles JSON + headline MD)を書き出した
|
- condition: 2ファイル(articles JSON + headline MD)を書き出した
|
||||||
@ -132,7 +105,6 @@ movements:
|
|||||||
- condition: 情報が不十分で追加収集が必要(output/raw/ が空を含む)
|
- condition: 情報が不十分で追加収集が必要(output/raw/ が空を含む)
|
||||||
next: collect
|
next: collect
|
||||||
- name: verify
|
- name: verify
|
||||||
edit: false
|
|
||||||
persona: supervisor
|
persona: supervisor
|
||||||
instruction: |
|
instruction: |
|
||||||
出力ファイルの存在とフォーマットを確認する。
|
出力ファイルの存在とフォーマットを確認する。
|
||||||
@ -183,10 +155,6 @@ movements:
|
|||||||
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
||||||
- 修正必要: `transition({next_step: "compose", summary: "差し戻し指摘"})` (上記形式で)
|
- 修正必要: `transition({next_step: "compose", summary: "差し戻し指摘"})` (上記形式で)
|
||||||
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools:
|
|
||||||
- Read
|
|
||||||
- Glob
|
|
||||||
- Grep
|
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
rules:
|
rules:
|
||||||
- condition: ファイルがない、またはフォーマットに不足がある
|
- condition: ファイルがない、またはフォーマットに不足がある
|
||||||
|
|||||||
33
scripts/gen-design-index.mjs
Normal file
33
scripts/gen-design-index.mjs
Normal 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
156
scripts/install-systemd.sh
Executable 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
|
||||||
136
scripts/lib/design-index.mjs
Normal file
136
scripts/lib/design-index.mjs
Normal 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;
|
||||||
|
}
|
||||||
167
scripts/lib/design-index.test.mjs
Normal file
167
scripts/lib/design-index.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
scripts/scaffold-manifest.mjs
Normal file
62
scripts/scaffold-manifest.mjs
Normal 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})`);
|
||||||
58
scripts/validate-design-docs.mjs
Normal file
58
scripts/validate-design-docs.mjs
Normal 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)`);
|
||||||
|
}
|
||||||
64
src/bridge/a2a/__e2e-helpers.ts
Normal file
64
src/bridge/a2a/__e2e-helpers.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* A2A OIDC e2e 共有ヘルパ。
|
||||||
|
*
|
||||||
|
* 実 oidc-provider を相手にした e2e(oidc-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/bridge/a2a/a2a-api.test.ts
Normal file
123
src/bridge/a2a/a2a-api.test.ts
Normal 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
162
src/bridge/a2a/a2a-api.ts
Normal 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;
|
||||||
|
}
|
||||||
391
src/bridge/a2a/a2a-async-e2e.test.ts
Normal file
391
src/bridge/a2a/a2a-async-e2e.test.ts
Normal 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-provider(Plan 1)+ consent(Plan 2A)+ DefaultRequestHandler(Plan 2B)+
|
||||||
|
* A2aTaskReconciler(Plan 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 は初期 Task(submitted)を
|
||||||
|
* publish した直後に永久に park する(=クライアント切断/executor 死亡の等価物)。
|
||||||
|
* これにより「reconciler だけが収束させる」ことを曖昧さなく実証できる。
|
||||||
|
* case 4(blocking: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);
|
||||||
|
});
|
||||||
236
src/bridge/a2a/a2a-card-e2e.test.ts
Normal file
236
src/bridge/a2a/a2a-card-e2e.test.ts
Normal 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 2A(inbound trust core + agent card)の統合 e2e。
|
||||||
|
*
|
||||||
|
* 同一 http server に「実 oidc provider + consent」と「A2A 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 user(extended 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 → consent(space/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-a(research)と space-b(analyze)が両方ユーザーから見えるが、
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
143
src/bridge/a2a/a2a-clients-admin-api.revocation.test.ts
Normal file
143
src/bridge/a2a/a2a-clients-admin-api.revocation.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
70
src/bridge/a2a/a2a-clients-admin-api.test.ts
Normal file
70
src/bridge/a2a/a2a-clients-admin-api.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
73
src/bridge/a2a/a2a-clients-admin-api.ts
Normal file
73
src/bridge/a2a/a2a-clients-admin-api.ts
Normal 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;
|
||||||
|
}
|
||||||
295
src/bridge/a2a/a2a-exec-e2e.test.ts
Normal file
295
src/bridge/a2a/a2a-exec-e2e.test.ts
Normal 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 2A(consent)+ Plan 2B(mountA2aRequestHandler)を
|
||||||
|
* 同一 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 → consent(space/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 result(error ではない)で、完了 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);
|
||||||
|
});
|
||||||
440
src/bridge/a2a/a2a-revocation-e2e.test.ts
Normal file
440
src/bridge/a2a/a2a-revocation-e2e.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
src/bridge/a2a/agent-card.test.ts
Normal file
30
src/bridge/a2a/agent-card.test.ts
Normal 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)
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/bridge/a2a/agent-card.ts
Normal file
55
src/bridge/a2a/agent-card.ts
Normal 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;
|
||||||
|
}
|
||||||
151
src/bridge/a2a/consent-api.test.ts
Normal file
151
src/bridge/a2a/consent-api.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
167
src/bridge/a2a/consent-api.ts
Normal file
167
src/bridge/a2a/consent-api.ts
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>`;
|
||||||
|
}
|
||||||
57
src/bridge/a2a/delegation.test.ts
Normal file
57
src/bridge/a2a/delegation.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
102
src/bridge/a2a/delegation.ts
Normal file
102
src/bridge/a2a/delegation.ts
Normal 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));
|
||||||
|
}
|
||||||
237
src/bridge/a2a/delegations-api.test.ts
Normal file
237
src/bridge/a2a/delegations-api.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
150
src/bridge/a2a/delegations-api.ts
Normal file
150
src/bridge/a2a/delegations-api.ts
Normal 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 → 200;not-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;
|
||||||
|
}
|
||||||
527
src/bridge/a2a/executor.test.ts
Normal file
527
src/bridge/a2a/executor.test.ts
Normal 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 は委任行の PK(a2aDelegationId に使用)
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 初期 Task(submitted)が先頭に 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 は委任行の PK(delegation.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');
|
||||||
|
|
||||||
|
// 最終 completed(final: 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 を直接 seed(execute 進行中と同状態)
|
||||||
|
(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
407
src/bridge/a2a/executor.ts
Normal 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;
|
||||||
|
/** ポーリング上限イテレーション数。デフォルト 600(1s×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 イベントを publish(submitted → 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 イベントを publish(terminal 手前まで)
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
89
src/bridge/a2a/oidc-adapter.test.ts
Normal file
89
src/bridge/a2a/oidc-adapter.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
116
src/bridge/a2a/oidc-adapter.ts
Normal file
116
src/bridge/a2a/oidc-adapter.ts
Normal 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 factory。model 名ごとに 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.exp(oidc が書く)で失効判定。期限切れは 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> {}
|
||||||
|
}
|
||||||
42
src/bridge/a2a/oidc-config.test.ts
Normal file
42
src/bridge/a2a/oidc-config.test.ts
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
82
src/bridge/a2a/oidc-config.ts
Normal file
82
src/bridge/a2a/oidc-config.ts
Normal 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 + PKCE(Task 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
199
src/bridge/a2a/oidc-e2e.test.ts
Normal file
199
src/bridge/a2a/oidc-e2e.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/bridge/a2a/oidc-keys.test.ts
Normal file
19
src/bridge/a2a/oidc-keys.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
25
src/bridge/a2a/oidc-keys.ts
Normal file
25
src/bridge/a2a/oidc-keys.ts
Normal 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
33
src/bridge/a2a/oidc-provider-shim.d.ts
vendored
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/bridge/a2a/oidc-provider.ts
Normal file
36
src/bridge/a2a/oidc-provider.ts
Normal 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');
|
||||||
|
}
|
||||||
100
src/bridge/a2a/reconciler-wiring.test.ts
Normal file
100
src/bridge/a2a/reconciler-wiring.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
438
src/bridge/a2a/reconciler.test.ts
Normal file
438
src/bridge/a2a/reconciler.test.ts
Normal file
@ -0,0 +1,438 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
/**
|
||||||
|
* A2aTaskReconciler のユニットテスト。
|
||||||
|
* TDD: fake repo + tmp workspace で動作を検証。real DB・real 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
243
src/bridge/a2a/reconciler.ts
Normal file
243
src/bridge/a2a/reconciler.ts
Normal 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 が undefined(row 消失)は失効扱い
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/bridge/a2a/request-handler-auth-gate.test.ts
Normal file
168
src/bridge/a2a/request-handler-auth-gate.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
154
src/bridge/a2a/request-handler.test.ts
Normal file
154
src/bridge/a2a/request-handler.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
136
src/bridge/a2a/request-handler.ts
Normal file
136
src/bridge/a2a/request-handler.ts
Normal 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 provider(DefaultRequestHandler 7th 引数)。
|
||||||
|
* ServerCallContext.user.a2aPrincipal が存在すれば scope 計算後の extended card を返す。
|
||||||
|
* principal がない(未認証)または scope が null(unknown/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 card(no 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 ブランケットに掛からない。
|
||||||
|
* 認証は makeA2aUserBuilder(UserBuilder)が担う。
|
||||||
|
*/
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
104
src/bridge/a2a/revoke.test.ts
Normal file
104
src/bridge/a2a/revoke.test.ts
Normal 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
94
src/bridge/a2a/revoke.ts
Normal 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);
|
||||||
|
|
||||||
|
// 5–6. 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
Loading…
x
Reference in New Issue
Block a user