typeclaw 0.1.2 → 0.1.4
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 +61 -4
- package/src/container/index.ts +2 -0
- package/src/container/start.ts +98 -2
- package/src/doctor/checks.ts +7 -27
- package/src/doctor/commit.ts +44 -3
- package/src/doctor/plugin-bridge.ts +19 -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/dockerfile.ts +83 -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 +49 -2
- package/src/secrets/env.ts +0 -43
package/src/init/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
6
6
|
import { config, configSchema, type Config } from '@/config'
|
|
7
7
|
import { DEFAULT_MODEL_REF, KNOWN_PROVIDERS, providerForModelRef, type KnownModelRef } from '@/config/providers'
|
|
8
8
|
import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
|
|
9
|
+
import { type Channels, type Secret, SecretsBackend } from '@/secrets'
|
|
9
10
|
import { createTui } from '@/tui'
|
|
10
11
|
|
|
11
12
|
import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
|
|
@@ -69,7 +70,13 @@ export type InitStepEvent =
|
|
|
69
70
|
| { step: 'hatching'; phase: 'start' }
|
|
70
71
|
| { step: 'hatching'; phase: 'done'; result: HatchingResult }
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
// `cliEntry` is the path to the running CLI module (typically `process.argv[1]`).
|
|
74
|
+
// When provided, the hatching step threads it into `start()`, which spawns the
|
|
75
|
+
// host daemon and registers the freshly-hatched container with the supervisor +
|
|
76
|
+
// portbroker — same path `typeclaw start` takes. When omitted (test fixtures,
|
|
77
|
+
// programmatic callers that never want a daemon), `start()` skips the daemon
|
|
78
|
+
// path entirely and the container runs unmanaged.
|
|
79
|
+
export type HatchRunner = (options: { cwd: string; port: number; cliEntry?: string }) => Promise<HatchingResult>
|
|
73
80
|
|
|
74
81
|
export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<KakaotalkAuthResult>
|
|
75
82
|
|
|
@@ -104,6 +111,12 @@ export type InitOptions = {
|
|
|
104
111
|
runHatching?: HatchRunner
|
|
105
112
|
runBunInstall?: InstallRunner
|
|
106
113
|
dockerExec?: DockerExec
|
|
114
|
+
// Production CLI callers (src/cli/init.ts) pass `process.argv[1]` so the
|
|
115
|
+
// hatching step's `start()` call spawns the host daemon and registers the
|
|
116
|
+
// freshly-hatched container — same path `typeclaw start` takes. Tests omit
|
|
117
|
+
// this to skip the daemon entirely (matching the existing seam in
|
|
118
|
+
// src/container/start.ts).
|
|
119
|
+
cliEntry?: string
|
|
107
120
|
}
|
|
108
121
|
|
|
109
122
|
export async function runInit({
|
|
@@ -125,6 +138,7 @@ export async function runInit({
|
|
|
125
138
|
runHatching = defaultRunHatching,
|
|
126
139
|
runBunInstall: installRunner = runBunInstall,
|
|
127
140
|
dockerExec,
|
|
141
|
+
cliEntry,
|
|
128
142
|
}: InitOptions): Promise<void> {
|
|
129
143
|
const emit = onProgress ?? (() => {})
|
|
130
144
|
|
|
@@ -217,13 +231,37 @@ export async function runInit({
|
|
|
217
231
|
emit({ step: 'git', phase: 'done', result: git })
|
|
218
232
|
|
|
219
233
|
emit({ step: 'hatching', phase: 'start' })
|
|
220
|
-
const hatching = await runHatching({ cwd, port: config.port })
|
|
234
|
+
const hatching = await runHatching({ cwd, port: config.port, ...(cliEntry !== undefined ? { cliEntry } : {}) })
|
|
221
235
|
emit({ step: 'hatching', phase: 'done', result: hatching })
|
|
222
236
|
}
|
|
223
237
|
|
|
224
|
-
|
|
238
|
+
// Exported for the composition test in index.test.ts: the seam that the
|
|
239
|
+
// hatching-hostd fix turns on (passing `cliEntry` into `start()`) is the bug
|
|
240
|
+
// site itself, so a guard test that proves `defaultRunHatching` forwards
|
|
241
|
+
// `cliEntry` to `start()` is what blocks the regression from coming back.
|
|
242
|
+
// Tests inject `startContainer` and `tui` to avoid Docker / TUI side effects;
|
|
243
|
+
// production callers omit both and get the real `start` + `createTui`.
|
|
244
|
+
export async function defaultRunHatching({
|
|
245
|
+
cwd,
|
|
246
|
+
port,
|
|
247
|
+
cliEntry,
|
|
248
|
+
startContainer = start,
|
|
249
|
+
tui: tuiFactory = createTui,
|
|
250
|
+
waitForAgent: waitForAgentFn = waitForAgent,
|
|
251
|
+
}: {
|
|
252
|
+
cwd: string
|
|
253
|
+
port: number
|
|
254
|
+
cliEntry?: string
|
|
255
|
+
startContainer?: typeof start
|
|
256
|
+
tui?: typeof createTui
|
|
257
|
+
waitForAgent?: typeof waitForAgent
|
|
258
|
+
}): Promise<HatchingResult> {
|
|
225
259
|
try {
|
|
226
|
-
const launch = await
|
|
260
|
+
const launch = await startContainer({
|
|
261
|
+
cwd,
|
|
262
|
+
preferredHostPort: port,
|
|
263
|
+
...(cliEntry !== undefined ? { cliEntry } : {}),
|
|
264
|
+
})
|
|
227
265
|
if (!launch.ok) return { ok: false, reason: launch.reason }
|
|
228
266
|
|
|
229
267
|
// start() may have allocated a different host port (the preferred one was
|
|
@@ -231,9 +269,9 @@ async function defaultRunHatching({ cwd, port }: { cwd: string; port: number }):
|
|
|
231
269
|
// the preferred port, otherwise we'd connect to the wrong service.
|
|
232
270
|
const hostPort = launch.hostPort
|
|
233
271
|
|
|
234
|
-
await
|
|
272
|
+
await waitForAgentFn(`http://localhost:${hostPort}`, { timeoutMs: 30_000 })
|
|
235
273
|
|
|
236
|
-
const tui =
|
|
274
|
+
const tui = tuiFactory({
|
|
237
275
|
url: `ws://localhost:${hostPort}`,
|
|
238
276
|
initialPrompt: HATCHING_PROMPT,
|
|
239
277
|
})
|
|
@@ -343,14 +381,18 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
|
|
|
343
381
|
// immediately populated, so packages/ is the only one that needs this.
|
|
344
382
|
await writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists)
|
|
345
383
|
|
|
346
|
-
// Only fields without sensible defaults elsewhere are emitted
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
384
|
+
// Only fields without sensible defaults elsewhere are emitted, with one
|
|
385
|
+
// exception: `network.blockInternal` is re-emitted at its default value
|
|
386
|
+
// (`true`) because the field is security-relevant and users need to
|
|
387
|
+
// discover it in their `typeclaw.json` to know they can opt out for LAN
|
|
388
|
+
// access. `mounts` defaults to `[]` in configSchema, and the bundled
|
|
389
|
+
// memory plugin owns its own defaults in src/bundled-plugins/memory/
|
|
390
|
+
// index.ts — re-emitting either here would be duplicate noise the user
|
|
391
|
+
// has to maintain in sync with the source of truth.
|
|
351
392
|
const config: Record<string, unknown> = {
|
|
352
393
|
$schema: './node_modules/typeclaw/typeclaw.schema.json',
|
|
353
394
|
model: options.model ?? DEFAULT_MODEL_REF,
|
|
395
|
+
network: { blockInternal: true },
|
|
354
396
|
}
|
|
355
397
|
const channels: Record<string, { allow: string[] }> = {}
|
|
356
398
|
if (options.withDiscord) channels['discord-bot'] = { allow: options.discordAllowAll === false ? [] : ['*'] }
|
|
@@ -516,10 +558,15 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
|
|
|
516
558
|
}
|
|
517
559
|
}
|
|
518
560
|
|
|
519
|
-
// Writes the LLM provider's API key (under its provider-specific
|
|
520
|
-
// e.g. OPENAI_API_KEY or FIREWORKS_API_KEY)
|
|
521
|
-
//
|
|
522
|
-
//
|
|
561
|
+
// Writes the LLM provider's API key to `.env` (under its provider-specific
|
|
562
|
+
// env var, e.g. OPENAI_API_KEY or FIREWORKS_API_KEY) and the channel adapter
|
|
563
|
+
// tokens to `secrets.json#channels`. Two stores on purpose: api-keys land in
|
|
564
|
+
// `.env` to match the `--env-file .env` boot contract (env-wins: `auth.ts`
|
|
565
|
+
// reads the value at runtime via `setRuntimeApiKey` and never persists it to
|
|
566
|
+
// `secrets.json`, see `src/agent/auth.ts`); channel tokens skip the .env hop
|
|
567
|
+
// entirely and land in `secrets.json#channels` as `{ value }` Secrets that
|
|
568
|
+
// `hydrateChannelEnvFromSecrets` injects into `process.env` only when the
|
|
569
|
+
// canonical env var is unset, see `src/secrets/hydrate.ts`.
|
|
523
570
|
export async function writeSecrets(
|
|
524
571
|
root: string,
|
|
525
572
|
{
|
|
@@ -531,8 +578,9 @@ export async function writeSecrets(
|
|
|
531
578
|
telegramBotToken,
|
|
532
579
|
}: {
|
|
533
580
|
model?: KnownModelRef
|
|
534
|
-
// Omitted on the OAuth path — credentials live in secrets.json instead.
|
|
535
|
-
// .env file still gets written
|
|
581
|
+
// Omitted on the OAuth path — credentials live in secrets.json instead.
|
|
582
|
+
// The .env file still gets written (empty) so post-init callers that
|
|
583
|
+
// read it don't ENOENT-crash.
|
|
536
584
|
apiKey?: string
|
|
537
585
|
discordBotToken?: string
|
|
538
586
|
slackBotToken?: string
|
|
@@ -546,22 +594,37 @@ export async function writeSecrets(
|
|
|
546
594
|
if (apiKey !== undefined && apiKeyEnv !== null) {
|
|
547
595
|
lines.push(`${apiKeyEnv}=${apiKey}`)
|
|
548
596
|
}
|
|
597
|
+
const body = lines.length > 0 ? `${lines.join('\n')}\n` : ''
|
|
598
|
+
await writeFile(join(root, SECRETS_FILE), body)
|
|
599
|
+
|
|
600
|
+
const channelTokens: Record<string, Record<string, Secret>> = {}
|
|
549
601
|
if (discordBotToken !== undefined && discordBotToken !== '') {
|
|
550
|
-
|
|
602
|
+
channelTokens['discord-bot'] = { token: { value: discordBotToken } }
|
|
551
603
|
}
|
|
552
604
|
if (slackBotToken !== undefined && slackBotToken !== '') {
|
|
553
|
-
|
|
605
|
+
channelTokens['slack-bot'] = { ...channelTokens['slack-bot'], botToken: { value: slackBotToken } }
|
|
554
606
|
}
|
|
555
607
|
if (slackAppToken !== undefined && slackAppToken !== '') {
|
|
556
|
-
|
|
608
|
+
channelTokens['slack-bot'] = { ...channelTokens['slack-bot'], appToken: { value: slackAppToken } }
|
|
557
609
|
}
|
|
558
610
|
if (telegramBotToken !== undefined && telegramBotToken !== '') {
|
|
559
|
-
|
|
611
|
+
channelTokens['telegram-bot'] = { token: { value: telegramBotToken } }
|
|
560
612
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
|
|
613
|
+
if (Object.keys(channelTokens).length === 0) return
|
|
614
|
+
|
|
615
|
+
const backend = new SecretsBackend(join(root, 'secrets.json'))
|
|
616
|
+
const existing = backend.readChannelsSync()
|
|
617
|
+
const merged: Channels = { ...existing }
|
|
618
|
+
for (const [adapterId, fields] of Object.entries(channelTokens)) {
|
|
619
|
+
const priorSlot = isObjectRecord(merged[adapterId]) ? { ...(merged[adapterId] as Record<string, unknown>) } : {}
|
|
620
|
+
for (const [k, v] of Object.entries(fields)) priorSlot[k] = v
|
|
621
|
+
merged[adapterId] = priorSlot as Channels[string]
|
|
622
|
+
}
|
|
623
|
+
backend.writeChannelsSync(merged)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
627
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
565
628
|
}
|
|
566
629
|
|
|
567
630
|
function resolveLLMAuth(llmAuth: LLMAuth | undefined, apiKey: string | undefined): LLMAuth {
|
|
@@ -635,7 +698,7 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
635
698
|
//
|
|
636
699
|
// We run KakaoTalk auth FIRST so a failed login leaves typeclaw.json and
|
|
637
700
|
// .env untouched. The runtime treats `channels.kakaotalk` without a
|
|
638
|
-
//
|
|
701
|
+
// secrets.json#channels.kakaotalk block as "missing credentials, skip adapter", which silently
|
|
639
702
|
// drops messages — the same trap `runInit` already guards against. Aborting
|
|
640
703
|
// before any file write means the user's next `typeclaw channel add
|
|
641
704
|
// kakaotalk` retry has no half-applied state to clean up.
|
|
@@ -651,7 +714,10 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
651
714
|
emit({ step: 'config', phase: 'done' })
|
|
652
715
|
|
|
653
716
|
emit({ step: 'secrets', phase: 'start' })
|
|
654
|
-
|
|
717
|
+
const tokens = channelSecretsFromOptions(options)
|
|
718
|
+
if (Object.keys(tokens).length > 0) {
|
|
719
|
+
await appendChannelSecrets(options.cwd, options.channel, tokens)
|
|
720
|
+
}
|
|
655
721
|
emit({ step: 'secrets', phase: 'done' })
|
|
656
722
|
}
|
|
657
723
|
|
|
@@ -666,13 +732,14 @@ function defaultAllowAll(channel: ChannelKind): boolean {
|
|
|
666
732
|
function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
|
|
667
733
|
switch (options.channel) {
|
|
668
734
|
case 'discord-bot':
|
|
669
|
-
return {
|
|
735
|
+
return { token: options.discordBotToken }
|
|
670
736
|
case 'slack-bot':
|
|
671
|
-
return {
|
|
737
|
+
return { botToken: options.slackBotToken, appToken: options.slackAppToken }
|
|
672
738
|
case 'telegram-bot':
|
|
673
|
-
return {
|
|
739
|
+
return { token: options.telegramBotToken }
|
|
674
740
|
case 'kakaotalk':
|
|
675
|
-
//
|
|
741
|
+
// KakaoTalk auth writes its structured multi-account block directly to
|
|
742
|
+
// secrets.json#channels.kakaotalk before config mutation.
|
|
676
743
|
return {}
|
|
677
744
|
}
|
|
678
745
|
}
|
|
@@ -741,56 +808,35 @@ function buildAllow(channel: ChannelKind, allowAll: boolean): string[] {
|
|
|
741
808
|
return allowAll ? ['*'] : []
|
|
742
809
|
}
|
|
743
810
|
|
|
744
|
-
//
|
|
745
|
-
// existing
|
|
746
|
-
//
|
|
747
|
-
// hand-
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
if (
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
try {
|
|
758
|
-
existing = await readFile(path, 'utf8')
|
|
759
|
-
} catch (error) {
|
|
760
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
761
|
-
throw new Error(
|
|
762
|
-
`${SECRETS_FILE} not found at ${cwd}. Run \`typeclaw init\` before adding channels, or run this command from inside an agent folder.`,
|
|
763
|
-
)
|
|
764
|
-
}
|
|
765
|
-
throw error
|
|
811
|
+
// Writes per-adapter field values into `secrets.json#channels.<adapter>`.
|
|
812
|
+
// Refuses to overwrite existing fields: if the user already has e.g.
|
|
813
|
+
// `botToken` recorded (from a prior `channel add` whose follow-up steps
|
|
814
|
+
// failed, or a hand-edit), we surface that as a hard error rather than
|
|
815
|
+
// silently displace it. Same trap the original .env-append path guarded
|
|
816
|
+
// against, applied to the field-keyed destination.
|
|
817
|
+
async function appendChannelSecrets(cwd: string, channel: ChannelKind, tokens: ChannelSecrets): Promise<void> {
|
|
818
|
+
if (Object.keys(tokens).length === 0) return
|
|
819
|
+
|
|
820
|
+
if (!existsSync(join(cwd, CONFIG_FILE))) {
|
|
821
|
+
throw new Error(
|
|
822
|
+
`${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` before adding channels, or run this command from inside an agent folder.`,
|
|
823
|
+
)
|
|
766
824
|
}
|
|
767
825
|
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
826
|
+
const backend = new SecretsBackend(join(cwd, 'secrets.json'))
|
|
827
|
+
const channels: Record<string, unknown> = backend.readChannelsSync()
|
|
828
|
+
const slot: Record<string, unknown> = isObjectRecord(channels[channel])
|
|
829
|
+
? { ...(channels[channel] as Record<string, unknown>) }
|
|
830
|
+
: {}
|
|
831
|
+
|
|
832
|
+
for (const field of Object.keys(tokens)) {
|
|
833
|
+
if (slot[field] !== undefined) {
|
|
771
834
|
throw new Error(
|
|
772
|
-
`${
|
|
835
|
+
`${field} is already set in secrets.json under "${channel}". Remove it before re-adding the channel, or edit the value by hand.`,
|
|
773
836
|
)
|
|
774
837
|
}
|
|
775
838
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
const trailingNewline = existing.endsWith('\n') || existing === '' ? '' : '\n'
|
|
780
|
-
const appended = Object.entries(secrets)
|
|
781
|
-
.map(([k, v]) => `${k}=${v}`)
|
|
782
|
-
.join('\n')
|
|
783
|
-
await writeFile(path, `${existing}${trailingNewline}${appended}\n`)
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
function parseEnvKeys(content: string): Set<string> {
|
|
787
|
-
const keys = new Set<string>()
|
|
788
|
-
for (const rawLine of content.split('\n')) {
|
|
789
|
-
const line = rawLine.trim()
|
|
790
|
-
if (line === '' || line.startsWith('#')) continue
|
|
791
|
-
const eq = line.indexOf('=')
|
|
792
|
-
if (eq <= 0) continue
|
|
793
|
-
keys.add(line.slice(0, eq).trim())
|
|
794
|
-
}
|
|
795
|
-
return keys
|
|
839
|
+
for (const [k, v] of Object.entries(tokens)) slot[k] = { value: v } satisfies Secret
|
|
840
|
+
channels[channel] = slot
|
|
841
|
+
backend.writeChannelsSync(channels as Channels)
|
|
796
842
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from 'node:module'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
|
|
5
5
|
|
|
6
6
|
export type KakaotalkBootstrapStatus = { ok: true } | { ok: false; reason: string }
|
|
7
7
|
|
|
@@ -50,11 +50,17 @@ export function kakaotalkConfigDir(agentDir: string): string {
|
|
|
50
50
|
return join(agentDir, 'workspace', '.agent-messenger')
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export function kakaotalkSecretsPath(agentDir: string): string {
|
|
54
|
+
return join(agentDir, 'secrets.json')
|
|
55
|
+
}
|
|
56
|
+
|
|
53
57
|
export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise<KakaotalkBootstrapStatus> {
|
|
54
|
-
const configDir = kakaotalkConfigDir(input.agentDir)
|
|
55
58
|
try {
|
|
56
59
|
const loginFlow = input.loginFlow ?? (await resolveLoginFlow())
|
|
57
|
-
const credManager = new
|
|
60
|
+
const credManager = new SecretsKakaoCredentialStore({
|
|
61
|
+
mode: 'host',
|
|
62
|
+
secretsPath: kakaotalkSecretsPath(input.agentDir),
|
|
63
|
+
})
|
|
58
64
|
const pending = await credManager.loadPendingLogin()
|
|
59
65
|
const existing = await credManager.getAccount()
|
|
60
66
|
const savedDeviceUuid =
|
|
@@ -34,3 +34,37 @@ export async function runBunInstall(cwd: string): Promise<InstallResult> {
|
|
|
34
34
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
// Signature for the function that re-resolves a SINGLE dep against its
|
|
39
|
+
// current spec. Distinct from InstallRunner because the auto-upgrade path
|
|
40
|
+
// MUST force re-resolution against the registry (lockfile-honoring `bun
|
|
41
|
+
// install` would no-op when the existing lockfile entry already satisfies
|
|
42
|
+
// an in-range spec — which is the exact regression auto-upgrade exists to
|
|
43
|
+
// prevent).
|
|
44
|
+
export type UpdateRunner = (cwd: string, pkg: string) => Promise<InstallResult>
|
|
45
|
+
|
|
46
|
+
export async function runBunUpdate(cwd: string, pkg: string): Promise<InstallResult> {
|
|
47
|
+
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
48
|
+
if (!bun) return { ok: false, reason: 'bun runtime not available' }
|
|
49
|
+
try {
|
|
50
|
+
const proc = bun.spawn({
|
|
51
|
+
// `bun update <pkg> --latest` re-resolves <pkg> against the registry,
|
|
52
|
+
// capped by the spec in package.json. For a caret/tilde range this
|
|
53
|
+
// pulls the highest in-range version (the case `bun install` won't
|
|
54
|
+
// upgrade because the lockfile already satisfies the spec). For an
|
|
55
|
+
// exact pin it's effectively a force re-fetch of that exact version.
|
|
56
|
+
// `--linker=hoisted` for the same Bun 1.3.x deadlock reason as
|
|
57
|
+
// runBunInstall above.
|
|
58
|
+
cmd: ['bun', 'update', pkg, '--latest', '--linker=hoisted'],
|
|
59
|
+
cwd,
|
|
60
|
+
stdout: 'pipe',
|
|
61
|
+
stderr: 'pipe',
|
|
62
|
+
})
|
|
63
|
+
const code = await proc.exited
|
|
64
|
+
if (code === 0) return { ok: true }
|
|
65
|
+
const stderr = await new Response(proc.stderr).text()
|
|
66
|
+
return { ok: false, reason: `bun update ${pkg} exited with code ${code}: ${stderr.trim() || 'no stderr'}` }
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -3,6 +3,7 @@ import backupPlugin from '@/bundled-plugins/backup'
|
|
|
3
3
|
import guardPlugin from '@/bundled-plugins/guard'
|
|
4
4
|
import memoryPlugin from '@/bundled-plugins/memory'
|
|
5
5
|
import securityPlugin from '@/bundled-plugins/security'
|
|
6
|
+
import toolResultCapPlugin from '@/bundled-plugins/tool-result-cap'
|
|
6
7
|
import type { ResolvedPlugin } from '@/plugin'
|
|
7
8
|
|
|
8
9
|
// Consumed by both `startAgent` (auto-loaded before user plugins) AND
|
|
@@ -18,6 +19,11 @@ import type { ResolvedPlugin } from '@/plugin'
|
|
|
18
19
|
// guard disjoint surfaces, but seeding the order now means future overlap
|
|
19
20
|
// (e.g. a security policy on writes) blocks before guard's softer advice.
|
|
20
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
|
+
//
|
|
21
27
|
// `memory` is registered before `backup` so memory's dreaming commits always
|
|
22
28
|
// land in the same git index window before backup's commit-and-push cycle.
|
|
23
29
|
// They commit disjoint paths today (memory/ vs sessions/ + agent changes),
|
|
@@ -25,6 +31,7 @@ import type { ResolvedPlugin } from '@/plugin'
|
|
|
25
31
|
// contention easier to reason about.
|
|
26
32
|
export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
|
|
27
33
|
{ name: 'security', version: undefined, source: '<bundled>', defined: securityPlugin },
|
|
34
|
+
{ name: 'tool-result-cap', version: undefined, source: '<bundled>', defined: toolResultCapPlugin },
|
|
28
35
|
{ name: 'guard', version: undefined, source: '<bundled>', defined: guardPlugin },
|
|
29
36
|
{ name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
|
|
30
37
|
{ name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
|
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,
|
|
@@ -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'
|