@cocorograph/hub-agent 0.8.23 → 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 +264 -30
- package/src/codex-stream-bridge.mjs +261 -49
- package/src/hooks.mjs +0 -17
- package/src/main.mjs +113 -293
- package/src/plugin-loader.mjs +49 -4
- package/src/plugin-uninstall.mjs +61 -0
- package/src/positive-int-env.mjs +81 -0
- package/src/session-seq.mjs +22 -0
- package/src/tmux.mjs +4 -32
- package/src/warm-bounds-total.mjs +150 -0
- 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
|
@@ -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
|
-
}
|
package/src/plugin-install.mjs
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `hub-agent plugins install <name>` / `uninstall <name>` の実装 (Sprint K)。
|
|
3
|
-
*
|
|
4
|
-
* リポ同梱の `plugins/<name>/` を `~/.hub/plugins/<name>/` にコピーする。
|
|
5
|
-
* uninstall は `~/.hub/plugins/<name>/` を rm -rf する。
|
|
6
|
-
*
|
|
7
|
-
* config.json は plugin によって user 編集が必要なため、existing なら上書き
|
|
8
|
-
* しない (force オプションで強制可)。
|
|
9
|
-
*/
|
|
10
|
-
import { promises as fs } from "node:fs"
|
|
11
|
-
import path from "node:path"
|
|
12
|
-
import { fileURLToPath } from "node:url"
|
|
13
|
-
|
|
14
|
-
import { paths as configPaths } from "./config.mjs"
|
|
15
|
-
|
|
16
|
-
function repoPluginsDir() {
|
|
17
|
-
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "plugins")
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export async function listInstallableExamples() {
|
|
21
|
-
const dir = repoPluginsDir()
|
|
22
|
-
let entries
|
|
23
|
-
try {
|
|
24
|
-
entries = await fs.readdir(dir, { withFileTypes: true })
|
|
25
|
-
} catch {
|
|
26
|
-
return []
|
|
27
|
-
}
|
|
28
|
-
const out = []
|
|
29
|
-
for (const e of entries) {
|
|
30
|
-
if (!e.isDirectory()) continue
|
|
31
|
-
const pluginFile = path.join(dir, e.name, "plugin.mjs")
|
|
32
|
-
try {
|
|
33
|
-
await fs.access(pluginFile)
|
|
34
|
-
out.push(e.name)
|
|
35
|
-
} catch {
|
|
36
|
-
/* plugin.mjs 無いディレクトリは skip */
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return out
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function copyDir(src, dest, { skipExisting = [] } = {}) {
|
|
43
|
-
await fs.mkdir(dest, { recursive: true, mode: 0o700 })
|
|
44
|
-
const entries = await fs.readdir(src, { withFileTypes: true })
|
|
45
|
-
for (const e of entries) {
|
|
46
|
-
const s = path.join(src, e.name)
|
|
47
|
-
const d = path.join(dest, e.name)
|
|
48
|
-
if (e.isDirectory()) {
|
|
49
|
-
await copyDir(s, d, { skipExisting })
|
|
50
|
-
} else {
|
|
51
|
-
if (skipExisting.includes(e.name)) {
|
|
52
|
-
try {
|
|
53
|
-
await fs.access(d)
|
|
54
|
-
continue // 既存ならコピーしない (user 編集を尊重)
|
|
55
|
-
} catch {
|
|
56
|
-
/* fall through to copy */
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
await fs.copyFile(s, d)
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** @returns {Promise<{ source: string, dest: string, copied: boolean }>} */
|
|
65
|
-
export async function installPlugin(name, { force = false } = {}) {
|
|
66
|
-
if (!name || typeof name !== "string") {
|
|
67
|
-
throw new Error("plugin name is required")
|
|
68
|
-
}
|
|
69
|
-
const src = path.join(repoPluginsDir(), name)
|
|
70
|
-
try {
|
|
71
|
-
const st = await fs.stat(src)
|
|
72
|
-
if (!st.isDirectory()) throw new Error("not a directory")
|
|
73
|
-
} catch {
|
|
74
|
-
const available = await listInstallableExamples()
|
|
75
|
-
const hint = available.length ? ` Available: ${available.join(", ")}` : ""
|
|
76
|
-
throw new Error(`plugin "${name}" not found in repo plugins/.${hint}`)
|
|
77
|
-
}
|
|
78
|
-
const dest = path.join(configPaths.pluginsDir, name)
|
|
79
|
-
if (!force) {
|
|
80
|
-
try {
|
|
81
|
-
await fs.access(dest)
|
|
82
|
-
throw new Error(
|
|
83
|
-
`${dest} already exists. Pass --force to overwrite (config.json は常に温存)`,
|
|
84
|
-
)
|
|
85
|
-
} catch (err) {
|
|
86
|
-
if (err.code !== "ENOENT") {
|
|
87
|
-
// dest exists かつ force でない
|
|
88
|
-
if (err.message && err.message.includes("already exists")) throw err
|
|
89
|
-
if (err.code !== undefined) throw err
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
// config.json は既存なら skip して user 編集を温存
|
|
94
|
-
await copyDir(src, dest, { skipExisting: ["config.json"] })
|
|
95
|
-
return { source: src, dest, copied: true }
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export async function uninstallPlugin(name) {
|
|
99
|
-
if (!name || typeof name !== "string") {
|
|
100
|
-
throw new Error("plugin name is required")
|
|
101
|
-
}
|
|
102
|
-
const dest = path.join(configPaths.pluginsDir, name)
|
|
103
|
-
try {
|
|
104
|
-
await fs.rm(dest, { recursive: true, force: true })
|
|
105
|
-
} catch (err) {
|
|
106
|
-
throw new Error(`failed to remove ${dest}: ${err.message}`)
|
|
107
|
-
}
|
|
108
|
-
return { dest, removed: true }
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export const _internal = { repoPluginsDir, copyDir }
|