typeclaw 0.1.1 → 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 (74) hide show
  1. package/README.md +16 -12
  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/doctor.ts +173 -0
  7. package/src/agent/subagents.ts +24 -2
  8. package/src/agent/tools/channel-fetch-attachment.ts +6 -0
  9. package/src/agent/tools/channel-history.ts +10 -1
  10. package/src/agent/tools/channel-log.ts +32 -0
  11. package/src/agent/tools/channel-reply.ts +18 -1
  12. package/src/agent/tools/channel-send.ts +13 -1
  13. package/src/bundled-plugins/backup/README.md +81 -0
  14. package/src/bundled-plugins/backup/index.ts +209 -0
  15. package/src/bundled-plugins/backup/runner.ts +231 -0
  16. package/src/bundled-plugins/backup/subagents.ts +200 -0
  17. package/src/bundled-plugins/memory/index.ts +42 -1
  18. package/src/bundled-plugins/tool-result-cap/README.md +67 -0
  19. package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
  20. package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
  21. package/src/channels/adapters/kakaotalk.ts +25 -16
  22. package/src/channels/manager.ts +47 -38
  23. package/src/channels/router.ts +29 -0
  24. package/src/cli/channel.ts +3 -3
  25. package/src/cli/compose.ts +92 -1
  26. package/src/cli/doctor.ts +100 -0
  27. package/src/cli/index.ts +4 -0
  28. package/src/cli/init.ts +2 -1
  29. package/src/cli/ui.ts +11 -0
  30. package/src/compose/doctor.ts +141 -0
  31. package/src/compose/index.ts +8 -0
  32. package/src/compose/logs.ts +32 -19
  33. package/src/config/config.ts +31 -0
  34. package/src/container/log-colors.ts +75 -0
  35. package/src/container/log-timestamps.ts +84 -0
  36. package/src/container/logs.ts +71 -5
  37. package/src/container/start.ts +113 -9
  38. package/src/cron/consumer.ts +29 -7
  39. package/src/doctor/checks.ts +426 -0
  40. package/src/doctor/commit.ts +71 -0
  41. package/src/doctor/index.ts +287 -0
  42. package/src/doctor/plugin-bridge.ts +147 -0
  43. package/src/doctor/report.ts +142 -0
  44. package/src/doctor/types.ts +87 -0
  45. package/src/hostd/daemon.ts +28 -3
  46. package/src/hostd/protocol.ts +7 -0
  47. package/src/init/auto-upgrade.ts +368 -0
  48. package/src/init/cli-version.ts +81 -0
  49. package/src/init/dockerfile.ts +234 -25
  50. package/src/init/index.ts +141 -87
  51. package/src/init/kakaotalk-auth.ts +9 -3
  52. package/src/init/run-bun-install.ts +34 -0
  53. package/src/plugin/hooks.ts +32 -0
  54. package/src/plugin/index.ts +7 -0
  55. package/src/plugin/manager.ts +2 -0
  56. package/src/plugin/registry.ts +32 -3
  57. package/src/plugin/types.ts +65 -0
  58. package/src/run/bundled-plugins.ts +15 -0
  59. package/src/run/index.ts +19 -5
  60. package/src/secrets/defaults.ts +67 -0
  61. package/src/secrets/hydrate.ts +99 -0
  62. package/src/secrets/index.ts +6 -12
  63. package/src/secrets/kakao-store.ts +129 -0
  64. package/src/secrets/migrate-kakaotalk.ts +82 -0
  65. package/src/secrets/migrate.ts +5 -4
  66. package/src/secrets/resolve.ts +57 -0
  67. package/src/secrets/schema.ts +162 -42
  68. package/src/secrets/storage.ts +253 -47
  69. package/src/server/index.ts +103 -5
  70. package/src/shared/index.ts +3 -0
  71. package/src/shared/protocol.ts +22 -0
  72. package/src/skills/typeclaw-config/SKILL.md +48 -9
  73. package/typeclaw.schema.json +84 -0
  74. package/src/secrets/env.ts +0 -43
@@ -97,6 +97,24 @@ export type SessionIdleEvent = {
97
97
  origin?: SessionOrigin
98
98
  }
99
99
 
100
+ // Brackets every `session.prompt(...)` invocation. Distinct from
101
+ // `session.start`/`session.end` (which bracket session lifetime) so that
102
+ // long-lived TUI or channel sessions, which can sit idle between turns,
103
+ // don't wedge a turn-counter forever. `origin` carries the session's origin
104
+ // so observers can exclude their own induced turns when counting (e.g. the
105
+ // backup plugin excludes `subagent: 'backup'` to avoid self-gating).
106
+ export type SessionTurnStartEvent = {
107
+ sessionId: string
108
+ agentDir: string
109
+ origin?: SessionOrigin
110
+ }
111
+
112
+ export type SessionTurnEndEvent = {
113
+ sessionId: string
114
+ agentDir: string
115
+ origin?: SessionOrigin
116
+ }
117
+
100
118
  // Provider prompt caching requires byte-identical prefixes. Mutations near the
101
119
  // end of `event.prompt` preserve cache hits across sessions; mutations near
102
120
  // the start invalidate the cache on every LLM call.
@@ -136,6 +154,8 @@ export type Hooks = {
136
154
  'session.end'?: (event: SessionEndEvent, ctx: HookContext) => Promise<void> | void
137
155
  'session.idle'?: (event: SessionIdleEvent, ctx: HookContext) => Promise<void> | void
138
156
  'session.prompt'?: (event: SessionPromptEvent, ctx: HookContext) => Promise<void> | void
157
+ 'session.turn.start'?: (event: SessionTurnStartEvent, ctx: HookContext) => Promise<void> | void
158
+ 'session.turn.end'?: (event: SessionTurnEndEvent, ctx: HookContext) => Promise<void> | void
139
159
  'tool.before'?: (event: ToolBeforeEvent, ctx: HookContext) => Promise<ToolBeforeResult> | ToolBeforeResult
140
160
  'tool.after'?: (event: ToolAfterEvent, ctx: HookContext) => Promise<void> | void
141
161
  }
@@ -164,6 +184,51 @@ export type PluginExports = {
164
184
  skills?: Record<string, PluginSkill>
165
185
  skillsDirs?: string[]
166
186
  hooks?: Hooks
187
+ doctorChecks?: Record<string, PluginDoctorCheck>
188
+ }
189
+
190
+ // `typeclaw doctor` plugin extension surface. Each check is read-only by
191
+ // default; declaring `fix.apply` opts the check into `typeclaw doctor --fix`,
192
+ // where the host serializes plugin fixes, validates their `changedPaths`
193
+ // against the agent folder, and commits the union of all fixes in a single
194
+ // commit.
195
+ export type PluginDoctorCheck = {
196
+ description: string
197
+ category?: string
198
+ run: (ctx: PluginDoctorContext) => Promise<PluginCheckResult>
199
+ }
200
+
201
+ export type PluginDoctorContext = {
202
+ readonly pluginName: string
203
+ readonly agentDir: string
204
+ readonly config: unknown
205
+ readonly logger: PluginLogger
206
+ }
207
+
208
+ export type PluginCheckStatus = 'ok' | 'warning' | 'error'
209
+
210
+ export type PluginCheckResult = {
211
+ status: PluginCheckStatus
212
+ message: string
213
+ details?: string[]
214
+ fix?: PluginFixSuggestion
215
+ }
216
+
217
+ export type PluginFixSuggestion = {
218
+ description: string
219
+ // When omitted, the fix is advisory-only. `typeclaw doctor --fix` only
220
+ // attempts to remediate checks whose suggestion includes an `apply`.
221
+ apply?: (ctx: PluginDoctorContext) => Promise<PluginFixResult>
222
+ }
223
+
224
+ export type PluginFixResult = {
225
+ // One-line description that appears in the commit body as a bullet.
226
+ summary: string
227
+ // POSIX paths relative to agentDir; the host validates each one stays
228
+ // inside agentDir before `git add`ing. Absolute paths and `..` segments
229
+ // are rejected to keep plugin fixes from staging files outside the agent
230
+ // folder. Empty array is valid (e.g. a fix that only logs).
231
+ changedPaths: string[]
167
232
  }
168
233
 
169
234
  export type DefinedPlugin<TConfig = never> = {
@@ -1,7 +1,9 @@
1
1
  import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
2
+ import backupPlugin from '@/bundled-plugins/backup'
2
3
  import guardPlugin from '@/bundled-plugins/guard'
3
4
  import memoryPlugin from '@/bundled-plugins/memory'
4
5
  import securityPlugin from '@/bundled-plugins/security'
6
+ import toolResultCapPlugin from '@/bundled-plugins/tool-result-cap'
5
7
  import type { ResolvedPlugin } from '@/plugin'
6
8
 
7
9
  // Consumed by both `startAgent` (auto-loaded before user plugins) AND
@@ -16,9 +18,22 @@ import type { ResolvedPlugin } from '@/plugin'
16
18
  // Letting `guard` run first would still work today since the two plugins
17
19
  // guard disjoint surfaces, but seeding the order now means future overlap
18
20
  // (e.g. a security policy on writes) blocks before guard's softer advice.
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
+ //
27
+ // `memory` is registered before `backup` so memory's dreaming commits always
28
+ // land in the same git index window before backup's commit-and-push cycle.
29
+ // They commit disjoint paths today (memory/ vs sessions/ + agent changes),
30
+ // but if either ever holds .git/index.lock the deterministic order makes the
31
+ // contention easier to reason about.
19
32
  export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
20
33
  { name: 'security', version: undefined, source: '<bundled>', defined: securityPlugin },
34
+ { name: 'tool-result-cap', version: undefined, source: '<bundled>', defined: toolResultCapPlugin },
21
35
  { name: 'guard', version: undefined, source: '<bundled>', defined: guardPlugin },
22
36
  { name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
37
+ { name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
23
38
  { name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
24
39
  ]
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,
@@ -142,14 +151,15 @@ export async function startAgent({
142
151
  const entry = snap.pluginSubagentByShim.get(subagent)
143
152
  if (entry) {
144
153
  const sessionId = `subagent-${entry.pluginName}-${crypto.randomUUID()}`
154
+ const origin = {
155
+ kind: 'subagent' as const,
156
+ subagent: subagentOptions?.name ?? entry.subagentName,
157
+ parentSessionId: subagentOptions?.parentSessionId ?? '<unknown>',
158
+ }
145
159
  const created = await createSessionWithDispose({
146
160
  systemPromptOverride: entry.pluginSubagent.systemPrompt,
147
161
  channelRouter: channelManager.router,
148
- origin: {
149
- kind: 'subagent',
150
- subagent: subagentOptions?.name ?? entry.subagentName,
151
- parentSessionId: subagentOptions?.parentSessionId ?? '<unknown>',
152
- },
162
+ origin,
153
163
  plugins: {
154
164
  registry: snap.registry,
155
165
  hooks: snap.hooks,
@@ -167,6 +177,8 @@ export async function startAgent({
167
177
  ...created,
168
178
  hooks: snap.hooks,
169
179
  sessionId,
180
+ agentDir: cwd,
181
+ origin,
170
182
  }
171
183
  }
172
184
  return defaultCreateSessionForSubagent(subagent, subagentOptions)
@@ -221,6 +233,8 @@ export async function startAgent({
221
233
  prompt: (text) => session.prompt(text),
222
234
  dispose: () => session.dispose(),
223
235
  sessionId,
236
+ agentDir: cwd,
237
+ origin: { kind: 'cron' as const, jobId: job.id, jobKind: 'prompt' as const },
224
238
  ...(snap.hasAnyPluginContent ? { hooks: snap.hooks } : {}),
225
239
  getTranscriptPath: () => sessionManager.getSessionFile(),
226
240
  }
@@ -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'
@@ -0,0 +1,129 @@
1
+ import type { KakaoAccountCredentials, KakaoConfig } from 'agent-messenger/kakaotalk'
2
+ import type { PendingLoginState } from 'agent-messenger/kakaotalk'
3
+
4
+ import { sendHttp } from '@/hostd/client'
5
+
6
+ import { type KakaoChannelBlock, kakaoChannelBlockSchema } from './schema'
7
+ import { SecretsBackend } from './storage'
8
+
9
+ export type SecretsKakaoCredentialStoreOptions =
10
+ | { mode: 'host'; secretsPath: string }
11
+ | { mode: 'container'; secretsPath: string; hostdUrl: string; restartToken: string; containerName: string }
12
+
13
+ const EMPTY_BLOCK: KakaoChannelBlock = { currentAccount: null, accounts: {} }
14
+
15
+ export class SecretsKakaoCredentialStore {
16
+ private readonly backend: SecretsBackend
17
+ private writeChain: Promise<void> = Promise.resolve()
18
+
19
+ constructor(private readonly options: SecretsKakaoCredentialStoreOptions) {
20
+ this.backend = new SecretsBackend(options.secretsPath)
21
+ }
22
+
23
+ async load(): Promise<KakaoConfig> {
24
+ return toKakaoConfig(this.readBlock())
25
+ }
26
+
27
+ async save(config: KakaoConfig): Promise<void> {
28
+ await this.writeBlock(() => fromKakaoConfig(config))
29
+ }
30
+
31
+ async getAccount(id?: string): Promise<KakaoAccountCredentials | null> {
32
+ const config = await this.load()
33
+ if (id) return config.accounts[id] ?? null
34
+ if (!config.current_account) return null
35
+ return config.accounts[config.current_account] ?? null
36
+ }
37
+
38
+ async setAccount(account: KakaoAccountCredentials): Promise<void> {
39
+ await this.writeBlock((block) => {
40
+ const accounts = { ...block.accounts, [account.account_id]: account }
41
+ return { ...block, currentAccount: block.currentAccount ?? account.account_id, accounts }
42
+ })
43
+ }
44
+
45
+ async removeAccount(id: string): Promise<void> {
46
+ await this.writeBlock((block) => {
47
+ const accounts = { ...block.accounts }
48
+ delete accounts[id]
49
+ const currentAccount = block.currentAccount === id ? (Object.keys(accounts)[0] ?? null) : block.currentAccount
50
+ return { ...block, currentAccount, accounts }
51
+ })
52
+ }
53
+
54
+ async listAccounts(): Promise<Array<KakaoAccountCredentials & { is_current: boolean }>> {
55
+ const config = await this.load()
56
+ return Object.values(config.accounts).map((account) => ({
57
+ ...account,
58
+ is_current: account.account_id === config.current_account,
59
+ }))
60
+ }
61
+
62
+ async setCurrentAccount(id: string): Promise<void> {
63
+ await this.writeBlock((block) => ({ ...block, currentAccount: id }))
64
+ }
65
+
66
+ async savePendingLogin(state: PendingLoginState): Promise<void> {
67
+ await this.writeBlock((block) => ({ ...block, pendingLogin: state }))
68
+ }
69
+
70
+ async loadPendingLogin(): Promise<PendingLoginState | null> {
71
+ return this.readBlock().pendingLogin ?? null
72
+ }
73
+
74
+ async clearPendingLogin(): Promise<void> {
75
+ await this.writeBlock((block) => {
76
+ const { pendingLogin: _pendingLogin, ...next } = block
77
+ return next
78
+ })
79
+ }
80
+
81
+ private readBlock(): KakaoChannelBlock {
82
+ const channels =
83
+ this.options.mode === 'container' ? this.backend.tryReadChannelsSync() : this.backend.readChannelsSync()
84
+ const raw = channels?.kakaotalk
85
+ return parseBlock(raw)
86
+ }
87
+
88
+ private async writeBlock(update: (current: KakaoChannelBlock) => KakaoChannelBlock): Promise<void> {
89
+ return this.enqueueWrite(async () => {
90
+ if (this.options.mode === 'container') {
91
+ const next = update(this.readBlock())
92
+ const response = await sendHttp(
93
+ {
94
+ kind: 'secrets-patch',
95
+ containerName: this.options.containerName,
96
+ patch: { channels: { kakaotalk: next } },
97
+ },
98
+ { url: this.options.hostdUrl, token: this.options.restartToken },
99
+ )
100
+ if (!response.ok) throw new Error(`secrets-patch failed: ${response.reason}`)
101
+ return
102
+ }
103
+
104
+ await this.backend.updateChannelsAsync(async (channels) => {
105
+ const next = { ...channels, kakaotalk: update(parseBlock(channels.kakaotalk)) }
106
+ return { result: undefined, next }
107
+ })
108
+ })
109
+ }
110
+
111
+ private enqueueWrite(op: () => Promise<void>): Promise<void> {
112
+ const next = this.writeChain.then(op, op)
113
+ this.writeChain = next.catch(() => {})
114
+ return next
115
+ }
116
+ }
117
+
118
+ function parseBlock(value: unknown): KakaoChannelBlock {
119
+ if (value === undefined) return EMPTY_BLOCK
120
+ return kakaoChannelBlockSchema.parse(value)
121
+ }
122
+
123
+ function toKakaoConfig(block: KakaoChannelBlock): KakaoConfig {
124
+ return { current_account: block.currentAccount, accounts: block.accounts }
125
+ }
126
+
127
+ function fromKakaoConfig(config: KakaoConfig): KakaoChannelBlock {
128
+ return { currentAccount: config.current_account, accounts: config.accounts }
129
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { rename } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ import { KakaoCredentialManager } from 'agent-messenger/kakaotalk'
6
+ import type { KakaoConfig, PendingLoginState } from 'agent-messenger/kakaotalk'
7
+
8
+ import { type KakaoChannelBlock, kakaoChannelBlockSchema } from './schema'
9
+ import { SecretsBackend } from './storage'
10
+
11
+ const KAKAO_CONFIG_DIR = join('workspace', '.agent-messenger')
12
+ const CREDENTIALS_FILE = 'kakaotalk-credentials.json'
13
+ const PENDING_LOGIN_FILE = 'kakaotalk-pending-login.json'
14
+
15
+ export type KakaotalkCredentialMigrationResult = { promoted: boolean }
16
+
17
+ export async function migrateKakaotalkCredentials(agentDir: string): Promise<KakaotalkCredentialMigrationResult> {
18
+ const configDir = join(agentDir, KAKAO_CONFIG_DIR)
19
+ const credentialsPath = join(configDir, CREDENTIALS_FILE)
20
+ const pendingLoginPath = join(configDir, PENDING_LOGIN_FILE)
21
+ if (!existsSync(credentialsPath) && !existsSync(pendingLoginPath)) return { promoted: false }
22
+
23
+ const secretsPath = join(agentDir, 'secrets.json')
24
+ const legacy = new KakaoCredentialManager(configDir)
25
+ const config = await legacy.load()
26
+ const pendingLogin = await legacy.loadPendingLogin()
27
+ if (Object.keys(config.accounts).length === 0 && pendingLogin === null) return { promoted: false }
28
+
29
+ const backend = new SecretsBackend(secretsPath)
30
+ const result = await backend.updateChannelsAsync(async (channels) => {
31
+ const existing = parseExistingBlock(channels.kakaotalk)
32
+ const next = mergeLegacyBlock(existing, config, pendingLogin)
33
+ if (next === existing) return { result: { promoted: false, renameCredentials: false, renamePending: false } }
34
+
35
+ return {
36
+ result: {
37
+ promoted: true,
38
+ renameCredentials: isEmptyBlock(existing) && Object.keys(config.accounts).length > 0,
39
+ renamePending: pendingLogin !== null && existing?.pendingLogin === undefined,
40
+ },
41
+ next: { ...channels, kakaotalk: next },
42
+ }
43
+ })
44
+ if (!result.promoted) return { promoted: false }
45
+
46
+ if (result.renameCredentials) await renameIfPresent(credentialsPath, `${credentialsPath}.migrated`)
47
+ if (result.renamePending) await renameIfPresent(pendingLoginPath, `${pendingLoginPath}.migrated`)
48
+ return { promoted: true }
49
+ }
50
+
51
+ function parseExistingBlock(value: unknown): KakaoChannelBlock | null {
52
+ if (value === undefined) return null
53
+ return kakaoChannelBlockSchema.parse(value)
54
+ }
55
+
56
+ function isEmptyBlock(block: KakaoChannelBlock | null): boolean {
57
+ return (
58
+ block === null ||
59
+ (block.currentAccount === null && Object.keys(block.accounts).length === 0 && block.pendingLogin === undefined)
60
+ )
61
+ }
62
+
63
+ function mergeLegacyBlock(
64
+ existing: KakaoChannelBlock | null,
65
+ config: KakaoConfig,
66
+ pendingLogin: PendingLoginState | null,
67
+ ): KakaoChannelBlock | null {
68
+ if (existing === null || isEmptyBlock(existing)) {
69
+ return {
70
+ currentAccount: config.current_account,
71
+ accounts: config.accounts,
72
+ ...(pendingLogin ? { pendingLogin } : {}),
73
+ }
74
+ }
75
+ if (pendingLogin === null || existing.pendingLogin !== undefined) return existing
76
+ return { ...existing, pendingLogin }
77
+ }
78
+
79
+ async function renameIfPresent(from: string, to: string): Promise<void> {
80
+ if (!existsSync(from)) return
81
+ await rename(from, to)
82
+ }
@@ -70,9 +70,10 @@ function renameWithRaceFallback(from: string, to: string): void {
70
70
  }
71
71
  }
72
72
 
73
- // "Empty envelope" = no actual credentials. Parsed shape with empty llm and
74
- // empty channels. We do NOT try to be clever about "approximately empty"
75
- // exact emptiness is the only safe auto-delete / auto-overwrite case.
73
+ // "Empty envelope" = no actual credentials. parseSecretsFile normalises both
74
+ // legacy v1 and current v2 to a v2-shaped SecretsFile, so we only check the
75
+ // v2 fields. We do NOT try to be clever about "approximately empty" — exact
76
+ // emptiness is the only safe auto-delete / auto-overwrite case.
76
77
  function isEmptyEnvelope(path: string): boolean {
77
78
  let raw: string
78
79
  try {
@@ -91,5 +92,5 @@ function isEmptyEnvelope(path: string): boolean {
91
92
 
92
93
  const result = parseSecretsFile(parsed)
93
94
  if (!result.ok) return false
94
- return Object.keys(result.file.llm).length === 0 && Object.keys(result.file.channels).length === 0
95
+ return Object.keys(result.file.providers).length === 0 && Object.keys(result.file.channels).length === 0
95
96
  }
@@ -0,0 +1,57 @@
1
+ import { z } from 'zod'
2
+
3
+ // A Secret is the on-disk shape for any env-injectable credential field.
4
+ // String shorthand is sugar for `{ value }`. The schema normalises to the
5
+ // object form at parse time so consumers only ever handle one shape, but
6
+ // writers MAY emit the string shorthand for the common no-custom-env case
7
+ // to keep `secrets.json` terse.
8
+ //
9
+ // Empty objects `{}` are rejected because they carry no information — the
10
+ // resolver would always return undefined for them and the file would silently
11
+ // fail to provide credentials at boot.
12
+ const secretObjectSchema = z
13
+ .object({
14
+ value: z.string().min(1).optional(),
15
+ env: z.string().min(1).optional(),
16
+ })
17
+ .refine((s) => s.value !== undefined || s.env !== undefined, {
18
+ message: 'Secret object must have at least one of `value` or `env`',
19
+ })
20
+
21
+ export const secretFieldSchema = z
22
+ .union([z.string().min(1), secretObjectSchema])
23
+ .transform((v) => (typeof v === 'string' ? { value: v } : v))
24
+
25
+ export type Secret = z.infer<typeof secretFieldSchema>
26
+
27
+ // Env-wins resolution. The single place env-vs-file precedence lives.
28
+ //
29
+ // Precedence (highest to lowest):
30
+ // 1. process.env[secret.env] — explicit binding wins
31
+ // 2. process.env[defaultEnv] — canonical env-var-name fallback
32
+ // 3. secret.value — on-disk value
33
+ // 4. undefined — caller decides (missing-credential error)
34
+ //
35
+ // Empty-string env values are treated as unset, matching the existing
36
+ // hydrate.ts policy (`env[key] !== '' `). This keeps `unset` and `set to ""`
37
+ // behaviorally identical for credentials, which is what every shell ecosystem
38
+ // converges on.
39
+ export function resolveSecret(
40
+ secret: Secret,
41
+ defaultEnv: string | undefined,
42
+ env: NodeJS.ProcessEnv,
43
+ ): string | undefined {
44
+ const envName = secret.env ?? defaultEnv
45
+ if (envName !== undefined) {
46
+ const fromEnv = env[envName]
47
+ if (fromEnv !== undefined && fromEnv !== '') return fromEnv
48
+ }
49
+ return secret.value
50
+ }
51
+
52
+ // Returns the env-var name that resolveSecret would consult for a given
53
+ // Secret + default. Used by doctor / diagnostics to report "if you want to
54
+ // override this, set $envName". Does NOT consult process.env — pure mapping.
55
+ export function effectiveEnvName(secret: Secret, defaultEnv: string | undefined): string | undefined {
56
+ return secret.env ?? defaultEnv
57
+ }