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/init/index.ts CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'
6
6
  import { config, configSchema, type Config } from '@/config'
7
7
  import { DEFAULT_MODEL_REF, KNOWN_PROVIDERS, providerForModelRef, type KnownModelRef } from '@/config/providers'
8
8
  import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
9
+ import { type Channels, type Secret, SecretsBackend } from '@/secrets'
9
10
  import { createTui } from '@/tui'
10
11
 
11
12
  import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
@@ -69,7 +70,13 @@ export type InitStepEvent =
69
70
  | { step: 'hatching'; phase: 'start' }
70
71
  | { step: 'hatching'; phase: 'done'; result: HatchingResult }
71
72
 
72
- export type HatchRunner = (options: { cwd: string; port: number }) => Promise<HatchingResult>
73
+ // `cliEntry` is the path to the running CLI module (typically `process.argv[1]`).
74
+ // When provided, the hatching step threads it into `start()`, which spawns the
75
+ // host daemon and registers the freshly-hatched container with the supervisor +
76
+ // portbroker — same path `typeclaw start` takes. When omitted (test fixtures,
77
+ // programmatic callers that never want a daemon), `start()` skips the daemon
78
+ // path entirely and the container runs unmanaged.
79
+ export type HatchRunner = (options: { cwd: string; port: number; cliEntry?: string }) => Promise<HatchingResult>
73
80
 
74
81
  export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<KakaotalkAuthResult>
75
82
 
@@ -104,6 +111,12 @@ export type InitOptions = {
104
111
  runHatching?: HatchRunner
105
112
  runBunInstall?: InstallRunner
106
113
  dockerExec?: DockerExec
114
+ // Production CLI callers (src/cli/init.ts) pass `process.argv[1]` so the
115
+ // hatching step's `start()` call spawns the host daemon and registers the
116
+ // freshly-hatched container — same path `typeclaw start` takes. Tests omit
117
+ // this to skip the daemon entirely (matching the existing seam in
118
+ // src/container/start.ts).
119
+ cliEntry?: string
107
120
  }
108
121
 
109
122
  export async function runInit({
@@ -125,6 +138,7 @@ export async function runInit({
125
138
  runHatching = defaultRunHatching,
126
139
  runBunInstall: installRunner = runBunInstall,
127
140
  dockerExec,
141
+ cliEntry,
128
142
  }: InitOptions): Promise<void> {
129
143
  const emit = onProgress ?? (() => {})
130
144
 
@@ -217,13 +231,37 @@ export async function runInit({
217
231
  emit({ step: 'git', phase: 'done', result: git })
218
232
 
219
233
  emit({ step: 'hatching', phase: 'start' })
220
- const hatching = await runHatching({ cwd, port: config.port })
234
+ const hatching = await runHatching({ cwd, port: config.port, ...(cliEntry !== undefined ? { cliEntry } : {}) })
221
235
  emit({ step: 'hatching', phase: 'done', result: hatching })
222
236
  }
223
237
 
224
- async function defaultRunHatching({ cwd, port }: { cwd: string; port: number }): Promise<HatchingResult> {
238
+ // Exported for the composition test in index.test.ts: the seam that the
239
+ // hatching-hostd fix turns on (passing `cliEntry` into `start()`) is the bug
240
+ // site itself, so a guard test that proves `defaultRunHatching` forwards
241
+ // `cliEntry` to `start()` is what blocks the regression from coming back.
242
+ // Tests inject `startContainer` and `tui` to avoid Docker / TUI side effects;
243
+ // production callers omit both and get the real `start` + `createTui`.
244
+ export async function defaultRunHatching({
245
+ cwd,
246
+ port,
247
+ cliEntry,
248
+ startContainer = start,
249
+ tui: tuiFactory = createTui,
250
+ waitForAgent: waitForAgentFn = waitForAgent,
251
+ }: {
252
+ cwd: string
253
+ port: number
254
+ cliEntry?: string
255
+ startContainer?: typeof start
256
+ tui?: typeof createTui
257
+ waitForAgent?: typeof waitForAgent
258
+ }): Promise<HatchingResult> {
225
259
  try {
226
- const launch = await start({ cwd, preferredHostPort: port })
260
+ const launch = await startContainer({
261
+ cwd,
262
+ preferredHostPort: port,
263
+ ...(cliEntry !== undefined ? { cliEntry } : {}),
264
+ })
227
265
  if (!launch.ok) return { ok: false, reason: launch.reason }
228
266
 
229
267
  // start() may have allocated a different host port (the preferred one was
@@ -231,9 +269,9 @@ async function defaultRunHatching({ cwd, port }: { cwd: string; port: number }):
231
269
  // the preferred port, otherwise we'd connect to the wrong service.
232
270
  const hostPort = launch.hostPort
233
271
 
234
- await waitForAgent(`http://localhost:${hostPort}`, { timeoutMs: 30_000 })
272
+ await waitForAgentFn(`http://localhost:${hostPort}`, { timeoutMs: 30_000 })
235
273
 
236
- const tui = createTui({
274
+ const tui = tuiFactory({
237
275
  url: `ws://localhost:${hostPort}`,
238
276
  initialPrompt: HATCHING_PROMPT,
239
277
  })
@@ -343,14 +381,18 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
343
381
  // immediately populated, so packages/ is the only one that needs this.
344
382
  await writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists)
345
383
 
346
- // Only fields without sensible defaults elsewhere are emitted. `mounts`
347
- // defaults to `[]` in configSchema, and the bundled memory plugin owns its
348
- // own defaults in src/bundled-plugins/memory/index.ts re-emitting either here would
349
- // be duplicate noise the user has to maintain in sync with the source of
350
- // truth.
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.
351
392
  const config: Record<string, unknown> = {
352
393
  $schema: './node_modules/typeclaw/typeclaw.schema.json',
353
394
  model: options.model ?? DEFAULT_MODEL_REF,
395
+ network: { blockInternal: true },
354
396
  }
355
397
  const channels: Record<string, { allow: string[] }> = {}
356
398
  if (options.withDiscord) channels['discord-bot'] = { allow: options.discordAllowAll === false ? [] : ['*'] }
@@ -516,10 +558,15 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
516
558
  }
517
559
  }
518
560
 
519
- // Writes the LLM provider's API key (under its provider-specific env var,
520
- // e.g. OPENAI_API_KEY or FIREWORKS_API_KEY) plus any channel adapter tokens.
521
- // The provider env var is resolved from KNOWN_PROVIDERS via the model ref,
522
- // so adding a new provider only requires touching providers.ts.
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`.
523
570
  export async function writeSecrets(
524
571
  root: string,
525
572
  {
@@ -531,8 +578,9 @@ export async function writeSecrets(
531
578
  telegramBotToken,
532
579
  }: {
533
580
  model?: KnownModelRef
534
- // Omitted on the OAuth path — credentials live in secrets.json instead. The
535
- // .env file still gets written for any channel adapter tokens.
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.
536
584
  apiKey?: string
537
585
  discordBotToken?: string
538
586
  slackBotToken?: string
@@ -546,22 +594,37 @@ export async function writeSecrets(
546
594
  if (apiKey !== undefined && apiKeyEnv !== null) {
547
595
  lines.push(`${apiKeyEnv}=${apiKey}`)
548
596
  }
597
+ const body = lines.length > 0 ? `${lines.join('\n')}\n` : ''
598
+ await writeFile(join(root, SECRETS_FILE), body)
599
+
600
+ const channelTokens: Record<string, Record<string, Secret>> = {}
549
601
  if (discordBotToken !== undefined && discordBotToken !== '') {
550
- lines.push(`DISCORD_BOT_TOKEN=${discordBotToken}`)
602
+ channelTokens['discord-bot'] = { token: { value: discordBotToken } }
551
603
  }
552
604
  if (slackBotToken !== undefined && slackBotToken !== '') {
553
- lines.push(`SLACK_BOT_TOKEN=${slackBotToken}`)
605
+ channelTokens['slack-bot'] = { ...channelTokens['slack-bot'], botToken: { value: slackBotToken } }
554
606
  }
555
607
  if (slackAppToken !== undefined && slackAppToken !== '') {
556
- lines.push(`SLACK_APP_TOKEN=${slackAppToken}`)
608
+ channelTokens['slack-bot'] = { ...channelTokens['slack-bot'], appToken: { value: slackAppToken } }
557
609
  }
558
610
  if (telegramBotToken !== undefined && telegramBotToken !== '') {
559
- lines.push(`TELEGRAM_BOT_TOKEN=${telegramBotToken}`)
611
+ channelTokens['telegram-bot'] = { token: { value: telegramBotToken } }
560
612
  }
561
- // Always write .env even when empty so existing callers that read it
562
- // post-init (channel `add`, runtime startup) don't ENOENT-crash.
563
- const body = lines.length > 0 ? `${lines.join('\n')}\n` : ''
564
- await writeFile(join(root, SECRETS_FILE), body)
613
+ if (Object.keys(channelTokens).length === 0) return
614
+
615
+ const backend = new SecretsBackend(join(root, 'secrets.json'))
616
+ const existing = backend.readChannelsSync()
617
+ const merged: Channels = { ...existing }
618
+ for (const [adapterId, fields] of Object.entries(channelTokens)) {
619
+ const priorSlot = isObjectRecord(merged[adapterId]) ? { ...(merged[adapterId] as Record<string, unknown>) } : {}
620
+ for (const [k, v] of Object.entries(fields)) priorSlot[k] = v
621
+ merged[adapterId] = priorSlot as Channels[string]
622
+ }
623
+ backend.writeChannelsSync(merged)
624
+ }
625
+
626
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
627
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
565
628
  }
566
629
 
567
630
  function resolveLLMAuth(llmAuth: LLMAuth | undefined, apiKey: string | undefined): LLMAuth {
@@ -635,7 +698,7 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
635
698
  //
636
699
  // We run KakaoTalk auth FIRST so a failed login leaves typeclaw.json and
637
700
  // .env untouched. The runtime treats `channels.kakaotalk` without a
638
- // credentials file as "missing credentials, skip adapter", which silently
701
+ // secrets.json#channels.kakaotalk block as "missing credentials, skip adapter", which silently
639
702
  // drops messages — the same trap `runInit` already guards against. Aborting
640
703
  // before any file write means the user's next `typeclaw channel add
641
704
  // kakaotalk` retry has no half-applied state to clean up.
@@ -651,7 +714,10 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
651
714
  emit({ step: 'config', phase: 'done' })
652
715
 
653
716
  emit({ step: 'secrets', phase: 'start' })
654
- await appendChannelSecrets(options.cwd, channelSecretsFromOptions(options))
717
+ const tokens = channelSecretsFromOptions(options)
718
+ if (Object.keys(tokens).length > 0) {
719
+ await appendChannelSecrets(options.cwd, options.channel, tokens)
720
+ }
655
721
  emit({ step: 'secrets', phase: 'done' })
656
722
  }
657
723
 
@@ -666,13 +732,14 @@ function defaultAllowAll(channel: ChannelKind): boolean {
666
732
  function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
667
733
  switch (options.channel) {
668
734
  case 'discord-bot':
669
- return { DISCORD_BOT_TOKEN: options.discordBotToken }
735
+ return { token: options.discordBotToken }
670
736
  case 'slack-bot':
671
- return { SLACK_BOT_TOKEN: options.slackBotToken, SLACK_APP_TOKEN: options.slackAppToken }
737
+ return { botToken: options.slackBotToken, appToken: options.slackAppToken }
672
738
  case 'telegram-bot':
673
- return { TELEGRAM_BOT_TOKEN: options.telegramBotToken }
739
+ return { token: options.telegramBotToken }
674
740
  case 'kakaotalk':
675
- // Credentials live in workspace/.agent-messenger/, not .env.
741
+ // KakaoTalk auth writes its structured multi-account block directly to
742
+ // secrets.json#channels.kakaotalk before config mutation.
676
743
  return {}
677
744
  }
678
745
  }
@@ -741,56 +808,35 @@ function buildAllow(channel: ChannelKind, allowAll: boolean): string[] {
741
808
  return allowAll ? ['*'] : []
742
809
  }
743
810
 
744
- // Appends only keys that are not already present in .env. We never rewrite
745
- // existing values: if the user has `SLACK_BOT_TOKEN=` left over from a manual
746
- // edit, we surface that as a hard error rather than overwrite the user's
747
- // hand-rolled value.
748
- //
749
- // `.env` parsing is intentionally line-based and dumb (matching dotenv's
750
- // minimum surface): trim, skip blanks/comments, split on the first `=`. We do
751
- // not unquote values because we only check for key presence.
752
- async function appendChannelSecrets(cwd: string, secrets: ChannelSecrets): Promise<void> {
753
- if (Object.keys(secrets).length === 0) return
754
-
755
- const path = join(cwd, SECRETS_FILE)
756
- let existing: string
757
- try {
758
- existing = await readFile(path, 'utf8')
759
- } catch (error) {
760
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
761
- throw new Error(
762
- `${SECRETS_FILE} not found at ${cwd}. Run \`typeclaw init\` before adding channels, or run this command from inside an agent folder.`,
763
- )
764
- }
765
- throw error
811
+ // Writes per-adapter field values into `secrets.json#channels.<adapter>`.
812
+ // Refuses to overwrite existing fields: if the user already has e.g.
813
+ // `botToken` recorded (from a prior `channel add` whose follow-up steps
814
+ // failed, or a hand-edit), we surface that as a hard error rather than
815
+ // silently displace it. Same trap the original .env-append path guarded
816
+ // against, applied to the field-keyed destination.
817
+ async function appendChannelSecrets(cwd: string, channel: ChannelKind, tokens: ChannelSecrets): Promise<void> {
818
+ if (Object.keys(tokens).length === 0) return
819
+
820
+ if (!existsSync(join(cwd, CONFIG_FILE))) {
821
+ throw new Error(
822
+ `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` before adding channels, or run this command from inside an agent folder.`,
823
+ )
766
824
  }
767
825
 
768
- const presentKeys = parseEnvKeys(existing)
769
- for (const key of Object.keys(secrets)) {
770
- if (presentKeys.has(key)) {
826
+ const backend = new SecretsBackend(join(cwd, 'secrets.json'))
827
+ const channels: Record<string, unknown> = backend.readChannelsSync()
828
+ const slot: Record<string, unknown> = isObjectRecord(channels[channel])
829
+ ? { ...(channels[channel] as Record<string, unknown>) }
830
+ : {}
831
+
832
+ for (const field of Object.keys(tokens)) {
833
+ if (slot[field] !== undefined) {
771
834
  throw new Error(
772
- `${key} is already set in ${SECRETS_FILE}. Remove it before re-adding the channel, or edit the value by hand.`,
835
+ `${field} is already set in secrets.json under "${channel}". Remove it before re-adding the channel, or edit the value by hand.`,
773
836
  )
774
837
  }
775
838
  }
776
-
777
- // Ensure exactly one trailing newline before our appended block so the
778
- // resulting file remains POSIX-clean even if the user's editor stripped it.
779
- const trailingNewline = existing.endsWith('\n') || existing === '' ? '' : '\n'
780
- const appended = Object.entries(secrets)
781
- .map(([k, v]) => `${k}=${v}`)
782
- .join('\n')
783
- await writeFile(path, `${existing}${trailingNewline}${appended}\n`)
784
- }
785
-
786
- function parseEnvKeys(content: string): Set<string> {
787
- const keys = new Set<string>()
788
- for (const rawLine of content.split('\n')) {
789
- const line = rawLine.trim()
790
- if (line === '' || line.startsWith('#')) continue
791
- const eq = line.indexOf('=')
792
- if (eq <= 0) continue
793
- keys.add(line.slice(0, eq).trim())
794
- }
795
- return keys
839
+ for (const [k, v] of Object.entries(tokens)) slot[k] = { value: v } satisfies Secret
840
+ channels[channel] = slot
841
+ backend.writeChannelsSync(channels as Channels)
796
842
  }
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from 'node:module'
2
2
  import { join } from 'node:path'
3
3
 
4
- import { KakaoCredentialManager } from 'agent-messenger/kakaotalk'
4
+ import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
5
5
 
6
6
  export type KakaotalkBootstrapStatus = { ok: true } | { ok: false; reason: string }
7
7
 
@@ -50,11 +50,17 @@ export function kakaotalkConfigDir(agentDir: string): string {
50
50
  return join(agentDir, 'workspace', '.agent-messenger')
51
51
  }
52
52
 
53
+ export function kakaotalkSecretsPath(agentDir: string): string {
54
+ return join(agentDir, 'secrets.json')
55
+ }
56
+
53
57
  export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise<KakaotalkBootstrapStatus> {
54
- const configDir = kakaotalkConfigDir(input.agentDir)
55
58
  try {
56
59
  const loginFlow = input.loginFlow ?? (await resolveLoginFlow())
57
- const credManager = new KakaoCredentialManager(configDir)
60
+ const credManager = new SecretsKakaoCredentialStore({
61
+ mode: 'host',
62
+ secretsPath: kakaotalkSecretsPath(input.agentDir),
63
+ })
58
64
  const pending = await credManager.loadPendingLogin()
59
65
  const existing = await credManager.getAccount()
60
66
  const savedDeviceUuid =
@@ -34,3 +34,37 @@ export async function runBunInstall(cwd: string): Promise<InstallResult> {
34
34
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
35
35
  }
36
36
  }
37
+
38
+ // Signature for the function that re-resolves a SINGLE dep against its
39
+ // current spec. Distinct from InstallRunner because the auto-upgrade path
40
+ // MUST force re-resolution against the registry (lockfile-honoring `bun
41
+ // install` would no-op when the existing lockfile entry already satisfies
42
+ // an in-range spec — which is the exact regression auto-upgrade exists to
43
+ // prevent).
44
+ export type UpdateRunner = (cwd: string, pkg: string) => Promise<InstallResult>
45
+
46
+ export async function runBunUpdate(cwd: string, pkg: string): Promise<InstallResult> {
47
+ const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
48
+ if (!bun) return { ok: false, reason: 'bun runtime not available' }
49
+ try {
50
+ const proc = bun.spawn({
51
+ // `bun update <pkg> --latest` re-resolves <pkg> against the registry,
52
+ // capped by the spec in package.json. For a caret/tilde range this
53
+ // pulls the highest in-range version (the case `bun install` won't
54
+ // upgrade because the lockfile already satisfies the spec). For an
55
+ // exact pin it's effectively a force re-fetch of that exact version.
56
+ // `--linker=hoisted` for the same Bun 1.3.x deadlock reason as
57
+ // runBunInstall above.
58
+ cmd: ['bun', 'update', pkg, '--latest', '--linker=hoisted'],
59
+ cwd,
60
+ stdout: 'pipe',
61
+ stderr: 'pipe',
62
+ })
63
+ const code = await proc.exited
64
+ if (code === 0) return { ok: true }
65
+ const stderr = await new Response(proc.stderr).text()
66
+ return { ok: false, reason: `bun update ${pkg} exited with code ${code}: ${stderr.trim() || 'no stderr'}` }
67
+ } catch (error) {
68
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
69
+ }
70
+ }
@@ -3,6 +3,7 @@ import backupPlugin from '@/bundled-plugins/backup'
3
3
  import guardPlugin from '@/bundled-plugins/guard'
4
4
  import memoryPlugin from '@/bundled-plugins/memory'
5
5
  import securityPlugin from '@/bundled-plugins/security'
6
+ import toolResultCapPlugin from '@/bundled-plugins/tool-result-cap'
6
7
  import type { ResolvedPlugin } from '@/plugin'
7
8
 
8
9
  // Consumed by both `startAgent` (auto-loaded before user plugins) AND
@@ -18,6 +19,11 @@ import type { ResolvedPlugin } from '@/plugin'
18
19
  // guard disjoint surfaces, but seeding the order now means future overlap
19
20
  // (e.g. a security policy on writes) blocks before guard's softer advice.
20
21
  //
22
+ // `tool-result-cap` is registered before `guard` so guard's `tool.after`
23
+ // advice (uncommitted-changes warning) appends to already-capped content.
24
+ // Reversing this order would make guard advise on the full oversized payload
25
+ // and then tool-result-cap would clobber the advice text along with the rest.
26
+ //
21
27
  // `memory` is registered before `backup` so memory's dreaming commits always
22
28
  // land in the same git index window before backup's commit-and-push cycle.
23
29
  // They commit disjoint paths today (memory/ vs sessions/ + agent changes),
@@ -25,6 +31,7 @@ import type { ResolvedPlugin } from '@/plugin'
25
31
  // contention easier to reason about.
26
32
  export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
27
33
  { name: 'security', version: undefined, source: '<bundled>', defined: securityPlugin },
34
+ { name: 'tool-result-cap', version: undefined, source: '<bundled>', defined: toolResultCapPlugin },
28
35
  { name: 'guard', version: undefined, source: '<bundled>', defined: guardPlugin },
29
36
  { name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
30
37
  { name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
package/src/run/index.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  import { loadPlugins, type LoadPluginsResult, pluginCronJobs, type PluginRegistry, summarizeLoaded } from '@/plugin'
26
26
  import { createContainerBroker, publishForwardResult } from '@/portbroker'
27
27
  import { ReloadRegistry } from '@/reload'
28
+ import { hydrateChannelEnvFromSecrets } from '@/secrets'
28
29
  import { createServer, type Server } from '@/server'
29
30
  import { createSessionFactory, type SessionFactory } from '@/sessions'
30
31
  import { createStream, type Stream } from '@/stream'
@@ -119,6 +120,14 @@ export async function startAgent({
119
120
  materializedSkills: null,
120
121
  })
121
122
 
123
+ // Channel adapters read `process.env[TOKEN_ENV]` (see channels/manager.ts).
124
+ // Hydrate fills any unset env var from secrets.json#channels via env-wins:
125
+ // values already in process.env (from `docker --env-file .env`) are kept
126
+ // as-is; missing ones get the resolved Secret value injected. The pre-v2
127
+ // auto-promotion from .env to secrets.json has been removed — env values
128
+ // stay in env, the file stays user-owned. See src/secrets/hydrate.ts.
129
+ hydrateChannelEnvFromSecrets({ agentDir: cwd })
130
+
122
131
  const channelManager = createChannelManager({
123
132
  agentDir: cwd,
124
133
  channelsConfigRef: () => getConfig().channels,
@@ -0,0 +1,67 @@
1
+ import { KNOWN_PROVIDERS, type KnownProviderId } from '@/config/providers'
2
+
3
+ // DEFAULT_ENV_NAMES is the single source of truth for the env-var name each
4
+ // secret-bearing field uses when the user does not override it via the `env`
5
+ // field of a `Secret` object. Three layers depend on it:
6
+ //
7
+ // 1. resolveSecret (src/secrets/resolve.ts) — when the on-disk Secret has
8
+ // no explicit `env`, it falls back to this table to know which env var
9
+ // to consult for env-wins resolution.
10
+ // 2. hydrateChannelEnvFromSecrets (src/secrets/hydrate.ts) — when injecting
11
+ // resolved channel field values into `process.env`, it uses these names
12
+ // so that `src/channels/manager.ts` (which reads `env.DISCORD_BOT_TOKEN`
13
+ // etc. directly) keeps working without per-adapter refactoring.
14
+ // 3. parseSecretsFile legacy upgrade — when reading a v1 file with the old
15
+ // `{ ENV_NAME: value }` channel shape, it inverts this table to rename
16
+ // the keys to the new per-adapter field names.
17
+ //
18
+ // Providers come from `KNOWN_PROVIDERS[id].apiKeyEnv` — derived, not duplicated.
19
+ // OAuth-only providers are intentionally absent: OAuth credentials are not
20
+ // env-injectable (refresh tokens are stateful).
21
+
22
+ export const CHANNEL_FIELD_ENV = {
23
+ 'discord-bot': { token: 'DISCORD_BOT_TOKEN' },
24
+ 'slack-bot': { botToken: 'SLACK_BOT_TOKEN', appToken: 'SLACK_APP_TOKEN' },
25
+ 'telegram-bot': { token: 'TELEGRAM_BOT_TOKEN' },
26
+ } as const satisfies Record<string, Record<string, string>>
27
+
28
+ export type KnownAdapterId = keyof typeof CHANNEL_FIELD_ENV
29
+
30
+ export function isKnownAdapterId(id: string): id is KnownAdapterId {
31
+ return id in CHANNEL_FIELD_ENV
32
+ }
33
+
34
+ // Reverse map: env-var name -> { adapterId, fieldName }. Built from
35
+ // CHANNEL_FIELD_ENV so adding a new adapter field updates both directions
36
+ // automatically. Used exclusively by the legacy v1 channels-shape upgrade.
37
+ export const CHANNEL_ENV_TO_FIELD: Record<string, { adapterId: KnownAdapterId; fieldName: string }> = (() => {
38
+ const out: Record<string, { adapterId: KnownAdapterId; fieldName: string }> = {}
39
+ for (const [adapterId, fields] of Object.entries(CHANNEL_FIELD_ENV)) {
40
+ for (const [fieldName, envName] of Object.entries(fields)) {
41
+ out[envName] = { adapterId: adapterId as KnownAdapterId, fieldName }
42
+ }
43
+ }
44
+ return out
45
+ })()
46
+
47
+ // Returns the default env-var name for a known channel field, or undefined
48
+ // when the adapter or field is not in CHANNEL_FIELD_ENV (forward-compat: a
49
+ // future adapter contributed via plugin would not appear in this table).
50
+ export function channelFieldDefaultEnv(adapterId: string, fieldName: string): string | undefined {
51
+ if (!isKnownAdapterId(adapterId)) return undefined
52
+ const adapterFields = CHANNEL_FIELD_ENV[adapterId] as Record<string, string>
53
+ return adapterFields[fieldName]
54
+ }
55
+
56
+ // Returns the canonical env-var name for an api-key provider, or undefined
57
+ // when the provider is OAuth-only (apiKeyEnv === null in KNOWN_PROVIDERS).
58
+ // OAuth-only providers never participate in env-wins resolution.
59
+ export function providerKeyDefaultEnv(providerId: string): string | undefined {
60
+ const provider = (KNOWN_PROVIDERS as Record<string, { apiKeyEnv: string | null }>)[providerId]
61
+ if (!provider) return undefined
62
+ return provider.apiKeyEnv ?? undefined
63
+ }
64
+
65
+ export function isKnownProviderId(id: string): id is KnownProviderId {
66
+ return id in KNOWN_PROVIDERS
67
+ }
@@ -0,0 +1,99 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { channelFieldDefaultEnv } from './defaults'
5
+ import { resolveSecret, secretFieldSchema, type Secret } from './resolve'
6
+ import { parseSecretsFile } from './schema'
7
+
8
+ // hydrateChannelEnvFromSecrets is the seam that lets channel adapters keep
9
+ // reading `process.env[TOKEN_ENV]` (in `src/channels/manager.ts`) without
10
+ // knowing about the new per-adapter Secret-typed config shape. Boot flow:
11
+ //
12
+ // 1. Read secrets.json#channels. Each field is a Secret (string shorthand
13
+ // or `{ value?, env? }` object).
14
+ // 2. For each (adapter, field) pair, look up the default env-var name via
15
+ // CHANNEL_FIELD_ENV (e.g. slack-bot.botToken -> SLACK_BOT_TOKEN).
16
+ // 3. Resolve the Secret via env-wins: if the target env var is already
17
+ // set, do nothing (env wins, intentional, by design). Otherwise inject
18
+ // the resolved file value into process.env under the default env name.
19
+ //
20
+ // Three explicit non-behaviors versus the pre-v2 implementation:
21
+ // - We DO NOT strip `.env` after injecting. Env values stay in `.env`; the
22
+ // boot-time file mutation that previously erased migrated keys is gone.
23
+ // The user's `.env` is treated as a first-class source, not a one-way
24
+ // migration channel.
25
+ // - We DO NOT promote env values into secrets.json. The old
26
+ // `promoteChannelEnvIntoSecrets` step has been deleted as part of the
27
+ // env-wins reshape. If the user wants the value in the file, they put it
28
+ // there explicitly (init writes it, or a manual edit).
29
+ // - We DO NOT touch unknown adapter ids (no entry in CHANNEL_FIELD_ENV)
30
+ // or unknown field names. Skipped silently. A future plugin adapter
31
+ // would need its own injection mechanism; the field-name-keyed shape is
32
+ // reserved for the curated set in CHANNEL_FIELD_ENV.
33
+ //
34
+ // Errors are non-fatal: a missing or malformed `secrets.json` returns an
35
+ // empty result rather than throwing, so an agent that hasn't run init yet
36
+ // can still boot.
37
+ export function hydrateChannelEnvFromSecrets(options: { agentDir: string; env?: NodeJS.ProcessEnv }): {
38
+ applied: string[]
39
+ skipped: string[]
40
+ } {
41
+ const env = options.env ?? process.env
42
+ const secretsPath = join(options.agentDir, 'secrets.json')
43
+ const channels = readChannelSecrets(secretsPath)
44
+
45
+ const applied: string[] = []
46
+ const skipped: string[] = []
47
+
48
+ for (const [adapterId, fields] of Object.entries(channels)) {
49
+ for (const [fieldName, secret] of Object.entries(fields)) {
50
+ const envName = channelFieldDefaultEnv(adapterId, fieldName)
51
+ if (envName === undefined) continue
52
+
53
+ const existing = env[envName]
54
+ if (existing !== undefined && existing !== '') {
55
+ skipped.push(envName)
56
+ continue
57
+ }
58
+
59
+ const resolved = resolveSecret(secret, envName, env)
60
+ if (resolved === undefined) continue
61
+
62
+ env[envName] = resolved
63
+ applied.push(envName)
64
+ }
65
+ }
66
+
67
+ return { applied, skipped }
68
+ }
69
+
70
+ function readChannelSecrets(secretsPath: string): Record<string, Record<string, Secret>> {
71
+ let raw: string
72
+ try {
73
+ raw = readFileSync(secretsPath, 'utf8')
74
+ } catch {
75
+ return {}
76
+ }
77
+ if (raw.trim() === '') return {}
78
+ let parsed: unknown
79
+ try {
80
+ parsed = JSON.parse(raw)
81
+ } catch {
82
+ return {}
83
+ }
84
+ const result = parseSecretsFile(parsed)
85
+ if (!result.ok) return {}
86
+
87
+ const out: Record<string, Record<string, Secret>> = {}
88
+ for (const [adapterId, slot] of Object.entries(result.file.channels)) {
89
+ if (typeof slot !== 'object' || slot === null || Array.isArray(slot)) continue
90
+ const slotRecord = slot as Record<string, unknown>
91
+ const fields: Record<string, Secret> = {}
92
+ for (const [fieldName, value] of Object.entries(slotRecord)) {
93
+ const ok = secretFieldSchema.safeParse(value)
94
+ if (ok.success) fields[fieldName] = ok.data
95
+ }
96
+ if (Object.keys(fields).length > 0) out[adapterId] = fields
97
+ }
98
+ return out
99
+ }
@@ -1,15 +1,9 @@
1
- export {
2
- channelsSchema,
3
- llmCredentialSchema,
4
- llmCredentialsSchema,
5
- parseSecretsFile,
6
- secretsFileSchema,
7
- type LlmCredential,
8
- type LlmCredentials,
9
- type ParseSecretsResult,
10
- type SecretsFile,
11
- } from './schema'
1
+ export { type Channels } from './schema'
12
2
 
13
3
  export { createSecretsStoreForAgent, SecretsBackend } from './storage'
14
4
 
15
- export { stripEnvKey } from './env'
5
+ export { type Secret } from './resolve'
6
+
7
+ export { hydrateChannelEnvFromSecrets } from './hydrate'
8
+
9
+ export { migrateKakaotalkCredentials } from './migrate-kakaotalk'