chocomint 1.0.0

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 (158) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +224 -0
  4. data/THIRD_PARTY_LICENSES.md +45 -0
  5. data/bin/chocomint +37 -0
  6. data/bin/chocomint-console-worker +35 -0
  7. data/bin/chocomint-server +43 -0
  8. data/config/config.yml +37 -0
  9. data/lib/chocomint/config.rb +74 -0
  10. data/lib/chocomint/console/conpty.rb +296 -0
  11. data/lib/chocomint/console/server.rb +164 -0
  12. data/lib/chocomint/console/worker.rb +88 -0
  13. data/lib/chocomint/console/worker_process.rb +254 -0
  14. data/lib/chocomint/edit_git_handlers.rb +397 -0
  15. data/lib/chocomint/edit_handlers.rb +696 -0
  16. data/lib/chocomint/edit_html.rb +9 -0
  17. data/lib/chocomint/errors.rb +18 -0
  18. data/lib/chocomint/factory.rb +145 -0
  19. data/lib/chocomint/llm/base_client.rb +113 -0
  20. data/lib/chocomint/llm/chat_client.rb +206 -0
  21. data/lib/chocomint/llm/primary_client.rb +320 -0
  22. data/lib/chocomint/llm/verifier_client.rb +127 -0
  23. data/lib/chocomint/logger/sqlite_logger.rb +245 -0
  24. data/lib/chocomint/orchestrator.rb +175 -0
  25. data/lib/chocomint/planner.rb +241 -0
  26. data/lib/chocomint/security/path_guard.rb +84 -0
  27. data/lib/chocomint/server.rb +863 -0
  28. data/lib/chocomint/tools/append_file.rb +50 -0
  29. data/lib/chocomint/tools/base.rb +88 -0
  30. data/lib/chocomint/tools/delete_file.rb +42 -0
  31. data/lib/chocomint/tools/edit.rb +65 -0
  32. data/lib/chocomint/tools/fetch_url.rb +156 -0
  33. data/lib/chocomint/tools/file_info.rb +54 -0
  34. data/lib/chocomint/tools/glob.rb +58 -0
  35. data/lib/chocomint/tools/grep.rb +78 -0
  36. data/lib/chocomint/tools/list_dir.rb +41 -0
  37. data/lib/chocomint/tools/ls.rb +51 -0
  38. data/lib/chocomint/tools/make_dir.rb +44 -0
  39. data/lib/chocomint/tools/read_file.rb +45 -0
  40. data/lib/chocomint/tools/registry.rb +29 -0
  41. data/lib/chocomint/tools/run_command.rb +96 -0
  42. data/lib/chocomint/tools/shell.rb +169 -0
  43. data/lib/chocomint/tools/write_file.rb +54 -0
  44. data/lib/chocomint/validator/machine_validator.rb +65 -0
  45. data/lib/chocomint/validator/semantic_validator.rb +52 -0
  46. data/lib/chocomint.rb +51 -0
  47. data/public/edit/edit.css +414 -0
  48. data/public/edit/edit.js +2137 -0
  49. data/public/edit/index.html +251 -0
  50. data/public/vendor/monaco/LICENSE.txt +23 -0
  51. data/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. data/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  53. data/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  54. data/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  55. data/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  56. data/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  57. data/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  58. data/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  59. data/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  60. data/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  61. data/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  62. data/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  63. data/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  64. data/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  65. data/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  66. data/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  67. data/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  68. data/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  69. data/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  70. data/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  71. data/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  72. data/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  73. data/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  74. data/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  75. data/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  76. data/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  77. data/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  78. data/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  79. data/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  80. data/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  81. data/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  82. data/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  83. data/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  84. data/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  85. data/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  86. data/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  87. data/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  88. data/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  89. data/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  90. data/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  91. data/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  92. data/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  93. data/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  94. data/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  95. data/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  96. data/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  97. data/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  98. data/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  99. data/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  100. data/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  101. data/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  102. data/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  103. data/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  104. data/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  105. data/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  106. data/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  107. data/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  108. data/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  109. data/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  110. data/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  111. data/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  112. data/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  113. data/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  114. data/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  115. data/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  116. data/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  117. data/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  118. data/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  119. data/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  120. data/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  121. data/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  122. data/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  123. data/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  124. data/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  125. data/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  126. data/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  127. data/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  128. data/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  129. data/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  130. data/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  131. data/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  132. data/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  133. data/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  134. data/public/vendor/monaco/vs/editor/editor.main.css +8 -0
  135. data/public/vendor/monaco/vs/editor/editor.main.js +798 -0
  136. data/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
  137. data/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  138. data/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  139. data/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  140. data/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  141. data/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  142. data/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  143. data/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  144. data/public/vendor/monaco/vs/loader.js +11 -0
  145. data/public/vendor/monaco/vs/nls.messages.de.js +21 -0
  146. data/public/vendor/monaco/vs/nls.messages.es.js +21 -0
  147. data/public/vendor/monaco/vs/nls.messages.fr.js +19 -0
  148. data/public/vendor/monaco/vs/nls.messages.it.js +19 -0
  149. data/public/vendor/monaco/vs/nls.messages.ja.js +21 -0
  150. data/public/vendor/monaco/vs/nls.messages.ko.js +19 -0
  151. data/public/vendor/monaco/vs/nls.messages.ru.js +21 -0
  152. data/public/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
  153. data/public/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
  154. data/public/vendor/xterm/LICENSE +25 -0
  155. data/public/vendor/xterm/addon-fit.js +2 -0
  156. data/public/vendor/xterm/xterm.css +218 -0
  157. data/public/vendor/xterm/xterm.js +2 -0
  158. metadata +318 -0
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+ require_relative "../errors"
5
+
6
+ module Chocomint
7
+ module Console
8
+ # Windows の擬似コンソール (ConPTY) を FFI で直接叩き、対話シェルを
9
+ # 本物の tty として起動するラッパー。xterm.js のバックエンドに使う。
10
+ #
11
+ # 重要な前提 (スパイク検証で判明): CreatePseudoConsole を呼ぶプロセスは
12
+ # 自身がコンソールにアタッチされている必要がある。コンソールを持たない
13
+ # プロセス (CREATE_NO_WINDOW で起動された等) から生成すると、初期 VT
14
+ # シーケンスは出るが子プロセスの出力が out パイプに流れてこない。
15
+ # そのため本クラスを使うワーカーは必ずコンソール付きで起動すること
16
+ # (例: 非表示ウィンドウの新規コンソール)。詳細は console_worker を参照。
17
+ #
18
+ # セキュリティ注意: 生の対話 tty は PathGuard / allowed_commands の監督外で
19
+ # 任意コマンドを実行できる。信頼できるローカル環境専用 (127.0.0.1 限定 +
20
+ # トークン認証) で使うこと。DESIGN §13 の shell 展開禁止原則の例外。
21
+ class ConPTY
22
+ # Win32 API 束縛。FFI に size/alignment を委ね、手動オフセットを排除する。
23
+ module Native
24
+ extend FFI::Library
25
+ ffi_lib "kernel32"
26
+ ffi_convention :stdcall
27
+
28
+ # STARTUPINFOW (x64)。フィールド順は Win32 定義に厳密に一致させる。
29
+ class StartupInfoW < FFI::Struct
30
+ layout(
31
+ :cb, :uint32,
32
+ :lpReserved, :pointer,
33
+ :lpDesktop, :pointer,
34
+ :lpTitle, :pointer,
35
+ :dwX, :uint32,
36
+ :dwY, :uint32,
37
+ :dwXSize, :uint32,
38
+ :dwYSize, :uint32,
39
+ :dwXCountChars, :uint32,
40
+ :dwYCountChars, :uint32,
41
+ :dwFillAttribute, :uint32,
42
+ :dwFlags, :uint32,
43
+ :wShowWindow, :uint16,
44
+ :cbReserved2, :uint16,
45
+ :lpReserved2, :pointer,
46
+ :hStdInput, :pointer,
47
+ :hStdOutput, :pointer,
48
+ :hStdError, :pointer
49
+ )
50
+ end
51
+
52
+ # STARTUPINFOEXW = STARTUPINFOW + lpAttributeList。ネストで正しいオフセットに。
53
+ class StartupInfoExW < FFI::Struct
54
+ layout :StartupInfo, StartupInfoW, :lpAttributeList, :pointer
55
+ end
56
+
57
+ class ProcessInformation < FFI::Struct
58
+ layout :hProcess, :pointer, :hThread, :pointer,
59
+ :dwProcessId, :uint32, :dwThreadId, :uint32
60
+ end
61
+
62
+ EXTENDED_STARTUPINFO_PRESENT = 0x00080000
63
+ PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016
64
+ LMEM_ZEROINIT = 0x0040
65
+ WAIT_TIMEOUT = 0x102
66
+
67
+ attach_function :CreatePipe, %i[pointer pointer pointer uint32], :int32
68
+ # COORD(80x25 等) は 4 バイトなので uint32 に X:lo16 / Y:hi16 でパックして値渡し。
69
+ attach_function :CreatePseudoConsole, %i[uint32 pointer pointer uint32 pointer], :long
70
+ attach_function :ResizePseudoConsole, %i[pointer uint32], :long
71
+ attach_function :ClosePseudoConsole, [:pointer], :void
72
+ attach_function :InitializeProcThreadAttributeList,
73
+ %i[pointer uint32 uint32 pointer], :int32
74
+ attach_function :UpdateProcThreadAttribute,
75
+ %i[pointer uint32 uintptr_t pointer size_t pointer pointer], :int32
76
+ attach_function :DeleteProcThreadAttributeList, [:pointer], :void
77
+ attach_function :CreateProcessW,
78
+ %i[pointer pointer pointer pointer int32 uint32
79
+ pointer pointer pointer pointer], :int32
80
+ attach_function :ReadFile, %i[pointer pointer uint32 pointer pointer], :int32
81
+ attach_function :WriteFile, %i[pointer pointer uint32 pointer pointer], :int32
82
+ attach_function :PeekNamedPipe,
83
+ %i[pointer pointer uint32 pointer pointer pointer], :int32
84
+ attach_function :WaitForSingleObject, %i[pointer uint32], :uint32
85
+ attach_function :GetExitCodeProcess, %i[pointer pointer], :int32
86
+ attach_function :TerminateProcess, %i[pointer uint32], :int32
87
+ attach_function :CloseHandle, [:pointer], :int32
88
+ attach_function :GetLastError, [], :uint32
89
+ attach_function :LocalAlloc, %i[uint32 size_t], :pointer
90
+ end
91
+
92
+ class Error < Chocomint::Error; end
93
+
94
+ # 出力を UTF-8 に固定する (-NoExit で初期コマンド実行後もセッションを継続)。
95
+ # これをしないと OutputEncoding が環境依存になり、日本語が化ける (âââ 等)。
96
+ DEFAULT_SHELL = %(pwsh.exe -NoProfile -NoLogo -NoExit ) +
97
+ %(-Command "[Console]::OutputEncoding=[Text.Encoding]::UTF8;) +
98
+ %($OutputEncoding=[Text.Encoding]::UTF8;chcp 65001 > $null;Clear-Host")
99
+ STILL_ACTIVE = 259
100
+
101
+ # COORD を CreatePseudoConsole/ResizePseudoConsole 用の DWORD にパックする。
102
+ def self.pack_size(cols, rows)
103
+ (rows << 16) | cols
104
+ end
105
+
106
+ # 起動せずにインスタンスだけ作る。実際の起動は #open で行う。
107
+ # command: 起動するシェル。cwd: 作業ディレクトリ (nil で継承)。
108
+ def initialize(command: DEFAULT_SHELL, cols: 80, rows: 25, cwd: nil)
109
+ @command = command
110
+ @cols = cols
111
+ @rows = rows
112
+ @cwd = cwd
113
+ @open = false
114
+ end
115
+
116
+ attr_reader :cols, :rows
117
+
118
+ def open?
119
+ @open
120
+ end
121
+
122
+ # 擬似コンソールを作りシェルを起動する。二重 open は禁止。
123
+ def open
124
+ raise Error, "already open" if @open
125
+
126
+ create_pipes
127
+ create_pseudo_console
128
+ build_attribute_list
129
+ spawn_process
130
+ # 親側で不要なハンドルを閉じる。out_write を閉じないと EOF が来ない。
131
+ Native.CloseHandle(@out_write)
132
+ Native.CloseHandle(@in_read)
133
+ @out_write = nil
134
+ @in_read = nil
135
+ @open = true
136
+ self
137
+ end
138
+
139
+ # シェルへ書き込む (xterm.js のキー入力に相当)。バイト数を返す。
140
+ def write(data)
141
+ ensure_open
142
+ bytes = data.to_s.b
143
+ return 0 if bytes.empty?
144
+
145
+ buf = FFI::MemoryPointer.new(:uint8, bytes.bytesize)
146
+ buf.put_bytes(0, bytes)
147
+ written = FFI::MemoryPointer.new(:uint32)
148
+ ok = Native.WriteFile(@in_write, buf, bytes.bytesize, written, nil)
149
+ raise Error, "WriteFile failed (#{Native.GetLastError})" if ok.zero?
150
+
151
+ written.read(:uint32)
152
+ end
153
+
154
+ # 現在パイプに来ている出力をノンブロッキングで読む。無ければ "" を返す
155
+ # (バイナリエンコーディング; VT シーケンスを含む生ストリーム)。
156
+ def read_available(max = 65_536)
157
+ ensure_open
158
+ avail = FFI::MemoryPointer.new(:uint32)
159
+ peek_ok = Native.PeekNamedPipe(@out_read, nil, 0, nil, avail, nil)
160
+ # PeekNamedPipe 失敗はパイプが壊れた (子終了) とみなす。
161
+ return nil if peek_ok.zero?
162
+
163
+ n = avail.read(:uint32)
164
+ return "".b if n.zero?
165
+
166
+ want = [n, max].min
167
+ buf = FFI::MemoryPointer.new(:uint8, want)
168
+ read = FFI::MemoryPointer.new(:uint32)
169
+ read_ok = Native.ReadFile(@out_read, buf, want, read, nil)
170
+ return nil if read_ok.zero?
171
+
172
+ buf.get_bytes(0, read.read(:uint32))
173
+ end
174
+
175
+ # 端末サイズを変更する (xterm.js のリサイズに追従)。
176
+ def resize(cols, rows)
177
+ ensure_open
178
+ hr = Native.ResizePseudoConsole(@hpcon, self.class.pack_size(cols, rows))
179
+ raise Error, "ResizePseudoConsole failed (hr=0x#{hr.to_s(16)})" unless hr.zero?
180
+
181
+ @cols = cols
182
+ @rows = rows
183
+ end
184
+
185
+ # 子プロセスが生きているか。
186
+ def alive?
187
+ return false unless @open
188
+
189
+ code = FFI::MemoryPointer.new(:uint32)
190
+ return false if Native.GetExitCodeProcess(@proc_handle, code).zero?
191
+
192
+ code.read(:uint32) == STILL_ACTIVE
193
+ end
194
+
195
+ # 子の終了コード (まだ生きているなら nil)。
196
+ def exit_code
197
+ return nil unless @open
198
+
199
+ code = FFI::MemoryPointer.new(:uint32)
200
+ return nil if Native.GetExitCodeProcess(@proc_handle, code).zero?
201
+
202
+ value = code.read(:uint32)
203
+ value == STILL_ACTIVE ? nil : value
204
+ end
205
+
206
+ # 擬似コンソールとプロセスを閉じ、ハンドルを解放する。冪等。
207
+ def close
208
+ return unless @open
209
+
210
+ Native.TerminateProcess(@proc_handle, 0) if alive?
211
+ Native.ClosePseudoConsole(@hpcon) if @hpcon
212
+ Native.CloseHandle(@in_write) if @in_write
213
+ Native.CloseHandle(@out_read) if @out_read
214
+ Native.CloseHandle(@proc_handle) if @proc_handle
215
+ Native.DeleteProcThreadAttributeList(@attr_list) if @attr_list
216
+ @open = false
217
+ nil
218
+ end
219
+
220
+ private
221
+
222
+ def ensure_open
223
+ raise Error, "not open" unless @open
224
+ end
225
+
226
+ def create_pipes
227
+ # 公式 ConPTY サンプル準拠: パイプは継承フラグなし。ハンドルは ConPTY 属性
228
+ # 経由でのみ子へ渡す (CreateProcess の bInheritHandles も FALSE)。
229
+ in_read = FFI::MemoryPointer.new(:pointer)
230
+ in_write = FFI::MemoryPointer.new(:pointer)
231
+ out_read = FFI::MemoryPointer.new(:pointer)
232
+ out_write = FFI::MemoryPointer.new(:pointer)
233
+ raise Error, "CreatePipe(in) failed" if Native.CreatePipe(in_read, in_write, nil, 0).zero?
234
+ raise Error, "CreatePipe(out) failed" if Native.CreatePipe(out_read, out_write, nil, 0).zero?
235
+
236
+ @in_read = in_read.read_pointer
237
+ @in_write = in_write.read_pointer
238
+ @out_read = out_read.read_pointer
239
+ @out_write = out_write.read_pointer
240
+ end
241
+
242
+ def create_pseudo_console
243
+ hpc = FFI::MemoryPointer.new(:pointer)
244
+ hr = Native.CreatePseudoConsole(
245
+ self.class.pack_size(@cols, @rows), @in_read, @out_write, 0, hpc
246
+ )
247
+ raise Error, "CreatePseudoConsole failed (hr=0x#{hr.to_s(16)})" unless hr.zero?
248
+
249
+ @hpcon = hpc.read_pointer
250
+ end
251
+
252
+ def build_attribute_list
253
+ lp = FFI::MemoryPointer.new(:size_t)
254
+ Native.InitializeProcThreadAttributeList(nil, 1, 0, lp) # サイズ取得(1回目は失敗が正常)
255
+ @attr_list = Native.LocalAlloc(Native::LMEM_ZEROINIT, lp.read(:size_t))
256
+ raise Error, "LocalAlloc failed" if @attr_list.null?
257
+ if Native.InitializeProcThreadAttributeList(@attr_list, 1, 0, lp).zero?
258
+ raise Error, "InitializeProcThreadAttributeList failed"
259
+ end
260
+
261
+ # lpValue には HPCON 値そのもの(=ハンドル)を渡す。cbSize はポインタ幅。
262
+ ok = Native.UpdateProcThreadAttribute(
263
+ @attr_list, 0, Native::PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
264
+ @hpcon, FFI::Pointer.size, nil, nil
265
+ )
266
+ raise Error, "UpdateProcThreadAttribute failed (#{Native.GetLastError})" if ok.zero?
267
+ end
268
+
269
+ def spawn_process
270
+ si = Native::StartupInfoExW.new
271
+ si[:StartupInfo][:cb] = Native::StartupInfoExW.size
272
+ si[:lpAttributeList] = @attr_list
273
+ pi = Native::ProcessInformation.new
274
+
275
+ cmd_w = @command.encode("UTF-16LE").b + "\x00\x00".b
276
+ cmd_ptr = FFI::MemoryPointer.new(:uint8, cmd_w.bytesize)
277
+ cmd_ptr.put_bytes(0, cmd_w)
278
+ cwd_ptr = nil
279
+ if @cwd
280
+ cwd_w = @cwd.encode("UTF-16LE").b + "\x00\x00".b
281
+ cwd_ptr = FFI::MemoryPointer.new(:uint8, cwd_w.bytesize)
282
+ cwd_ptr.put_bytes(0, cwd_w)
283
+ end
284
+
285
+ ok = Native.CreateProcessW(
286
+ nil, cmd_ptr, nil, nil, 0, Native::EXTENDED_STARTUPINFO_PRESENT,
287
+ nil, cwd_ptr, si.pointer, pi.pointer
288
+ )
289
+ raise Error, "CreateProcessW failed (#{Native.GetLastError})" if ok.zero?
290
+
291
+ @proc_handle = pi[:hProcess]
292
+ Native.CloseHandle(pi[:hThread])
293
+ end
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "json"
5
+ require "base64"
6
+ require "uri"
7
+ require "websocket/driver"
8
+ require_relative "worker_process"
9
+
10
+ module Chocomint
11
+ module Console
12
+ # xterm.js のバックエンドとなる WebSocket サーバー。
13
+ #
14
+ # WEBrick は WebSocket 非対応なので、コンソール用途だけ独立した TCP サーバーを
15
+ # 別スレッド・別ポートで動かす (既存の /edit・/logs HTTP は WEBrick のまま)。
16
+ # 接続ごとに ConPTY ワーカー (新規コンソール付き) を spawn し、ブラウザの
17
+ # キー入力を tty へ、tty 出力をブラウザへ双方向に橋渡しする。
18
+ #
19
+ # セキュリティ: 生の対話 tty は監督外で任意コマンド実行が可能。bind は
20
+ # 127.0.0.1 固定。token を要求し (URL クエリ ?token=)、一致しなければ拒否する。
21
+ class Server
22
+ # command: 起動するシェル。cwd: 作業ディレクトリ。token: 接続に要求する共有秘密。
23
+ def initialize(host: "127.0.0.1", port: 8081, command: ConPTY::DEFAULT_SHELL,
24
+ cwd: nil, token: nil, logger: $stderr)
25
+ @host = host
26
+ @port = port
27
+ @command = command
28
+ @cwd = cwd
29
+ @token = token
30
+ @logger = logger
31
+ end
32
+
33
+ # ブロッキングで待ち受ける。別スレッドで呼ぶ想定 (#start_in_thread)。
34
+ def start
35
+ server = TCPServer.new(@host, @port)
36
+ @logger.puts("chocomint console (ws) listening on ws://#{@host}:#{@port}/")
37
+ loop do
38
+ socket = server.accept
39
+ Thread.new(socket) { |s| handle_connection(s) }
40
+ end
41
+ rescue IOError, Errno::EBADF
42
+ # サーバーソケットが閉じられた (shutdown)。
43
+ end
44
+
45
+ def start_in_thread
46
+ Thread.new { start }
47
+ end
48
+
49
+ private
50
+
51
+ def handle_connection(socket)
52
+ conn = Connection.new(socket, command: @command, cwd: @cwd, token: @token,
53
+ logger: @logger)
54
+ conn.run
55
+ rescue StandardError => e
56
+ @logger.puts("console connection error: #{e.class}: #{e.message}")
57
+ ensure
58
+ socket.close rescue nil
59
+ end
60
+
61
+ # 1 つの WebSocket 接続を ConPTY ワーカーへ橋渡しするアダプタ。
62
+ class Connection
63
+ def initialize(socket, command:, cwd:, token:, logger:)
64
+ @socket = socket
65
+ @command = command
66
+ @cwd = cwd
67
+ @token = token
68
+ @logger = logger
69
+ @open = false
70
+ @worker = nil
71
+ end
72
+
73
+ # websocket-driver は write(String) を持つオブジェクトを要求する。
74
+ def write(data)
75
+ @socket.write(data)
76
+ end
77
+
78
+ def run
79
+ @driver = WebSocket::Driver.server(self)
80
+ setup_driver(@driver)
81
+
82
+ loop do
83
+ data = @socket.readpartial(65_536)
84
+ @driver.parse(data)
85
+ end
86
+ rescue EOFError, IOError, Errno::ECONNRESET
87
+ # クライアント切断。
88
+ ensure
89
+ @pump&.kill
90
+ @worker&.close
91
+ end
92
+
93
+ private
94
+
95
+ def setup_driver(driver)
96
+ # ハンドシェイクの QUERY_STRING の ?token= で認証する。失敗なら接続を閉じる。
97
+ driver.on(:connect) do
98
+ if authorized?(driver)
99
+ driver.start
100
+ else
101
+ @socket.close rescue nil
102
+ end
103
+ end
104
+
105
+ driver.on(:open) do
106
+ @open = true
107
+ start_worker(driver)
108
+ end
109
+
110
+ driver.on(:message) { |event| handle_client_message(event.data) }
111
+ driver.on(:close) { @worker&.close }
112
+ driver.on(:error) { |e| @logger.puts("console ws error: #{e.message}") }
113
+ end
114
+
115
+ # ハンドシェイク要求の QUERY_STRING の token が設定値と一致するか。
116
+ # token 未設定なら常に許可 (ローカル限定運用向け)。
117
+ def authorized?(driver)
118
+ return true if @token.nil? || @token.empty?
119
+
120
+ query = driver.env["QUERY_STRING"].to_s
121
+ params = (URI.decode_www_form(query).to_h rescue {})
122
+ params["token"] == @token
123
+ end
124
+
125
+ def start_worker(driver)
126
+ @worker = WorkerProcess.new(command: @command, cols: 80, rows: 25, cwd: @cwd).start
127
+ @pump = start_worker_pump(driver)
128
+ rescue StandardError => e
129
+ driver.text(JSON.generate("type" => "error", "message" => e.message))
130
+ end
131
+
132
+ # worker → ブラウザ: tty 出力を WebSocket テキストフレームで送る。
133
+ def start_worker_pump(driver)
134
+ Thread.new do
135
+ loop do
136
+ msg = @worker.read_message
137
+ if msg
138
+ driver.text(JSON.generate(msg))
139
+ break if msg["type"] == "exit"
140
+ elsif !@worker.alive?
141
+ driver.text(JSON.generate("type" => "exit", "code" => 0))
142
+ break
143
+ end
144
+ sleep 0.01
145
+ end
146
+ rescue StandardError
147
+ nil
148
+ end
149
+ end
150
+
151
+ # ブラウザ → worker: 入力・リサイズを転送する。
152
+ def handle_client_message(data)
153
+ msg = JSON.parse(data)
154
+ case msg["type"]
155
+ when "input" then @worker&.send_input(msg["data"].to_s)
156
+ when "resize" then @worker&.send_resize(msg["cols"].to_i, msg["rows"].to_i)
157
+ end
158
+ rescue JSON::ParserError
159
+ nil
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "base64"
5
+ require_relative "conpty"
6
+
7
+ module Chocomint
8
+ module Console
9
+ # ConPTY を保持し、名前付きパイプを介して親 (WorkerProcess) と対話するワーカー。
10
+ #
11
+ # ConPTY は「コンソール付きプロセス」からしか正しく動かない
12
+ # (chocomint-conpty メモの制約) ため、このワーカーは新規コンソール付きで
13
+ # spawn される。通信に stdio を使うと新規コンソールとの関連付けが壊れて
14
+ # ConPTY 出力が止まるため、制御通信は独立した名前付きパイプで行う。
15
+ #
16
+ # プロトコル (親 ⇄ worker、いずれも 1 行 = 1 JSON):
17
+ # 親 → worker: {"type":"input","data":<base64>} / {"type":"resize","cols":N,"rows":M}
18
+ # worker → 親: {"type":"output","data":<base64>} / {"type":"exit","code":N}
19
+ class Worker
20
+ # c2w_name: 親→worker パイプ, w2c_name: worker→親 パイプ (親が作成済み)。
21
+ def initialize(command:, cols:, rows:, cwd:, c2w_name:, w2c_name:)
22
+ @pty = ConPTY.new(command: command, cols: cols, rows: rows, cwd: cwd)
23
+ @c2w_name = c2w_name
24
+ @w2c_name = w2c_name
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ def run
29
+ # 親の ConnectNamedPipe の順序 (c2w → w2c) に合わせて接続する。
30
+ @c2w = File.open(@c2w_name, "rb")
31
+ @w2c = File.open(@w2c_name, "wb")
32
+ @w2c.sync = true
33
+ @pty.open
34
+ reader = start_output_pump
35
+ while (line = @c2w.gets)
36
+ handle(line)
37
+ end
38
+ rescue StandardError
39
+ # 接続断・tty 破損等。ensure でクリーンアップする。
40
+ ensure
41
+ @pty.close if @pty.open?
42
+ reader&.join(1)
43
+ @c2w&.close
44
+ @w2c&.close
45
+ end
46
+
47
+ private
48
+
49
+ def start_output_pump
50
+ Thread.new do
51
+ loop do
52
+ chunk = @pty.read_available
53
+ if chunk.nil? # パイプ破損 = 子終了
54
+ emit("exit", "code" => (@pty.exit_code || 0))
55
+ break
56
+ end
57
+ emit("output", "data" => Base64.strict_encode64(chunk)) unless chunk.empty?
58
+ sleep 0.01
59
+ end
60
+ rescue StandardError => e
61
+ emit("error", "message" => e.message)
62
+ end
63
+ end
64
+
65
+ def handle(line)
66
+ msg = JSON.parse(line)
67
+ case msg["type"]
68
+ when "input" then @pty.write(Base64.strict_decode64(msg["data"].to_s))
69
+ when "resize" then @pty.resize(msg["cols"].to_i, msg["rows"].to_i)
70
+ end
71
+ rescue JSON::ParserError
72
+ nil
73
+ rescue ConPTY::Error
74
+ nil
75
+ end
76
+
77
+ def emit(type, extra = {})
78
+ @mutex.synchronize do
79
+ @w2c.write(JSON.generate({ "type" => type }.merge(extra)))
80
+ @w2c.write("\n")
81
+ @w2c.flush
82
+ end
83
+ rescue StandardError
84
+ nil
85
+ end
86
+ end
87
+ end
88
+ end