@cocorograph/hub-agent 0.8.24 → 0.8.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `hub-agent plugins uninstall <name>` の実装。
3
+ *
4
+ * プラグインは利用者が `~/.hub/plugins/<prio>-<name>/` へ自分で配置する運用なので、
5
+ * CLI が提供するのは「読み込み済み一覧 (list)」と「配置済みディレクトリの削除
6
+ * (uninstall)」だけ。リポ同梱 example を配る install 系は持たない。
7
+ *
8
+ * ⚠️ セキュリティ: 引数の `name` はそのままパス結合すると `../` で plugins ディレクトリの
9
+ * 外を再帰削除できてしまう (旧 plugin-install.mjs の uninstallPlugin はこれを塞いでいな
10
+ * かった)。次の 2 段で塞ぐ:
11
+ * 1. ディレクトリ名を `plugin-loader.mjs` の `parsePluginDirName()` で検証する
12
+ * (discovery 側と **同じ 1 つの受理集合** を再利用する。判定を書き写すと
13
+ * 「読める形」と「消せる形」がずれ、置けるのに消せないディレクトリが生まれる)
14
+ * 2. 削除直前に `path.resolve` した結果が plugins ディレクトリ配下に収まっているか
15
+ * を再確認する (形の検証をすり抜けた場合の最後の砦)
16
+ */
17
+ import { promises as fs } from "node:fs"
18
+ import path from "node:path"
19
+
20
+ import { paths as configPaths } from "./config.mjs"
21
+ import { parsePluginDirName } from "./plugin-loader.mjs"
22
+
23
+ /**
24
+ * `~/.hub/plugins/<name>/` を再帰削除する。存在しない場合も成功扱い (冪等)。
25
+ *
26
+ * @param {string} name プラグインディレクトリ名 (`<prio>-<name>` 形式)
27
+ * @returns {Promise<{ dest: string, removed: boolean }>}
28
+ */
29
+ export async function uninstallPlugin(name) {
30
+ if (!name || typeof name !== "string") {
31
+ throw new Error("plugin name is required")
32
+ }
33
+ // ⚠️ 受理集合は loader と共有する 1 つの validator が決める (ここで条件を足さない)。
34
+ if (!parsePluginDirName(name)) {
35
+ throw new Error(
36
+ `invalid plugin name "${name}": expected <prio>-<name> (e.g. 10-my-plugin)`,
37
+ )
38
+ }
39
+
40
+ const pluginsRoot = path.resolve(configPaths.pluginsDir)
41
+ const dest = path.resolve(path.join(pluginsRoot, name))
42
+ // 封じ込めの再確認。ここに来る時点で形は検証済みだが、削除は不可逆なので結合結果も見る。
43
+ // ⚠️ ここで見ているのは「**設定された論理 root** (configPaths.pluginsDir) 配下に収まって
44
+ // いるか」であり、実 (real) パスではない。`path.resolve` は字句正規化で symlink を
45
+ // 解決しないため、root 自体が別ボリュームへの symlink ならその先が正式な plugins root と
46
+ // して扱われる (loader も同じ論理 root を読むので一貫する)。`realpath` による拒否は
47
+ // 入れない — `.hub` や plugins を別ボリュームへ置く環境を壊し、かつ不存在時に ENOENT で
48
+ // 「存在しなくても成功 (冪等)」の契約も壊れるため。
49
+ if (!dest.startsWith(pluginsRoot + path.sep)) {
50
+ throw new Error(`refusing to remove "${dest}": outside of ${pluginsRoot}`)
51
+ }
52
+
53
+ try {
54
+ await fs.rm(dest, { recursive: true, force: true })
55
+ } catch (err) {
56
+ throw new Error(`failed to remove ${dest}: ${err.message}`)
57
+ }
58
+ return { dest, removed: true }
59
+ }
60
+
61
+ export default { uninstallPlugin }
package/src/tmux.mjs CHANGED
@@ -588,7 +588,7 @@ export async function setTmuxGlobalEnv(name, value, opts = {}) {
588
588
  }
589
589
 
590
590
  /**
591
- * 汎用 tmux 実行。`tmux.exec` メッセージから呼び出される。
591
+ * 汎用 tmux 実行 (raw)。任意の tmux サブコマンドを実行して結果を返す低レベル API。
592
592
  *
593
593
  * @returns {Promise<{stdout: string, stderr: string, exit_code: number}>}
594
594
  */
@@ -913,16 +913,9 @@ export async function createSession(name, cwd, opts = {}) {
913
913
  // has-session が非 0 = セッション無し
914
914
  }
915
915
  await execFileP(tmuxBin(opts), ["new-session", "-d", "-s", name, "-c", resolvedCwd])
916
- // 既定では mouse mode を ON にする (スマホの touch swipe → SGR wheel escape を
916
+ // mouse mode を ON にする (スマホの touch swipe → SGR wheel escape を
917
917
  // tmux が copy-mode スクロールとして拾えるようにするため)。tmux 2.1+ で `mouse`
918
918
  // option は session-scoped、`-t <session>` 指定で他 session への副作用なし。
919
- //
920
- // ⚠️ ただしこれは「初期既定値」にすぎない。実際の値は接続してきた端末に応じて
921
- // pty.attach 時に setSessionMouse() で上書きされる: デスクトップ attach は off
922
- // (= マウスのドラッグを xterm のローカル選択にして、コピー奪取・button release
923
- // 取りこぼしによる操作不能を防ぐ。0.5.11 で全 session を mouse on にしたことが
924
- // デスクトップのドラッグ選択を壊した回帰の修正)。スマホ attach は on のまま。
925
- // 旧フロント (mouse フィールド未送信) はこの既定値 on がそのまま使われる。
926
919
  try {
927
920
  await execFileP(tmuxBin(opts), ["set-option", "-t", name, "mouse", "on"])
928
921
  } catch (err) {
@@ -995,27 +988,6 @@ export async function createSession(name, cwd, opts = {}) {
995
988
  }
996
989
  }
997
990
 
998
- /**
999
- * 指定 session の tmux mouse mode を on/off する。pty.attach 時に接続端末の
1000
- * 種別に応じて呼ぶ (デスクトップ=off / スマホ=on)。失敗してもセッションの
1001
- * 基本機能は動くので warn のみで握り潰す。
1002
- * @param {string} name session 名
1003
- * @param {boolean} on true=mouse on / false=mouse off
1004
- * @param {{logger?: any, tmuxBin?: string}} [opts]
1005
- */
1006
- export async function setSessionMouse(name, on, opts = {}) {
1007
- if (!name) return
1008
- const value = on ? "on" : "off"
1009
- try {
1010
- await execFileP(tmuxBin(opts), ["set-option", "-t", name, "mouse", value])
1011
- } catch (err) {
1012
- opts.logger?.warn?.(
1013
- { session: name, mouse: value, err: err?.message || String(err) },
1014
- "tmux set-option mouse failed",
1015
- )
1016
- }
1017
- }
1018
-
1019
991
  /** session_id として安全な形だけ許可する (send-keys へ流すためコマンド注入を防ぐ)。
1020
992
  * Claude の session_id は UUID (ASCII 英数 + ハイフン) なので、それ以外は弾く。 */
1021
993
  export function isSafeSessionId(sessionId) {
@@ -1172,8 +1144,8 @@ const _delay = (ms) => new Promise((r) => setTimeout(r, ms))
1172
1144
  * resumeWithMessage は改行を空白へ畳む単一行用 (誤確定防止) でチャット本文の複数行を壊すため、
1173
1145
  * こちらは **bracketed paste** (ESC[200~ … ESC[201~) で本文を「貼り付け」として送り、改行を
1174
1146
  * 保ったまま入力欄へ入れてから、確定の Enter (CR) を別途送る。これは frontend の通常送信
1175
- * (`\x1b[200~${text}\x1b[201~` → CR) と同じ手順を agent 側で再現したもの。PTY 非依存
1176
- * (tmux send-keys) なので、当該セッションのターミナルを誰も開いていなくても届く。
1147
+ * (`\x1b[200~${text}\x1b[201~` → CR) と同じ手順を agent 側で再現したもの。tmux send-keys
1148
+ * で直接ペインへ届くので、ブラウザ側が何も開いていなくても投入できる。
1177
1149
  *
1178
1150
  * @param {string} name tmux セッション名
1179
1151
  * @param {string} text 投入する本文 (複数行可)
package/src/ws-client.mjs CHANGED
@@ -49,36 +49,17 @@ const STABLE_CONNECTION_MS = 10_000
49
49
  // 当たって連発失敗するため、最低 5s 待つ。30s リトライまで段階的に伸びる。
50
50
  const MIN_BACKOFF_AFTER_5XX_MS = 5_000
51
51
 
52
- // outbound pty.data buffer のサイズ上限。
53
- // WS not OPEN 中に pty.data を捨てると Cockpit 上のターミナル描画が欠落して
54
- // 「動かない / 一部だけ表示される」体感を生むため、reconnect 後に flush する。
55
- // pty.data は冪等で順序維持できれば xterm 側で正しく再現できる。
56
- // 1 frame は典型的に 数十〜数百 bytes (claude TUI の partial redraw)。
57
- // 500 frame ≒ 数十KB を 1 切断あたりの上限とする。
58
- const PTY_BUFFER_MAX_FRAMES = 500
59
- // outbound buffer に残った pty.data が古すぎると、ターミナルの履歴として
60
- // 再生する意味が薄れる (ユーザーが視覚的に「過去のもの」として無視する範囲)。
61
- // 30 秒以上経過した pty.data は flush 時に破棄する。
62
- const PTY_BUFFER_MAX_AGE_MS = 30_000
63
-
64
52
  // agent→browser の制御/イベント系メッセージのうち、WS not OPEN 中に捨てると
65
53
  // Cockpit の挙動が壊れるものを reconnect 越しに保持する (機構③: 本番 agent.log で
66
54
  // `claude.jsonl.event ... "ws send skipped (not open)"` のドロップを観測)。
67
- // - pty.input.ack: 喪失すると browser の pendingAcks が永久滞留し「送信待ち(未配信)」が
68
- // 消えない (S1/S3)。再接続 flush で遅れて届けば即解消する。
69
- // - claude/codex の event / ready / exit / error / permission / input ack,nack:
55
+ // - claude/codex の input ack,nack: 喪失すると browser の pendingAcks が永久滞留し
56
+ // 「送信待ち(未配信)」が消えない (S1/S3)。再接続 flush で遅れて届けば即解消する。
57
+ // - claude/codex の event / ready / exit / error / permission:
70
58
  // 喪失すると新規セッションの楽観バブルが本物へ昇格できず、再 attach も固着する。
71
59
  // - claude.jsonl.event / session.event: TUI と一覧の確定状態を復元する。
72
- // - pty.exit / pty.ready / pty.error: stream ライフサイクルの節目。落とすと browser の
73
- // 再 attach/復帰判断が狂う。
74
- // pty.data とは別バッファにするのは、高頻度な pty.data の洪水で低頻度な ack/event が
75
- // リング evict されるのを防ぐため (pty.data は冪等で最新フレームだけ届けば良いが、
76
- // ack/event は 1 件ずつ意味を持つ)。
60
+ // 高頻度の delta 系はここに積まず (下の isEphemeral*DeltaEvent で除外)、低頻度な
61
+ // ack/event がリング evict されるのを防ぐ (ack/event は 1 件ずつ意味を持つ)。
77
62
  const BUFFERED_CTRL_TYPES = new Set([
78
- "pty.input.ack",
79
- "pty.exit",
80
- "pty.ready",
81
- "pty.error",
82
63
  "claude.jsonl.event",
83
64
  "claude.event",
84
65
  "claude.ready",
@@ -97,7 +78,7 @@ const BUFFERED_CTRL_TYPES = new Set([
97
78
  "codex.input.nack",
98
79
  "session.event",
99
80
  ])
100
- // 制御/イベントバッファの上限。低頻度なので frames は小さめ、age は pty と揃える。
81
+ // 制御/イベントバッファの上限。低頻度なので frames は小さめ。
101
82
  const CTRL_BUFFER_MAX_FRAMES = 200
102
83
  const CTRL_BUFFER_MAX_AGE_MS = 30_000
103
84
  // reconnect 後の buffered event は backend が browser の active stream 集合を
@@ -168,11 +149,8 @@ export class WsClient extends EventEmitter {
168
149
  this.backoff = MIN_BACKOFF_MS
169
150
  this.stopped = false
170
151
  this.startedAt = Date.now()
171
- // pty.data の outbound buffer (WS not OPEN 中に積み、open 後 flush)。
172
- // entry: { obj, ts }
173
- this.ptyOutboundBuffer = []
174
- // 制御/イベント系 (ack / jsonl.event 等) の outbound buffer。pty.data とは別管理で、
175
- // 高頻度 pty.data の洪水に evict されないようにする (機構③)。entry: { obj, ts }
152
+ // 制御/イベント系 (ack / jsonl.event 等) の outbound buffer
153
+ // (WS not OPEN 中に積み、open 後 flush)。entry: { obj, ts }
176
154
  this.ctrlOutboundBuffer = []
177
155
  // reconnect 世代ごとの stream sync barrier。古い request_id の遅延応答で
178
156
  // 現接続の buffer を flush しないよう request_id を照合する。
@@ -282,11 +260,10 @@ export class WsClient extends EventEmitter {
282
260
  available_clis: this.availableClis,
283
261
  ...this.bundleStatus,
284
262
  })
285
- // Plan ε: 毎回 reconnect 直後に backend ↔ agent の stream_id を能動同期する
286
- // (orphan stream の即時 kill 用)。response は main.mjs の dispatch が拾い、
287
- // ptyBridge.list() と差分を取って kill する。request_id は uuid で
288
- // 重複排除。backend が古い (= Plan ε 未対応) なら "unknown_type" error が
289
- // 返るだけで動作には影響しない。
263
+ // Plan ε: 毎回 reconnect 直後に backend ↔ agent の stream_id を能動同期する。
264
+ // response は main.mjs の dispatch が拾い、mode 別 snapshot から外れた
265
+ // chat subscriber を掃除する。request_id は uuid で重複排除。backend が
266
+ // 古い (= Plan ε 未対応) なら "unknown_type" error が返るだけで動作には影響しない。
290
267
  const streamsSyncRequestId = randomUUID()
291
268
  this._sendJson({
292
269
  type: "agent.streams.sync.request",
@@ -298,11 +275,11 @@ export class WsClient extends EventEmitter {
298
275
  this.emit("open")
299
276
  }
300
277
 
301
- /** メッセージを送る。未接続時は pty.data と制御/イベント系 (ack / jsonl.event 等) を
278
+ /** メッセージを送る。未接続時は制御/イベント系 (ack / jsonl.event 等) を
302
279
  * buffer に積み、reconnect 後に flush する。
303
280
  *
304
281
  * heartbeat / hello / agent.streams.sync.* など「古くなると意味が無い」制御系は
305
- * buffer しない (warn のみ)。ack / event 系は 1 件ずつ意味を持つので別バッファに保持する。
282
+ * buffer しない (warn のみ)。ack / event 系は 1 件ずつ意味を持つのでバッファに保持する。
306
283
  */
307
284
  send(obj) {
308
285
  const type = obj?.type
@@ -310,12 +287,8 @@ export class WsClient extends EventEmitter {
310
287
  // sync barrier 中に新しく発生した frame も同じ buffer へ積み、切断中 frame より
311
288
  // 先に送って時系列を逆転させない。heartbeat 等の非 buffer 制御系は通常どおり送る。
312
289
  const holdForStreamsSync = Boolean(this.pendingStreamsSyncRequestId)
313
- && (type === "pty.data" || BUFFERED_CTRL_TYPES.has(type))
290
+ && BUFFERED_CTRL_TYPES.has(type)
314
291
  if (!wsOpen || holdForStreamsSync) {
315
- if (type === "pty.data") {
316
- this._bufferPtyData(obj)
317
- return false
318
- }
319
292
  if (BUFFERED_CTRL_TYPES.has(type)) {
320
293
  // SDK-P2 (2026-07-08): ephemeral な stream_event の content_block_delta (text/thinking
321
294
  // delta) は ctrl バッファに積まない。切断中は生成が続くと大量の delta が流れ、200 frame
@@ -340,50 +313,6 @@ export class WsClient extends EventEmitter {
340
313
  return this._sendJson(obj)
341
314
  }
342
315
 
343
- /** pty.data を outbound buffer に積む。リング (drop oldest on overflow)。 */
344
- _bufferPtyData(obj) {
345
- this.ptyOutboundBuffer.push({ obj, ts: Date.now() })
346
- if (this.ptyOutboundBuffer.length > PTY_BUFFER_MAX_FRAMES) {
347
- const dropped = this.ptyOutboundBuffer.length - PTY_BUFFER_MAX_FRAMES
348
- this.ptyOutboundBuffer.splice(0, dropped)
349
- this.logger?.warn(
350
- { dropped, kept: this.ptyOutboundBuffer.length },
351
- "pty outbound buffer overflow (oldest dropped)"
352
- )
353
- }
354
- }
355
-
356
- /** open 直後に buffer を flush。古すぎる entry は破棄する。 */
357
- _flushPtyBuffer() {
358
- if (this.ptyOutboundBuffer.length === 0) return
359
- const now = Date.now()
360
- // B10: バッファを退避してから処理する。送信失敗 (_sendJson が false) で break
361
- // した場合、未送信の残りフレームを ptyOutboundBuffer の先頭へ戻して順序を保持する
362
- // (空配列で上書きしたまま break すると残りが欠落する)。
363
- const buf = this.ptyOutboundBuffer
364
- this.ptyOutboundBuffer = []
365
- let sent = 0
366
- let expired = 0
367
- for (let i = 0; i < buf.length; i++) {
368
- const entry = buf[i]
369
- if (now - entry.ts > PTY_BUFFER_MAX_AGE_MS) {
370
- expired += 1
371
- continue
372
- }
373
- const ok = this._sendJson(entry.obj)
374
- if (!ok) {
375
- // 未送信分 (現エントリ含む i 以降) を先頭へ戻す。次回 open / flush で再送する。
376
- this.ptyOutboundBuffer = buf.slice(i).concat(this.ptyOutboundBuffer)
377
- break
378
- }
379
- sent += 1
380
- }
381
- this.logger?.info(
382
- { sent, expired, total: buf.length, requeued: this.ptyOutboundBuffer.length },
383
- "pty outbound buffer flushed"
384
- )
385
- }
386
-
387
316
  /** 制御/イベント系 (ack / jsonl.event 等) を outbound buffer に積む。リング (drop oldest)。 */
388
317
  _bufferCtrl(obj) {
389
318
  this.ctrlOutboundBuffer.push({ obj, ts: Date.now() })
@@ -398,7 +327,10 @@ export class WsClient extends EventEmitter {
398
327
  }
399
328
 
400
329
  /** open 直後に制御/イベントバッファを flush。古すぎる entry は破棄する。
401
- * 構造は _flushPtyBuffer と同じ (退避 → 期限切れ skip → 送信失敗で残りを順序保持で戻す)。
330
+ *
331
+ * B10: バッファを退避してから処理する。送信失敗 (_sendJson が false) で break した
332
+ * 場合、未送信の残りフレームを ctrlOutboundBuffer の先頭へ戻して順序を保持する
333
+ * (空配列で上書きしたまま break すると残りが欠落する)。
402
334
  */
403
335
  _flushCtrlBuffer() {
404
336
  if (this.ctrlOutboundBuffer.length === 0) return
@@ -443,7 +375,6 @@ export class WsClient extends EventEmitter {
443
375
  // frame を失うため、2s compatibility timeout まで barrier を維持する。
444
376
  if (activeIds.size === 0) return false
445
377
  this._clearStreamsSyncBarrier()
446
- this._flushPtyBuffer()
447
378
  this._flushCtrlBuffer()
448
379
  return true
449
380
  }
@@ -459,7 +390,6 @@ export class WsClient extends EventEmitter {
459
390
  { request_id: requestId },
460
391
  "streams sync barrier timed out; flushing buffered frames for legacy backend",
461
392
  )
462
- this._flushPtyBuffer()
463
393
  this._flushCtrlBuffer()
464
394
  }, STREAMS_SYNC_BARRIER_TIMEOUT_MS)
465
395
  this.streamsSyncBarrierTimer.unref?.()
@@ -1,71 +0,0 @@
1
- # 10-tailscale-remote
2
-
3
- 自宅 Mac (hub-agent host) を、Tailscale 等で繋がった会社 Mac (`remote_host`) の
4
- tmux に対する透過プロキシ化するサンプル plugin。
5
-
6
- ## 何が起きるか
7
-
8
- | Browser action | 通常 (plugin 無し) | このプラグイン有効時 |
9
- |---|---|---|
10
- | `pty.attach` | ローカル `tmux attach -t SESSION` | `mosh remote_host -- tmux attach -t SESSION` |
11
- | `tmux.exec [...]` | ローカル `tmux ...` | `ssh remote_host tmux ...` |
12
-
13
- つまり、`hub.cocorograph.com/user/cockpit/terminal` から開くセッションが、
14
- あなたの手元 Mac ではなく `remote_host` の tmux に直接繋がります。Cockpit UI の
15
- `session.state` / `tmux.list_sessions` も全て `remote_host` 側の値です。
16
-
17
- ## セットアップ
18
-
19
- 1. ディレクトリを `~/.hub/plugins/10-tailscale-remote/` にコピー
20
- ```bash
21
- # CLI 経由 (推奨)
22
- hub-agent plugins install 10-tailscale-remote
23
-
24
- # 手動
25
- mkdir -p ~/.hub/plugins/10-tailscale-remote
26
- cp -r $(npm root -g)/@cocorograph/hub-agent/plugins/10-tailscale-remote/* \
27
- ~/.hub/plugins/10-tailscale-remote/
28
- ```
29
-
30
- 2. `config.json` を編集
31
- ```bash
32
- cp ~/.hub/plugins/10-tailscale-remote/config.example.json \
33
- ~/.hub/plugins/10-tailscale-remote/config.json
34
- $EDITOR ~/.hub/plugins/10-tailscale-remote/config.json
35
- ```
36
-
37
- ```jsonc
38
- {
39
- "remote_host": "mbpm5-office", // ssh ホスト名
40
- "ssh_key": "/Users/kaz/.ssh/mbpm5-office/id_ed25519",
41
- "mosh_server": "/opt/homebrew/bin/mosh-server" // remote_host 側のパス
42
- }
43
- ```
44
-
45
- 3. remote 側の準備
46
- - 同じユーザーで ssh 鍵ログイン可能であること
47
- - `tmux` + `mosh-server` がインストール済みであること
48
- - tmux session が既に走っていること (or `hub-agent` の `tmux.create_session` 経由で作る)
49
-
50
- 4. hub-agent restart
51
- ```bash
52
- launchctl kickstart -k gui/$UID/co.cocorograph.hub-agent # macOS
53
- # or
54
- systemctl --user restart hub-agent.service # Linux
55
- ```
56
-
57
- 5. agent log で `tailscale-remote-ops 有効` の表示を確認
58
- ```bash
59
- tail -f ~/.hub/agent.log
60
- ```
61
-
62
- ## トラブルシューティング
63
-
64
- - `config.json` 無し → plugin は no-op で読み込まれます (`agent.log` に WARN)
65
- - ssh が「Host key verification failed」→ `ssh remote_host` で 1 度手動接続して known_hosts に追加
66
- - mosh が `Did not find mosh server startup message` → remote_host の `mosh_server` パスを確認 (`which mosh-server`)
67
- - Tailscale が落ちて remote_host に届かない → cockpit terminal を開くと WS 接続自体は成功するが pty が即 exit する
68
-
69
- ## 仕様書
70
-
71
- [`ナレッジ/インフラ/cockpit-hub-hosted-integration-spec`](https://hub.cocorograph.com/knowledge/spec/cockpit-hub-hosted-integration-spec) (id=6080)
@@ -1,6 +0,0 @@
1
- {
2
- "_comment": "このファイルを config.json にコピーしてから編集してください。",
3
- "remote_host": "mbpm5-office",
4
- "ssh_key": "/Users/kaz/.ssh/mbpm5-office/id_ed25519",
5
- "mosh_server": "/opt/homebrew/bin/mosh-server"
6
- }
@@ -1,99 +0,0 @@
1
- /**
2
- * tailscale-remote: 自宅 Mac (hub-agent host) を 会社 Mac (remote_host) の
3
- * tmux に対する透過プロキシ化するプラグイン (Sprint K サンプル)。
4
- *
5
- * - `interceptPtySpawn`: ブラウザの attach を mosh + ssh + tmux attach に差し替え
6
- * - `interceptTmuxExec`: tmux.* RPC (list/create/kill 等) を ssh 越しに発行
7
- *
8
- * 使い方:
9
- * 1. `~/.hub/plugins/10-tailscale-remote/` にこのファイル + config.json を置く
10
- * (`hub-agent plugins install 10-tailscale-remote` で自動コピー可)
11
- * 2. config.json の remote_host / ssh_key / mosh_server を埋める
12
- * 3. hub-agent restart (`hub-agent install-service` してあれば
13
- * `launchctl kickstart -k gui/$UID/co.cocorograph.hub-agent`)
14
- *
15
- * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
16
- */
17
- import { readFileSync } from "node:fs"
18
-
19
- const cfgUrl = new URL("./config.json", import.meta.url)
20
- let cfg = { remote_host: "", ssh_key: "", mosh_server: "/opt/homebrew/bin/mosh-server" }
21
- try {
22
- cfg = { ...cfg, ...JSON.parse(readFileSync(cfgUrl, "utf-8")) }
23
- } catch (err) {
24
- // config.json が無い場合は plugin を no-op で読み込む (hook は何も上書きしない)
25
- // user が編集してから restart する想定
26
- cfg.__error = err.message
27
- }
28
-
29
- function isConfigured() {
30
- return Boolean(cfg.remote_host && cfg.ssh_key && !cfg.__error)
31
- }
32
-
33
- function sshOpts() {
34
- return ["-o", "IdentityAgent=none", "-i", cfg.ssh_key]
35
- }
36
-
37
- export default {
38
- name: "tailscale-remote-ops",
39
- hooks: {
40
- async onAgentStart({ logger }) {
41
- if (cfg.__error) {
42
- logger?.warn(
43
- { err: cfg.__error },
44
- "tailscale-remote-ops: config.json が読めません。plugin は no-op で動作します",
45
- )
46
- return
47
- }
48
- if (!isConfigured()) {
49
- logger?.warn(
50
- { config: cfg },
51
- "tailscale-remote-ops: remote_host / ssh_key が未設定。plugin は no-op",
52
- )
53
- return
54
- }
55
- logger?.info(
56
- { remote_host: cfg.remote_host },
57
- "tailscale-remote-ops 有効: pty.attach / tmux.exec を ssh 越しに発行します",
58
- )
59
- },
60
-
61
- /**
62
- * Browser からの attach を mosh + ssh + tmux attach に差し替える。
63
- * pty bridge は通常通り node-pty で /bin/sh を spawn するが、その中身が
64
- * remote_host への mosh ssh セッションになる。
65
- */
66
- async interceptPtySpawn({ sessionName }) {
67
- if (!isConfigured()) return null
68
- const sshOpt = sshOpts().join(" ")
69
- const command = "/bin/sh"
70
- const inner = [
71
- `exec mosh`,
72
- `--predict=adaptive`,
73
- `--server=${cfg.mosh_server}`,
74
- `--ssh="ssh ${sshOpt}"`,
75
- cfg.remote_host,
76
- `--`,
77
- `bash -l -c "tmux attach -t ${sessionName}"`,
78
- ].join(" ")
79
- return {
80
- command,
81
- args: ["-c", inner],
82
- }
83
- },
84
-
85
- /**
86
- * tmux.* RPC (list-sessions / new-session / kill-session 等) を
87
- * ssh 越しに remote_host で実行する。
88
- */
89
- async interceptTmuxExec({ args }) {
90
- if (!isConfigured()) return null
91
- return {
92
- command: "ssh",
93
- args: [...sshOpts(), cfg.remote_host, "tmux", ...args],
94
- }
95
- },
96
- },
97
- }
98
-
99
- export const _internal = { cfg, isConfigured, sshOpts }
@@ -1,89 +0,0 @@
1
- #!/usr/bin/env node
2
- // pnpm 経由インストールだと prebuilds/*/spawn-helper の実行権限が剥がれて
3
- // posix_spawnp が失敗する事象への workaround. postinstall で確実に +x を付与する。
4
- //
5
- // PTY backend は @lydell/node-pty (Microsoft 公式の fd-leak 修正フォーク) に移行済み。
6
- // prebuild は @lydell/node-pty-<platform>/prebuilds/<platform>/spawn-helper に入る
7
- // (npm install では +x 付きで展開されるが、pnpm 等で剥がれるケースに備えて修復する)。
8
- // 旧 node-pty の配置も後方互換で走査する。
9
- import { readdirSync, statSync, chmodSync } from "node:fs";
10
- import { join } from "node:path";
11
-
12
- const PNPM_GLOB = "node_modules/.pnpm";
13
- // prebuilds ディレクトリの候補 (node-pty 旧配置 + @lydell platform パッケージ)。
14
- const FALLBACK_DIRS = ["node_modules/node-pty/prebuilds"];
15
-
16
- function fixDir(dir) {
17
- let count = 0;
18
- for (const platform of readdirSync(dir)) {
19
- const helper = join(dir, platform, "spawn-helper");
20
- try {
21
- const st = statSync(helper);
22
- if (!(st.mode & 0o111)) {
23
- chmodSync(helper, st.mode | 0o755);
24
- console.log(`[fix-perms] chmod +x ${helper}`);
25
- count++;
26
- }
27
- } catch {
28
- // not present (e.g. win32 dir has no spawn-helper)
29
- }
30
- }
31
- return count;
32
- }
33
-
34
- // @lydell の platform パッケージ (@lydell/node-pty-darwin-arm64 等) を走査する。
35
- function findLydellPrebuilds() {
36
- const out = [];
37
- const scopeDir = "node_modules/@lydell";
38
- try {
39
- for (const entry of readdirSync(scopeDir)) {
40
- if (!entry.startsWith("node-pty-")) continue; // platform バイナリパッケージのみ
41
- const p = join(scopeDir, entry, "prebuilds");
42
- try {
43
- statSync(p);
44
- out.push(p);
45
- } catch { /* prebuilds 無し */ }
46
- }
47
- } catch { /* @lydell scope 無し */ }
48
- return out;
49
- }
50
-
51
- function findPnpmPrebuilds() {
52
- const out = [];
53
- try {
54
- for (const entry of readdirSync(PNPM_GLOB)) {
55
- let inner = null;
56
- if (entry.startsWith("node-pty@")) {
57
- // 旧 node-pty: node_modules/.pnpm/node-pty@x/node_modules/node-pty/prebuilds
58
- inner = "node_modules/node-pty/prebuilds";
59
- } else if (entry.startsWith("@lydell+node-pty-")) {
60
- // @lydell platform: @lydell+node-pty-darwin-arm64@x → pkg 名 = node-pty-darwin-arm64
61
- const pkg = entry.slice("@lydell+".length).split("@")[0];
62
- inner = join("node_modules/@lydell", pkg, "prebuilds");
63
- } else {
64
- continue;
65
- }
66
- const p = join(PNPM_GLOB, entry, inner);
67
- try {
68
- statSync(p);
69
- out.push(p);
70
- } catch { /* skip */ }
71
- }
72
- } catch { /* no pnpm dir */ }
73
- return out;
74
- }
75
-
76
- let fixed = 0;
77
- for (const dir of [...findPnpmPrebuilds(), ...findLydellPrebuilds()]) {
78
- fixed += fixDir(dir);
79
- }
80
- for (const fallback of FALLBACK_DIRS) {
81
- try {
82
- statSync(fallback);
83
- fixed += fixDir(fallback);
84
- } catch { /* not hoisted */ }
85
- }
86
-
87
- if (fixed === 0) {
88
- // 何も修正しなかった = 既に OK or node-pty 未インストール。サイレント終了。
89
- }