typeclaw 0.1.2 → 0.1.3

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 (42) 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 +15 -4
  21. package/src/container/start.ts +90 -1
  22. package/src/hostd/daemon.ts +28 -3
  23. package/src/hostd/protocol.ts +7 -0
  24. package/src/init/auto-upgrade.ts +368 -0
  25. package/src/init/dockerfile.ts +25 -14
  26. package/src/init/index.ts +123 -77
  27. package/src/init/kakaotalk-auth.ts +9 -3
  28. package/src/init/run-bun-install.ts +34 -0
  29. package/src/run/bundled-plugins.ts +7 -0
  30. package/src/run/index.ts +9 -0
  31. package/src/secrets/defaults.ts +67 -0
  32. package/src/secrets/hydrate.ts +99 -0
  33. package/src/secrets/index.ts +6 -12
  34. package/src/secrets/kakao-store.ts +129 -0
  35. package/src/secrets/migrate-kakaotalk.ts +82 -0
  36. package/src/secrets/migrate.ts +5 -4
  37. package/src/secrets/resolve.ts +57 -0
  38. package/src/secrets/schema.ts +162 -42
  39. package/src/secrets/storage.ts +253 -47
  40. package/src/skills/typeclaw-config/SKILL.md +47 -8
  41. package/typeclaw.schema.json +36 -2
  42. 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 {
@@ -111,13 +111,24 @@ export type GitignoreConfig = z.infer<typeof gitignoreSchema>
111
111
  // multicast/reserved, IPv6 ULA/link-local/multicast. The capability is then
112
112
  // dropped from the bounding set via setpriv before the agent process exec's,
113
113
  // 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).
114
+ //
115
+ // Default is `true`: the threat model that motivated this feature — prompt
116
+ // injection asking the agent to fetch RFC1918 hosts (e.g. a LAN router admin
117
+ // page) or the cloud-IMDS endpoint — applies to every agent equally, so the
118
+ // safe default is "on" and
119
+ // the explicit opt-out is for users who need their agent to reach LAN hosts
120
+ // (NAS, internal services, sibling dev machines). PR #145 shipped this with
121
+ // default `false` to preserve existing-folder behavior on upgrade; this
122
+ // follow-up (the one PR #145 promised in its description) makes the default
123
+ // match the intent. `typeclaw init` also writes `true` explicitly so the
124
+ // field is discoverable in fresh `typeclaw.json` files. Loopback traffic
125
+ // (`-o lo`) is always allowed by the shim, so `bun run dev` and local APIs
126
+ // on `localhost` / `127.0.0.1` are unaffected.
116
127
  export const networkSchema = z
117
128
  .object({
118
- blockInternal: z.boolean().default(false),
129
+ blockInternal: z.boolean().default(true),
119
130
  })
120
- .default({ blockInternal: false })
131
+ .default({ blockInternal: true })
121
132
 
122
133
  export type NetworkConfig = z.infer<typeof networkSchema>
123
134
 
@@ -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,
@@ -537,6 +625,7 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
537
625
  hostPort,
538
626
  hostd: { state: 'disabled' },
539
627
  alreadyRunning: true,
628
+ autoUpgrade: { kind: 'skipped-already-running' },
540
629
  }
541
630
  }
542
631
 
@@ -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
  }