typeclaw 0.3.1 → 0.5.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 (125) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +1 -1
  4. package/secrets.schema.json +113 -0
  5. package/src/agent/auth.ts +4 -2
  6. package/src/agent/index.ts +16 -28
  7. package/src/agent/model-fallback.ts +127 -0
  8. package/src/agent/session-meta.ts +1 -1
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/tools/curl-impersonate.ts +300 -0
  11. package/src/agent/tools/ddg.ts +13 -88
  12. package/src/agent/tools/webfetch/fetch.ts +105 -2
  13. package/src/agent/tools/webfetch/tool.ts +4 -0
  14. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  15. package/src/bundled-plugins/backup/subagents.ts +2 -0
  16. package/src/bundled-plugins/memory/README.md +49 -12
  17. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  18. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  19. package/src/bundled-plugins/memory/index.ts +2 -2
  20. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  21. package/src/bundled-plugins/memory/strength.ts +127 -0
  22. package/src/bundled-plugins/memory/topics.ts +75 -0
  23. package/src/bundled-plugins/security/index.ts +88 -43
  24. package/src/bundled-plugins/security/permissions.ts +36 -0
  25. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  26. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  27. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  28. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  29. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  30. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  31. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  32. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  33. package/src/channels/adapters/github/auth-app.ts +120 -0
  34. package/src/channels/adapters/github/auth-pat.ts +50 -0
  35. package/src/channels/adapters/github/auth.ts +33 -0
  36. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  37. package/src/channels/adapters/github/dedup.ts +26 -0
  38. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  39. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  40. package/src/channels/adapters/github/history.ts +63 -0
  41. package/src/channels/adapters/github/inbound.ts +286 -0
  42. package/src/channels/adapters/github/index.ts +370 -0
  43. package/src/channels/adapters/github/managed-path.ts +54 -0
  44. package/src/channels/adapters/github/membership.ts +35 -0
  45. package/src/channels/adapters/github/outbound.ts +145 -0
  46. package/src/channels/adapters/github/webhook-register.ts +349 -0
  47. package/src/channels/manager.ts +94 -9
  48. package/src/channels/router.ts +194 -28
  49. package/src/channels/schema.ts +31 -1
  50. package/src/channels/tunnel-bridge.ts +51 -0
  51. package/src/channels/types.ts +3 -1
  52. package/src/cli/builtins.ts +28 -0
  53. package/src/cli/channel.ts +511 -25
  54. package/src/cli/container-command-client.ts +244 -0
  55. package/src/cli/cron.ts +173 -0
  56. package/src/cli/host-command-runner.ts +150 -0
  57. package/src/cli/index.ts +42 -1
  58. package/src/cli/init.ts +400 -67
  59. package/src/cli/model.ts +14 -4
  60. package/src/cli/oauth-callbacks.ts +49 -0
  61. package/src/cli/plugin-command-help.ts +49 -0
  62. package/src/cli/plugin-commands-dispatch.ts +112 -0
  63. package/src/cli/plugin-commands.ts +118 -0
  64. package/src/cli/provider.ts +3 -20
  65. package/src/cli/tui.ts +10 -2
  66. package/src/cli/tunnel.ts +533 -0
  67. package/src/cli/ui.ts +8 -3
  68. package/src/config/config.ts +134 -24
  69. package/src/config/models-mutation.ts +42 -8
  70. package/src/config/providers-mutation.ts +12 -8
  71. package/src/container/start.ts +48 -4
  72. package/src/cron/bridge.ts +136 -0
  73. package/src/cron/consumer.ts +174 -48
  74. package/src/cron/index.ts +19 -2
  75. package/src/cron/list.ts +105 -0
  76. package/src/cron/scheduler.ts +12 -3
  77. package/src/cron/schema.ts +11 -3
  78. package/src/doctor/checks.ts +0 -50
  79. package/src/init/dockerfile.ts +165 -13
  80. package/src/init/ensure-deps.ts +15 -4
  81. package/src/init/github-webhook-install.ts +109 -0
  82. package/src/init/hatching.ts +2 -2
  83. package/src/init/index.ts +519 -12
  84. package/src/init/oauth-login.ts +17 -3
  85. package/src/init/run-bun-install.ts +17 -3
  86. package/src/init/run-owner-claim.ts +11 -2
  87. package/src/permissions/builtins.ts +29 -2
  88. package/src/permissions/match-rule.ts +24 -2
  89. package/src/permissions/permissions.ts +24 -7
  90. package/src/permissions/resolve.ts +1 -0
  91. package/src/plugin/define.ts +44 -1
  92. package/src/plugin/index.ts +18 -3
  93. package/src/plugin/manager.ts +16 -0
  94. package/src/plugin/registry.ts +85 -3
  95. package/src/plugin/types.ts +144 -1
  96. package/src/plugin/zod-introspect.ts +100 -0
  97. package/src/role-claim/match-rule.ts +2 -1
  98. package/src/run/index.ts +112 -4
  99. package/src/secrets/index.ts +1 -1
  100. package/src/secrets/schema.ts +21 -0
  101. package/src/server/command-runner.ts +476 -0
  102. package/src/server/index.ts +388 -5
  103. package/src/shared/index.ts +8 -0
  104. package/src/shared/protocol.ts +80 -1
  105. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  106. package/src/skills/typeclaw-config/SKILL.md +27 -26
  107. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  108. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  109. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  110. package/src/skills/typeclaw-permissions/SKILL.md +35 -16
  111. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  112. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  113. package/src/test-helpers/wait-for.ts +50 -0
  114. package/src/tui/index.ts +70 -7
  115. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  116. package/src/tunnels/events.ts +14 -0
  117. package/src/tunnels/index.ts +12 -0
  118. package/src/tunnels/log-ring.ts +54 -0
  119. package/src/tunnels/manager.ts +139 -0
  120. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  121. package/src/tunnels/providers/external.ts +53 -0
  122. package/src/tunnels/quick-url-parser.ts +5 -0
  123. package/src/tunnels/types.ts +43 -0
  124. package/src/usage/report.ts +15 -12
  125. package/typeclaw.schema.json +311 -26
package/src/tui/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { Editor, Key, Markdown, matchesKey, ProcessTerminal, type Terminal, Text, TUI } from '@mariozechner/pi-tui'
2
2
 
3
+ import { parseCommand } from '@/commands'
4
+
3
5
  import { createClient as createClientDefault, type Client } from './client'
4
6
  import { formatQueuePanel, formatToolEnd, formatToolStart, formatUserPromptHistory } from './format'
5
7
  import { colors, editorTheme, markdownTheme } from './theme'
@@ -9,6 +11,23 @@ export type TerminalFactory = () => Terminal
9
11
 
10
12
  const DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000
11
13
 
14
+ // Bare slash-command names (no leading `/`) the TUI intercepts client-side and
15
+ // turns into a clean process exit. The hatching ritual tells the agent to point
16
+ // users at `/quit` (see src/init/hatching.ts); without an intercept the literal
17
+ // text would be shipped to the LLM as a chat message. Grammar (case-insensitive,
18
+ // whitespace-tolerant, `//foo` escapes to a literal prompt) comes from
19
+ // `parseCommand` in src/commands so channel and TUI slash commands stay
20
+ // consistent. Arguments after the name disqualify the match: `/quit me a story`
21
+ // is a real prompt, not a command.
22
+ const QUIT_COMMAND_NAMES: ReadonlySet<string> = new Set(['quit', 'exit'])
23
+
24
+ function isQuitCommand(text: string): boolean {
25
+ const parsed = parseCommand(text)
26
+ return parsed !== null && parsed.args.length === 0 && QUIT_COMMAND_NAMES.has(parsed.name)
27
+ }
28
+
29
+ export type VersionMismatch = { expected: string; actual: string }
30
+
12
31
  export type TuiOptions = {
13
32
  url: string
14
33
  initialPrompt?: string
@@ -16,6 +35,13 @@ export type TuiOptions = {
16
35
  createTerminal?: TerminalFactory
17
36
  handshakeTimeoutMs?: number
18
37
  exit?: (code: number) => void
38
+ // Locally-known typeclaw version the host CLI is running. When provided
39
+ // and the connected frame's serverVersion is defined and differs,
40
+ // onVersionMismatch is invoked AND a yellow warning line is rendered
41
+ // into the TUI history. The container-side local TUI omits this so no
42
+ // mismatch check fires when client and server are guaranteed in lockstep.
43
+ expectedVersion?: string
44
+ onVersionMismatch?: (info: VersionMismatch) => void
19
45
  }
20
46
 
21
47
  export function createTui({
@@ -25,6 +51,8 @@ export function createTui({
25
51
  createTerminal = () => new ProcessTerminal(),
26
52
  handshakeTimeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS,
27
53
  exit = process.exit.bind(process),
54
+ expectedVersion,
55
+ onVersionMismatch,
28
56
  }: TuiOptions) {
29
57
  async function run(): Promise<void> {
30
58
  const terminal = createTerminal()
@@ -44,7 +72,7 @@ export function createTui({
44
72
  throw err
45
73
  })
46
74
 
47
- const sessionId = await waitForConnected(client, displayUrl, handshakeTimeoutMs).catch((err) => {
75
+ const handshake = await waitForConnected(client, displayUrl, handshakeTimeoutMs).catch((err) => {
48
76
  status.setText(colors.red(`connection error: ${err instanceof Error ? err.message : String(err)}`))
49
77
  tui.requestRender()
50
78
  client.close()
@@ -52,6 +80,7 @@ export function createTui({
52
80
  exit(1)
53
81
  throw err
54
82
  })
83
+ const { sessionId, serverVersion } = handshake
55
84
  status.setText(colors.dim(`session: ${sessionId}`))
56
85
  tui.requestRender()
57
86
 
@@ -193,14 +222,18 @@ export function createTui({
193
222
  return undefined
194
223
  })
195
224
 
225
+ const shutdown = (code: number) => {
226
+ tui.stop()
227
+ client.close()
228
+ exit(code)
229
+ }
230
+
196
231
  // Ctrl+C exits cleanly. In raw mode the kernel does NOT generate SIGINT,
197
232
  // so we must intercept the \x03 byte ourselves. The Editor would otherwise
198
233
  // swallow it. tui.stop() restores raw-mode/cursor/echo before we exit.
199
234
  tui.addInputListener((data) => {
200
235
  if (matchesKey(data, Key.ctrl('c'))) {
201
- tui.stop()
202
- client.close()
203
- exit(0)
236
+ shutdown(0)
204
237
  return { consume: true }
205
238
  }
206
239
  return undefined
@@ -208,6 +241,10 @@ export function createTui({
208
241
 
209
242
  editor.onSubmit = (text) => {
210
243
  if (text.trim().length === 0) return
244
+ if (isQuitCommand(text)) {
245
+ shutdown(0)
246
+ return
247
+ }
211
248
  editor.setText('')
212
249
  editor.addToHistory(text)
213
250
  tui.requestRender()
@@ -217,7 +254,22 @@ export function createTui({
217
254
  tui.setFocus(editor)
218
255
  tui.requestRender()
219
256
 
257
+ if (expectedVersion !== undefined && serverVersion !== undefined && serverVersion !== expectedVersion) {
258
+ const mismatch: VersionMismatch = { expected: expectedVersion, actual: serverVersion }
259
+ const warning = formatVersionMismatchWarning(mismatch)
260
+ appendHistory(new Text(colors.yellow(warning), 0, 0))
261
+ tui.requestRender()
262
+ onVersionMismatch?.(mismatch)
263
+ }
264
+
220
265
  if (initialPrompt) {
266
+ // initialPrompt bypasses editor.onSubmit, so the quit intercept above
267
+ // would never run. Guard the same way so `typeclaw tui /quit` exits
268
+ // instead of leaking the command into the agent's chat context.
269
+ if (isQuitCommand(initialPrompt)) {
270
+ shutdown(0)
271
+ return
272
+ }
221
273
  await send(initialPrompt)
222
274
  }
223
275
 
@@ -238,8 +290,12 @@ function redactUrl(url: string): string {
238
290
  }
239
291
  }
240
292
 
241
- async function waitForConnected(client: Client, url: string, timeoutMs: number): Promise<string> {
242
- return await new Promise<string>((resolve, reject) => {
293
+ async function waitForConnected(
294
+ client: Client,
295
+ url: string,
296
+ timeoutMs: number,
297
+ ): Promise<{ sessionId: string; serverVersion?: string }> {
298
+ return await new Promise<{ sessionId: string; serverVersion?: string }>((resolve, reject) => {
243
299
  const timer = setTimeout(() => {
244
300
  cleanup()
245
301
  reject(new Error(`timed out waiting for connected message from ${url} after ${timeoutMs}ms`))
@@ -253,7 +309,10 @@ async function waitForConnected(client: Client, url: string, timeoutMs: number):
253
309
  client.onMessage((msg) => {
254
310
  if (msg.type === 'connected') {
255
311
  cleanup()
256
- resolve(msg.sessionId)
312
+ resolve({
313
+ sessionId: msg.sessionId,
314
+ ...(msg.serverVersion !== undefined ? { serverVersion: msg.serverVersion } : {}),
315
+ })
257
316
  }
258
317
  if (msg.type === 'error') {
259
318
  cleanup()
@@ -275,3 +334,7 @@ async function waitForConnected(client: Client, url: string, timeoutMs: number):
275
334
  )
276
335
  })
277
336
  }
337
+
338
+ export function formatVersionMismatchWarning({ expected, actual }: VersionMismatch): string {
339
+ return `WARN: host CLI is v${expected}, agent container is v${actual}. Some commands may hang or fail. Try \`typeclaw restart --build\`.`
340
+ }
@@ -0,0 +1,11 @@
1
+ # Fixture source: cloudflare/cloudflared cmd/cloudflared/tunnel/quick_tunnel.go
2
+ # RunQuickTunnel logs the disclaimer, "Requesting new quick Tunnel on trycloudflare.com...",
3
+ # then AsciiBox(...) lines containing the resolved https://<subdomain>.trycloudflare.com URL.
4
+ # Reference fetched from:
5
+ # https://raw.githubusercontent.com/cloudflare/cloudflared/master/cmd/cloudflared/tunnel/quick_tunnel.go
6
+ 2026-01-01T00:00:00Z INF Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee, are subject to the Cloudflare Online Services Terms of Use (https://www.cloudflare.com/website-terms/), and Cloudflare reserves the right to investigate your use of Tunnels for violations of such terms. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps
7
+ 2026-01-01T00:00:00Z INF Requesting new quick Tunnel on trycloudflare.com...
8
+ 2026-01-01T00:00:00Z INF +--------------------------------------------------------------------------------------------------+
9
+ 2026-01-01T00:00:00Z INF | Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): |
10
+ 2026-01-01T00:00:00Z INF | https://wave-one-fixture.trycloudflare.com |
11
+ 2026-01-01T00:00:00Z INF +--------------------------------------------------------------------------------------------------+
@@ -0,0 +1,14 @@
1
+ import type { TunnelUrlChangedPayload } from './types'
2
+
3
+ export function isTunnelUrlChangedPayload(value: unknown): value is TunnelUrlChangedPayload {
4
+ if (value === null || typeof value !== 'object') return false
5
+ const v = value as Record<string, unknown>
6
+ if (v.kind !== 'tunnel-url-changed') return false
7
+ if (typeof v.tunnelName !== 'string') return false
8
+ if (typeof v.url !== 'string') return false
9
+ if (typeof v.rotatedAt !== 'string') return false
10
+ if (v.for === null || typeof v.for !== 'object') return false
11
+ const forKind = (v.for as Record<string, unknown>).kind
12
+ if (forKind !== 'channel' && forKind !== 'manual') return false
13
+ return true
14
+ }
@@ -0,0 +1,12 @@
1
+ export { createTunnelManager, type TunnelManager, type TunnelManagerOptions, type TunnelManagerLogger } from './manager'
2
+ export { createCloudflareQuickProvider, type CloudflareQuickProviderOptions } from './providers/cloudflare-quick'
3
+ export {
4
+ type TunnelConfig,
5
+ type TunnelFor,
6
+ type TunnelProvider,
7
+ type TunnelProviderHandle,
8
+ type TunnelState,
9
+ type TunnelStatus,
10
+ type TunnelUrlChangedPayload,
11
+ } from './types'
12
+ export { isTunnelUrlChangedPayload } from './events'
@@ -0,0 +1,54 @@
1
+ import type { Unsubscribe } from '@/stream'
2
+
3
+ export const DEFAULT_LOG_RING_MAX_BYTES = 1024 * 1024
4
+
5
+ export type LogLineSubscriber = (line: string) => void
6
+
7
+ export type LogRingOptions = {
8
+ maxBytes?: number
9
+ }
10
+
11
+ export type LogRing = {
12
+ append: (line: string) => void
13
+ snapshot: () => string[]
14
+ subscribe: (cb: LogLineSubscriber) => Unsubscribe
15
+ }
16
+
17
+ const encoder = new TextEncoder()
18
+
19
+ export function createLogRing(options: LogRingOptions = {}): LogRing {
20
+ const maxBytes = options.maxBytes ?? DEFAULT_LOG_RING_MAX_BYTES
21
+ if (!Number.isInteger(maxBytes) || maxBytes < 1) {
22
+ throw new Error('LogRing maxBytes must be a positive integer')
23
+ }
24
+
25
+ const lines: string[] = []
26
+ const sizes: number[] = []
27
+ const subscribers = new Set<LogLineSubscriber>()
28
+ let bytes = 0
29
+
30
+ return {
31
+ append(line: string): void {
32
+ const size = encoder.encode(line).byteLength
33
+ lines.push(line)
34
+ sizes.push(size)
35
+ bytes += size
36
+
37
+ while (bytes > maxBytes && lines.length > 1) {
38
+ lines.shift()
39
+ bytes -= sizes.shift() ?? 0
40
+ }
41
+
42
+ for (const subscriber of subscribers) subscriber(line)
43
+ },
44
+ snapshot(): string[] {
45
+ return [...lines]
46
+ },
47
+ subscribe(cb: LogLineSubscriber): Unsubscribe {
48
+ subscribers.add(cb)
49
+ return () => {
50
+ subscribers.delete(cb)
51
+ }
52
+ },
53
+ }
54
+ }
@@ -0,0 +1,139 @@
1
+ import type { Stream } from '@/stream'
2
+
3
+ import { createCloudflareQuickProvider } from './providers/cloudflare-quick'
4
+ import { createExternalProvider } from './providers/external'
5
+ import type { TunnelConfig, TunnelProviderHandle, TunnelState, TunnelUrlChangedPayload } from './types'
6
+
7
+ export type TunnelManagerLogger = {
8
+ info: (m: string) => void
9
+ warn: (m: string) => void
10
+ error: (m: string) => void
11
+ }
12
+
13
+ export type TunnelManagerOptions = {
14
+ tunnels: TunnelConfig[]
15
+ stream: Stream
16
+ resolveChannelUpstreamPort?: (channelName: string) => number | null
17
+ cloudflareQuickBinary?: string
18
+ logger?: TunnelManagerLogger
19
+ }
20
+
21
+ export type TunnelManager = {
22
+ start: () => Promise<void>
23
+ stop: () => Promise<void>
24
+ snapshot: () => TunnelState[]
25
+ urlFor: (tunnelName: string) => string | null
26
+ tail: (tunnelName: string) => string[]
27
+ subscribeToLogs: (tunnelName: string, cb: (line: string) => void) => () => void
28
+ }
29
+
30
+ const consoleLogger: TunnelManagerLogger = {
31
+ info: (m) => console.log(m),
32
+ warn: (m) => console.warn(m),
33
+ error: (m) => console.error(m),
34
+ }
35
+
36
+ export function createTunnelManager(options: TunnelManagerOptions): TunnelManager {
37
+ const logger = options.logger ?? consoleLogger
38
+ const handles = new Map<string, TunnelProviderHandle>()
39
+
40
+ for (const config of options.tunnels) {
41
+ const handle = buildProvider(
42
+ config,
43
+ options.resolveChannelUpstreamPort,
44
+ (url) => publishUrlChange(options.stream, config, url, logger),
45
+ options.cloudflareQuickBinary,
46
+ )
47
+ handles.set(config.name, handle)
48
+ }
49
+
50
+ return {
51
+ async start(): Promise<void> {
52
+ await Promise.all(
53
+ Array.from(handles.values()).map(async (h) => {
54
+ try {
55
+ await h.start()
56
+ } catch (err) {
57
+ logger.error(
58
+ `[tunnels] ${h.snapshot().name}: start failed: ${err instanceof Error ? err.message : String(err)}`,
59
+ )
60
+ }
61
+ }),
62
+ )
63
+ },
64
+ async stop(): Promise<void> {
65
+ await Promise.all(
66
+ Array.from(handles.values()).map((h) =>
67
+ h.stop().catch((err: unknown) => {
68
+ logger.warn(
69
+ `[tunnels] ${h.snapshot().name}: stop failed: ${err instanceof Error ? err.message : String(err)}`,
70
+ )
71
+ }),
72
+ ),
73
+ )
74
+ },
75
+ snapshot(): TunnelState[] {
76
+ return Array.from(handles.values()).map((h) => h.snapshot())
77
+ },
78
+ urlFor(tunnelName: string): string | null {
79
+ return handles.get(tunnelName)?.snapshot().url ?? null
80
+ },
81
+ tail(tunnelName: string): string[] {
82
+ return handles.get(tunnelName)?.tail() ?? []
83
+ },
84
+ subscribeToLogs(tunnelName: string, cb: (line: string) => void): () => void {
85
+ return handles.get(tunnelName)?.subscribeToLogs(cb) ?? (() => {})
86
+ },
87
+ }
88
+ }
89
+
90
+ function buildProvider(
91
+ config: TunnelConfig,
92
+ resolveChannelUpstreamPort: TunnelManagerOptions['resolveChannelUpstreamPort'],
93
+ onUrlChange: (url: string) => void,
94
+ cloudflareQuickBinary: string | undefined,
95
+ ): TunnelProviderHandle {
96
+ switch (config.provider) {
97
+ case 'external':
98
+ return createExternalProvider({ config, onUrlChange })
99
+ case 'cloudflare-quick':
100
+ return createCloudflareQuickProvider({
101
+ config,
102
+ upstreamPort: resolveUpstreamPort(config, resolveChannelUpstreamPort),
103
+ onUrlChange,
104
+ binary: cloudflareQuickBinary,
105
+ })
106
+ }
107
+ }
108
+
109
+ function resolveUpstreamPort(
110
+ config: TunnelConfig,
111
+ resolveChannelUpstreamPort: TunnelManagerOptions['resolveChannelUpstreamPort'],
112
+ ): number {
113
+ if (config.for.kind === 'manual') {
114
+ if (config.upstreamPort === undefined) {
115
+ throw new Error(`tunnel '${config.name}' (cloudflare-quick): upstreamPort is required for manual tunnels`)
116
+ }
117
+ return config.upstreamPort
118
+ }
119
+
120
+ const upstreamPort = resolveChannelUpstreamPort?.(config.for.name) ?? null
121
+ if (upstreamPort === null) {
122
+ throw new Error(
123
+ `tunnel '${config.name}' (cloudflare-quick): no upstream port resolved for channel '${config.for.name}'`,
124
+ )
125
+ }
126
+ return upstreamPort
127
+ }
128
+
129
+ function publishUrlChange(stream: Stream, config: TunnelConfig, url: string, logger: TunnelManagerLogger): void {
130
+ const payload: TunnelUrlChangedPayload = {
131
+ kind: 'tunnel-url-changed',
132
+ tunnelName: config.name,
133
+ url,
134
+ for: config.for,
135
+ rotatedAt: new Date().toISOString(),
136
+ }
137
+ stream.publish({ target: { kind: 'broadcast' }, payload })
138
+ logger.info(`[tunnels] ${config.name}: URL set to ${url}`)
139
+ }
@@ -0,0 +1,189 @@
1
+ import type { Unsubscribe } from '@/stream'
2
+
3
+ import { createLogRing, type LogLineSubscriber, type LogRing } from '../log-ring'
4
+ import { extractQuickTunnelUrl } from '../quick-url-parser'
5
+ import type { TunnelConfig, TunnelProviderHandle, TunnelState } from '../types'
6
+
7
+ const DEFAULT_BINARY = 'cloudflared'
8
+ const DEFAULT_RESTART_BACKOFF_MS = [1_000, 2_000, 4_000, 10_000, 30_000]
9
+ const DEFAULT_MAX_FAILURES_WITHOUT_URL = 10
10
+ const DEFAULT_STOP_GRACE_MS = 5_000
11
+
12
+ export type CloudflareQuickProviderOptions = {
13
+ config: TunnelConfig
14
+ upstreamPort: number
15
+ onUrlChange: (url: string) => void
16
+ binary?: string
17
+ restartBackoffMs?: number[]
18
+ maxConsecutiveFailuresWithoutUrl?: number
19
+ stopGraceMs?: number
20
+ }
21
+
22
+ export type CloudflareQuickProviderHandle = TunnelProviderHandle & {
23
+ tail: () => string[]
24
+ subscribeToLogs: (cb: LogLineSubscriber) => Unsubscribe
25
+ }
26
+
27
+ export function createCloudflareQuickProvider(options: CloudflareQuickProviderOptions): CloudflareQuickProviderHandle {
28
+ const { config, upstreamPort, onUrlChange } = options
29
+ if (config.provider !== 'cloudflare-quick') {
30
+ throw new Error(`createCloudflareQuickProvider: provider must be 'cloudflare-quick', got '${config.provider}'`)
31
+ }
32
+ if (!Number.isInteger(upstreamPort) || upstreamPort < 1 || upstreamPort > 65535) {
33
+ throw new Error(`tunnel '${config.name}' (cloudflare-quick): upstreamPort must be a valid TCP port`)
34
+ }
35
+
36
+ const binary = options.binary ?? DEFAULT_BINARY
37
+ const restartBackoffMs = options.restartBackoffMs ?? DEFAULT_RESTART_BACKOFF_MS
38
+ const maxConsecutiveFailuresWithoutUrl = options.maxConsecutiveFailuresWithoutUrl ?? DEFAULT_MAX_FAILURES_WITHOUT_URL
39
+ const stopGraceMs = options.stopGraceMs ?? DEFAULT_STOP_GRACE_MS
40
+ const logs = createLogRing()
41
+ const state: TunnelState = {
42
+ name: config.name,
43
+ provider: 'cloudflare-quick',
44
+ for: config.for,
45
+ url: null,
46
+ status: 'stopped',
47
+ lastUrlAt: null,
48
+ detail: '',
49
+ }
50
+
51
+ let started = false
52
+ let stopping = false
53
+ let proc: ReturnType<typeof Bun.spawn> | null = null
54
+ let retryTimer: ReturnType<typeof setTimeout> | null = null
55
+ let restartFailuresWithoutUrl = 0
56
+ let attemptEmittedUrl = false
57
+
58
+ async function launch(): Promise<void> {
59
+ if (!started || stopping) return
60
+
61
+ attemptEmittedUrl = false
62
+ state.status = 'starting'
63
+ state.detail = 'starting cloudflared'
64
+ const spawned = Bun.spawn(
65
+ [binary, 'tunnel', '--url', `http://127.0.0.1:${upstreamPort}`, '--no-autoupdate', '--metrics', '127.0.0.1:0'],
66
+ { stdout: 'ignore', stderr: 'pipe' },
67
+ )
68
+ proc = spawned
69
+
70
+ void pumpStderr(spawned.stderr, logs, (line) => {
71
+ const url = extractQuickTunnelUrl(line)
72
+ if (url === null) return
73
+ attemptEmittedUrl = true
74
+ restartFailuresWithoutUrl = 0
75
+ state.url = url
76
+ state.status = 'healthy'
77
+ state.lastUrlAt = Date.now()
78
+ state.detail = 'quick tunnel URL emitted'
79
+ onUrlChange(url)
80
+ })
81
+
82
+ void spawned.exited.then((code) => {
83
+ if (proc !== spawned) return
84
+ proc = null
85
+ if (!started || stopping) return
86
+ handleExit(code)
87
+ })
88
+ }
89
+
90
+ function handleExit(code: number): void {
91
+ if (!attemptEmittedUrl) restartFailuresWithoutUrl += 1
92
+ if (restartFailuresWithoutUrl >= maxConsecutiveFailuresWithoutUrl) {
93
+ state.status = 'permanently-failed'
94
+ state.detail = `cloudflared exited ${code}; retry cap reached before URL emission`
95
+ return
96
+ }
97
+
98
+ state.status = 'unhealthy'
99
+ state.detail = `cloudflared exited ${code}; restarting`
100
+ const delay = restartBackoffMs[Math.min(restartFailuresWithoutUrl - 1, restartBackoffMs.length - 1)] ?? 30_000
101
+ retryTimer = setTimeout(() => {
102
+ retryTimer = null
103
+ void launch()
104
+ }, delay)
105
+ }
106
+
107
+ return {
108
+ async start(): Promise<void> {
109
+ if (started) return
110
+ started = true
111
+ stopping = false
112
+ restartFailuresWithoutUrl = 0
113
+ await launch()
114
+ },
115
+ async stop(): Promise<void> {
116
+ if (!started && proc === null) return
117
+ started = false
118
+ stopping = true
119
+ if (retryTimer !== null) {
120
+ clearTimeout(retryTimer)
121
+ retryTimer = null
122
+ }
123
+
124
+ const running = proc
125
+ proc = null
126
+ if (running !== null) {
127
+ running.kill('SIGTERM')
128
+ await Promise.race([
129
+ running.exited,
130
+ sleep(stopGraceMs).then(() => {
131
+ running.kill('SIGKILL')
132
+ return running.exited
133
+ }),
134
+ ])
135
+ }
136
+
137
+ stopping = false
138
+ state.status = 'stopped'
139
+ state.detail = ''
140
+ },
141
+ snapshot(): TunnelState {
142
+ return { ...state }
143
+ },
144
+ tail(): string[] {
145
+ return logs.snapshot()
146
+ },
147
+ subscribeToLogs(cb: LogLineSubscriber): Unsubscribe {
148
+ return logs.subscribe(cb)
149
+ },
150
+ }
151
+ }
152
+
153
+ async function pumpStderr(
154
+ stream: ReadableStream<Uint8Array> | null,
155
+ logs: LogRing,
156
+ onLine: (line: string) => void,
157
+ ): Promise<void> {
158
+ if (stream === null) return
159
+ const reader = stream.getReader()
160
+ const decoder = new TextDecoder()
161
+ let buffered = ''
162
+ try {
163
+ while (true) {
164
+ const { done, value } = await reader.read()
165
+ if (done) break
166
+ buffered += decoder.decode(value, { stream: true })
167
+ let newlineIndex = buffered.indexOf('\n')
168
+ while (newlineIndex !== -1) {
169
+ const line = buffered.slice(0, newlineIndex).replace(/\r$/, '')
170
+ logs.append(line)
171
+ onLine(line)
172
+ buffered = buffered.slice(newlineIndex + 1)
173
+ newlineIndex = buffered.indexOf('\n')
174
+ }
175
+ }
176
+ buffered += decoder.decode()
177
+ if (buffered !== '') {
178
+ const line = buffered.replace(/\r$/, '')
179
+ logs.append(line)
180
+ onLine(line)
181
+ }
182
+ } finally {
183
+ reader.releaseLock()
184
+ }
185
+ }
186
+
187
+ function sleep(ms: number): Promise<void> {
188
+ return new Promise((resolve) => setTimeout(resolve, ms))
189
+ }
@@ -0,0 +1,53 @@
1
+ import type { TunnelConfig, TunnelProviderHandle, TunnelState } from '../types'
2
+
3
+ export type ExternalProviderOptions = {
4
+ config: TunnelConfig
5
+ onUrlChange: (url: string) => void
6
+ }
7
+
8
+ export function createExternalProvider(options: ExternalProviderOptions): TunnelProviderHandle {
9
+ const { config, onUrlChange } = options
10
+ if (config.provider !== 'external') {
11
+ throw new Error(`createExternalProvider: provider must be 'external', got '${config.provider}'`)
12
+ }
13
+ const url = config.externalUrl
14
+ if (url === undefined || url.trim() === '') {
15
+ throw new Error(`tunnel '${config.name}' (external): externalUrl is required`)
16
+ }
17
+
18
+ let started = false
19
+ const state: TunnelState = {
20
+ name: config.name,
21
+ provider: 'external',
22
+ for: config.for,
23
+ url: null,
24
+ status: 'stopped',
25
+ lastUrlAt: null,
26
+ detail: '',
27
+ }
28
+
29
+ return {
30
+ async start(): Promise<void> {
31
+ if (started) return
32
+ started = true
33
+ state.url = url
34
+ state.status = 'healthy'
35
+ state.lastUrlAt = Date.now()
36
+ onUrlChange(url)
37
+ },
38
+ async stop(): Promise<void> {
39
+ if (!started) return
40
+ started = false
41
+ state.status = 'stopped'
42
+ },
43
+ snapshot(): TunnelState {
44
+ return { ...state }
45
+ },
46
+ tail(): string[] {
47
+ return []
48
+ },
49
+ subscribeToLogs(): () => void {
50
+ return () => {}
51
+ },
52
+ }
53
+ }
@@ -0,0 +1,5 @@
1
+ const QUICK_TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/
2
+
3
+ export function extractQuickTunnelUrl(line: string): string | null {
4
+ return QUICK_TUNNEL_URL_PATTERN.exec(line)?.[0] ?? null
5
+ }
@@ -0,0 +1,43 @@
1
+ import type { Unsubscribe } from '@/stream'
2
+
3
+ export type TunnelProvider = 'external' | 'cloudflare-quick'
4
+
5
+ export type TunnelFor = { kind: 'channel'; name: string } | { kind: 'manual' }
6
+
7
+ export type TunnelConfig = {
8
+ name: string
9
+ provider: TunnelProvider
10
+ for: TunnelFor
11
+ externalUrl?: string
12
+ upstreamPort?: number
13
+ }
14
+
15
+ export type TunnelStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy' | 'permanently-failed'
16
+
17
+ export type TunnelState = {
18
+ name: string
19
+ provider: TunnelProvider
20
+ for: TunnelFor
21
+ url: string | null
22
+ status: TunnelStatus
23
+ lastUrlAt: number | null
24
+ detail: string
25
+ }
26
+
27
+ export type TunnelProviderHandle = {
28
+ start: () => Promise<void>
29
+ stop: () => Promise<void>
30
+ snapshot: () => TunnelState
31
+ tail: () => string[]
32
+ subscribeToLogs: (cb: TunnelLogSubscriber) => Unsubscribe
33
+ }
34
+
35
+ export type TunnelLogSubscriber = (line: string) => void
36
+
37
+ export type TunnelUrlChangedPayload = {
38
+ kind: 'tunnel-url-changed'
39
+ tunnelName: string
40
+ url: string
41
+ for: TunnelFor
42
+ rotatedAt: string
43
+ }