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
@@ -27,6 +27,12 @@ export type BuildDockerfileOptions = {
27
27
  // `util-linux` carries `setpriv`, which the shim uses to drop CAP_NET_ADMIN
28
28
  // from the bounding set before exec'ing the agent. Listed first in the
29
29
  // apt-get install line so the package set is self-documenting at a glance.
30
+ //
31
+ // xvfb is intentionally NOT in baseline — it's a toggle (`xvfb: true` by
32
+ // default, opt-out via `docker.file.xvfb: false`) because the shim
33
+ // self-heals: it spawns Xvfb (and exports DISPLAY) if the binary is on
34
+ // PATH, and execs the agent directly otherwise. See APT_FEATURES.xvfb
35
+ // below and `buildEntrypointShim`.
30
36
  const BASELINE_APT_PACKAGES = ['git', 'ca-certificates', 'curl', 'gnupg', 'iptables', 'util-linux'] as const
31
37
 
32
38
  // curl-impersonate is the only currently-working way to query DuckDuckGo from
@@ -56,6 +62,19 @@ export const CURL_IMPERSONATE_SHA256_ARM64 = '6766bc67fd3e8e2313875f32b36b5a3fab
56
62
  // the impersonation to whatever `curl_chrome` resolves to.
57
63
  export const CURL_IMPERSONATE_PROFILE = 'chrome136'
58
64
 
65
+ // cloudflared powers `cloudflare-quick` tunnels. Pinned-version + per-arch
66
+ // SHA256 mirrors the curl-impersonate pattern above: bumping requires updating
67
+ // all three constants in the same commit, and the build fails loudly at
68
+ // `sha256sum -c` if either hash is wrong for the version. To bump: pick a
69
+ // release from https://github.com/cloudflare/cloudflared/releases, then
70
+ // curl -fsSLO .../cloudflared-linux-amd64 && shasum -a 256 cloudflared-linux-amd64
71
+ // for each architecture. The version literal is the release tag exactly as it
72
+ // appears on GitHub (no `v` prefix).
73
+ export const CLOUDFLARED_VERSION = '2025.5.0'
74
+ export const CLOUDFLARED_SHA256_AMD64 = 'a62266fd02041374f1fca0d85694aafdf7e26e171a314467356b471d4ebb2393'
75
+ export const CLOUDFLARED_SHA256_ARM64 = '47e55e6eba2755239f641c2c4f89878643ac0d9eaa127a6c84a2cb43fa2e0f03'
76
+ export const CLOUDFLARED_RELEASE_URL_BASE = 'https://github.com/cloudflare/cloudflared/releases/download'
77
+
59
78
  export const TYPECLAW_ENTRYPOINT_PATH = '/usr/local/bin/typeclaw-entrypoint'
60
79
 
61
80
  // IPv4 networks the container is forbidden to egress to when
@@ -206,7 +225,96 @@ export function buildEntrypointShim(): string {
206
225
  # Source: src/init/dockerfile.ts \`buildEntrypointShim()\`.
207
226
  set -eu
208
227
 
228
+ # start_xvfb launches Xvfb in the background under a stripped capability
229
+ # bounding set so headed Chrome (agent-browser --headed, Playwright
230
+ # headful) has a real X11 display to connect to. Headless containers
231
+ # have no display server; Chrome --headless / --headless=new is
232
+ # fingerprinted by modern bot detection (Akamai / Cloudflare BM)
233
+ # regardless of UA spoof, so real headed Chrome under a virtual
234
+ # framebuffer is the only path to a passing sensor score from a
235
+ # server-side container.
236
+ #
237
+ # Two correctness invariants this function enforces:
238
+ #
239
+ # 1. Xvfb never holds CAP_NET_ADMIN. The shim runs as PID 1 with the
240
+ # container's full capability set (including NET_ADMIN when
241
+ # network.blockInternal=true). If we backgrounded Xvfb naked, it
242
+ # would inherit NET_ADMIN and keep it for the container's lifetime
243
+ # — defeating the capability-drop contract that setpriv applies to
244
+ # the agent process. Routing Xvfb through the same setpriv invocation
245
+ # we use for the agent strips NET_ADMIN before Xvfb's first exec.
246
+ # On the off-path (blockInternal=false) the bounding-set drop is a
247
+ # no-op (NET_ADMIN was never granted), but the call is harmless.
248
+ #
249
+ # 2. Xvfb startup failure is loud, not silent. \`Xvfb ... >/dev/null &\`
250
+ # under \`set -e\` does not fail the script if Xvfb exits immediately
251
+ # (missing library, port conflict, malformed args). Without the
252
+ # explicit liveness probe below, the shim would then export DISPLAY
253
+ # and exec bun, agent-browser launches would die with "cannot open
254
+ # display", and the operator would chase a phantom bug. We capture
255
+ # $! and \`kill -0\` it on every poll iteration so an early exit
256
+ # becomes a clear stderr line and a non-zero shim exit.
257
+ #
258
+ # We DO NOT use \`xvfb-run\`. xvfb-run hangs forever when it runs as
259
+ # PID 1 inside a container: its SIGUSR1-based ready handshake races
260
+ # and stalls because PID 1 ignores signals without explicit handlers,
261
+ # so the \`trap : USR1 ; wait || :\` dance never wakes up. Observed in
262
+ # practice: container alive, Xvfb running, PID 1 stuck in
263
+ # \`rt_sigsuspend\`, no agent process ever spawns, \`docker logs\` empty.
264
+ # Documented industry workarounds are tini-as-PID-1 or direct Xvfb
265
+ # spawn; we pick the latter (no new dep).
266
+ #
267
+ # Xvfb args:
268
+ # :99 fixed display number. Filesystem
269
+ # (/tmp/.X11-unix/X99) and abstract
270
+ # (\\0/tmp/.X11-unix/X99) sockets are both
271
+ # network-namespace-scoped, so :99 is safe
272
+ # across all Compose'd containers.
273
+ # -screen 0 1920x1080x24 desktop viewport agent-browser advertises;
274
+ # mismatched geometry is itself a fingerprint
275
+ # signal.
276
+ # -ac disable host-based X access control so
277
+ # Chrome connects without XAUTHORITY plumbing.
278
+ # +extension RANDR expose the RandR extension; Chrome queries
279
+ # it for screen geometry, and without it
280
+ # \`screen.*\` values come back inconsistent.
281
+ # -nolisten tcp refuse TCP connections (Unix socket only).
282
+ # Defense-in-depth — we are in a netns with
283
+ # no inbound exposure anyway.
284
+ start_xvfb() {
285
+ if ! command -v Xvfb >/dev/null 2>&1; then
286
+ return 0
287
+ fi
288
+ setpriv --bounding-set -net_admin --inh-caps -net_admin --ambient-caps -net_admin \\
289
+ -- Xvfb :99 -screen 0 1920x1080x24 -ac +extension RANDR -nolisten tcp \\
290
+ >/dev/null 2>&1 &
291
+ xvfb_pid=$!
292
+ export DISPLAY=:99
293
+ # Poll the socket every 10ms up to ~3s. Xvfb cold start is typically
294
+ # ~20-50ms on a modern host; 3s covers slow Docker Desktop VMs,
295
+ # Rosetta/QEMU emulation, and loaded CI runners. We also \`kill -0\`
296
+ # the pid each iteration so an Xvfb that died immediately surfaces
297
+ # as a clear error instead of a 3-second hang followed by silent
298
+ # "cannot open display" downstream.
299
+ i=0
300
+ while [ $i -lt 300 ]; do
301
+ if [ -S /tmp/.X11-unix/X99 ]; then
302
+ unset i xvfb_pid
303
+ return 0
304
+ fi
305
+ if ! kill -0 "$xvfb_pid" 2>/dev/null; then
306
+ echo "typeclaw-entrypoint: Xvfb exited immediately; cannot start headed display (docker.file.xvfb=true)" >&2
307
+ exit 1
308
+ fi
309
+ sleep 0.01
310
+ i=$((i + 1))
311
+ done
312
+ echo "typeclaw-entrypoint: Xvfb did not create /tmp/.X11-unix/X99 within 3s; refusing to continue (docker.file.xvfb=true)" >&2
313
+ exit 1
314
+ }
315
+
209
316
  if [ "\${TYPECLAW_NETWORK_BLOCK_INTERNAL:-0}" != "1" ]; then
317
+ start_xvfb
210
318
  exec bun run typeclaw "$@"
211
319
  fi
212
320
 
@@ -251,6 +359,7 @@ ip6tables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
251
359
  ip6tables -A OUTPUT -o lo -j ACCEPT
252
360
  ${ipv6Rules.join('\n')}
253
361
 
362
+ start_xvfb
254
363
  exec setpriv --bounding-set -net_admin --inh-caps -net_admin --ambient-caps -net_admin -- bun run typeclaw "$@"
255
364
  `
256
365
  }
@@ -283,9 +392,11 @@ RUN echo "${encoded}" | base64 -d > ${TYPECLAW_ENTRYPOINT_PATH} \\
283
392
  // names are the trixie-renamed variants from the 64-bit time_t ABI
284
393
  // transition; SONAMEs (libglib-2.0.so.0 etc.) are unchanged. Packages
285
394
  // 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.
395
+ // packages.debian.org/trixie. Fonts are intentionally omitted from this
396
+ // list: the failure these packages address is launch-time linker errors,
397
+ // not rendering glyphs. CJK glyph rendering is a separate concern handled
398
+ // by the `cjkFonts` toggle (see CJK_FONTS_PACKAGE / APT_FEATURES below),
399
+ // which layers `fonts-noto-cjk` on top via the toggle apt install path.
289
400
  export const CHROME_RUNTIME_APT_PACKAGES_AMD64 = [
290
401
  'libasound2t64',
291
402
  'libatk-bridge2.0-0t64',
@@ -310,17 +421,27 @@ export const CHROME_RUNTIME_APT_PACKAGES_AMD64 = [
310
421
  'libxrandr2',
311
422
  ] as const
312
423
 
424
+ // `fonts-noto-cjk` provides CJK glyphs for Chromium-rendered output
425
+ // (screenshots, page.pdf()). Without it CJK text in agent-browser output
426
+ // renders as `.notdef` tofu boxes. Treated as a toggle apt package (like
427
+ // gh/tmux) rather than a base-image staple so users with `cjkFonts: false`
428
+ // genuinely skip the ~56MB layer; baking into the base image would force
429
+ // every GHCR-base user to ship the fonts regardless of their opt-out.
430
+ export const CJK_FONTS_PACKAGE = 'fonts-noto-cjk'
431
+
313
432
  type AptFeature = {
314
433
  toAptArgs: (toggle: DockerfileFeatureToggle) => string[]
315
434
  }
316
435
 
317
- const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python', AptFeature> = {
436
+ const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python' | 'cjkFonts' | 'xvfb', AptFeature> = {
318
437
  ffmpeg: { toAptArgs: (v) => singlePackageArgs('ffmpeg', v) },
319
438
  gh: { toAptArgs: (v) => singlePackageArgs('gh', v) },
320
439
  tmux: { toAptArgs: (v) => singlePackageArgs('tmux', v) },
321
440
  python: {
322
441
  toAptArgs: (v) => (v === true ? ['python3', 'python3-pip', 'python3-venv', 'python-is-python3'] : []),
323
442
  },
443
+ cjkFonts: { toAptArgs: (v) => (v === true ? [CJK_FONTS_PACKAGE] : []) },
444
+ xvfb: { toAptArgs: (v) => (v === true ? ['xvfb'] : []) },
324
445
  }
325
446
 
326
447
  export function buildDockerfile(
@@ -329,13 +450,14 @@ export function buildDockerfile(
329
450
  ): string {
330
451
  const toggleAptArgs = collectToggleAptArgs(config)
331
452
  const ghKeyringLayer = renderGhKeyringLayer(config.gh)
453
+ const cloudflaredLayer = renderCloudflaredLayer(config.cloudflared)
332
454
  const customLines = renderCustomDockerfileLines(config.append)
333
455
  const baseImageVersion = options.baseImageVersion ?? null
334
456
 
335
457
  const fromAndHeavyLayers =
336
458
  baseImageVersion !== null
337
- ? renderVersionedHead(baseImageVersion, ghKeyringLayer, toggleAptArgs)
338
- : renderInlineHead(ghKeyringLayer, toggleAptArgs)
459
+ ? renderVersionedHead(baseImageVersion, ghKeyringLayer, toggleAptArgs, cloudflaredLayer)
460
+ : renderInlineHead(ghKeyringLayer, toggleAptArgs, cloudflaredLayer)
339
461
 
340
462
  return `${BUILDKIT_HEADER}
341
463
  # AUTOGENERATED by typeclaw — do not edit.
@@ -377,7 +499,12 @@ CMD ["run"]
377
499
  // npm + `typeclaw start --build` immediately, instead of being blocked on a
378
500
  // fresh base-image release. The base image's copy is harmlessly overwritten
379
501
  // by this RUN — same path, same chmod.
380
- function renderVersionedHead(baseImageVersion: string, ghKeyringLayer: string, toggleAptArgs: string[]): string {
502
+ function renderVersionedHead(
503
+ baseImageVersion: string,
504
+ ghKeyringLayer: string,
505
+ toggleAptArgs: string[],
506
+ cloudflaredLayer: string,
507
+ ): string {
381
508
  const toggleAptLayer = toggleAptArgs.length === 0 ? '' : `${renderToggleAptInstallLayer(toggleAptArgs)}\n\n`
382
509
  return `FROM ${GHCR_BASE_IMAGE_REPO}:${baseImageVersion}
383
510
 
@@ -385,7 +512,7 @@ WORKDIR /agent
385
512
 
386
513
  ARG TARGETARCH
387
514
 
388
- ${ghKeyringLayer}${toggleAptLayer}${renderEntrypointShimLayer()}
515
+ ${ghKeyringLayer}${toggleAptLayer}${cloudflaredLayer}${renderEntrypointShimLayer()}
389
516
 
390
517
  `
391
518
  }
@@ -394,7 +521,7 @@ ${ghKeyringLayer}${toggleAptLayer}${renderEntrypointShimLayer()}
394
521
  // dev-mode runs (typeclaw installed via file: / link: spec) where the
395
522
  // matching :version GHCR tag does not yet exist, and by the test suite to
396
523
  // keep coverage of the full-stack layers independent of GHCR availability.
397
- function renderInlineHead(ghKeyringLayer: string, toggleAptArgs: string[]): string {
524
+ function renderInlineHead(ghKeyringLayer: string, toggleAptArgs: string[], cloudflaredLayer: string): string {
398
525
  const baselineAndToggleArgs = [...BASELINE_APT_PACKAGES, ...toggleAptArgs]
399
526
  return `${FROM_AND_WORKDIR}
400
527
 
@@ -436,7 +563,23 @@ ${LAYER_4_AGENT_BROWSER_INSTALL}
436
563
 
437
564
  ${LAYER_5_CHROME_FOR_TESTING}
438
565
 
439
- ${renderEntrypointShimLayer()}
566
+ ${cloudflaredLayer}${renderEntrypointShimLayer()}
567
+
568
+ `
569
+ }
570
+
571
+ function renderCloudflaredLayer(enabled: boolean): string {
572
+ if (!enabled) return ''
573
+ return `# Layer 5.5 (optional): pinned cloudflared for cloudflare-quick tunnels.
574
+ RUN ARCH_BIN="$(if [ "$TARGETARCH" = "arm64" ]; then echo arm64; else echo amd64; fi)" \
575
+ && ARCH_SHA="$(if [ "$TARGETARCH" = "arm64" ]; then echo ${CLOUDFLARED_SHA256_ARM64}; else echo ${CLOUDFLARED_SHA256_AMD64}; fi)" \
576
+ && cd /tmp \
577
+ && curl -fsSL -o cloudflared \
578
+ "${CLOUDFLARED_RELEASE_URL_BASE}/${CLOUDFLARED_VERSION}/cloudflared-linux-\${ARCH_BIN}" \
579
+ && echo "\${ARCH_SHA} cloudflared" | sha256sum -c - \
580
+ && chmod +x cloudflared \
581
+ && mv cloudflared /usr/local/bin/cloudflared \
582
+ && /usr/local/bin/cloudflared --version > /dev/null
440
583
 
441
584
  `
442
585
  }
@@ -444,7 +587,7 @@ ${renderEntrypointShimLayer()}
444
587
  function renderToggleAptInstallLayer(toggleAptArgs: string[]): string {
445
588
  return `# Layer 1 (toggle apt install): packages requested via typeclaw.json
446
589
  # #docker.file toggles. Baseline + Chrome runtime libs are already in the
447
- # base image; this layer only adds gh/tmux/python/ffmpeg if enabled.
590
+ # base image; this layer only adds gh/tmux/python/ffmpeg/cjkFonts if enabled.
448
591
  RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
449
592
  --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
450
593
  apt-get update \\
@@ -570,12 +713,21 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
570
713
  fi`
571
714
 
572
715
  function defaultConfig(): DockerfileConfig {
573
- return { ffmpeg: false, gh: true, python: true, tmux: true, append: [] }
716
+ return {
717
+ ffmpeg: false,
718
+ gh: true,
719
+ python: true,
720
+ tmux: true,
721
+ cjkFonts: true,
722
+ cloudflared: true,
723
+ xvfb: true,
724
+ append: [],
725
+ }
574
726
  }
575
727
 
576
728
  function collectToggleAptArgs(config: DockerfileConfig): string[] {
577
729
  const args: string[] = []
578
- for (const key of ['ffmpeg', 'gh', 'python', 'tmux'] as const) {
730
+ for (const key of ['ffmpeg', 'gh', 'python', 'tmux', 'cjkFonts', 'xvfb'] as const) {
579
731
  args.push(...APT_FEATURES[key].toAptArgs(config[key]))
580
732
  }
581
733
  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
+ }
@@ -45,9 +45,9 @@ Do these in order. Do **not** ask further questions.
45
45
  2. Write one short paragraph in \`MEMORY.md\` marking this moment: the date, how you came to be, what you and the user agreed on.
46
46
  3. Configure local git identity with \`bash\`: \`git config user.name "<your name>"\` and \`git config user.email "<reasonable placeholder>@typeclaw.local"\` (unless the user provided an email).
47
47
  4. Stage and commit **only the files you authored** with commit message \`Hatched 🐣\`. This is the hatching-specific commit message — it overrides the normal version-control style guidance for this one commit.
48
- 5. Send **one final short message** — two sentences at most — telling the user hatching is complete and they can \`/quit\` the TUI. Do not ask further questions. Do not offer more work. The container keeps running once they quit; keeping the TUI open here wastes time.
48
+ 5. Send **one final short message** — two sentences at most — telling the user hatching is complete and they can leave the TUI with \`/quit\` (or Ctrl+C). Do not ask further questions. Do not offer more work. The container keeps running once they quit; keeping the TUI open here wastes time.
49
49
 
50
- After that final message, stop. If the user keeps talking, answer briefly and remind them they can \`/quit\` whenever they are ready.
50
+ After that final message, stop. If the user keeps talking, answer briefly and remind them they can \`/quit\` (or Ctrl+C) whenever they are ready.
51
51
 
52
52
  This is the only time you will receive these instructions. After the \`Hatched 🐣\` commit, your identity takes over and you run as yourself.`
53
53