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.
Files changed (101) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +2 -1
  4. package/scripts/dump-system-prompt.ts +401 -0
  5. package/secrets.schema.json +113 -0
  6. package/src/agent/index.ts +149 -30
  7. package/src/agent/provider-error.ts +44 -0
  8. package/src/agent/session-meta.ts +43 -0
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/subagents.ts +8 -0
  11. package/src/agent/system-prompt.ts +70 -35
  12. package/src/bundled-plugins/security/index.ts +3 -2
  13. package/src/channels/adapters/github/auth-app.ts +120 -0
  14. package/src/channels/adapters/github/auth-pat.ts +50 -0
  15. package/src/channels/adapters/github/auth.ts +33 -0
  16. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  17. package/src/channels/adapters/github/dedup.ts +26 -0
  18. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  19. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  20. package/src/channels/adapters/github/history.ts +63 -0
  21. package/src/channels/adapters/github/inbound.ts +286 -0
  22. package/src/channels/adapters/github/index.ts +286 -0
  23. package/src/channels/adapters/github/managed-path.ts +54 -0
  24. package/src/channels/adapters/github/membership.ts +35 -0
  25. package/src/channels/adapters/github/outbound.ts +145 -0
  26. package/src/channels/adapters/github/webhook-register.ts +349 -0
  27. package/src/channels/manager.ts +94 -9
  28. package/src/channels/router.ts +28 -2
  29. package/src/channels/schema.ts +31 -1
  30. package/src/channels/tunnel-bridge.ts +51 -0
  31. package/src/cli/builtins.ts +28 -0
  32. package/src/cli/channel.ts +511 -25
  33. package/src/cli/container-command-client.ts +244 -0
  34. package/src/cli/cron.ts +173 -0
  35. package/src/cli/host-command-runner.ts +150 -0
  36. package/src/cli/index.ts +42 -1
  37. package/src/cli/init.ts +256 -27
  38. package/src/cli/model.ts +4 -2
  39. package/src/cli/plugin-command-help.ts +49 -0
  40. package/src/cli/plugin-commands-dispatch.ts +112 -0
  41. package/src/cli/plugin-commands.ts +118 -0
  42. package/src/cli/tui.ts +10 -2
  43. package/src/cli/tunnel.ts +533 -0
  44. package/src/cli/ui.ts +8 -3
  45. package/src/cli/usage.ts +30 -2
  46. package/src/config/config.ts +90 -4
  47. package/src/config/reloadable.ts +22 -4
  48. package/src/container/start.ts +30 -3
  49. package/src/cron/bridge.ts +136 -0
  50. package/src/cron/consumer.ts +62 -6
  51. package/src/cron/index.ts +19 -2
  52. package/src/cron/list.ts +105 -0
  53. package/src/cron/scheduler.ts +12 -3
  54. package/src/cron/schema.ts +11 -3
  55. package/src/doctor/checks.ts +0 -50
  56. package/src/init/dockerfile.ts +59 -13
  57. package/src/init/ensure-deps.ts +15 -4
  58. package/src/init/github-webhook-install.ts +109 -0
  59. package/src/init/index.ts +505 -9
  60. package/src/init/run-bun-install.ts +17 -3
  61. package/src/init/run-owner-claim.ts +11 -2
  62. package/src/permissions/builtins.ts +6 -1
  63. package/src/permissions/match-rule.ts +24 -2
  64. package/src/permissions/resolve.ts +1 -0
  65. package/src/plugin/define.ts +42 -1
  66. package/src/plugin/index.ts +18 -3
  67. package/src/plugin/manager.ts +2 -0
  68. package/src/plugin/registry.ts +85 -3
  69. package/src/plugin/types.ts +138 -1
  70. package/src/plugin/zod-introspect.ts +100 -0
  71. package/src/role-claim/match-rule.ts +2 -1
  72. package/src/run/index.ts +119 -4
  73. package/src/secrets/index.ts +1 -1
  74. package/src/secrets/schema.ts +21 -0
  75. package/src/server/command-runner.ts +476 -0
  76. package/src/server/index.ts +393 -15
  77. package/src/shared/index.ts +8 -0
  78. package/src/shared/protocol.ts +80 -1
  79. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  80. package/src/skills/typeclaw-config/SKILL.md +27 -26
  81. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  82. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  83. package/src/skills/typeclaw-permissions/SKILL.md +5 -4
  84. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  85. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  86. package/src/test-helpers/wait-for.ts +50 -0
  87. package/src/tui/index.ts +35 -4
  88. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  89. package/src/tunnels/events.ts +14 -0
  90. package/src/tunnels/index.ts +12 -0
  91. package/src/tunnels/log-ring.ts +54 -0
  92. package/src/tunnels/manager.ts +139 -0
  93. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  94. package/src/tunnels/providers/external.ts +53 -0
  95. package/src/tunnels/quick-url-parser.ts +5 -0
  96. package/src/tunnels/types.ts +43 -0
  97. package/src/usage/aggregate.ts +30 -1
  98. package/src/usage/index.ts +3 -2
  99. package/src/usage/report.ts +103 -3
  100. package/src/usage/scan.ts +59 -4
  101. package/typeclaw.schema.json +254 -1
@@ -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
+ }
@@ -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),
@@ -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 CronJob = z.infer<typeof cronJobSchema>
46
- export type PromptJob = Extract<CronJob, { kind: 'prompt' }>
47
- export type ExecJob = Extract<CronJob, { kind: 'exec' }>
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 }
@@ -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',
@@ -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: the
287
- // reported failure is launch-time linker errors, not rendering glyphs;
288
- // font packages (esp. fonts-noto-cjk) cost ~50MB+ for no launch impact.
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(baseImageVersion: string, ghKeyringLayer: string, toggleAptArgs: string[]): string {
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
@@ -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
+ }