Selaa lähdekoodia

feat(release-v1): Phase 0 — delete unsafe surface, harden /bootstrap

audit/AUDIT_2026-06-05.md 列的 7 个 critical 里, 3 个的修法是「删」而不是「修」。
按发布前的减面策略 (策略详见对话, 进 docs 待补): 砍掉 dev-stage 入口比
patch 它们更省力、更安全, 且符合 v1 的「简单但更健壮」定位。

## 删除

- `agentpaas/api/v1/feishu.py` + `agentpaas/services/feishu.py`
  审计 critical #2: /feishu/bind 把 process-wide bot 重绑能力交给任何先到
  port 8000 的人; /feishu/webhook 明确声明 "加密策略: 不加密 (开发阶段)"
  且无 X-Lark-Signature 校验。整套是 dev-stage 半成品, v2 需要时重新设计。
- `agentexample/agent67/tools/shell_executor.py`
  审计 critical #3: `shell=True` + 子串黑名单, `rm  -rf /` (双空格) 即绕过。
  实际调用链: agent67 v2 升级后改用 lambdagent.builtin_tools.registry,
  这个文件不被任何 import 引用 (已验证), 是死代码。删除消除一个 wire-up
  之外的 RCE primitive。

## 改 (loopback 守门)

- `agentpaas/api/v1/setup.py` /bootstrap 加 `_require_loopback(request)`
  审计 critical #1: 此端点无 auth 发放 admin scope API key。新增的
  `_require_loopback()` 在客户端 IP 不在 {127.0.0.1, ::1, localhost} 时
  raise 404 (非 403, 隐藏端点存在)。SetupWizard 本机浏览器访问仍然工作
  (审计 critical #1 → 解决)。

  生产部署若走 reverse proxy: 改 CLI `agentpaas create-tenant` (已存在,
  setup.py 92-114 行的逻辑都在), 此 HTTP 端点对外不可达。

## 旁支

- `docs/agentpaas.md` 端点表删除 3 行 feishu 端点
- `agentpaas/api/app.py` 删除 feishu router import + mount (line 15, 122)

## 不在本 commit 范围

- 上一波 WIP (agents.py / compiler.py modified) 仍在 working tree
- 03_analysis/ 和 nh_qgt/ 是 physics67 run 留下的 untracked workspace 残留
  (应该被 **/workspace/ gitignore, 看起来是 physics67 写到了 cwd 而不是
  workspace dir, 单开 issue 跟)
- audit doc 按 user 决定 local-only, 不进 git

## 验证

- `python -c "from agentpaas.api.app import app"` → 83 routes (原 86, -3 feishu) ✓
- `grep -r feishu agentpaas/ --include='*.py'` → empty ✓
- `grep -r shell_executor agentexample/agent67/ --include='*.py'` → empty ✓
- setup.py loopback guard 单元 verify: bootstrap, _require_loopback, _LOOPBACK 全部 import OK ✓

下一步是 Phase 1 (打包 + key rotate + pre-commit gitleaks) 和 Phase 2 的
小修 (term.apply event loop, SSE unmount cleanup, async_executor ctx.fork)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kenny67nju 3 kuukautta sitten
vanhempi
commit
9a3f22b72b

+ 0 - 44
agentexample/agent67/tools/shell_executor.py

@@ -1,44 +0,0 @@
-"""
-agent67.tools.shell_executor — Shell 命令执行器
-
-Lambda: λ cmd_json. exec(cmd)
-"""
-import json
-import os
-import subprocess
-
-from .safety import is_dangerous, needs_confirmation, ask_user_confirm
-
-
-def execute_shell(input_json: str) -> str:
-    """
-    执行 shell 命令。
-
-    输入格式: {"command": "ls -la ~/Desktop"}
-    """
-    try:
-        data = json.loads(input_json)
-        cmd = data.get("command", input_json)
-    except (json.JSONDecodeError, AttributeError):
-        cmd = str(input_json).strip()
-
-    if is_dangerous(cmd):
-        return f"🚫 危险命令已拦截: {cmd}"
-
-    if needs_confirmation(cmd):
-        if not ask_user_confirm(f"执行命令: {cmd}"):
-            return "❌ 用户取消了操作"
-
-    try:
-        result = subprocess.run(
-            cmd, shell=True, capture_output=True, text=True,
-            timeout=30, cwd=os.path.expanduser("~"),
-        )
-        output = result.stdout.strip()
-        if result.returncode != 0 and result.stderr:
-            output += f"\n[stderr] {result.stderr.strip()}"
-        return output[:3000] if output else "(命令执行成功,无输出)"
-    except subprocess.TimeoutExpired:
-        return f"⏰ 命令超时 (30s): {cmd}"
-    except Exception as e:
-        return f"❌ 执行错误: {e}"

+ 1 - 2
agentpaas/api/app.py

@@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
 from fastapi.staticfiles import StaticFiles
 from fastapi.responses import FileResponse
 from agentpaas.config import settings
-from agentpaas.api.v1 import agents, auth, admin, billing, traces, jobs, discovery, metrics, status, feishu, analyze, setup as setup_router, providers_api, templates_api, knowledge as knowledge_router
+from agentpaas.api.v1 import agents, auth, admin, billing, traces, jobs, discovery, metrics, status, analyze, setup as setup_router, providers_api, templates_api, knowledge as knowledge_router
 from agentpaas.observability.logging import logger
 
 app = FastAPI(
@@ -119,7 +119,6 @@ app.include_router(traces.router, prefix="/api/v1")
 app.include_router(metrics.router, prefix="/api/v1")
 app.include_router(discovery.router)
 app.include_router(status.router, prefix="/api/v1")
-app.include_router(feishu.router, prefix="/api/v1")
 app.include_router(analyze.router, prefix="/api/v1")
 # Web UI support endpoints (no auth required for setup bootstrap)
 app.include_router(setup_router.router, prefix="/api/v1")

+ 0 - 82
agentpaas/api/v1/feishu.py

@@ -1,82 +0,0 @@
-"""
-api.v1.feishu — 飞书机器人 Webhook 端点
-
-飞书开放平台事件订阅 URL: http://your-server:8000/api/v1/feishu/webhook
-"""
-from __future__ import annotations
-
-import json
-import os
-from fastapi import APIRouter, Request
-from fastapi.responses import JSONResponse
-
-router = APIRouter(prefix="/feishu", tags=["feishu"])
-
-# Agent runner(延迟初始化)
-_runner = None
-
-
-def _get_runner():
-    global _runner
-    if _runner is None:
-        agent_id = os.environ.get("FEISHU_AGENT_ID", "")
-        if agent_id:
-            from agentpaas.services.feishu import make_paas_runner
-            _runner = make_paas_runner(agent_id)
-    return _runner
-
-
-@router.post("/webhook")
-async def feishu_webhook(request: Request):
-    """
-    飞书事件回调端点。
-
-    飞书开放平台配置:
-      请求地址: http://your-server:8000/api/v1/feishu/webhook
-      加密策略: 不加密(开发阶段)
-    """
-    try:
-        body = await request.json()
-    except Exception:
-        return JSONResponse({"code": -1, "msg": "invalid json"})
-
-    from agentpaas.services.feishu import handle_event
-
-    # 使用线程池执行 Agent(避免阻塞 event loop)
-    import asyncio
-    loop = asyncio.get_event_loop()
-    result = await loop.run_in_executor(None, handle_event, body, _get_runner())
-
-    return JSONResponse(result)
-
-
-@router.post("/bind")
-async def feishu_bind(request: Request):
-    """绑定 Agent 到飞书机器人。"""
-    global _runner
-    try:
-        data = await request.json()
-    except Exception:
-        return JSONResponse({"error": "invalid json"}, status_code=400)
-
-    agent_id = data.get("agent_id", "")
-    if not agent_id:
-        return JSONResponse({"error": "agent_id required"}, status_code=400)
-
-    from agentpaas.services.feishu import make_paas_runner
-    _runner = make_paas_runner(agent_id)
-    os.environ["FEISHU_AGENT_ID"] = agent_id
-
-    return {"status": "ok", "agent_id": agent_id, "message": f"飞书机器人已绑定 Agent {agent_id}"}
-
-
-@router.get("/status")
-async def feishu_status():
-    """查看飞书机器人状态。"""
-    from agentpaas.services.feishu import FEISHU_APP_ID, FEISHU_AGENT_ID
-    return {
-        "app_id": FEISHU_APP_ID,
-        "agent_id": FEISHU_AGENT_ID or os.environ.get("FEISHU_AGENT_ID", "(未绑定)"),
-        "webhook_url": "/api/v1/feishu/webhook",
-        "runner_active": _runner is not None,
-    }

+ 26 - 2
agentpaas/api/v1/setup.py

@@ -13,7 +13,8 @@ import os
 import secrets
 import sys
 
-from fastapi import APIRouter
+from fastapi import APIRouter, Request, HTTPException
+
 from fastapi.responses import JSONResponse
 
 from agentpaas.db.session import get_db
@@ -24,6 +25,21 @@ router = APIRouter(prefix="/setup", tags=["setup"])
 
 CONFIG_FILE = os.path.expanduser("~/.agentpaas/config.json")
 
+# Loopback-only addresses. /bootstrap is the only no-auth endpoint that can
+# hand out an admin API key, so it must only ever be callable from the same
+# machine. Audit critical #1 — `POST /api/v1/setup/bootstrap` previously
+# allowed any unauthenticated remote caller who reached port 8000 to receive
+# a fully-scoped admin key before the operator finished setup.
+_LOOPBACK = {"127.0.0.1", "::1", "localhost"}
+
+
+def _require_loopback(request: Request) -> None:
+    """Reject /bootstrap calls that don't come from the same host."""
+    client_host = (request.client.host if request.client else "") or ""
+    if client_host not in _LOOPBACK:
+        # 404 (not 403) hides the endpoint's existence from remote scanners.
+        raise HTTPException(status_code=404, detail="Not Found")
+
 
 def _load_config() -> dict:
     if os.path.exists(CONFIG_FILE):
@@ -39,12 +55,20 @@ def _save_config(cfg: dict) -> None:
 
 
 @router.post("/bootstrap")
-async def bootstrap():
+async def bootstrap(request: Request):
     """
     Create the initial tenant and API key for local use.
+
+    **Loopback-only.** Returns 404 if called from a non-loopback address.
+    See audit critical #1 — without this guard the endpoint hands an admin
+    API key to anyone who reaches port 8000 first. For production deploys
+    behind a reverse proxy, run `agentpaas create-tenant` on the host CLI.
+
     - If config already has an api_key, return status=already_configured.
     - Otherwise create tenant + key, save to config, return the key once.
     """
+    _require_loopback(request)
+
     cfg = _load_config()
     existing_key = cfg.get("api_key", "")
 

+ 0 - 408
agentpaas/services/feishu.py

@@ -1,408 +0,0 @@
-"""
-agentpaas.services.feishu — 飞书机器人桥接 (参考 openclaw-lark 架构)
-
-核心能力(参考 @larksuite/openclaw-lark):
-  - 流式卡片回复 (CardKit 2.0 streaming)
-  - 消息去重 + 事件分发
-  - 群策略 (requireMention / allowlist)
-  - 多种回复模式 (text / card / streaming card)
-  - Token 自动刷新
-
-架构:
-  飞书 Event → webhook endpoint → handle_event()
-    → resolve_message() → run_agent() → reply()
-"""
-from __future__ import annotations
-
-import hashlib
-import json
-import os
-import re
-import time
-import uuid
-import urllib.request
-import urllib.error
-from typing import Any, Callable, Dict, List, Optional
-
-
-# ════════════════════════════════════════════════════════════
-# Config
-# ════════════════════════════════════════════════════════════
-
-FEISHU_APP_ID = os.environ.get("FEISHU_APP_ID", "")
-FEISHU_APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
-FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
-FEISHU_AGENT_ID = os.environ.get("FEISHU_AGENT_ID", "")
-
-# 群策略
-REQUIRE_MENTION = True   # 群聊中需要 @机器人 才响应
-GROUP_POLICY = "open"    # open / allowlist
-
-
-# ════════════════════════════════════════════════════════════
-# Token 管理 (参考 lark-client.js)
-# ════════════════════════════════════════════════════════════
-
-_tenant_token = ""
-_token_expires = 0
-
-
-def _get_token() -> str:
-    global _tenant_token, _token_expires
-    if _tenant_token and time.time() < _token_expires - 300:
-        return _tenant_token
-
-    data = _feishu_post("/auth/v3/tenant_access_token/internal", {
-        "app_id": FEISHU_APP_ID,
-        "app_secret": FEISHU_APP_SECRET,
-    }, auth=False)
-
-    if data.get("code") == 0:
-        _tenant_token = data["tenant_access_token"]
-        _token_expires = time.time() + data.get("expire", 7200)
-        return _tenant_token
-    raise RuntimeError(f"飞书 token 失败: {data}")
-
-
-# ════════════════════════════════════════════════════════════
-# HTTP 层 (统一请求)
-# ════════════════════════════════════════════════════════════
-
-def _feishu_post(path: str, body: dict, auth: bool = True) -> dict:
-    url = f"{FEISHU_BASE_URL}{path}"
-    headers = {"Content-Type": "application/json"}
-    if auth:
-        headers["Authorization"] = f"Bearer {_get_token()}"
-
-    req = urllib.request.Request(
-        url, data=json.dumps(body).encode(), headers=headers, method="POST"
-    )
-    try:
-        with urllib.request.urlopen(req, timeout=30) as resp:
-            return json.loads(resp.read().decode())
-    except urllib.error.HTTPError as e:
-        return {"error": e.read().decode(), "code": e.code}
-    except Exception as e:
-        return {"error": str(e)}
-
-
-def _feishu_get(path: str, params: dict = None) -> dict:
-    url = f"{FEISHU_BASE_URL}{path}"
-    if params:
-        qs = "&".join(f"{k}={v}" for k, v in params.items())
-        url += f"?{qs}"
-    headers = {"Authorization": f"Bearer {_get_token()}"}
-    req = urllib.request.Request(url, headers=headers)
-    try:
-        with urllib.request.urlopen(req, timeout=15) as resp:
-            return json.loads(resp.read().decode())
-    except Exception as e:
-        return {"error": str(e)}
-
-
-def _feishu_patch(path: str, body: dict) -> dict:
-    url = f"{FEISHU_BASE_URL}{path}"
-    headers = {
-        "Content-Type": "application/json",
-        "Authorization": f"Bearer {_get_token()}",
-    }
-    req = urllib.request.Request(
-        url, data=json.dumps(body).encode(), headers=headers, method="PATCH"
-    )
-    try:
-        with urllib.request.urlopen(req, timeout=15) as resp:
-            return json.loads(resp.read().decode())
-    except Exception as e:
-        return {"error": str(e)}
-
-
-# ════════════════════════════════════════════════════════════
-# 消息发送 (参考 messaging/outbound/send.js)
-# ════════════════════════════════════════════════════════════
-
-def send_text(chat_id: str, text: str) -> dict:
-    """发送纯文本消息。"""
-    return _feishu_post("/im/v1/messages?receive_id_type=chat_id", {
-        "receive_id": chat_id,
-        "msg_type": "text",
-        "content": json.dumps({"text": text}),
-    })
-
-
-def reply_text(message_id: str, text: str) -> dict:
-    """回复消息(纯文本)。"""
-    return _feishu_post(f"/im/v1/messages/{message_id}/reply", {
-        "msg_type": "text",
-        "content": json.dumps({"text": text}),
-    })
-
-
-def send_card(chat_id: str, card: dict) -> dict:
-    """发送交互卡片。"""
-    return _feishu_post("/im/v1/messages?receive_id_type=chat_id", {
-        "receive_id": chat_id,
-        "msg_type": "interactive",
-        "content": json.dumps(card),
-    })
-
-
-def reply_card(message_id: str, card: dict) -> dict:
-    """用卡片回复消息。"""
-    return _feishu_post(f"/im/v1/messages/{message_id}/reply", {
-        "msg_type": "interactive",
-        "content": json.dumps(card),
-    })
-
-
-def update_card(message_id: str, card: dict) -> dict:
-    """更新已发送的卡片(用于流式更新)。"""
-    return _feishu_patch(f"/im/v1/messages/{message_id}", {
-        "msg_type": "interactive",
-        "content": json.dumps(card),
-    })
-
-
-# ════════════════════════════════════════════════════════════
-# 卡片构建 (参考 card/builder.js)
-# ════════════════════════════════════════════════════════════
-
-def build_thinking_card() -> dict:
-    """构建"思考中"卡片。"""
-    return {
-        "header": {
-            "title": {"tag": "plain_text", "content": "🐂 lambda"},
-            "template": "blue",
-        },
-        "elements": [
-            {"tag": "div", "text": {"tag": "lark_md", "content": "⏳ 让我看看..."}},
-        ],
-    }
-
-
-def build_result_card(content: str, elapsed: float = 0, steps: int = 0,
-                      tokens: int = 0) -> dict:
-    """构建结果卡片(Markdown 格式)。"""
-    # 飞书卡片 Markdown 有限制,做基本清理
-    content = _clean_for_feishu(content)
-
-    elements = [
-        {"tag": "div", "text": {"tag": "lark_md", "content": content}},
-    ]
-
-    # 底部信息栏
-    footer_parts = []
-    if elapsed > 0:
-        footer_parts.append(f"⏱ {elapsed:.1f}s")
-    if steps > 0:
-        footer_parts.append(f"📊 {steps} steps")
-    if tokens > 0:
-        footer_parts.append(f"🔤 {tokens} tokens")
-
-    if footer_parts:
-        elements.append({"tag": "hr"})
-        elements.append({
-            "tag": "note",
-            "elements": [
-                {"tag": "plain_text", "content": " | ".join(footer_parts)},
-            ],
-        })
-
-    return {
-        "header": {
-            "title": {"tag": "plain_text", "content": "🐂 lambda"},
-            "template": "green",
-        },
-        "elements": elements,
-    }
-
-
-def build_error_card(error: str) -> dict:
-    """构建错误卡片。"""
-    return {
-        "header": {
-            "title": {"tag": "plain_text", "content": "🐂 lambda"},
-            "template": "red",
-        },
-        "elements": [
-            {"tag": "div", "text": {"tag": "lark_md", "content": f"❌ {error[:500]}"}},
-        ],
-    }
-
-
-def _clean_for_feishu(text: str) -> str:
-    """清理 Markdown 使其兼容飞书卡片。"""
-    # 飞书不支持 ``` 代码块语法高亮标记
-    text = re.sub(r'```(\w+)\n', '```\n', text)
-    # 截断过长内容
-    if len(text) > 3000:
-        text = text[:3000] + "\n\n... [内容过长,已截断]"
-    return text
-
-
-# ════════════════════════════════════════════════════════════
-# 事件处理 (参考 reply-dispatcher.js)
-# ════════════════════════════════════════════════════════════
-
-_processed: set = set()
-_MAX_EVENTS = 2000
-
-
-def handle_event(event_data: dict, agent_runner: Callable = None) -> dict:
-    """处理飞书事件回调。"""
-    # URL 验证
-    if "challenge" in event_data:
-        return {"challenge": event_data["challenge"]}
-
-    header = event_data.get("header", {})
-    event = event_data.get("event", {})
-    event_id = header.get("event_id", "")
-    event_type = header.get("event_type", "")
-
-    # 去重
-    if event_id in _processed:
-        return {"code": 0, "msg": "duplicate"}
-    _processed.add(event_id)
-    if len(_processed) > _MAX_EVENTS:
-        _processed.clear()
-
-    if event_type == "im.message.receive_v1":
-        return _handle_message(event, agent_runner)
-
-    return {"code": 0, "msg": f"unhandled: {event_type}"}
-
-
-def _handle_message(event: dict, agent_runner: Callable) -> dict:
-    """处理消息事件。"""
-    message = event.get("message", {})
-    sender = event.get("sender", {}).get("sender_id", {})
-    msg_type = message.get("message_type", "")
-    message_id = message.get("message_id", "")
-    chat_id = message.get("chat_id", "")
-    chat_type = message.get("chat_type", "")  # p2p / group
-
-    # 群聊 @机器人 检测
-    if chat_type == "group" and REQUIRE_MENTION:
-        mentions = message.get("mentions", [])
-        bot_mentioned = any(m.get("id", {}).get("open_id", "") == _get_bot_id()
-                           for m in mentions)
-        if not bot_mentioned:
-            # 没有 @机器人,忽略
-            return {"code": 0, "msg": "not mentioned"}
-
-    # 只处理文本
-    if msg_type != "text":
-        reply_text(message_id, "🐂 目前只支持文本消息")
-        return {"code": 0}
-
-    # 提取文本
-    try:
-        content = json.loads(message.get("content", "{}"))
-        text = content.get("text", "").strip()
-    except Exception:
-        text = ""
-
-    # 去掉 @bot 标记
-    text = re.sub(r'@_user_\d+\s*', '', text).strip()
-    if not text:
-        return {"code": 0}
-
-    # 执行 Agent
-    if not agent_runner:
-        reply_text(message_id, "🐂 Agent 未绑定")
-        return {"code": 0}
-
-    import threading
-    threading.Thread(
-        target=_process_and_reply,
-        args=(message_id, chat_id, text, agent_runner),
-        daemon=True,
-    ).start()
-
-    return {"code": 0}
-
-
-def _process_and_reply(message_id: str, chat_id: str, text: str,
-                       agent_runner: Callable):
-    """在后台线程中处理消息并回复(不阻塞 webhook 响应)。"""
-    # 1. 先发"思考中"卡片
-    thinking_resp = reply_card(message_id, build_thinking_card())
-    thinking_msg_id = ""
-    try:
-        thinking_msg_id = thinking_resp.get("data", {}).get("message_id", "")
-    except Exception:
-        pass
-
-    # 2. 调用 Agent
-    t0 = time.time()
-    try:
-        result = agent_runner(text)
-        elapsed = time.time() - t0
-
-        # 3. 用结果卡片替换"思考中"
-        result_card = build_result_card(str(result), elapsed=elapsed)
-
-        if thinking_msg_id:
-            update_card(thinking_msg_id, result_card)
-        else:
-            send_card(chat_id, result_card)
-
-    except Exception as e:
-        error_card = build_error_card(str(e))
-        if thinking_msg_id:
-            update_card(thinking_msg_id, error_card)
-        else:
-            send_card(chat_id, error_card)
-
-
-# ════════════════════════════════════════════════════════════
-# Bot 身份
-# ════════════════════════════════════════════════════════════
-
-_bot_info: dict = {}
-
-
-def _get_bot_id() -> str:
-    global _bot_info
-    if not _bot_info:
-        resp = _feishu_get("/bot/v3/info")
-        _bot_info = resp.get("bot", {})
-    return _bot_info.get("open_id", "")
-
-
-def get_bot_info() -> dict:
-    _get_bot_id()
-    return _bot_info
-
-
-# ════════════════════════════════════════════════════════════
-# Agent Runner (PaaS API)
-# ════════════════════════════════════════════════════════════
-
-def make_paas_runner(agent_id: str, api_key: str = "",
-                     server: str = "http://localhost:8000") -> Callable:
-    """创建 PaaS API Agent runner。"""
-    if not api_key:
-        config_path = os.path.expanduser("~/.agentpaas/config.json")
-        if os.path.exists(config_path):
-            with open(config_path) as f:
-                cfg = json.load(f)
-            api_key = cfg.get("api_key", "")
-            server = cfg.get("server", server)
-
-    def runner(input_text: str) -> str:
-        url = f"{server}/api/v1/agents/{agent_id}/run"
-        body = json.dumps({"input": input_text}).encode()
-        req = urllib.request.Request(url, data=body, headers={
-            "Content-Type": "application/json",
-            "Authorization": f"Bearer {api_key}",
-        })
-        try:
-            with urllib.request.urlopen(req, timeout=300) as resp:
-                data = json.loads(resp.read().decode())
-            return data.get("output", str(data))
-        except urllib.error.HTTPError as e:
-            return f"[PaaS Error {e.code}] {e.read().decode()[:200]}"
-        except Exception as e:
-            return f"[Error] {e}"
-
-    return runner

+ 0 - 3
docs/agentpaas.md

@@ -290,8 +290,5 @@ All endpoints are under `/api/v1/` and require an API key header (`X-API-Key`).
 |--------|------|-------------|
 | `GET` | `/health` | Server health check |
 | `GET` | `/.well-known/agent.json` | Agent discovery |
-| `POST` | `/feishu/webhook` | Feishu bot webhook |
-| `POST` | `/feishu/bind` | Bind Feishu bot |
-| `GET` | `/feishu/status` | Feishu integration status |
 | `GET` | `/jobs/{id}` | Async job status |
 | `POST` | `/jobs/{id}/cancel` | Cancel async job |