typeclaw 0.1.2 → 0.1.4

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 (46) hide show
  1. package/README.md +4 -0
  2. package/auth.schema.json +238 -7
  3. package/package.json +1 -1
  4. package/secrets.schema.json +238 -7
  5. package/src/agent/auth.ts +19 -38
  6. package/src/agent/tools/channel-fetch-attachment.ts +6 -0
  7. package/src/agent/tools/channel-history.ts +10 -1
  8. package/src/agent/tools/channel-log.ts +32 -0
  9. package/src/agent/tools/channel-reply.ts +18 -1
  10. package/src/agent/tools/channel-send.ts +13 -1
  11. package/src/bundled-plugins/tool-result-cap/README.md +67 -0
  12. package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
  13. package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
  14. package/src/channels/adapters/kakaotalk.ts +25 -16
  15. package/src/channels/manager.ts +47 -38
  16. package/src/cli/channel.ts +3 -3
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/init.ts +2 -1
  19. package/src/cli/ui.ts +11 -0
  20. package/src/config/config.ts +61 -4
  21. package/src/container/index.ts +2 -0
  22. package/src/container/start.ts +98 -2
  23. package/src/doctor/checks.ts +7 -27
  24. package/src/doctor/commit.ts +44 -3
  25. package/src/doctor/plugin-bridge.ts +19 -0
  26. package/src/hostd/daemon.ts +28 -3
  27. package/src/hostd/protocol.ts +7 -0
  28. package/src/init/auto-upgrade.ts +368 -0
  29. package/src/init/dockerfile.ts +83 -14
  30. package/src/init/index.ts +123 -77
  31. package/src/init/kakaotalk-auth.ts +9 -3
  32. package/src/init/run-bun-install.ts +34 -0
  33. package/src/run/bundled-plugins.ts +7 -0
  34. package/src/run/index.ts +9 -0
  35. package/src/secrets/defaults.ts +67 -0
  36. package/src/secrets/hydrate.ts +99 -0
  37. package/src/secrets/index.ts +6 -12
  38. package/src/secrets/kakao-store.ts +129 -0
  39. package/src/secrets/migrate-kakaotalk.ts +82 -0
  40. package/src/secrets/migrate.ts +5 -4
  41. package/src/secrets/resolve.ts +57 -0
  42. package/src/secrets/schema.ts +162 -42
  43. package/src/secrets/storage.ts +253 -47
  44. package/src/skills/typeclaw-config/SKILL.md +47 -8
  45. package/typeclaw.schema.json +49 -2
  46. package/src/secrets/env.ts +0 -43
package/src/cli/index.ts CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  import { defineCommand, runMain } from 'citty'
4
4
 
5
+ import { CLI_VERSION } from '../init/cli-version'
6
+
5
7
  const main = defineCommand({
6
8
  meta: {
7
9
  name: 'typeclaw',
10
+ version: CLI_VERSION,
8
11
  description: 'TypeClaw agent runtime',
9
12
  },
10
13
  subCommands: {
package/src/cli/init.ts CHANGED
@@ -275,6 +275,7 @@ export const init = defineCommand({
275
275
  cwd,
276
276
  llmAuth,
277
277
  model: selectedModel.ref,
278
+ cliEntry: process.argv[1],
278
279
  ...(discordBotToken !== undefined ? { discordBotToken } : {}),
279
280
  ...(slackBotToken !== undefined ? { slackBotToken, slackAppToken } : {}),
280
281
  ...(telegramBotToken !== undefined ? { telegramBotToken } : {}),
@@ -414,7 +415,7 @@ function preflightFailureGuidance(result: Extract<DockerAvailability, { ok: fals
414
415
  }
415
416
 
416
417
  function reportKakaotalkAuth(result: KakaotalkAuthResult): string {
417
- if (result.ok) return 'KakaoTalk credentials saved to workspace/.agent-messenger/.'
418
+ if (result.ok) return 'KakaoTalk credentials saved to secrets.json.'
418
419
  return `KakaoTalk login failed: ${result.reason}`
419
420
  }
420
421
 
package/src/cli/ui.ts CHANGED
@@ -2,6 +2,8 @@ import { styleText } from 'node:util'
2
2
 
3
3
  import { cancel, intro, isCancel, log, note, outro, spinner as clackSpinner } from '@clack/prompts'
4
4
 
5
+ import { type AutoUpgradeOutcome, describeAutoUpgrade } from '@/init/auto-upgrade'
6
+
5
7
  export { cancel, intro, isCancel, log, note, outro }
6
8
 
7
9
  function colorize(modifier: Parameters<typeof styleText>[0], s: string): string {
@@ -62,6 +64,7 @@ export type StartLikeResult = {
62
64
  hostPort: number
63
65
  containerId: string
64
66
  hostd: { state: 'registered' } | { state: 'unavailable'; reason: string } | { state: 'disabled' }
67
+ autoUpgrade?: AutoUpgradeOutcome
65
68
  }
66
69
 
67
70
  export function renderStartSuccess(result: StartLikeResult): string {
@@ -69,6 +72,14 @@ export function renderStartSuccess(result: StartLikeResult): string {
69
72
  const name = c.cyan(result.plan.containerName)
70
73
  const port = c.green(String(result.hostPort))
71
74
 
75
+ if (result.autoUpgrade) {
76
+ const message = describeAutoUpgrade(result.autoUpgrade)
77
+ if (message.length > 0) {
78
+ const tint = result.autoUpgrade.kind === 'exact-pin-respected' ? c.yellow : c.cyan
79
+ lines.push(tint(message))
80
+ }
81
+ }
82
+
72
83
  if (result.alreadyRunning) {
73
84
  lines.push(`${c.green('●')} ${name} is already running on host port ${port}.`)
74
85
  } else {
@@ -104,6 +104,25 @@ export const gitignoreSchema = z
104
104
 
105
105
  export type GitignoreConfig = z.infer<typeof gitignoreSchema>
106
106
 
107
+ const IPV4_CIDR_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/
108
+
109
+ const ipv4CidrSchema = z.string().refine(
110
+ (value) => {
111
+ const match = IPV4_CIDR_PATTERN.exec(value)
112
+ if (!match) return false
113
+ const octets = [match[1], match[2], match[3], match[4]].map(Number)
114
+ if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false
115
+ if (match[5] !== undefined) {
116
+ const prefix = Number(match[5])
117
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false
118
+ }
119
+ return true
120
+ },
121
+ {
122
+ message: 'network.allow entries must be IPv4 addresses or CIDR ranges (e.g. "10.0.0.0/16", "10.0.0.2")',
123
+ },
124
+ )
125
+
107
126
  // `blockInternal` is the kill-switch for the container-stage egress filter
108
127
  // installed by Dockerfile entrypoint shim: when true, the container is granted
109
128
  // CAP_NET_ADMIN at boot just long enough to install iptables OUTPUT rules
@@ -111,13 +130,51 @@ export type GitignoreConfig = z.infer<typeof gitignoreSchema>
111
130
  // multicast/reserved, IPv6 ULA/link-local/multicast. The capability is then
112
131
  // dropped from the bounding set via setpriv before the agent process exec's,
113
132
  // so no child (python, curl, bun-spawned anything) can mutate or recover it.
114
- // Default is `false` so existing agent folders are unaffected by an upgrade;
115
- // `typeclaw init` writes `true` for new agents (handled separately in init).
133
+ //
134
+ // Default is `true`: the threat model that motivated this feature — prompt
135
+ // injection asking the agent to fetch RFC1918 hosts (e.g. a LAN router admin
136
+ // page) or the cloud-IMDS endpoint — applies to every agent equally, so the
137
+ // safe default is "on" and
138
+ // the explicit opt-out is for users who need their agent to reach LAN hosts
139
+ // (NAS, internal services, sibling dev machines). PR #145 shipped this with
140
+ // default `false` to preserve existing-folder behavior on upgrade; this
141
+ // follow-up (the one PR #145 promised in its description) makes the default
142
+ // match the intent. `typeclaw init` also writes `true` explicitly so the
143
+ // field is discoverable in fresh `typeclaw.json` files. Loopback traffic
144
+ // (`-o lo`) is always allowed by the shim, so `bun run dev` and local APIs
145
+ // on `localhost` / `127.0.0.1` are unaffected.
146
+ //
147
+ // `autoAllowResolvers` (default `true`) makes the shim narrowly carve out
148
+ // the container's DNS resolvers — every `nameserver` line in
149
+ // `/etc/resolv.conf` gets a `udp/tcp --dport 53` ACCEPT inserted BEFORE the
150
+ // REJECT rules. This fixes the canonical EC2/GCE/Azure footgun: cloud VPC
151
+ // resolvers live inside RFC1918 (e.g. AWS VPC DNS at `10.0.0.2`), so
152
+ // `blockInternal: true` would otherwise kill every DNS lookup the agent
153
+ // makes. The carve-out is scoped to port 53 only — a compromised agent
154
+ // cannot reach the resolver host on any other port. On a laptop where
155
+ // `/etc/resolv.conf` points at a public resolver (1.1.1.1, 8.8.8.8), the
156
+ // generated ACCEPT rules are no-ops because public IPs are not in the
157
+ // block list to begin with. Opt-out (`false`) is for users who explicitly
158
+ // configure DNS via `.env` (e.g. `DOCKER_DNS=1.1.1.1`) and want a fully
159
+ // closed filter.
160
+ //
161
+ // `allow` is the power-user escape hatch: an explicit list of IPv4 CIDRs
162
+ // or bare IPv4 addresses that punch through the block list wholesale (all
163
+ // ports, all protocols). Use case: VPC-private services the agent must
164
+ // reach by IP — internal APIs, RDS endpoints, VPC interface endpoints for
165
+ // S3/Bedrock. Each entry inserts an unscoped `iptables -A OUTPUT -d <cidr>
166
+ // -j ACCEPT` before the REJECT rules. IPv4 only: the carve-out is for
167
+ // destinations the operator names explicitly, and every cloud VPC we
168
+ // support is IPv4-routable. Validation at parse time rejects non-CIDR
169
+ // strings, IPv6 forms, and out-of-range octets so a typo in
170
+ // `typeclaw.json` surfaces immediately instead of at container boot.
116
171
  export const networkSchema = z
117
172
  .object({
118
- blockInternal: z.boolean().default(false),
173
+ blockInternal: z.boolean().default(true),
174
+ autoAllowResolvers: z.boolean().default(true),
175
+ allow: z.array(ipv4CidrSchema).default([]),
119
176
  })
120
- .default({ blockInternal: false })
177
+ .default({ blockInternal: true, autoAllowResolvers: true, allow: [] })
121
178
 
122
179
  export type NetworkConfig = z.infer<typeof networkSchema>
123
180
 
@@ -17,6 +17,8 @@ export {
17
17
  } from './shared'
18
18
  export {
19
19
  planStart,
20
+ refreshDockerfile,
21
+ refreshGitignore,
20
22
  start,
21
23
  type HostDaemonStatus,
22
24
  type PlanStartOptions,
@@ -7,11 +7,20 @@ import { configSchema, expandMountPath, type Config } from '@/config/config'
7
7
  import { send as sendToDaemon } from '@/hostd/client'
8
8
  import type { HttpInfoResult } from '@/hostd/protocol'
9
9
  import { ensureDaemon } from '@/hostd/spawn'
10
+ import {
11
+ autoUpgradeTypeclawDep,
12
+ type AutoUpgradeOutcome,
13
+ expectedInstalledAfterUpgrade,
14
+ outcomeForcesInstall,
15
+ readInstalledTypeclawVersionFromAgent,
16
+ } from '@/init/auto-upgrade'
10
17
  import { resolveBaseImageVersion } from '@/init/cli-version'
11
18
  import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
12
19
  import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
13
20
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
14
21
  import { refreshPackageJson } from '@/init/packagejson'
22
+ import { runBunUpdate, type UpdateRunner } from '@/init/run-bun-install'
23
+ import { migrateKakaotalkCredentials } from '@/secrets'
15
24
 
16
25
  import { CONTAINER_PORT, findFreePort, isPortAllocatedError } from './port'
17
26
  import {
@@ -77,6 +86,25 @@ export type StartOptions = {
77
86
  // Reusing that daemon avoids a self-shutdown when disk source has drifted.
78
87
  reuseCurrentHostDaemon?: boolean
79
88
  ensureDeps?: (cwd: string) => Promise<EnsureDepsResult>
89
+ // Test seam for the typeclaw-version auto-upgrade. Production callers omit
90
+ // this and get the real autoUpgradeTypeclawDep (which reads the CLI's own
91
+ // package.json). Tests inject a stub to simulate `bun -g update typeclaw`
92
+ // having bumped the CLI without touching the agent folder.
93
+ autoUpgrade?: (cwd: string) => Promise<AutoUpgradeOutcome>
94
+ // Test seam for the auto-upgrade-triggered registry resolution. Defaults
95
+ // to `bun update typeclaw --latest`. Cannot be `runBunInstall` — see the
96
+ // module header in src/init/auto-upgrade.ts for why install doesn't move
97
+ // an already-locked in-range dep.
98
+ forceBunUpdate?: UpdateRunner
99
+ // Test seam for the post-install verification. Reads the version actually
100
+ // present in <agent>/node_modules/typeclaw/package.json after the upgrade
101
+ // install completes. Defaults to readInstalledTypeclawVersionFromAgent.
102
+ // Verification is mandatory: `bun update` can succeed (exit 0) but still
103
+ // resolve to an older version than expected if the registry has issues
104
+ // or the spec resolution surprises us; we MUST refuse to proceed to
105
+ // refreshDockerfile in that case, otherwise the Dockerfile pins a stale
106
+ // base image and the build either fails or runs against the wrong runtime.
107
+ readInstalledVersion?: (cwd: string) => string | null
80
108
  // Post-`docker run` verifier. `docker run -d` returns exit 0 the moment the
81
109
  // container is created, even if its entrypoint crashes milliseconds later.
82
110
  // The default verifier polls `docker inspect` for 1.5s and converts crashes
@@ -106,6 +134,7 @@ export type StartResult =
106
134
  // every fresh launch, including the post-stale-corpse `--rm` recovery
107
135
  // path — that one rebuilds the container from scratch.
108
136
  alreadyRunning: boolean
137
+ autoUpgrade: AutoUpgradeOutcome
109
138
  }
110
139
  | { ok: false; reason: string }
111
140
 
@@ -118,6 +147,9 @@ export async function start({
118
147
  cliEntry,
119
148
  reuseCurrentHostDaemon = false,
120
149
  ensureDeps = (dir) => ensureDepsInstalled({ cwd: dir }),
150
+ autoUpgrade = (dir) => autoUpgradeTypeclawDep({ cwd: dir }),
151
+ forceBunUpdate = runBunUpdate,
152
+ readInstalledVersion = readInstalledTypeclawVersionFromAgent,
121
153
  verifyRunning = createVerifyRunning({ exec }),
122
154
  }: StartOptions): Promise<StartResult> {
123
155
  try {
@@ -149,6 +181,42 @@ export async function start({
149
181
  if (pkgRefresh.changed) {
150
182
  await commitSystemFile(cwd, pkgRefresh.files, 'Enable bun workspaces (packages/*)')
151
183
  }
184
+
185
+ // Align the agent's typeclaw dep with the global CLI version BEFORE
186
+ // ensureDeps runs. The classic regression this prevents: `bun -g update
187
+ // typeclaw` bumps the global CLI but the agent's node_modules/typeclaw
188
+ // stays pinned to whatever was installed at init time. refreshDockerfile
189
+ // then pins FROM ghcr/typeclaw-base:<old-version> and the docker build
190
+ // either fails (image never published) or runs against a stale runtime.
191
+ //
192
+ // We use `bun update typeclaw --latest` (NOT `bun install`) because plain
193
+ // install honors the lockfile and is a no-op when the lockfile already
194
+ // satisfies the declared spec — which is the canonical regression case
195
+ // (lockfile pins 0.1.0, spec says ^0.1.0, CLI is 0.1.2; install does
196
+ // nothing, update force-resolves to 0.1.2).
197
+ //
198
+ // After the update we MUST verify the installed version actually matches
199
+ // the upgrade target. `bun update` can exit 0 but resolve to a stale
200
+ // version (registry hiccups, surprising spec resolution). If verification
201
+ // fails we abort before refreshDockerfile so we never pin a stale base
202
+ // image to a fresh container build.
203
+ const upgrade = await autoUpgrade(cwd)
204
+ const upgradeCommitMessage = commitMessageForAutoUpgrade(upgrade)
205
+ if (outcomeForcesInstall(upgrade)) {
206
+ const forced = await forceBunUpdate(cwd, 'typeclaw')
207
+ if (!forced.ok) {
208
+ return { ok: false, reason: `typeclaw auto-upgrade install failed: ${forced.reason}` }
209
+ }
210
+ const expected = expectedInstalledAfterUpgrade(upgrade)
211
+ const installedAfter = readInstalledVersion(cwd)
212
+ if (expected !== null && (installedAfter === null || !installedReachesTarget(installedAfter, expected))) {
213
+ return {
214
+ ok: false,
215
+ reason: `typeclaw auto-upgrade verification failed: bun update reported success but <agent>/node_modules/typeclaw is ${installedAfter ?? 'missing'} (expected >= ${expected}). Refusing to build a Docker image against a stale runtime.`,
216
+ }
217
+ }
218
+ }
219
+
152
220
  // Run `bun install` BEFORE the dependency-drift commit so the lockfile
153
221
  // changes the install produces are caught by the same commit. Without
154
222
  // this, upgrading the typeclaw CLI to a version that adds a new dep
@@ -160,12 +228,13 @@ export async function start({
160
228
  if (!deps.ok) {
161
229
  return { ok: false, reason: `dependency install failed: ${deps.reason}` }
162
230
  }
163
- await commitSystemFile(cwd, DEPENDENCY_FILES, 'Update dependencies')
231
+ await commitSystemFile(cwd, DEPENDENCY_FILES, upgradeCommitMessage ?? 'Update dependencies')
164
232
  // Dockerfile refresh AFTER ensureDeps so the version pin in the FROM
165
233
  // line resolves against the agent's installed node_modules/typeclaw —
166
234
  // ensures the base image's CLI version matches the runtime the
167
235
  // container will actually load.
168
236
  await refreshDockerfile(cwd)
237
+ await migrateKakaotalkCredentials(cwd)
169
238
 
170
239
  if (state.exists) {
171
240
  // Container holds the name but is not running. Without `--rm`, this is
@@ -303,12 +372,31 @@ export async function start({
303
372
  hostPort,
304
373
  hostd: stripHostDaemonControl(hostd),
305
374
  alreadyRunning: false,
375
+ autoUpgrade: upgrade,
306
376
  }
307
377
  } catch (error) {
308
378
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
309
379
  }
310
380
  }
311
381
 
382
+ function commitMessageForAutoUpgrade(outcome: AutoUpgradeOutcome): string | null {
383
+ if (outcome.kind === 'spec-rewritten') return `Upgrade typeclaw to ${outcome.to}`
384
+ if (outcome.kind === 'reinstall-needed') return `Upgrade typeclaw to ${outcome.to}`
385
+ return null
386
+ }
387
+
388
+ function installedReachesTarget(installed: string, target: string): boolean {
389
+ const ai = installed.match(/^(\d+)\.(\d+)\.(\d+)$/)
390
+ const at = target.match(/^(\d+)\.(\d+)\.(\d+)$/)
391
+ if (!ai || !at) return false
392
+ for (let i = 1; i <= 3; i++) {
393
+ const a = Number.parseInt(ai[i]!, 10)
394
+ const t = Number.parseInt(at[i]!, 10)
395
+ if (a !== t) return a > t
396
+ }
397
+ return true
398
+ }
399
+
312
400
  export async function planStart({
313
401
  cwd,
314
402
  hostPort,
@@ -336,9 +424,16 @@ export async function planStart({
336
424
  // bounding set via setpriv before exec'ing the agent — see the shim source
337
425
  // in src/init/dockerfile.ts for the full handoff. The `-e` flag is what
338
426
  // tells the shim to take the on-path; absent or set to anything other than
339
- // "1", the shim is a no-op.
427
+ // "1", the shim is a no-op. `autoAllowResolvers` / `allow` envs are only
428
+ // emitted on the on-path because the shim's off-path doesn't read them;
429
+ // `TYPECLAW_NETWORK_ALLOW` is comma-joined to match the shim's `IFS=','`
430
+ // loop, and CIDR validation already happened at config parse time.
340
431
  if (cfg.network.blockInternal) {
341
432
  runArgs.push('--cap-add=NET_ADMIN', '-e', 'TYPECLAW_NETWORK_BLOCK_INTERNAL=1')
433
+ runArgs.push('-e', `TYPECLAW_NETWORK_AUTO_ALLOW_RESOLVERS=${cfg.network.autoAllowResolvers ? '1' : '0'}`)
434
+ if (cfg.network.allow.length > 0) {
435
+ runArgs.push('-e', `TYPECLAW_NETWORK_ALLOW=${cfg.network.allow.join(',')}`)
436
+ }
342
437
  }
343
438
 
344
439
  if (hostdControl) {
@@ -537,6 +632,7 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
537
632
  hostPort,
538
633
  hostd: { state: 'disabled' },
539
634
  alreadyRunning: true,
635
+ autoUpgrade: { kind: 'skipped-already-running' },
540
636
  }
541
637
  }
542
638
 
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs'
2
- import { readFile, writeFile } from 'node:fs/promises'
2
+ import { readFile } from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import { join, relative } from 'node:path'
5
5
 
@@ -10,10 +10,13 @@ import {
10
10
  defaultDockerExec,
11
11
  imageTagFromCwd,
12
12
  inspectContainer,
13
+ refreshDockerfile,
14
+ refreshGitignore,
13
15
  resolveHostPort,
14
16
  type DockerExec,
15
17
  } from '@/container'
16
18
  import { isDaemonReachable, send } from '@/hostd'
19
+ import { resolveBaseImageVersion } from '@/init/cli-version'
17
20
  import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
18
21
  import { detectMissingDeps } from '@/init/ensure-deps'
19
22
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
@@ -33,7 +36,6 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
33
36
  agentFolderDockerfileTemplate(),
34
37
  agentFolderGitignoreTemplate(),
35
38
  agentFolderNodeModules(),
36
- agentFolderEnvFile(),
37
39
  agentFolderGitRepo(),
38
40
  configValid(),
39
41
  hostdHomeWritable(),
@@ -152,7 +154,7 @@ function agentFolderDockerfileTemplate(): DoctorCheck {
152
154
  fix: {
153
155
  description: 'Regenerate the Dockerfile from the typeclaw template.',
154
156
  autoFix: async () => {
155
- await writeAtomic(dockerfilePath, expected)
157
+ await refreshDockerfile(ctx.cwd)
156
158
  return { summary: 'refreshed Dockerfile from template', changedPaths: [DOCKERFILE] }
157
159
  },
158
160
  },
@@ -181,7 +183,7 @@ function agentFolderGitignoreTemplate(): DoctorCheck {
181
183
  fix: {
182
184
  description: 'Regenerate .gitignore from the typeclaw template.',
183
185
  autoFix: async () => {
184
- await writeAtomic(gitignorePath, expected)
186
+ await refreshGitignore(ctx.cwd)
185
187
  return { summary: 'refreshed .gitignore from template', changedPaths: [GITIGNORE_FILE] }
186
188
  },
187
189
  },
@@ -209,24 +211,6 @@ function agentFolderNodeModules(): DoctorCheck {
209
211
  }
210
212
  }
211
213
 
212
- function agentFolderEnvFile(): DoctorCheck {
213
- return {
214
- name: 'agent-folder.env-file',
215
- category: 'agent-folder',
216
- description: '.env file is present',
217
- applies: (ctx) => ctx.hasAgentFolder,
218
- async run(ctx) {
219
- if (existsSync(join(ctx.cwd, '.env'))) return { status: 'ok', message: '.env present' }
220
- return {
221
- status: 'warning',
222
- message: '.env is missing',
223
- details: ['Channels and external API integrations will not have their secrets injected.'],
224
- fix: { description: 'Create a .env file with the credentials your agent needs.' },
225
- }
226
- },
227
- }
228
- }
229
-
230
214
  function agentFolderGitRepo(): DoctorCheck {
231
215
  return {
232
216
  name: 'agent-folder.git-repo',
@@ -384,7 +368,7 @@ function buildExpectedDockerfile(cwd: string): string | null {
384
368
  try {
385
369
  const cfg = loadConfigStrictForTemplate(cwd)
386
370
  if (cfg === null) return null
387
- return buildDockerfile(cfg.dockerfile)
371
+ return buildDockerfile(cfg.dockerfile, { baseImageVersion: resolveBaseImageVersion(cwd) })
388
372
  } catch {
389
373
  return null
390
374
  }
@@ -417,10 +401,6 @@ async function safeRead(path: string): Promise<string | null> {
417
401
  }
418
402
  }
419
403
 
420
- async function writeAtomic(path: string, content: string): Promise<void> {
421
- await writeFile(path, content)
422
- }
423
-
424
404
  export function relativeToCwd(cwd: string, path: string): string {
425
405
  return relative(cwd, path) || '.'
426
406
  }
@@ -18,8 +18,8 @@ export async function commitAutoFixes(opts: CommitOptions): Promise<CommitOutcom
18
18
  return { kind: 'skipped', reason: 'no successful auto-fixes' }
19
19
  }
20
20
 
21
- const pathsStaged = uniqueSorted(successes.flatMap((a) => a.changedPaths))
22
- if (pathsStaged.length === 0) {
21
+ const requested = uniqueSorted(successes.flatMap((a) => a.changedPaths))
22
+ if (requested.length === 0) {
23
23
  return { kind: 'skipped', reason: 'auto-fixes reported no changed paths' }
24
24
  }
25
25
 
@@ -29,13 +29,23 @@ export async function commitAutoFixes(opts: CommitOptions): Promise<CommitOutcom
29
29
 
30
30
  const spawnGit = opts.spawnGit ?? defaultSpawnGit
31
31
 
32
+ const filter = await filterCommittable(spawnGit, opts.cwd, requested)
33
+ if (filter.kind === 'failed') return filter
34
+ const pathsStaged = filter.paths
35
+ if (pathsStaged.length === 0) {
36
+ return {
37
+ kind: 'skipped',
38
+ reason: `all changed path(s) are gitignored or untracked-and-ignored (${requested.join(', ')})`,
39
+ }
40
+ }
41
+
32
42
  const add = await spawnGit(['add', '--', ...pathsStaged], opts.cwd)
33
43
  if (add.exitCode !== 0) {
34
44
  return { kind: 'failed', reason: `git add failed: ${add.stderr.trim() || `exit ${add.exitCode}`}` }
35
45
  }
36
46
 
37
47
  const message = buildCommitMessage(opts.attempts)
38
- const commit = await spawnGit(['commit', '-m', message], opts.cwd)
48
+ const commit = await spawnGit(['commit', '-m', message, '--only', '--', ...pathsStaged], opts.cwd)
39
49
  if (commit.exitCode !== 0) {
40
50
  return { kind: 'failed', reason: `git commit failed: ${commit.stderr.trim() || `exit ${commit.exitCode}`}` }
41
51
  }
@@ -45,6 +55,37 @@ export async function commitAutoFixes(opts: CommitOptions): Promise<CommitOutcom
45
55
  return { kind: 'committed', commitSha, pathsStaged }
46
56
  }
47
57
 
58
+ // TypeClaw-owned files like `Dockerfile` live in the "truly-ignored" gitignore
59
+ // category — they're regenerated from the CLI template on every `typeclaw
60
+ // start`, so tracking them would produce noisy "Update Dockerfile" commits.
61
+ // `commitSystemFile` in src/container/start.ts skips them silently because
62
+ // `git status --porcelain -- <ignored>` returns empty. We replicate that
63
+ // behavior here so `doctor --fix` produces the same skip semantics instead
64
+ // of failing with `git add` hints about the ignored file.
65
+ //
66
+ // A non-zero git-status exit IS NOT the same signal as 'empty stdout' — the
67
+ // former means git itself failed (broken index, malformed pathspec, etc.).
68
+ // Surface that as { kind: 'failed' } so the user sees the real cause instead
69
+ // of a misleading 'all paths are gitignored' message.
70
+ async function filterCommittable(
71
+ spawnGit: SpawnGit,
72
+ cwd: string,
73
+ paths: string[],
74
+ ): Promise<{ kind: 'ok'; paths: string[] } | { kind: 'failed'; reason: string }> {
75
+ const out: string[] = []
76
+ for (const p of paths) {
77
+ const status = await spawnGit(['status', '--porcelain', '--', p], cwd)
78
+ if (status.exitCode !== 0) {
79
+ return {
80
+ kind: 'failed',
81
+ reason: `git status failed for ${p}: ${status.stderr.trim() || `exit ${status.exitCode}`}`,
82
+ }
83
+ }
84
+ if (status.stdout.trim().length > 0) out.push(p)
85
+ }
86
+ return { kind: 'ok', paths: out }
87
+ }
88
+
48
89
  export function buildCommitMessage(attempts: FixAttempt[]): string {
49
90
  const successes = attempts.filter((a): a is Extract<FixAttempt, { ok: true }> => a.ok === true)
50
91
  const subject = `typeclaw doctor: auto-fix ${successes.length} issue${successes.length === 1 ? '' : 's'}`
@@ -88,7 +88,13 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
88
88
  const ws = new WebSocket(url)
89
89
  try {
90
90
  await new Promise<void>((resolve, reject) => {
91
+ // `timer` is declared up front so `cleanup` can reference it without
92
+ // hitting the TDZ if the WS fires a synchronous error event during
93
+ // addEventListener (theoretical, but const-after-cleanup-definition
94
+ // would throw ReferenceError instead of being the intended no-op).
95
+ let timer: ReturnType<typeof setTimeout> | undefined
91
96
  const cleanup = () => {
97
+ if (timer !== undefined) clearTimeout(timer)
92
98
  ws.removeEventListener('open', onOpen)
93
99
  ws.removeEventListener('error', onError)
94
100
  }
@@ -100,6 +106,19 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
100
106
  cleanup()
101
107
  reject(err instanceof Error ? err : new Error(`failed to connect to ${url}`))
102
108
  }
109
+ // Bun's WebSocket has no built-in connect timeout. Without this, a TCP
110
+ // handshake that completes but never produces an Upgrade response
111
+ // (e.g. a wedged docker/orbstack port-forward) leaves the WS stuck in
112
+ // CONNECTING forever — neither 'open' nor 'error' ever fires, and
113
+ // `typeclaw doctor` hangs. The per-request timeout downstream doesn't
114
+ // help because we never reach it.
115
+ timer = setTimeout(() => {
116
+ cleanup()
117
+ try {
118
+ ws.close()
119
+ } catch {}
120
+ reject(new Error(`websocket connect timeout after ${timeoutMs}ms`))
121
+ }, timeoutMs)
103
122
  ws.addEventListener('open', onOpen, { once: true })
104
123
  ws.addEventListener('error', onError, { once: true })
105
124
  })
@@ -7,6 +7,8 @@ import type { Socket, UnixSocketListener } from 'bun'
7
7
  import type { PortForward } from '@/config'
8
8
  import { defaultDockerExec, type DockerExec } from '@/container'
9
9
  import type { PortForwardEvent } from '@/portbroker'
10
+ import { kakaoChannelBlockSchema } from '@/secrets/schema'
11
+ import { SecretsBackend } from '@/secrets/storage'
10
12
 
11
13
  import { isDaemonReachable } from './client'
12
14
  import { ensureDirs, registrationFilePath, registrationsDir, socketPath } from './paths'
@@ -16,6 +18,7 @@ import type {
16
18
  Request,
17
19
  Response as RpcResponse,
18
20
  RestartResult,
21
+ SecretsPatchResult,
19
22
  ShutdownResult,
20
23
  StatusResult,
21
24
  VersionResult,
@@ -351,6 +354,26 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
351
354
  return { ok: true, result }
352
355
  }
353
356
 
357
+ const handleSecretsPatch = async (req: {
358
+ containerName: string
359
+ patch: { channels: { kakaotalk: unknown } }
360
+ }): Promise<RpcResponse> =>
361
+ runSerially(req.containerName, async () => {
362
+ const cwd = cwds.get(req.containerName)
363
+ if (!cwd) return { ok: false, reason: `not registered: ${req.containerName}` }
364
+ const parsed = kakaoChannelBlockSchema.safeParse(req.patch?.channels?.kakaotalk)
365
+ if (!parsed.success) {
366
+ return { ok: false, reason: parsed.error.issues.map((issue) => issue.message).join('; ') }
367
+ }
368
+ const backend = new SecretsBackend(join(cwd, 'secrets.json'))
369
+ await backend.updateChannelsAsync(async (channels) => ({
370
+ result: undefined,
371
+ next: { ...channels, kakaotalk: parsed.data },
372
+ }))
373
+ const result: SecretsPatchResult = { containerName: req.containerName, patched: true }
374
+ return { ok: true, result }
375
+ })
376
+
354
377
  const handleHttpInfo = (): RpcResponse => {
355
378
  const result: HttpInfoResult = { port: httpPort }
356
379
  return { ok: true, result }
@@ -392,6 +415,8 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
392
415
  return handleStatus(req)
393
416
  case 'restart':
394
417
  return handleRestart(req)
418
+ case 'secrets-patch':
419
+ return handleSecretsPatch(req)
395
420
  case 'http-info':
396
421
  return handleHttpInfo()
397
422
  case 'version':
@@ -454,13 +479,13 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
454
479
  } catch {
455
480
  return json({ ok: false, reason: 'invalid request json' }, 400)
456
481
  }
457
- if (rpc.kind !== 'restart') {
458
- return json({ ok: false, reason: 'http transport only supports restart' }, 403)
482
+ if (rpc.kind !== 'restart' && rpc.kind !== 'secrets-patch') {
483
+ return json({ ok: false, reason: 'http transport only supports restart and secrets-patch' }, 403)
459
484
  }
460
485
  if (restartTokens.get(rpc.containerName) !== token) {
461
486
  return json({ ok: false, reason: 'invalid restart token' }, 403)
462
487
  }
463
- return json(await handleRestart(rpc))
488
+ return json(rpc.kind === 'restart' ? await handleRestart(rpc) : await handleSecretsPatch(rpc))
464
489
  }
465
490
 
466
491
  const httpHostname = opts.httpHost ?? '0.0.0.0'
@@ -1,4 +1,5 @@
1
1
  import type { PortForward } from '@/config'
2
+ import type { KakaoChannelBlock } from '@/secrets/schema'
2
3
 
3
4
  export type Request =
4
5
  | {
@@ -14,6 +15,7 @@ export type Request =
14
15
  | { kind: 'list' }
15
16
  | { kind: 'status'; containerName: string }
16
17
  | { kind: 'restart'; containerName: string; build?: boolean }
18
+ | { kind: 'secrets-patch'; containerName: string; patch: { channels: { kakaotalk: KakaoChannelBlock } } }
17
19
  | { kind: 'http-info' }
18
20
  | { kind: 'version' }
19
21
  | { kind: 'shutdown' }
@@ -35,6 +37,11 @@ export type RestartResult = {
35
37
  scheduled: true
36
38
  }
37
39
 
40
+ export type SecretsPatchResult = {
41
+ containerName: string
42
+ patched: true
43
+ }
44
+
38
45
  export type HttpInfoResult = {
39
46
  port: number
40
47
  }