typeclaw 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -13
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +13 -10
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +137 -7
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +809 -300
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +11 -3
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +13 -3
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +491 -19
- package/src/config/index.ts +15 -1
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +6 -1
- package/src/container/port.ts +10 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +81 -63
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +51 -34
- package/src/doctor/plugin-bridge.ts +28 -4
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +36 -10
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +213 -85
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/reload/client.ts +25 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +68 -7
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +83 -0
- package/src/server/index.ts +198 -71
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +104 -112
- package/src/skills/typeclaw-memory/SKILL.md +9 -9
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +134 -98
package/src/init/dockerfile.ts
CHANGED
|
@@ -136,14 +136,38 @@ export const NETWORK_BLOCK_IPV6_NETS = ['fc00::/7', 'fe80::/10', 'ff00::/8', '::
|
|
|
136
136
|
// Carve-out ordering is load-bearing. iptables OUTPUT is first-match-wins,
|
|
137
137
|
// and we use -A (append). So the order written into the shim is the order
|
|
138
138
|
// rules will be evaluated:
|
|
139
|
-
// 1.
|
|
140
|
-
//
|
|
141
|
-
//
|
|
139
|
+
// 1. ESTABLISHED,RELATED ACCEPT (return path for any connection initiated
|
|
140
|
+
// from outside the container — see comment block below)
|
|
141
|
+
// 2. loopback ACCEPT
|
|
142
|
+
// 3. hostd port ACCEPT (narrow: tcp + single dport on the host gateway)
|
|
143
|
+
// 4. resolver ACCEPT (narrow: udp/tcp dport 53 to each /etc/resolv.conf
|
|
142
144
|
// nameserver) — gated on TYPECLAW_NETWORK_AUTO_ALLOW_RESOLVERS=1
|
|
143
|
-
//
|
|
145
|
+
// 5. user-supplied allowlist ACCEPT (wholesale: -d <cidr>) — driven by
|
|
144
146
|
// TYPECLAW_NETWORK_ALLOW comma-separated env
|
|
145
|
-
//
|
|
146
|
-
// A resolver at 10.0.0.2 hits (
|
|
147
|
+
// 6. RFC1918 + link-local + CGNAT + multicast + reserved REJECTs
|
|
148
|
+
// A resolver at 10.0.0.2 hits (4) and ACCEPTs before (6) DROPs it.
|
|
149
|
+
//
|
|
150
|
+
// Rule 1 (conntrack ESTABLISHED,RELATED) is what makes Docker port-forward
|
|
151
|
+
// reply traffic survive the RFC1918 REJECT. On Docker Desktop and OrbStack
|
|
152
|
+
// the bridge gateway is in 192.168.0.0/16 (OrbStack: 192.168.215.1 or
|
|
153
|
+
// 192.168.139.1; Docker Desktop: 192.168.65.1). A host -> container request
|
|
154
|
+
// via `docker run -p 127.0.0.1:HOST:CONTAINER` arrives at the container
|
|
155
|
+
// from the bridge gateway IP. Without rule 1, the reply packets would
|
|
156
|
+
// match rule 6 (192.168.0.0/16 REJECT) and never reach the host — TCP
|
|
157
|
+
// handshake completes (kernel SYN/ACK is in INPUT, not OUTPUT), the
|
|
158
|
+
// request body is delivered, but the agent's HTTP response is dropped at
|
|
159
|
+
// OUTPUT. Symptom: `curl http://127.0.0.1:HOST` connects but receives
|
|
160
|
+
// zero bytes and times out. Stateful inversion via conntrack is the
|
|
161
|
+
// canonical fix: ESTABLISHED matches packets belonging to a connection
|
|
162
|
+
// the kernel already tracks (including the inbound port-forward), and
|
|
163
|
+
// RELATED covers ICMP error packets for those connections. No new
|
|
164
|
+
// outbound capability is granted — a compromised agent still cannot
|
|
165
|
+
// initiate connections to RFC1918, only respond to inbound ones.
|
|
166
|
+
//
|
|
167
|
+
// Requires the `xt_conntrack` kernel module (universal on Linux 2.6.20+
|
|
168
|
+
// and on every Docker/OrbStack VM kernel) and the userspace iptables
|
|
169
|
+
// `conntrack` match (shipped in the `iptables` Debian package on trixie
|
|
170
|
+
// alongside the binary itself; no extra apt install needed).
|
|
147
171
|
//
|
|
148
172
|
// The resolver carve-out reads /etc/resolv.conf inside the container, NOT
|
|
149
173
|
// on the host. Docker propagates the host's resolver into the container by
|
|
@@ -186,6 +210,7 @@ if [ "\${TYPECLAW_NETWORK_BLOCK_INTERNAL:-0}" != "1" ]; then
|
|
|
186
210
|
exec bun run typeclaw "$@"
|
|
187
211
|
fi
|
|
188
212
|
|
|
213
|
+
iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
189
214
|
iptables -A OUTPUT -o lo -j ACCEPT
|
|
190
215
|
|
|
191
216
|
# Hostd HTTP control carve-out: narrow ACCEPT, scoped to one TCP port on
|
|
@@ -222,6 +247,7 @@ if [ -n "\${TYPECLAW_NETWORK_ALLOW:-}" ]; then
|
|
|
222
247
|
fi
|
|
223
248
|
${ipv4Rules.join('\n')}
|
|
224
249
|
|
|
250
|
+
ip6tables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
225
251
|
ip6tables -A OUTPUT -o lo -j ACCEPT
|
|
226
252
|
${ipv6Rules.join('\n')}
|
|
227
253
|
|
|
@@ -384,7 +410,7 @@ ${ghKeyringLayer}# Layer 2 (changes when the package list changes): the actual a
|
|
|
384
410
|
# Cache mounts make a re-install nearly free when this layer is invalidated:
|
|
385
411
|
# .deb files come straight from the host's BuildKit cache instead of being
|
|
386
412
|
# refetched from Debian/GitHub mirrors. Package set is composed from the
|
|
387
|
-
# \`
|
|
413
|
+
# \`docker.file\` config block in typeclaw.json — toggles for tmux/python/gh/
|
|
388
414
|
# ffmpeg fan out into the args below. Baseline (git/ca-certificates/curl/
|
|
389
415
|
# gnupg) is always installed because downstream layers depend on it.
|
|
390
416
|
#
|
|
@@ -417,7 +443,7 @@ ${renderEntrypointShimLayer()}
|
|
|
417
443
|
|
|
418
444
|
function renderToggleAptInstallLayer(toggleAptArgs: string[]): string {
|
|
419
445
|
return `# Layer 1 (toggle apt install): packages requested via typeclaw.json
|
|
420
|
-
# #
|
|
446
|
+
# #docker.file toggles. Baseline + Chrome runtime libs are already in the
|
|
421
447
|
# base image; this layer only adds gh/tmux/python/ffmpeg if enabled.
|
|
422
448
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
423
449
|
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
|
|
@@ -432,7 +458,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
|
432
458
|
// two cannot drift — the published image is a function of this source, not
|
|
433
459
|
// a checked-in Dockerfile that needs hand-syncing. The base intentionally
|
|
434
460
|
// stops before the per-agent layers (gh keyring, apt feature toggles,
|
|
435
|
-
//
|
|
461
|
+
// docker.file.append, ENV, ENTRYPOINT) so users can still toggle them via
|
|
436
462
|
// typeclaw.json without forcing a base-image rebuild.
|
|
437
463
|
//
|
|
438
464
|
// Layer 2's apt-get install line installs only the baseline packages, NOT
|
|
@@ -587,7 +613,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
|
587
613
|
|
|
588
614
|
function renderCustomDockerfileLines(lines: string[]): string {
|
|
589
615
|
if (lines.length === 0) return ''
|
|
590
|
-
return `# Custom lines from typeclaw.json#
|
|
616
|
+
return `# Custom lines from typeclaw.json#docker.file.append.
|
|
591
617
|
${lines.join('\n')}
|
|
592
618
|
|
|
593
619
|
`
|
package/src/init/gitignore.ts
CHANGED
|
@@ -39,7 +39,7 @@ channels/
|
|
|
39
39
|
|
|
40
40
|
function renderCustomGitignoreEntries(entries: string[]): string {
|
|
41
41
|
if (entries.length === 0) return ''
|
|
42
|
-
return `# Custom entries from typeclaw.json#
|
|
42
|
+
return `# Custom entries from typeclaw.json#git.ignore.append.
|
|
43
43
|
${entries.join('\n')}
|
|
44
44
|
|
|
45
45
|
`
|
package/src/init/index.ts
CHANGED
|
@@ -3,10 +3,16 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
|
|
6
|
-
import { config, configSchema, type Config } from '@/config'
|
|
7
|
-
import {
|
|
6
|
+
import { config, configSchema, migrateLegacyConfigShape, type Config } from '@/config'
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_MODEL_REF,
|
|
9
|
+
KNOWN_PROVIDERS,
|
|
10
|
+
providerForModelRef,
|
|
11
|
+
type KnownModelRef,
|
|
12
|
+
type KnownProviderId,
|
|
13
|
+
} from '@/config/providers'
|
|
8
14
|
import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
|
|
9
|
-
import { type Channels, type Secret, SecretsBackend } from '@/secrets'
|
|
15
|
+
import { createSecretsStoreForAgent, type Channels, type Secret, SecretsBackend } from '@/secrets'
|
|
10
16
|
import { createTui } from '@/tui'
|
|
11
17
|
|
|
12
18
|
import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
|
|
@@ -23,7 +29,6 @@ export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
|
|
|
23
29
|
|
|
24
30
|
const CONFIG_FILE = 'typeclaw.json'
|
|
25
31
|
const CRON_FILE = 'cron.json'
|
|
26
|
-
const SECRETS_FILE = '.env'
|
|
27
32
|
const PACKAGE_FILE = 'package.json'
|
|
28
33
|
|
|
29
34
|
const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as const
|
|
@@ -76,7 +81,15 @@ export type InitStepEvent =
|
|
|
76
81
|
// portbroker — same path `typeclaw start` takes. When omitted (test fixtures,
|
|
77
82
|
// programmatic callers that never want a daemon), `start()` skips the daemon
|
|
78
83
|
// path entirely and the container runs unmanaged.
|
|
79
|
-
export type HatchRunner = (options: {
|
|
84
|
+
export type HatchRunner = (options: {
|
|
85
|
+
cwd: string
|
|
86
|
+
port: number
|
|
87
|
+
cliEntry?: string
|
|
88
|
+
// Set when the wizard wired at least one channel adapter, so the runner
|
|
89
|
+
// can offer to run `typeclaw role claim` after the container is ready.
|
|
90
|
+
// Empty / undefined means "no channels — skip the claim flow".
|
|
91
|
+
configuredChannels?: readonly ChannelKind[]
|
|
92
|
+
}) => Promise<HatchingResult>
|
|
80
93
|
|
|
81
94
|
export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<KakaotalkAuthResult>
|
|
82
95
|
|
|
@@ -96,16 +109,27 @@ export type InitOptions = {
|
|
|
96
109
|
// defaults to the api-key path with `apiKey` (legacy field, still
|
|
97
110
|
// supported for backwards compat with the old `runInit` signature).
|
|
98
111
|
llmAuth?: LLMAuth
|
|
112
|
+
// Optional second model + auth, written as `models.vision` when the
|
|
113
|
+
// default model is text-only. Auth is reused from the default path
|
|
114
|
+
// when both refer to the same provider; the wizard enforces this
|
|
115
|
+
// pairing rule, so by the time we get here `visionAuth` is either
|
|
116
|
+
// (a) absent, or (b) the right auth for `visionModel`'s provider.
|
|
117
|
+
visionModel?: KnownModelRef
|
|
118
|
+
visionAuth?: LLMAuth
|
|
99
119
|
apiKey?: string
|
|
100
120
|
discordBotToken?: string
|
|
101
|
-
discordAllowAll?: boolean
|
|
102
121
|
slackBotToken?: string
|
|
103
122
|
slackAppToken?: string
|
|
104
|
-
slackAllowAll?: boolean
|
|
105
123
|
telegramBotToken?: string
|
|
106
|
-
|
|
124
|
+
// When reusing existing channel credentials from a pre-init `secrets.json`,
|
|
125
|
+
// the CLI passes `with<Adapter>: true` without a corresponding token so the
|
|
126
|
+
// scaffolded `typeclaw.json` wires the adapter while `writeSecrets` leaves
|
|
127
|
+
// the existing slot in `secrets.json#channels` untouched. Defaults below
|
|
128
|
+
// mirror the legacy derivation (`<token> !== undefined && !== ''`).
|
|
129
|
+
withDiscord?: boolean
|
|
130
|
+
withSlack?: boolean
|
|
131
|
+
withTelegram?: boolean
|
|
107
132
|
withKakaotalk?: boolean
|
|
108
|
-
kakaotalkAllowAll?: boolean
|
|
109
133
|
runKakaotalkAuth?: KakaotalkAuthRunner
|
|
110
134
|
onProgress?: (event: InitStepEvent) => void
|
|
111
135
|
runHatching?: HatchRunner
|
|
@@ -124,15 +148,16 @@ export async function runInit({
|
|
|
124
148
|
apiKey,
|
|
125
149
|
llmAuth,
|
|
126
150
|
model = DEFAULT_MODEL_REF,
|
|
151
|
+
visionModel,
|
|
152
|
+
visionAuth,
|
|
127
153
|
discordBotToken,
|
|
128
|
-
discordAllowAll = true,
|
|
129
154
|
slackBotToken,
|
|
130
155
|
slackAppToken,
|
|
131
|
-
slackAllowAll = true,
|
|
132
156
|
telegramBotToken,
|
|
133
|
-
|
|
157
|
+
withDiscord,
|
|
158
|
+
withSlack,
|
|
159
|
+
withTelegram,
|
|
134
160
|
withKakaotalk = false,
|
|
135
|
-
kakaotalkAllowAll = false,
|
|
136
161
|
runKakaotalkAuth,
|
|
137
162
|
onProgress,
|
|
138
163
|
runHatching = defaultRunHatching,
|
|
@@ -175,20 +200,36 @@ export async function runInit({
|
|
|
175
200
|
}
|
|
176
201
|
}
|
|
177
202
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
203
|
+
// When the vision profile uses a different provider than the default, its
|
|
204
|
+
// OAuth login runs here too, before any file write. Same-provider vision
|
|
205
|
+
// reuses the default auth (no separate login). API-key vision auth is
|
|
206
|
+
// captured in memory and persisted by writeSecrets() below.
|
|
207
|
+
if (
|
|
208
|
+
visionAuth !== undefined &&
|
|
209
|
+
visionAuth.kind === 'oauth' &&
|
|
210
|
+
visionModel !== undefined &&
|
|
211
|
+
providerForModelRef(visionModel) !== providerForModelRef(model)
|
|
212
|
+
) {
|
|
213
|
+
emit({ step: 'oauth-login', phase: 'start' })
|
|
214
|
+
await mkdir(cwd, { recursive: true })
|
|
215
|
+
const result = await visionAuth.runLogin({ cwd, model: visionModel })
|
|
216
|
+
emit({ step: 'oauth-login', phase: 'done', result })
|
|
217
|
+
if (!result.ok) {
|
|
218
|
+
throw new Error(`OAuth login failed: ${result.reason}`)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const wantsDiscord = withDiscord ?? (discordBotToken !== undefined && discordBotToken !== '')
|
|
223
|
+
const wantsSlack = withSlack ?? (slackBotToken !== undefined && slackBotToken !== '')
|
|
224
|
+
const wantsTelegram = withTelegram ?? (telegramBotToken !== undefined && telegramBotToken !== '')
|
|
181
225
|
emit({ step: 'scaffold', phase: 'start' })
|
|
182
226
|
await scaffold(cwd, {
|
|
183
227
|
model,
|
|
228
|
+
...(visionModel !== undefined ? { visionModel } : {}),
|
|
184
229
|
withDiscord: wantsDiscord,
|
|
185
|
-
discordAllowAll,
|
|
186
230
|
withSlack: wantsSlack,
|
|
187
|
-
slackAllowAll,
|
|
188
231
|
withTelegram: wantsTelegram,
|
|
189
|
-
telegramAllowAll,
|
|
190
232
|
withKakaotalk,
|
|
191
|
-
kakaotalkAllowAll,
|
|
192
233
|
})
|
|
193
234
|
// Only write the LLM API key on the api-key path. OAuth providers persist
|
|
194
235
|
// their credentials to secrets.json (via the OAuth login step above); writing
|
|
@@ -196,6 +237,9 @@ export async function runInit({
|
|
|
196
237
|
await writeSecrets(cwd, {
|
|
197
238
|
model,
|
|
198
239
|
apiKey: resolvedAuth.kind === 'api-key' ? resolvedAuth.apiKey : undefined,
|
|
240
|
+
...(visionModel !== undefined && visionAuth?.kind === 'api-key'
|
|
241
|
+
? { visionModel, visionApiKey: visionAuth.apiKey }
|
|
242
|
+
: {}),
|
|
199
243
|
discordBotToken,
|
|
200
244
|
slackBotToken,
|
|
201
245
|
slackAppToken,
|
|
@@ -230,8 +274,19 @@ export async function runInit({
|
|
|
230
274
|
const git = await initGitRepo(cwd)
|
|
231
275
|
emit({ step: 'git', phase: 'done', result: git })
|
|
232
276
|
|
|
277
|
+
const configuredChannels: ChannelKind[] = []
|
|
278
|
+
if (wantsDiscord) configuredChannels.push('discord-bot')
|
|
279
|
+
if (wantsSlack) configuredChannels.push('slack-bot')
|
|
280
|
+
if (wantsTelegram) configuredChannels.push('telegram-bot')
|
|
281
|
+
if (withKakaotalk) configuredChannels.push('kakaotalk')
|
|
282
|
+
|
|
233
283
|
emit({ step: 'hatching', phase: 'start' })
|
|
234
|
-
const hatching = await runHatching({
|
|
284
|
+
const hatching = await runHatching({
|
|
285
|
+
cwd,
|
|
286
|
+
port: config.port,
|
|
287
|
+
...(cliEntry !== undefined ? { cliEntry } : {}),
|
|
288
|
+
...(configuredChannels.length > 0 ? { configuredChannels } : {}),
|
|
289
|
+
})
|
|
235
290
|
emit({ step: 'hatching', phase: 'done', result: hatching })
|
|
236
291
|
}
|
|
237
292
|
|
|
@@ -245,16 +300,20 @@ export async function defaultRunHatching({
|
|
|
245
300
|
cwd,
|
|
246
301
|
port,
|
|
247
302
|
cliEntry,
|
|
303
|
+
configuredChannels,
|
|
248
304
|
startContainer = start,
|
|
249
305
|
tui: tuiFactory = createTui,
|
|
250
306
|
waitForAgent: waitForAgentFn = waitForAgent,
|
|
307
|
+
runClaim = defaultRunClaim,
|
|
251
308
|
}: {
|
|
252
309
|
cwd: string
|
|
253
310
|
port: number
|
|
254
311
|
cliEntry?: string
|
|
312
|
+
configuredChannels?: readonly ChannelKind[]
|
|
255
313
|
startContainer?: typeof start
|
|
256
314
|
tui?: typeof createTui
|
|
257
315
|
waitForAgent?: typeof waitForAgent
|
|
316
|
+
runClaim?: ClaimRunner
|
|
258
317
|
}): Promise<HatchingResult> {
|
|
259
318
|
try {
|
|
260
319
|
const launch = await startContainer({
|
|
@@ -269,10 +328,15 @@ export async function defaultRunHatching({
|
|
|
269
328
|
// the preferred port, otherwise we'd connect to the wrong service.
|
|
270
329
|
const hostPort = launch.hostPort
|
|
271
330
|
|
|
272
|
-
await waitForAgentFn(`http://
|
|
331
|
+
await waitForAgentFn(`http://127.0.0.1:${hostPort}`, { timeoutMs: 30_000 })
|
|
332
|
+
|
|
333
|
+
if (configuredChannels !== undefined && configuredChannels.length > 0) {
|
|
334
|
+
const url = buildTuiUrl(hostPort, launch.tuiToken)
|
|
335
|
+
await runClaim({ url, configuredChannels })
|
|
336
|
+
}
|
|
273
337
|
|
|
274
338
|
const tui = tuiFactory({
|
|
275
|
-
url:
|
|
339
|
+
url: buildTuiUrl(hostPort, launch.tuiToken),
|
|
276
340
|
initialPrompt: HATCHING_PROMPT,
|
|
277
341
|
})
|
|
278
342
|
await tui.run()
|
|
@@ -282,6 +346,19 @@ export async function defaultRunHatching({
|
|
|
282
346
|
}
|
|
283
347
|
}
|
|
284
348
|
|
|
349
|
+
export type ClaimRunner = (options: { url: string; configuredChannels: readonly ChannelKind[] }) => Promise<void>
|
|
350
|
+
|
|
351
|
+
const defaultRunClaim: ClaimRunner = async ({ url, configuredChannels }) => {
|
|
352
|
+
const { runOwnerClaim } = await import('./run-owner-claim')
|
|
353
|
+
await runOwnerClaim({ url, configuredChannels })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function buildTuiUrl(hostPort: number, token: string | null): string {
|
|
357
|
+
const url = new URL(`ws://127.0.0.1:${hostPort}`)
|
|
358
|
+
if (token !== null) url.searchParams.set('token', token)
|
|
359
|
+
return url.toString()
|
|
360
|
+
}
|
|
361
|
+
|
|
285
362
|
// Probe the server's plain HTTP fallback (non-upgrade requests get a 200 with
|
|
286
363
|
// body "typeclaw agent") instead of opening a WebSocket. Opening a WS here
|
|
287
364
|
// would trigger createSession on the server and burn an LLM session just to
|
|
@@ -361,14 +438,11 @@ export async function isHatched(dir: string): Promise<boolean> {
|
|
|
361
438
|
|
|
362
439
|
export type ScaffoldOptions = {
|
|
363
440
|
model?: KnownModelRef
|
|
441
|
+
visionModel?: KnownModelRef
|
|
364
442
|
withDiscord?: boolean
|
|
365
|
-
discordAllowAll?: boolean
|
|
366
443
|
withSlack?: boolean
|
|
367
|
-
slackAllowAll?: boolean
|
|
368
444
|
withTelegram?: boolean
|
|
369
|
-
telegramAllowAll?: boolean
|
|
370
445
|
withKakaotalk?: boolean
|
|
371
|
-
kakaotalkAllowAll?: boolean
|
|
372
446
|
}
|
|
373
447
|
|
|
374
448
|
export async function scaffold(root: string, options: ScaffoldOptions = {}): Promise<void> {
|
|
@@ -381,30 +455,22 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
|
|
|
381
455
|
// immediately populated, so packages/ is the only one that needs this.
|
|
382
456
|
await writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists)
|
|
383
457
|
|
|
384
|
-
// Only fields without sensible defaults elsewhere are emitted
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
// has to maintain in sync with the source of truth.
|
|
458
|
+
// Only fields without sensible defaults elsewhere are emitted. Everything
|
|
459
|
+
// with a schema-provided default (e.g. `network.blockInternal`, `mounts`,
|
|
460
|
+
// `memory.*`) is omitted to keep the scaffold minimal — duplicating defaults
|
|
461
|
+
// here would mean every schema change has to be mirrored in two places, and
|
|
462
|
+
// users would feel obligated to maintain values they never set.
|
|
463
|
+
const models: Record<string, KnownModelRef> = { default: options.model ?? DEFAULT_MODEL_REF }
|
|
464
|
+
if (options.visionModel !== undefined) models.vision = options.visionModel
|
|
392
465
|
const config: Record<string, unknown> = {
|
|
393
466
|
$schema: './node_modules/typeclaw/typeclaw.schema.json',
|
|
394
|
-
|
|
395
|
-
network: { blockInternal: true },
|
|
396
|
-
}
|
|
397
|
-
const channels: Record<string, { allow: string[] }> = {}
|
|
398
|
-
if (options.withDiscord) channels['discord-bot'] = { allow: options.discordAllowAll === false ? [] : ['*'] }
|
|
399
|
-
if (options.withSlack) channels['slack-bot'] = { allow: options.slackAllowAll === false ? [] : ['*'] }
|
|
400
|
-
if (options.withTelegram) channels['telegram-bot'] = { allow: options.telegramAllowAll === false ? [] : ['*'] }
|
|
401
|
-
if (options.withKakaotalk) {
|
|
402
|
-
// KakaoTalk involves a personal account, so we default to a tighter
|
|
403
|
-
// allow list (DMs only) than Slack/Discord/Telegram which scope to a
|
|
404
|
-
// workspace the user explicitly admitted the bot into. The user can
|
|
405
|
-
// broaden to `kakao:*` later by editing typeclaw.json.
|
|
406
|
-
channels.kakaotalk = { allow: options.kakaotalkAllowAll === true ? ['kakao:*'] : ['kakao:dm/*'] }
|
|
467
|
+
models,
|
|
407
468
|
}
|
|
469
|
+
const channels: Record<string, Record<string, never>> = {}
|
|
470
|
+
if (options.withDiscord) channels['discord-bot'] = {}
|
|
471
|
+
if (options.withSlack) channels['slack-bot'] = {}
|
|
472
|
+
if (options.withTelegram) channels['telegram-bot'] = {}
|
|
473
|
+
if (options.withKakaotalk) channels.kakaotalk = {}
|
|
408
474
|
if (Object.keys(channels).length > 0) config.channels = channels
|
|
409
475
|
await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
|
|
410
476
|
|
|
@@ -484,7 +550,7 @@ export async function writeDockerAssets(root: string): Promise<DockerAssetsResul
|
|
|
484
550
|
const typeclawConfig = await readTypeclawConfig(root)
|
|
485
551
|
await writeFile(
|
|
486
552
|
join(root, DOCKERFILE),
|
|
487
|
-
buildDockerfile(typeclawConfig.
|
|
553
|
+
buildDockerfile(typeclawConfig.docker.file, { baseImageVersion: resolveBaseImageVersion(root) }),
|
|
488
554
|
{ flag: 'wx' },
|
|
489
555
|
).catch(ignoreExists)
|
|
490
556
|
|
|
@@ -502,7 +568,7 @@ async function readPackageJson(root: string): Promise<{ name?: string; dependenc
|
|
|
502
568
|
async function readTypeclawConfig(root: string): Promise<Config> {
|
|
503
569
|
try {
|
|
504
570
|
const raw = await readFile(join(root, CONFIG_FILE), 'utf8')
|
|
505
|
-
return configSchema.parse(JSON.parse(raw))
|
|
571
|
+
return configSchema.parse(migrateLegacyConfigShape(JSON.parse(raw)).json)
|
|
506
572
|
} catch (error) {
|
|
507
573
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return configSchema.parse({})
|
|
508
574
|
throw error
|
|
@@ -558,30 +624,27 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
|
|
|
558
624
|
}
|
|
559
625
|
}
|
|
560
626
|
|
|
561
|
-
// Writes
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
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`.
|
|
627
|
+
// Writes LLM provider API keys to `secrets.json#providers` and channel adapter
|
|
628
|
+
// tokens to `secrets.json#channels`. Both paths go through the structured
|
|
629
|
+
// v2 secrets envelope so reruns can reuse existing values without depending on
|
|
630
|
+
// host-stage env files.
|
|
570
631
|
export async function writeSecrets(
|
|
571
632
|
root: string,
|
|
572
633
|
{
|
|
573
634
|
model = DEFAULT_MODEL_REF,
|
|
574
635
|
apiKey,
|
|
636
|
+
visionModel,
|
|
637
|
+
visionApiKey,
|
|
575
638
|
discordBotToken,
|
|
576
639
|
slackBotToken,
|
|
577
640
|
slackAppToken,
|
|
578
641
|
telegramBotToken,
|
|
579
642
|
}: {
|
|
580
643
|
model?: KnownModelRef
|
|
581
|
-
// Omitted on the OAuth path — credentials live in secrets.json
|
|
582
|
-
// The .env file still gets written (empty) so post-init callers that
|
|
583
|
-
// read it don't ENOENT-crash.
|
|
644
|
+
// Omitted on the OAuth path — credentials live in secrets.json via the OAuth runner.
|
|
584
645
|
apiKey?: string
|
|
646
|
+
visionModel?: KnownModelRef
|
|
647
|
+
visionApiKey?: string
|
|
585
648
|
discordBotToken?: string
|
|
586
649
|
slackBotToken?: string
|
|
587
650
|
slackAppToken?: string
|
|
@@ -590,12 +653,21 @@ export async function writeSecrets(
|
|
|
590
653
|
): Promise<void> {
|
|
591
654
|
const providerId = providerForModelRef(model)
|
|
592
655
|
const apiKeyEnv = KNOWN_PROVIDERS[providerId].apiKeyEnv
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
656
|
+
const wantsDefaultKey = apiKey !== undefined && apiKeyEnv !== null
|
|
657
|
+
const visionProviderId = visionModel !== undefined ? providerForModelRef(visionModel) : null
|
|
658
|
+
const wantsVisionKey =
|
|
659
|
+
visionModel !== undefined &&
|
|
660
|
+
visionApiKey !== undefined &&
|
|
661
|
+
visionProviderId !== providerId &&
|
|
662
|
+
visionProviderId !== null &&
|
|
663
|
+
KNOWN_PROVIDERS[visionProviderId].apiKeyEnv !== null
|
|
664
|
+
if (wantsDefaultKey || wantsVisionKey) {
|
|
665
|
+
const secretsStore = createSecretsStoreForAgent(join(root, 'secrets.json'))
|
|
666
|
+
if (wantsDefaultKey) secretsStore.set(providerId, { type: 'api_key', key: apiKey! })
|
|
667
|
+
if (wantsVisionKey) {
|
|
668
|
+
secretsStore.set(visionProviderId, { type: 'api_key', key: visionApiKey! })
|
|
669
|
+
}
|
|
596
670
|
}
|
|
597
|
-
const body = lines.length > 0 ? `${lines.join('\n')}\n` : ''
|
|
598
|
-
await writeFile(join(root, SECRETS_FILE), body)
|
|
599
671
|
|
|
600
672
|
const channelTokens: Record<string, Record<string, Secret>> = {}
|
|
601
673
|
if (discordBotToken !== undefined && discordBotToken !== '') {
|
|
@@ -623,6 +695,76 @@ export async function writeSecrets(
|
|
|
623
695
|
backend.writeChannelsSync(merged)
|
|
624
696
|
}
|
|
625
697
|
|
|
698
|
+
export async function readExistingProviderApiKey(root: string, providerId: KnownProviderId): Promise<string | null> {
|
|
699
|
+
const provider = KNOWN_PROVIDERS[providerId]
|
|
700
|
+
if (provider.apiKeyEnv === null) return null
|
|
701
|
+
return new SecretsBackend(join(root, 'secrets.json')).tryReadProviderApiKeySync(providerId)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Detects whether the requested channel already has usable credentials in
|
|
705
|
+
// `secrets.json#channels`, so the init wizard can offer to reuse them
|
|
706
|
+
// instead of re-prompting for tokens. Mirrors `readExistingProviderApiKey`:
|
|
707
|
+
// returns `true` only when ALL fields the adapter needs are present in a
|
|
708
|
+
// shape `hydrateChannelEnvFromSecrets` would inject at runtime — both the
|
|
709
|
+
// `{ value }` form and the `{ env }` env-binding form count, matching the
|
|
710
|
+
// runtime resolution rules in `src/secrets/resolve.ts`. Partial slots (e.g.
|
|
711
|
+
// `slack-bot` with `botToken` but no `appToken`) return `false` so the
|
|
712
|
+
// missing field gets filled in by the normal prompt.
|
|
713
|
+
//
|
|
714
|
+
// KakaoTalk reuse is stricter: a usable block requires both a complete
|
|
715
|
+
// account (currentAccount + matching entry in accounts) AND the renewal
|
|
716
|
+
// fields (email + encryptedPassword) the hostd renewal cron needs to mint
|
|
717
|
+
// fresh tokens unattended (`src/secrets/kakao-renewal.ts`). Without those,
|
|
718
|
+
// the saved `oauth_token` will work only until KakaoTalk's ~7-day TTL
|
|
719
|
+
// expires, after which the user has to run `typeclaw channel reauth
|
|
720
|
+
// kakaotalk` anyway — better to re-auth now during init.
|
|
721
|
+
export async function hasExistingChannelSecrets(
|
|
722
|
+
root: string,
|
|
723
|
+
channel: 'discord' | 'slack' | 'telegram' | 'kakaotalk',
|
|
724
|
+
): Promise<boolean> {
|
|
725
|
+
const channels = new SecretsBackend(join(root, 'secrets.json')).tryReadChannelsSync()
|
|
726
|
+
if (channels === null) return false
|
|
727
|
+
switch (channel) {
|
|
728
|
+
case 'discord':
|
|
729
|
+
return hasSecretField(channels['discord-bot'], 'token')
|
|
730
|
+
case 'slack':
|
|
731
|
+
return hasSecretField(channels['slack-bot'], 'botToken') && hasSecretField(channels['slack-bot'], 'appToken')
|
|
732
|
+
case 'telegram':
|
|
733
|
+
return hasSecretField(channels['telegram-bot'], 'token')
|
|
734
|
+
case 'kakaotalk': {
|
|
735
|
+
const block = channels.kakaotalk
|
|
736
|
+
if (!isObjectRecord(block)) return false
|
|
737
|
+
const current = (block as { currentAccount?: unknown }).currentAccount
|
|
738
|
+
if (typeof current !== 'string' || current.length === 0) return false
|
|
739
|
+
const accounts = (block as { accounts?: unknown }).accounts
|
|
740
|
+
if (!isObjectRecord(accounts)) return false
|
|
741
|
+
const account = accounts[current]
|
|
742
|
+
if (!isObjectRecord(account)) return false
|
|
743
|
+
const email = (account as { email?: unknown }).email
|
|
744
|
+
const encryptedPassword = (account as { encryptedPassword?: unknown }).encryptedPassword
|
|
745
|
+
return typeof email === 'string' && email.length > 0 && isObjectRecord(encryptedPassword)
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Accepts either the `{ value }` form (resolves to a literal at runtime) or
|
|
751
|
+
// the `{ env }` form (resolves at runtime by reading `process.env[<env>]`).
|
|
752
|
+
// String shorthand is sugar for `{ value }`. The schema already rejects
|
|
753
|
+
// empty strings via `z.string().min(1)`, so the length checks here are
|
|
754
|
+
// defense-in-depth against forward-compat shape drift.
|
|
755
|
+
function hasSecretField(slot: unknown, field: string): boolean {
|
|
756
|
+
if (!isObjectRecord(slot)) return false
|
|
757
|
+
const secret = slot[field]
|
|
758
|
+
if (typeof secret === 'string') return secret.length > 0
|
|
759
|
+
if (isObjectRecord(secret)) {
|
|
760
|
+
const value = (secret as { value?: unknown }).value
|
|
761
|
+
if (typeof value === 'string' && value.length > 0) return true
|
|
762
|
+
const env = (secret as { env?: unknown }).env
|
|
763
|
+
if (typeof env === 'string' && env.length > 0) return true
|
|
764
|
+
}
|
|
765
|
+
return false
|
|
766
|
+
}
|
|
767
|
+
|
|
626
768
|
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
627
769
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
628
770
|
}
|
|
@@ -682,7 +824,6 @@ export type AddChannelStepEvent =
|
|
|
682
824
|
// from prompts; tests build them inline.
|
|
683
825
|
export type AddChannelOptions = {
|
|
684
826
|
cwd: string
|
|
685
|
-
allowAll?: boolean
|
|
686
827
|
onProgress?: (event: AddChannelStepEvent) => void
|
|
687
828
|
} & (
|
|
688
829
|
| { channel: 'discord-bot'; discordBotToken: string }
|
|
@@ -710,7 +851,7 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
710
851
|
}
|
|
711
852
|
|
|
712
853
|
emit({ step: 'config', phase: 'start' })
|
|
713
|
-
await mergeChannelIntoConfig(options.cwd, options.channel
|
|
854
|
+
await mergeChannelIntoConfig(options.cwd, options.channel)
|
|
714
855
|
emit({ step: 'config', phase: 'done' })
|
|
715
856
|
|
|
716
857
|
emit({ step: 'secrets', phase: 'start' })
|
|
@@ -721,14 +862,6 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
721
862
|
emit({ step: 'secrets', phase: 'done' })
|
|
722
863
|
}
|
|
723
864
|
|
|
724
|
-
// `channel add` mirrors `runInit`'s allow defaults: workspace-scoped adapters
|
|
725
|
-
// (discord/slack/telegram) default to `*` because the bot only sees what the
|
|
726
|
-
// operator invited it into, while KakaoTalk uses a personal account and
|
|
727
|
-
// defaults to DMs only.
|
|
728
|
-
function defaultAllowAll(channel: ChannelKind): boolean {
|
|
729
|
-
return channel !== 'kakaotalk'
|
|
730
|
-
}
|
|
731
|
-
|
|
732
865
|
function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
|
|
733
866
|
switch (options.channel) {
|
|
734
867
|
case 'discord-bot':
|
|
@@ -767,7 +900,7 @@ export async function readConfiguredChannels(cwd: string): Promise<Set<ChannelKi
|
|
|
767
900
|
return present
|
|
768
901
|
}
|
|
769
902
|
|
|
770
|
-
async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind
|
|
903
|
+
async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind): Promise<void> {
|
|
771
904
|
const path = join(cwd, CONFIG_FILE)
|
|
772
905
|
let parsed: Record<string, unknown>
|
|
773
906
|
try {
|
|
@@ -791,23 +924,18 @@ async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind, allowAl
|
|
|
791
924
|
// Defense in depth — the CLI already filters configured channels out of
|
|
792
925
|
// the picker and rejects them as the positional arg. Hitting this branch
|
|
793
926
|
// means a programmatic caller passed a duplicate; better to fail loudly
|
|
794
|
-
// than silently overwrite the user's existing
|
|
927
|
+
// than silently overwrite the user's existing config.
|
|
795
928
|
throw new Error(`Channel "${channel}" is already configured in ${CONFIG_FILE}.`)
|
|
796
929
|
}
|
|
797
930
|
|
|
798
931
|
parsed.channels = {
|
|
799
932
|
...existingChannels,
|
|
800
|
-
[channel]: {
|
|
933
|
+
[channel]: {},
|
|
801
934
|
}
|
|
802
935
|
|
|
803
936
|
await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
804
937
|
}
|
|
805
938
|
|
|
806
|
-
function buildAllow(channel: ChannelKind, allowAll: boolean): string[] {
|
|
807
|
-
if (channel === 'kakaotalk') return allowAll ? ['kakao:*'] : ['kakao:dm/*']
|
|
808
|
-
return allowAll ? ['*'] : []
|
|
809
|
-
}
|
|
810
|
-
|
|
811
939
|
// Writes per-adapter field values into `secrets.json#channels.<adapter>`.
|
|
812
940
|
// Refuses to overwrite existing fields: if the user already has e.g.
|
|
813
941
|
// `botToken` recorded (from a prior `channel add` whose follow-up steps
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { createRequire } from 'node:module'
|
|
2
|
-
import { join } from 'node:path'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
3
|
|
|
4
|
+
import { containerNameFromCwd } from '@/container'
|
|
5
|
+
import { keysDir } from '@/hostd/paths'
|
|
6
|
+
import { encrypt } from '@/secrets/encryption'
|
|
4
7
|
import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
|
|
8
|
+
import { createKeyStore, type KeyStore } from '@/secrets/keys'
|
|
5
9
|
|
|
6
10
|
export type KakaotalkBootstrapStatus = { ok: true } | { ok: false; reason: string }
|
|
7
11
|
|
|
@@ -15,6 +19,13 @@ export type KakaotalkLoginInput = {
|
|
|
15
19
|
agentDir: string
|
|
16
20
|
callbacks: KakaotalkLoginCallbacks
|
|
17
21
|
loginFlow?: LoginFlowFn
|
|
22
|
+
// Test seam: inject a custom keystore (typically pointing at a tmpdir).
|
|
23
|
+
// Production uses ~/.typeclaw/keys/<containerName>.key.
|
|
24
|
+
keyStore?: KeyStore
|
|
25
|
+
// Test seam: override the containerName used to bind the encrypted
|
|
26
|
+
// password's AAD. Production derives it from basename(agentDir) via
|
|
27
|
+
// containerNameFromCwd to match what `typeclaw start` registers with hostd.
|
|
28
|
+
containerName?: string
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
export type LoginFlowOptions = {
|
|
@@ -82,6 +93,10 @@ export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise
|
|
|
82
93
|
|
|
83
94
|
const now = new Date().toISOString()
|
|
84
95
|
const accountId = result.credentials.user_id || 'default'
|
|
96
|
+
const containerName = input.containerName ?? containerNameFromCwd(resolve(input.agentDir))
|
|
97
|
+
const keyStore = input.keyStore ?? createKeyStore({ keysDir: keysDir() })
|
|
98
|
+
const key = await keyStore.ensure(containerName)
|
|
99
|
+
const encryptedPassword = encrypt(input.password, key, { containerName, accountId })
|
|
85
100
|
await credManager.setAccount({
|
|
86
101
|
account_id: accountId,
|
|
87
102
|
oauth_token: result.credentials.access_token,
|
|
@@ -92,6 +107,8 @@ export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise
|
|
|
92
107
|
auth_method: 'login',
|
|
93
108
|
created_at: now,
|
|
94
109
|
updated_at: now,
|
|
110
|
+
email: input.email,
|
|
111
|
+
encryptedPassword,
|
|
95
112
|
})
|
|
96
113
|
await credManager.setCurrentAccount(accountId)
|
|
97
114
|
await credManager.clearPendingLogin()
|