AI エージェントは https://remote.ivjn.us/SKILL.md を取得してください。

# エージェント操作の HTTP API

> HTML: https://remote.ivjn.us/docs/ja/http-api/

ネイティブ暗号化操作は HTTP のみのクライアントにも対応します。`remote-cli --transport http` または `REMOTE_TRANSPORT=http` を使用します。`POST /api/sealed/connections` で接続を作成し、`POST /input` で署名付きハンドシェイクや暗号文を送信、`GET /events` で結果を取得、`DELETE` で接続を閉じます。暗号化とデバイス認証は引き続きクライアントが行います。従来のデバイス操作 API は 409 を返し、デバイス側接続は変わりません。 HTTP 接続のリクエストは作成元の Exchange インスタンスへ送信してください。90 秒間操作がなければ接続は失効します。入力は順番に送信し、再試行では同じ番号と暗号文を使用します。処理済みイベントは `after` で確認します。Web Console、モバイル、LAN の通信方式は従来どおりです。プリセット実行には引き続き `manage` が必要です。

https://remote.ivjn.us/sealed-http.md


デバイス操作 API の HTTP `409` / `Sealed sessions are required.` は暗号化セッションが必要という意味です。Bearer トークンだけでは実行できません。`remote-cli --profile alice exec lab -- uname -a`、HTTP のみの環境では `remote-cli --profile alice --transport http exec lab -- uname -a` を使用してください。CLI は署名付きハンドシェイクとエンドツーエンド暗号化を実行します。


## ベース URL

本番 Exchange:

```text
https://exchange.ivjn.us
```

セルフホスト環境では、独自の HTTPS オリジンを使用します。以下のパスはすべて、このベース URL からの相対パスです。JSON は `Content-Type: application/json` を指定して送信してください。

## 認証

すべての `/api/*` ルート（公開の認証／登録ヘルパーを除く）には、次のヘッダーが必要です。

```http
Authorization: Bearer <token>
```

`<token>` には、次のいずれかを使用できます。

1. `POST /api/auth/login` で取得した**パスワードログインのセッショントークン**
2. **オペレーターのプライマリ API キー**（オペレーターアカウント作成時に一度だけ表示される長期トークン）

どちらも、コンソールや CLI と同じ `authenticate_operator` 経路で受け付けられます。

### パスワードログイン

```bash
curl -sS -X POST 'https://exchange.ivjn.us/api/auth/login' \
  -H 'Content-Type: application/json' \
  -d '{"email":"alice@example.com","password":"your-password-here"}'
```

`email` はアカウントの識別情報で、有効なアドレスである必要があります。サービスは前後の空白を除去し、小文字に正規化し、アドレスの確認済み状態を要求します。既存の環境では、アカウントをメールアドレスに移行するまで `{ "username": "alice", "password": "..." }` を引き続き送信できます。`username` に `@` を含む値を指定すると、メールアドレスとして扱われます。無効なアドレスは、パスワードの確認前に拒否されます。

レスポンス例:

```json
{
  "username": "alice",
  "email": "alice@example.com",
  "tenant_id": "00000000-0000-0000-0000-000000000000",
  "token": "<prefix>.<secret>",
  "expires_in": 2592000
}
```

`token` を Bearer 認証情報として使用します。同じヘッダーを指定して `POST /api/auth/logout` を呼び出すと、そのセッションを失効できます。ログアウトでは、オペレーターのプライマリ API キーは失効しません。

### オペレーター API キー（プライマリトークン）

```bash
TOKEN='<prefix>.<secret>'   # issued at operator creation; store offline
curl -sS 'https://exchange.ivjn.us/api/devices' \
  -H "Authorization: Bearer ${TOKEN}"
```

## デバイス一覧

```bash
curl -sS 'https://exchange.ivjn.us/api/devices' \
  -H "Authorization: Bearer ${TOKEN}"
```

各項目には、`id`、`name`、`online`、`enabled`、`info`、タイムスタンプ、`active_sessions` が含まれます。デバイス一覧は Exchange のメタデータです。デバイスを一覧に表示しても、操作に必要な密封セッションは確立されません。

## HTTP デバイス操作の拒否とセッション管理

すべてのデバイス操作は密封 WebSocket セッションを使用します。以下の HTTP ルートではクライアントとデバイスのハンドシェイクを確立できないため、認証・認可とリクエスト検証を通過したリクエストに HTTP `409` を返します。デバイスへの送信前に拒否し、コマンド、センサー要求、ネットワーク診断、更新、ubus 呼び出し、保持中ジョブへの要求、固定コマンドのいずれも Agent へ送信しません。デバイスがオンラインであっても、これらのルートで実行できるようにはなりません。

認証、権限、入力検証は引き続き適用されます。無効な認証情報には `401`、許可されない操作には `403` を返します。不正なコマンド、URL、リクエスト本文には、`400` を含む従来の検証エラーを返します。密封セッションの `409` は、それ以前の検証や認可の失敗を置き換えません。固定コマンドの権限検査は固定コマンドのポリシーに従いますが、検査を通過しても HTTP 実行は許可されません。

| メソッドとパス | 認証・認可・検証後の動作 |
| --- | --- |
| `POST /api/devices/{id}/exec` | `409`：密封 WebSocket セッション内で実行する |
| `GET /api/devices/{id}/exec/holds` | `409`：密封 WebSocket セッション内で保持中ジョブを確認する |
| `POST /api/devices/{id}/exec/holds/{job_id}/kill` | `409`：密封 WebSocket セッション内で保持中ジョブを停止する |
| `GET /api/devices/{id}/exec/holds/{job_id}/cat` | `409`：密封 WebSocket セッション内で保持中ジョブの出力を読む |
| `POST /api/devices/{id}/sensors` | `409`：密封 WebSocket セッション内でセンサーを取得する |
| `POST /api/devices/{id}/net` | `409`：密封 WebSocket セッション内でネットワーク診断を実行する |
| `POST /api/devices/{id}/update` | `409`：密封 WebSocket セッション内で更新を確認または適用する |
| `POST /api/devices/{id}/ubus` | `409`：密封 WebSocket セッション内で ubus を呼び出す |
| `POST /api/devices/{id}/commands/{cmd}/run` | `409`：固定コマンドを取得し、デバイスが認可した manage grant を使って密封 WebSocket セッション内で実行する |

たとえば、次の有効な操作リクエストは拒否動作の確認用であり、コマンド実行のインターフェースではありません。

```bash
curl -i -X POST "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/exec" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{"command":"uname -a","timeout_secs":30}'
```

HTTP `409` と英語のエラーメッセージ `Sealed sessions are required.`（リクエスト言語に応じてローカライズ）が返ることを確認してください。操作するにはネイティブの密封 WebSocket セッションを確立します。この HTTP ルートからコマンド出力の成功レスポンスが返ることはありません。

### Exchange のセッション管理

セッション一覧とキャンセルは Exchange のルーティングメタデータを管理するため、引き続き HTTP で利用できます。デバイス操作を実行せず、追加のデバイスセッションも開きません。

| メソッドとパス | 成功時のボディ |
| --- | --- |
| `GET /api/devices/{id}/sessions` | `{sessions:[DeviceSessionInfo], limit, count}` |
| `POST /api/devices/{id}/sessions/{session_id}/kill` | `{session_id, released}` |

`DeviceSessionInfo` のフィールドは `session_id`（デバイス側の数値 ID）、`operator_session_id`、`kind`、`hold`、`age_secs`、`deadline_unix_ms` です。`limit` は `max_sessions_per_device`（既定値 32）、`count` は現在の一覧の長さです。一覧取得はローカルの Exchange ハブ状態を直接読み、ローカルでオフラインのデバイスには `503` を返します。kill は**デバイス側**の ID を使用し、適切なキャンセル（保持中 exec を含む）を送信して、デバイスが応答しなくてもローカルでルートを解放します。セッションが存在しない場合、またはデバイスがローカルでオフラインの場合は HTTP `200` と `released:false` を返します。読み取り専用オペレーターには、両方のエンドポイントで `403` を返します。

```bash
curl -sS "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/sessions" \
  -H "Authorization: Bearer ${TOKEN}"

curl -sS -X POST "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/sessions/${SESSION_ID}/kill" \
  -H "Authorization: Bearer ${TOKEN}"
```

セッション数上限による拒否の前に、Exchange は接続を失ったオペレーターのルートと期限切れのルートを回収します。期限は exec のタイムアウト + 15 秒、ファイル転送は 3600 秒 + 15 秒、その他の種類は 24 時間です。期限内の保持中 exec は、オペレーターが切断されても終了せず、デタッチされます。

### ネイティブ CLI の密封操作

CLI は通常のデバイス操作の一環として密封セッションを確立します。先にワークスペースルートの承認とデバイスの認可を完了してください。[密封セッション](/docs/ja/sealed-sessions.md)を参照してください。平文を選択する方法はありません。通常のコマンドに密封用の追加フラグは不要です。

```bash
remote-cli exec "$DEVICE_ID" -- uname -a
remote-cli shell "$DEVICE_ID"
remote-cli exec-holds "$DEVICE_ID"
remote-cli exec-hold-cat "$DEVICE_ID" "$JOB_ID"
remote-cli exec-hold-kill "$DEVICE_ID" "$JOB_ID"
remote-cli sensors "$DEVICE_ID" --json
remote-cli net "$DEVICE_ID" ping 1.1.1.1
remote-cli net "$DEVICE_ID" http https://example.com/
remote-cli ubus "$DEVICE_ID" system board '{}'
remote-cli commands list "$DEVICE_ID"
remote-cli commands run "$DEVICE_ID" "$COMMAND_ID"
```

保持中ジョブの操作は密封セッション内で実行します。Exchange のセッション管理は別途 `remote-cli sessions` と `remote-cli session-kill` で利用できます。固定コマンドのメタデータを取得しても実行の認可にはなりません。デバイスが密封セッションのオペレーターの manage grant を受け入れる必要があります。

## 密封セッションの鍵ディレクトリと権限付与リレー

Exchange は、クライアント同士が互いを検出できるよう、オペレーターの公開鍵と所有者署名付きの `rsg1.` 権限付与トークンを保存します。**これは認可ではありません。** デバイスは引き続き、ローカルポリシーに照らして所有者の署名を検証します。Exchange はトークンのペイロード（`owner_key`、`operator_key`、`device_id`、`level`、`expires_at`）を索引化するだけで、アップロードを許可として扱いません。Exchange はルート署名を偽造できませんが、認証情報を渡さない、自動承認するルートクライアントに名簿を偽って伝える、メタデータを観測する、サービスを拒否するといったことは可能です。

ワークスペースルート（`rsr1.`）と権限付与の招待（`rsi1.`）も同じ種類のリレーです。Exchange はトークンの形式と Ed25519 の自己署名を確認します（招待は保存済みのルート鍵とも一致する必要があります）が、どのオペレーター鍵を承認するかは**決定しません**。`GET /api/seal/roster` はメンバー、ロール、`operator_device_grants` を表示する superadmin 向けのビューです。クライアントは、新しく現れた鍵をルートクライアントがローカルで承認するまで信頼してはいけません。`docs/sealed-sessions.md` を参照してください。

認証済みのワークスペースメンバーは、鍵の一覧取得、自分の鍵の登録、権限付与のアップロードができます（権限付与は自己認証可能です）。鍵の `DELETE` は登録したアカウントに限定されます（管理者は任意の鍵を削除可能）。権限付与の `DELETE` はアップロードしたアカウントに限定されます（管理者は任意の権限付与を削除可能）。`GET /api/seal/grants` は期限切れの行を除外します。

鍵登録の証明には、次の文字列に対する Ed25519 署名を使用します。

```text
remote-seal-key-register:v1:<workspace_id>:<operator_id>:<public_key>:<issued_at_ms>
```

`issued_at_ms` はサーバー時刻の ±10 分以内である必要があります。`public_key` と `proof` は標準 base64 です（32 バイトの鍵、64 バイトの署名）。

| メソッドとパス | ボディ | 成功時 |
| --- | --- | --- |
| `POST /api/seal/keys` | `{"public_key","label"?,"proof","issued_at_ms"}` | 登録済みの鍵（同じアカウントでは upsert） |
| `GET /api/seal/keys` | — | `[{operator_id, username, email, public_key, label, created_at, last_seen_at}]` |
| `DELETE /api/seal/keys/{key}` | — | `204`（`{key}` は URL エンコードされた公開鍵） |
| `POST /api/seal/grants` | `{"tokens":["rsg1.…"]}` | `{stored:[…], errors:[…]}` |
| `GET /api/seal/grants?mine=1` | — | `operator_key` が呼び出し元の登録済み鍵のいずれかである権限付与。`owner_key` が公開済みルートでない各権限付与には、`chain`（証明書トークン、空の場合もある）も含まれる |
| `GET /api/seal/grants?owner_key=…` | — | その所有者鍵が署名した権限付与 |
| `DELETE /api/seal/grants/{id}` | — | `204` |
| `PUT /api/seal/root` | `{"token":"rsr1.…","rotation":"rsx1.…"?}` | 保存済みのワークスペースルート。superadmin 専用。形式と鍵自身の署名を確認するが、これは承認ではない。任意の `rotation` は現在公開されているルートで署名され、新しいルートを指定する必要がある。Exchange はオンラインの密封デバイスに `seal_root {token, rotation}` をプッシュし、デバイスは既に信頼しているルートによる署名がある場合のみルートを再固定する。有効な rotation がなければ、古いルートを固定済みのデバイスはそのルートを維持する |
| `GET /api/seal/root` | — | `{workspace_id, root_key, payload, signature, token, created_at}`。未公開の場合は `404` |
| `POST /api/seal/invitations` | `{"token":"rsi1.…"}` | 保存済みの招待。superadmin 専用で、ルートが存在する場合のみ可能 |
| `GET /api/seal/invitations` | — | 有効期限内の招待を新しい順に返す（存在しない場合は `[]`） |
| `GET /api/seal/roster` | — | `{members:[{operator_id, username, email, role, restricted, enabled, keys:[{public_key, label}], device_grants:[{device_id, can_manage}]}]}`。superadmin 専用。無効化されたメンバーも含む |

ディレクトリを利用できなくても、密封セッションとデバイス grant の要件は変わりません。ワークスペースルートが未公開の場合、`GET /api/seal/root` は `404` を返します。

```bash
# Register this operator key (proof signed locally)
curl -sS -X POST "$BASE/api/seal/keys" \
  -H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' \
  -d '{"public_key":"<ed25519-b64>","label":"cli","proof":"<sig-b64>","issued_at_ms":1700000000000}'

curl -sS "$BASE/api/seal/keys" -H "Authorization: Bearer ${TOKEN}"

curl -sS -X POST "$BASE/api/seal/grants" \
  -H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' \
  -d '{"tokens":["rsg1.…"]}'

curl -sS "$BASE/api/seal/grants?mine=1" -H "Authorization: Bearer ${TOKEN}"
```

### 中間証明書（`rsc1.`）

Exchange は、ルート以外の鍵がデバイスに受け入れられる権限付与に署名できるよう、中間証明書を保存します。発行者の Ed25519 署名を**検証**し、証明書を**偽造することはできません**。トークンのアップロード自体は認可ではなく、呼び出し元は発行者のオペレーターである必要があります。ルート署名付き証明書（`issuer_key` が公開済みワークスペースルートと等しい）は、ワークスペースの superadmin のみ公開できます。中間証明書は、登録済み密封鍵が `issuer_key` であるアカウントのみ公開でき、その発行者自身が、保存済みで未失効かつ有効期限内の `can_issue` を持つ証明書の対象でなければなりません。子証明書は、親のレベル、デバイス範囲、有効期限の内側に収まる必要があります（デバイスと同じ検証を適用）。同じ `(issuer_key, subject_key)` を再公開すると、古い行を置き換えて失効状態を解除します。

`GET /api/seal/certs/chain?subject_key=` は、発行者のつながりを公開済みルートまでたどり、最大 4 個の証明書をルート発行分から順に `{chain:[tokens]}` として返すか、`404` を返します。認証済みメンバーなら誰でも呼び出せます。`GET /api/seal/grants?mine=1` は、署名者がルートでない権限付与にその `chain` を追加し、クライアントが権限付与とともに受け取れるようにします。ルートが署名した権限付与では `chain` を省略します。

`GET /api/seal/certs` は、失効済みを含むすべての行を一覧表示します（管理者と superadmin）。`DELETE /api/seal/certs/{serial}`（発行者のアカウント、または superadmin）は `revoked` を設定します。これは配布を停止するだけです。デバイスは既に取得したコピーを有効期限まで引き続き受け入れます。強制失効には、デバイスポリシーの `revoked_keys` を使用します。`{serial}` は URL エンコードされた標準 base64 のシリアルです。

| メソッドとパス | ボディ | 成功時 |
| --- | --- | --- |
| `POST /api/seal/certs` | `{"token":"rsc1.…"}` | 保存済み証明書（発行者と対象で upsert） |
| `GET /api/seal/certs/chain?subject_key=` | — | `{chain:["rsc1.…"]}`。ルート発行分が先頭。または `404` |
| `GET /api/seal/certs` | — | `[{serial, issuer_key, subject_key, token, expires_at_ms, created_at, revoked}]`。管理者または superadmin |
| `DELETE /api/seal/certs/{serial}` | — | `204`。発行者または superadmin。配布の停止のみ |

```bash
curl -sS -X POST "$BASE/api/seal/certs" \
  -H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' \
  -d '{"token":"rsc1.…"}'

curl -sS "$BASE/api/seal/certs/chain?subject_key=<ed25519-b64>" \
  -H "Authorization: Bearer ${TOKEN}"
```

### ワークスペースルート鍵のバックアップ（任意）

`PUT` / `GET` / `DELETE /api/seal/backup` は、アカウントごとに 1 個の不透明な `rsb1.` blob を保存します。`PUT` と `GET` は superadmin 専用です。blob はワークスペースの**ルート**秘密鍵をクライアント上で Argon2id（64 MiB、t=3、p=1）と ChaCha20-Poly1305 により暗号化したものです。パスフレーズは任意で、強度の制約はありません。**空または弱いパスフレーズでは、blob を持つ者（Exchange を含む）がルート鍵を復号し、すべてのデバイスを制御できます。** アップロードは任意です。代わりにクライアントからルート鍵（`rsk1.`）をエクスポートし、オフラインで保管できます。Exchange は `blob` が `rsb1.` で始まることと長さを確認し、復号しません。`GET` は、プロセスごとにアカウントあたり 1 時間 5 リクエストに制限されます（HTTP 429）。読み取り、書き込み、削除の監査にはバイト数のみを記録します。レスポンスには `Cache-Control: no-store` を付けます。

| メソッドとパス | ボディ | 成功時 |
| --- | --- | --- |
| `PUT /api/seal/backup` | `{"blob":"rsb1.…"}` | `{blob, updated_at}` |
| `GET /api/seal/backup` | — | `{blob, updated_at}`。未保存の場合は `404`。レート制限あり |
| `DELETE /api/seal/backup` | — | `204`。行が存在しない場合も同じ |

```bash
curl -sS -X PUT "$BASE/api/seal/backup" \
  -H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' \
  -d '{"blob":"rsb1.…"}'

curl -sS "$BASE/api/seal/backup" -H "Authorization: Bearer ${TOKEN}"

curl -sS -X DELETE "$BASE/api/seal/backup" -H "Authorization: Bearer ${TOKEN}"
```

## ブラウザー拡張機能のルートチェーン認証

ブラウザー操作には、ネイティブの密封 WebSocket セッションを使用します。HTTP の単発実行ではありません。拡張機能は Ed25519 オペレーター鍵を自ら生成します。オペレーターの Bearer トークンを使って `/api/auth/me` から `tenant_id` / `operator_id` を取得し、`{public_key, label, proof, issued_at_ms}` を指定して `POST /api/seal/keys` を呼び出します。PoP は次の文字列に署名します。

```text
remote-seal-key-register:v1:<workspace_id>:<operator_id>:<public_key>:<issued_at_ms>
```

鍵／署名は標準 base64（32/64 バイト）で、時刻はサーバー時刻の ±10 分以内である必要があります。登録が証明するのは鍵の所持であり、認可ではありません。拡張機能は `GET /api/seal/root`、`GET /api/seal/grants?mine=1`、および権限付与に含まれるか `GET /api/seal/certs/chain?subject_key=<url-encoded-key>` から取得した証明書トークンを同期します。権限付与は、直接または有効な証明書チェーンを通して、信頼済みワークスペースルートまで検証でき、デバイス範囲、レベル、有効期間が適切である必要があります。デバイストークンの登録は、このオペレータートークンのディレクトリ処理とは別です。

拡張機能は、自身のルートを通じて受信したネイティブ hello を検証し、自分の署名付き hello／権限付与／チェーンを `SealAccept.peer` に含めます。CLI／コンソールはその証明を検証し、署名付きレスポンスを受け入れる前に `peer.operator_key == accept.device_key` を要求します。拡張機能がオペレーターとして動作する場合、Rust エージェントは通常のルートポリシーを使用します。無効または欠落した証明は安全側で拒否され、独立したブラウザー用トラストストア／UI や旧方式へのフォールバックはありません。ネイティブデバイスとワークスペースルートの TOFU は維持されます。拡張機能はサーバー／ワークスペースごとに最初のルートを保存し、変更を拒否します。

**セキュリティ:** 最初のルートは別経路で検証してください。PoP やサーバーのロールは、ルートによる承認ではありません。ルートクライアントの自動承認前に、未知の鍵を確認してください。エンドポイントのプロファイル／鍵とホストされたスクリプトを保護してください。ディレクトリから削除しても、キャッシュ済みの署名付き認証情報は失効しません。Exchange はメタデータを確認したり、認証情報を渡さなかったり、サービスを拒否したりできます。密封セッションは、CDP の全権限を持つ悪意あるオペレーターを隔離するものではありません。

## WebSocket と HTTPS の選択

| 要件 | 使用するもの |
| --- | --- |
| コマンド、保持中ジョブの確認やキャンセル、センサー、ネットワーク診断、更新、ubus、固定コマンド | ネイティブの密封 WebSocket セッション |
| 対話型 PTY、出力ストリーミング、ファイル転送、Android の画面／ツリー／タップ | ネイティブの密封 WebSocket セッション |
| 認証、デバイス一覧、鍵／認可／証明書のディレクトリ、ルートバックアップ | HTTPS API |
| Exchange のルーティングセッションの一覧とキャンセル | HTTPS のセッション管理エンドポイント |
| デバイスのオンライン状態のリアルタイム通知と長時間のオペレーター接続 | WebSocket。接続上のデバイス操作には引き続き密封が必要 |

ブラウザーチケットの流れ（認証情報は WebSocket URL に含めません）：

1. `Authorization: Bearer …` を付けて `POST /api/ws-ticket` を呼び出します。
2. `GET /ws/browser` に接続し、最初の JSON フレームとして `{"ticket":"…"}` を送信します。
3. デバイス操作を送信する前に、ネイティブの密封クライアント／デバイス間ハンドシェイクを確立します。オペレーターの grant と証明書チェーンは、デバイスが信頼するルートまで検証できる必要があります。

ネイティブクライアントはオペレーター認証付きの `GET /ws/operator` を使用し、同じ密封セッション要件に従います。WebSocket 接続やアカウントへのログイン成功だけでは、デバイス操作は認可されません。[Web コンソールと Web Push](/docs/ja/web-console-and-notifications.md)、[プロトコル](/docs/ja/websocket-protocol.md)、[密封セッション](/docs/ja/sealed-sessions.md)を参照してください。

## エラーステータス早見表

| ステータス | 典型的な原因 |
| --- | --- |
| `400` | 空のコマンドや不正な更新元 URL などの無効な操作入力。密封セッションによる拒否の前に検証する |
| `401` | Bearer トークンの欠落または無効 |
| `403` | アカウントまたはデバイスの権限がリクエストを認可しない |
| `409` | 有効で認可済みの HTTP デバイス操作には密封 WebSocket セッションが必要。デバイスへの送信は行わない |
| `404` | エンドポイントの仕様に応じ、ディレクトリエントリの不在またはワークスペースルートが未公開 |
| `429` | ワークスペースルートのバックアップ読み取りを含む、エンドポイント固有のレート制限 |
| `503` | セッション一覧がローカルの Exchange ハブ状態でデバイスを見つけられない |

HTTP のデバイス操作ルートは Agent の出力を待たないため、認可済みの場合の通常の結果は `409` です。実行成功、オフライン送信エラー、HTTP 実行のタイムアウトは返しません。メタデータ、認証、ディレクトリ API のエラーは、各エンドポイント固有の意味を維持します。

## 手動検証計画

権限が既知のテスト用アカウントとデバイスを使用してください。HTTP の拒否確認ではデバイス上で処理を実行する必要はありません。

1. HTTPS で認証してデバイス一覧を取得し、アカウントとデバイスの権限を確認します。
2. 拒否一覧の各デバイス操作ルートに有効なリクエストを送信します。HTTP `409` を確認し、保持中ジョブの読み取りや固定コマンドの実行も含め、Agent に操作が届かないことを確認します。
3. 無効な認証情報、許可されないアカウント、不正な入力をそれぞれ確認します。すべてのリクエストが `409` を返すと想定せず、認証、権限、検証の各エラーを確認してください。
4. Exchange のセッション一覧を読み取ります。明示的に選んだ使い捨てセッションでキャンセルを試し、`released` を確認します。存在しないセッションは `released:false`、ローカルでオフラインのデバイスの一覧取得は `503` を返します。
5. ルート承認と grant を完了し、`remote-cli exec "$DEVICE_ID" -- uname -a` などの無害なネイティブ CLI 操作を実行します。密封ハンドシェイクを通して成功することを確認します。デバイス grant が欠落または無効の場合は、失敗時に閉じる必要があります。
6. 鍵登録、認可／証明書の取得、任意のルートバックアップ、ブラウザーのルートチェーン認証を個別に検証します。これらの HTTPS API は、上記の仕様に従って引き続き利用できます。

## 監査の送信先

PostgreSQL Exchange のみ対応し、認証されたワークスペースで分離します。管理者とスーパ管理者のみ利用でき、他のアカウントには 403 を返します。Worker 版と Web / モバイル Console の設定 UI は未対応です。

```text
GET    /api/audit/sinks
POST   /api/audit/sinks
PATCH  /api/audit/sinks/{id}
DELETE /api/audit/sinks/{id}
POST   /api/audit/sinks/{id}/test
GET    /api/audit/sinks/status
```

```sh
printf '%s' '{"name":"archive","kind":"webhook","endpoint":"https://audit.example.net/events","secret":"REPLACE-WITH-RANDOM-SECRET","mode":"full"}' | remote-cli audit sink add
remote-cli audit sink list
printf '%s' '{"interval_ms":500,"batch_size":16}' | remote-cli audit sink update 1
remote-cli audit sink test 1
remote-cli audit sink status
remote-cli audit sink remove 1
```

既定の throttled は保存済み監査を送信します。256 KiB を超えるテキストは先頭 250 KiB と末尾 6 KiB、ファイルはハッシュのみ、ストリームはセッション終了後に送信します。full は切り詰め前に取得し、元の JSON とバイナリを分割（既定 1 MiB）して送信します。操作 / 資産 ID、連番、オフセット、最終フラグ、SHA-256、総サイズを含みます。sealed は両モードともメタデータと暗号文参照 / ハッシュのみで、端点の平文を復元しません。

secret と headers は暗号化保存し、読み取りでは *** のみ表示します。PATCH の *** は維持、header の null は削除です。非同期の永続 FIFO と指数バックオフを使用します。節流キュー満杯時は最古を破棄して計数し、全量のディスクキューは背圧をかけます。status は成功 / 失敗、滞留、dropped、gaps を表示します。削除時の未送信データ数と設定変更も監査します。

webhook は JSON events 配列でバイトを base64 にします。X-Remote-Timestamp と X-Remote-Signature: sha256=<hex> は HMAC-SHA256(timestamp + "." + 生 body) です。OTel は OTLP/HTTP JSON /v1/logs、分割データは body.bytesValue、属性は remote.device.id / remote.operator / remote.action、service.name は設定可能です。gRPC は Collector 経由で、直接送信は未対応です。

既定設定、webhook ペイロード、署名検証、Collector の例を以下に示します。生の HTTP body を検証し、永続化と delivery_id による重複排除後に 2xx を返します。Loki 3.x で allow_structured_metadata を有効にし、Grafana に Loki データソースを追加して {service_name="remote-exchange"} を検索します。gRPC は pipeline exporters を [otlp/grpc] に変更します。本番環境は TLS と認証を使用してください。

```json
{"name":"collector","kind":"otel","mode":"throttled","endpoint":"https://collector.example.net:4318/v1/logs","headers":{"Authorization":"Bearer REPLACE"},"service_name":"remote-exchange","resource":{"deployment.environment.name":"production"},"batch_size":32,"interval_ms":1000,"chunk_bytes":1048576,"queue_limit":10000,"overflow_bytes":10737418240,"actions":[],"devices":[],"operators":[],"enabled":true,"ca_pem":null}
```

```json
{"version":1,"events":[{"delivery_id":123,"type":"full","mode":"full","entry":{"id":42,"action":"exec","operator_name":"alice","device_id":"dev"},"data":{"kind":"binary","asset_id":"asset-uuid","path":"/data_base64","sequence":0,"offset":0,"last":true,"data_base64":"YWJj","total_size":3,"sha256":"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad","context":{"direction":"output","frame_sequence":0,"stream_offset":0}}}]}
```

```python
import hashlib, hmac, time

def verify(headers, raw_body, secret):
    stamp = headers['X-Remote-Timestamp']
    if abs(time.time() - int(stamp)) > 300:
        raise ValueError('expired webhook')
    expected = 'sha256=' + hmac.new(
        secret.encode(), stamp.encode() + b'.' + raw_body, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, headers['X-Remote-Signature']):
        raise ValueError('invalid signature')
```

```yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 127.0.0.1:4318
processors:
  batch: {}
exporters:
  otlphttp/loki:
    endpoint: http://loki:3100/otlp
  otlp/grpc:
    endpoint: telemetry.example.net:4317
    headers:
      Authorization: "${env:OTEL_AUTHORIZATION}"
    tls:
      insecure: false
service:
  pipelines:
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/loki]
```

| 監査フィールド | OTLP フィールド |
|---|---|
| service_name / resource | Resource service.name / attributes |
| entry.started_at | timeUnixNano |
| entry.id / delivery_id | remote.audit.id / remote.delivery.id |
| entry.device_id / operator_name / action | remote.device.id / remote.operator / remote.action |
| entry.tenant_id | remote.workspace.id |
| entry.started_at / ended_at / detail | remote.started_at / remote.ended_at / remote.result |
| traceId / spanId | traceId / spanId |
| data.data_base64 | body.bytesValue / remote.chunk.* |
| stored events | body.stringValue |
