# リモートエディターの保存とレイテンシーの監視 ## 根本的な原因 以前のエディターは、作業用ファイルを対象ファイルの親ディレクトリにアップロードしていましたが、`/etc` や `/var` の配下では、SSH アカウントがそのディレクトリに書き込めないことがよくありました。SFTP の名前変更には、認可された sudo の経路も、ベースラインとの競合チェックも、セキュリティメタデータの検証も、確実な上書きの仕組みもありませんでした。また、エラー処理で一時コピーを削除していたため、完了したかどうかが不明な場合でも、復旧に使える材料が失われていました。 以前のセッションの状態表示は、接続状態だけでした。ターミナルのインジケーターに反映される ICMP や認証済み SSH の応答時間の計測はありませんでした。 ## 保存のプロトコル 1. 固定の SSH ヘルパーが、ディスクリプター基準の相対操作と、パスの各要素に対する `O_NOFOLLOW` を使って、リンクが 1 つだけの通常ファイルを開きます。内容、SHA-256 ハッシュ、デバイス/inode、UID、GID、すべてのパーミッションビット、ナノ秒単位の変更/状態変更時刻、読み取り可能なすべての拡張属性を記録します。Core はハッシュを検証し、BOM を保持したまま厳密な UTF-8 としてデコードします。各ドキュメントには、そのプラグイン、ターミナルセッション、サーバーの識別情報に限定された、不透明な `editId` が割り当てられます。 2. 特権のない準備処理が、`pwd.getpwuid(os.getuid())` によって認証済みアカウントのホームディレクトリを特定し、モード `0700` で `$HOME/ch-j-sm/` を作成して、非公開かつ排他的な復旧用ジャーナルを作成します。安全でないホームディレクトリ、シンボリックリンクの作業領域、ほかのアカウントが所有する作業領域は拒否されます。 3. Core は、排他的な作成(`wx`)、モード `0600`、60 秒のチャネル/アップロードのタイムアウトを指定して、隠しファイルとなるランダムな 128 ビット名の `.tmp` 作業用ファイルを SFTP でアップロードします。ジャーナルには、対象、元のベースライン、想定される作業用ファイルのハッシュが含まれますが、認証情報は含まれません。 4. **1 回の SSH exec で最終処理全体を実行します**。必要に応じて、既存の sudo 実行機構を介して実行されます。ヘルパーは非公開の作業用コピーを安全に開いてハッシュを計算し、ディスクに書き出します。元の inode をロックしてベースラインを確認し、対象の隣に排他的な保存先ファイルを作成して内容をすべて書き込み、メタデータを適用・検証したうえで、元のファイルをもう一度確認し、置き換え用のファイルをディスクに書き出します。 5. 同じディレクトリ内での `os.replace` によって、対象をアトミックに置き換えます。親ディレクトリをディスクに書き出し、最終的なパス、ハッシュ、inode、メタデータを検証します。置き換えが完了するまで、元のパスは利用可能なままです。作業用ファイルがファイルシステムをまたいで移動されることはなく、最終処理で削除されることもありません。 6. Core が検証済みの成功を受け取った後、別のクリーンアップ処理が最終的な内容とセキュリティメタデータを再確認してから、作業用ファイルとジャーナルを削除します。クリーンアップに失敗した場合も、`recoveryAvailable: true` 付きで保存の確定が報告されます。 アップロードの失敗、sudo の失敗、競合、メタデータのエラー、置き換えの失敗が起きた場合は、元のファイルと、準備済みのすべての材料が保持されます。未完了のアップロードはそのように識別され、完全な編集済みドキュメントとして復旧することはできません。SSH 通信の途絶、終了ステータスの欠落、タイムアウト、想定外の最終処理結果は、保存済みではなく **不明** として報告されます。結果が不確かなドキュメントについては、Core が次の保存をブロックします。置き換えが自動で再試行されることはありません。 ## メタデータ、権限、安全上の境界 ヘルパーは、UID、GID、および実行、setuid、setgid、sticky の各ビットを含むすべての POSIX パーミッションビットを保持し、検証します。POSIX ACL、capabilities、SELinux ラベルを含む Linux の拡張属性を、1 バイトずつそのまま再現します。書き込みや chown によって capabilities や特殊なパーミッションビットがクリアされることがあるため、所有者とファイルの内容をセキュリティ属性より先に設定します。元のファイルにはないのに保存先ファイルが継承した属性は削除されます。変更時刻は新しい書き込みを反映するもので、元のファイルからはコピーされません。 読み取れない属性や表現できない属性があると、処理は中止されます。メタデータが知らないうちに失われることはありません。IMA/EVM 署名は明示的に拒否されます。内容/inode に結び付いた完全性の署名をコピーすると、署名が無効になってしまうためです。対象パスやホームディレクトリのパスにあるシンボリックリンク、ハードリンクされたファイル、通常ファイル以外のファイルは拒否されます。その場での上書きや、より弱い SFTP での上書きへのフォールバックは使用しません。 書き込み可能な通常の保存は、SSH アカウントとして実行されます。保護された保存には、明示的な `{ sudo: true }` が必要です。パスワードなしの sudo の場合、sudo パスワードは任意です。パスワードは検証されたうえでチャネルの stdin でのみ渡され、コマンド、ジャーナル、作業用ファイル、ロガー、リモートのエラーに含まれることはありません。root の SSH セッションでは、既存の root 直接実行機構を使用します。認可に失敗した場合、元のファイルと作業用コピーはそのまま残ります。root として実行されるすべての保存(root での直接 SSH または sudo)では、さらに、ほかのアカウントが所有または書き込み可能な、あるいはアクセス ACL が設定された対象ディレクトリ/上位ディレクトリを拒否し、ほかのアカウントが制御する特権的なパス名を信頼しません。 固定のヘルパーは `python3 -I -B` で実行されます。ユーザーの Python パス、作業ディレクトリ、ユーザーの site-packages を無視し、バイトコードを書き出しません。パスと JSON の引数は、Core 内でシェル用にクォートされます。プラグインが任意の SSH exec や SFTP のハンドルを受け取ることはありません。既存のサンドボックス、CSP、マニフェストの権限、ホスト鍵の検証、アップデートの検証は引き続き有効です。 ## Plugin API と残っている Monaco との統合 **別途配布される Monaco/File Manager の UI は、このチェックアウトには含まれていません。その UI との統合、sudo のプロンプト、未保存のバッファーの扱い、翻訳は、ここでは実装もテストもできませんでした**。同梱のプラグインのソースは Hash & Checksum プラグインです。代わりとなる File Manager のソースを作り出すことはしていません。 既存のメソッドは引き続き利用できます。追加のメソッド/オプションは、サンドボックス化された preload と、権限をチェックするランタイムによって公開されます: ```js const opened = await chjPlugin.files.readText(sessionId, path); // For a protected read, explicitly pass { sudo: true, sudoPassword }. const result = await chjPlugin.files.saveText(sessionId, path, editor.getValue(), { editId: opened.editId, sudo: true, // Include only after the user authorizes elevation. sudoPassword // Omit for passwordless sudo; never persist this value. }); if (result.ok && result.value.status === "saved") { // Mark this exact submitted editor version as saved. Later edits stay dirty. } else { // Keep the Monaco model dirty. Translate result.error.code/status. // result.error.recoveryId/recoveryPath identify retained working material. } await chjPlugin.files.closeText(opened.editId); const recovery = await chjPlugin.files.listRecovery(sessionId); const copy = await chjPlugin.files.readRecovery(sessionId, recoveryId); // Review copy.status and copy.text; open a fresh document before another save. await chjPlugin.files.cleanupRecovery(sessionId, recoveryId); // Cleanup rejects anything whose target is not currently confirmed. ``` 対象の確認に権限の昇格が必要な場合、復旧用のメソッドは最後の引数として任意の `{ sudo: true, sudoPassword }` を受け付けます。sudo を介して実行した場合でも、認証済みユーザーの作業領域が保持されます。ジャーナルはアプリの再起動や SSH の再接続後も残ります。復旧によって最終処理が自動で繰り返されることはありません。復旧したバッファーを明示的に保存する前に、開き直した対象と比較して確認してください。 `files.read` は読み取り、確認、ハンドルの解放を保護します。`files.write` は保存と復旧のクリーンアップを保護します。既存のプラグイン向けに、`writeText` は失敗時に引き続き reject します。一方 `saveText` は、Electron が reject された Error オブジェクトのカスタムプロパティを削除してしまうため、構造化された `{ ok, value/error }` のエンベロープを返します。従来の 3 引数の `writeText` は、開いているドキュメントが 1 つだけの場合、そのベースラインを使用できます。同じパスで複数のドキュメントが開いている場合は `editId` が必要です。あいまいな従来の呼び出しは、別のドキュメントのベースラインを選ぶことなく、安全に失敗します。プラグインは使わなくなったハンドルを解放してください。 外部の UI では、保存中であることをすぐに表示し、失敗時にはバッファーを未保存のまま保ち、権限/認証に失敗した場合にだけ sudo を提案し、上書きせずに競合を表示し、接続が切れた場合や結果が不明な場合には復旧を提案する必要があります。これらの状態は、チェコ語、ドイツ語、英語に翻訳しなければなりません。推奨する状態の文言: | 状態 | チェコ語 | ドイツ語 | 英語 | | --- | --- | --- | --- | | 保存中 | Ukládání… | Wird gespeichert… | Saving… | | 確定 | Uloženo | Gespeichert | Saved | | 失敗 | Uložení selhalo | Speichern fehlgeschlagen | Save failed | | sudo が必要 | Vyžadováno oprávnění sudo | sudo-Autorisierung erforderlich | Sudo authorization required | | 競合 | Původní soubor byl změněn | Originaldatei wurde geändert | Original file changed | | 切断 | Spojení bylo přerušeno | Verbindung unterbrochen | Connection lost | | 不明 | Výsledek uložení není znám | Speicherergebnis unbekannt | Save result unknown | | 復旧 | Obnova je dostupná | Wiederherstellung verfügbar | Recovery available | ## レイテンシーの定義とライフサイクル **Ping** は、SSH が使用する解決済みの IP に対する ICMP echo に、クライアント側で実際に応答が返ってきたときに報告されるレイテンシーです。Core は、シェルを使わず、管理者としての起動も必要とせずに、固定の引数配列でプラットフォームの ping 実行ファイルを非同期に呼び出します。Linux、macOS(`ping6` を含む)、Windows の引数は、それぞれ個別に選択されます。ICMP のブロック、実行ファイルの欠落、タイムアウト、無効な出力、`<1ms` のように上限値しか示さない Windows の応答では、数値をでっち上げることなく「利用不可」の値になります。 **SSH RTT** は、既存の認証済み SSH クライアント上で固定の `true` exec リクエストを実行するのにかかった、単調な `performance.now()` による時間です。チャネルの確立、サーバーでの処理、応答の到着が含まれます。純粋なネットワークの RTT でも、SSH のログインにかかる時間でもありません。翻訳されたインジケーターのツールチップで、この違いを説明しています。 監視は、ターミナルが接続されるとすぐに開始されます。プローブは並行して非同期に実行され、リクエスト/プロセスのタイムアウトは 5 秒です。重複を防ぐため、次のサンプルは前のサンプルが完了してから 12 秒後に予定されます。切断すると、ICMP が中止され、タイマーがクリアされ、保留中の SSH の処理がキャンセルされ、値がクリアされます。レコードの識別情報のチェックにより、再接続後に以前の接続が値を発行することはありません。ICMP の失敗によって SSH の接続状態が変わることはありません。 レイテンシーは、既存の `state` イベント / `ssh:state` IPC のペイロードに含まれて送られます: ```js latency: { pingMs: 24.1, sshRttMs: 31.25, measuredAt: "…" } ``` 利用できない数値は `null` です。コンパクトなターミナルのインジケーターは、ミリ秒、ローカライズされた「利用不可」の文言、チェコ語/ドイツ語/英語のラベルを使用します。切断すると非表示になります。レンダラーに追加の実行機能や IPC リクエストは必要ありませんでした。 ## 検証と運用上の制限 - `app/` で `npm ci` を実行し、続けて `npm test` を実行します。 - 最終的なローカルの結果:**179 件のテスト、成功 163 件、失敗 0 件、スキップ 16 件**。スキップされたのは、すべてこの macOS ホスト上での Linux ファイルシステムのケースです。`npm ci`、Python の構文コンパイル、`git diff --check` はいずれも正常に完了しました。 - テストは、Core の保存/復旧、sudo のプロトコルとパスワードのマスキング、悪意のあるパスに対する実際の POSIX シェルのクォート、競合、アップロード、クリーンアップの失敗、不確かな結果、同時に開いたドキュメント、IPC の権限、レイテンシーのライフサイクルを網羅しています。 - Linux のファイルシステムのテストでは、出荷される Python のエントリーポイントを、テスト用の非公開のアカウントホーム参照を使って実行します:所有者/モード/xattr、POSIX ACL、シンボリックリンク/ハードリンク、FIFO、アップロードとメタデータの失敗、置き換えの失敗、対象での競合、置き換え後の不明な結果、復旧。ファイルシステムをまたぐステージングでは、利用可能な場合に `/dev/shm` と別のファイルシステムを使用します。これらのテストは Linux 以外のホストではスキップされます。ACL/別ファイルシステムの機能がない場合は、明示的にスキップされます。 - 追加した Linux 用の GitHub Actions ジョブは、テストスイート全体を実行し、ファイルシステムのケースを root として繰り返します。このワークフローは、この作業ツリーからはまだ実行されていません。 - ローカルでの検証は macOS で行いました。Python のソースの構文は、Command Line Tools の Python 実行ファイルで直接確認しました。実際の macOS のループバックでの ICMP スモークチェックでは `0.071 ms` が返されました。ライブのリモート SSH サーバー、Windows のランタイム、Linux のランタイム、SELinux の環境、外部の Monaco UI は、ローカルではテストしていません。 - リモートのテキスト編集には現在、Linux と、ディスクリプター基準の相対的なファイルシステム/xattr の操作に対応した Python 3 が必要です。インタープリターがない場合は明示的に失敗します。この Core は、安全でないメタデータ、リンク、ディレクトリの構成を意図的に拒否します。 - ロックは協調的なものにすぎません。最後のベースライン確認とアトミックな名前変更の間のわずかな時間に、協調しないプロセスとの競合が起きる可能性は残ります。Linux には、内容のハッシュを比較して置き換える汎用のアトミックな操作がありません。チェックはその時間より前の変更を検出し、保持された作業用コピーがその後の復旧を支えます。 - 復旧用のデータは、確定したクリーンアップが行われるまでリモートのアカウントに残ります。未完了のもの、競合したもの、確認できないジャーナルが、知らないうちに破棄されることはありません。復旧用ファイルが自動で期限切れになることはなく、強制的に上書きするオプションもありません。 - `npm ci` は、依存関係の監査で 17 件の指摘(中程度 9 件、高 8 件)を報告しました。今回の変更で、依存関係やアプリケーションのアップデートに関する信頼モデルが変わることはありません。 ユーザーの要望により、変更は既存の作業ツリー(現在は `feature/hash-checksum-plugin`)に残しています。ブランチの切り替えや、関係のない整理は行っていません。 ## この作業で変更したファイル - `src/main/files/remoteFileService.js` - `src/main/files/remoteEditor.py`(新規) - `src/main/sessions/sessionManager.js` - `src/main/sessions/latencyMonitor.js`(新規) - `src/main/plugins/pluginRuntime.js` - `src/preload/pluginPreload.js` - `src/renderer/app.js` - `src/renderer/index.html` - `src/renderer/styles.css` - `src/renderer/i18n.js` - `test/remoteFileService.test.js` - `test/sessionManager.test.js` - `test/pluginRuntime.test.js` - `test/remoteEditor.test.js`(新規) - `test/remoteEditorProtocol.test.js`(新規) - `test/remoteEditorLinux.test.js`(新規) - `test/fixtures/remoteEditorLinux.py`(新規) - `test/latencyMonitor.test.js`(新規) - `README.md` - `docs/remote-editor-latency.md`(このファイル) - `../.github/workflows/remote-editor-tests.yml`(新規)