typeclaw 0.3.0 → 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.
- package/README.md +20 -15
- package/auth.schema.json +113 -0
- package/package.json +2 -1
- package/scripts/dump-system-prompt.ts +401 -0
- package/secrets.schema.json +113 -0
- package/src/agent/index.ts +149 -30
- package/src/agent/provider-error.ts +44 -0
- package/src/agent/session-meta.ts +43 -0
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/subagents.ts +8 -0
- package/src/agent/system-prompt.ts +70 -35
- package/src/bundled-plugins/security/index.ts +3 -2
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +286 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/router.ts +28 -2
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +256 -27
- package/src/cli/model.ts +4 -2
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/cli/usage.ts +30 -2
- package/src/config/config.ts +90 -4
- package/src/config/reloadable.ts +22 -4
- package/src/container/start.ts +30 -3
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +62 -6
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +59 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/index.ts +505 -9
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +6 -1
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +42 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +138 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +119 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +393 -15
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +5 -4
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +35 -4
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- package/src/usage/aggregate.ts +30 -1
- package/src/usage/index.ts +3 -2
- package/src/usage/report.ts +103 -3
- package/src/usage/scan.ts +59 -4
- package/typeclaw.schema.json +254 -1
package/src/cron/list.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { RegisteredCronJob } from '@/plugin'
|
|
2
|
+
|
|
3
|
+
import { computeNextFire } from './scheduler'
|
|
4
|
+
import type { CronJob } from './schema'
|
|
5
|
+
|
|
6
|
+
// `plugin` carries `localId` (the original key on `definePlugin({ cronJobs })`)
|
|
7
|
+
// so callers can render "memory.dreaming" rather than the synthetic
|
|
8
|
+
// `__plugin_memory_dreaming` global id the scheduler uses internally.
|
|
9
|
+
export type CronListSource = { kind: 'user' } | { kind: 'plugin'; pluginName: string; localId: string }
|
|
10
|
+
|
|
11
|
+
// Display-oriented snapshot of a CronJob, separated from CronJob itself
|
|
12
|
+
// so the WS wire shape stays stable as CronJob accretes runtime-only
|
|
13
|
+
// fields (scheduledByOrigin, future description, etc.).
|
|
14
|
+
export type CronListEntry = {
|
|
15
|
+
id: string
|
|
16
|
+
source: CronListSource
|
|
17
|
+
kind: 'prompt' | 'exec' | 'handler'
|
|
18
|
+
schedule: string
|
|
19
|
+
timezone: string | undefined
|
|
20
|
+
enabled: boolean
|
|
21
|
+
scheduledByRole: string | undefined
|
|
22
|
+
// null when cron-parser rejects `schedule` — keeps such rows visible
|
|
23
|
+
// in the list with the original error preserved in `scheduleError`,
|
|
24
|
+
// rather than dropping them silently as the scheduler would.
|
|
25
|
+
nextFireMs: number | null
|
|
26
|
+
scheduleError: string | undefined
|
|
27
|
+
prompt: string | undefined
|
|
28
|
+
subagent: string | undefined
|
|
29
|
+
command: readonly string[] | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type AggregateCronListOptions = {
|
|
33
|
+
userJobs: readonly CronJob[]
|
|
34
|
+
// Registered entries (not flat CronJob[]) so each row can be attributed
|
|
35
|
+
// to its plugin + localId without re-parsing the global id.
|
|
36
|
+
pluginJobs: readonly RegisteredCronJob[]
|
|
37
|
+
now: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function aggregateCronList(opts: AggregateCronListOptions): CronListEntry[] {
|
|
41
|
+
const entries: CronListEntry[] = []
|
|
42
|
+
for (const job of opts.userJobs) {
|
|
43
|
+
entries.push(toEntry(job, { kind: 'user' }, opts.now))
|
|
44
|
+
}
|
|
45
|
+
for (const reg of opts.pluginJobs) {
|
|
46
|
+
entries.push(toEntry(reg.job, { kind: 'plugin', pluginName: reg.pluginName, localId: reg.localId }, opts.now))
|
|
47
|
+
}
|
|
48
|
+
// Sort by next-fire time ascending so the soonest-firing job is at the
|
|
49
|
+
// top. Jobs with a null nextFireMs (parse errors) sort to the bottom
|
|
50
|
+
// so the human-readable list keeps the actionable rows first. Disabled
|
|
51
|
+
// jobs still get a nextFireMs computed — they appear in the list with
|
|
52
|
+
// an "(disabled)" badge but their position reflects when they WOULD
|
|
53
|
+
// have fired had they been enabled.
|
|
54
|
+
entries.sort(compareByNextFire)
|
|
55
|
+
return entries
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toEntry(job: CronJob, source: CronListSource, now: number): CronListEntry {
|
|
59
|
+
const fire = computeNextFire(job, now)
|
|
60
|
+
const base = {
|
|
61
|
+
id: job.id,
|
|
62
|
+
source,
|
|
63
|
+
schedule: job.schedule,
|
|
64
|
+
timezone: job.timezone,
|
|
65
|
+
enabled: job.enabled,
|
|
66
|
+
scheduledByRole: job.scheduledByRole,
|
|
67
|
+
nextFireMs: fire.ok ? fire.nextFire : null,
|
|
68
|
+
scheduleError: fire.ok ? undefined : fire.reason,
|
|
69
|
+
} as const
|
|
70
|
+
if (job.kind === 'prompt') {
|
|
71
|
+
return {
|
|
72
|
+
...base,
|
|
73
|
+
kind: 'prompt',
|
|
74
|
+
prompt: job.prompt,
|
|
75
|
+
subagent: job.subagent,
|
|
76
|
+
command: undefined,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (job.kind === 'exec') {
|
|
80
|
+
return {
|
|
81
|
+
...base,
|
|
82
|
+
kind: 'exec',
|
|
83
|
+
prompt: undefined,
|
|
84
|
+
subagent: undefined,
|
|
85
|
+
command: job.command,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Handler jobs carry a function reference, not a serializable payload.
|
|
89
|
+
// Surface the row so the list stays complete; leave action fields undefined.
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
kind: 'handler',
|
|
93
|
+
prompt: undefined,
|
|
94
|
+
subagent: undefined,
|
|
95
|
+
command: undefined,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function compareByNextFire(a: CronListEntry, b: CronListEntry): number {
|
|
100
|
+
if (a.nextFireMs === null && b.nextFireMs === null) return a.id.localeCompare(b.id)
|
|
101
|
+
if (a.nextFireMs === null) return 1
|
|
102
|
+
if (b.nextFireMs === null) return -1
|
|
103
|
+
if (a.nextFireMs !== b.nextFireMs) return a.nextFireMs - b.nextFireMs
|
|
104
|
+
return a.id.localeCompare(b.id)
|
|
105
|
+
}
|
package/src/cron/scheduler.ts
CHANGED
|
@@ -177,12 +177,21 @@ function jobFingerprint(job: CronJob): string {
|
|
|
177
177
|
|
|
178
178
|
function jobPayload(job: CronJob): unknown {
|
|
179
179
|
if (job.kind === 'prompt') return { prompt: job.prompt, subagent: job.subagent ?? null, payload: job.payload ?? null }
|
|
180
|
-
return job.command
|
|
180
|
+
if (job.kind === 'exec') return job.command
|
|
181
|
+
// Use the handler's source as the discriminator. A constant placeholder
|
|
182
|
+
// would make every handler fingerprint identically, so a plugin reload
|
|
183
|
+
// that replaces the handler with a new implementation would be classified
|
|
184
|
+
// as `unchanged` by `diff()` — the old function reference would keep
|
|
185
|
+
// firing forever. `Function.prototype.toString()` returns the function's
|
|
186
|
+
// declared source (deterministic per declaration site, changes when the
|
|
187
|
+
// plugin module is re-imported with edits), which is the cheapest stable
|
|
188
|
+
// discriminator without keeping a separate identity Map. JSON-safe.
|
|
189
|
+
return { handler: String(job.handler) }
|
|
181
190
|
}
|
|
182
191
|
|
|
183
|
-
type ComputeNextFireResult = { ok: true; nextFire: number } | { ok: false; reason: string }
|
|
192
|
+
export type ComputeNextFireResult = { ok: true; nextFire: number } | { ok: false; reason: string }
|
|
184
193
|
|
|
185
|
-
function computeNextFire(job: CronJob, now: number): ComputeNextFireResult {
|
|
194
|
+
export function computeNextFire(job: CronJob, now: number): ComputeNextFireResult {
|
|
186
195
|
try {
|
|
187
196
|
const expr = CronExpressionParser.parse(job.schedule, {
|
|
188
197
|
currentDate: new Date(now),
|
package/src/cron/schema.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
|
|
4
4
|
import type { SubagentRegistry } from '@/agent/subagents'
|
|
5
5
|
import { validateSubagentPayload } from '@/agent/subagents'
|
|
6
|
+
import type { CronHandlerContext } from '@/plugin/types'
|
|
6
7
|
|
|
7
8
|
const idPattern = /^[a-zA-Z0-9_-]+$/
|
|
8
9
|
|
|
@@ -42,9 +43,16 @@ export const cronFileSchema = z.object({
|
|
|
42
43
|
jobs: z.array(cronJobSchema).default([]),
|
|
43
44
|
})
|
|
44
45
|
|
|
45
|
-
export type
|
|
46
|
-
export type PromptJob = Extract<
|
|
47
|
-
export type ExecJob = Extract<
|
|
46
|
+
export type ParsedCronJob = z.infer<typeof cronJobSchema>
|
|
47
|
+
export type PromptJob = Extract<ParsedCronJob, { kind: 'prompt' }>
|
|
48
|
+
export type ExecJob = Extract<ParsedCronJob, { kind: 'exec' }>
|
|
49
|
+
|
|
50
|
+
export type HandlerJob = z.infer<typeof baseJob> & {
|
|
51
|
+
kind: 'handler'
|
|
52
|
+
handler: (ctx: CronHandlerContext) => Promise<void>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type CronJob = ParsedCronJob | HandlerJob
|
|
48
56
|
export type CronFile = z.infer<typeof cronFileSchema>
|
|
49
57
|
|
|
50
58
|
export type ParseCronResult = { ok: true; file: CronFile } | { ok: false; reason: string }
|
package/src/doctor/checks.ts
CHANGED
|
@@ -35,7 +35,6 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
|
|
|
35
35
|
agentFolderNodeModules(),
|
|
36
36
|
agentFolderGitRepo(),
|
|
37
37
|
configValid(),
|
|
38
|
-
configBundledProfiles(),
|
|
39
38
|
hostdHomeWritable(),
|
|
40
39
|
hostdReachable(),
|
|
41
40
|
hostdRegistration(),
|
|
@@ -215,55 +214,6 @@ function configValid(): DoctorCheck {
|
|
|
215
214
|
}
|
|
216
215
|
}
|
|
217
216
|
|
|
218
|
-
// Warns (not errors) when a model profile that a bundled subagent prefers is
|
|
219
|
-
// absent from `models`. Bundled subagents fall back to `default` silently
|
|
220
|
-
// today, but the operator likely declared a `fast`/`deep`/`vision` model in
|
|
221
|
-
// the design discussion's tier scheme expecting the bundled subagents to
|
|
222
|
-
// pick them up. This check surfaces the gap once at `typeclaw doctor` time
|
|
223
|
-
// instead of leaving it buried in container logs (where the rate-limited
|
|
224
|
-
// fallback warning lives).
|
|
225
|
-
//
|
|
226
|
-
// We deliberately limit this to known bundled profiles (memory-logger=fast,
|
|
227
|
-
// dreaming=deep, multimodal-looker=vision). Plugin-contributed subagents
|
|
228
|
-
// would require loading the plugin registry — a heavyweight async path
|
|
229
|
-
// that doesn't belong in doctor's static check surface.
|
|
230
|
-
const BUNDLED_PROFILES: ReadonlyArray<{ profile: string; subagent: string }> = [
|
|
231
|
-
{ profile: 'fast', subagent: 'memory-logger' },
|
|
232
|
-
{ profile: 'deep', subagent: 'dreaming' },
|
|
233
|
-
{ profile: 'vision', subagent: 'multimodal-looker (via look_at tool)' },
|
|
234
|
-
]
|
|
235
|
-
|
|
236
|
-
function configBundledProfiles(): DoctorCheck {
|
|
237
|
-
return {
|
|
238
|
-
name: 'config.bundled-profiles',
|
|
239
|
-
category: 'config',
|
|
240
|
-
description: 'bundled subagent profiles (`fast`, `deep`, `vision`) declared in models',
|
|
241
|
-
applies: (ctx) => ctx.hasAgentFolder,
|
|
242
|
-
async run(ctx) {
|
|
243
|
-
const validation = validateConfig(ctx.cwd)
|
|
244
|
-
if (!validation.ok) {
|
|
245
|
-
return { status: 'ok', message: 'skipped (config.valid will report the underlying error)' }
|
|
246
|
-
}
|
|
247
|
-
const config = loadConfigSync(ctx.cwd)
|
|
248
|
-
const declared = new Set(Object.keys(config.models))
|
|
249
|
-
const missing = BUNDLED_PROFILES.filter((p) => !declared.has(p.profile))
|
|
250
|
-
if (missing.length === 0) {
|
|
251
|
-
return { status: 'ok', message: 'all bundled subagent profiles declared' }
|
|
252
|
-
}
|
|
253
|
-
return {
|
|
254
|
-
status: 'warning',
|
|
255
|
-
message: `${missing.length} bundled profile(s) missing; will fall back to \`default\``,
|
|
256
|
-
details: missing.map(
|
|
257
|
-
(m) => `${m.profile}: used by ${m.subagent}; declare \`models.${m.profile}\` in typeclaw.json to override`,
|
|
258
|
-
),
|
|
259
|
-
fix: {
|
|
260
|
-
description: 'Add the missing profile(s) under `models` in typeclaw.json. See the typeclaw-config skill.',
|
|
261
|
-
},
|
|
262
|
-
}
|
|
263
|
-
},
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
217
|
function hostdHomeWritable(): DoctorCheck {
|
|
268
218
|
return {
|
|
269
219
|
name: 'hostd.home-writable',
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -56,6 +56,19 @@ export const CURL_IMPERSONATE_SHA256_ARM64 = '6766bc67fd3e8e2313875f32b36b5a3fab
|
|
|
56
56
|
// the impersonation to whatever `curl_chrome` resolves to.
|
|
57
57
|
export const CURL_IMPERSONATE_PROFILE = 'chrome136'
|
|
58
58
|
|
|
59
|
+
// cloudflared powers `cloudflare-quick` tunnels. Pinned-version + per-arch
|
|
60
|
+
// SHA256 mirrors the curl-impersonate pattern above: bumping requires updating
|
|
61
|
+
// all three constants in the same commit, and the build fails loudly at
|
|
62
|
+
// `sha256sum -c` if either hash is wrong for the version. To bump: pick a
|
|
63
|
+
// release from https://github.com/cloudflare/cloudflared/releases, then
|
|
64
|
+
// curl -fsSLO .../cloudflared-linux-amd64 && shasum -a 256 cloudflared-linux-amd64
|
|
65
|
+
// for each architecture. The version literal is the release tag exactly as it
|
|
66
|
+
// appears on GitHub (no `v` prefix).
|
|
67
|
+
export const CLOUDFLARED_VERSION = '2025.5.0'
|
|
68
|
+
export const CLOUDFLARED_SHA256_AMD64 = 'a62266fd02041374f1fca0d85694aafdf7e26e171a314467356b471d4ebb2393'
|
|
69
|
+
export const CLOUDFLARED_SHA256_ARM64 = '47e55e6eba2755239f641c2c4f89878643ac0d9eaa127a6c84a2cb43fa2e0f03'
|
|
70
|
+
export const CLOUDFLARED_RELEASE_URL_BASE = 'https://github.com/cloudflare/cloudflared/releases/download'
|
|
71
|
+
|
|
59
72
|
export const TYPECLAW_ENTRYPOINT_PATH = '/usr/local/bin/typeclaw-entrypoint'
|
|
60
73
|
|
|
61
74
|
// IPv4 networks the container is forbidden to egress to when
|
|
@@ -283,9 +296,11 @@ RUN echo "${encoded}" | base64 -d > ${TYPECLAW_ENTRYPOINT_PATH} \\
|
|
|
283
296
|
// names are the trixie-renamed variants from the 64-bit time_t ABI
|
|
284
297
|
// transition; SONAMEs (libglib-2.0.so.0 etc.) are unchanged. Packages
|
|
285
298
|
// without t64 here have no t64 sibling on trixie — verified against
|
|
286
|
-
// packages.debian.org/trixie. Fonts are intentionally omitted
|
|
287
|
-
//
|
|
288
|
-
//
|
|
299
|
+
// packages.debian.org/trixie. Fonts are intentionally omitted from this
|
|
300
|
+
// list: the failure these packages address is launch-time linker errors,
|
|
301
|
+
// not rendering glyphs. CJK glyph rendering is a separate concern handled
|
|
302
|
+
// by the `cjkFonts` toggle (see CJK_FONTS_PACKAGE / APT_FEATURES below),
|
|
303
|
+
// which layers `fonts-noto-cjk` on top via the toggle apt install path.
|
|
289
304
|
export const CHROME_RUNTIME_APT_PACKAGES_AMD64 = [
|
|
290
305
|
'libasound2t64',
|
|
291
306
|
'libatk-bridge2.0-0t64',
|
|
@@ -310,17 +325,26 @@ export const CHROME_RUNTIME_APT_PACKAGES_AMD64 = [
|
|
|
310
325
|
'libxrandr2',
|
|
311
326
|
] as const
|
|
312
327
|
|
|
328
|
+
// `fonts-noto-cjk` provides CJK glyphs for Chromium-rendered output
|
|
329
|
+
// (screenshots, page.pdf()). Without it CJK text in agent-browser output
|
|
330
|
+
// renders as `.notdef` tofu boxes. Treated as a toggle apt package (like
|
|
331
|
+
// gh/tmux) rather than a base-image staple so users with `cjkFonts: false`
|
|
332
|
+
// genuinely skip the ~56MB layer; baking into the base image would force
|
|
333
|
+
// every GHCR-base user to ship the fonts regardless of their opt-out.
|
|
334
|
+
export const CJK_FONTS_PACKAGE = 'fonts-noto-cjk'
|
|
335
|
+
|
|
313
336
|
type AptFeature = {
|
|
314
337
|
toAptArgs: (toggle: DockerfileFeatureToggle) => string[]
|
|
315
338
|
}
|
|
316
339
|
|
|
317
|
-
const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python', AptFeature> = {
|
|
340
|
+
const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python' | 'cjkFonts', AptFeature> = {
|
|
318
341
|
ffmpeg: { toAptArgs: (v) => singlePackageArgs('ffmpeg', v) },
|
|
319
342
|
gh: { toAptArgs: (v) => singlePackageArgs('gh', v) },
|
|
320
343
|
tmux: { toAptArgs: (v) => singlePackageArgs('tmux', v) },
|
|
321
344
|
python: {
|
|
322
345
|
toAptArgs: (v) => (v === true ? ['python3', 'python3-pip', 'python3-venv', 'python-is-python3'] : []),
|
|
323
346
|
},
|
|
347
|
+
cjkFonts: { toAptArgs: (v) => (v === true ? [CJK_FONTS_PACKAGE] : []) },
|
|
324
348
|
}
|
|
325
349
|
|
|
326
350
|
export function buildDockerfile(
|
|
@@ -329,13 +353,14 @@ export function buildDockerfile(
|
|
|
329
353
|
): string {
|
|
330
354
|
const toggleAptArgs = collectToggleAptArgs(config)
|
|
331
355
|
const ghKeyringLayer = renderGhKeyringLayer(config.gh)
|
|
356
|
+
const cloudflaredLayer = renderCloudflaredLayer(config.cloudflared)
|
|
332
357
|
const customLines = renderCustomDockerfileLines(config.append)
|
|
333
358
|
const baseImageVersion = options.baseImageVersion ?? null
|
|
334
359
|
|
|
335
360
|
const fromAndHeavyLayers =
|
|
336
361
|
baseImageVersion !== null
|
|
337
|
-
? renderVersionedHead(baseImageVersion, ghKeyringLayer, toggleAptArgs)
|
|
338
|
-
: renderInlineHead(ghKeyringLayer, toggleAptArgs)
|
|
362
|
+
? renderVersionedHead(baseImageVersion, ghKeyringLayer, toggleAptArgs, cloudflaredLayer)
|
|
363
|
+
: renderInlineHead(ghKeyringLayer, toggleAptArgs, cloudflaredLayer)
|
|
339
364
|
|
|
340
365
|
return `${BUILDKIT_HEADER}
|
|
341
366
|
# AUTOGENERATED by typeclaw — do not edit.
|
|
@@ -377,7 +402,12 @@ CMD ["run"]
|
|
|
377
402
|
// npm + `typeclaw start --build` immediately, instead of being blocked on a
|
|
378
403
|
// fresh base-image release. The base image's copy is harmlessly overwritten
|
|
379
404
|
// by this RUN — same path, same chmod.
|
|
380
|
-
function renderVersionedHead(
|
|
405
|
+
function renderVersionedHead(
|
|
406
|
+
baseImageVersion: string,
|
|
407
|
+
ghKeyringLayer: string,
|
|
408
|
+
toggleAptArgs: string[],
|
|
409
|
+
cloudflaredLayer: string,
|
|
410
|
+
): string {
|
|
381
411
|
const toggleAptLayer = toggleAptArgs.length === 0 ? '' : `${renderToggleAptInstallLayer(toggleAptArgs)}\n\n`
|
|
382
412
|
return `FROM ${GHCR_BASE_IMAGE_REPO}:${baseImageVersion}
|
|
383
413
|
|
|
@@ -385,7 +415,7 @@ WORKDIR /agent
|
|
|
385
415
|
|
|
386
416
|
ARG TARGETARCH
|
|
387
417
|
|
|
388
|
-
${ghKeyringLayer}${toggleAptLayer}${renderEntrypointShimLayer()}
|
|
418
|
+
${ghKeyringLayer}${toggleAptLayer}${cloudflaredLayer}${renderEntrypointShimLayer()}
|
|
389
419
|
|
|
390
420
|
`
|
|
391
421
|
}
|
|
@@ -394,7 +424,7 @@ ${ghKeyringLayer}${toggleAptLayer}${renderEntrypointShimLayer()}
|
|
|
394
424
|
// dev-mode runs (typeclaw installed via file: / link: spec) where the
|
|
395
425
|
// matching :version GHCR tag does not yet exist, and by the test suite to
|
|
396
426
|
// keep coverage of the full-stack layers independent of GHCR availability.
|
|
397
|
-
function renderInlineHead(ghKeyringLayer: string, toggleAptArgs: string[]): string {
|
|
427
|
+
function renderInlineHead(ghKeyringLayer: string, toggleAptArgs: string[], cloudflaredLayer: string): string {
|
|
398
428
|
const baselineAndToggleArgs = [...BASELINE_APT_PACKAGES, ...toggleAptArgs]
|
|
399
429
|
return `${FROM_AND_WORKDIR}
|
|
400
430
|
|
|
@@ -436,7 +466,23 @@ ${LAYER_4_AGENT_BROWSER_INSTALL}
|
|
|
436
466
|
|
|
437
467
|
${LAYER_5_CHROME_FOR_TESTING}
|
|
438
468
|
|
|
439
|
-
${renderEntrypointShimLayer()}
|
|
469
|
+
${cloudflaredLayer}${renderEntrypointShimLayer()}
|
|
470
|
+
|
|
471
|
+
`
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function renderCloudflaredLayer(enabled: boolean): string {
|
|
475
|
+
if (!enabled) return ''
|
|
476
|
+
return `# Layer 5.5 (optional): pinned cloudflared for cloudflare-quick tunnels.
|
|
477
|
+
RUN ARCH_BIN="$(if [ "$TARGETARCH" = "arm64" ]; then echo arm64; else echo amd64; fi)" \
|
|
478
|
+
&& ARCH_SHA="$(if [ "$TARGETARCH" = "arm64" ]; then echo ${CLOUDFLARED_SHA256_ARM64}; else echo ${CLOUDFLARED_SHA256_AMD64}; fi)" \
|
|
479
|
+
&& cd /tmp \
|
|
480
|
+
&& curl -fsSL -o cloudflared \
|
|
481
|
+
"${CLOUDFLARED_RELEASE_URL_BASE}/${CLOUDFLARED_VERSION}/cloudflared-linux-\${ARCH_BIN}" \
|
|
482
|
+
&& echo "\${ARCH_SHA} cloudflared" | sha256sum -c - \
|
|
483
|
+
&& chmod +x cloudflared \
|
|
484
|
+
&& mv cloudflared /usr/local/bin/cloudflared \
|
|
485
|
+
&& /usr/local/bin/cloudflared --version > /dev/null
|
|
440
486
|
|
|
441
487
|
`
|
|
442
488
|
}
|
|
@@ -444,7 +490,7 @@ ${renderEntrypointShimLayer()}
|
|
|
444
490
|
function renderToggleAptInstallLayer(toggleAptArgs: string[]): string {
|
|
445
491
|
return `# Layer 1 (toggle apt install): packages requested via typeclaw.json
|
|
446
492
|
# #docker.file toggles. Baseline + Chrome runtime libs are already in the
|
|
447
|
-
# base image; this layer only adds gh/tmux/python/ffmpeg if enabled.
|
|
493
|
+
# base image; this layer only adds gh/tmux/python/ffmpeg/cjkFonts if enabled.
|
|
448
494
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
449
495
|
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
|
|
450
496
|
apt-get update \\
|
|
@@ -570,12 +616,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
|
570
616
|
fi`
|
|
571
617
|
|
|
572
618
|
function defaultConfig(): DockerfileConfig {
|
|
573
|
-
return { ffmpeg: false, gh: true, python: true, tmux: true, append: [] }
|
|
619
|
+
return { ffmpeg: false, gh: true, python: true, tmux: true, cjkFonts: true, cloudflared: true, append: [] }
|
|
574
620
|
}
|
|
575
621
|
|
|
576
622
|
function collectToggleAptArgs(config: DockerfileConfig): string[] {
|
|
577
623
|
const args: string[] = []
|
|
578
|
-
for (const key of ['ffmpeg', 'gh', 'python', 'tmux'] as const) {
|
|
624
|
+
for (const key of ['ffmpeg', 'gh', 'python', 'tmux', 'cjkFonts'] as const) {
|
|
579
625
|
args.push(...APT_FEATURES[key].toAptArgs(config[key]))
|
|
580
626
|
}
|
|
581
627
|
return args
|
package/src/init/ensure-deps.ts
CHANGED
|
@@ -15,24 +15,35 @@ export type EnsureDepsOptions = {
|
|
|
15
15
|
cwd: string
|
|
16
16
|
install?: InstallRunner
|
|
17
17
|
detect?: (cwd: string) => Promise<readonly string[]>
|
|
18
|
+
// Skip the pre-install drift detection and run `bun install --force`
|
|
19
|
+
// unconditionally. Used by `typeclaw start --build` when the agent declares
|
|
20
|
+
// typeclaw via `file:` or `link:`: bun's file-dep cache otherwise treats
|
|
21
|
+
// unchanged name+version as a cache hit even after the source on disk has
|
|
22
|
+
// changed, so the post-install re-detect at the bottom (the silent-no-op
|
|
23
|
+
// guard) stays — `--force` does NOT excuse us from verifying the install
|
|
24
|
+
// actually populated the agent's node_modules/.
|
|
25
|
+
force?: boolean
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
export async function ensureDepsInstalled(options: EnsureDepsOptions): Promise<EnsureDepsResult> {
|
|
21
29
|
const { cwd } = options
|
|
22
30
|
const install = options.install ?? runBunInstall
|
|
23
31
|
const detect = options.detect ?? detectMissingDeps
|
|
32
|
+
const force = options.force ?? false
|
|
24
33
|
|
|
25
|
-
const missing = await detect(cwd)
|
|
26
|
-
if (missing.length === 0) return { ok: true, installed: false }
|
|
34
|
+
const missing = force ? [] : await detect(cwd)
|
|
35
|
+
if (!force && missing.length === 0) return { ok: true, installed: false }
|
|
27
36
|
|
|
28
|
-
const result = await install(cwd)
|
|
37
|
+
const result = await install(cwd, { force })
|
|
29
38
|
if (!result.ok) return { ok: false, reason: result.reason, missing }
|
|
30
39
|
|
|
31
40
|
// Re-probe: `bun install` returns 0 even when a file:-linked dep's own
|
|
32
41
|
// package.json is unreachable (it silently no-ops on the target). Without
|
|
33
42
|
// this check, we'd proceed to `docker run` with a known-broken
|
|
34
43
|
// node_modules/ and the agent would crash with a confusing in-container
|
|
35
|
-
// `Cannot find package 'x'`.
|
|
44
|
+
// `Cannot find package 'x'`. This guard remains under `force` too —
|
|
45
|
+
// --force bypasses the cache but does not guarantee the install actually
|
|
46
|
+
// landed every declared dep.
|
|
36
47
|
const stillMissing = await detect(cwd)
|
|
37
48
|
if (stillMissing.length > 0) {
|
|
38
49
|
return {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { buildAuthStrategy } from '@/channels/adapters/github/auth'
|
|
2
|
+
import { applyManagedPath, buildManagedPath, resolveAgentId } from '@/channels/adapters/github/managed-path'
|
|
3
|
+
import { registerGithubWebhooks, type WebhookRegistrationResult } from '@/channels/adapters/github/webhook-register'
|
|
4
|
+
import { DEFAULT_GITHUB_EVENT_ALLOWLIST } from '@/channels/schema'
|
|
5
|
+
|
|
6
|
+
import type { GithubInitCredentials } from './index'
|
|
7
|
+
|
|
8
|
+
// Host-side webhook install for `typeclaw channel add github` (and the
|
|
9
|
+
// init-time GitHub branch). The container-side adapter still re-runs this on
|
|
10
|
+
// every start so a missing/rotated tunnel URL eventually catches up, but
|
|
11
|
+
// doing it eagerly here means the user sees the install succeed at CLI
|
|
12
|
+
// time — no more "I added the channel, why isn't GitHub delivering events?"
|
|
13
|
+
// when the URL is already known (external provider, or a user-set
|
|
14
|
+
// webhookUrl).
|
|
15
|
+
//
|
|
16
|
+
// Only fires when an effective webhook URL is known up front: external
|
|
17
|
+
// tunnel provider, or an explicit `webhookUrl`. Cloudflare quick tunnels
|
|
18
|
+
// don't resolve until cloudflared boots inside the container, so they
|
|
19
|
+
// stay on the existing deferred (tunnel-bridge → restartAdapter) path.
|
|
20
|
+
|
|
21
|
+
export type EagerGithubWebhookInstallOptions = {
|
|
22
|
+
webhookUrl: string
|
|
23
|
+
webhookSecret: string
|
|
24
|
+
repos: readonly string[]
|
|
25
|
+
events?: readonly string[]
|
|
26
|
+
auth: GithubInitCredentials['auth']
|
|
27
|
+
// Agent folder (or container name). Used to derive a stable webhook URL
|
|
28
|
+
// path marker so this eager-installed hook is recognizable to the
|
|
29
|
+
// container-side adapter on every subsequent start, even after the
|
|
30
|
+
// public URL's hostname rotates. Optional only because legacy direct
|
|
31
|
+
// callers (host-side init flows on stable user-set webhookUrls) may
|
|
32
|
+
// omit it; production wiring in src/init/index.ts always passes it.
|
|
33
|
+
agentDir?: string
|
|
34
|
+
fetchImpl?: typeof fetch
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type EagerGithubWebhookInstallResult = WebhookRegistrationResult | { error: string; repos: [] }
|
|
38
|
+
|
|
39
|
+
export async function installGithubWebhooksEagerly(
|
|
40
|
+
options: EagerGithubWebhookInstallOptions,
|
|
41
|
+
): Promise<EagerGithubWebhookInstallResult> {
|
|
42
|
+
if (options.repos.length === 0) return { repos: [] }
|
|
43
|
+
|
|
44
|
+
let strategy: ReturnType<typeof buildAuthStrategy>
|
|
45
|
+
try {
|
|
46
|
+
strategy = buildAuthStrategy({
|
|
47
|
+
auth: authToSecretBlock(options.auth),
|
|
48
|
+
...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}),
|
|
49
|
+
})
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return { error: describe(err), repos: [] }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const managedPath =
|
|
55
|
+
options.agentDir !== undefined ? buildManagedPath(resolveAgentId({ agentDir: options.agentDir })) : undefined
|
|
56
|
+
const webhookUrl = managedPath !== undefined ? applyManagedPath(options.webhookUrl, managedPath) : options.webhookUrl
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const result = await registerGithubWebhooks({
|
|
60
|
+
token: () => strategy.token(),
|
|
61
|
+
webhookUrl,
|
|
62
|
+
webhookSecret: options.webhookSecret,
|
|
63
|
+
repos: options.repos,
|
|
64
|
+
events: options.events ?? DEFAULT_GITHUB_EVENT_ALLOWLIST,
|
|
65
|
+
...(managedPath !== undefined ? { managedPath } : {}),
|
|
66
|
+
...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}),
|
|
67
|
+
})
|
|
68
|
+
return result
|
|
69
|
+
} finally {
|
|
70
|
+
// PatAuthStrategy.dispose() is a no-op and AppAuthStrategy clears its
|
|
71
|
+
// cached installation token. Either way, releasing it here keeps the
|
|
72
|
+
// host CLI from holding onto credentials longer than needed.
|
|
73
|
+
await strategy.dispose().catch(() => {})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Bridge the wizard/CLI's plaintext credentials union into the Secret-wrapped
|
|
78
|
+
// shape buildAuthStrategy expects. Plain strings are wrapped as `{ value }`
|
|
79
|
+
// so the underlying PatAuthStrategy resolver doesn't try (and fail) to read
|
|
80
|
+
// from process.env.
|
|
81
|
+
function authToSecretBlock(auth: GithubInitCredentials['auth']) {
|
|
82
|
+
if (auth.type === 'pat') {
|
|
83
|
+
return { type: 'pat' as const, token: { value: auth.pat } }
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
type: 'app' as const,
|
|
87
|
+
appId: auth.appId,
|
|
88
|
+
privateKey: { value: auth.privateKey },
|
|
89
|
+
...(auth.installationId !== undefined ? { installationId: auth.installationId } : {}),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function describe(err: unknown): string {
|
|
94
|
+
return err instanceof Error ? err.message : String(err)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function formatEagerGithubWebhookInstallResult(result: EagerGithubWebhookInstallResult): string {
|
|
98
|
+
if ('error' in result) return `GitHub webhook install failed: ${result.error}`
|
|
99
|
+
const created = result.repos.filter((r) => r.action === 'created').length
|
|
100
|
+
const updated = result.repos.filter((r) => r.action === 'updated').length
|
|
101
|
+
const failed = result.repos.filter((r) => r.action === 'failed')
|
|
102
|
+
const parts: string[] = []
|
|
103
|
+
if (created > 0) parts.push(`${created} created`)
|
|
104
|
+
if (updated > 0) parts.push(`${updated} updated`)
|
|
105
|
+
if (failed.length > 0) parts.push(`${failed.length} failed`)
|
|
106
|
+
const summary = parts.length > 0 ? parts.join(', ') : 'no repos'
|
|
107
|
+
const tail = failed.length > 0 ? ` (${failed.map((f) => `${f.repo}: ${f.error}`).join('; ')})` : ''
|
|
108
|
+
return `GitHub webhooks: ${summary}.${tail}`
|
|
109
|
+
}
|