typeclaw 0.1.5 → 0.1.6
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.
- package/README.md +14 -12
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +385 -12
- package/src/config/index.ts +7 -0
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +50 -33
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +32 -6
- package/src/init/index.ts +183 -62
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +55 -6
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +68 -0
- package/src/server/index.ts +122 -11
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
128
|
-
|
|
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
|
+
}
|
package/src/secrets/schema.ts
CHANGED
|
@@ -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')]),
|