typeclaw 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/auth.schema.json +238 -7
- package/package.json +1 -1
- package/secrets.schema.json +238 -7
- package/src/agent/auth.ts +19 -38
- package/src/agent/tools/channel-fetch-attachment.ts +6 -0
- package/src/agent/tools/channel-history.ts +10 -1
- package/src/agent/tools/channel-log.ts +32 -0
- package/src/agent/tools/channel-reply.ts +18 -1
- package/src/agent/tools/channel-send.ts +13 -1
- package/src/bundled-plugins/tool-result-cap/README.md +67 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
- package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
- package/src/channels/adapters/kakaotalk.ts +25 -16
- package/src/channels/manager.ts +47 -38
- package/src/cli/channel.ts +3 -3
- package/src/cli/index.ts +3 -0
- package/src/cli/init.ts +2 -1
- package/src/cli/ui.ts +11 -0
- package/src/config/config.ts +15 -4
- package/src/container/start.ts +90 -1
- package/src/hostd/daemon.ts +28 -3
- package/src/hostd/protocol.ts +7 -0
- package/src/init/auto-upgrade.ts +368 -0
- package/src/init/dockerfile.ts +25 -14
- package/src/init/index.ts +123 -77
- package/src/init/kakaotalk-auth.ts +9 -3
- package/src/init/run-bun-install.ts +34 -0
- package/src/run/bundled-plugins.ts +7 -0
- package/src/run/index.ts +9 -0
- package/src/secrets/defaults.ts +67 -0
- package/src/secrets/hydrate.ts +99 -0
- package/src/secrets/index.ts +6 -12
- package/src/secrets/kakao-store.ts +129 -0
- package/src/secrets/migrate-kakaotalk.ts +82 -0
- package/src/secrets/migrate.ts +5 -4
- package/src/secrets/resolve.ts +57 -0
- package/src/secrets/schema.ts +162 -42
- package/src/secrets/storage.ts +253 -47
- package/src/skills/typeclaw-config/SKILL.md +47 -8
- package/typeclaw.schema.json +36 -2
- package/src/secrets/env.ts +0 -43
|
@@ -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
|
+
}
|
package/src/secrets/migrate.ts
CHANGED
|
@@ -70,9 +70,10 @@ function renameWithRaceFallback(from: string, to: string): void {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
// "Empty envelope" = no actual credentials.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
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.
|
|
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
|
+
}
|
package/src/secrets/schema.ts
CHANGED
|
@@ -1,72 +1,192 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
3
|
+
import { CHANNEL_ENV_TO_FIELD } from './defaults'
|
|
4
|
+
import { secretFieldSchema, type Secret } from './resolve'
|
|
5
|
+
|
|
6
|
+
// providers.<id> for api-key credentials: the `key` field is a Secret (string
|
|
7
|
+
// shorthand or `{ value?, env? }` object). resolveSecret turns this into a
|
|
8
|
+
// flat string at read time so AuthStorage (which expects `key: string`)
|
|
9
|
+
// stays happy. OAuth credentials carry stateful refresh/access tokens that
|
|
10
|
+
// are not env-injectable, so they pass through unchanged via catchall.
|
|
11
|
+
const apiKeyProviderSchema = z.object({
|
|
8
12
|
type: z.literal('api_key'),
|
|
9
|
-
key:
|
|
13
|
+
key: secretFieldSchema,
|
|
10
14
|
})
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
// (access, refresh, expires, plus arbitrary upstream additions). We accept
|
|
14
|
-
// them as a passthrough object so future upstream additions don't break parse.
|
|
15
|
-
// Upstream is the runtime authority on OAuth shape; our job here is only to
|
|
16
|
-
// route the slice safely through the file envelope.
|
|
17
|
-
const llmOAuthCredentialSchema = z
|
|
16
|
+
const oauthProviderSchema = z
|
|
18
17
|
.object({
|
|
19
18
|
type: z.literal('oauth'),
|
|
20
19
|
})
|
|
21
20
|
.catchall(z.unknown())
|
|
22
21
|
|
|
23
|
-
export const
|
|
22
|
+
export const providerCredentialSchema = z.discriminatedUnion('type', [apiKeyProviderSchema, oauthProviderSchema])
|
|
23
|
+
|
|
24
|
+
export const providersSchema = z.record(z.string(), providerCredentialSchema)
|
|
25
|
+
|
|
26
|
+
// Per-adapter channel slots use named fields (`botToken`, `appToken`, `token`)
|
|
27
|
+
// instead of env-var-name keys. The Secret union per field carries the env-var
|
|
28
|
+
// override. Unknown adapter ids pass through via catchall so a future
|
|
29
|
+
// plugin-contributed adapter doesn't fail validation.
|
|
30
|
+
const slackBotChannelSchema = z.object({
|
|
31
|
+
botToken: secretFieldSchema.optional(),
|
|
32
|
+
appToken: secretFieldSchema.optional(),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const discordBotChannelSchema = z.object({
|
|
36
|
+
token: secretFieldSchema.optional(),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const telegramBotChannelSchema = z.object({
|
|
40
|
+
token: secretFieldSchema.optional(),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export const kakaoAccountRecordSchema = z.object({
|
|
44
|
+
account_id: z.string(),
|
|
45
|
+
oauth_token: z.string(),
|
|
46
|
+
user_id: z.string(),
|
|
47
|
+
refresh_token: z.string().optional(),
|
|
48
|
+
device_uuid: z.string(),
|
|
49
|
+
device_type: z.union([z.literal('pc'), z.literal('tablet')]),
|
|
50
|
+
auth_method: z.union([z.literal('login'), z.literal('extract')]).optional(),
|
|
51
|
+
created_at: z.string(),
|
|
52
|
+
updated_at: z.string(),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const kakaoPendingLoginRecordSchema = z.object({
|
|
56
|
+
device_uuid: z.string(),
|
|
57
|
+
device_type: z.union([z.literal('pc'), z.literal('tablet')]),
|
|
58
|
+
email: z.string(),
|
|
59
|
+
created_at: z.string(),
|
|
60
|
+
})
|
|
24
61
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
62
|
+
export const kakaoChannelBlockSchema = z.object({
|
|
63
|
+
currentAccount: z.string().nullable(),
|
|
64
|
+
accounts: z.record(z.string(), kakaoAccountRecordSchema),
|
|
65
|
+
pendingLogin: kakaoPendingLoginRecordSchema.optional(),
|
|
66
|
+
})
|
|
28
67
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
68
|
+
export const channelsSchema = z
|
|
69
|
+
.object({
|
|
70
|
+
'slack-bot': slackBotChannelSchema.optional(),
|
|
71
|
+
'discord-bot': discordBotChannelSchema.optional(),
|
|
72
|
+
'telegram-bot': telegramBotChannelSchema.optional(),
|
|
73
|
+
kakaotalk: kakaoChannelBlockSchema.optional(),
|
|
74
|
+
})
|
|
75
|
+
.catchall(z.unknown())
|
|
76
|
+
|
|
77
|
+
// version 2 = providers.* with Secret-typed api-key.key + per-adapter
|
|
78
|
+
// channel field shapes. version 1 = the previous shape (flat `llm.*`, channel
|
|
79
|
+
// slots keyed by env-var name). Legacy v1 input is upgraded transparently by
|
|
80
|
+
// parseSecretsFile; the first write persists v2.
|
|
81
|
+
export const SECRETS_FILE_VERSION = 2
|
|
33
82
|
|
|
34
83
|
export const secretsFileSchema = z.object({
|
|
35
84
|
$schema: z.string().optional(),
|
|
36
|
-
version: z.literal(
|
|
37
|
-
|
|
85
|
+
version: z.literal(SECRETS_FILE_VERSION),
|
|
86
|
+
providers: providersSchema.default({}),
|
|
38
87
|
channels: channelsSchema.default({}),
|
|
39
88
|
})
|
|
40
89
|
|
|
41
|
-
export type
|
|
42
|
-
export type
|
|
90
|
+
export type ProviderCredential = z.infer<typeof providerCredentialSchema>
|
|
91
|
+
export type Providers = z.infer<typeof providersSchema>
|
|
92
|
+
export type Channels = z.infer<typeof channelsSchema>
|
|
93
|
+
export type KakaoAccountRecord = z.infer<typeof kakaoAccountRecordSchema>
|
|
94
|
+
export type PendingLoginRecord = z.infer<typeof kakaoPendingLoginRecordSchema>
|
|
95
|
+
export type KakaoChannelBlock = z.infer<typeof kakaoChannelBlockSchema>
|
|
43
96
|
export type SecretsFile = z.infer<typeof secretsFileSchema>
|
|
44
97
|
|
|
45
98
|
export type ParseSecretsResult = { ok: true; file: SecretsFile } | { ok: false; reason: string }
|
|
46
99
|
|
|
47
|
-
// parseSecretsFile recognises
|
|
48
|
-
// 1. The
|
|
49
|
-
// 2. The legacy
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
100
|
+
// parseSecretsFile recognises three shapes, in priority order:
|
|
101
|
+
// 1. The v2 envelope (current): { version: 2, providers, channels }
|
|
102
|
+
// 2. The v1 envelope (legacy): { version: 1, llm, channels } where channel
|
|
103
|
+
// slots are keyed by env-var name. Both `llm` and `channels` get
|
|
104
|
+
// reshaped — llm -> providers, env-keyed channel slots -> field-keyed.
|
|
105
|
+
// 3. The pre-envelope flat shape (very legacy): Record<string, AuthCredential>
|
|
106
|
+
// at top level. Treated as { version: 2, providers: <flat>, channels: {} }
|
|
107
|
+
// so existing OAuth users transparently upgrade.
|
|
53
108
|
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
109
|
+
// Every legacy upgrade produces a v2-shaped SecretsFile in memory; the next
|
|
110
|
+
// write persists v2 to disk. The legacy branches stay forever as a quiet
|
|
111
|
+
// compatibility seam — only the v2 form is documented.
|
|
57
112
|
export function parseSecretsFile(raw: unknown): ParseSecretsResult {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
113
|
+
const v2 = secretsFileSchema.safeParse(raw)
|
|
114
|
+
if (v2.success) return { ok: true, file: v2.data }
|
|
115
|
+
|
|
116
|
+
const v1 = legacyV1Schema.safeParse(raw)
|
|
117
|
+
if (v1.success) return { ok: true, file: upgradeV1ToV2(v1.data) }
|
|
118
|
+
|
|
119
|
+
const flat = legacyFlatProviderSchema.safeParse(raw)
|
|
120
|
+
if (flat.success) {
|
|
121
|
+
return { ok: true, file: upgradeV1ToV2({ version: 1, llm: flat.data, channels: {} }) }
|
|
122
|
+
}
|
|
60
123
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
124
|
+
return { ok: false, reason: v2.error.issues.map(formatIssue).join('; ') }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Legacy v1 schema: `llm` (flat string-key) and `channels` (env-var-keyed
|
|
128
|
+
// flat map per adapter). Used only for upgrade reads; never written.
|
|
129
|
+
const legacyV1ApiKeySchema = z.object({
|
|
130
|
+
type: z.literal('api_key'),
|
|
131
|
+
key: z.string().min(1),
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const legacyV1OAuthSchema = z
|
|
135
|
+
.object({
|
|
136
|
+
type: z.literal('oauth'),
|
|
137
|
+
})
|
|
138
|
+
.catchall(z.unknown())
|
|
139
|
+
|
|
140
|
+
const legacyV1CredentialSchema = z.discriminatedUnion('type', [legacyV1ApiKeySchema, legacyV1OAuthSchema])
|
|
141
|
+
|
|
142
|
+
const legacyV1LlmSchema = z.record(z.string(), legacyV1CredentialSchema)
|
|
143
|
+
|
|
144
|
+
const legacyV1ChannelsSchema = z.record(z.string(), z.record(z.string(), z.string()))
|
|
145
|
+
|
|
146
|
+
const legacyV1Schema = z.object({
|
|
147
|
+
$schema: z.string().optional(),
|
|
148
|
+
version: z.literal(1),
|
|
149
|
+
llm: legacyV1LlmSchema.default({}),
|
|
150
|
+
channels: legacyV1ChannelsSchema.default({}),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const legacyFlatProviderSchema = z.record(z.string(), legacyV1CredentialSchema)
|
|
154
|
+
|
|
155
|
+
function upgradeV1ToV2(legacy: z.infer<typeof legacyV1Schema>): SecretsFile {
|
|
156
|
+
const providers: Providers = {}
|
|
157
|
+
for (const [providerId, cred] of Object.entries(legacy.llm)) {
|
|
158
|
+
if (cred.type === 'api_key') {
|
|
159
|
+
providers[providerId] = { type: 'api_key', key: { value: cred.key } }
|
|
160
|
+
} else {
|
|
161
|
+
providers[providerId] = cred
|
|
162
|
+
}
|
|
64
163
|
}
|
|
65
164
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
165
|
+
const channels: Channels = {}
|
|
166
|
+
for (const [adapterId, envKeyedSlot] of Object.entries(legacy.channels)) {
|
|
167
|
+
const upgradedSlot: Record<string, Secret> = {}
|
|
168
|
+
for (const [envKey, value] of Object.entries(envKeyedSlot)) {
|
|
169
|
+
const mapping = CHANNEL_ENV_TO_FIELD[envKey]
|
|
170
|
+
if (mapping && mapping.adapterId === adapterId) {
|
|
171
|
+
upgradedSlot[mapping.fieldName] = { value }
|
|
172
|
+
} else {
|
|
173
|
+
// Unknown env-var-name key on a known adapter, or an adapter we don't
|
|
174
|
+
// recognise: pass through verbatim under the original key. Better to
|
|
175
|
+
// preserve user data than drop it; the catchall on channelsSchema
|
|
176
|
+
// makes this safe.
|
|
177
|
+
upgradedSlot[envKey] = { value }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
channels[adapterId] = upgradedSlot
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const result: SecretsFile = {
|
|
184
|
+
version: SECRETS_FILE_VERSION,
|
|
185
|
+
providers,
|
|
186
|
+
channels,
|
|
187
|
+
}
|
|
188
|
+
if (legacy.$schema !== undefined) result.$schema = legacy.$schema
|
|
189
|
+
return result
|
|
70
190
|
}
|
|
71
191
|
|
|
72
192
|
function formatIssue(issue: { path: PropertyKey[]; message: string }): string {
|