@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.
- package/README.md +13 -12
- package/bin/hub-agent.mjs +7 -37
- package/package.json +2 -5
- package/scripts/install.sh +5 -5
- package/src/claude-stream-bridge.mjs +2 -3
- package/src/codex-stream-bridge.mjs +1 -1
- package/src/hooks.mjs +0 -17
- package/src/main.mjs +91 -291
- package/src/plugin-loader.mjs +49 -4
- package/src/plugin-uninstall.mjs +61 -0
- package/src/tmux.mjs +4 -32
- package/src/ws-client.mjs +19 -89
- package/plugins/10-tailscale-remote/README.md +0 -71
- package/plugins/10-tailscale-remote/config.example.json +0 -6
- package/plugins/10-tailscale-remote/plugin.mjs +0 -99
- package/scripts/fix-node-pty-perms.mjs +0 -89
- package/src/plugin-install.mjs +0 -111
- package/src/pty-bridge.mjs +0 -510
|
@@ -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
|
|
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
|
-
//
|
|
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 側で再現したもの。
|
|
1176
|
-
*
|
|
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
|
-
// -
|
|
68
|
-
//
|
|
69
|
-
// - claude/codex の event / ready / exit / error / permission
|
|
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
|
-
//
|
|
73
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
&&
|
|
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
|
-
*
|
|
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,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
|
-
}
|