typeclaw 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -12
- 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/doctor.ts +173 -0
- package/src/agent/subagents.ts +24 -2
- 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/backup/README.md +81 -0
- package/src/bundled-plugins/backup/index.ts +209 -0
- package/src/bundled-plugins/backup/runner.ts +231 -0
- package/src/bundled-plugins/backup/subagents.ts +200 -0
- package/src/bundled-plugins/memory/index.ts +42 -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/channels/router.ts +29 -0
- package/src/cli/channel.ts +3 -3
- package/src/cli/compose.ts +92 -1
- package/src/cli/doctor.ts +100 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +2 -1
- package/src/cli/ui.ts +11 -0
- package/src/compose/doctor.ts +141 -0
- package/src/compose/index.ts +8 -0
- package/src/compose/logs.ts +32 -19
- package/src/config/config.ts +31 -0
- package/src/container/log-colors.ts +75 -0
- package/src/container/log-timestamps.ts +84 -0
- package/src/container/logs.ts +71 -5
- package/src/container/start.ts +113 -9
- package/src/cron/consumer.ts +29 -7
- package/src/doctor/checks.ts +426 -0
- package/src/doctor/commit.ts +71 -0
- package/src/doctor/index.ts +287 -0
- package/src/doctor/plugin-bridge.ts +147 -0
- package/src/doctor/report.ts +142 -0
- package/src/doctor/types.ts +87 -0
- 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/cli-version.ts +81 -0
- package/src/init/dockerfile.ts +234 -25
- package/src/init/index.ts +141 -87
- package/src/init/kakaotalk-auth.ts +9 -3
- package/src/init/run-bun-install.ts +34 -0
- package/src/plugin/hooks.ts +32 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +32 -3
- package/src/plugin/types.ts +65 -0
- package/src/run/bundled-plugins.ts +15 -0
- package/src/run/index.ts +19 -5
- 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/server/index.ts +103 -5
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +22 -0
- package/src/skills/typeclaw-config/SKILL.md +48 -9
- package/typeclaw.schema.json +84 -0
- package/src/secrets/env.ts +0 -43
package/src/plugin/types.ts
CHANGED
|
@@ -97,6 +97,24 @@ export type SessionIdleEvent = {
|
|
|
97
97
|
origin?: SessionOrigin
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// Brackets every `session.prompt(...)` invocation. Distinct from
|
|
101
|
+
// `session.start`/`session.end` (which bracket session lifetime) so that
|
|
102
|
+
// long-lived TUI or channel sessions, which can sit idle between turns,
|
|
103
|
+
// don't wedge a turn-counter forever. `origin` carries the session's origin
|
|
104
|
+
// so observers can exclude their own induced turns when counting (e.g. the
|
|
105
|
+
// backup plugin excludes `subagent: 'backup'` to avoid self-gating).
|
|
106
|
+
export type SessionTurnStartEvent = {
|
|
107
|
+
sessionId: string
|
|
108
|
+
agentDir: string
|
|
109
|
+
origin?: SessionOrigin
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type SessionTurnEndEvent = {
|
|
113
|
+
sessionId: string
|
|
114
|
+
agentDir: string
|
|
115
|
+
origin?: SessionOrigin
|
|
116
|
+
}
|
|
117
|
+
|
|
100
118
|
// Provider prompt caching requires byte-identical prefixes. Mutations near the
|
|
101
119
|
// end of `event.prompt` preserve cache hits across sessions; mutations near
|
|
102
120
|
// the start invalidate the cache on every LLM call.
|
|
@@ -136,6 +154,8 @@ export type Hooks = {
|
|
|
136
154
|
'session.end'?: (event: SessionEndEvent, ctx: HookContext) => Promise<void> | void
|
|
137
155
|
'session.idle'?: (event: SessionIdleEvent, ctx: HookContext) => Promise<void> | void
|
|
138
156
|
'session.prompt'?: (event: SessionPromptEvent, ctx: HookContext) => Promise<void> | void
|
|
157
|
+
'session.turn.start'?: (event: SessionTurnStartEvent, ctx: HookContext) => Promise<void> | void
|
|
158
|
+
'session.turn.end'?: (event: SessionTurnEndEvent, ctx: HookContext) => Promise<void> | void
|
|
139
159
|
'tool.before'?: (event: ToolBeforeEvent, ctx: HookContext) => Promise<ToolBeforeResult> | ToolBeforeResult
|
|
140
160
|
'tool.after'?: (event: ToolAfterEvent, ctx: HookContext) => Promise<void> | void
|
|
141
161
|
}
|
|
@@ -164,6 +184,51 @@ export type PluginExports = {
|
|
|
164
184
|
skills?: Record<string, PluginSkill>
|
|
165
185
|
skillsDirs?: string[]
|
|
166
186
|
hooks?: Hooks
|
|
187
|
+
doctorChecks?: Record<string, PluginDoctorCheck>
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// `typeclaw doctor` plugin extension surface. Each check is read-only by
|
|
191
|
+
// default; declaring `fix.apply` opts the check into `typeclaw doctor --fix`,
|
|
192
|
+
// where the host serializes plugin fixes, validates their `changedPaths`
|
|
193
|
+
// against the agent folder, and commits the union of all fixes in a single
|
|
194
|
+
// commit.
|
|
195
|
+
export type PluginDoctorCheck = {
|
|
196
|
+
description: string
|
|
197
|
+
category?: string
|
|
198
|
+
run: (ctx: PluginDoctorContext) => Promise<PluginCheckResult>
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export type PluginDoctorContext = {
|
|
202
|
+
readonly pluginName: string
|
|
203
|
+
readonly agentDir: string
|
|
204
|
+
readonly config: unknown
|
|
205
|
+
readonly logger: PluginLogger
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export type PluginCheckStatus = 'ok' | 'warning' | 'error'
|
|
209
|
+
|
|
210
|
+
export type PluginCheckResult = {
|
|
211
|
+
status: PluginCheckStatus
|
|
212
|
+
message: string
|
|
213
|
+
details?: string[]
|
|
214
|
+
fix?: PluginFixSuggestion
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export type PluginFixSuggestion = {
|
|
218
|
+
description: string
|
|
219
|
+
// When omitted, the fix is advisory-only. `typeclaw doctor --fix` only
|
|
220
|
+
// attempts to remediate checks whose suggestion includes an `apply`.
|
|
221
|
+
apply?: (ctx: PluginDoctorContext) => Promise<PluginFixResult>
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export type PluginFixResult = {
|
|
225
|
+
// One-line description that appears in the commit body as a bullet.
|
|
226
|
+
summary: string
|
|
227
|
+
// POSIX paths relative to agentDir; the host validates each one stays
|
|
228
|
+
// inside agentDir before `git add`ing. Absolute paths and `..` segments
|
|
229
|
+
// are rejected to keep plugin fixes from staging files outside the agent
|
|
230
|
+
// folder. Empty array is valid (e.g. a fix that only logs).
|
|
231
|
+
changedPaths: string[]
|
|
167
232
|
}
|
|
168
233
|
|
|
169
234
|
export type DefinedPlugin<TConfig = never> = {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
|
|
2
|
+
import backupPlugin from '@/bundled-plugins/backup'
|
|
2
3
|
import guardPlugin from '@/bundled-plugins/guard'
|
|
3
4
|
import memoryPlugin from '@/bundled-plugins/memory'
|
|
4
5
|
import securityPlugin from '@/bundled-plugins/security'
|
|
6
|
+
import toolResultCapPlugin from '@/bundled-plugins/tool-result-cap'
|
|
5
7
|
import type { ResolvedPlugin } from '@/plugin'
|
|
6
8
|
|
|
7
9
|
// Consumed by both `startAgent` (auto-loaded before user plugins) AND
|
|
@@ -16,9 +18,22 @@ import type { ResolvedPlugin } from '@/plugin'
|
|
|
16
18
|
// Letting `guard` run first would still work today since the two plugins
|
|
17
19
|
// guard disjoint surfaces, but seeding the order now means future overlap
|
|
18
20
|
// (e.g. a security policy on writes) blocks before guard's softer advice.
|
|
21
|
+
//
|
|
22
|
+
// `tool-result-cap` is registered before `guard` so guard's `tool.after`
|
|
23
|
+
// advice (uncommitted-changes warning) appends to already-capped content.
|
|
24
|
+
// Reversing this order would make guard advise on the full oversized payload
|
|
25
|
+
// and then tool-result-cap would clobber the advice text along with the rest.
|
|
26
|
+
//
|
|
27
|
+
// `memory` is registered before `backup` so memory's dreaming commits always
|
|
28
|
+
// land in the same git index window before backup's commit-and-push cycle.
|
|
29
|
+
// They commit disjoint paths today (memory/ vs sessions/ + agent changes),
|
|
30
|
+
// but if either ever holds .git/index.lock the deterministic order makes the
|
|
31
|
+
// contention easier to reason about.
|
|
19
32
|
export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
|
|
20
33
|
{ name: 'security', version: undefined, source: '<bundled>', defined: securityPlugin },
|
|
34
|
+
{ name: 'tool-result-cap', version: undefined, source: '<bundled>', defined: toolResultCapPlugin },
|
|
21
35
|
{ name: 'guard', version: undefined, source: '<bundled>', defined: guardPlugin },
|
|
22
36
|
{ name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
|
|
37
|
+
{ name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
|
|
23
38
|
{ name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
|
|
24
39
|
]
|
package/src/run/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { loadPlugins, type LoadPluginsResult, pluginCronJobs, type PluginRegistry, summarizeLoaded } from '@/plugin'
|
|
26
26
|
import { createContainerBroker, publishForwardResult } from '@/portbroker'
|
|
27
27
|
import { ReloadRegistry } from '@/reload'
|
|
28
|
+
import { hydrateChannelEnvFromSecrets } from '@/secrets'
|
|
28
29
|
import { createServer, type Server } from '@/server'
|
|
29
30
|
import { createSessionFactory, type SessionFactory } from '@/sessions'
|
|
30
31
|
import { createStream, type Stream } from '@/stream'
|
|
@@ -119,6 +120,14 @@ export async function startAgent({
|
|
|
119
120
|
materializedSkills: null,
|
|
120
121
|
})
|
|
121
122
|
|
|
123
|
+
// Channel adapters read `process.env[TOKEN_ENV]` (see channels/manager.ts).
|
|
124
|
+
// Hydrate fills any unset env var from secrets.json#channels via env-wins:
|
|
125
|
+
// values already in process.env (from `docker --env-file .env`) are kept
|
|
126
|
+
// as-is; missing ones get the resolved Secret value injected. The pre-v2
|
|
127
|
+
// auto-promotion from .env to secrets.json has been removed — env values
|
|
128
|
+
// stay in env, the file stays user-owned. See src/secrets/hydrate.ts.
|
|
129
|
+
hydrateChannelEnvFromSecrets({ agentDir: cwd })
|
|
130
|
+
|
|
122
131
|
const channelManager = createChannelManager({
|
|
123
132
|
agentDir: cwd,
|
|
124
133
|
channelsConfigRef: () => getConfig().channels,
|
|
@@ -142,14 +151,15 @@ export async function startAgent({
|
|
|
142
151
|
const entry = snap.pluginSubagentByShim.get(subagent)
|
|
143
152
|
if (entry) {
|
|
144
153
|
const sessionId = `subagent-${entry.pluginName}-${crypto.randomUUID()}`
|
|
154
|
+
const origin = {
|
|
155
|
+
kind: 'subagent' as const,
|
|
156
|
+
subagent: subagentOptions?.name ?? entry.subagentName,
|
|
157
|
+
parentSessionId: subagentOptions?.parentSessionId ?? '<unknown>',
|
|
158
|
+
}
|
|
145
159
|
const created = await createSessionWithDispose({
|
|
146
160
|
systemPromptOverride: entry.pluginSubagent.systemPrompt,
|
|
147
161
|
channelRouter: channelManager.router,
|
|
148
|
-
origin
|
|
149
|
-
kind: 'subagent',
|
|
150
|
-
subagent: subagentOptions?.name ?? entry.subagentName,
|
|
151
|
-
parentSessionId: subagentOptions?.parentSessionId ?? '<unknown>',
|
|
152
|
-
},
|
|
162
|
+
origin,
|
|
153
163
|
plugins: {
|
|
154
164
|
registry: snap.registry,
|
|
155
165
|
hooks: snap.hooks,
|
|
@@ -167,6 +177,8 @@ export async function startAgent({
|
|
|
167
177
|
...created,
|
|
168
178
|
hooks: snap.hooks,
|
|
169
179
|
sessionId,
|
|
180
|
+
agentDir: cwd,
|
|
181
|
+
origin,
|
|
170
182
|
}
|
|
171
183
|
}
|
|
172
184
|
return defaultCreateSessionForSubagent(subagent, subagentOptions)
|
|
@@ -221,6 +233,8 @@ export async function startAgent({
|
|
|
221
233
|
prompt: (text) => session.prompt(text),
|
|
222
234
|
dispose: () => session.dispose(),
|
|
223
235
|
sessionId,
|
|
236
|
+
agentDir: cwd,
|
|
237
|
+
origin: { kind: 'cron' as const, jobId: job.id, jobKind: 'prompt' as const },
|
|
224
238
|
...(snap.hasAnyPluginContent ? { hooks: snap.hooks } : {}),
|
|
225
239
|
getTranscriptPath: () => sessionManager.getSessionFile(),
|
|
226
240
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { KNOWN_PROVIDERS, type KnownProviderId } from '@/config/providers'
|
|
2
|
+
|
|
3
|
+
// DEFAULT_ENV_NAMES is the single source of truth for the env-var name each
|
|
4
|
+
// secret-bearing field uses when the user does not override it via the `env`
|
|
5
|
+
// field of a `Secret` object. Three layers depend on it:
|
|
6
|
+
//
|
|
7
|
+
// 1. resolveSecret (src/secrets/resolve.ts) — when the on-disk Secret has
|
|
8
|
+
// no explicit `env`, it falls back to this table to know which env var
|
|
9
|
+
// to consult for env-wins resolution.
|
|
10
|
+
// 2. hydrateChannelEnvFromSecrets (src/secrets/hydrate.ts) — when injecting
|
|
11
|
+
// resolved channel field values into `process.env`, it uses these names
|
|
12
|
+
// so that `src/channels/manager.ts` (which reads `env.DISCORD_BOT_TOKEN`
|
|
13
|
+
// etc. directly) keeps working without per-adapter refactoring.
|
|
14
|
+
// 3. parseSecretsFile legacy upgrade — when reading a v1 file with the old
|
|
15
|
+
// `{ ENV_NAME: value }` channel shape, it inverts this table to rename
|
|
16
|
+
// the keys to the new per-adapter field names.
|
|
17
|
+
//
|
|
18
|
+
// Providers come from `KNOWN_PROVIDERS[id].apiKeyEnv` — derived, not duplicated.
|
|
19
|
+
// OAuth-only providers are intentionally absent: OAuth credentials are not
|
|
20
|
+
// env-injectable (refresh tokens are stateful).
|
|
21
|
+
|
|
22
|
+
export const CHANNEL_FIELD_ENV = {
|
|
23
|
+
'discord-bot': { token: 'DISCORD_BOT_TOKEN' },
|
|
24
|
+
'slack-bot': { botToken: 'SLACK_BOT_TOKEN', appToken: 'SLACK_APP_TOKEN' },
|
|
25
|
+
'telegram-bot': { token: 'TELEGRAM_BOT_TOKEN' },
|
|
26
|
+
} as const satisfies Record<string, Record<string, string>>
|
|
27
|
+
|
|
28
|
+
export type KnownAdapterId = keyof typeof CHANNEL_FIELD_ENV
|
|
29
|
+
|
|
30
|
+
export function isKnownAdapterId(id: string): id is KnownAdapterId {
|
|
31
|
+
return id in CHANNEL_FIELD_ENV
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Reverse map: env-var name -> { adapterId, fieldName }. Built from
|
|
35
|
+
// CHANNEL_FIELD_ENV so adding a new adapter field updates both directions
|
|
36
|
+
// automatically. Used exclusively by the legacy v1 channels-shape upgrade.
|
|
37
|
+
export const CHANNEL_ENV_TO_FIELD: Record<string, { adapterId: KnownAdapterId; fieldName: string }> = (() => {
|
|
38
|
+
const out: Record<string, { adapterId: KnownAdapterId; fieldName: string }> = {}
|
|
39
|
+
for (const [adapterId, fields] of Object.entries(CHANNEL_FIELD_ENV)) {
|
|
40
|
+
for (const [fieldName, envName] of Object.entries(fields)) {
|
|
41
|
+
out[envName] = { adapterId: adapterId as KnownAdapterId, fieldName }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
})()
|
|
46
|
+
|
|
47
|
+
// Returns the default env-var name for a known channel field, or undefined
|
|
48
|
+
// when the adapter or field is not in CHANNEL_FIELD_ENV (forward-compat: a
|
|
49
|
+
// future adapter contributed via plugin would not appear in this table).
|
|
50
|
+
export function channelFieldDefaultEnv(adapterId: string, fieldName: string): string | undefined {
|
|
51
|
+
if (!isKnownAdapterId(adapterId)) return undefined
|
|
52
|
+
const adapterFields = CHANNEL_FIELD_ENV[adapterId] as Record<string, string>
|
|
53
|
+
return adapterFields[fieldName]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Returns the canonical env-var name for an api-key provider, or undefined
|
|
57
|
+
// when the provider is OAuth-only (apiKeyEnv === null in KNOWN_PROVIDERS).
|
|
58
|
+
// OAuth-only providers never participate in env-wins resolution.
|
|
59
|
+
export function providerKeyDefaultEnv(providerId: string): string | undefined {
|
|
60
|
+
const provider = (KNOWN_PROVIDERS as Record<string, { apiKeyEnv: string | null }>)[providerId]
|
|
61
|
+
if (!provider) return undefined
|
|
62
|
+
return provider.apiKeyEnv ?? undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isKnownProviderId(id: string): id is KnownProviderId {
|
|
66
|
+
return id in KNOWN_PROVIDERS
|
|
67
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { channelFieldDefaultEnv } from './defaults'
|
|
5
|
+
import { resolveSecret, secretFieldSchema, type Secret } from './resolve'
|
|
6
|
+
import { parseSecretsFile } from './schema'
|
|
7
|
+
|
|
8
|
+
// hydrateChannelEnvFromSecrets is the seam that lets channel adapters keep
|
|
9
|
+
// reading `process.env[TOKEN_ENV]` (in `src/channels/manager.ts`) without
|
|
10
|
+
// knowing about the new per-adapter Secret-typed config shape. Boot flow:
|
|
11
|
+
//
|
|
12
|
+
// 1. Read secrets.json#channels. Each field is a Secret (string shorthand
|
|
13
|
+
// or `{ value?, env? }` object).
|
|
14
|
+
// 2. For each (adapter, field) pair, look up the default env-var name via
|
|
15
|
+
// CHANNEL_FIELD_ENV (e.g. slack-bot.botToken -> SLACK_BOT_TOKEN).
|
|
16
|
+
// 3. Resolve the Secret via env-wins: if the target env var is already
|
|
17
|
+
// set, do nothing (env wins, intentional, by design). Otherwise inject
|
|
18
|
+
// the resolved file value into process.env under the default env name.
|
|
19
|
+
//
|
|
20
|
+
// Three explicit non-behaviors versus the pre-v2 implementation:
|
|
21
|
+
// - We DO NOT strip `.env` after injecting. Env values stay in `.env`; the
|
|
22
|
+
// boot-time file mutation that previously erased migrated keys is gone.
|
|
23
|
+
// The user's `.env` is treated as a first-class source, not a one-way
|
|
24
|
+
// migration channel.
|
|
25
|
+
// - We DO NOT promote env values into secrets.json. The old
|
|
26
|
+
// `promoteChannelEnvIntoSecrets` step has been deleted as part of the
|
|
27
|
+
// env-wins reshape. If the user wants the value in the file, they put it
|
|
28
|
+
// there explicitly (init writes it, or a manual edit).
|
|
29
|
+
// - We DO NOT touch unknown adapter ids (no entry in CHANNEL_FIELD_ENV)
|
|
30
|
+
// or unknown field names. Skipped silently. A future plugin adapter
|
|
31
|
+
// would need its own injection mechanism; the field-name-keyed shape is
|
|
32
|
+
// reserved for the curated set in CHANNEL_FIELD_ENV.
|
|
33
|
+
//
|
|
34
|
+
// Errors are non-fatal: a missing or malformed `secrets.json` returns an
|
|
35
|
+
// empty result rather than throwing, so an agent that hasn't run init yet
|
|
36
|
+
// can still boot.
|
|
37
|
+
export function hydrateChannelEnvFromSecrets(options: { agentDir: string; env?: NodeJS.ProcessEnv }): {
|
|
38
|
+
applied: string[]
|
|
39
|
+
skipped: string[]
|
|
40
|
+
} {
|
|
41
|
+
const env = options.env ?? process.env
|
|
42
|
+
const secretsPath = join(options.agentDir, 'secrets.json')
|
|
43
|
+
const channels = readChannelSecrets(secretsPath)
|
|
44
|
+
|
|
45
|
+
const applied: string[] = []
|
|
46
|
+
const skipped: string[] = []
|
|
47
|
+
|
|
48
|
+
for (const [adapterId, fields] of Object.entries(channels)) {
|
|
49
|
+
for (const [fieldName, secret] of Object.entries(fields)) {
|
|
50
|
+
const envName = channelFieldDefaultEnv(adapterId, fieldName)
|
|
51
|
+
if (envName === undefined) continue
|
|
52
|
+
|
|
53
|
+
const existing = env[envName]
|
|
54
|
+
if (existing !== undefined && existing !== '') {
|
|
55
|
+
skipped.push(envName)
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const resolved = resolveSecret(secret, envName, env)
|
|
60
|
+
if (resolved === undefined) continue
|
|
61
|
+
|
|
62
|
+
env[envName] = resolved
|
|
63
|
+
applied.push(envName)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { applied, skipped }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readChannelSecrets(secretsPath: string): Record<string, Record<string, Secret>> {
|
|
71
|
+
let raw: string
|
|
72
|
+
try {
|
|
73
|
+
raw = readFileSync(secretsPath, 'utf8')
|
|
74
|
+
} catch {
|
|
75
|
+
return {}
|
|
76
|
+
}
|
|
77
|
+
if (raw.trim() === '') return {}
|
|
78
|
+
let parsed: unknown
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(raw)
|
|
81
|
+
} catch {
|
|
82
|
+
return {}
|
|
83
|
+
}
|
|
84
|
+
const result = parseSecretsFile(parsed)
|
|
85
|
+
if (!result.ok) return {}
|
|
86
|
+
|
|
87
|
+
const out: Record<string, Record<string, Secret>> = {}
|
|
88
|
+
for (const [adapterId, slot] of Object.entries(result.file.channels)) {
|
|
89
|
+
if (typeof slot !== 'object' || slot === null || Array.isArray(slot)) continue
|
|
90
|
+
const slotRecord = slot as Record<string, unknown>
|
|
91
|
+
const fields: Record<string, Secret> = {}
|
|
92
|
+
for (const [fieldName, value] of Object.entries(slotRecord)) {
|
|
93
|
+
const ok = secretFieldSchema.safeParse(value)
|
|
94
|
+
if (ok.success) fields[fieldName] = ok.data
|
|
95
|
+
}
|
|
96
|
+
if (Object.keys(fields).length > 0) out[adapterId] = fields
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
package/src/secrets/index.ts
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
export {
|
|
2
|
-
channelsSchema,
|
|
3
|
-
llmCredentialSchema,
|
|
4
|
-
llmCredentialsSchema,
|
|
5
|
-
parseSecretsFile,
|
|
6
|
-
secretsFileSchema,
|
|
7
|
-
type LlmCredential,
|
|
8
|
-
type LlmCredentials,
|
|
9
|
-
type ParseSecretsResult,
|
|
10
|
-
type SecretsFile,
|
|
11
|
-
} from './schema'
|
|
1
|
+
export { type Channels } from './schema'
|
|
12
2
|
|
|
13
3
|
export { createSecretsStoreForAgent, SecretsBackend } from './storage'
|
|
14
4
|
|
|
15
|
-
export {
|
|
5
|
+
export { type Secret } from './resolve'
|
|
6
|
+
|
|
7
|
+
export { hydrateChannelEnvFromSecrets } from './hydrate'
|
|
8
|
+
|
|
9
|
+
export { migrateKakaotalkCredentials } from './migrate-kakaotalk'
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { KakaoAccountCredentials, KakaoConfig } from 'agent-messenger/kakaotalk'
|
|
2
|
+
import type { PendingLoginState } from 'agent-messenger/kakaotalk'
|
|
3
|
+
|
|
4
|
+
import { sendHttp } from '@/hostd/client'
|
|
5
|
+
|
|
6
|
+
import { type KakaoChannelBlock, kakaoChannelBlockSchema } from './schema'
|
|
7
|
+
import { SecretsBackend } from './storage'
|
|
8
|
+
|
|
9
|
+
export type SecretsKakaoCredentialStoreOptions =
|
|
10
|
+
| { mode: 'host'; secretsPath: string }
|
|
11
|
+
| { mode: 'container'; secretsPath: string; hostdUrl: string; restartToken: string; containerName: string }
|
|
12
|
+
|
|
13
|
+
const EMPTY_BLOCK: KakaoChannelBlock = { currentAccount: null, accounts: {} }
|
|
14
|
+
|
|
15
|
+
export class SecretsKakaoCredentialStore {
|
|
16
|
+
private readonly backend: SecretsBackend
|
|
17
|
+
private writeChain: Promise<void> = Promise.resolve()
|
|
18
|
+
|
|
19
|
+
constructor(private readonly options: SecretsKakaoCredentialStoreOptions) {
|
|
20
|
+
this.backend = new SecretsBackend(options.secretsPath)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async load(): Promise<KakaoConfig> {
|
|
24
|
+
return toKakaoConfig(this.readBlock())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async save(config: KakaoConfig): Promise<void> {
|
|
28
|
+
await this.writeBlock(() => fromKakaoConfig(config))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async getAccount(id?: string): Promise<KakaoAccountCredentials | null> {
|
|
32
|
+
const config = await this.load()
|
|
33
|
+
if (id) return config.accounts[id] ?? null
|
|
34
|
+
if (!config.current_account) return null
|
|
35
|
+
return config.accounts[config.current_account] ?? null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async setAccount(account: KakaoAccountCredentials): Promise<void> {
|
|
39
|
+
await this.writeBlock((block) => {
|
|
40
|
+
const accounts = { ...block.accounts, [account.account_id]: account }
|
|
41
|
+
return { ...block, currentAccount: block.currentAccount ?? account.account_id, accounts }
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async removeAccount(id: string): Promise<void> {
|
|
46
|
+
await this.writeBlock((block) => {
|
|
47
|
+
const accounts = { ...block.accounts }
|
|
48
|
+
delete accounts[id]
|
|
49
|
+
const currentAccount = block.currentAccount === id ? (Object.keys(accounts)[0] ?? null) : block.currentAccount
|
|
50
|
+
return { ...block, currentAccount, accounts }
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async listAccounts(): Promise<Array<KakaoAccountCredentials & { is_current: boolean }>> {
|
|
55
|
+
const config = await this.load()
|
|
56
|
+
return Object.values(config.accounts).map((account) => ({
|
|
57
|
+
...account,
|
|
58
|
+
is_current: account.account_id === config.current_account,
|
|
59
|
+
}))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async setCurrentAccount(id: string): Promise<void> {
|
|
63
|
+
await this.writeBlock((block) => ({ ...block, currentAccount: id }))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async savePendingLogin(state: PendingLoginState): Promise<void> {
|
|
67
|
+
await this.writeBlock((block) => ({ ...block, pendingLogin: state }))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async loadPendingLogin(): Promise<PendingLoginState | null> {
|
|
71
|
+
return this.readBlock().pendingLogin ?? null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async clearPendingLogin(): Promise<void> {
|
|
75
|
+
await this.writeBlock((block) => {
|
|
76
|
+
const { pendingLogin: _pendingLogin, ...next } = block
|
|
77
|
+
return next
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private readBlock(): KakaoChannelBlock {
|
|
82
|
+
const channels =
|
|
83
|
+
this.options.mode === 'container' ? this.backend.tryReadChannelsSync() : this.backend.readChannelsSync()
|
|
84
|
+
const raw = channels?.kakaotalk
|
|
85
|
+
return parseBlock(raw)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async writeBlock(update: (current: KakaoChannelBlock) => KakaoChannelBlock): Promise<void> {
|
|
89
|
+
return this.enqueueWrite(async () => {
|
|
90
|
+
if (this.options.mode === 'container') {
|
|
91
|
+
const next = update(this.readBlock())
|
|
92
|
+
const response = await sendHttp(
|
|
93
|
+
{
|
|
94
|
+
kind: 'secrets-patch',
|
|
95
|
+
containerName: this.options.containerName,
|
|
96
|
+
patch: { channels: { kakaotalk: next } },
|
|
97
|
+
},
|
|
98
|
+
{ url: this.options.hostdUrl, token: this.options.restartToken },
|
|
99
|
+
)
|
|
100
|
+
if (!response.ok) throw new Error(`secrets-patch failed: ${response.reason}`)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await this.backend.updateChannelsAsync(async (channels) => {
|
|
105
|
+
const next = { ...channels, kakaotalk: update(parseBlock(channels.kakaotalk)) }
|
|
106
|
+
return { result: undefined, next }
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private enqueueWrite(op: () => Promise<void>): Promise<void> {
|
|
112
|
+
const next = this.writeChain.then(op, op)
|
|
113
|
+
this.writeChain = next.catch(() => {})
|
|
114
|
+
return next
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseBlock(value: unknown): KakaoChannelBlock {
|
|
119
|
+
if (value === undefined) return EMPTY_BLOCK
|
|
120
|
+
return kakaoChannelBlockSchema.parse(value)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toKakaoConfig(block: KakaoChannelBlock): KakaoConfig {
|
|
124
|
+
return { current_account: block.currentAccount, accounts: block.accounts }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function fromKakaoConfig(config: KakaoConfig): KakaoChannelBlock {
|
|
128
|
+
return { currentAccount: config.current_account, accounts: config.accounts }
|
|
129
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { rename } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { KakaoCredentialManager } from 'agent-messenger/kakaotalk'
|
|
6
|
+
import type { KakaoConfig, PendingLoginState } from 'agent-messenger/kakaotalk'
|
|
7
|
+
|
|
8
|
+
import { type KakaoChannelBlock, kakaoChannelBlockSchema } from './schema'
|
|
9
|
+
import { SecretsBackend } from './storage'
|
|
10
|
+
|
|
11
|
+
const KAKAO_CONFIG_DIR = join('workspace', '.agent-messenger')
|
|
12
|
+
const CREDENTIALS_FILE = 'kakaotalk-credentials.json'
|
|
13
|
+
const PENDING_LOGIN_FILE = 'kakaotalk-pending-login.json'
|
|
14
|
+
|
|
15
|
+
export type KakaotalkCredentialMigrationResult = { promoted: boolean }
|
|
16
|
+
|
|
17
|
+
export async function migrateKakaotalkCredentials(agentDir: string): Promise<KakaotalkCredentialMigrationResult> {
|
|
18
|
+
const configDir = join(agentDir, KAKAO_CONFIG_DIR)
|
|
19
|
+
const credentialsPath = join(configDir, CREDENTIALS_FILE)
|
|
20
|
+
const pendingLoginPath = join(configDir, PENDING_LOGIN_FILE)
|
|
21
|
+
if (!existsSync(credentialsPath) && !existsSync(pendingLoginPath)) return { promoted: false }
|
|
22
|
+
|
|
23
|
+
const secretsPath = join(agentDir, 'secrets.json')
|
|
24
|
+
const legacy = new KakaoCredentialManager(configDir)
|
|
25
|
+
const config = await legacy.load()
|
|
26
|
+
const pendingLogin = await legacy.loadPendingLogin()
|
|
27
|
+
if (Object.keys(config.accounts).length === 0 && pendingLogin === null) return { promoted: false }
|
|
28
|
+
|
|
29
|
+
const backend = new SecretsBackend(secretsPath)
|
|
30
|
+
const result = await backend.updateChannelsAsync(async (channels) => {
|
|
31
|
+
const existing = parseExistingBlock(channels.kakaotalk)
|
|
32
|
+
const next = mergeLegacyBlock(existing, config, pendingLogin)
|
|
33
|
+
if (next === existing) return { result: { promoted: false, renameCredentials: false, renamePending: false } }
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
result: {
|
|
37
|
+
promoted: true,
|
|
38
|
+
renameCredentials: isEmptyBlock(existing) && Object.keys(config.accounts).length > 0,
|
|
39
|
+
renamePending: pendingLogin !== null && existing?.pendingLogin === undefined,
|
|
40
|
+
},
|
|
41
|
+
next: { ...channels, kakaotalk: next },
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
if (!result.promoted) return { promoted: false }
|
|
45
|
+
|
|
46
|
+
if (result.renameCredentials) await renameIfPresent(credentialsPath, `${credentialsPath}.migrated`)
|
|
47
|
+
if (result.renamePending) await renameIfPresent(pendingLoginPath, `${pendingLoginPath}.migrated`)
|
|
48
|
+
return { promoted: true }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseExistingBlock(value: unknown): KakaoChannelBlock | null {
|
|
52
|
+
if (value === undefined) return null
|
|
53
|
+
return kakaoChannelBlockSchema.parse(value)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isEmptyBlock(block: KakaoChannelBlock | null): boolean {
|
|
57
|
+
return (
|
|
58
|
+
block === null ||
|
|
59
|
+
(block.currentAccount === null && Object.keys(block.accounts).length === 0 && block.pendingLogin === undefined)
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mergeLegacyBlock(
|
|
64
|
+
existing: KakaoChannelBlock | null,
|
|
65
|
+
config: KakaoConfig,
|
|
66
|
+
pendingLogin: PendingLoginState | null,
|
|
67
|
+
): KakaoChannelBlock | null {
|
|
68
|
+
if (existing === null || isEmptyBlock(existing)) {
|
|
69
|
+
return {
|
|
70
|
+
currentAccount: config.current_account,
|
|
71
|
+
accounts: config.accounts,
|
|
72
|
+
...(pendingLogin ? { pendingLogin } : {}),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (pendingLogin === null || existing.pendingLogin !== undefined) return existing
|
|
76
|
+
return { ...existing, pendingLogin }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function renameIfPresent(from: string, to: string): Promise<void> {
|
|
80
|
+
if (!existsSync(from)) return
|
|
81
|
+
await rename(from, to)
|
|
82
|
+
}
|
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
|
+
}
|