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,102 @@
1
+ // Allocate an in-container port whose host-side forward succeeds.
2
+ //
3
+ // In-container LISTEN succeeds even when the host-side forward collides with
4
+ // another container — each container has its own netns, so the procfs check
5
+ // can't tell us anything about host-side availability. This helper closes
6
+ // that gap: it calls a factory to bind a candidate port internally, waits
7
+ // for the broker's `port-forward-result` event, and on failure tears the
8
+ // candidate down and tries the next port. Used today by the agent-browser
9
+ // plugin's dashboard proxy bind, where multiple typeclaw containers on one
10
+ // host all want port 4848 externally and only the first to register wins.
11
+ //
12
+ // Returns the bound result on first success, or null after exhausting the
13
+ // candidate list. Callers MUST treat null as "give up, no host-reachable
14
+ // port available" — there is no further recourse without operator action
15
+ // (e.g. stopping the colliding container).
16
+ //
17
+ // If the broker isn't reachable (no TYPECLAW_HOSTD_BROKER_TOKEN, broker
18
+ // disconnected, etc.) the bus never receives results. The helper falls
19
+ // through to optimistic mode: the first successful in-container bind is
20
+ // returned without waiting, on the assumption that no broker means no
21
+ // host-side cross-container collision is possible.
22
+
23
+ import { subscribeForwardResult } from './forward-result-bus'
24
+
25
+ export type BindResult<T> = {
26
+ port: number
27
+ hostPort: number | null
28
+ resource: T
29
+ }
30
+
31
+ export type BindFactory<T> = (port: number) => Promise<{ resource: T; close: () => void } | null>
32
+
33
+ export type BindWithForwardOptions<T> = {
34
+ candidates: number[]
35
+ factory: BindFactory<T>
36
+ timeoutMs?: number
37
+ brokerEnabled?: boolean
38
+ onLog?: (msg: string) => void
39
+ }
40
+
41
+ const DEFAULT_TIMEOUT_MS = 2_000
42
+
43
+ export async function bindWithForward<T>(opts: BindWithForwardOptions<T>): Promise<BindResult<T> | null> {
44
+ const log = opts.onLog ?? (() => {})
45
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
46
+ const brokerEnabled = opts.brokerEnabled ?? defaultBrokerEnabled()
47
+
48
+ for (const port of opts.candidates) {
49
+ const bound = await opts.factory(port)
50
+ if (bound === null) {
51
+ log(`bind ${port}: factory returned null (in-container bind failed); trying next`)
52
+ continue
53
+ }
54
+
55
+ if (!brokerEnabled) {
56
+ log(`bind ${port}: broker disabled; returning optimistically`)
57
+ return { port, hostPort: null, resource: bound.resource }
58
+ }
59
+
60
+ const forward = await waitForForwardResult(port, timeoutMs)
61
+ if (forward.kind === 'ok') {
62
+ log(`bind ${port}: forwarded to host:${forward.hostPort}`)
63
+ return { port, hostPort: forward.hostPort, resource: bound.resource }
64
+ }
65
+
66
+ log(`bind ${port}: forward ${forward.kind === 'failed' ? `failed (${forward.reason})` : 'timed out'}; tearing down`)
67
+ try {
68
+ bound.close()
69
+ } catch {
70
+ // Close failures are non-fatal here; the next factory call may pick a
71
+ // different port and the orphaned listener will be reaped on process
72
+ // exit. Logging would just be noise.
73
+ }
74
+ }
75
+ return null
76
+ }
77
+
78
+ type WaitResult = { kind: 'ok'; hostPort: number } | { kind: 'failed'; reason: string } | { kind: 'timeout' }
79
+
80
+ function waitForForwardResult(port: number, timeoutMs: number): Promise<WaitResult> {
81
+ return new Promise((resolve) => {
82
+ let settled = false
83
+ const timer = setTimeout(() => {
84
+ if (settled) return
85
+ settled = true
86
+ unsubscribe()
87
+ resolve({ kind: 'timeout' })
88
+ }, timeoutMs)
89
+ const unsubscribe = subscribeForwardResult((event) => {
90
+ if (event.port !== port || settled) return
91
+ settled = true
92
+ clearTimeout(timer)
93
+ unsubscribe()
94
+ resolve(event.ok ? { kind: 'ok', hostPort: event.hostPort } : { kind: 'failed', reason: event.reason })
95
+ })
96
+ })
97
+ }
98
+
99
+ function defaultBrokerEnabled(): boolean {
100
+ const token = process.env['TYPECLAW_HOSTD_BROKER_TOKEN']
101
+ return token !== undefined && token.length > 0
102
+ }
@@ -0,0 +1,305 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ import type { ServerWebSocket } from 'bun'
4
+
5
+ import { parseProcNetTcp } from './proc-net-tcp'
6
+ import { decodeBytes, encodeBytes, type ContainerToHostd, type HostdToContainer, type StreamId } from './protocol'
7
+
8
+ export type BrokerWsData = { kind: 'portbroker'; authed: boolean }
9
+
10
+ export type ContainerBrokerOptions = {
11
+ expectedToken: string
12
+ pollIntervalMs?: number
13
+ // Test seam: defaults to reading /proc/net/tcp + /proc/net/tcp6. Tests inject
14
+ // a fake. The function MUST resolve with the *concatenated* contents of both
15
+ // files (or just one if the system is IPv4-only) — the parser handles a
16
+ // mixed input gracefully.
17
+ readProcNetTcp?: () => Promise<string>
18
+ // Test seam: replaces Bun.connect for unit tests.
19
+ upstreamConnect?: (port: number, handlers: UpstreamHandlers) => Promise<UpstreamConnection>
20
+ onLog?: (event: ContainerBrokerLogEvent) => void
21
+ // In-container consumers (e.g. the agent-browser plugin) call this to learn
22
+ // whether a port that just opened a LISTEN socket got successfully forwarded
23
+ // to the host side. Without it, code that picks an in-container port has no
24
+ // way to detect host-side EADDRINUSE collisions across containers.
25
+ onForwardResult?: (event: ForwardResultEvent) => void
26
+ }
27
+
28
+ export type ForwardResultEvent =
29
+ | { port: number; ok: true; hostPort: number }
30
+ | { port: number; ok: false; reason: string }
31
+
32
+ export type UpstreamHandlers = {
33
+ onData: (chunk: Uint8Array) => void
34
+ onClose: () => void
35
+ onError: (err: Error) => void
36
+ }
37
+
38
+ export type UpstreamConnection = {
39
+ write: (chunk: Uint8Array) => void
40
+ end: () => void
41
+ }
42
+
43
+ export type ContainerBrokerLogEvent =
44
+ | { kind: 'auth-failed'; reason: string }
45
+ | { kind: 'authed' }
46
+ | { kind: 'subscribed' }
47
+ | { kind: 'unsubscribed' }
48
+ | { kind: 'relay-open-failed'; streamId: StreamId; port: number; reason: string }
49
+ | { kind: 'relay-data-error'; streamId: StreamId; reason: string }
50
+ | { kind: 'unexpected'; reason: string }
51
+
52
+ export type ContainerBroker = {
53
+ open: (ws: BrokerSocket) => void
54
+ message: (ws: BrokerSocket, raw: string | Buffer) => Promise<void>
55
+ close: (ws: BrokerSocket) => void
56
+ }
57
+
58
+ export type BrokerSocket = ServerWebSocket<BrokerWsData>
59
+
60
+ const DEFAULT_POLL_MS = 500
61
+
62
+ export function createContainerBroker(opts: ContainerBrokerOptions): ContainerBroker {
63
+ const log = opts.onLog ?? (() => {})
64
+ const pollMs = opts.pollIntervalMs ?? DEFAULT_POLL_MS
65
+ const readProc = opts.readProcNetTcp ?? defaultReadProcNetTcp
66
+ const connectUpstream = opts.upstreamConnect ?? defaultUpstreamConnect
67
+
68
+ type SessionState = {
69
+ pollTimer: ReturnType<typeof setInterval> | null
70
+ lastSnapshot: Map<number, '0.0.0.0' | '127.0.0.1'>
71
+ upstreams: Map<StreamId, UpstreamConnection>
72
+ }
73
+
74
+ const sessions = new WeakMap<BrokerSocket, SessionState>()
75
+
76
+ const send = (ws: BrokerSocket, msg: ContainerToHostd): void => {
77
+ try {
78
+ ws.send(JSON.stringify(msg))
79
+ } catch {}
80
+ }
81
+
82
+ const startWatcher = async (ws: BrokerSocket, state: SessionState): Promise<void> => {
83
+ const initial = await snapshotPorts()
84
+ state.lastSnapshot = initial
85
+ send(ws, {
86
+ type: 'port-listen-snapshot',
87
+ ports: Array.from(initial.entries()).map(([port, bindAddr]) => ({ port, bindAddr })),
88
+ })
89
+ state.pollTimer = setInterval(() => {
90
+ void tickWatcher(ws, state).catch(() => {})
91
+ }, pollMs)
92
+ }
93
+
94
+ const stopWatcher = (state: SessionState): void => {
95
+ if (state.pollTimer !== null) {
96
+ clearInterval(state.pollTimer)
97
+ state.pollTimer = null
98
+ }
99
+ }
100
+
101
+ const tickWatcher = async (ws: BrokerSocket, state: SessionState): Promise<void> => {
102
+ const next = await snapshotPorts()
103
+ for (const [port, bindAddr] of next) {
104
+ if (!state.lastSnapshot.has(port)) {
105
+ send(ws, { type: 'port-listen-opened', port, bindAddr })
106
+ }
107
+ }
108
+ for (const port of state.lastSnapshot.keys()) {
109
+ if (!next.has(port)) {
110
+ send(ws, { type: 'port-listen-closed', port })
111
+ }
112
+ }
113
+ state.lastSnapshot = next
114
+ }
115
+
116
+ const snapshotPorts = async (): Promise<Map<number, '0.0.0.0' | '127.0.0.1'>> => {
117
+ let raw: string
118
+ try {
119
+ raw = await readProc()
120
+ } catch {
121
+ return new Map()
122
+ }
123
+ const entries = parseProcNetTcp(raw)
124
+ const m = new Map<number, '0.0.0.0' | '127.0.0.1'>()
125
+ for (const e of entries) m.set(e.port, e.bindAddr)
126
+ return m
127
+ }
128
+
129
+ const handleRelayOpen = async (ws: BrokerSocket, state: SessionState, msg: HostdToContainer): Promise<void> => {
130
+ if (msg.type !== 'relay-open') return
131
+ const { streamId, port } = msg
132
+ try {
133
+ const conn = await connectUpstream(port, {
134
+ onData: (chunk) => {
135
+ send(ws, { type: 'relay-data', streamId, bytes: encodeBytes(chunk) })
136
+ },
137
+ onClose: () => {
138
+ if (state.upstreams.delete(streamId)) {
139
+ send(ws, { type: 'relay-close', streamId, side: 'upstream' })
140
+ }
141
+ },
142
+ onError: (err) => {
143
+ if (state.upstreams.delete(streamId)) {
144
+ log({ kind: 'relay-data-error', streamId, reason: err.message })
145
+ send(ws, { type: 'relay-close', streamId, side: 'upstream' })
146
+ }
147
+ },
148
+ })
149
+ state.upstreams.set(streamId, conn)
150
+ send(ws, { type: 'relay-open-ack', streamId })
151
+ } catch (err) {
152
+ const reason = err instanceof Error ? err.message : String(err)
153
+ log({ kind: 'relay-open-failed', streamId, port, reason })
154
+ send(ws, { type: 'relay-open-nack', streamId, reason })
155
+ }
156
+ }
157
+
158
+ return {
159
+ open(ws) {
160
+ sessions.set(ws, { pollTimer: null, lastSnapshot: new Map(), upstreams: new Map() })
161
+ },
162
+
163
+ async message(ws, raw) {
164
+ const state = sessions.get(ws)
165
+ if (!state) return
166
+ let msg: HostdToContainer
167
+ try {
168
+ msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf8')) as HostdToContainer
169
+ } catch {
170
+ log({ kind: 'unexpected', reason: 'invalid json' })
171
+ return
172
+ }
173
+
174
+ if (!ws.data.authed) {
175
+ if (msg.type !== 'broker-hello') {
176
+ log({ kind: 'auth-failed', reason: 'first message was not broker-hello' })
177
+ send(ws, { type: 'broker-hello-nack', reason: 'expected broker-hello first' })
178
+ ws.close(1008, 'auth required')
179
+ return
180
+ }
181
+ if (msg.token !== opts.expectedToken) {
182
+ log({ kind: 'auth-failed', reason: 'token mismatch' })
183
+ send(ws, { type: 'broker-hello-nack', reason: 'invalid token' })
184
+ ws.close(1008, 'invalid token')
185
+ return
186
+ }
187
+ ws.data.authed = true
188
+ log({ kind: 'authed' })
189
+ send(ws, { type: 'broker-hello-ack' })
190
+ return
191
+ }
192
+
193
+ switch (msg.type) {
194
+ case 'broker-hello':
195
+ return
196
+ case 'port-watch-subscribe':
197
+ if (state.pollTimer === null) {
198
+ log({ kind: 'subscribed' })
199
+ await startWatcher(ws, state)
200
+ }
201
+ return
202
+ case 'port-watch-unsubscribe':
203
+ if (state.pollTimer !== null) {
204
+ log({ kind: 'unsubscribed' })
205
+ stopWatcher(state)
206
+ }
207
+ return
208
+ case 'port-forward-result':
209
+ if (opts.onForwardResult) {
210
+ try {
211
+ opts.onForwardResult(
212
+ msg.ok
213
+ ? { port: msg.port, ok: true, hostPort: msg.hostPort }
214
+ : { port: msg.port, ok: false, reason: msg.reason },
215
+ )
216
+ } catch (err) {
217
+ log({
218
+ kind: 'unexpected',
219
+ reason: `onForwardResult threw: ${err instanceof Error ? err.message : String(err)}`,
220
+ })
221
+ }
222
+ }
223
+ return
224
+ case 'relay-open':
225
+ await handleRelayOpen(ws, state, msg)
226
+ return
227
+ case 'relay-data': {
228
+ const conn = state.upstreams.get(msg.streamId)
229
+ if (conn) conn.write(decodeBytes(msg.bytes))
230
+ return
231
+ }
232
+ case 'relay-close': {
233
+ const conn = state.upstreams.get(msg.streamId)
234
+ if (conn) {
235
+ state.upstreams.delete(msg.streamId)
236
+ try {
237
+ conn.end()
238
+ } catch {}
239
+ }
240
+ return
241
+ }
242
+ }
243
+ },
244
+
245
+ close(ws) {
246
+ const state = sessions.get(ws)
247
+ if (!state) return
248
+ stopWatcher(state)
249
+ for (const conn of state.upstreams.values()) {
250
+ try {
251
+ conn.end()
252
+ } catch {}
253
+ }
254
+ state.upstreams.clear()
255
+ sessions.delete(ws)
256
+ },
257
+ }
258
+ }
259
+
260
+ async function defaultReadProcNetTcp(): Promise<string> {
261
+ const tcp4 = await readFile('/proc/net/tcp', 'utf8').catch(() => '')
262
+ const tcp6 = await readFile('/proc/net/tcp6', 'utf8').catch(() => '')
263
+ return `${tcp4}\n${tcp6}`
264
+ }
265
+
266
+ function defaultUpstreamConnect(port: number, handlers: UpstreamHandlers): Promise<UpstreamConnection> {
267
+ return new Promise((resolve, reject) => {
268
+ let resolved = false
269
+ const sock = Bun.connect({
270
+ hostname: '127.0.0.1',
271
+ port,
272
+ socket: {
273
+ open(s) {
274
+ resolved = true
275
+ resolve({
276
+ write: (chunk: Uint8Array) => {
277
+ try {
278
+ s.write(chunk)
279
+ } catch {}
280
+ },
281
+ end: () => {
282
+ try {
283
+ s.end()
284
+ } catch {}
285
+ },
286
+ })
287
+ },
288
+ data(_s, data) {
289
+ handlers.onData(new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice())
290
+ },
291
+ close() {
292
+ handlers.onClose()
293
+ },
294
+ error(_s, error) {
295
+ if (!resolved) {
296
+ reject(error instanceof Error ? error : new Error(String(error)))
297
+ } else {
298
+ handlers.onError(error instanceof Error ? error : new Error(String(error)))
299
+ }
300
+ },
301
+ },
302
+ })
303
+ void sock
304
+ })
305
+ }
@@ -0,0 +1,36 @@
1
+ // In-process event bus for `port-forward-result` events emitted by the
2
+ // host-side broker over the WS to the container side. Lives as a module-level
3
+ // singleton so the run-loop wiring (src/run/index.ts) can publish events from
4
+ // the broker callback while consumers (e.g. the agent-browser plugin's
5
+ // bind-with-forward retry loop) subscribe by importing this module without
6
+ // needing a reference to the ContainerBroker itself.
7
+ //
8
+ // Tests should call `__resetForwardResultBus()` in afterEach so subscriptions
9
+ // from a previous test don't leak.
10
+
11
+ import type { ForwardResultEvent } from './container-server'
12
+
13
+ type Subscriber = (event: ForwardResultEvent) => void
14
+
15
+ const subscribers = new Set<Subscriber>()
16
+
17
+ export function publishForwardResult(event: ForwardResultEvent): void {
18
+ for (const sub of subscribers) {
19
+ try {
20
+ sub(event)
21
+ } catch {
22
+ // Subscriber failures must not block the bus or affect peer subscribers.
23
+ }
24
+ }
25
+ }
26
+
27
+ export function subscribeForwardResult(cb: Subscriber): () => void {
28
+ subscribers.add(cb)
29
+ return () => {
30
+ subscribers.delete(cb)
31
+ }
32
+ }
33
+
34
+ export function __resetForwardResultBus(): void {
35
+ subscribers.clear()
36
+ }