typeclaw 0.1.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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. package/typeclaw.schema.json +826 -0
@@ -0,0 +1,115 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readdir, readFile } from 'node:fs/promises'
3
+ import { dirname, join, resolve, sep } from 'node:path'
4
+
5
+ // Identifies the typeclaw source the daemon was loaded from. Computed by
6
+ // hashing the bytes of every .ts file under `src/` (excluding tests).
7
+ //
8
+ // Why: the host daemon (`typeclaw _hostd`) is the only long-lived host-stage
9
+ // process. Bun reads source files once at process start; subsequent edits on
10
+ // disk do not propagate. A short-lived CLI invocation (`typeclaw start`) sees
11
+ // the new code immediately, but if it reuses an existing daemon, the daemon
12
+ // keeps serving old in-memory daemon logic indefinitely. The
13
+ // observable effect is that bug fixes "don't apply" until the user manually
14
+ // kills `_hostd` — which is invisible footgun territory.
15
+ //
16
+ // This module produces a deterministic fingerprint of the source the running
17
+ // daemon represents, so the CLI can detect drift over the control socket and
18
+ // transparently respawn. Two implementation guarantees matter:
19
+ //
20
+ // 1. Determinism. The same source tree must always produce the same hash
21
+ // across processes/machines. We sort entries by path and hash bytes only,
22
+ // not metadata (mtime/size/inode), so a clean checkout of HEAD always
23
+ // matches.
24
+ // 2. Right scope. Hash only files the daemon actually loads. Test files and
25
+ // non-typescript assets are out — changes to them must not trigger a
26
+ // daemon respawn. The current scheme covers all of `src/**/*.ts` because
27
+ // the daemon's behavior depends on transitive imports (e.g. the supervisor
28
+ // callback in `src/cli/hostd.ts` reaches into `src/container/` and
29
+ // `src/config/`). Over-respawning on `src/agent/` changes is acceptable
30
+ // cost: a daemon spawn is < 100ms.
31
+
32
+ export type SourceVersion = string
33
+
34
+ export type ComputeOptions = {
35
+ // Absolute path to the project's `src/` directory. The hash covers every
36
+ // *.ts file rooted here, recursively.
37
+ srcRoot: string
38
+ // Test seam. Tests inject an in-memory file map to keep the unit tests
39
+ // hermetic; production reads the real filesystem.
40
+ fs?: VersionFs
41
+ }
42
+
43
+ export type VersionFs = {
44
+ readdir: (path: string) => Promise<Array<{ name: string; isDirectory: boolean }>>
45
+ readFile: (path: string) => Promise<Buffer>
46
+ }
47
+
48
+ const realFs: VersionFs = {
49
+ readdir: async (path) => {
50
+ const entries = await readdir(path, { withFileTypes: true })
51
+ return entries.map((e) => ({ name: e.name, isDirectory: e.isDirectory() }))
52
+ },
53
+ readFile: (path) => readFile(path),
54
+ }
55
+
56
+ export async function computeSourceVersion(opts: ComputeOptions): Promise<SourceVersion> {
57
+ const fs = opts.fs ?? realFs
58
+ const root = resolve(opts.srcRoot)
59
+ const files = await collectSourceFiles(root, root, fs)
60
+ files.sort()
61
+
62
+ const hash = createHash('sha256')
63
+ for (const rel of files) {
64
+ const abs = join(root, rel)
65
+ const bytes = await fs.readFile(abs)
66
+ // Path separator is normalized so a hash computed on macOS matches one
67
+ // computed on Linux for the same checkout. Tree fingerprints should not
68
+ // depend on the host OS's path conventions.
69
+ const normalizedRel = rel.split(sep).join('/')
70
+ hash.update(`${normalizedRel}\u0000`)
71
+ hash.update(bytes)
72
+ hash.update('\u0000')
73
+ }
74
+ return hash.digest('hex').slice(0, 32)
75
+ }
76
+
77
+ async function collectSourceFiles(root: string, dir: string, fs: VersionFs): Promise<string[]> {
78
+ const out: string[] = []
79
+ const entries = await fs.readdir(dir)
80
+ for (const entry of entries) {
81
+ if (entry.isDirectory) {
82
+ const sub = await collectSourceFiles(root, join(dir, entry.name), fs)
83
+ out.push(...sub)
84
+ continue
85
+ }
86
+ if (!entry.name.endsWith('.ts')) continue
87
+ if (entry.name.endsWith('.test.ts')) continue
88
+ const absPath = join(dir, entry.name)
89
+ const relPath = absPath.slice(root.length + 1)
90
+ out.push(relPath)
91
+ }
92
+ return out
93
+ }
94
+
95
+ // Resolves the project's `src/` directory from a CLI entry path
96
+ // (typically `process.argv[1]`, which points at `src/cli/index.ts` in dev
97
+ // stage or a bundled JS entry in published builds). Returns `null` if no
98
+ // `src/` ancestor is found, in which case versioning falls back to a
99
+ // constant — disabling drift detection rather than crashing.
100
+ export function resolveSrcRoot(cliEntry: string): string | null {
101
+ let current = resolve(cliEntry)
102
+ while (true) {
103
+ const parent = dirname(current)
104
+ if (parent === current) return null
105
+ if (parent.endsWith(`${sep}src`) || parent === 'src') return parent
106
+ current = parent
107
+ }
108
+ }
109
+
110
+ // Sentinel used when the source root cannot be resolved (e.g. published
111
+ // bundle that lives outside a `src/` tree). Both daemon and CLI compute the
112
+ // same fallback, so they will appear in-sync and skip the respawn path. This
113
+ // preserves correctness for non-dev installs at the cost of disabling drift
114
+ // detection there.
115
+ export const UNVERSIONED_SENTINEL: SourceVersion = 'unversioned'
@@ -0,0 +1,327 @@
1
+ import type { DockerfileConfig, DockerfileFeatureToggle } from '@/config/config'
2
+
3
+ export const DOCKERFILE = 'Dockerfile'
4
+
5
+ // Apt packages that EVERY image must have — git for the agent runtime,
6
+ // curl/ca-certificates/gnupg for HTTPS and key fetches that downstream layers
7
+ // (e.g. the gh keyring) depend on. Listed first in the apt-get install line so
8
+ // the package set is self-documenting at a glance.
9
+ const BASELINE_APT_PACKAGES = ['git', 'ca-certificates', 'curl', 'gnupg'] as const
10
+
11
+ // curl-impersonate is the only currently-working way to query DuckDuckGo from
12
+ // a non-browser client on residential IPs in 2026. DDG fingerprints incoming
13
+ // requests at the TLS handshake (JA3/JA4) and HTTP/2 SETTINGS-frame layer
14
+ // before any HTTP headers are read; Bun's native fetch cannot match Chrome's
15
+ // fingerprint (upstream Bun issue #11368, open) so requests get gated behind
16
+ // 202 anomaly-modal responses, escalating to interactive duck-picker
17
+ // challenges. See `src/agent/tools/ddg.ts` for the runtime invocation.
18
+ //
19
+ // Pinned to lexiforest's actively-maintained fork (Chrome 136+ profiles in
20
+ // v1.5.6, May 2026), NOT the original `lwthiker/curl-impersonate` whose last
21
+ // release v0.6.1 (March 2024) carries Chrome ≤116 profiles — two years stale
22
+ // and useless against current DDG fingerprinting. Bumping: replace the
23
+ // version + sha256 constants below and run `typeclaw start --build` in any
24
+ // agent folder per the AGENTS.md "owns the Dockerfile" rule. Verify the new
25
+ // release ships the wrapper named in CURL_IMPERSONATE_PROFILE; lexiforest
26
+ // regenerates the bundled wrappers on Chrome major bumps and occasionally
27
+ // drops older ones.
28
+ export const CURL_IMPERSONATE_VERSION = 'v1.5.6'
29
+ export const CURL_IMPERSONATE_SHA256_AMD64 = 'b60344f63b9ed8806f0e9f7fd357d9f6c9a82aca279ed1e9e257d544885dcbde'
30
+ export const CURL_IMPERSONATE_SHA256_ARM64 = '6766bc67fd3e8e2313875f32b36b5a3fab02beffe77e5f1cf7fc5da99731d403'
31
+ // Wrapper symlink shipped in the v1.5.6 tarball. The tarball lays out
32
+ // curl_chrome136 → curl_chrome alongside the canonical `curl-impersonate`
33
+ // binary; we invoke the version-pinned wrapper so a future release that
34
+ // drops chrome136 fails loudly at search time instead of silently regressing
35
+ // the impersonation to whatever `curl_chrome` resolves to.
36
+ export const CURL_IMPERSONATE_PROFILE = 'chrome136'
37
+
38
+ // Shared-library runtime deps Chrome for Testing needs to launch on amd64
39
+ // Debian trixie (base of `oven/bun:1-slim`). `agent-browser install
40
+ // --with-deps` (v0.27.0) is supposed to install these but silently no-ops:
41
+ // its hardcoded list omits `libglib2.0-0t64`, so Chrome dies on launch
42
+ // with `libglib-2.0.so.0: cannot open shared object file` even though the
43
+ // binary download and `--with-deps` both exit 0. We install the full
44
+ // Playwright-tested chromium dep set here in Layer 2 so the bug doesn't
45
+ // recur if upstream omits another package; Layer 5 still calls
46
+ // `--with-deps` as a no-op-on-cache-hit backstop for future deps.
47
+ //
48
+ // Package list mirrors Playwright's `debian13-x64` chromium deps in
49
+ // nativeDeps.ts (https://github.com/microsoft/playwright). t64-suffixed
50
+ // names are the trixie-renamed variants from the 64-bit time_t ABI
51
+ // transition; SONAMEs (libglib-2.0.so.0 etc.) are unchanged. Packages
52
+ // without t64 here have no t64 sibling on trixie — verified against
53
+ // packages.debian.org/trixie. Fonts are intentionally omitted: the
54
+ // reported failure is launch-time linker errors, not rendering glyphs;
55
+ // font packages (esp. fonts-noto-cjk) cost ~50MB+ for no launch impact.
56
+ export const CHROME_RUNTIME_APT_PACKAGES_AMD64 = [
57
+ 'libasound2t64',
58
+ 'libatk-bridge2.0-0t64',
59
+ 'libatk1.0-0t64',
60
+ 'libatspi2.0-0t64',
61
+ 'libcairo2',
62
+ 'libcups2t64',
63
+ 'libdbus-1-3',
64
+ 'libdrm2',
65
+ 'libgbm1',
66
+ 'libglib2.0-0t64',
67
+ 'libnspr4',
68
+ 'libnss3',
69
+ 'libpango-1.0-0',
70
+ 'libx11-6',
71
+ 'libxcb1',
72
+ 'libxcomposite1',
73
+ 'libxdamage1',
74
+ 'libxext6',
75
+ 'libxfixes3',
76
+ 'libxkbcommon0',
77
+ 'libxrandr2',
78
+ ] as const
79
+
80
+ type AptFeature = {
81
+ toAptArgs: (toggle: DockerfileFeatureToggle) => string[]
82
+ }
83
+
84
+ const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python', AptFeature> = {
85
+ ffmpeg: { toAptArgs: (v) => singlePackageArgs('ffmpeg', v) },
86
+ gh: { toAptArgs: (v) => singlePackageArgs('gh', v) },
87
+ tmux: { toAptArgs: (v) => singlePackageArgs('tmux', v) },
88
+ python: {
89
+ toAptArgs: (v) => (v === true ? ['python3', 'python3-pip', 'python3-venv', 'python-is-python3'] : []),
90
+ },
91
+ }
92
+
93
+ export function buildDockerfile(config: DockerfileConfig = defaultConfig()): string {
94
+ const aptArgs = collectAptArgs(config)
95
+ const ghKeyringLayer = renderGhKeyringLayer(config.gh)
96
+ const customLines = renderCustomDockerfileLines(config.append)
97
+
98
+ return `${BUILDKIT_HEADER}
99
+ # AUTOGENERATED by typeclaw — do not edit.
100
+ # This file is rewritten on every \`typeclaw start\` from src/init/dockerfile.ts
101
+ # in the typeclaw repo. Local edits will be overwritten (and committed away if
102
+ # the working tree is dirty). To change the template, edit dockerfile.ts there.
103
+
104
+ ${FROM_AND_WORKDIR}
105
+
106
+ # Layers are ordered most-stable first to maximize Docker layer cache hits on
107
+ # rebuilds. Anything that pulls from npm (volatile) sits below anything that
108
+ # pulls from apt (stable, version-pinned by the base image's debian release),
109
+ # and the heavy Chrome-for-Testing download on amd64 is isolated in its own
110
+ # final layer so unrelated changes do not invalidate it.
111
+
112
+ ${LAYER_0_APT_KEEP_CACHE}
113
+
114
+ ${ghKeyringLayer}# Layer 2 (changes when the package list changes): the actual apt install.
115
+ # Cache mounts make a re-install nearly free when this layer is invalidated:
116
+ # .deb files come straight from the host's BuildKit cache instead of being
117
+ # refetched from Debian/GitHub mirrors. Package set is composed from the
118
+ # \`dockerfile\` config block in typeclaw.json — toggles for tmux/python/gh/
119
+ # ffmpeg fan out into the args below. Baseline (git/ca-certificates/curl/
120
+ # gnupg) is always installed because downstream layers depend on it.
121
+ #
122
+ # No \`rm -rf /var/lib/apt/lists/*\` because the lists live on a cache mount
123
+ # that is excluded from the image layer by definition.
124
+ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
125
+ --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
126
+ apt-get update \\
127
+ && apt-get install -y --no-install-recommends \\
128
+ ${aptArgs.join(' ')} \\
129
+ && if [ "$TARGETARCH" = "arm64" ]; then \\
130
+ apt-get install -y --no-install-recommends chromium; \\
131
+ else \\
132
+ apt-get install -y --no-install-recommends \\
133
+ ${CHROME_RUNTIME_APT_PACKAGES_AMD64.join(' ')}; \\
134
+ fi
135
+
136
+ ${LAYER_2_5_CURL_IMPERSONATE}
137
+
138
+ ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
139
+
140
+ ${LAYER_4_AGENT_BROWSER_INSTALL}
141
+
142
+ ${LAYER_5_CHROME_FOR_TESTING}
143
+
144
+ # The agent folder (including node_modules) is bind-mounted at runtime by
145
+ # \`typeclaw start\`, so we do not COPY or install here. This keeps the image
146
+ # tiny and lets edits on the host take effect without rebuilds.
147
+
148
+ ENV NODE_ENV=production
149
+
150
+ # Pin agent-messenger's config dir into the agent's workspace/ so KakaoTalk
151
+ # (and any future agent-messenger-backed channel) reads/writes credentials
152
+ # inside the bind-mounted agent folder. Without this, the SDK would default
153
+ # to /root/.config/agent-messenger inside the container, which doesn't
154
+ # survive container restarts and isn't visible from the host. The agent
155
+ # folder's bind-mount maps /agent → host's agent dir, so the credentials
156
+ # end up at <agentDir>/workspace/.agent-messenger/ on the host.
157
+ ENV AGENT_MESSENGER_CONFIG_DIR=/agent/workspace/.agent-messenger
158
+
159
+ ${customLines}ENTRYPOINT ["bun", "run", "typeclaw"]
160
+ CMD ["run"]
161
+ `
162
+ }
163
+
164
+ // Recipe for the prebuilt typeclaw-base image published to
165
+ // ghcr.io/typeclaw/typeclaw-base by .github/workflows/base-image.yml. Built
166
+ // from the same constants and layer templates as buildDockerfile() so the
167
+ // two cannot drift — the published image is a function of this source, not
168
+ // a checked-in Dockerfile that needs hand-syncing. The base intentionally
169
+ // stops before the per-agent layers (gh keyring, apt feature toggles,
170
+ // dockerfile.append, ENV, ENTRYPOINT) so users can still toggle them via
171
+ // typeclaw.json without forcing a base-image rebuild.
172
+ //
173
+ // Layer 2's apt-get install line installs only the baseline packages, NOT
174
+ // the gh/python/tmux/ffmpeg toggles — those layer onto the base in the
175
+ // per-agent Dockerfile.
176
+ export function buildBaseDockerfile(): string {
177
+ return `${BUILDKIT_HEADER}
178
+ # AUTOGENERATED by scripts/emit-base-dockerfile.ts from src/init/dockerfile.ts.
179
+ # Do not edit by hand — your changes will be lost on the next CI run.
180
+
181
+ ${FROM_AND_WORKDIR}
182
+
183
+ ${LAYER_0_APT_KEEP_CACHE}
184
+
185
+ # Layer 2 (baseline only): apt baseline + Chrome runtime libs. Toggle-driven
186
+ # packages (gh/python/tmux/ffmpeg) are intentionally NOT installed here —
187
+ # they layer onto the base in the per-agent Dockerfile so users can opt in/
188
+ # out via typeclaw.json without forcing a base-image rebuild.
189
+ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
190
+ --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
191
+ apt-get update \\
192
+ && apt-get install -y --no-install-recommends \\
193
+ ${BASELINE_APT_PACKAGES.join(' ')} \\
194
+ && if [ "$TARGETARCH" = "arm64" ]; then \\
195
+ apt-get install -y --no-install-recommends chromium; \\
196
+ else \\
197
+ apt-get install -y --no-install-recommends \\
198
+ ${CHROME_RUNTIME_APT_PACKAGES_AMD64.join(' ')}; \\
199
+ fi
200
+
201
+ ${LAYER_2_5_CURL_IMPERSONATE}
202
+
203
+ ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
204
+
205
+ ${LAYER_4_AGENT_BROWSER_INSTALL}
206
+
207
+ ${LAYER_5_CHROME_FOR_TESTING}
208
+ `
209
+ }
210
+
211
+ // Shared layer templates. Both buildDockerfile() (per-agent) and
212
+ // buildBaseDockerfile() (prebuilt base image) compose from these so the
213
+ // two outputs cannot drift on Chrome runtime libs, curl-impersonate
214
+ // version, or the agent-browser install path.
215
+
216
+ const BUILDKIT_HEADER = `# syntax=docker/dockerfile:1.7`
217
+
218
+ const FROM_AND_WORKDIR = `FROM oven/bun:1-slim
219
+
220
+ WORKDIR /agent
221
+
222
+ ARG TARGETARCH`
223
+
224
+ // Layer 0: defeat Debian's apt auto-clean so \`--mount=type=cache\` below
225
+ // actually retains downloaded .debs across builds. The default
226
+ // /etc/apt/apt.conf.d/docker-clean (inherited from debian:slim) deletes
227
+ // /var/cache/apt/archives at the end of every apt invocation, which would
228
+ // nullify our cache mount. Also pre-create the keyring dir so the gh repo
229
+ // layer in the per-agent Dockerfile is one cheap cp/echo with no mkdir.
230
+ const LAYER_0_APT_KEEP_CACHE = `# Layer 0 (rarely changes): defeat Debian's apt auto-clean so cache mounts
231
+ # below actually retain downloaded .debs across builds.
232
+ RUN rm -f /etc/apt/apt.conf.d/docker-clean \\
233
+ && echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache \\
234
+ && mkdir -p -m 755 /etc/apt/keyrings`
235
+
236
+ // Layer 2.5: install pinned curl-impersonate (lexiforest fork) for the
237
+ // websearch tool's DDG scraper. Required to evade DDG's TLS/HTTP2
238
+ // fingerprinting on residential IPs — see src/agent/tools/ddg.ts for the
239
+ // full rationale. Placed after Layer 2 so curl + ca-certificates + tar
240
+ // (already in baseline) are present, and before agent-browser so a version
241
+ // bump there doesn't invalidate this layer.
242
+ const LAYER_2_5_CURL_IMPERSONATE = `# Layer 2.5 (stable): pinned curl-impersonate (lexiforest fork) for DDG.
243
+ RUN ARCH_TARBALL="$(if [ "$TARGETARCH" = "arm64" ]; then echo aarch64-linux-gnu; else echo x86_64-linux-gnu; fi)" \\
244
+ && ARCH_SHA="$(if [ "$TARGETARCH" = "arm64" ]; then echo ${CURL_IMPERSONATE_SHA256_ARM64}; else echo ${CURL_IMPERSONATE_SHA256_AMD64}; fi)" \\
245
+ && cd /tmp \\
246
+ && curl -fsSL -o curl-impersonate.tar.gz \\
247
+ "https://github.com/lexiforest/curl-impersonate/releases/download/${CURL_IMPERSONATE_VERSION}/curl-impersonate-${CURL_IMPERSONATE_VERSION}.\${ARCH_TARBALL}.tar.gz" \\
248
+ && echo "\${ARCH_SHA} curl-impersonate.tar.gz" | sha256sum -c - \\
249
+ && tar -xzf curl-impersonate.tar.gz -C /usr/local/bin/ \\
250
+ && rm curl-impersonate.tar.gz \\
251
+ && /usr/local/bin/curl_${CURL_IMPERSONATE_PROFILE} --version > /dev/null`
252
+
253
+ const LAYER_3_AGENT_BROWSER_ARM64_CONFIG = `# Layer 3 (stable, arm64 only): point agent-browser at the apt-installed
254
+ # chromium. Independent of the npm install below so it stays cached across
255
+ # agent-browser version bumps.
256
+ RUN if [ "$TARGETARCH" = "arm64" ]; then \\
257
+ mkdir -p /root/.agent-browser \\
258
+ && printf '%s\\n' '{"executablePath":"/usr/bin/chromium"}' > /root/.agent-browser/config.json; \\
259
+ fi`
260
+
261
+ const LAYER_4_AGENT_BROWSER_INSTALL = `# Layer 4 (volatile): install agent-browser globally so it survives the
262
+ # runtime bind-mount over /agent/node_modules.
263
+ RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \\
264
+ bun install -g agent-browser`
265
+
266
+ // Layer 5: download the pinned Chrome for Testing build into
267
+ // ~/.agent-browser/browsers/. NO cache mount on that path because the
268
+ // runtime needs the binary in the image. System shared libraries are
269
+ // already installed in Layer 2; --with-deps is a defense-in-depth backstop
270
+ // so a future agent-browser bump that adds new deps installs them
271
+ // automatically (near-no-op when Layer 2 already covers them).
272
+ const LAYER_5_CHROME_FOR_TESTING = `# Layer 5 (heavy, amd64 only): Chrome for Testing download.
273
+ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
274
+ --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
275
+ if [ "$TARGETARCH" != "arm64" ]; then \\
276
+ agent-browser install --with-deps; \\
277
+ fi`
278
+
279
+ function defaultConfig(): DockerfileConfig {
280
+ return { ffmpeg: false, gh: true, python: true, tmux: true, append: [] }
281
+ }
282
+
283
+ function collectAptArgs(config: DockerfileConfig): string[] {
284
+ const args: string[] = [...BASELINE_APT_PACKAGES]
285
+ for (const key of ['ffmpeg', 'gh', 'python', 'tmux'] as const) {
286
+ args.push(...APT_FEATURES[key].toAptArgs(config[key]))
287
+ }
288
+ return args
289
+ }
290
+
291
+ function singlePackageArgs(name: string, toggle: DockerfileFeatureToggle): string[] {
292
+ if (toggle === false) return []
293
+ if (toggle === true) return [name]
294
+ return [`${name}=${toggle}`]
295
+ }
296
+
297
+ // The gh keyring bootstrap is a separate layer so editing the package list
298
+ // (the most frequent change) does not re-fetch the GPG key over the network.
299
+ // When `gh` is disabled, omit the layer entirely — both to skip the network
300
+ // roundtrip on cold builds and to keep the package source registry clean.
301
+ function renderGhKeyringLayer(toggle: DockerfileFeatureToggle): string {
302
+ if (toggle === false) return ''
303
+ return `# Layer 1 (rarely changes): register the GitHub CLI apt repository and trust
304
+ # its keyring. Split from the package install below so editing the package
305
+ # list (the most frequent change to this Dockerfile) does NOT re-fetch the
306
+ # GPG key over the network. The cache mount on /var/cache/apt covers the
307
+ # tiny gnupg/curl install we need to bootstrap the key fetch.
308
+ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
309
+ --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
310
+ apt-get update \\
311
+ && apt-get install -y --no-install-recommends curl ca-certificates gnupg \\
312
+ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \\
313
+ | gpg --dearmor -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \\
314
+ && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \\
315
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \\
316
+ > /etc/apt/sources.list.d/github-cli.list
317
+
318
+ `
319
+ }
320
+
321
+ function renderCustomDockerfileLines(lines: string[]): string {
322
+ if (lines.length === 0) return ''
323
+ return `# Custom lines from typeclaw.json#dockerfile.append.
324
+ ${lines.join('\n')}
325
+
326
+ `
327
+ }
@@ -0,0 +1,152 @@
1
+ import { existsSync, realpathSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { dirname, join, parse as parsePath } from 'node:path'
4
+
5
+ import { runBunInstall } from './run-bun-install'
6
+
7
+ const PACKAGE_FILE = 'package.json'
8
+ const NODE_MODULES = 'node_modules'
9
+
10
+ export type EnsureDepsResult =
11
+ | { ok: true; installed: boolean }
12
+ | { ok: false; reason: string; missing?: readonly string[] }
13
+
14
+ export type EnsureDepsOptions = {
15
+ cwd: string
16
+ install?: (cwd: string) => Promise<{ ok: true } | { ok: false; reason: string }>
17
+ detect?: (cwd: string) => Promise<readonly string[]>
18
+ }
19
+
20
+ export async function ensureDepsInstalled(options: EnsureDepsOptions): Promise<EnsureDepsResult> {
21
+ const { cwd } = options
22
+ const install = options.install ?? runBunInstall
23
+ const detect = options.detect ?? detectMissingDeps
24
+
25
+ const missing = await detect(cwd)
26
+ if (missing.length === 0) return { ok: true, installed: false }
27
+
28
+ const result = await install(cwd)
29
+ if (!result.ok) return { ok: false, reason: result.reason, missing }
30
+
31
+ // Re-probe: `bun install` returns 0 even when a file:-linked dep's own
32
+ // package.json is unreachable (it silently no-ops on the target). Without
33
+ // this check, we'd proceed to `docker run` with a known-broken
34
+ // node_modules/ and the agent would crash with a confusing in-container
35
+ // `Cannot find package 'x'`.
36
+ const stillMissing = await detect(cwd)
37
+ if (stillMissing.length > 0) {
38
+ return {
39
+ ok: false,
40
+ reason: `bun install completed but these deps are still missing from ${cwd}/node_modules/: ${stillMissing.join(', ')}`,
41
+ missing: stillMissing,
42
+ }
43
+ }
44
+ return { ok: true, installed: true }
45
+ }
46
+
47
+ // Walks the agent's package.json plus one level of transitive deps. The
48
+ // canonical failure we guard against: typeclaw is installed, but its own
49
+ // deps (e.g. agent-messenger, zod) aren't reachable from the agent folder
50
+ // after a CLI upgrade added them. Direct deps alone wouldn't catch that —
51
+ // typeclaw IS present, but its dependencies field has grown. Deeper drift
52
+ // (dep-of-dep-of-dep missing) is rare and surfaces during `bun install`.
53
+ //
54
+ // Root deps are checked against <cwd>/node_modules/<dep> ONLY (no walk-up):
55
+ // the agent folder is what gets bind-mounted into the container, so a dep
56
+ // satisfied by some ancestor host folder's node_modules would be invisible
57
+ // inside the container. Walking the host filesystem upward here would
58
+ // silently pass the gate and let `docker run` crash later with "Cannot find
59
+ // package", which is the exact failure mode this whole module was added to
60
+ // prevent.
61
+ //
62
+ // Transitive deps are different: they MUST be resolved via Node's algorithm
63
+ // from the parent package's realpath, not lexical-joined against <cwd>.
64
+ // Bun's isolated linker (used for new workspace projects with
65
+ // `configVersion = 1`, which is TypeClaw's scaffold) symlinks
66
+ // node_modules/<dep> into node_modules/.bun/<dep>@<ver>/node_modules/<dep>/
67
+ // and stores that package's own deps as siblings inside the same nested
68
+ // node_modules. A lexical probe at <cwd>/node_modules/<transitive> finds
69
+ // nothing even though the dep is correctly installed and reachable from
70
+ // the parent — that was the original false-positive that aborted
71
+ // `typeclaw start`.
72
+ export async function detectMissingDeps(cwd: string): Promise<readonly string[]> {
73
+ const rootDeps = await readDeclaredDeps(join(cwd, PACKAGE_FILE))
74
+ if (rootDeps.length === 0) return []
75
+
76
+ const missing = new Set<string>()
77
+ const installedRootDirs = new Map<string, string>()
78
+ for (const dep of rootDeps) {
79
+ const dir = resolveDirectDep(cwd, dep)
80
+ if (dir === null) {
81
+ missing.add(dep)
82
+ } else {
83
+ installedRootDirs.set(dep, dir)
84
+ }
85
+ }
86
+
87
+ for (const [dep, parentDir] of installedRootDirs) {
88
+ if (missing.has(dep)) continue
89
+ const transitive = await readDeclaredDeps(join(parentDir, PACKAGE_FILE))
90
+ for (const t of transitive) {
91
+ if (!resolveTransitiveDep(parentDir, t)) {
92
+ missing.add(t)
93
+ }
94
+ }
95
+ }
96
+
97
+ return [...missing].sort()
98
+ }
99
+
100
+ async function readDeclaredDeps(packageJsonPath: string): Promise<readonly string[]> {
101
+ if (!existsSync(packageJsonPath)) return []
102
+ let raw: string
103
+ try {
104
+ raw = await readFile(packageJsonPath, 'utf8')
105
+ } catch {
106
+ return []
107
+ }
108
+ let parsed: unknown
109
+ try {
110
+ parsed = JSON.parse(raw)
111
+ } catch {
112
+ return []
113
+ }
114
+ if (parsed === null || typeof parsed !== 'object') return []
115
+ const deps = (parsed as { dependencies?: unknown }).dependencies
116
+ if (deps === null || typeof deps !== 'object') return []
117
+ return Object.keys(deps as Record<string, unknown>)
118
+ }
119
+
120
+ function resolveDirectDep(cwd: string, depName: string): string | null {
121
+ const candidate = join(cwd, NODE_MODULES, depName)
122
+ if (!existsSync(join(candidate, PACKAGE_FILE))) return null
123
+ try {
124
+ return realpathSync(candidate)
125
+ } catch {
126
+ return null
127
+ }
128
+ }
129
+
130
+ function resolveTransitiveDep(fromDir: string, depName: string): string | null {
131
+ let dir: string
132
+ try {
133
+ dir = realpathSync(fromDir)
134
+ } catch {
135
+ return null
136
+ }
137
+ const fsRoot = parsePath(dir).root
138
+ while (true) {
139
+ const candidate = join(dir, NODE_MODULES, depName)
140
+ if (existsSync(join(candidate, PACKAGE_FILE))) {
141
+ try {
142
+ return realpathSync(candidate)
143
+ } catch {
144
+ return null
145
+ }
146
+ }
147
+ if (dir === fsRoot) return null
148
+ const parent = dirname(dir)
149
+ if (parent === dir) return null
150
+ dir = parent
151
+ }
152
+ }
@@ -0,0 +1,46 @@
1
+ import type { GitignoreConfig } from '@/config/config'
2
+
3
+ export const GITIGNORE_FILE = '.gitignore'
4
+
5
+ export function buildGitignore(config: GitignoreConfig = { append: [] }): string {
6
+ const customEntries = renderCustomGitignoreEntries(config.append)
7
+
8
+ return `${customEntries}# Truly ignored: secrets, runtime junk, the agent's free-write zone, and
9
+ # regenerated-on-every-start system files. Never enter git history.
10
+ #
11
+ # Dockerfile is rewritten from the typeclaw CLI template on every \`typeclaw
12
+ # start\` (see src/init/dockerfile.ts), so tracking it would only produce
13
+ # noisy "Update Dockerfile" commits whenever the template changes. Treat it
14
+ # like node_modules/ — reproducible from source, not part of agent state.
15
+ #
16
+ # auth.json is the pre-rename name for secrets.json; kept here permanently
17
+ # as a safety net so an agent folder cloned from a pre-rename machine never
18
+ # stages credentials by accident, even if its agent boot hasn't yet run the
19
+ # auth.json -> secrets.json migration.
20
+ .env
21
+ .env.local
22
+ secrets.json
23
+ auth.json
24
+ node_modules/
25
+ packages/*/node_modules/
26
+ workspace/
27
+ mounts/
28
+ Dockerfile
29
+ .DS_Store
30
+
31
+ # System-managed: gitignored by default so the agent never stages them by hand,
32
+ # but TypeClaw force-commits them on its own schedule (sessions/ via auto-backup,
33
+ # memory/ via the dreaming subagent). Treat them as runtime-owned, not agent-owned.
34
+ sessions/
35
+ memory/
36
+ channels/
37
+ `
38
+ }
39
+
40
+ function renderCustomGitignoreEntries(entries: string[]): string {
41
+ if (entries.length === 0) return ''
42
+ return `# Custom entries from typeclaw.json#gitignore.append.
43
+ ${entries.join('\n')}
44
+
45
+ `
46
+ }