typeclaw 0.1.5 → 0.2.0

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 (128) hide show
  1. package/README.md +14 -12
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +7 -4
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +23 -9
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +799 -319
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +6 -1
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +8 -1
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +385 -12
  67. package/src/config/index.ts +7 -0
  68. package/src/config/models-mutation.ts +209 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +5 -0
  73. package/src/container/require-running.ts +33 -0
  74. package/src/container/start.ts +39 -58
  75. package/src/cron/consumer.ts +22 -2
  76. package/src/cron/index.ts +45 -4
  77. package/src/cron/schema.ts +104 -0
  78. package/src/doctor/checks.ts +50 -33
  79. package/src/git/system-commit.ts +103 -0
  80. package/src/hostd/daemon.ts +16 -0
  81. package/src/hostd/kakao-renewal-manager.ts +223 -0
  82. package/src/hostd/paths.ts +7 -0
  83. package/src/init/dockerfile.ts +32 -6
  84. package/src/init/index.ts +190 -61
  85. package/src/init/kakaotalk-auth.ts +18 -1
  86. package/src/init/models-dev.ts +26 -1
  87. package/src/init/run-owner-claim.ts +77 -0
  88. package/src/permissions/builtins.ts +70 -0
  89. package/src/permissions/grant.ts +99 -0
  90. package/src/permissions/index.ts +29 -0
  91. package/src/permissions/match-rule.ts +305 -0
  92. package/src/permissions/permissions.ts +196 -0
  93. package/src/permissions/resolve.ts +80 -0
  94. package/src/permissions/schema.ts +79 -0
  95. package/src/plugin/context.ts +8 -4
  96. package/src/plugin/define.ts +2 -0
  97. package/src/plugin/index.ts +2 -0
  98. package/src/plugin/manager.ts +41 -0
  99. package/src/plugin/registry.ts +9 -0
  100. package/src/plugin/types.ts +35 -1
  101. package/src/role-claim/client.ts +182 -0
  102. package/src/role-claim/code.ts +53 -0
  103. package/src/role-claim/controller.ts +194 -0
  104. package/src/role-claim/index.ts +19 -0
  105. package/src/role-claim/match-rule.ts +43 -0
  106. package/src/role-claim/pending.ts +100 -0
  107. package/src/run/channel-session-factory.ts +76 -5
  108. package/src/run/index.ts +55 -6
  109. package/src/secrets/encryption.ts +116 -0
  110. package/src/secrets/kakao-renewal.ts +248 -0
  111. package/src/secrets/kakao-store.ts +66 -7
  112. package/src/secrets/keys.ts +173 -0
  113. package/src/secrets/schema.ts +23 -0
  114. package/src/secrets/storage.ts +68 -0
  115. package/src/server/index.ts +122 -11
  116. package/src/shared/index.ts +4 -0
  117. package/src/shared/protocol.ts +27 -0
  118. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  119. package/src/skills/typeclaw-config/SKILL.md +38 -64
  120. package/src/skills/typeclaw-memory/SKILL.md +1 -1
  121. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  122. package/src/stream/types.ts +7 -1
  123. package/src/usage/aggregate.ts +117 -0
  124. package/src/usage/format.ts +30 -0
  125. package/src/usage/index.ts +68 -0
  126. package/src/usage/report.ts +354 -0
  127. package/src/usage/scan.ts +186 -0
  128. package/typeclaw.schema.json +57 -45
@@ -0,0 +1,116 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
2
+
3
+ // AES-256-GCM authenticated encryption for at-rest secrets. The threat model
4
+ // is narrow and conditional: "agent folder leaked but the effective TypeClaw
5
+ // home (default ~/.typeclaw/, overridable via TYPECLAW_HOME) did not." That
6
+ // covers accidental `git add secrets.json`, agent-folder backups, shared
7
+ // mounts that expose only the agent dir. It does NOT cover (a) full host
8
+ // compromise (the live OAuth tokens in the same secrets.json grant equivalent
9
+ // capability), (b) whole-$HOME backups that capture both the agent dir and
10
+ // ~/.typeclaw/, or (c) misconfiguration where TYPECLAW_HOME points inside the
11
+ // leaked scope (e.g. inside the agent folder itself).
12
+ //
13
+ // AAD binds the ciphertext to the specific (containerName, accountId, version)
14
+ // it was produced for, so a ciphertext copied between accounts or containers
15
+ // fails authentication on decrypt even if the same key happens to unlock both.
16
+
17
+ const ALGORITHM = 'AES-256-GCM' as const
18
+ const KEY_BYTES = 32
19
+ const IV_BYTES = 12
20
+ const AUTH_TAG_BYTES = 16
21
+ const ENVELOPE_VERSION = 1
22
+
23
+ export type EncryptedEnvelope = {
24
+ v: typeof ENVELOPE_VERSION
25
+ alg: typeof ALGORITHM
26
+ kid: string
27
+ iv: string
28
+ ciphertext: string
29
+ authTag: string
30
+ createdAt: string
31
+ }
32
+
33
+ export type EncryptionContext = {
34
+ containerName: string
35
+ accountId: string
36
+ }
37
+
38
+ export class EncryptionError extends Error {
39
+ constructor(
40
+ message: string,
41
+ public readonly code: 'decrypt_failed' | 'envelope_invalid' | 'key_size_invalid' | 'algorithm_unsupported',
42
+ ) {
43
+ super(message)
44
+ this.name = 'EncryptionError'
45
+ }
46
+ }
47
+
48
+ export function generateKey(): Buffer {
49
+ return randomBytes(KEY_BYTES)
50
+ }
51
+
52
+ export function fingerprintKey(key: Buffer): string {
53
+ if (key.length !== KEY_BYTES) {
54
+ throw new EncryptionError(`key must be ${KEY_BYTES} bytes, got ${key.length}`, 'key_size_invalid')
55
+ }
56
+ return `sha256:${createHash('sha256').update(key).digest('hex').slice(0, 16)}`
57
+ }
58
+
59
+ export function encrypt(plaintext: string, key: Buffer, context: EncryptionContext): EncryptedEnvelope {
60
+ if (key.length !== KEY_BYTES) {
61
+ throw new EncryptionError(`key must be ${KEY_BYTES} bytes, got ${key.length}`, 'key_size_invalid')
62
+ }
63
+ const iv = randomBytes(IV_BYTES)
64
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
65
+ cipher.setAAD(buildAad(context))
66
+ const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
67
+ const authTag = cipher.getAuthTag()
68
+ return {
69
+ v: ENVELOPE_VERSION,
70
+ alg: ALGORITHM,
71
+ kid: fingerprintKey(key),
72
+ iv: iv.toString('base64'),
73
+ ciphertext: ciphertext.toString('base64'),
74
+ authTag: authTag.toString('base64'),
75
+ createdAt: new Date().toISOString(),
76
+ }
77
+ }
78
+
79
+ export function decrypt(envelope: EncryptedEnvelope, key: Buffer, context: EncryptionContext): string {
80
+ if (envelope.v !== ENVELOPE_VERSION) {
81
+ throw new EncryptionError(`unsupported envelope version: ${envelope.v}`, 'envelope_invalid')
82
+ }
83
+ if (envelope.alg !== ALGORITHM) {
84
+ throw new EncryptionError(`unsupported algorithm: ${envelope.alg}`, 'algorithm_unsupported')
85
+ }
86
+ if (key.length !== KEY_BYTES) {
87
+ throw new EncryptionError(`key must be ${KEY_BYTES} bytes, got ${key.length}`, 'key_size_invalid')
88
+ }
89
+ const iv = Buffer.from(envelope.iv, 'base64')
90
+ if (iv.length !== IV_BYTES) {
91
+ throw new EncryptionError(`iv must be ${IV_BYTES} bytes, got ${iv.length}`, 'envelope_invalid')
92
+ }
93
+ const authTag = Buffer.from(envelope.authTag, 'base64')
94
+ if (authTag.length !== AUTH_TAG_BYTES) {
95
+ throw new EncryptionError(`authTag must be ${AUTH_TAG_BYTES} bytes, got ${authTag.length}`, 'envelope_invalid')
96
+ }
97
+ const ciphertext = Buffer.from(envelope.ciphertext, 'base64')
98
+ const decipher = createDecipheriv('aes-256-gcm', key, iv)
99
+ decipher.setAAD(buildAad(context))
100
+ decipher.setAuthTag(authTag)
101
+ try {
102
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()])
103
+ return plaintext.toString('utf8')
104
+ } catch (err) {
105
+ // GCM auth failure produces an opaque "Unsupported state or unable to
106
+ // authenticate data" — wrap it so callers can distinguish a wrong key /
107
+ // tampered blob from invariant violations on the envelope shape.
108
+ throw new EncryptionError(`decrypt failed: ${err instanceof Error ? err.message : String(err)}`, 'decrypt_failed')
109
+ }
110
+ }
111
+
112
+ // Changing this format breaks decryption of every previously-stored ciphertext.
113
+ // See the module-header threat-model comment for the binding rationale.
114
+ function buildAad(context: EncryptionContext): Buffer {
115
+ return Buffer.from(`typeclaw:kakaotalk-password:v1:${context.containerName}:${context.accountId}`, 'utf8')
116
+ }
@@ -0,0 +1,248 @@
1
+ import { createRequire } from 'node:module'
2
+ import { join } from 'node:path'
3
+
4
+ import type { KakaoDeviceType } from 'agent-messenger/kakaotalk'
5
+
6
+ import { decrypt, EncryptionError } from './encryption'
7
+ import { SecretsKakaoCredentialStore } from './kakao-store'
8
+ import { type KeyStore, KeyStoreError } from './keys'
9
+ import { type KakaoChannelBlock } from './schema'
10
+ import { SecretsBackend } from './storage'
11
+
12
+ // Mirrors KakaoLoginResult from agent-messenger/kakaotalk's types.d.ts. The
13
+ // upstream interface is not re-exported from the package root, so we declare
14
+ // the structural shape locally. If a future version adds new fields, this
15
+ // stays forward-compatible because we only read the ones declared here.
16
+ export type KakaoLoginResult = {
17
+ authenticated: boolean
18
+ next_action?: string
19
+ message?: string
20
+ warning?: string
21
+ account_id?: string
22
+ device_type?: KakaoDeviceType
23
+ user_id?: string
24
+ error?: string
25
+ }
26
+
27
+ export const RENEWAL_THRESHOLD_MS = 5 * 24 * 60 * 60 * 1000
28
+
29
+ // Hard ~7-day TTL on KakaoTalk sub-device tokens means renewal must happen
30
+ // before that wall. We refresh at >5 days old to leave a 2-day safety margin
31
+ // for cron skips (host asleep, daemon respawning, etc.) and to absorb any
32
+ // downward drift in KakaoTalk's actual TTL.
33
+
34
+ export type RenewalDecision =
35
+ | { kind: 'skip'; reason: 'no_account' | 'fresh_enough'; ageMs?: number }
36
+ | { kind: 'reauth_required'; reason: 'no_password' | 'no_email' | 'key_missing' | 'decrypt_failed'; message: string }
37
+ | { kind: 'should_renew'; account: AccountSnapshot; password: string }
38
+
39
+ export type AccountSnapshot = {
40
+ account_id: string
41
+ email: string
42
+ device_uuid: string
43
+ device_type: KakaoDeviceType
44
+ created_at: string
45
+ updated_at: string
46
+ }
47
+
48
+ export type RenewalAttempt =
49
+ | { kind: 'ok'; account_id: string; previousUpdatedAt: string; nextUpdatedAt: string }
50
+ | { kind: 'reauth_required'; account_id: string; reason: string; message: string }
51
+ | { kind: 'transient_failure'; account_id: string; reason: string }
52
+
53
+ export type AttemptLoginFn = (
54
+ email: string,
55
+ password: string,
56
+ deviceUuid: string,
57
+ deviceType: KakaoDeviceType,
58
+ forced: boolean,
59
+ ) => Promise<KakaoLoginResult & { credentials?: LoginCredentials }>
60
+
61
+ export type LoginCredentials = {
62
+ access_token: string
63
+ refresh_token: string
64
+ user_id: string
65
+ device_uuid: string
66
+ device_type: KakaoDeviceType
67
+ }
68
+
69
+ export type RenewalContext = {
70
+ containerName: string
71
+ agentDir: string
72
+ keyStore: KeyStore
73
+ now?: () => number
74
+ attemptLogin?: AttemptLoginFn
75
+ }
76
+
77
+ export async function decideRenewal(block: KakaoChannelBlock, ctx: RenewalContext): Promise<RenewalDecision> {
78
+ const accountId = block.currentAccount
79
+ if (!accountId) return { kind: 'skip', reason: 'no_account' }
80
+ const account = block.accounts[accountId]
81
+ if (!account) return { kind: 'skip', reason: 'no_account' }
82
+
83
+ const now = (ctx.now ?? Date.now)()
84
+ const ageMs = now - Date.parse(account.updated_at)
85
+ if (Number.isFinite(ageMs) && ageMs < RENEWAL_THRESHOLD_MS) {
86
+ return { kind: 'skip', reason: 'fresh_enough', ageMs }
87
+ }
88
+
89
+ if (!account.email) {
90
+ return {
91
+ kind: 'reauth_required',
92
+ reason: 'no_email',
93
+ message: `KakaoTalk account ${accountId} has no stored email — run \`typeclaw channel reauth kakaotalk\`.`,
94
+ }
95
+ }
96
+ if (!account.encryptedPassword) {
97
+ return {
98
+ kind: 'reauth_required',
99
+ reason: 'no_password',
100
+ message: `KakaoTalk account ${accountId} has no stored password — run \`typeclaw channel reauth kakaotalk\`.`,
101
+ }
102
+ }
103
+
104
+ let plaintextPassword: string
105
+ try {
106
+ const key = await ctx.keyStore.read(ctx.containerName)
107
+ plaintextPassword = decrypt(account.encryptedPassword, key, {
108
+ containerName: ctx.containerName,
109
+ accountId: account.account_id,
110
+ })
111
+ } catch (err) {
112
+ return classifyDecryptFailure(err, accountId)
113
+ }
114
+
115
+ return {
116
+ kind: 'should_renew',
117
+ account: {
118
+ account_id: account.account_id,
119
+ email: account.email,
120
+ device_uuid: account.device_uuid,
121
+ device_type: account.device_type,
122
+ created_at: account.created_at,
123
+ updated_at: account.updated_at,
124
+ },
125
+ password: plaintextPassword,
126
+ }
127
+ }
128
+
129
+ export async function renewCurrentAccount(
130
+ ctx: RenewalContext,
131
+ ): Promise<RenewalAttempt | { kind: 'skipped'; reason: string; ageMs?: number }> {
132
+ const secretsPath = join(ctx.agentDir, 'secrets.json')
133
+ const backend = new SecretsBackend(secretsPath)
134
+ const block = backend.readChannelsSync()?.kakaotalk
135
+ const parsed = parseBlockOrEmpty(block)
136
+ const decision = await decideRenewal(parsed, ctx)
137
+
138
+ if (decision.kind === 'skip') {
139
+ return {
140
+ kind: 'skipped',
141
+ reason: decision.reason,
142
+ ...(decision.ageMs !== undefined ? { ageMs: decision.ageMs } : {}),
143
+ }
144
+ }
145
+ if (decision.kind === 'reauth_required') {
146
+ return {
147
+ kind: 'reauth_required',
148
+ account_id: parsed.currentAccount ?? '',
149
+ reason: decision.reason,
150
+ message: decision.message,
151
+ }
152
+ }
153
+
154
+ const attemptLogin = ctx.attemptLogin ?? (await resolveAttemptLogin())
155
+ const result = await attemptLogin(
156
+ decision.account.email,
157
+ decision.password,
158
+ decision.account.device_uuid,
159
+ decision.account.device_type,
160
+ false,
161
+ )
162
+
163
+ if (!result.authenticated || !result.credentials) {
164
+ const message = result.message ?? result.error ?? 'login did not authenticate'
165
+ if (result.error === 'bad_credentials' || result.next_action === 'provide_passcode') {
166
+ return {
167
+ kind: 'reauth_required',
168
+ account_id: decision.account.account_id,
169
+ reason: result.error ?? result.next_action ?? 'login_failed',
170
+ message,
171
+ }
172
+ }
173
+ return {
174
+ kind: 'transient_failure',
175
+ account_id: decision.account.account_id,
176
+ reason: message,
177
+ }
178
+ }
179
+
180
+ const store = new SecretsKakaoCredentialStore({ mode: 'host', secretsPath })
181
+ const nowIso = new Date().toISOString()
182
+ await store.setAccount({
183
+ account_id: decision.account.account_id,
184
+ oauth_token: result.credentials.access_token,
185
+ user_id: result.credentials.user_id,
186
+ refresh_token: result.credentials.refresh_token,
187
+ device_uuid: result.credentials.device_uuid,
188
+ device_type: result.credentials.device_type,
189
+ auth_method: 'login',
190
+ created_at: decision.account.created_at,
191
+ updated_at: nowIso,
192
+ })
193
+
194
+ return {
195
+ kind: 'ok',
196
+ account_id: decision.account.account_id,
197
+ previousUpdatedAt: decision.account.updated_at,
198
+ nextUpdatedAt: nowIso,
199
+ }
200
+ }
201
+
202
+ function parseBlockOrEmpty(value: unknown): KakaoChannelBlock {
203
+ if (value === undefined) return { currentAccount: null, accounts: {} }
204
+ return value as KakaoChannelBlock
205
+ }
206
+
207
+ function classifyDecryptFailure(err: unknown, accountId: string): RenewalDecision {
208
+ if (err instanceof KeyStoreError) {
209
+ if (err.code === 'missing') {
210
+ return {
211
+ kind: 'reauth_required',
212
+ reason: 'key_missing',
213
+ message: `Encryption key missing for KakaoTalk account ${accountId} — run \`typeclaw channel reauth kakaotalk\` to mint a fresh one.`,
214
+ }
215
+ }
216
+ return {
217
+ kind: 'reauth_required',
218
+ reason: 'key_missing',
219
+ message: `Encryption key for KakaoTalk account ${accountId} is unusable (${err.code}: ${err.message}). Move it aside and run \`typeclaw channel reauth kakaotalk\` to mint a fresh one.`,
220
+ }
221
+ }
222
+ if (err instanceof EncryptionError) {
223
+ return {
224
+ kind: 'reauth_required',
225
+ reason: 'decrypt_failed',
226
+ message: `Could not decrypt stored KakaoTalk password (${err.code}) — run \`typeclaw channel reauth kakaotalk\`.`,
227
+ }
228
+ }
229
+ return {
230
+ kind: 'reauth_required',
231
+ reason: 'decrypt_failed',
232
+ message: `Could not decrypt stored KakaoTalk password (${err instanceof Error ? err.message : String(err)}).`,
233
+ }
234
+ }
235
+
236
+ async function resolveAttemptLogin(): Promise<AttemptLoginFn> {
237
+ // agent-messenger does not export `attemptLogin` from its public exports
238
+ // map. Resolve the package's installed location and import the auth
239
+ // implementation file directly — same pattern as runKakaotalkBootstrap's
240
+ // loginFlow resolution in src/init/kakaotalk-auth.ts.
241
+ const require = createRequire(import.meta.url)
242
+ const pkgJson = require.resolve('agent-messenger/package.json')
243
+ const pkgDir = pkgJson.replace(/\/package\.json$/, '')
244
+ const mod = (await import(`${pkgDir}/dist/src/platforms/kakaotalk/auth/kakao-login.js`)) as {
245
+ attemptLogin: AttemptLoginFn
246
+ }
247
+ return mod.attemptLogin
248
+ }
@@ -3,9 +3,19 @@ import type { PendingLoginState } from 'agent-messenger/kakaotalk'
3
3
 
4
4
  import { sendHttp } from '@/hostd/client'
5
5
 
6
- import { type KakaoChannelBlock, kakaoChannelBlockSchema } from './schema'
6
+ import { type KakaoChannelBlock, type KakaoEncryptedPassword, kakaoChannelBlockSchema } from './schema'
7
7
  import { SecretsBackend } from './storage'
8
8
 
9
+ // Account shape accepted by setAccount(). Extends the upstream
10
+ // KakaoAccountCredentials with the typeclaw-only renewal fields (email +
11
+ // encrypted password). Callers that don't care about renewal can pass the
12
+ // upstream slice unchanged; production's runKakaotalkBootstrap supplies the
13
+ // extra fields so the renewal cron can use them.
14
+ export type SetKakaoAccountInput = KakaoAccountCredentials & {
15
+ email?: string
16
+ encryptedPassword?: KakaoEncryptedPassword
17
+ }
18
+
9
19
  export type SecretsKakaoCredentialStoreOptions =
10
20
  | { mode: 'host'; secretsPath: string }
11
21
  | { mode: 'container'; secretsPath: string; hostdUrl: string; restartToken: string; containerName: string }
@@ -25,7 +35,7 @@ export class SecretsKakaoCredentialStore {
25
35
  }
26
36
 
27
37
  async save(config: KakaoConfig): Promise<void> {
28
- await this.writeBlock(() => fromKakaoConfig(config))
38
+ await this.writeBlock((prior) => fromKakaoConfig(config, prior))
29
39
  }
30
40
 
31
41
  async getAccount(id?: string): Promise<KakaoAccountCredentials | null> {
@@ -35,9 +45,21 @@ export class SecretsKakaoCredentialStore {
35
45
  return config.accounts[config.current_account] ?? null
36
46
  }
37
47
 
38
- async setAccount(account: KakaoAccountCredentials): Promise<void> {
48
+ // Same as getAccount but preserves the typeclaw-only renewal fields
49
+ // (email + encryptedPassword). Use this when the caller cares about
50
+ // those fields; the bare getAccount() returns the upstream-shaped slice
51
+ // that the agent-messenger SDK consumes via load()/save().
52
+ async getAccountWithRenewalFields(id?: string): Promise<KakaoChannelBlock['accounts'][string] | null> {
53
+ const block = this.readBlock()
54
+ const accountId = id ?? block.currentAccount
55
+ if (!accountId) return null
56
+ return block.accounts[accountId] ?? null
57
+ }
58
+
59
+ async setAccount(account: SetKakaoAccountInput): Promise<void> {
39
60
  await this.writeBlock((block) => {
40
- const accounts = { ...block.accounts, [account.account_id]: account }
61
+ const merged = mergeAccountPreservingRenewalFields(account, block.accounts[account.account_id])
62
+ const accounts = { ...block.accounts, [account.account_id]: merged }
41
63
  return { ...block, currentAccount: block.currentAccount ?? account.account_id, accounts }
42
64
  })
43
65
  }
@@ -120,10 +142,47 @@ function parseBlock(value: unknown): KakaoChannelBlock {
120
142
  return kakaoChannelBlockSchema.parse(value)
121
143
  }
122
144
 
145
+ // Strips the typeclaw-only renewal fields (email + encryptedPassword) when
146
+ // projecting the on-disk block to the upstream SDK's KakaoConfig shape. This
147
+ // keeps getAccount()'s runtime contract honest with its TypeScript type, and
148
+ // means the SDK's KakaoCredentialManager never sees fields it doesn't know
149
+ // about (forward-compat for future SDK validation tightening).
123
150
  function toKakaoConfig(block: KakaoChannelBlock): KakaoConfig {
124
- return { current_account: block.currentAccount, accounts: block.accounts }
151
+ const accounts: KakaoConfig['accounts'] = {}
152
+ for (const [id, account] of Object.entries(block.accounts)) {
153
+ const { email: _email, encryptedPassword: _encryptedPassword, ...upstream } = account
154
+ accounts[id] = upstream
155
+ }
156
+ return { current_account: block.currentAccount, accounts }
157
+ }
158
+
159
+ // The upstream KakaoConfig/KakaoAccountCredentials types have no awareness of
160
+ // `email` or `encryptedPassword`, so any SDK-driven round-trip (save() after
161
+ // token refresh, KakaoCredentialManager replacing a config slot) would strip
162
+ // them. Re-attach them per-account from the prior on-disk block, keyed by
163
+ // account_id, so token rotations preserve the renewal credentials the cron
164
+ // depends on.
165
+ function fromKakaoConfig(config: KakaoConfig, prior: KakaoChannelBlock): KakaoChannelBlock {
166
+ const accounts: KakaoChannelBlock['accounts'] = {}
167
+ for (const [id, account] of Object.entries(config.accounts)) {
168
+ accounts[id] = mergeAccountPreservingRenewalFields(account, prior.accounts[id])
169
+ }
170
+ return { ...prior, currentAccount: config.current_account, accounts }
125
171
  }
126
172
 
127
- function fromKakaoConfig(config: KakaoConfig): KakaoChannelBlock {
128
- return { currentAccount: config.current_account, accounts: config.accounts }
173
+ function mergeAccountPreservingRenewalFields(
174
+ incoming: SetKakaoAccountInput | KakaoAccountCredentials,
175
+ priorOnDisk: KakaoChannelBlock['accounts'][string] | undefined,
176
+ ): KakaoChannelBlock['accounts'][string] {
177
+ const incomingExt = incoming as SetKakaoAccountInput
178
+ const merged: KakaoChannelBlock['accounts'][string] = { ...incoming }
179
+ // Incoming wins for fields it explicitly carries (e.g. fresh login provides
180
+ // email + encryptedPassword); otherwise we fall back to the prior on-disk
181
+ // record so token-refresh round-trips through the SDK don't strip our
182
+ // renewal credentials.
183
+ if (incomingExt.email !== undefined) merged.email = incomingExt.email
184
+ else if (priorOnDisk?.email !== undefined) merged.email = priorOnDisk.email
185
+ if (incomingExt.encryptedPassword !== undefined) merged.encryptedPassword = incomingExt.encryptedPassword
186
+ else if (priorOnDisk?.encryptedPassword !== undefined) merged.encryptedPassword = priorOnDisk.encryptedPassword
187
+ return merged
129
188
  }
@@ -0,0 +1,173 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { chmod, lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ import { fingerprintKey, generateKey } from './encryption'
6
+
7
+ const KEY_FILE_MODE = 0o600
8
+ const KEY_DIR_MODE = 0o700
9
+ const KEY_BYTES = 32
10
+
11
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/
12
+
13
+ export class KeyStoreError extends Error {
14
+ constructor(
15
+ message: string,
16
+ public readonly code: 'invalid_name' | 'missing' | 'corrupt_size' | 'not_a_regular_file' | 'race_lost',
17
+ ) {
18
+ super(message)
19
+ this.name = 'KeyStoreError'
20
+ }
21
+ }
22
+
23
+ export type KeyStoreOptions = {
24
+ keysDir: string
25
+ }
26
+
27
+ // Per-agent symmetric key store. Each container/agent gets its own 32-byte
28
+ // random key under <keysDir>/<containerName>.key (file mode 0600, dir 0700).
29
+ // The host CLI generates and reads these; hostd reads them during scheduled
30
+ // renewal. The container never receives the key — that's load-bearing for the
31
+ // encryption-vs-collocation argument in encryption.ts's threat model comment.
32
+ export function createKeyStore(opts: KeyStoreOptions): KeyStore {
33
+ const ensureDir = async (): Promise<void> => {
34
+ await mkdir(opts.keysDir, { recursive: true })
35
+ await chmod(opts.keysDir, KEY_DIR_MODE).catch(() => {})
36
+ await chmod(dirname(opts.keysDir), KEY_DIR_MODE).catch(() => {})
37
+ }
38
+
39
+ const keyPath = (containerName: string): string => {
40
+ if (!SAFE_NAME.test(containerName)) {
41
+ throw new KeyStoreError(`invalid container name for key file: ${JSON.stringify(containerName)}`, 'invalid_name')
42
+ }
43
+ return join(opts.keysDir, `${containerName}.key`)
44
+ }
45
+
46
+ return {
47
+ keyPath,
48
+
49
+ exists(containerName: string): boolean {
50
+ try {
51
+ return existsSync(keyPath(containerName))
52
+ } catch {
53
+ return false
54
+ }
55
+ },
56
+
57
+ async read(containerName: string): Promise<Buffer> {
58
+ const path = keyPath(containerName)
59
+ if (!existsSync(path)) {
60
+ throw new KeyStoreError(`key file missing: ${path}`, 'missing')
61
+ }
62
+ await assertRegularFile(path)
63
+ const buf = await readFile(path)
64
+ if (buf.length !== KEY_BYTES) {
65
+ throw new KeyStoreError(`key file is ${buf.length} bytes (expected ${KEY_BYTES}): ${path}`, 'corrupt_size')
66
+ }
67
+ return buf
68
+ },
69
+
70
+ async ensure(containerName: string): Promise<Buffer> {
71
+ const path = keyPath(containerName)
72
+ if (existsSync(path)) {
73
+ await assertRegularFile(path)
74
+ const existing = await readFile(path)
75
+ if (existing.length === KEY_BYTES) return existing
76
+ // Corrupt-size key: surface rather than silently overwrite. The user
77
+ // may have a backup; replacing it here would make every existing
78
+ // ciphertext permanently undecryptable without telling them.
79
+ throw new KeyStoreError(
80
+ `existing key file is ${existing.length} bytes (expected ${KEY_BYTES}): ${path}. ` +
81
+ 'Move it aside and re-run reauth to mint a fresh key.',
82
+ 'corrupt_size',
83
+ )
84
+ }
85
+ await ensureDir()
86
+ // Exclusive-create (wx flag) makes the first-write race-safe: if two
87
+ // processes both pass the existsSync check above and race, only one
88
+ // wx open of the temp file (or the final-path file via rename below)
89
+ // succeeds. The loser reads the winning key off disk and returns IT
90
+ // rather than its own bytes. Without this, the loser's
91
+ // secrets.json#encryptedPassword would be ciphertext for a key that
92
+ // just got overwritten by the winner's rename — silent
93
+ // undecryptable-ness.
94
+ const fresh = generateKey()
95
+ // Per-PID-and-randomized temp path so two same-process concurrent
96
+ // ensure() calls don't collide on the same temp file. The crypto-random
97
+ // suffix is the only reason same-process concurrency is safe at all.
98
+ const tmpSuffix = Math.random().toString(36).slice(2, 10)
99
+ const tmp = `${path}.${process.pid}.${tmpSuffix}.tmp`
100
+ try {
101
+ await writeFile(tmp, fresh, { mode: KEY_FILE_MODE, flag: 'wx' })
102
+ } catch (err) {
103
+ const code = (err as NodeJS.ErrnoException).code
104
+ if (code !== 'EEXIST') throw err
105
+ // Astronomically unlikely (same pid AND same random suffix). Treat
106
+ // as a corruption signal and surface; do not silently overwrite.
107
+ throw new KeyStoreError(`temp key path collision at ${tmp}; refusing to overwrite`, 'race_lost')
108
+ }
109
+ try {
110
+ // link() is atomic and refuses to overwrite an existing dest. If the
111
+ // dest already exists, another process won the race; link will throw
112
+ // EEXIST. We then unlink our tmp and read the winner's bytes.
113
+ const fs = await import('node:fs/promises')
114
+ await fs.link(tmp, path)
115
+ } catch (err) {
116
+ const code = (err as NodeJS.ErrnoException).code
117
+ const fs = await import('node:fs/promises')
118
+ if (code === 'EEXIST') {
119
+ await fs.unlink(tmp).catch(() => {})
120
+ if (!existsSync(path)) {
121
+ throw new KeyStoreError(
122
+ `key path ${path} reports EEXIST but is not present; refusing to retry`,
123
+ 'race_lost',
124
+ )
125
+ }
126
+ await assertRegularFile(path)
127
+ const winner = await readFile(path)
128
+ if (winner.length !== KEY_BYTES) {
129
+ throw new KeyStoreError(
130
+ `race winner key at ${path} is ${winner.length} bytes (expected ${KEY_BYTES})`,
131
+ 'race_lost',
132
+ )
133
+ }
134
+ return winner
135
+ }
136
+ await fs.unlink(tmp).catch(() => {})
137
+ throw err
138
+ }
139
+ // Clean up the temp file (the link succeeded; tmp is now a second
140
+ // hardlink that we no longer need). Best-effort.
141
+ const fs2 = await import('node:fs/promises')
142
+ await fs2.unlink(tmp).catch(() => {})
143
+ await chmod(path, KEY_FILE_MODE).catch(() => {})
144
+ return fresh
145
+ },
146
+
147
+ fingerprint(key: Buffer): string {
148
+ return fingerprintKey(key)
149
+ },
150
+ }
151
+ }
152
+
153
+ export type KeyStore = {
154
+ keyPath: (containerName: string) => string
155
+ exists: (containerName: string) => boolean
156
+ read: (containerName: string) => Promise<Buffer>
157
+ ensure: (containerName: string) => Promise<Buffer>
158
+ fingerprint: (key: Buffer) => string
159
+ }
160
+
161
+ // Reject symlinks (and other non-regular files) so a same-user attacker who
162
+ // can write under ~/.typeclaw/keys/ can't pre-place <name>.key as a symlink
163
+ // to another 32-byte readable file and have TypeClaw treat that file's
164
+ // content as the encryption key. lstat refuses to follow the link itself.
165
+ async function assertRegularFile(path: string): Promise<void> {
166
+ const info = await lstat(path)
167
+ if (!info.isFile()) {
168
+ throw new KeyStoreError(
169
+ `key path ${path} is not a regular file (mode=${info.mode.toString(8)})`,
170
+ 'not_a_regular_file',
171
+ )
172
+ }
173
+ }
@@ -40,6 +40,22 @@ const telegramBotChannelSchema = z.object({
40
40
  token: secretFieldSchema.optional(),
41
41
  })
42
42
 
43
+ // Encrypted password envelope produced by src/secrets/encryption.ts. Optional
44
+ // in the schema because legacy v2 accounts (pre-renewal feature) don't have
45
+ // one; the renewal cron treats a missing envelope as "reauth required" and
46
+ // degrades to logged warnings rather than crashing.
47
+ const kakaoEncryptedPasswordSchema = z
48
+ .object({
49
+ v: z.literal(1),
50
+ alg: z.literal('AES-256-GCM'),
51
+ kid: z.string(),
52
+ iv: z.string(),
53
+ ciphertext: z.string(),
54
+ authTag: z.string(),
55
+ createdAt: z.string(),
56
+ })
57
+ .strict()
58
+
43
59
  export const kakaoAccountRecordSchema = z.object({
44
60
  account_id: z.string(),
45
61
  oauth_token: z.string(),
@@ -50,8 +66,15 @@ export const kakaoAccountRecordSchema = z.object({
50
66
  auth_method: z.union([z.literal('login'), z.literal('extract')]).optional(),
51
67
  created_at: z.string(),
52
68
  updated_at: z.string(),
69
+ // Renewal-feature additions. Both optional to preserve compatibility with
70
+ // legacy accounts; renewal degrades to "reauth required" when either is
71
+ // absent. See src/secrets/kakao-renewal.ts.
72
+ email: z.string().optional(),
73
+ encryptedPassword: kakaoEncryptedPasswordSchema.optional(),
53
74
  })
54
75
 
76
+ export type KakaoEncryptedPassword = z.infer<typeof kakaoEncryptedPasswordSchema>
77
+
55
78
  export const kakaoPendingLoginRecordSchema = z.object({
56
79
  device_uuid: z.string(),
57
80
  device_type: z.union([z.literal('pc'), z.literal('tablet')]),