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,170 @@
1
+ // Discovers the actual port the agent-browser dashboard daemon is listening
2
+ // on. Necessary because the previous design hardcoded 4849 in the proxy and
3
+ // trusted the shim to force upstream onto that port — but the shim is bypass-
4
+ // able (someone runs `bunx agent-browser dashboard --port 9999`, the binary
5
+ // gets invoked from a path that isn't shimmed, an old container leaves a
6
+ // stale daemon, etc.). The proxy now consults this module to find the
7
+ // dashboard wherever it actually is.
8
+ //
9
+ // Two-stage discovery, fastest signal first:
10
+ //
11
+ // 1. Hint file at PORT_HINT_PATH. The shim writes the port it asked
12
+ // upstream to bind to (via the rewritten --port). If the file exists,
13
+ // points at a port, AND that port currently has a LISTEN socket we
14
+ // can fast-probe with HEAD /api/sessions, we use it. Zero I/O on the
15
+ // hot path beyond a small file read.
16
+ //
17
+ // 2. Fallback: read the dashboard's own pidfile at DASHBOARD_PID_PATH
18
+ // (written by upstream itself). If the PID is alive, scan
19
+ // /proc/<pid>/fd for socket inodes, cross-reference with /proc/net/tcp
20
+ // to find LISTEN sockets owned by that PID, drop the proxy's own port,
21
+ // probe each remaining port with HEAD /api/sessions, return the
22
+ // first that responds 2xx. Linux-only, which is fine — typeclaw runs
23
+ // in a Linux container.
24
+ //
25
+ // The fallback is what makes "agent uses other port" work when the shim
26
+ // doesn't catch the call. Without it, the proxy is stuck at whatever port
27
+ // it was configured with and silently 502s on a moved dashboard.
28
+
29
+ import { existsSync, readdirSync, readFileSync, readlinkSync } from 'node:fs'
30
+
31
+ export const PORT_HINT_PATH = '/tmp/typeclaw-agent-browser-upstream-port'
32
+ export const DASHBOARD_PID_PATH = '/root/.agent-browser/dashboard.pid'
33
+ const DEFAULT_PROBE_TIMEOUT_MS = 250
34
+
35
+ export type DiscoveryOptions = {
36
+ hintPath?: string
37
+ pidPath?: string
38
+ excludePort?: number
39
+ fetchImpl?: typeof fetch
40
+ probeTimeoutMs?: number
41
+ procfs?: ProcFs
42
+ }
43
+
44
+ export type ProcFs = {
45
+ pidExists: (pid: number) => boolean
46
+ listenInodesForPid: (pid: number) => Set<string>
47
+ listenSockets: () => Array<{ port: number; inode: string }>
48
+ }
49
+
50
+ export async function discoverDashboardPort(opts: DiscoveryOptions = {}): Promise<number | null> {
51
+ const hintPath = opts.hintPath ?? PORT_HINT_PATH
52
+ const pidPath = opts.pidPath ?? DASHBOARD_PID_PATH
53
+ const fetcher = opts.fetchImpl ?? fetch
54
+ const probeTimeout = opts.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS
55
+ const procfs = opts.procfs ?? defaultProcFs()
56
+
57
+ const hint = readPortHint(hintPath)
58
+ if (hint !== null && (await isDashboardPort(hint, fetcher, probeTimeout))) return hint
59
+
60
+ const pidContents = readPidFile(pidPath)
61
+ if (pidContents === null) return null
62
+ if (!procfs.pidExists(pidContents)) return null
63
+
64
+ const pidInodes = procfs.listenInodesForPid(pidContents)
65
+ const candidates: number[] = []
66
+ for (const socket of procfs.listenSockets()) {
67
+ if (!pidInodes.has(socket.inode)) continue
68
+ if (opts.excludePort !== undefined && socket.port === opts.excludePort) continue
69
+ candidates.push(socket.port)
70
+ }
71
+
72
+ for (const port of candidates) {
73
+ if (await isDashboardPort(port, fetcher, probeTimeout)) return port
74
+ }
75
+ return null
76
+ }
77
+
78
+ export function writePortHint(port: number, hintPath: string = PORT_HINT_PATH): void {
79
+ Bun.write(hintPath, String(port))
80
+ }
81
+
82
+ function readPortHint(path: string): number | null {
83
+ try {
84
+ const raw = readFileSync(path, 'utf-8').trim()
85
+ const port = Number(raw)
86
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) return null
87
+ return port
88
+ } catch {
89
+ return null
90
+ }
91
+ }
92
+
93
+ function readPidFile(path: string): number | null {
94
+ try {
95
+ const raw = readFileSync(path, 'utf-8').trim()
96
+ const pid = Number(raw)
97
+ if (!Number.isInteger(pid) || pid < 1) return null
98
+ return pid
99
+ } catch {
100
+ return null
101
+ }
102
+ }
103
+
104
+ async function isDashboardPort(port: number, fetcher: typeof fetch, timeoutMs: number): Promise<boolean> {
105
+ const ctrl = new AbortController()
106
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs)
107
+ try {
108
+ const res = await fetcher(`http://127.0.0.1:${port}/api/sessions`, {
109
+ method: 'GET',
110
+ signal: ctrl.signal,
111
+ })
112
+ return res.ok
113
+ } catch {
114
+ return false
115
+ } finally {
116
+ clearTimeout(timer)
117
+ }
118
+ }
119
+
120
+ function defaultProcFs(): ProcFs {
121
+ return {
122
+ pidExists: (pid) => existsSync(`/proc/${pid}`),
123
+ listenInodesForPid: (pid) => {
124
+ const inodes = new Set<string>()
125
+ const fdDir = `/proc/${pid}/fd`
126
+ let entries: string[]
127
+ try {
128
+ entries = readdirSync(fdDir)
129
+ } catch {
130
+ return inodes
131
+ }
132
+ for (const entry of entries) {
133
+ try {
134
+ const target = readlinkSync(`${fdDir}/${entry}`)
135
+ const match = target.match(/^socket:\[(\d+)\]$/)
136
+ if (match) inodes.add(match[1]!)
137
+ } catch {
138
+ continue
139
+ }
140
+ }
141
+ return inodes
142
+ },
143
+ listenSockets: () => {
144
+ const out: Array<{ port: number; inode: string }> = []
145
+ for (const file of ['/proc/net/tcp', '/proc/net/tcp6']) {
146
+ let raw: string
147
+ try {
148
+ raw = readFileSync(file, 'utf-8')
149
+ } catch {
150
+ continue
151
+ }
152
+ const lines = raw.split('\n').slice(1)
153
+ for (const line of lines) {
154
+ const cols = line.trim().split(/\s+/)
155
+ if (cols.length < 10) continue
156
+ if (cols[3] !== '0A') continue
157
+ const local = cols[1] ?? ''
158
+ const colonIdx = local.lastIndexOf(':')
159
+ if (colonIdx < 0) continue
160
+ const port = Number.parseInt(local.slice(colonIdx + 1), 16)
161
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) continue
162
+ const inode = cols[9]
163
+ if (inode === undefined) continue
164
+ out.push({ port, inode })
165
+ }
166
+ }
167
+ return out
168
+ },
169
+ }
170
+ }
@@ -0,0 +1,421 @@
1
+ import type { Server } from 'bun'
2
+
3
+ import { discoverDashboardPort } from './dashboard-discovery'
4
+
5
+ export type DashboardProxyOptions = {
6
+ listenPort?: number
7
+ upstreamPort?: number
8
+ resolveUpstreamPort?: () => Promise<number | null>
9
+ upstreamHost?: string
10
+ listenHost?: string
11
+ fetchImpl?: typeof fetch
12
+ onLog?: (event: DashboardProxyLogEvent) => void
13
+ }
14
+
15
+ export type DashboardProxyLogEvent =
16
+ | { kind: 'started'; listenHost: string; listenPort: number; upstreamHost: string }
17
+ | { kind: 'http-proxy'; port: number; path: string }
18
+ | { kind: 'ws-proxy'; port: number; path: string }
19
+ | { kind: 'invalid-proxy-target'; prefix: string; pathname: string }
20
+ | { kind: 'proxy-target-denied'; port: number; path: string; reason: string }
21
+ | { kind: 'upstream-error'; target: string; reason: string }
22
+ | { kind: 'no-upstream'; path: string }
23
+
24
+ export type DashboardProxy = {
25
+ server: Server<WebSocketData>
26
+ stop: () => void
27
+ }
28
+
29
+ type WebSocketData = {
30
+ port: number
31
+ path: string
32
+ upstream?: WebSocket
33
+ pending: Array<string | ArrayBuffer>
34
+ }
35
+
36
+ const DEFAULT_PROXY_PORT = 4848
37
+ const DEFAULT_UPSTREAM_PORT = 4849
38
+ const DEFAULT_HOST = '127.0.0.1'
39
+ const DEFAULT_LISTEN_HOST = '0.0.0.0'
40
+ const HTTP_PROXY_PREFIX = '/__typeclaw_agent_browser_http/'
41
+ const WS_PROXY_PREFIX = '/__typeclaw_agent_browser_ws/'
42
+ const TYPECLAW_AGENT_PORT = 8973
43
+ const TYPECLAW_HOSTD_CONTROL_PORT = 8974
44
+ const UPSTREAM_PORT_CACHE_MS = 1_000
45
+
46
+ export function startDashboardProxy(opts: DashboardProxyOptions = {}): DashboardProxy {
47
+ const listenPort = opts.listenPort ?? DEFAULT_PROXY_PORT
48
+ const upstreamHost = opts.upstreamHost ?? DEFAULT_HOST
49
+ const listenHost = opts.listenHost ?? DEFAULT_LISTEN_HOST
50
+ const fetcher = opts.fetchImpl ?? fetch
51
+ const log = opts.onLog ?? (() => {})
52
+
53
+ const resolveUpstreamPort = makeResolverWithCache(opts, listenPort)
54
+ const reservedPorts = new Set([listenPort, TYPECLAW_AGENT_PORT, TYPECLAW_HOSTD_CONTROL_PORT])
55
+
56
+ const server = Bun.serve<WebSocketData>({
57
+ hostname: listenHost,
58
+ port: listenPort,
59
+ async fetch(request, bunServer) {
60
+ const url = new URL(request.url)
61
+
62
+ const wsTarget = parsePortPath(url.pathname, WS_PROXY_PREFIX)
63
+ if (wsTarget) {
64
+ const upstreamPort = await resolveUpstreamPort()
65
+ const denied = await denyProxyTarget({
66
+ target: wsTarget,
67
+ reservedPorts,
68
+ upstreamPort,
69
+ fetcher,
70
+ upstreamHost,
71
+ })
72
+ if (denied) {
73
+ log({ kind: 'proxy-target-denied', port: wsTarget.port, path: wsTarget.path, reason: denied })
74
+ return new Response(denied, { status: 403 })
75
+ }
76
+ if (
77
+ bunServer.upgrade(request, {
78
+ data: { port: wsTarget.port, path: `${wsTarget.path}${url.search}`, pending: [] },
79
+ })
80
+ ) {
81
+ log({ kind: 'ws-proxy', port: wsTarget.port, path: wsTarget.path })
82
+ return undefined
83
+ }
84
+ return new Response('WebSocket upgrade failed', { status: 400 })
85
+ }
86
+
87
+ if (url.pathname.startsWith(WS_PROXY_PREFIX)) {
88
+ log({ kind: 'invalid-proxy-target', prefix: WS_PROXY_PREFIX, pathname: url.pathname })
89
+ return new Response('Invalid WebSocket proxy target', { status: 400 })
90
+ }
91
+
92
+ const httpTarget = parsePortPath(url.pathname, HTTP_PROXY_PREFIX)
93
+ if (httpTarget) {
94
+ const upstreamPort = await resolveUpstreamPort()
95
+ const denied = await denyProxyTarget({
96
+ target: httpTarget,
97
+ reservedPorts,
98
+ upstreamPort,
99
+ fetcher,
100
+ upstreamHost,
101
+ })
102
+ if (denied) {
103
+ log({ kind: 'proxy-target-denied', port: httpTarget.port, path: httpTarget.path, reason: denied })
104
+ return new Response(denied, { status: 403 })
105
+ }
106
+ log({ kind: 'http-proxy', port: httpTarget.port, path: httpTarget.path })
107
+ return proxyHttp({
108
+ request,
109
+ fetcher,
110
+ host: DEFAULT_HOST,
111
+ port: httpTarget.port,
112
+ path: `${httpTarget.path}${url.search}`,
113
+ })
114
+ }
115
+
116
+ if (url.pathname.startsWith(HTTP_PROXY_PREFIX)) {
117
+ log({ kind: 'invalid-proxy-target', prefix: HTTP_PROXY_PREFIX, pathname: url.pathname })
118
+ return new Response('Invalid HTTP proxy target', { status: 400 })
119
+ }
120
+
121
+ const upstreamPort = await resolveUpstreamPort()
122
+ if (upstreamPort === null) {
123
+ log({ kind: 'no-upstream', path: url.pathname })
124
+ return new Response('agent-browser dashboard is not running. Start it with `agent-browser dashboard start`.', {
125
+ status: 502,
126
+ })
127
+ }
128
+
129
+ const upstreamPath = `${url.pathname}${url.search}`
130
+ const response = await proxyHttp({ request, fetcher, host: upstreamHost, port: upstreamPort, path: upstreamPath })
131
+ return maybeInjectDashboardPatch(response)
132
+ },
133
+ websocket: {
134
+ open(ws) {
135
+ const data = ws.data
136
+ const upstream = new WebSocket(`ws://${DEFAULT_HOST}:${data.port}${data.path}`)
137
+ data.upstream = upstream
138
+ upstream.binaryType = 'arraybuffer'
139
+ upstream.addEventListener('open', () => flushPending(data))
140
+ upstream.addEventListener('message', (event) => ws.send(toBunWebSocketPayload(event.data)))
141
+ upstream.addEventListener('close', () => ws.close())
142
+ upstream.addEventListener('error', () => ws.close())
143
+ },
144
+ message(ws, message) {
145
+ const data = ws.data
146
+ if (data.upstream?.readyState === WebSocket.OPEN) {
147
+ data.upstream.send(toWebSocketPayload(message))
148
+ return
149
+ }
150
+ data.pending.push(toWebSocketPayload(message))
151
+ },
152
+ close(ws) {
153
+ ws.data.upstream?.close()
154
+ ws.data.pending = []
155
+ },
156
+ },
157
+ })
158
+
159
+ if (server.port !== undefined) reservedPorts.add(server.port)
160
+ log({ kind: 'started', listenHost, listenPort: server.port ?? listenPort, upstreamHost })
161
+
162
+ return {
163
+ server,
164
+ stop: () => server.stop(true),
165
+ }
166
+ }
167
+
168
+ function makeResolverWithCache(opts: DashboardProxyOptions, listenPort: number): () => Promise<number | null> {
169
+ // The resolver is called on every proxied request; cache for a short
170
+ // window so concurrent dashboard fetches do not each spawn a procfs walk
171
+ // and a /api/sessions probe. UPSTREAM_PORT_CACHE_MS is below the
172
+ // perceptible-latency threshold and well under the time it takes to
173
+ // start/stop a dashboard, so a stale entry resolves itself within one
174
+ // tick of the user noticing.
175
+ if (opts.resolveUpstreamPort) {
176
+ let cached: { port: number | null; at: number } | null = null
177
+ return async () => {
178
+ const now = Date.now()
179
+ if (cached !== null && now - cached.at < UPSTREAM_PORT_CACHE_MS) return cached.port
180
+ const port = await opts.resolveUpstreamPort!()
181
+ cached = { port, at: now }
182
+ return port
183
+ }
184
+ }
185
+ if (opts.upstreamPort !== undefined) {
186
+ const fixed = opts.upstreamPort
187
+ return async () => fixed
188
+ }
189
+ let cached: { port: number | null; at: number } | null = null
190
+ return async () => {
191
+ const now = Date.now()
192
+ if (cached !== null && now - cached.at < UPSTREAM_PORT_CACHE_MS) return cached.port
193
+ const port = await discoverDashboardPort({ excludePort: listenPort })
194
+ cached = { port, at: now }
195
+ return port
196
+ }
197
+ }
198
+
199
+ export function buildDashboardPatchScript(): string {
200
+ return `<script>${dashboardPatchBody()}</script>`
201
+ }
202
+
203
+ export async function maybeInjectDashboardPatch(response: Response): Promise<Response> {
204
+ const contentType = response.headers.get('content-type') ?? ''
205
+ if (!contentType.includes('text/html')) return response
206
+
207
+ const html = await response.text()
208
+ const patch = buildDashboardPatchScript()
209
+ const patched = injectPatch(html, patch)
210
+ const headers = new Headers(response.headers)
211
+ headers.delete('content-length')
212
+ return new Response(patched, { status: response.status, statusText: response.statusText, headers })
213
+ }
214
+
215
+ function injectPatch(html: string, patch: string): string {
216
+ const closingHead = html.match(/<\/head\s*>/i)
217
+ if (closingHead && closingHead.index !== undefined) {
218
+ return html.slice(0, closingHead.index) + patch + html.slice(closingHead.index)
219
+ }
220
+ const openingHead = html.match(/<head\b[^>]*>/i)
221
+ if (openingHead && openingHead.index !== undefined) {
222
+ const insertAt = openingHead.index + openingHead[0].length
223
+ return html.slice(0, insertAt) + patch + html.slice(insertAt)
224
+ }
225
+ const openingHtml = html.match(/<html\b[^>]*>/i)
226
+ if (openingHtml && openingHtml.index !== undefined) {
227
+ const insertAt = openingHtml.index + openingHtml[0].length
228
+ return html.slice(0, insertAt) + patch + html.slice(insertAt)
229
+ }
230
+ return patch + html
231
+ }
232
+
233
+ export function parsePortPath(pathname: string, prefix: string): { port: number; path: string } | null {
234
+ if (!pathname.startsWith(prefix)) return null
235
+ const rest = pathname.slice(prefix.length)
236
+ const slash = rest.indexOf('/')
237
+ const encodedPort = slash === -1 ? rest : rest.slice(0, slash)
238
+ const port = Number.parseInt(decodeURIComponent(encodedPort), 10)
239
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null
240
+ return { port, path: slash === -1 ? '/' : rest.slice(slash) }
241
+ }
242
+
243
+ function dashboardPatchBody(): string {
244
+ return String.raw`
245
+ (() => {
246
+ const httpPrefix = '${HTTP_PROXY_PREFIX}';
247
+ const wsPrefix = '${WS_PROXY_PREFIX}';
248
+
249
+ function isLoopbackHost(hostname) {
250
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
251
+ }
252
+
253
+ function rewriteHttp(input) {
254
+ const raw = typeof input === 'string' ? input : input && input.url;
255
+ if (!raw) return input;
256
+
257
+ let url;
258
+ try { url = new URL(raw, window.location.href); } catch { return input; }
259
+ if (!isLoopbackHost(url.hostname) || !url.port) return input;
260
+
261
+ const currentPort = String(window.location.port || (window.location.protocol === 'https:' ? 443 : 80));
262
+ if (url.port === currentPort) return url.pathname + url.search + url.hash;
263
+ return httpPrefix + encodeURIComponent(url.port) + url.pathname + url.search + url.hash;
264
+ }
265
+
266
+ const nativeFetch = window.fetch.bind(window);
267
+ window.fetch = (input, init) => nativeFetch(rewriteHttp(input), init);
268
+
269
+ const NativeWebSocket = window.WebSocket;
270
+ window.WebSocket = function(url, protocols) {
271
+ let next = url;
272
+ try {
273
+ const parsed = new URL(String(url), window.location.href);
274
+ if (isLoopbackHost(parsed.hostname) && parsed.port) {
275
+ const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
276
+ next = scheme + '//' + window.location.host + wsPrefix + encodeURIComponent(parsed.port) + parsed.pathname + parsed.search + parsed.hash;
277
+ }
278
+ } catch {}
279
+ return protocols === undefined ? new NativeWebSocket(next) : new NativeWebSocket(next, protocols);
280
+ };
281
+ window.WebSocket.prototype = NativeWebSocket.prototype;
282
+ for (const key of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']) {
283
+ Object.defineProperty(window.WebSocket, key, { value: NativeWebSocket[key] });
284
+ }
285
+ })();`
286
+ }
287
+
288
+ async function proxyHttp({
289
+ request,
290
+ fetcher,
291
+ host,
292
+ port,
293
+ path,
294
+ }: {
295
+ request: Request
296
+ fetcher: typeof fetch
297
+ host: string
298
+ port: number
299
+ path: string
300
+ }): Promise<Response> {
301
+ const target = `http://${host}:${port}${path}`
302
+ try {
303
+ const response = await fetcher(target, {
304
+ method: request.method,
305
+ headers: hopHeaders(request.headers),
306
+ body: request.body,
307
+ redirect: 'manual',
308
+ })
309
+ return rewriteCorsHeaders(response, request)
310
+ } catch (err) {
311
+ const reason = err instanceof Error ? err.message : String(err)
312
+ return new Response(`Failed to proxy ${target}: ${reason}`, { status: 502 })
313
+ }
314
+ }
315
+
316
+ function rewriteCorsHeaders(response: Response, request: Request): Response {
317
+ const origin = request.headers.get('origin')
318
+ if (origin === null) return response
319
+
320
+ const allowOrigin = response.headers.get('access-control-allow-origin')
321
+ if (allowOrigin === null || !isLoopbackOrigin(allowOrigin)) return response
322
+
323
+ const headers = new Headers(response.headers)
324
+ headers.set('access-control-allow-origin', origin)
325
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
326
+ }
327
+
328
+ function isLoopbackOrigin(value: string): boolean {
329
+ try {
330
+ const url = new URL(value)
331
+ return (
332
+ url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' || url.hostname === '::1'
333
+ )
334
+ } catch {
335
+ return false
336
+ }
337
+ }
338
+
339
+ function hopHeaders(headers: Headers): Headers {
340
+ const next = new Headers(headers)
341
+ for (const name of [
342
+ 'host',
343
+ 'connection',
344
+ 'upgrade',
345
+ 'sec-websocket-key',
346
+ 'sec-websocket-version',
347
+ 'sec-websocket-extensions',
348
+ 'sec-websocket-protocol',
349
+ ]) {
350
+ next.delete(name)
351
+ }
352
+ return next
353
+ }
354
+
355
+ function flushPending(data: WebSocketData): void {
356
+ const upstream = data.upstream
357
+ if (!upstream || upstream.readyState !== WebSocket.OPEN) return
358
+ const pending = data.pending.splice(0)
359
+ for (const message of pending) upstream.send(message)
360
+ }
361
+
362
+ function toBunWebSocketPayload(data: unknown): string | Uint8Array {
363
+ if (typeof data === 'string') return data
364
+ if (data instanceof ArrayBuffer) return new Uint8Array(data)
365
+ if (ArrayBuffer.isView(data))
366
+ return new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength))
367
+ return String(data)
368
+ }
369
+
370
+ function toWebSocketPayload(data: string | Buffer): string | ArrayBuffer {
371
+ if (typeof data === 'string') return data
372
+ const copy = new Uint8Array(data.byteLength)
373
+ copy.set(data)
374
+ return copy.buffer
375
+ }
376
+
377
+ async function denyProxyTarget({
378
+ target,
379
+ reservedPorts,
380
+ upstreamPort,
381
+ fetcher,
382
+ upstreamHost,
383
+ }: {
384
+ target: { port: number; path: string }
385
+ reservedPorts: Set<number>
386
+ upstreamPort: number | null
387
+ fetcher: typeof fetch
388
+ upstreamHost: string
389
+ }): Promise<string | null> {
390
+ if (reservedPorts.has(target.port)) return `port ${target.port} is reserved`
391
+ if (upstreamPort !== null && target.port === upstreamPort) return `port ${target.port} is reserved`
392
+ if (upstreamPort === null) return 'agent-browser dashboard is not running; cannot validate session port'
393
+ const allowed = await discoverSessionPorts({ fetcher, upstreamHost, upstreamPort })
394
+ if (!allowed.has(target.port)) return `port ${target.port} is not an active agent-browser session port`
395
+ return null
396
+ }
397
+
398
+ async function discoverSessionPorts({
399
+ fetcher,
400
+ upstreamHost,
401
+ upstreamPort,
402
+ }: {
403
+ fetcher: typeof fetch
404
+ upstreamHost: string
405
+ upstreamPort: number
406
+ }): Promise<Set<number>> {
407
+ const response = await fetcher(`http://${upstreamHost}:${upstreamPort}/api/sessions`)
408
+ if (!response.ok) return new Set()
409
+ const raw: unknown = await response.json().catch(() => [])
410
+ if (!Array.isArray(raw)) return new Set()
411
+ const ports = new Set<number>()
412
+ for (const entry of raw) {
413
+ if (typeof entry !== 'object' || entry === null) continue
414
+ const port = (entry as { port?: unknown }).port
415
+ if (typeof port === 'number' && Number.isInteger(port) && port > 0 && port <= 65535) ports.add(port)
416
+ }
417
+ return ports
418
+ }
419
+
420
+ export const AGENT_BROWSER_DASHBOARD_PROXY_PORT = DEFAULT_PROXY_PORT
421
+ export const AGENT_BROWSER_DASHBOARD_UPSTREAM_PORT = DEFAULT_UPSTREAM_PORT