typeclaw 0.1.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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. package/typeclaw.schema.json +826 -0
@@ -0,0 +1,443 @@
1
+ import type { Socket, TCPSocketListener } from 'bun'
2
+
3
+ import type { PortForward } from '@/config'
4
+
5
+ import { brokerEnabled, shouldForward } from './policy'
6
+ import type { BindAddr } from './proc-net-tcp'
7
+ import {
8
+ decodeBytes,
9
+ encodeBytes,
10
+ type ContainerToHostd,
11
+ type HostdToContainer,
12
+ type PortForwardEvent,
13
+ type StreamId,
14
+ } from './protocol'
15
+
16
+ export type BrokerOptions = {
17
+ containerName: string
18
+ cwd: string
19
+ policy: PortForward
20
+ // Resolves the host port currently published for this container's
21
+ // CONTAINER_PORT mapping. The broker calls this on each (re)connect attempt
22
+ // because the supervisor's restart can pick a different port if the previous
23
+ // one is now bound. Returning null signals "container not running yet" — the
24
+ // broker waits and retries.
25
+ resolveHostPort: () => Promise<number | null>
26
+ brokerToken: string
27
+ onEvent: (event: PortForwardEvent) => void
28
+ onLog?: (msg: string) => void
29
+ connectWs?: (url: string) => Promise<WsClient>
30
+ listenHost?: ListenHostFn
31
+ reconnectDelaysMs?: number[]
32
+ hostBindAddr?: string
33
+ }
34
+
35
+ export type WsClient = {
36
+ send: (msg: HostdToContainer) => void
37
+ close: () => void
38
+ onMessage: (cb: (msg: ContainerToHostd) => void) => void
39
+ onClose: (cb: () => void) => void
40
+ }
41
+
42
+ export type ListenHostFn = (
43
+ host: string,
44
+ port: number,
45
+ handlers: {
46
+ onConnection: (sock: HostSocket) => void
47
+ },
48
+ ) => Promise<HostListener>
49
+
50
+ export type HostListener = {
51
+ port: number
52
+ stop: () => void
53
+ }
54
+
55
+ export type HostSocket = {
56
+ write: (chunk: Uint8Array) => void
57
+ end: () => void
58
+ onData: (cb: (chunk: Uint8Array) => void) => void
59
+ onClose: (cb: () => void) => void
60
+ }
61
+
62
+ export type Broker = {
63
+ start: () => void
64
+ stop: () => Promise<void>
65
+ forwardedPorts: () => number[]
66
+ }
67
+
68
+ const DEFAULT_RECONNECT_DELAYS = [1_000, 2_000, 4_000, 10_000]
69
+ const DEFAULT_HOST_BIND = '127.0.0.1'
70
+
71
+ export function createBroker(opts: BrokerOptions): Broker {
72
+ const log = opts.onLog ?? (() => {})
73
+ const reconnectDelays = opts.reconnectDelaysMs ?? DEFAULT_RECONNECT_DELAYS
74
+ const hostBind = opts.hostBindAddr ?? DEFAULT_HOST_BIND
75
+ const connectWs = opts.connectWs ?? defaultConnectWs
76
+ const listenHost = opts.listenHost ?? defaultListenHost
77
+
78
+ type ForwarderState = {
79
+ port: number
80
+ bindAddr: BindAddr
81
+ listener: HostListener
82
+ streams: Map<StreamId, { sock: HostSocket; opened: boolean; pending: Uint8Array[] }>
83
+ }
84
+
85
+ const forwarders = new Map<number, ForwarderState>()
86
+ let ws: WsClient | null = null
87
+ let nextStreamId: StreamId = 1
88
+ let stopped = false
89
+ let reconnectAttempt = 0
90
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
91
+
92
+ const allocStreamId = (): StreamId => {
93
+ const id = nextStreamId
94
+ nextStreamId = nextStreamId >= 0x7fffffff ? 1 : nextStreamId + 1
95
+ return id
96
+ }
97
+
98
+ const emit = (event: PortForwardEvent): void => {
99
+ try {
100
+ opts.onEvent(event)
101
+ } catch (err) {
102
+ log(`onEvent threw: ${err instanceof Error ? err.message : String(err)}`)
103
+ }
104
+ }
105
+
106
+ const closeStream = (port: number, streamId: StreamId, sendClose: boolean): void => {
107
+ const fwd = forwarders.get(port)
108
+ if (!fwd) return
109
+ const stream = fwd.streams.get(streamId)
110
+ if (!stream) return
111
+ fwd.streams.delete(streamId)
112
+ try {
113
+ stream.sock.end()
114
+ } catch {}
115
+ if (sendClose && ws) ws.send({ type: 'relay-close', streamId, side: 'downstream' })
116
+ }
117
+
118
+ const handleHostConnection = (port: number, sock: HostSocket): void => {
119
+ if (!ws) {
120
+ try {
121
+ sock.end()
122
+ } catch {}
123
+ return
124
+ }
125
+ const streamId = allocStreamId()
126
+ const fwd = forwarders.get(port)
127
+ if (!fwd) {
128
+ try {
129
+ sock.end()
130
+ } catch {}
131
+ return
132
+ }
133
+ fwd.streams.set(streamId, { sock, opened: false, pending: [] })
134
+
135
+ sock.onData((chunk) => {
136
+ const stream = fwd.streams.get(streamId)
137
+ if (!stream) return
138
+ const copy = new Uint8Array(chunk.byteLength)
139
+ copy.set(chunk)
140
+ if (stream.opened) {
141
+ if (ws) ws.send({ type: 'relay-data', streamId, bytes: encodeBytes(copy) })
142
+ } else {
143
+ stream.pending.push(copy)
144
+ }
145
+ })
146
+ sock.onClose(() => {
147
+ closeStream(port, streamId, true)
148
+ })
149
+
150
+ if (ws) ws.send({ type: 'relay-open', streamId, port })
151
+ }
152
+
153
+ const installForwarder = async (port: number, bindAddr: BindAddr): Promise<void> => {
154
+ if (forwarders.has(port)) return
155
+ if (!shouldForward({ policy: opts.policy, port })) {
156
+ // Policy excluded the port. Tell the container so it can stop waiting
157
+ // (e.g. the agent-browser plugin's bind-with-forward retry loop). Without
158
+ // this, the container would block on the forward-result timeout for
159
+ // every policy-denied port.
160
+ if (ws) ws.send({ type: 'port-forward-result', port, ok: false, reason: 'policy excluded' })
161
+ return
162
+ }
163
+ try {
164
+ const listener = await listenHost(hostBind, port, {
165
+ onConnection: (sock) => handleHostConnection(port, sock),
166
+ })
167
+ forwarders.set(port, { port, bindAddr, listener, streams: new Map() })
168
+ emit({ kind: 'port-forward-opened', containerName: opts.containerName, port, bindAddr })
169
+ if (ws) ws.send({ type: 'port-forward-result', port, ok: true, hostPort: listener.port })
170
+ } catch (err) {
171
+ const reason = err instanceof Error ? err.message : String(err)
172
+ log(`forward bind ${port}: ${reason}`)
173
+ emit({ kind: 'port-forward-failed', containerName: opts.containerName, port, reason })
174
+ if (ws) ws.send({ type: 'port-forward-result', port, ok: false, reason })
175
+ }
176
+ }
177
+
178
+ const removeForwarder = (port: number, reason: 'container-released' | 'host-error'): void => {
179
+ const fwd = forwarders.get(port)
180
+ if (!fwd) return
181
+ forwarders.delete(port)
182
+ try {
183
+ fwd.listener.stop()
184
+ } catch {}
185
+ for (const [streamId] of fwd.streams) closeStream(port, streamId, false)
186
+ emit({ kind: 'port-forward-closed', containerName: opts.containerName, port, reason })
187
+ }
188
+
189
+ const teardownAllForwarders = (reason: 'broker-stopped' | 'deregistered' | 'host-error'): void => {
190
+ for (const port of Array.from(forwarders.keys())) {
191
+ const fwd = forwarders.get(port)
192
+ if (!fwd) continue
193
+ forwarders.delete(port)
194
+ try {
195
+ fwd.listener.stop()
196
+ } catch {}
197
+ for (const [streamId] of fwd.streams) {
198
+ const stream = fwd.streams.get(streamId)
199
+ try {
200
+ stream?.sock.end()
201
+ } catch {}
202
+ }
203
+ emit({ kind: 'port-forward-closed', containerName: opts.containerName, port, reason })
204
+ }
205
+ }
206
+
207
+ const handleContainerMessage = (msg: ContainerToHostd): void => {
208
+ switch (msg.type) {
209
+ case 'broker-hello-ack':
210
+ if (ws) ws.send({ type: 'port-watch-subscribe' })
211
+ return
212
+ case 'broker-hello-nack':
213
+ log(`broker-hello rejected: ${msg.reason}`)
214
+ if (ws) ws.close()
215
+ return
216
+ case 'port-listen-snapshot':
217
+ for (const { port, bindAddr } of msg.ports) {
218
+ void installForwarder(port, bindAddr)
219
+ }
220
+ return
221
+ case 'port-listen-opened':
222
+ void installForwarder(msg.port, msg.bindAddr)
223
+ return
224
+ case 'port-listen-closed':
225
+ removeForwarder(msg.port, 'container-released')
226
+ return
227
+ case 'relay-open-ack': {
228
+ const port = findStreamPort(msg.streamId)
229
+ if (port === null) return
230
+ const fwd = forwarders.get(port)!
231
+ const stream = fwd.streams.get(msg.streamId)
232
+ if (!stream) return
233
+ stream.opened = true
234
+ if (ws) {
235
+ for (const buf of stream.pending)
236
+ ws.send({ type: 'relay-data', streamId: msg.streamId, bytes: encodeBytes(buf) })
237
+ }
238
+ stream.pending = []
239
+ return
240
+ }
241
+ case 'relay-open-nack': {
242
+ const port = findStreamPort(msg.streamId)
243
+ if (port !== null) closeStream(port, msg.streamId, false)
244
+ return
245
+ }
246
+ case 'relay-data': {
247
+ const port = findStreamPort(msg.streamId)
248
+ if (port === null) return
249
+ const stream = forwarders.get(port)?.streams.get(msg.streamId)
250
+ if (!stream) return
251
+ try {
252
+ stream.sock.write(decodeBytes(msg.bytes))
253
+ } catch {}
254
+ return
255
+ }
256
+ case 'relay-close': {
257
+ const port = findStreamPort(msg.streamId)
258
+ if (port !== null) closeStream(port, msg.streamId, false)
259
+ return
260
+ }
261
+ }
262
+ }
263
+
264
+ const findStreamPort = (streamId: StreamId): number | null => {
265
+ for (const [port, fwd] of forwarders) {
266
+ if (fwd.streams.has(streamId)) return port
267
+ }
268
+ return null
269
+ }
270
+
271
+ const connect = async (): Promise<void> => {
272
+ if (stopped) return
273
+ const hostPort = await opts.resolveHostPort()
274
+ if (hostPort === null) {
275
+ scheduleReconnect()
276
+ return
277
+ }
278
+ const url = `ws://127.0.0.1:${hostPort}/portbroker`
279
+ let client: WsClient
280
+ try {
281
+ client = await connectWs(url)
282
+ } catch (err) {
283
+ log(`ws connect ${url}: ${err instanceof Error ? err.message : String(err)}`)
284
+ scheduleReconnect()
285
+ return
286
+ }
287
+ ws = client
288
+ reconnectAttempt = 0
289
+
290
+ client.onMessage(handleContainerMessage)
291
+ client.onClose(() => {
292
+ ws = null
293
+ teardownAllForwarders('host-error')
294
+ scheduleReconnect()
295
+ })
296
+
297
+ client.send({ type: 'broker-hello', token: opts.brokerToken })
298
+ }
299
+
300
+ const scheduleReconnect = (): void => {
301
+ if (stopped) return
302
+ const delay = reconnectDelays[Math.min(reconnectAttempt, reconnectDelays.length - 1)] ?? 10_000
303
+ reconnectAttempt += 1
304
+ reconnectTimer = setTimeout(() => {
305
+ reconnectTimer = null
306
+ void connect()
307
+ }, delay)
308
+ }
309
+
310
+ return {
311
+ start() {
312
+ if (!brokerEnabled(opts.policy)) {
313
+ log(`portForward disabled (allow:[]) — broker not started for ${opts.containerName}`)
314
+ return
315
+ }
316
+ void connect()
317
+ },
318
+ async stop() {
319
+ stopped = true
320
+ if (reconnectTimer !== null) {
321
+ clearTimeout(reconnectTimer)
322
+ reconnectTimer = null
323
+ }
324
+ teardownAllForwarders('broker-stopped')
325
+ if (ws) {
326
+ try {
327
+ ws.close()
328
+ } catch {}
329
+ ws = null
330
+ }
331
+ },
332
+ forwardedPorts() {
333
+ return Array.from(forwarders.keys())
334
+ },
335
+ }
336
+ }
337
+
338
+ async function defaultConnectWs(url: string): Promise<WsClient> {
339
+ return new Promise((resolve, reject) => {
340
+ const ws = new WebSocket(url)
341
+ let resolved = false
342
+ const messageCbs: Array<(msg: ContainerToHostd) => void> = []
343
+ const closeCbs: Array<() => void> = []
344
+ const client: WsClient = {
345
+ send: (msg) => {
346
+ try {
347
+ ws.send(JSON.stringify(msg))
348
+ } catch {}
349
+ },
350
+ close: () => {
351
+ try {
352
+ ws.close()
353
+ } catch {}
354
+ },
355
+ onMessage: (cb) => {
356
+ messageCbs.push(cb)
357
+ },
358
+ onClose: (cb) => {
359
+ closeCbs.push(cb)
360
+ },
361
+ }
362
+ ws.onopen = (): void => {
363
+ resolved = true
364
+ resolve(client)
365
+ }
366
+ ws.onmessage = (ev: MessageEvent): void => {
367
+ let msg: ContainerToHostd
368
+ try {
369
+ msg = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as ContainerToHostd
370
+ } catch {
371
+ return
372
+ }
373
+ for (const cb of messageCbs) cb(msg)
374
+ }
375
+ ws.onerror = (): void => {
376
+ if (!resolved) reject(new Error(`ws error connecting to ${url}`))
377
+ }
378
+ ws.onclose = (): void => {
379
+ for (const cb of closeCbs) cb()
380
+ }
381
+ })
382
+ }
383
+
384
+ function defaultListenHost(
385
+ host: string,
386
+ port: number,
387
+ handlers: { onConnection: (sock: HostSocket) => void },
388
+ ): Promise<HostListener> {
389
+ return new Promise((resolve, reject) => {
390
+ type SocketState = { dataCbs: Array<(c: Uint8Array) => void>; closeCbs: Array<() => void> }
391
+ let listener: TCPSocketListener<SocketState> | null = null
392
+ try {
393
+ listener = Bun.listen<SocketState>({
394
+ hostname: host,
395
+ port,
396
+ socket: {
397
+ open(s: Socket<SocketState>) {
398
+ s.data = { dataCbs: [], closeCbs: [] }
399
+ const sockApi: HostSocket = {
400
+ write: (chunk) => {
401
+ try {
402
+ s.write(chunk)
403
+ } catch {}
404
+ },
405
+ end: () => {
406
+ try {
407
+ s.end()
408
+ } catch {}
409
+ },
410
+ onData: (cb) => {
411
+ s.data.dataCbs.push(cb)
412
+ },
413
+ onClose: (cb) => {
414
+ s.data.closeCbs.push(cb)
415
+ },
416
+ }
417
+ handlers.onConnection(sockApi)
418
+ },
419
+ data(s: Socket<SocketState>, data: Buffer) {
420
+ const copy = new Uint8Array(data.byteLength)
421
+ copy.set(data)
422
+ for (const cb of s.data.dataCbs) cb(copy)
423
+ },
424
+ close(s: Socket<SocketState>) {
425
+ for (const cb of s.data.closeCbs) cb()
426
+ },
427
+ error() {},
428
+ },
429
+ })
430
+ const captured = listener
431
+ resolve({
432
+ port: captured?.port ?? port,
433
+ stop: () => {
434
+ try {
435
+ captured?.stop(true)
436
+ } catch {}
437
+ },
438
+ })
439
+ } catch (err) {
440
+ reject(err instanceof Error ? err : new Error(String(err)))
441
+ }
442
+ })
443
+ }
@@ -0,0 +1,33 @@
1
+ export { brokerEnabled, shouldForward } from './policy'
2
+ export { parseProcNetTcp, type BindAddr, type ListenEntry } from './proc-net-tcp'
3
+ export {
4
+ decodeBytes,
5
+ encodeBytes,
6
+ type ContainerToHostd,
7
+ type HostdToContainer,
8
+ type PortForwardCloseReason,
9
+ type PortForwardEvent,
10
+ type StreamId,
11
+ } from './protocol'
12
+ export {
13
+ createContainerBroker,
14
+ type BrokerSocket,
15
+ type BrokerWsData,
16
+ type ContainerBroker,
17
+ type ContainerBrokerLogEvent,
18
+ type ContainerBrokerOptions,
19
+ type ForwardResultEvent,
20
+ type UpstreamConnection,
21
+ type UpstreamHandlers,
22
+ } from './container-server'
23
+ export { __resetForwardResultBus, publishForwardResult, subscribeForwardResult } from './forward-result-bus'
24
+ export { bindWithForward, type BindFactory, type BindResult, type BindWithForwardOptions } from './bind-with-forward'
25
+ export {
26
+ createBroker,
27
+ type Broker,
28
+ type BrokerOptions,
29
+ type HostListener,
30
+ type HostSocket,
31
+ type ListenHostFn,
32
+ type WsClient,
33
+ } from './hostd-client'
@@ -0,0 +1,24 @@
1
+ import type { PortForward } from '@/config'
2
+ import { CONTAINER_PORT } from '@/container'
3
+
4
+ export type ShouldForwardOptions = {
5
+ policy: PortForward
6
+ port: number
7
+ containerPort?: number
8
+ }
9
+
10
+ // CONTAINER_PORT is always implicitly excluded: that mapping is owned by
11
+ // `docker run -p ${hostPort}:${CONTAINER_PORT}` and forwarding it again would
12
+ // fight the published port. Tests can override containerPort to verify the
13
+ // implicit exclusion independently of the global constant.
14
+ export function shouldForward({ policy, port, containerPort = CONTAINER_PORT }: ShouldForwardOptions): boolean {
15
+ if (port === containerPort) return false
16
+ if (Array.isArray(policy.allow)) return policy.allow.includes(port)
17
+ if (policy.deny?.includes(port)) return false
18
+ return true
19
+ }
20
+
21
+ export function brokerEnabled(policy: PortForward): boolean {
22
+ if (Array.isArray(policy.allow) && policy.allow.length === 0) return false
23
+ return true
24
+ }
@@ -0,0 +1,72 @@
1
+ export type BindAddr = '0.0.0.0' | '127.0.0.1'
2
+
3
+ export type ListenEntry = {
4
+ port: number
5
+ bindAddr: BindAddr
6
+ }
7
+
8
+ const STATE_LISTEN = '0A'
9
+
10
+ // /proc/net/tcp[6] format (one row per connection):
11
+ // sl local_address rem_address st tx_queue:rx_queue ...
12
+ // 0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 ...
13
+ //
14
+ // `local_address` is `<ip-hex>:<port-hex>`. The IP is little-endian for IPv4
15
+ // (so "0100007F" = 127.0.0.1) and big-endian-grouped for IPv6. We only care
16
+ // about the LISTEN state (st === '0A') and the bind side (loopback vs any).
17
+ //
18
+ // Parser is loose-by-design: anything that doesn't look like a LISTEN row is
19
+ // silently skipped (header rows, partial reads, kernel format additions). We
20
+ // never throw — the procfs file changes between syscalls and a transient
21
+ // parse failure must not propagate up.
22
+ export function parseProcNetTcp(input: string): ListenEntry[] {
23
+ const out: ListenEntry[] = []
24
+ for (const raw of input.split('\n')) {
25
+ const line = raw.trim()
26
+ if (line.length === 0) continue
27
+ const cols = line.split(/\s+/)
28
+ if (cols.length < 4) continue
29
+ const localAddr = cols[1]
30
+ const state = cols[3]
31
+ if (state !== STATE_LISTEN || localAddr === undefined) continue
32
+ const colonIdx = localAddr.lastIndexOf(':')
33
+ if (colonIdx < 0) continue
34
+ const ipHex = localAddr.slice(0, colonIdx)
35
+ const portHex = localAddr.slice(colonIdx + 1)
36
+ const port = Number.parseInt(portHex, 16)
37
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) continue
38
+ const bindAddr = classifyBindAddr(ipHex)
39
+ if (bindAddr === null) continue
40
+ out.push({ port, bindAddr })
41
+ }
42
+ return dedupePreferringLoopback(out)
43
+ }
44
+
45
+ function classifyBindAddr(ipHex: string): BindAddr | null {
46
+ if (ipHex.length === 8) {
47
+ if (ipHex === '00000000') return '0.0.0.0'
48
+ if (ipHex.toUpperCase() === '0100007F') return '127.0.0.1'
49
+ return null
50
+ }
51
+ if (ipHex.length === 32) {
52
+ if (ipHex === '00000000000000000000000000000000') return '0.0.0.0'
53
+ if (ipHex === '00000000000000000000000001000000') return '127.0.0.1'
54
+ return null
55
+ }
56
+ return null
57
+ }
58
+
59
+ // A server bound to both 0.0.0.0 (IPv4) and ::1 (IPv6) appears twice in our
60
+ // merged tcp+tcp6 input. Collapse to one entry per port. If the same port has
61
+ // both classifications, prefer 127.0.0.1 (the more restrictive one) so the
62
+ // downstream forwarder routes through `Bun.connect('127.0.0.1', port)` either
63
+ // way — the loopback path works for both bind addresses.
64
+ function dedupePreferringLoopback(entries: ListenEntry[]): ListenEntry[] {
65
+ const byPort = new Map<number, BindAddr>()
66
+ for (const e of entries) {
67
+ const existing = byPort.get(e.port)
68
+ if (existing === undefined) byPort.set(e.port, e.bindAddr)
69
+ else if (existing === '0.0.0.0' && e.bindAddr === '127.0.0.1') byPort.set(e.port, '127.0.0.1')
70
+ }
71
+ return Array.from(byPort.entries()).map(([port, bindAddr]) => ({ port, bindAddr }))
72
+ }
@@ -0,0 +1,39 @@
1
+ import type { BindAddr } from './proc-net-tcp'
2
+
3
+ export type StreamId = number
4
+
5
+ export type HostdToContainer =
6
+ | { type: 'broker-hello'; token: string }
7
+ | { type: 'port-watch-subscribe' }
8
+ | { type: 'port-watch-unsubscribe' }
9
+ | { type: 'port-forward-result'; port: number; ok: true; hostPort: number }
10
+ | { type: 'port-forward-result'; port: number; ok: false; reason: string }
11
+ | { type: 'relay-open'; streamId: StreamId; port: number }
12
+ | { type: 'relay-data'; streamId: StreamId; bytes: string }
13
+ | { type: 'relay-close'; streamId: StreamId; side: 'upstream' | 'downstream' }
14
+
15
+ export type ContainerToHostd =
16
+ | { type: 'broker-hello-ack' }
17
+ | { type: 'broker-hello-nack'; reason: string }
18
+ | { type: 'port-listen-snapshot'; ports: Array<{ port: number; bindAddr: BindAddr }> }
19
+ | { type: 'port-listen-opened'; port: number; bindAddr: BindAddr }
20
+ | { type: 'port-listen-closed'; port: number }
21
+ | { type: 'relay-open-ack'; streamId: StreamId }
22
+ | { type: 'relay-open-nack'; streamId: StreamId; reason: string }
23
+ | { type: 'relay-data'; streamId: StreamId; bytes: string }
24
+ | { type: 'relay-close'; streamId: StreamId; side: 'upstream' | 'downstream' }
25
+
26
+ export type PortForwardEvent =
27
+ | { kind: 'port-forward-opened'; containerName: string; port: number; bindAddr: BindAddr }
28
+ | { kind: 'port-forward-closed'; containerName: string; port: number; reason: PortForwardCloseReason }
29
+ | { kind: 'port-forward-failed'; containerName: string; port: number; reason: string }
30
+
31
+ export type PortForwardCloseReason = 'container-released' | 'host-error' | 'deregistered' | 'broker-stopped'
32
+
33
+ export function encodeBytes(buf: Uint8Array): string {
34
+ return Buffer.from(buf).toString('base64')
35
+ }
36
+
37
+ export function decodeBytes(s: string): Uint8Array {
38
+ return new Uint8Array(Buffer.from(s, 'base64'))
39
+ }
@@ -0,0 +1,59 @@
1
+ import type { ClientMessage, ServerMessage } from '@/shared'
2
+
3
+ import type { ReloadResult } from './types'
4
+
5
+ export type RequestReloadOptions = {
6
+ url: string
7
+ scope?: string
8
+ timeoutMs?: number
9
+ }
10
+
11
+ const DEFAULT_TIMEOUT_MS = 30_000
12
+
13
+ export async function requestReload({
14
+ url,
15
+ scope,
16
+ timeoutMs = DEFAULT_TIMEOUT_MS,
17
+ }: RequestReloadOptions): Promise<ReloadResult[]> {
18
+ const ws = new WebSocket(url)
19
+
20
+ await new Promise<void>((resolve, reject) => {
21
+ const onOpen = () => {
22
+ cleanup()
23
+ resolve()
24
+ }
25
+ const onError = (err: unknown) => {
26
+ cleanup()
27
+ reject(err instanceof Error ? err : new Error(`failed to connect to ${url}`))
28
+ }
29
+ const cleanup = () => {
30
+ ws.removeEventListener('open', onOpen)
31
+ ws.removeEventListener('error', onError)
32
+ }
33
+ ws.addEventListener('open', onOpen, { once: true })
34
+ ws.addEventListener('error', onError, { once: true })
35
+ })
36
+
37
+ try {
38
+ const request: ClientMessage = scope ? { type: 'reload', scope } : { type: 'reload' }
39
+ ws.send(JSON.stringify(request))
40
+
41
+ return await new Promise<ReloadResult[]>((resolve, reject) => {
42
+ const timer = setTimeout(() => {
43
+ ws.removeEventListener('message', onMessage)
44
+ reject(new Error(`timed out waiting for reload_result after ${timeoutMs}ms`))
45
+ }, timeoutMs)
46
+
47
+ const onMessage = (event: MessageEvent) => {
48
+ const msg = JSON.parse(String(event.data)) as ServerMessage
49
+ if (msg.type !== 'reload_result') return
50
+ clearTimeout(timer)
51
+ ws.removeEventListener('message', onMessage)
52
+ resolve(msg.results)
53
+ }
54
+ ws.addEventListener('message', onMessage)
55
+ })
56
+ } finally {
57
+ ws.close()
58
+ }
59
+ }
@@ -0,0 +1,3 @@
1
+ export { requestReload, type RequestReloadOptions } from './client'
2
+ export { ReloadRegistry } from './registry'
3
+ export type { Reloadable, ReloadAllResult, ReloadResult } from './types'