typeclaw 0.1.4 → 0.1.6

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 (134) hide show
  1. package/README.md +15 -13
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +13 -10
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +137 -7
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +809 -300
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +11 -3
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +13 -3
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +491 -19
  67. package/src/config/index.ts +15 -1
  68. package/src/config/models-mutation.ts +200 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +6 -1
  73. package/src/container/port.ts +10 -0
  74. package/src/container/require-running.ts +33 -0
  75. package/src/container/start.ts +81 -63
  76. package/src/cron/consumer.ts +22 -2
  77. package/src/cron/index.ts +45 -4
  78. package/src/cron/schema.ts +104 -0
  79. package/src/doctor/checks.ts +51 -34
  80. package/src/doctor/plugin-bridge.ts +28 -4
  81. package/src/git/system-commit.ts +103 -0
  82. package/src/hostd/daemon.ts +16 -0
  83. package/src/hostd/kakao-renewal-manager.ts +223 -0
  84. package/src/hostd/paths.ts +7 -0
  85. package/src/init/dockerfile.ts +36 -10
  86. package/src/init/gitignore.ts +1 -1
  87. package/src/init/index.ts +213 -85
  88. package/src/init/kakaotalk-auth.ts +18 -1
  89. package/src/init/models-dev.ts +26 -1
  90. package/src/init/run-owner-claim.ts +77 -0
  91. package/src/permissions/builtins.ts +70 -0
  92. package/src/permissions/grant.ts +99 -0
  93. package/src/permissions/index.ts +29 -0
  94. package/src/permissions/match-rule.ts +305 -0
  95. package/src/permissions/permissions.ts +196 -0
  96. package/src/permissions/resolve.ts +80 -0
  97. package/src/permissions/schema.ts +79 -0
  98. package/src/plugin/context.ts +8 -4
  99. package/src/plugin/define.ts +2 -0
  100. package/src/plugin/index.ts +2 -0
  101. package/src/plugin/manager.ts +41 -0
  102. package/src/plugin/registry.ts +9 -0
  103. package/src/plugin/types.ts +35 -1
  104. package/src/reload/client.ts +25 -1
  105. package/src/role-claim/client.ts +182 -0
  106. package/src/role-claim/code.ts +53 -0
  107. package/src/role-claim/controller.ts +194 -0
  108. package/src/role-claim/index.ts +19 -0
  109. package/src/role-claim/match-rule.ts +43 -0
  110. package/src/role-claim/pending.ts +100 -0
  111. package/src/run/channel-session-factory.ts +76 -5
  112. package/src/run/index.ts +68 -7
  113. package/src/secrets/encryption.ts +116 -0
  114. package/src/secrets/kakao-renewal.ts +248 -0
  115. package/src/secrets/kakao-store.ts +66 -7
  116. package/src/secrets/keys.ts +173 -0
  117. package/src/secrets/schema.ts +23 -0
  118. package/src/secrets/storage.ts +83 -0
  119. package/src/server/index.ts +198 -71
  120. package/src/shared/index.ts +4 -0
  121. package/src/shared/protocol.ts +27 -0
  122. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  123. package/src/skills/typeclaw-config/SKILL.md +104 -112
  124. package/src/skills/typeclaw-memory/SKILL.md +9 -9
  125. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  126. package/src/stream/types.ts +7 -1
  127. package/src/tui/client.ts +66 -5
  128. package/src/tui/index.ts +61 -9
  129. package/src/usage/aggregate.ts +117 -0
  130. package/src/usage/format.ts +30 -0
  131. package/src/usage/index.ts +68 -0
  132. package/src/usage/report.ts +354 -0
  133. package/src/usage/scan.ts +186 -0
  134. package/typeclaw.schema.json +134 -98
@@ -136,14 +136,38 @@ export const NETWORK_BLOCK_IPV6_NETS = ['fc00::/7', 'fe80::/10', 'ff00::/8', '::
136
136
  // Carve-out ordering is load-bearing. iptables OUTPUT is first-match-wins,
137
137
  // and we use -A (append). So the order written into the shim is the order
138
138
  // rules will be evaluated:
139
- // 1. loopback ACCEPT
140
- // 2. hostd port ACCEPT (narrow: tcp + single dport on the host gateway)
141
- // 3. resolver ACCEPT (narrow: udp/tcp dport 53 to each /etc/resolv.conf
139
+ // 1. ESTABLISHED,RELATED ACCEPT (return path for any connection initiated
140
+ // from outside the container see comment block below)
141
+ // 2. loopback ACCEPT
142
+ // 3. hostd port ACCEPT (narrow: tcp + single dport on the host gateway)
143
+ // 4. resolver ACCEPT (narrow: udp/tcp dport 53 to each /etc/resolv.conf
142
144
  // nameserver) — gated on TYPECLAW_NETWORK_AUTO_ALLOW_RESOLVERS=1
143
- // 4. user-supplied allowlist ACCEPT (wholesale: -d <cidr>) — driven by
145
+ // 5. user-supplied allowlist ACCEPT (wholesale: -d <cidr>) — driven by
144
146
  // TYPECLAW_NETWORK_ALLOW comma-separated env
145
- // 5. RFC1918 + link-local + CGNAT + multicast + reserved REJECTs
146
- // A resolver at 10.0.0.2 hits (3) and ACCEPTs before (5) DROPs it.
147
+ // 6. RFC1918 + link-local + CGNAT + multicast + reserved REJECTs
148
+ // A resolver at 10.0.0.2 hits (4) and ACCEPTs before (6) DROPs it.
149
+ //
150
+ // Rule 1 (conntrack ESTABLISHED,RELATED) is what makes Docker port-forward
151
+ // reply traffic survive the RFC1918 REJECT. On Docker Desktop and OrbStack
152
+ // the bridge gateway is in 192.168.0.0/16 (OrbStack: 192.168.215.1 or
153
+ // 192.168.139.1; Docker Desktop: 192.168.65.1). A host -> container request
154
+ // via `docker run -p 127.0.0.1:HOST:CONTAINER` arrives at the container
155
+ // from the bridge gateway IP. Without rule 1, the reply packets would
156
+ // match rule 6 (192.168.0.0/16 REJECT) and never reach the host — TCP
157
+ // handshake completes (kernel SYN/ACK is in INPUT, not OUTPUT), the
158
+ // request body is delivered, but the agent's HTTP response is dropped at
159
+ // OUTPUT. Symptom: `curl http://127.0.0.1:HOST` connects but receives
160
+ // zero bytes and times out. Stateful inversion via conntrack is the
161
+ // canonical fix: ESTABLISHED matches packets belonging to a connection
162
+ // the kernel already tracks (including the inbound port-forward), and
163
+ // RELATED covers ICMP error packets for those connections. No new
164
+ // outbound capability is granted — a compromised agent still cannot
165
+ // initiate connections to RFC1918, only respond to inbound ones.
166
+ //
167
+ // Requires the `xt_conntrack` kernel module (universal on Linux 2.6.20+
168
+ // and on every Docker/OrbStack VM kernel) and the userspace iptables
169
+ // `conntrack` match (shipped in the `iptables` Debian package on trixie
170
+ // alongside the binary itself; no extra apt install needed).
147
171
  //
148
172
  // The resolver carve-out reads /etc/resolv.conf inside the container, NOT
149
173
  // on the host. Docker propagates the host's resolver into the container by
@@ -186,6 +210,7 @@ if [ "\${TYPECLAW_NETWORK_BLOCK_INTERNAL:-0}" != "1" ]; then
186
210
  exec bun run typeclaw "$@"
187
211
  fi
188
212
 
213
+ iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
189
214
  iptables -A OUTPUT -o lo -j ACCEPT
190
215
 
191
216
  # Hostd HTTP control carve-out: narrow ACCEPT, scoped to one TCP port on
@@ -222,6 +247,7 @@ if [ -n "\${TYPECLAW_NETWORK_ALLOW:-}" ]; then
222
247
  fi
223
248
  ${ipv4Rules.join('\n')}
224
249
 
250
+ ip6tables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
225
251
  ip6tables -A OUTPUT -o lo -j ACCEPT
226
252
  ${ipv6Rules.join('\n')}
227
253
 
@@ -384,7 +410,7 @@ ${ghKeyringLayer}# Layer 2 (changes when the package list changes): the actual a
384
410
  # Cache mounts make a re-install nearly free when this layer is invalidated:
385
411
  # .deb files come straight from the host's BuildKit cache instead of being
386
412
  # refetched from Debian/GitHub mirrors. Package set is composed from the
387
- # \`dockerfile\` config block in typeclaw.json — toggles for tmux/python/gh/
413
+ # \`docker.file\` config block in typeclaw.json — toggles for tmux/python/gh/
388
414
  # ffmpeg fan out into the args below. Baseline (git/ca-certificates/curl/
389
415
  # gnupg) is always installed because downstream layers depend on it.
390
416
  #
@@ -417,7 +443,7 @@ ${renderEntrypointShimLayer()}
417
443
 
418
444
  function renderToggleAptInstallLayer(toggleAptArgs: string[]): string {
419
445
  return `# Layer 1 (toggle apt install): packages requested via typeclaw.json
420
- # #dockerfile toggles. Baseline + Chrome runtime libs are already in the
446
+ # #docker.file toggles. Baseline + Chrome runtime libs are already in the
421
447
  # base image; this layer only adds gh/tmux/python/ffmpeg if enabled.
422
448
  RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
423
449
  --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
@@ -432,7 +458,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
432
458
  // two cannot drift — the published image is a function of this source, not
433
459
  // a checked-in Dockerfile that needs hand-syncing. The base intentionally
434
460
  // stops before the per-agent layers (gh keyring, apt feature toggles,
435
- // dockerfile.append, ENV, ENTRYPOINT) so users can still toggle them via
461
+ // docker.file.append, ENV, ENTRYPOINT) so users can still toggle them via
436
462
  // typeclaw.json without forcing a base-image rebuild.
437
463
  //
438
464
  // Layer 2's apt-get install line installs only the baseline packages, NOT
@@ -587,7 +613,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
587
613
 
588
614
  function renderCustomDockerfileLines(lines: string[]): string {
589
615
  if (lines.length === 0) return ''
590
- return `# Custom lines from typeclaw.json#dockerfile.append.
616
+ return `# Custom lines from typeclaw.json#docker.file.append.
591
617
  ${lines.join('\n')}
592
618
 
593
619
  `
@@ -39,7 +39,7 @@ channels/
39
39
 
40
40
  function renderCustomGitignoreEntries(entries: string[]): string {
41
41
  if (entries.length === 0) return ''
42
- return `# Custom entries from typeclaw.json#gitignore.append.
42
+ return `# Custom entries from typeclaw.json#git.ignore.append.
43
43
  ${entries.join('\n')}
44
44
 
45
45
  `
package/src/init/index.ts CHANGED
@@ -3,10 +3,16 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { basename, dirname, join, relative, resolve } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
 
6
- import { config, configSchema, type Config } from '@/config'
7
- import { DEFAULT_MODEL_REF, KNOWN_PROVIDERS, providerForModelRef, type KnownModelRef } from '@/config/providers'
6
+ import { config, configSchema, migrateLegacyConfigShape, type Config } from '@/config'
7
+ import {
8
+ DEFAULT_MODEL_REF,
9
+ KNOWN_PROVIDERS,
10
+ providerForModelRef,
11
+ type KnownModelRef,
12
+ type KnownProviderId,
13
+ } from '@/config/providers'
8
14
  import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
9
- import { type Channels, type Secret, SecretsBackend } from '@/secrets'
15
+ import { createSecretsStoreForAgent, type Channels, type Secret, SecretsBackend } from '@/secrets'
10
16
  import { createTui } from '@/tui'
11
17
 
12
18
  import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
@@ -23,7 +29,6 @@ export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
23
29
 
24
30
  const CONFIG_FILE = 'typeclaw.json'
25
31
  const CRON_FILE = 'cron.json'
26
- const SECRETS_FILE = '.env'
27
32
  const PACKAGE_FILE = 'package.json'
28
33
 
29
34
  const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as const
@@ -76,7 +81,15 @@ export type InitStepEvent =
76
81
  // portbroker — same path `typeclaw start` takes. When omitted (test fixtures,
77
82
  // programmatic callers that never want a daemon), `start()` skips the daemon
78
83
  // path entirely and the container runs unmanaged.
79
- export type HatchRunner = (options: { cwd: string; port: number; cliEntry?: string }) => Promise<HatchingResult>
84
+ export type HatchRunner = (options: {
85
+ cwd: string
86
+ port: number
87
+ cliEntry?: string
88
+ // Set when the wizard wired at least one channel adapter, so the runner
89
+ // can offer to run `typeclaw role claim` after the container is ready.
90
+ // Empty / undefined means "no channels — skip the claim flow".
91
+ configuredChannels?: readonly ChannelKind[]
92
+ }) => Promise<HatchingResult>
80
93
 
81
94
  export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<KakaotalkAuthResult>
82
95
 
@@ -96,16 +109,27 @@ export type InitOptions = {
96
109
  // defaults to the api-key path with `apiKey` (legacy field, still
97
110
  // supported for backwards compat with the old `runInit` signature).
98
111
  llmAuth?: LLMAuth
112
+ // Optional second model + auth, written as `models.vision` when the
113
+ // default model is text-only. Auth is reused from the default path
114
+ // when both refer to the same provider; the wizard enforces this
115
+ // pairing rule, so by the time we get here `visionAuth` is either
116
+ // (a) absent, or (b) the right auth for `visionModel`'s provider.
117
+ visionModel?: KnownModelRef
118
+ visionAuth?: LLMAuth
99
119
  apiKey?: string
100
120
  discordBotToken?: string
101
- discordAllowAll?: boolean
102
121
  slackBotToken?: string
103
122
  slackAppToken?: string
104
- slackAllowAll?: boolean
105
123
  telegramBotToken?: string
106
- telegramAllowAll?: boolean
124
+ // When reusing existing channel credentials from a pre-init `secrets.json`,
125
+ // the CLI passes `with<Adapter>: true` without a corresponding token so the
126
+ // scaffolded `typeclaw.json` wires the adapter while `writeSecrets` leaves
127
+ // the existing slot in `secrets.json#channels` untouched. Defaults below
128
+ // mirror the legacy derivation (`<token> !== undefined && !== ''`).
129
+ withDiscord?: boolean
130
+ withSlack?: boolean
131
+ withTelegram?: boolean
107
132
  withKakaotalk?: boolean
108
- kakaotalkAllowAll?: boolean
109
133
  runKakaotalkAuth?: KakaotalkAuthRunner
110
134
  onProgress?: (event: InitStepEvent) => void
111
135
  runHatching?: HatchRunner
@@ -124,15 +148,16 @@ export async function runInit({
124
148
  apiKey,
125
149
  llmAuth,
126
150
  model = DEFAULT_MODEL_REF,
151
+ visionModel,
152
+ visionAuth,
127
153
  discordBotToken,
128
- discordAllowAll = true,
129
154
  slackBotToken,
130
155
  slackAppToken,
131
- slackAllowAll = true,
132
156
  telegramBotToken,
133
- telegramAllowAll = true,
157
+ withDiscord,
158
+ withSlack,
159
+ withTelegram,
134
160
  withKakaotalk = false,
135
- kakaotalkAllowAll = false,
136
161
  runKakaotalkAuth,
137
162
  onProgress,
138
163
  runHatching = defaultRunHatching,
@@ -175,20 +200,36 @@ export async function runInit({
175
200
  }
176
201
  }
177
202
 
178
- const wantsDiscord = discordBotToken !== undefined && discordBotToken !== ''
179
- const wantsSlack = slackBotToken !== undefined && slackBotToken !== ''
180
- const wantsTelegram = telegramBotToken !== undefined && telegramBotToken !== ''
203
+ // When the vision profile uses a different provider than the default, its
204
+ // OAuth login runs here too, before any file write. Same-provider vision
205
+ // reuses the default auth (no separate login). API-key vision auth is
206
+ // captured in memory and persisted by writeSecrets() below.
207
+ if (
208
+ visionAuth !== undefined &&
209
+ visionAuth.kind === 'oauth' &&
210
+ visionModel !== undefined &&
211
+ providerForModelRef(visionModel) !== providerForModelRef(model)
212
+ ) {
213
+ emit({ step: 'oauth-login', phase: 'start' })
214
+ await mkdir(cwd, { recursive: true })
215
+ const result = await visionAuth.runLogin({ cwd, model: visionModel })
216
+ emit({ step: 'oauth-login', phase: 'done', result })
217
+ if (!result.ok) {
218
+ throw new Error(`OAuth login failed: ${result.reason}`)
219
+ }
220
+ }
221
+
222
+ const wantsDiscord = withDiscord ?? (discordBotToken !== undefined && discordBotToken !== '')
223
+ const wantsSlack = withSlack ?? (slackBotToken !== undefined && slackBotToken !== '')
224
+ const wantsTelegram = withTelegram ?? (telegramBotToken !== undefined && telegramBotToken !== '')
181
225
  emit({ step: 'scaffold', phase: 'start' })
182
226
  await scaffold(cwd, {
183
227
  model,
228
+ ...(visionModel !== undefined ? { visionModel } : {}),
184
229
  withDiscord: wantsDiscord,
185
- discordAllowAll,
186
230
  withSlack: wantsSlack,
187
- slackAllowAll,
188
231
  withTelegram: wantsTelegram,
189
- telegramAllowAll,
190
232
  withKakaotalk,
191
- kakaotalkAllowAll,
192
233
  })
193
234
  // Only write the LLM API key on the api-key path. OAuth providers persist
194
235
  // their credentials to secrets.json (via the OAuth login step above); writing
@@ -196,6 +237,9 @@ export async function runInit({
196
237
  await writeSecrets(cwd, {
197
238
  model,
198
239
  apiKey: resolvedAuth.kind === 'api-key' ? resolvedAuth.apiKey : undefined,
240
+ ...(visionModel !== undefined && visionAuth?.kind === 'api-key'
241
+ ? { visionModel, visionApiKey: visionAuth.apiKey }
242
+ : {}),
199
243
  discordBotToken,
200
244
  slackBotToken,
201
245
  slackAppToken,
@@ -230,8 +274,19 @@ export async function runInit({
230
274
  const git = await initGitRepo(cwd)
231
275
  emit({ step: 'git', phase: 'done', result: git })
232
276
 
277
+ const configuredChannels: ChannelKind[] = []
278
+ if (wantsDiscord) configuredChannels.push('discord-bot')
279
+ if (wantsSlack) configuredChannels.push('slack-bot')
280
+ if (wantsTelegram) configuredChannels.push('telegram-bot')
281
+ if (withKakaotalk) configuredChannels.push('kakaotalk')
282
+
233
283
  emit({ step: 'hatching', phase: 'start' })
234
- const hatching = await runHatching({ cwd, port: config.port, ...(cliEntry !== undefined ? { cliEntry } : {}) })
284
+ const hatching = await runHatching({
285
+ cwd,
286
+ port: config.port,
287
+ ...(cliEntry !== undefined ? { cliEntry } : {}),
288
+ ...(configuredChannels.length > 0 ? { configuredChannels } : {}),
289
+ })
235
290
  emit({ step: 'hatching', phase: 'done', result: hatching })
236
291
  }
237
292
 
@@ -245,16 +300,20 @@ export async function defaultRunHatching({
245
300
  cwd,
246
301
  port,
247
302
  cliEntry,
303
+ configuredChannels,
248
304
  startContainer = start,
249
305
  tui: tuiFactory = createTui,
250
306
  waitForAgent: waitForAgentFn = waitForAgent,
307
+ runClaim = defaultRunClaim,
251
308
  }: {
252
309
  cwd: string
253
310
  port: number
254
311
  cliEntry?: string
312
+ configuredChannels?: readonly ChannelKind[]
255
313
  startContainer?: typeof start
256
314
  tui?: typeof createTui
257
315
  waitForAgent?: typeof waitForAgent
316
+ runClaim?: ClaimRunner
258
317
  }): Promise<HatchingResult> {
259
318
  try {
260
319
  const launch = await startContainer({
@@ -269,10 +328,15 @@ export async function defaultRunHatching({
269
328
  // the preferred port, otherwise we'd connect to the wrong service.
270
329
  const hostPort = launch.hostPort
271
330
 
272
- await waitForAgentFn(`http://localhost:${hostPort}`, { timeoutMs: 30_000 })
331
+ await waitForAgentFn(`http://127.0.0.1:${hostPort}`, { timeoutMs: 30_000 })
332
+
333
+ if (configuredChannels !== undefined && configuredChannels.length > 0) {
334
+ const url = buildTuiUrl(hostPort, launch.tuiToken)
335
+ await runClaim({ url, configuredChannels })
336
+ }
273
337
 
274
338
  const tui = tuiFactory({
275
- url: `ws://localhost:${hostPort}`,
339
+ url: buildTuiUrl(hostPort, launch.tuiToken),
276
340
  initialPrompt: HATCHING_PROMPT,
277
341
  })
278
342
  await tui.run()
@@ -282,6 +346,19 @@ export async function defaultRunHatching({
282
346
  }
283
347
  }
284
348
 
349
+ export type ClaimRunner = (options: { url: string; configuredChannels: readonly ChannelKind[] }) => Promise<void>
350
+
351
+ const defaultRunClaim: ClaimRunner = async ({ url, configuredChannels }) => {
352
+ const { runOwnerClaim } = await import('./run-owner-claim')
353
+ await runOwnerClaim({ url, configuredChannels })
354
+ }
355
+
356
+ function buildTuiUrl(hostPort: number, token: string | null): string {
357
+ const url = new URL(`ws://127.0.0.1:${hostPort}`)
358
+ if (token !== null) url.searchParams.set('token', token)
359
+ return url.toString()
360
+ }
361
+
285
362
  // Probe the server's plain HTTP fallback (non-upgrade requests get a 200 with
286
363
  // body "typeclaw agent") instead of opening a WebSocket. Opening a WS here
287
364
  // would trigger createSession on the server and burn an LLM session just to
@@ -361,14 +438,11 @@ export async function isHatched(dir: string): Promise<boolean> {
361
438
 
362
439
  export type ScaffoldOptions = {
363
440
  model?: KnownModelRef
441
+ visionModel?: KnownModelRef
364
442
  withDiscord?: boolean
365
- discordAllowAll?: boolean
366
443
  withSlack?: boolean
367
- slackAllowAll?: boolean
368
444
  withTelegram?: boolean
369
- telegramAllowAll?: boolean
370
445
  withKakaotalk?: boolean
371
- kakaotalkAllowAll?: boolean
372
446
  }
373
447
 
374
448
  export async function scaffold(root: string, options: ScaffoldOptions = {}): Promise<void> {
@@ -381,30 +455,22 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
381
455
  // immediately populated, so packages/ is the only one that needs this.
382
456
  await writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists)
383
457
 
384
- // Only fields without sensible defaults elsewhere are emitted, with one
385
- // exception: `network.blockInternal` is re-emitted at its default value
386
- // (`true`) because the field is security-relevant and users need to
387
- // discover it in their `typeclaw.json` to know they can opt out for LAN
388
- // access. `mounts` defaults to `[]` in configSchema, and the bundled
389
- // memory plugin owns its own defaults in src/bundled-plugins/memory/
390
- // index.ts re-emitting either here would be duplicate noise the user
391
- // has to maintain in sync with the source of truth.
458
+ // Only fields without sensible defaults elsewhere are emitted. Everything
459
+ // with a schema-provided default (e.g. `network.blockInternal`, `mounts`,
460
+ // `memory.*`) is omitted to keep the scaffold minimal duplicating defaults
461
+ // here would mean every schema change has to be mirrored in two places, and
462
+ // users would feel obligated to maintain values they never set.
463
+ const models: Record<string, KnownModelRef> = { default: options.model ?? DEFAULT_MODEL_REF }
464
+ if (options.visionModel !== undefined) models.vision = options.visionModel
392
465
  const config: Record<string, unknown> = {
393
466
  $schema: './node_modules/typeclaw/typeclaw.schema.json',
394
- model: options.model ?? DEFAULT_MODEL_REF,
395
- network: { blockInternal: true },
396
- }
397
- const channels: Record<string, { allow: string[] }> = {}
398
- if (options.withDiscord) channels['discord-bot'] = { allow: options.discordAllowAll === false ? [] : ['*'] }
399
- if (options.withSlack) channels['slack-bot'] = { allow: options.slackAllowAll === false ? [] : ['*'] }
400
- if (options.withTelegram) channels['telegram-bot'] = { allow: options.telegramAllowAll === false ? [] : ['*'] }
401
- if (options.withKakaotalk) {
402
- // KakaoTalk involves a personal account, so we default to a tighter
403
- // allow list (DMs only) than Slack/Discord/Telegram which scope to a
404
- // workspace the user explicitly admitted the bot into. The user can
405
- // broaden to `kakao:*` later by editing typeclaw.json.
406
- channels.kakaotalk = { allow: options.kakaotalkAllowAll === true ? ['kakao:*'] : ['kakao:dm/*'] }
467
+ models,
407
468
  }
469
+ const channels: Record<string, Record<string, never>> = {}
470
+ if (options.withDiscord) channels['discord-bot'] = {}
471
+ if (options.withSlack) channels['slack-bot'] = {}
472
+ if (options.withTelegram) channels['telegram-bot'] = {}
473
+ if (options.withKakaotalk) channels.kakaotalk = {}
408
474
  if (Object.keys(channels).length > 0) config.channels = channels
409
475
  await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
410
476
 
@@ -484,7 +550,7 @@ export async function writeDockerAssets(root: string): Promise<DockerAssetsResul
484
550
  const typeclawConfig = await readTypeclawConfig(root)
485
551
  await writeFile(
486
552
  join(root, DOCKERFILE),
487
- buildDockerfile(typeclawConfig.dockerfile, { baseImageVersion: resolveBaseImageVersion(root) }),
553
+ buildDockerfile(typeclawConfig.docker.file, { baseImageVersion: resolveBaseImageVersion(root) }),
488
554
  { flag: 'wx' },
489
555
  ).catch(ignoreExists)
490
556
 
@@ -502,7 +568,7 @@ async function readPackageJson(root: string): Promise<{ name?: string; dependenc
502
568
  async function readTypeclawConfig(root: string): Promise<Config> {
503
569
  try {
504
570
  const raw = await readFile(join(root, CONFIG_FILE), 'utf8')
505
- return configSchema.parse(JSON.parse(raw))
571
+ return configSchema.parse(migrateLegacyConfigShape(JSON.parse(raw)).json)
506
572
  } catch (error) {
507
573
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return configSchema.parse({})
508
574
  throw error
@@ -558,30 +624,27 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
558
624
  }
559
625
  }
560
626
 
561
- // Writes the LLM provider's API key to `.env` (under its provider-specific
562
- // env var, e.g. OPENAI_API_KEY or FIREWORKS_API_KEY) and the channel adapter
563
- // tokens to `secrets.json#channels`. Two stores on purpose: api-keys land in
564
- // `.env` to match the `--env-file .env` boot contract (env-wins: `auth.ts`
565
- // reads the value at runtime via `setRuntimeApiKey` and never persists it to
566
- // `secrets.json`, see `src/agent/auth.ts`); channel tokens skip the .env hop
567
- // entirely and land in `secrets.json#channels` as `{ value }` Secrets that
568
- // `hydrateChannelEnvFromSecrets` injects into `process.env` only when the
569
- // canonical env var is unset, see `src/secrets/hydrate.ts`.
627
+ // Writes LLM provider API keys to `secrets.json#providers` and channel adapter
628
+ // tokens to `secrets.json#channels`. Both paths go through the structured
629
+ // v2 secrets envelope so reruns can reuse existing values without depending on
630
+ // host-stage env files.
570
631
  export async function writeSecrets(
571
632
  root: string,
572
633
  {
573
634
  model = DEFAULT_MODEL_REF,
574
635
  apiKey,
636
+ visionModel,
637
+ visionApiKey,
575
638
  discordBotToken,
576
639
  slackBotToken,
577
640
  slackAppToken,
578
641
  telegramBotToken,
579
642
  }: {
580
643
  model?: KnownModelRef
581
- // Omitted on the OAuth path — credentials live in secrets.json instead.
582
- // The .env file still gets written (empty) so post-init callers that
583
- // read it don't ENOENT-crash.
644
+ // Omitted on the OAuth path — credentials live in secrets.json via the OAuth runner.
584
645
  apiKey?: string
646
+ visionModel?: KnownModelRef
647
+ visionApiKey?: string
585
648
  discordBotToken?: string
586
649
  slackBotToken?: string
587
650
  slackAppToken?: string
@@ -590,12 +653,21 @@ export async function writeSecrets(
590
653
  ): Promise<void> {
591
654
  const providerId = providerForModelRef(model)
592
655
  const apiKeyEnv = KNOWN_PROVIDERS[providerId].apiKeyEnv
593
- const lines: string[] = []
594
- if (apiKey !== undefined && apiKeyEnv !== null) {
595
- lines.push(`${apiKeyEnv}=${apiKey}`)
656
+ const wantsDefaultKey = apiKey !== undefined && apiKeyEnv !== null
657
+ const visionProviderId = visionModel !== undefined ? providerForModelRef(visionModel) : null
658
+ const wantsVisionKey =
659
+ visionModel !== undefined &&
660
+ visionApiKey !== undefined &&
661
+ visionProviderId !== providerId &&
662
+ visionProviderId !== null &&
663
+ KNOWN_PROVIDERS[visionProviderId].apiKeyEnv !== null
664
+ if (wantsDefaultKey || wantsVisionKey) {
665
+ const secretsStore = createSecretsStoreForAgent(join(root, 'secrets.json'))
666
+ if (wantsDefaultKey) secretsStore.set(providerId, { type: 'api_key', key: apiKey! })
667
+ if (wantsVisionKey) {
668
+ secretsStore.set(visionProviderId, { type: 'api_key', key: visionApiKey! })
669
+ }
596
670
  }
597
- const body = lines.length > 0 ? `${lines.join('\n')}\n` : ''
598
- await writeFile(join(root, SECRETS_FILE), body)
599
671
 
600
672
  const channelTokens: Record<string, Record<string, Secret>> = {}
601
673
  if (discordBotToken !== undefined && discordBotToken !== '') {
@@ -623,6 +695,76 @@ export async function writeSecrets(
623
695
  backend.writeChannelsSync(merged)
624
696
  }
625
697
 
698
+ export async function readExistingProviderApiKey(root: string, providerId: KnownProviderId): Promise<string | null> {
699
+ const provider = KNOWN_PROVIDERS[providerId]
700
+ if (provider.apiKeyEnv === null) return null
701
+ return new SecretsBackend(join(root, 'secrets.json')).tryReadProviderApiKeySync(providerId)
702
+ }
703
+
704
+ // Detects whether the requested channel already has usable credentials in
705
+ // `secrets.json#channels`, so the init wizard can offer to reuse them
706
+ // instead of re-prompting for tokens. Mirrors `readExistingProviderApiKey`:
707
+ // returns `true` only when ALL fields the adapter needs are present in a
708
+ // shape `hydrateChannelEnvFromSecrets` would inject at runtime — both the
709
+ // `{ value }` form and the `{ env }` env-binding form count, matching the
710
+ // runtime resolution rules in `src/secrets/resolve.ts`. Partial slots (e.g.
711
+ // `slack-bot` with `botToken` but no `appToken`) return `false` so the
712
+ // missing field gets filled in by the normal prompt.
713
+ //
714
+ // KakaoTalk reuse is stricter: a usable block requires both a complete
715
+ // account (currentAccount + matching entry in accounts) AND the renewal
716
+ // fields (email + encryptedPassword) the hostd renewal cron needs to mint
717
+ // fresh tokens unattended (`src/secrets/kakao-renewal.ts`). Without those,
718
+ // the saved `oauth_token` will work only until KakaoTalk's ~7-day TTL
719
+ // expires, after which the user has to run `typeclaw channel reauth
720
+ // kakaotalk` anyway — better to re-auth now during init.
721
+ export async function hasExistingChannelSecrets(
722
+ root: string,
723
+ channel: 'discord' | 'slack' | 'telegram' | 'kakaotalk',
724
+ ): Promise<boolean> {
725
+ const channels = new SecretsBackend(join(root, 'secrets.json')).tryReadChannelsSync()
726
+ if (channels === null) return false
727
+ switch (channel) {
728
+ case 'discord':
729
+ return hasSecretField(channels['discord-bot'], 'token')
730
+ case 'slack':
731
+ return hasSecretField(channels['slack-bot'], 'botToken') && hasSecretField(channels['slack-bot'], 'appToken')
732
+ case 'telegram':
733
+ return hasSecretField(channels['telegram-bot'], 'token')
734
+ case 'kakaotalk': {
735
+ const block = channels.kakaotalk
736
+ if (!isObjectRecord(block)) return false
737
+ const current = (block as { currentAccount?: unknown }).currentAccount
738
+ if (typeof current !== 'string' || current.length === 0) return false
739
+ const accounts = (block as { accounts?: unknown }).accounts
740
+ if (!isObjectRecord(accounts)) return false
741
+ const account = accounts[current]
742
+ if (!isObjectRecord(account)) return false
743
+ const email = (account as { email?: unknown }).email
744
+ const encryptedPassword = (account as { encryptedPassword?: unknown }).encryptedPassword
745
+ return typeof email === 'string' && email.length > 0 && isObjectRecord(encryptedPassword)
746
+ }
747
+ }
748
+ }
749
+
750
+ // Accepts either the `{ value }` form (resolves to a literal at runtime) or
751
+ // the `{ env }` form (resolves at runtime by reading `process.env[<env>]`).
752
+ // String shorthand is sugar for `{ value }`. The schema already rejects
753
+ // empty strings via `z.string().min(1)`, so the length checks here are
754
+ // defense-in-depth against forward-compat shape drift.
755
+ function hasSecretField(slot: unknown, field: string): boolean {
756
+ if (!isObjectRecord(slot)) return false
757
+ const secret = slot[field]
758
+ if (typeof secret === 'string') return secret.length > 0
759
+ if (isObjectRecord(secret)) {
760
+ const value = (secret as { value?: unknown }).value
761
+ if (typeof value === 'string' && value.length > 0) return true
762
+ const env = (secret as { env?: unknown }).env
763
+ if (typeof env === 'string' && env.length > 0) return true
764
+ }
765
+ return false
766
+ }
767
+
626
768
  function isObjectRecord(value: unknown): value is Record<string, unknown> {
627
769
  return typeof value === 'object' && value !== null && !Array.isArray(value)
628
770
  }
@@ -682,7 +824,6 @@ export type AddChannelStepEvent =
682
824
  // from prompts; tests build them inline.
683
825
  export type AddChannelOptions = {
684
826
  cwd: string
685
- allowAll?: boolean
686
827
  onProgress?: (event: AddChannelStepEvent) => void
687
828
  } & (
688
829
  | { channel: 'discord-bot'; discordBotToken: string }
@@ -710,7 +851,7 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
710
851
  }
711
852
 
712
853
  emit({ step: 'config', phase: 'start' })
713
- await mergeChannelIntoConfig(options.cwd, options.channel, options.allowAll ?? defaultAllowAll(options.channel))
854
+ await mergeChannelIntoConfig(options.cwd, options.channel)
714
855
  emit({ step: 'config', phase: 'done' })
715
856
 
716
857
  emit({ step: 'secrets', phase: 'start' })
@@ -721,14 +862,6 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
721
862
  emit({ step: 'secrets', phase: 'done' })
722
863
  }
723
864
 
724
- // `channel add` mirrors `runInit`'s allow defaults: workspace-scoped adapters
725
- // (discord/slack/telegram) default to `*` because the bot only sees what the
726
- // operator invited it into, while KakaoTalk uses a personal account and
727
- // defaults to DMs only.
728
- function defaultAllowAll(channel: ChannelKind): boolean {
729
- return channel !== 'kakaotalk'
730
- }
731
-
732
865
  function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
733
866
  switch (options.channel) {
734
867
  case 'discord-bot':
@@ -767,7 +900,7 @@ export async function readConfiguredChannels(cwd: string): Promise<Set<ChannelKi
767
900
  return present
768
901
  }
769
902
 
770
- async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind, allowAll: boolean): Promise<void> {
903
+ async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind): Promise<void> {
771
904
  const path = join(cwd, CONFIG_FILE)
772
905
  let parsed: Record<string, unknown>
773
906
  try {
@@ -791,23 +924,18 @@ async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind, allowAl
791
924
  // Defense in depth — the CLI already filters configured channels out of
792
925
  // the picker and rejects them as the positional arg. Hitting this branch
793
926
  // means a programmatic caller passed a duplicate; better to fail loudly
794
- // than silently overwrite the user's existing allow list.
927
+ // than silently overwrite the user's existing config.
795
928
  throw new Error(`Channel "${channel}" is already configured in ${CONFIG_FILE}.`)
796
929
  }
797
930
 
798
931
  parsed.channels = {
799
932
  ...existingChannels,
800
- [channel]: { allow: buildAllow(channel, allowAll) },
933
+ [channel]: {},
801
934
  }
802
935
 
803
936
  await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
804
937
  }
805
938
 
806
- function buildAllow(channel: ChannelKind, allowAll: boolean): string[] {
807
- if (channel === 'kakaotalk') return allowAll ? ['kakao:*'] : ['kakao:dm/*']
808
- return allowAll ? ['*'] : []
809
- }
810
-
811
939
  // Writes per-adapter field values into `secrets.json#channels.<adapter>`.
812
940
  // Refuses to overwrite existing fields: if the user already has e.g.
813
941
  // `botToken` recorded (from a prior `channel add` whose follow-up steps
@@ -1,7 +1,11 @@
1
1
  import { createRequire } from 'node:module'
2
- import { join } from 'node:path'
2
+ import { join, resolve } from 'node:path'
3
3
 
4
+ import { containerNameFromCwd } from '@/container'
5
+ import { keysDir } from '@/hostd/paths'
6
+ import { encrypt } from '@/secrets/encryption'
4
7
  import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
8
+ import { createKeyStore, type KeyStore } from '@/secrets/keys'
5
9
 
6
10
  export type KakaotalkBootstrapStatus = { ok: true } | { ok: false; reason: string }
7
11
 
@@ -15,6 +19,13 @@ export type KakaotalkLoginInput = {
15
19
  agentDir: string
16
20
  callbacks: KakaotalkLoginCallbacks
17
21
  loginFlow?: LoginFlowFn
22
+ // Test seam: inject a custom keystore (typically pointing at a tmpdir).
23
+ // Production uses ~/.typeclaw/keys/<containerName>.key.
24
+ keyStore?: KeyStore
25
+ // Test seam: override the containerName used to bind the encrypted
26
+ // password's AAD. Production derives it from basename(agentDir) via
27
+ // containerNameFromCwd to match what `typeclaw start` registers with hostd.
28
+ containerName?: string
18
29
  }
19
30
 
20
31
  export type LoginFlowOptions = {
@@ -82,6 +93,10 @@ export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise
82
93
 
83
94
  const now = new Date().toISOString()
84
95
  const accountId = result.credentials.user_id || 'default'
96
+ const containerName = input.containerName ?? containerNameFromCwd(resolve(input.agentDir))
97
+ const keyStore = input.keyStore ?? createKeyStore({ keysDir: keysDir() })
98
+ const key = await keyStore.ensure(containerName)
99
+ const encryptedPassword = encrypt(input.password, key, { containerName, accountId })
85
100
  await credManager.setAccount({
86
101
  account_id: accountId,
87
102
  oauth_token: result.credentials.access_token,
@@ -92,6 +107,8 @@ export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise
92
107
  auth_method: 'login',
93
108
  created_at: now,
94
109
  updated_at: now,
110
+ email: input.email,
111
+ encryptedPassword,
95
112
  })
96
113
  await credManager.setCurrentAccount(accountId)
97
114
  await credManager.clearPendingLogin()