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,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
|
+
}
|