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