@minimalcorp/tsunagi 0.1.11 → 0.1.13

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.
Files changed (62) hide show
  1. package/.next/standalone/apps/web/.next/BUILD_ID +1 -1
  2. package/.next/standalone/apps/web/.next/build-manifest.json +3 -3
  3. package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
  5. package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
  13. package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +12 -12
  14. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +12 -12
  15. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  16. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +7 -7
  17. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  18. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  19. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  20. package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
  21. package/.next/standalone/apps/web/.next/server/app/index.rsc +14 -14
  22. package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  23. package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +14 -14
  24. package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +4 -4
  25. package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +7 -7
  26. package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  27. package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  28. package/.next/standalone/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  29. package/.next/standalone/apps/web/.next/server/app/settings.html +1 -1
  30. package/.next/standalone/apps/web/.next/server/app/settings.rsc +14 -14
  31. package/.next/standalone/apps/web/.next/server/app/settings.segments/_full.segment.rsc +14 -14
  32. package/.next/standalone/apps/web/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  33. package/.next/standalone/apps/web/.next/server/app/settings.segments/_index.segment.rsc +7 -7
  34. package/.next/standalone/apps/web/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
  35. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  36. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  37. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  38. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__05isylw._.js +2 -2
  39. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0ro8-ey._.js +2 -2
  40. package/.next/standalone/apps/web/.next/server/chunks/ssr/_075y~8w._.js +1 -1
  41. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0g.cd6a._.js +1 -1
  42. package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
  43. package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
  44. package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
  45. package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
  46. package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
  47. package/.next/standalone/apps/web/.next/static/chunks/{0kzrcfsni_2f9.js → 0092f2_1y376d.js} +2 -2
  48. package/.next/standalone/apps/web/.next/static/chunks/{0025qph152a3w.js → 0120px2-6x5y9.js} +1 -1
  49. package/.next/standalone/apps/web/.next/static/chunks/{041xteb991n6..js → 03lw1.jf3nzsp.js} +1 -1
  50. package/.next/standalone/apps/web/.next/static/chunks/{03gclil1~.ilq.js → 160s33.o3y2-d.js} +2 -2
  51. package/.next/standalone/apps/web/package.json +2 -2
  52. package/dist/cli.js +6 -4
  53. package/dist/server/basic-auth.d.ts +10 -0
  54. package/dist/server/basic-auth.js +53 -0
  55. package/dist/server/index.js +80 -3
  56. package/dist/server/routes/terminal.js +32 -10
  57. package/npm-shrinkwrap.json +134 -81
  58. package/package.json +2 -1
  59. package/tsunagi-marketplace/plugins/tsunagi-plugin/.claude-plugin/plugin.json +23 -23
  60. /package/.next/standalone/apps/web/.next/static/{VrVIYPY4igpYT9v1gtRIl → ry8Ez0jPeeq0ioBJhqhRc}/_buildManifest.js +0 -0
  61. /package/.next/standalone/apps/web/.next/static/{VrVIYPY4igpYT9v1gtRIl → ry8Ez0jPeeq0ioBJhqhRc}/_clientMiddlewareManifest.js +0 -0
  62. /package/.next/standalone/apps/web/.next/static/{VrVIYPY4igpYT9v1gtRIl → ry8Ez0jPeeq0ioBJhqhRc}/_ssgManifest.js +0 -0
@@ -5,9 +5,9 @@
5
5
  "description": "Next.js UI for tsunagi",
6
6
  "type": "module",
7
7
  "scripts": {
8
- "dev": "next dev -p 2791",
8
+ "dev": "next dev -p 2792",
9
9
  "build": "next build",
10
- "start": "next start -p 2791",
10
+ "start": "next start -p 2792",
11
11
  "lint": "eslint",
12
12
  "type-check": "tsc --noEmit"
13
13
  },
package/dist/cli.js CHANGED
@@ -181,16 +181,18 @@ function verifyArtifact(p, label) {
181
181
  }
182
182
  verifyArtifact(FASTIFY_ENTRY_JS, 'Fastify server artifact');
183
183
  verifyArtifact(NEXT_STANDALONE_ENTRY, 'Next.js standalone artifact');
184
+ // Fastify が単一の公開ポート。Next.js は内部ポートで動かし Fastify からプロキシする。
184
185
  const PORT = process.env.PORT ?? '2791';
186
+ const NEXT_PORT = '2792';
185
187
  const fastifyChild = spawn(process.execPath, [FASTIFY_ENTRY_JS], {
186
188
  stdio: ['inherit', 'pipe', 'pipe'],
187
189
  cwd: PACKAGE_ROOT,
188
- env: { ...process.env, NODE_ENV: 'production' },
190
+ env: { ...process.env, NODE_ENV: 'production', PORT, TSUNAGI_NEXT_PORT: NEXT_PORT },
189
191
  });
190
192
  const nextChild = spawn(process.execPath, [NEXT_STANDALONE_ENTRY], {
191
193
  stdio: ['inherit', 'pipe', 'pipe'],
192
194
  cwd: path.dirname(NEXT_STANDALONE_ENTRY),
193
- env: { ...process.env, PORT, NODE_ENV: 'production', HOSTNAME: '0.0.0.0' },
195
+ env: { ...process.env, PORT: NEXT_PORT, NODE_ENV: 'production', HOSTNAME: '0.0.0.0' },
194
196
  });
195
197
  const docsServer = startDocsServer();
196
198
  // ---------------------------------------------------------------------------
@@ -213,7 +215,6 @@ nextChild.stderr?.on('data', (data) => {
213
215
  // ---------------------------------------------------------------------------
214
216
  // Ready detection via /health polling
215
217
  // ---------------------------------------------------------------------------
216
- const SERVER_PORT = 2792;
217
218
  const POLL_INTERVAL_MS = 300;
218
219
  function pollHealth(port) {
219
220
  return new Promise((resolve) => {
@@ -235,7 +236,8 @@ function pollHealth(port) {
235
236
  poll();
236
237
  });
237
238
  }
238
- Promise.all([pollHealth(SERVER_PORT), pollHealth(Number(PORT))]).then(() => {
239
+ // Fastify(PORT) と Next(NEXT_PORT) は各々 /health を持つ。各プロセスを直接叩く。
240
+ Promise.all([pollHealth(Number(PORT)), pollHealth(Number(NEXT_PORT))]).then(() => {
239
241
  spinner.stop();
240
242
  console.log(TSUNAGI_AA);
241
243
  const url = `http://localhost:${PORT}`;
@@ -0,0 +1,10 @@
1
+ import type { IncomingHttpHeaders } from 'node:http';
2
+ export interface BasicAuth {
3
+ /** クライアントへ返す WWW-Authenticate ヘッダ値 */
4
+ readonly challenge: string;
5
+ /** Authorization ヘッダが想定値と一致するか(定数時間比較) */
6
+ isValidHeader(header: string | undefined): boolean;
7
+ /** リクエストを通してよいか(認証成功 or ローカル内部連携の免除) */
8
+ isAuthorized(headers: IncomingHttpHeaders, url: string | undefined, remoteAddress: string | undefined): boolean;
9
+ }
10
+ export declare function createBasicAuth(env?: NodeJS.ProcessEnv): BasicAuth | null;
@@ -0,0 +1,53 @@
1
+ import crypto from 'node:crypto';
2
+ /**
3
+ * Basic 認証。`TSUNAGI_BASIC_AUTH_USER` と `TSUNAGI_BASIC_AUTH_PASSWORD` が
4
+ * 両方設定されているときだけ有効化する(cloudflared 等で外部公開する際の保護)。
5
+ *
6
+ * 単一ポート集約の都合上、cloudflared はアプリと同じ `localhost` から Fastify へ
7
+ * 接続するため、外部アクセスもローカルの内部連携も IP 上は loopback に見える。
8
+ * そこで「外部 = Cloudflare 経由」を Cloudflare が付与するヘッダで判別し、外部からは
9
+ * すべて認証必須にしつつ、ローカルマシン上の Claude Code プラグイン連携
10
+ * (hooks / MCP) や cli の死活監視だけは認証なしで通す。
11
+ */
12
+ // ローカルからのみ認証を免除する内部連携エンドポイント。外部(cloudflared)からは
13
+ // これらであっても認証必須にする。
14
+ const LOCAL_EXEMPT_PREFIXES = ['/health', '/api/hooks', '/api/internal', '/api/mcp'];
15
+ const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
16
+ export function createBasicAuth(env = process.env) {
17
+ const user = env.TSUNAGI_BASIC_AUTH_USER;
18
+ const password = env.TSUNAGI_BASIC_AUTH_PASSWORD;
19
+ if (!user || !password)
20
+ return null;
21
+ const expected = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`;
22
+ const expectedBuf = Buffer.from(expected);
23
+ const isValidHeader = (header) => {
24
+ if (!header)
25
+ return false;
26
+ const given = Buffer.from(header);
27
+ // 長さが異なると timingSafeEqual が throw するため先にガード(長さ差のみ漏れる)。
28
+ return given.length === expectedBuf.length && crypto.timingSafeEqual(given, expectedBuf);
29
+ };
30
+ // Cloudflare(cloudflared)経由のリクエストには Cloudflare が必ず付与するヘッダが付く。
31
+ // Cloudflare はクライアント指定の cf-connecting-ip を上書きするため、外部からは
32
+ // 「ローカルに偽装」できない。
33
+ const isViaCloudflare = (headers) => Boolean(headers['cf-connecting-ip'] || headers['cf-ray'] || headers['x-forwarded-for']);
34
+ const isLoopback = (remoteAddress) => !!remoteAddress && LOOPBACK_ADDRESSES.has(remoteAddress);
35
+ const isLocalExemptPath = (url) => {
36
+ const path = (url ?? '').split('?')[0];
37
+ return LOCAL_EXEMPT_PREFIXES.some((p) => path === p || path.startsWith(`${p}/`));
38
+ };
39
+ return {
40
+ challenge: 'Basic realm="tsunagi", charset="UTF-8"',
41
+ isValidHeader,
42
+ isAuthorized(headers, url, remoteAddress) {
43
+ if (isValidHeader(headers['authorization']))
44
+ return true;
45
+ // 認証情報が無くても、loopback かつ Cloudflare 経由でない(=同一マシン直結の)
46
+ // 内部連携エンドポイントだけは許可する。
47
+ if (isLoopback(remoteAddress) && !isViaCloudflare(headers) && isLocalExemptPath(url)) {
48
+ return true;
49
+ }
50
+ return false;
51
+ },
52
+ };
53
+ }
@@ -1,5 +1,7 @@
1
+ import net from 'node:net';
1
2
  import Fastify from 'fastify';
2
3
  import fastifyCors from '@fastify/cors';
4
+ import httpProxy from '@fastify/http-proxy';
3
5
  import { Server as SocketIOServer } from 'socket.io';
4
6
  import { tasksRoutes } from './routes/tasks.js';
5
7
  import { reposRoutes } from './routes/repos.js';
@@ -13,12 +15,19 @@ import { hooksRoutes } from './routes/hooks.js';
13
15
  import { mcpRoutes } from './routes/mcp.js';
14
16
  import { terminalRoutes } from './routes/terminal.js';
15
17
  import { editorRoutes } from './routes/editor.js';
16
- const PORT = 2792;
18
+ import { createBasicAuth } from './basic-auth.js';
19
+ // Fastify は単一の公開エンドポイント。Next.js は内部ポートで動かしプロキシする。
20
+ const PORT = Number(process.env.PORT) || 2791;
21
+ const NEXT_PORT = Number(process.env.TSUNAGI_NEXT_PORT) || 2792;
17
22
  const extraOrigins = (process.env.TSUNAGI_EXTRA_CORS_ORIGINS ?? '')
18
23
  .split(',')
19
24
  .map((s) => s.trim())
20
25
  .filter(Boolean);
21
- const corsOrigins = ['http://localhost:2791', ...extraOrigins];
26
+ // 本番(cloudflared 等)は同一オリジンで CORS 不要。dev は Next を 2792 で直接開く
27
+ // ため、その origin からの cross-origin リクエストを許可する。
28
+ const corsOrigins = [`http://localhost:${NEXT_PORT}`, ...extraOrigins];
29
+ // TSUNAGI_BASIC_AUTH_USER / TSUNAGI_BASIC_AUTH_PASSWORD が両方ある時だけ有効。
30
+ const basicAuth = createBasicAuth();
22
31
  async function start() {
23
32
  const fastify = Fastify({
24
33
  logger: {
@@ -29,9 +38,31 @@ async function start() {
29
38
  origin: corsOrigins,
30
39
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
31
40
  });
41
+ // Basic 認証(有効時のみ)。CORS の後に登録することで preflight(OPTIONS) は
42
+ // @fastify/cors が先に応答し、認証で弾かれない。
43
+ if (basicAuth) {
44
+ fastify.addHook('onRequest', async (request, reply) => {
45
+ if (basicAuth.isAuthorized(request.headers, request.url, request.socket.remoteAddress)) {
46
+ return;
47
+ }
48
+ reply
49
+ .header('WWW-Authenticate', basicAuth.challenge)
50
+ .code(401)
51
+ .send('Authentication required');
52
+ return reply;
53
+ });
54
+ }
32
55
  const io = new SocketIOServer(fastify.server, {
33
- transports: ['websocket'],
56
+ // polling を許可しておく。iOS Safari/Chrome(WebKit) は Basic 認証のキャッシュ
57
+ // 資格情報を WebSocket ハンドシェイクには付与しない既知の挙動があり、WS のみだと
58
+ // 認証付き公開時に iOS 端末から接続できず "connecting..." のまま固まる。polling は
59
+ // XHR なので Authorization が乗り、認証を通過できる(デスクトップは WS に自動昇格)。
60
+ transports: ['polling', 'websocket'],
34
61
  cors: { origin: corsOrigins },
62
+ // Basic 認証(有効時)。polling は XHR、WS はハンドシェイクの Authorization を検証する。
63
+ allowRequest: basicAuth
64
+ ? (req, callback) => callback(null, basicAuth.isAuthorized(req.headers, req.url, req.socket.remoteAddress))
65
+ : undefined,
35
66
  // 死んだ接続(スリープ・ネットワーク断等)を早めに検出して、ぶら下がった socket が
36
67
  // 保持する PTY の onData/onExit ハンドラを早く解放する。既定 (25s/20s) では検出までに
37
68
  // 最大 ~45s かかり、その間ゾンビ socket がリスナーを溜め込み出力が多重化する。
@@ -52,6 +83,52 @@ async function start() {
52
83
  await fastify.register(mcpRoutes, { prefix: '/api' });
53
84
  await fastify.register(terminalRoutes, { prefix: '/api' });
54
85
  await fastify.register(editorRoutes, { prefix: '/api' });
86
+ // catch-all リバースプロキシ: /api・/socket.io・/health 以外を内部 Next.js へ転送。
87
+ // - /api/* と /health は上で定義済みルートが wildcard より優先される。
88
+ // - /socket.io は Socket.IO が HTTP サーバ層で先取りするためここには来ない。
89
+ // - websocket は false。HMR は下の透過リレーで、Socket.IO は engine.io が終端する。
90
+ // - OPTIONS は除外。@fastify/cors が `OPTIONS *` を登録済みで、proxy が同じ
91
+ // `OPTIONS /*` を登録すると "Method 'OPTIONS' already declared" で起動失敗する。
92
+ // preflight は cors が処理するため proxy 側で OPTIONS を扱う必要はない。
93
+ await fastify.register(httpProxy, {
94
+ upstream: `http://localhost:${NEXT_PORT}`,
95
+ prefix: '/',
96
+ websocket: false,
97
+ httpMethods: ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'],
98
+ });
99
+ // 開発時のみ: Next.js の HMR(WebSocket = /_next/webpack-hmr) を 2791 経由でも
100
+ // 使えるよう透過 TCP リレーする。これにより dev も本番と同じく単一ポート(2791)で
101
+ // 完結し、HMR / API / Socket.IO がすべて同一オリジンで動く。
102
+ //
103
+ // 注意: @fastify/http-proxy の websocket:true は upgrade を Fastify router 経由で
104
+ // ディスパッチするため、/socket.io の upgrade まで catch-all ルートが拾い例外を
105
+ // 投げる(Socket.IO 接続毎にエラーログ)。そのため WS は自前で /_next/webpack-hmr
106
+ // だけを対象にし、/socket.io には一切触れず engine.io に委ねる。
107
+ // 本番(standalone Next)は HMR が無いので何もしない。
108
+ if (process.env.NODE_ENV !== 'production') {
109
+ fastify.server.on('upgrade', (req, socket, head) => {
110
+ if (!req.url?.startsWith('/_next/webpack-hmr'))
111
+ return;
112
+ if (basicAuth && !basicAuth.isAuthorized(req.headers, req.url, req.socket.remoteAddress)) {
113
+ socket.destroy();
114
+ return;
115
+ }
116
+ const upstream = net.connect(NEXT_PORT, '127.0.0.1', () => {
117
+ upstream.write(`${req.method} ${req.url} HTTP/1.1\r\n`);
118
+ const raw = req.rawHeaders;
119
+ for (let i = 0; i < raw.length; i += 2) {
120
+ upstream.write(`${raw[i]}: ${raw[i + 1]}\r\n`);
121
+ }
122
+ upstream.write('\r\n');
123
+ if (head?.length)
124
+ upstream.write(head);
125
+ socket.pipe(upstream);
126
+ upstream.pipe(socket);
127
+ });
128
+ upstream.on('error', () => socket.destroy());
129
+ socket.on('error', () => upstream.destroy());
130
+ });
131
+ }
55
132
  await fastify.listen({ port: PORT, host: '0.0.0.0' });
56
133
  console.log(`Fastify server running on port ${PORT}`);
57
134
  const shutdown = async (signal) => {
@@ -13,14 +13,19 @@ export async function terminalRoutes(fastify) {
13
13
  let dataHandlerDispose = null;
14
14
  let exitHandlerDispose = null;
15
15
  // join: roomに参加する。
16
- // - mode='subscribe': status-changed / todos-updated のブロードキャスト受信のみ。
17
- // PTY IO は接続しない。タスク一覧 / プランナーの購読 hook 用。PTY が未起動でも参加できる。
18
- // - mode='terminal'(既定): PTY の入出力を接続する。TerminalView 用。
19
- socket.on('join', ({ room, mode = 'terminal' }) => {
16
+ // - mode='terminal': PTY の入出力を接続する。TerminalView 用。明示指定が必須。
17
+ // - mode='subscribe'(既定。mode 省略・不明値もここに含む): status-changed /
18
+ // todos-updated のブロードキャスト受信のみ。PTY IO は接続しない。
19
+ // タスク一覧 / プランナーの購読 hook 用。PTY が未起動でも参加できる。
20
+ socket.on('join', ({ room, mode }) => {
20
21
  const sessionId = room.startsWith('tab:') ? room.slice(4) : room;
21
- // 購読のみ: room メンバーシップだけ付与して終了。PTY ハンドラ・input/resize は登録せず、
22
- // boundSessionId もセットしない(disconnect 時に他人のセッションへ GC を仕掛けないため)。
23
- if (mode === 'subscribe') {
22
+ // PTY バインドは mode==='terminal' を明示した socket のみ。
23
+ // mode 省略・不明な値はすべて購読扱いにし、PTY IO を絶対に接続しない。
24
+ // (購読 hook の join が mode 省略で誤って PTY バインドし、input/onData/exit
25
+ // ハンドラ登録や GC 干渉で入力多重化を招く事故を構造的に防ぐ)
26
+ // 購読のみ: room メンバーシップだけ付与して終了。boundSessionId もセットしない
27
+ // (disconnect 時に他人のセッションへ GC を仕掛けないため)。
28
+ if (mode !== 'terminal') {
24
29
  socket.join(room);
25
30
  return;
26
31
  }
@@ -42,6 +47,14 @@ export async function terminalRoutes(fastify) {
42
47
  socket.join(room);
43
48
  // 接続確立 → GCタイマーをキャンセル
44
49
  ptyManager.cancelGc(sessionId);
50
+ // この socket を PTY の所有者(active socket)にする。input の書き込みは所有者の
51
+ // socket からのみ許可し(下記 input ハンドラのゲート参照)、複数 socket が同一 PTY に
52
+ // バインドしても 1 キーストロークが多重書き込みされないことを構造的に保証する。
53
+ const prevOwner = ptyManager.getActiveSocket(sessionId);
54
+ if (prevOwner && prevOwner !== socket.id) {
55
+ console.log(`[terminal] session ${sessionId}: owner ${prevOwner} -> ${socket.id} (previous terminal socket still bound)`);
56
+ }
57
+ ptyManager.setActiveSocket(sessionId, socket.id);
45
58
  const { pty: ptyProcess } = session;
46
59
  const isReused = session.scrollback.length > 0;
47
60
  // リングバッファの内容を一括送信(再接続時の画面復元)
@@ -95,11 +108,14 @@ export async function terminalRoutes(fastify) {
95
108
  }
96
109
  }
97
110
  });
98
- // input イベント: PTYへ書き込み + アクティブソケット追跡
111
+ // input イベント: 所有者 socket からの入力のみ PTY へ書き込む。
112
+ // ゾンビ/非所有 socket(再接続前の旧 socket・誤バインド socket 等)からの重複 input は
113
+ // 破棄し、1 キーストロークの多重書き込み(例: /clear が連続入力される)を防ぐ。
99
114
  socket.on('input', ({ sessionId: sid, data }) => {
100
115
  if (sid !== sessionId)
101
116
  return;
102
- ptyManager.setActiveSocket(sessionId, socket.id);
117
+ if (ptyManager.getActiveSocket(sessionId) !== socket.id)
118
+ return;
103
119
  ptyProcess.write(data);
104
120
  });
105
121
  });
@@ -116,8 +132,14 @@ export async function terminalRoutes(fastify) {
116
132
  dataHandlerDispose?.();
117
133
  exitHandlerDispose?.();
118
134
  if (boundSessionId) {
135
+ // 所有者だった socket の切断時のみ GC をスケジュールする。
136
+ // 再接続で新しい socket が既に所有者になっている場合や、ゾンビ/非所有 socket の
137
+ // 遅延切断では GC を張らない(現役セッションが誤って GC 対象になるのを防ぐ)。
138
+ const wasOwner = ptyManager.getActiveSocket(boundSessionId) === socket.id;
119
139
  ptyManager.clearActiveSocket(boundSessionId, socket.id);
120
- ptyManager.scheduleGc(boundSessionId);
140
+ if (wasOwner) {
141
+ ptyManager.scheduleGc(boundSessionId);
142
+ }
121
143
  }
122
144
  });
123
145
  });