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.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- 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
|
+
}
|