AI エージェントの隣でシリアルコンソールを開く — トランスポート抽象化と xterm.js で SSH/シリアルを1画面に
連載「AI エージェントに SSH を渡すのが怖いので、人間承認ゲートウェイを自作した」第6回(シリアルポート編)。
- リポジトリ:
ssh-gete(ssh-gate-oss) —- 対象バージョン:
main(タグ未発行。本記事のコード引用は記事公開時点のmainを参照)- この回は単体でも読めます。連載の入口は第1回(コンセプト編)です。
0. この記事のゴール
ssh-gete は「AI エージェントが要求した SSH コマンドを、人間が承認してから実行する」デスクトップアプリです。第5回までで、MCP サーバー・承認フロー・GUI・永続化を作ってきました。
第6回では、ここに シリアルポートを足します。ネットワーク機器のコンソールポート、SSH の入らない組み込みボード、USB シリアル変換器——「最後の手段」のシリアルを、SSH とまったく同じ画面・同じ操作で開けるようにします。
ポイントは3つです。
-
トランスポート抽象化:SSH と シリアルを
terminalSessionという1つの型に押し込み、入力送信・リサイズ・切断のコードを共有する。 - xterm.js への双方向ストリーミング:リモート出力を Wails イベントで push し、キー入力を送り返す。バイナリ安全のため base64 で運ぶ。
- 接続先に種別を持たせる:SSH/シリアルの切替はトグルではなく「接続先の属性」。ターミナルは選択した接続先に従属する。
完成形はこうです。サイドバーの「ターミナル」を開き、シリアル接続先を選んで接続すると、ブラウザ内ターミナルでそのまま enable や show version を叩けます。
1. なぜシリアルなのか — SSH では届かない世界
SSH は便利ですが、SSH デーモンが上がっていないと何もできません。実務でシリアルが要るのはこういう場面です。
- ルーター/スイッチの console ポート(初期設定前・ネットワーク断・ブートローダー)
- 組み込みボード(Raspberry Pi Pico、各種マイコンの UART)
- ネットワークが死んだサーバーの最後のアクセス手段
つまりシリアルは「ネットワーク層より下」に手を入れる窓口です。AI エージェント連携アプリにこれを足すと、対応できる現場が一気に広がります。
macOS なら USB シリアル変換器は /dev/cu.usbserial-XXXX のように見えます(tty. ではなく cu. を使うのが定石。call-up デバイスで DCD を待たない)。Windows なら COM3 などです。
2. SSH との根本的な違い — 「コマンド→終了コード」モデルが無い
ここが設計の分かれ目です。第3回で作った SSH 実行はこういうモデルでした。
1コマンド投げる → 実行される → stdout/stderr と終了コードが返る → 終わり
ssh_exec.go の runSSHCommand は session.Run(command) で一発実行し、結果を commandResult に詰めて返します。承認キューに乗るのもこのモデルだからこそです(「このコマンドを承認」という単位が成立する)。
ところがシリアルには**「コマンドの終わり」がありません**。あるのは延々と流れるバイト列だけ。show version の出力が「終わった」かどうかは、プロンプト(Router#)が出たかどうかを人間が見て判断します。
だからシリアルは対話ストリームとして扱うのが自然で、承認キュー(コマンド→終了モデル)には乗せません。シリアルは「人が GUI で直接触る」ものとして実装します。MCP エージェントからシリアル接続先を実行しようとした場合は明示的に拒否します(後述)。
この「対話ストリーム」という性質は、実は SSH の対話シェル(session.Shell() + PTY)と同じです。top や vim を SSH 越しに使うのと、シリアルでルーターを叩くのは、抽象としては双子です。ここに抽象化の余地があります。
3. トランスポート抽象化 — terminalSession
新規ファイル terminal.go の中心がこの型です。
// terminal.go:24
type terminalSession struct {
kind string // "ssh" | "serial"
label string // 表示用ラベル
stdin io.Writer // ユーザー入力の書き込み先
resize func(cols, rows int) error // 端末サイズ変更(シリアルは nil)
closer func() // 後始末
}
SSH もシリアルも、突き詰めると次の3つしか要りません。
-
入力を書き込む先(
stdin io.Writer) - 出力を読んでフロントへ流す仕組み
-
閉じる(
closer)
resize だけは SSH 固有(シリアルに端末サイズの概念はない)なので、関数ポインタにして任意にしています。シリアルは nil を入れておけばいい。
稼働中のセッションは App に1本だけ持ち、専用ロックで守ります。
// app.go(App 構造体に追加)
termMu sync.Mutex // ターミナル専用ロック(本体の mu とは別)
term *terminalSession // 稼働中の端末(同時1本)
本体の mu(接続先・承認キューを守る RWMutex)と分けたのは、長時間の送受信が他の状態アクセスをブロックしないためです。新セッションを差し込むときは、必ず旧セッションを閉じます。
// terminal.go:59
func (a *App) setTerminalSession(ts *terminalSession) {
a.termMu.Lock()
old := a.term
a.term = ts
a.termMu.Unlock()
if old != nil {
old.closeQuiet()
}
}
そして入力・リサイズ・切断の API は、トランスポートを一切知りません。
// terminal.go:229, 244, 261
func (a *App) SendTerminalInput(data string) ActionResult { /* ts.stdin.Write([]byte(data)) */ }
func (a *App) ResizeTerminal(cols, rows int) ActionResult { /* ts.resize があれば呼ぶ。無ければ no-op */ }
func (a *App) CloseTerminal() ActionResult { /* a.term を nil にして closeQuiet() */ }
ResizeTerminal はシリアルだと ts.resize == nil なので安全に何もしません。これが抽象化の効き目で、フロントは SSH かシリアルかを気にせず同じ関数を呼べます。
4. 出力ストリーミング — Wails イベント + base64
リモートからの出力は、ポーリングではなく Wails のイベントでフロントへ push します。鍵になるのは「io.Writer を1個作る」ことです。
// terminal.go:41
type ptyEmitter struct {
app *App
}
func (e *ptyEmitter) Write(p []byte) (int, error) {
if e.app != nil && e.app.ctx != nil {
wruntime.EventsEmit(e.app.ctx, terminalDataEvent, // "terminal:data"
base64.StdEncoding.EncodeToString(p))
}
return len(p), nil
}
ptyEmitter は io.Writer です。だから:
-
SSH は
session.Stdout = emitterと差し込むだけ -
シリアル は読み取りループから
emitter.Write(buf[:n])を呼ぶだけ
どちらも同じ terminal:data イベントでフロントに届きます。
なぜ base64 か
端末出力は任意のバイト列です。エスケープシーケンス、不正な UTF-8 の断片、制御コード——これらを JSON のイベントペイロードに生で載せると壊れます。そこで base64 にエンコードして運び、フロント側で復号します。地味ですが、これを怠ると「日本語やカラー出力で表示が崩れる」事故になります。
セッション終了時は terminal:exit を送り、フロントが「切断」表示へ切り替えます。
// terminal.go:52
func (a *App) emitTerminalExit(message string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, terminalExitEvent, message)
}
}
5. SSH 側 — session.Shell() + PTY で本物の対話シェル
StartTerminal は接続先の種別でディスパッチするだけです。
// terminal.go:72
func (a *App) StartTerminal(name string) ActionResult {
conn, ok := a.findConnection(name)
if !ok {
return ActionResult{OK: false, Message: "対象の接続先が見つかりません"}
}
if conn.connectionType() == "Serial" {
return a.startSerialSession(conn)
}
return a.startSSHSession(conn)
}
SSH 側 startSSHSession の要点(terminal.go:84):
modes := ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}
if err := session.RequestPty("xterm-256color", 24, 80, modes); err != nil { /* ... */ }
stdin, err := session.StdinPipe()
emitter := &ptyEmitter{app: a}
session.Stdout = emitter
session.Stderr = emitter
if err := session.Shell(); err != nil { /* ... */ }
第3回の session.Run(command) との違いは RequestPty + Shell() です。PTY を確保することで vim/top/タブ補完が動く、本物の対話シェルになります。組み立てた terminalSession の resize は session.WindowChange を呼ぶクロージャです。
// terminal.go:132
ts := &terminalSession{
kind: "ssh",
label: conn.Name,
stdin: stdin,
resize: func(cols, rows int) error { return session.WindowChange(rows, cols) },
closer: func() { _ = session.Close(); _ = client.Close() },
}
接続確立に使う context はダイヤルとハンドシェイクだけを制限し、その後 cancel() してもクライアントは生き続けます(対話セッションは長時間続くので、ここでタイムアウトを効かせてはいけない)。
// terminal.go:91
dialCtx, cancel := context.WithTimeout(context.Background(), timeoutSeconds(conn.ConnectTimeout, 10))
client, err := openSSHClient(dialCtx, conn, strictHostKey)
cancel()
最後に、終了監視の goroutine。session.Wait() が返ったら App.term を片付けて terminal:exit を送ります。
// terminal.go:146
go func() {
_ = session.Wait()
a.termMu.Lock()
current := a.term == ts
if current { a.term = nil }
a.termMu.Unlock()
ts.closeQuiet()
if current {
a.emitTerminalExit(fmt.Sprintf("%s のセッションが終了しました", conn.Name))
}
}()
current := a.term == ts で「自分が今のセッションか」を確かめてから片付けるのがミソ。別のセッションに差し替わっていたら、勝手に消したり exit を送ったりしません。
6. シリアル側 — go.bug.st/serial で開いて読み続ける
依存に go.bug.st/serial を足します(クロスプラットフォームのシリアル I/O)。
まずポート列挙。接続先編集のドロップダウンに出すためのものです。
// terminal.go:164
func (a *App) ListSerialPorts() []string {
ports, err := serial.GetPortsList()
if err != nil || ports == nil {
return []string{}
}
return ports
}
接続本体。SSH と違って「読み取りループ」を自前で回します。
// terminal.go:174
func (a *App) startSerialSession(conn Connection) ActionResult {
portName := conn.SerialPort
// ...(空なら conn.Host を許容、baud は 0 なら 115200)
port, err := serial.Open(portName, &serial.Mode{BaudRate: baud})
if err != nil { /* ... */ }
ts := &terminalSession{
kind: "serial",
label: fmt.Sprintf("%s @ %d", portName, baud),
stdin: port, // ポートそのものが io.Writer
resize: nil, // シリアルにサイズの概念なし
closer: func() { _ = port.Close() },
}
a.setTerminalSession(ts)
emitter := &ptyEmitter{app: a}
go func() {
buf := make([]byte, 4096)
for {
n, readErr := port.Read(buf)
if n > 0 { _, _ = emitter.Write(buf[:n]) } // ← SSH と同じ emitter
if readErr != nil { break }
}
// ...(App.term を片付けて terminal:exit)
}()
return ActionResult{OK: true, Message: fmt.Sprintf("%s に接続しました", ts.label)}
}
注目してほしいのは、emitter も terminalSession も SSH と完全に共通だという点です。違うのは「session.Shell() のストリーム」か「port.Read のループ」か、それだけ。SendTerminalInput / CloseTerminal は一切変更せずに動きます。これがトランスポート抽象化の配当です。
7. 接続先に種別を持たせる — 切替は「接続先」で決まる
UI の素直さのために、SSH/シリアルの切替をトグルにしませんでした。代わりに 接続先(Connection)に種別を持たせ、ターミナルは選んだ接続先に従属します。
Connection 構造体を拡張します。
// app.go
type Connection struct {
Name string
Type string // "SSH" | "Serial"
Host string
// ... SSH 用フィールド ...
SerialPort string // シリアルのデバイスパス
BaudRate int // シリアルの速度
// ...
}
// 旧レコード(種別なし)を安全に SSH 扱いにする後方互換ヘルパー
func (c Connection) connectionType() string {
if c.Type == "Serial" { return "Serial" }
return "SSH"
}
保存時のバリデーションも種別で分けます。シリアルはホスト/ユーザー不要、代わりにポート必須。
// app.go:SaveConnection
if connection.Name == "" {
return ActionResult{OK: false, Message: "接続先名は必須です"}
}
if connection.Type == "Serial" {
if connection.SerialPort == "" {
return ActionResult{OK: false, Message: "シリアルポートを選択してください"}
}
} else if connection.Host == "" || connection.User == "" {
return ActionResult{OK: false, Message: "ホスト、ユーザー名は必須です"}
}
ここは実装中に踏んだ落とし穴でもあります。最初「接続先名・ホスト・ユーザーは必須」のままで、シリアル接続先がそもそも保存できませんでした。種別ごとに必須項目を分ける、という当たり前の修正です。
SQLite の追加カラム
既存 DB を壊さないために、type/serial_port/baud_rate は「無ければ足す」方式で追加します。
// storage.go
func (s *Store) addColumnIfMissing(table, column, ddl string) error {
// pragma_table_info で既存カラムを調べ、無ければ ALTER TABLE ADD COLUMN
}
CREATE TABLE IF NOT EXISTS の直後に呼べば、旧ユーザーの DB も新カラム付きへ無痛で移行できます。バージョン管理されたマイグレーションへの第一歩です。
8. フロントの難所 — 全画面再描画の中で xterm を“生かす”
ssh-gete のフロントは「状態が変わるたび app.innerHTML を丸ごと差し替える」素朴な方式です(第4回)。ところが xterm.js を素直に置くと、5秒ごとのポーリング再描画のたびに端末 DOM が破棄され、対話セッションが壊れます。
解決は2段構えです。
(1) xterm インスタンスを再描画の外で保持し、DOM を“移植”する
xterm のインスタンスを state や DOM ではなくモジュール変数に持ち、再描画後に「既存の要素を新しいホストへ appendChild で付け替える」だけにします。再生成ではなく移植なので、スクロールバックも生きたままです。
// frontend/src/main.js
let term = null;
let fitAddon = null;
let termWired = false;
function mountTerminal() {
const host = document.querySelector('#xterm-host');
if (!host) return;
ensureTerminal();
if (!term.element) {
term.open(host); // 初回だけ生成
} else if (term.element.parentElement !== host) {
host.appendChild(term.element); // 2回目以降は“移植”
}
if (!termWired) {
term.onData((data) => { // キー入力を送る配線は1度だけ
if (state.terminal.connected) SendTerminalInput(data);
});
termWired = true;
}
fitTerminal();
}
(2) ターミナル表示中はポーリング再描画そのものを止める
そもそも余計な render() を起こさないのが確実です。
// refreshData 内
const protectTerminal = state.active === 'terminal';
// ...
if (!protectConnections && !protectSettings && !protectModal && !protectTerminal) render();
受信と配線
出力は base64 を復号して term.write、終了はメッセージ表示。Wails の EventsOn で受けます。
EventsOn('terminal:data', (payload) => {
if (term) term.write(b64ToBytes(payload));
});
EventsOn('terminal:exit', (message) => {
if (term) term.write(`\r\n\x1b[33m[${message}]\x1b[0m\r\n`);
state.terminal.connected = false;
if (state.active === 'terminal') render();
});
function b64ToBytes(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
return bytes;
}
ウィンドウリサイズには fitAddon.fit() + ResizeTerminal で追従し、タブ離脱・切断時は teardownTerminal() で CloseTerminal() + term.dispose() します。
9. 承認モデルとの整合 — シリアルは MCP から実行させない
ここは設計の一貫性のために重要です。シリアルは「人が GUI で対話する」もので、MCP の「コマンド→承認→実行」モデルには乗りません。そこで:
-
list_hostsの結果にtypeを足し、エージェントが SSH/シリアルを判別できるようにする -
execute_command/request_command_executionがシリアル接続先を指定されたら明示的に拒否する
// mcp_server.go
conn, ok := a.findConnection(args.Host)
if !ok {
return nil, &rpcError{Code: -32602, Message: "host not found"}
}
if conn.connectionType() == "Serial" {
return nil, &rpcError{Code: -32602,
Message: "serial connections are interactive-only; not executable via MCP"}
}
これを入れないと、シリアル接続先(ホスト空)に対して SSH 実行を試みて無駄な失敗履歴が残るだけでした。役割分担をコードで強制したわけです。回帰テスト TestMCPRejectsSerialConnection で「拒否され、キューにも積まれない」ことを保証しています。
10. OS 差分(第7回への布石)
-
macOS:
/dev/cu.usbserial-XXXX。tty.ではなくcu.を使う -
Windows:
COM3など。さらに ssh-agent は Unix ソケットではなく名前付きパイプ\\.\pipe\openssh-ssh-agent
go.bug.st/serial がポート列挙の OS 差分は吸収してくれますが、Windows 配布まわり(cgo・コード署名・ssh-agent)は別の山です。これは第7回(クロスプラットフォーム・公開編)で扱います。
11. デモ
- 接続先管理で「新規」→ 接続種類 = Serial
- シリアルポート(例:
/dev/cu.usbserial-8330)とボーレート(例:115200)を選んで保存 - 「ターミナル」タブでその接続先を選び「シリアル接続」
- xterm に出力が流れ、キー入力がそのまま機器に届く
- 「切断」で
port.Close()、terminal:exitが表示される
まとめ — 承認ゲートウェイはトランスポートを足しても壊れない
- SSH の対話シェルとシリアルは、抽象としては双子。
terminalSession(入力先・出力ストリーム・closer) に押し込めば、入力/リサイズ/切断のコードを丸ごと共有できる。 - リモート出力は Wails イベント + base64 で xterm.js に双方向ストリーミング。
io.Writer(ptyEmitter)を1個作るだけで SSH/シリアル両対応になる。 - SSH/シリアルの切替は接続先の属性。UI はシンプルになり、
StartTerminal(name)がバックエンドで自動ディスパッチする。 - シリアルは対話専用とし、MCP からは拒否。承認モデルの一貫性を保つ。
- フロントは、全画面再描画の中で xterm を移植+再描画抑止で生かす。
新しいトランスポートを足しても、承認ゲートウェイの骨格(状態の唯一の所有者・承認フロー・監査)は一切壊れませんでした。「壊れない設計だったから壊れなかった」のではなく、terminalSession という継ぎ目を1枚かませたからです。
次回(第7回)は、これを Windows / Mac 両対応で配布するまでの全ハマりどころ——cgo・名前付きパイプ・コード署名・GitHub Actions——を扱います。
参照コード(本記事の引用元)
-
terminal.go(270行)— セッション抽象・SSH PTY・シリアル・イベント -
app.go—Connection(Type/SerialPort/BaudRate)、SaveConnection、connectionType()、termMu/term -
storage.go—addColumnIfMissing(追加カラムのマイグレーション) -
mcp_server.go—list_hostsのtype、シリアル拒否ガード -
frontend/src/main.js— xterm 統合(mountTerminal/EventsOn/b64ToBytes/接続先種別フォーム) -
mcp_server_test.go—TestMCPRejectsSerialConnection
逐行の詳細は
docs/解説.md第18章「インタラクティブターミナルとシリアル接続」を参照。
この記事はオープンソース ssh-gate の紹介記事です。
クイックイタレート株式会社
IoT / 電力監視 / AI / 衛星・無線通信 / システムインテグレーション/
ローカル LLM・エージェント基盤に関するお問い合わせはお気軽にどうぞ。
