typeclaw 0.3.1 → 0.4.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 (89) 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/session-meta.ts +1 -1
  6. package/src/agent/session-origin.ts +3 -2
  7. package/src/bundled-plugins/security/index.ts +3 -2
  8. package/src/channels/adapters/github/auth-app.ts +120 -0
  9. package/src/channels/adapters/github/auth-pat.ts +50 -0
  10. package/src/channels/adapters/github/auth.ts +33 -0
  11. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  12. package/src/channels/adapters/github/dedup.ts +26 -0
  13. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  14. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  15. package/src/channels/adapters/github/history.ts +63 -0
  16. package/src/channels/adapters/github/inbound.ts +286 -0
  17. package/src/channels/adapters/github/index.ts +286 -0
  18. package/src/channels/adapters/github/managed-path.ts +54 -0
  19. package/src/channels/adapters/github/membership.ts +35 -0
  20. package/src/channels/adapters/github/outbound.ts +145 -0
  21. package/src/channels/adapters/github/webhook-register.ts +349 -0
  22. package/src/channels/manager.ts +94 -9
  23. package/src/channels/schema.ts +31 -1
  24. package/src/channels/tunnel-bridge.ts +51 -0
  25. package/src/cli/builtins.ts +28 -0
  26. package/src/cli/channel.ts +511 -25
  27. package/src/cli/container-command-client.ts +244 -0
  28. package/src/cli/cron.ts +173 -0
  29. package/src/cli/host-command-runner.ts +150 -0
  30. package/src/cli/index.ts +42 -1
  31. package/src/cli/init.ts +256 -27
  32. package/src/cli/model.ts +4 -2
  33. package/src/cli/plugin-command-help.ts +49 -0
  34. package/src/cli/plugin-commands-dispatch.ts +112 -0
  35. package/src/cli/plugin-commands.ts +118 -0
  36. package/src/cli/tui.ts +10 -2
  37. package/src/cli/tunnel.ts +533 -0
  38. package/src/cli/ui.ts +8 -3
  39. package/src/config/config.ts +75 -0
  40. package/src/container/start.ts +30 -3
  41. package/src/cron/bridge.ts +136 -0
  42. package/src/cron/consumer.ts +45 -5
  43. package/src/cron/index.ts +19 -2
  44. package/src/cron/list.ts +105 -0
  45. package/src/cron/scheduler.ts +12 -3
  46. package/src/cron/schema.ts +11 -3
  47. package/src/doctor/checks.ts +0 -50
  48. package/src/init/dockerfile.ts +59 -13
  49. package/src/init/ensure-deps.ts +15 -4
  50. package/src/init/github-webhook-install.ts +109 -0
  51. package/src/init/index.ts +505 -9
  52. package/src/init/run-bun-install.ts +17 -3
  53. package/src/init/run-owner-claim.ts +11 -2
  54. package/src/permissions/builtins.ts +6 -1
  55. package/src/permissions/match-rule.ts +24 -2
  56. package/src/permissions/resolve.ts +1 -0
  57. package/src/plugin/define.ts +42 -1
  58. package/src/plugin/index.ts +18 -3
  59. package/src/plugin/manager.ts +2 -0
  60. package/src/plugin/registry.ts +85 -3
  61. package/src/plugin/types.ts +138 -1
  62. package/src/plugin/zod-introspect.ts +100 -0
  63. package/src/role-claim/match-rule.ts +2 -1
  64. package/src/run/index.ts +110 -3
  65. package/src/secrets/index.ts +1 -1
  66. package/src/secrets/schema.ts +21 -0
  67. package/src/server/command-runner.ts +476 -0
  68. package/src/server/index.ts +388 -5
  69. package/src/shared/index.ts +8 -0
  70. package/src/shared/protocol.ts +80 -1
  71. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  72. package/src/skills/typeclaw-config/SKILL.md +27 -26
  73. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  74. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  75. package/src/skills/typeclaw-permissions/SKILL.md +5 -4
  76. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  77. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  78. package/src/test-helpers/wait-for.ts +50 -0
  79. package/src/tui/index.ts +35 -4
  80. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  81. package/src/tunnels/events.ts +14 -0
  82. package/src/tunnels/index.ts +12 -0
  83. package/src/tunnels/log-ring.ts +54 -0
  84. package/src/tunnels/manager.ts +139 -0
  85. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  86. package/src/tunnels/providers/external.ts +53 -0
  87. package/src/tunnels/quick-url-parser.ts +5 -0
  88. package/src/tunnels/types.ts +43 -0
  89. package/typeclaw.schema.json +254 -1
@@ -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
+ }