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/doctor/checks.ts
CHANGED
|
@@ -23,8 +23,6 @@ import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
|
|
|
23
23
|
|
|
24
24
|
import type { DoctorCheck } from './types'
|
|
25
25
|
|
|
26
|
-
const REQUIRED_DIRS = ['workspace', 'sessions', '.agents/skills', 'mounts', 'packages'] as const
|
|
27
|
-
|
|
28
26
|
export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): DoctorCheck[] {
|
|
29
27
|
const dockerExec = opts.dockerExec ?? defaultDockerExec
|
|
30
28
|
|
|
@@ -32,12 +30,12 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
|
|
|
32
30
|
dockerDaemon(dockerExec),
|
|
33
31
|
bunRuntime(),
|
|
34
32
|
agentFolderInitialized(),
|
|
35
|
-
agentFolderRequiredDirs(),
|
|
36
33
|
agentFolderDockerfileTemplate(),
|
|
37
34
|
agentFolderGitignoreTemplate(),
|
|
38
35
|
agentFolderNodeModules(),
|
|
39
36
|
agentFolderGitRepo(),
|
|
40
37
|
configValid(),
|
|
38
|
+
configBundledProfiles(),
|
|
41
39
|
hostdHomeWritable(),
|
|
42
40
|
hostdReachable(),
|
|
43
41
|
hostdRegistration(),
|
|
@@ -103,36 +101,6 @@ function agentFolderInitialized(): DoctorCheck {
|
|
|
103
101
|
}
|
|
104
102
|
}
|
|
105
103
|
|
|
106
|
-
function agentFolderRequiredDirs(): DoctorCheck {
|
|
107
|
-
return {
|
|
108
|
-
name: 'agent-folder.required-dirs',
|
|
109
|
-
category: 'agent-folder',
|
|
110
|
-
description: 'required agent directories exist',
|
|
111
|
-
applies: (ctx) => ctx.hasAgentFolder,
|
|
112
|
-
async run(ctx) {
|
|
113
|
-
const missing = REQUIRED_DIRS.filter((d) => !existsSync(join(ctx.cwd, d)))
|
|
114
|
-
if (missing.length === 0) return { status: 'ok', message: 'all required directories present' }
|
|
115
|
-
return {
|
|
116
|
-
status: 'warning',
|
|
117
|
-
message: `${missing.length} required ${missing.length === 1 ? 'directory' : 'directories'} missing`,
|
|
118
|
-
details: missing.map((d) => `missing: ${d}/`),
|
|
119
|
-
fix: {
|
|
120
|
-
description: `Create the missing directories (${missing.map((d) => `${d}/`).join(', ')}).`,
|
|
121
|
-
autoFix: async () => {
|
|
122
|
-
for (const d of missing) {
|
|
123
|
-
mkdirSync(join(ctx.cwd, d), { recursive: true })
|
|
124
|
-
}
|
|
125
|
-
return {
|
|
126
|
-
summary: `created ${missing.map((d) => `${d}/`).join(', ')}`,
|
|
127
|
-
changedPaths: missing.map((d) => `${d}/`),
|
|
128
|
-
}
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
104
|
function agentFolderDockerfileTemplate(): DoctorCheck {
|
|
137
105
|
return {
|
|
138
106
|
name: 'agent-folder.dockerfile-managed',
|
|
@@ -247,6 +215,55 @@ function configValid(): DoctorCheck {
|
|
|
247
215
|
}
|
|
248
216
|
}
|
|
249
217
|
|
|
218
|
+
// Warns (not errors) when a model profile that a bundled subagent prefers is
|
|
219
|
+
// absent from `models`. Bundled subagents fall back to `default` silently
|
|
220
|
+
// today, but the operator likely declared a `fast`/`deep`/`vision` model in
|
|
221
|
+
// the design discussion's tier scheme expecting the bundled subagents to
|
|
222
|
+
// pick them up. This check surfaces the gap once at `typeclaw doctor` time
|
|
223
|
+
// instead of leaving it buried in container logs (where the rate-limited
|
|
224
|
+
// fallback warning lives).
|
|
225
|
+
//
|
|
226
|
+
// We deliberately limit this to known bundled profiles (memory-logger=fast,
|
|
227
|
+
// dreaming=deep, multimodal-looker=vision). Plugin-contributed subagents
|
|
228
|
+
// would require loading the plugin registry — a heavyweight async path
|
|
229
|
+
// that doesn't belong in doctor's static check surface.
|
|
230
|
+
const BUNDLED_PROFILES: ReadonlyArray<{ profile: string; subagent: string }> = [
|
|
231
|
+
{ profile: 'fast', subagent: 'memory-logger' },
|
|
232
|
+
{ profile: 'deep', subagent: 'dreaming' },
|
|
233
|
+
{ profile: 'vision', subagent: 'multimodal-looker (via look_at tool)' },
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
function configBundledProfiles(): DoctorCheck {
|
|
237
|
+
return {
|
|
238
|
+
name: 'config.bundled-profiles',
|
|
239
|
+
category: 'config',
|
|
240
|
+
description: 'bundled subagent profiles (`fast`, `deep`, `vision`) declared in models',
|
|
241
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
242
|
+
async run(ctx) {
|
|
243
|
+
const validation = validateConfig(ctx.cwd)
|
|
244
|
+
if (!validation.ok) {
|
|
245
|
+
return { status: 'ok', message: 'skipped (config.valid will report the underlying error)' }
|
|
246
|
+
}
|
|
247
|
+
const config = loadConfigSync(ctx.cwd)
|
|
248
|
+
const declared = new Set(Object.keys(config.models))
|
|
249
|
+
const missing = BUNDLED_PROFILES.filter((p) => !declared.has(p.profile))
|
|
250
|
+
if (missing.length === 0) {
|
|
251
|
+
return { status: 'ok', message: 'all bundled subagent profiles declared' }
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
status: 'warning',
|
|
255
|
+
message: `${missing.length} bundled profile(s) missing; will fall back to \`default\``,
|
|
256
|
+
details: missing.map(
|
|
257
|
+
(m) => `${m.profile}: used by ${m.subagent}; declare \`models.${m.profile}\` in typeclaw.json to override`,
|
|
258
|
+
),
|
|
259
|
+
fix: {
|
|
260
|
+
description: 'Add the missing profile(s) under `models` in typeclaw.json. See the typeclaw-config skill.',
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
250
267
|
function hostdHomeWritable(): DoctorCheck {
|
|
251
268
|
return {
|
|
252
269
|
name: 'hostd.home-writable',
|
|
@@ -390,7 +407,7 @@ function loadConfigStrictForTemplate(
|
|
|
390
407
|
const result = validateConfig(cwd)
|
|
391
408
|
if (!result.ok) return null
|
|
392
409
|
const cfg = loadConfigSync(cwd)
|
|
393
|
-
return { dockerfile: cfg.
|
|
410
|
+
return { dockerfile: cfg.docker.file, gitignore: cfg.git.ignore }
|
|
394
411
|
}
|
|
395
412
|
|
|
396
413
|
async function safeRead(path: string): Promise<string | null> {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveHostPort } from '@/container'
|
|
1
|
+
import { resolveHostPort, resolveTuiToken } from '@/container'
|
|
2
2
|
import type { ClientMessage, DoctorCheckPayload, DoctorFixPayload, ServerMessage } from '@/shared'
|
|
3
3
|
|
|
4
4
|
export type PluginBridgeOptions = {
|
|
@@ -80,12 +80,14 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
|
|
|
80
80
|
if (url === undefined) {
|
|
81
81
|
try {
|
|
82
82
|
const port = await resolveHostPort({ cwd: opts.cwd })
|
|
83
|
-
|
|
83
|
+
const token = await resolveTuiToken({ cwd: opts.cwd })
|
|
84
|
+
url = buildBridgeUrl(port, token)
|
|
84
85
|
} catch (err) {
|
|
85
86
|
return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
const ws = new WebSocket(url)
|
|
90
|
+
const displayUrl = redactUrl(url)
|
|
89
91
|
try {
|
|
90
92
|
await new Promise<void>((resolve, reject) => {
|
|
91
93
|
// `timer` is declared up front so `cleanup` can reference it without
|
|
@@ -97,6 +99,7 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
|
|
|
97
99
|
if (timer !== undefined) clearTimeout(timer)
|
|
98
100
|
ws.removeEventListener('open', onOpen)
|
|
99
101
|
ws.removeEventListener('error', onError)
|
|
102
|
+
ws.removeEventListener('close', onClose)
|
|
100
103
|
}
|
|
101
104
|
const onOpen = () => {
|
|
102
105
|
cleanup()
|
|
@@ -104,7 +107,11 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
|
|
|
104
107
|
}
|
|
105
108
|
const onError = (err: unknown) => {
|
|
106
109
|
cleanup()
|
|
107
|
-
reject(
|
|
110
|
+
reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
|
|
111
|
+
}
|
|
112
|
+
const onClose = () => {
|
|
113
|
+
cleanup()
|
|
114
|
+
reject(new Error(`connection to ${displayUrl} closed before opening`))
|
|
108
115
|
}
|
|
109
116
|
// Bun's WebSocket has no built-in connect timeout. Without this, a TCP
|
|
110
117
|
// handshake that completes but never produces an Upgrade response
|
|
@@ -117,10 +124,11 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
|
|
|
117
124
|
try {
|
|
118
125
|
ws.close()
|
|
119
126
|
} catch {}
|
|
120
|
-
reject(new Error(`
|
|
127
|
+
reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
|
|
121
128
|
}, timeoutMs)
|
|
122
129
|
ws.addEventListener('open', onOpen, { once: true })
|
|
123
130
|
ws.addEventListener('error', onError, { once: true })
|
|
131
|
+
ws.addEventListener('close', onClose, { once: true })
|
|
124
132
|
})
|
|
125
133
|
} catch (err) {
|
|
126
134
|
return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
|
|
@@ -128,6 +136,22 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
|
|
|
128
136
|
return { kind: 'ok', ws, timeoutMs }
|
|
129
137
|
}
|
|
130
138
|
|
|
139
|
+
function buildBridgeUrl(port: number, token: string | null): string {
|
|
140
|
+
const url = new URL(`ws://127.0.0.1:${port}`)
|
|
141
|
+
if (token !== null) url.searchParams.set('token', token)
|
|
142
|
+
return url.toString()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function redactUrl(url: string): string {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = new URL(url)
|
|
148
|
+
if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
|
|
149
|
+
return parsed.toString()
|
|
150
|
+
} catch {
|
|
151
|
+
return url
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
131
155
|
async function withRequest<R extends { kind: string }>(
|
|
132
156
|
ws: WebSocket,
|
|
133
157
|
timeoutMs: number,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// Commits TypeClaw-owned tracked files (.gitignore, package.json,
|
|
5
|
+
// typeclaw.json) if any are dirty in git. Skips silently when the agent
|
|
6
|
+
// folder is not a git repo, when Bun is unavailable, or when every named
|
|
7
|
+
// file is clean. Uses the user's global git config for authorship —
|
|
8
|
+
// TypeClaw does not impersonate the user here.
|
|
9
|
+
//
|
|
10
|
+
// Accepts a single file or an array; the array form produces a single
|
|
11
|
+
// atomic commit covering all listed paths (used for migrations that touch
|
|
12
|
+
// multiple files together, e.g. enabling bun workspaces writes both
|
|
13
|
+
// package.json and packages/.gitkeep in one commit).
|
|
14
|
+
//
|
|
15
|
+
// Lives under src/git/ rather than src/container/ because both the
|
|
16
|
+
// host-stage launcher (typeclaw start) and src/config/config.ts (called
|
|
17
|
+
// from every entry point that reads typeclaw.json, host AND container)
|
|
18
|
+
// need to commit migration artifacts. Putting it in src/container/ would
|
|
19
|
+
// pull container-level imports into the config module and create a
|
|
20
|
+
// circular dependency at the package boundary.
|
|
21
|
+
export async function commitSystemFile(cwd: string, file: string | readonly string[], message: string): Promise<void> {
|
|
22
|
+
const files = typeof file === 'string' ? [file] : file
|
|
23
|
+
if (files.length === 0) return
|
|
24
|
+
|
|
25
|
+
const bun = getBunAsync()
|
|
26
|
+
if (!bun) return
|
|
27
|
+
if (!existsSync(join(cwd, '.git'))) return
|
|
28
|
+
|
|
29
|
+
const status = bun.spawn({
|
|
30
|
+
cmd: ['git', 'status', '--porcelain', '--', ...files],
|
|
31
|
+
cwd,
|
|
32
|
+
stdout: 'pipe',
|
|
33
|
+
stderr: 'pipe',
|
|
34
|
+
})
|
|
35
|
+
if ((await status.exited) !== 0) return
|
|
36
|
+
const dirty = (await new Response(status.stdout).text()).trim().length > 0
|
|
37
|
+
if (!dirty) return
|
|
38
|
+
|
|
39
|
+
const add = bun.spawn({ cmd: ['git', 'add', '--', ...files], cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
40
|
+
if ((await add.exited) !== 0) return
|
|
41
|
+
|
|
42
|
+
const commit = bun.spawn({
|
|
43
|
+
cmd: ['git', 'commit', '-m', message, '--only', '--', ...files],
|
|
44
|
+
cwd,
|
|
45
|
+
stdout: 'pipe',
|
|
46
|
+
stderr: 'pipe',
|
|
47
|
+
})
|
|
48
|
+
await commit.exited
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Synchronous variant for callers that already hold a synchronous codepath
|
|
52
|
+
// — specifically `persistMigratedConfig` in src/config/config.ts. The
|
|
53
|
+
// migration write is itself synchronous (writeFileSync) and the call sites
|
|
54
|
+
// (loadConfigSync, validateConfig, loadPluginConfigsSync) are sync, so we
|
|
55
|
+
// cannot await an async commit without forcing them all to become async,
|
|
56
|
+
// which would ripple into hundreds of call sites across the codebase.
|
|
57
|
+
//
|
|
58
|
+
// The commit overhead (~10-50ms) is paid exactly once per agent folder
|
|
59
|
+
// per legacy form: after the first call rewrites the file to canonical
|
|
60
|
+
// shape, subsequent migrateLegacyConfigShape calls return changed=false
|
|
61
|
+
// and this codepath is unreachable. On canonical folders (the common
|
|
62
|
+
// case) this function is never called at all.
|
|
63
|
+
//
|
|
64
|
+
// Same skip semantics as the async variant — no-op when the folder is not
|
|
65
|
+
// a git repo, when Bun is unavailable, or when the file is clean.
|
|
66
|
+
export function commitSystemFileSync(cwd: string, file: string | readonly string[], message: string): void {
|
|
67
|
+
const files = typeof file === 'string' ? [file] : file
|
|
68
|
+
if (files.length === 0) return
|
|
69
|
+
|
|
70
|
+
const bun = getBunSync()
|
|
71
|
+
if (!bun) return
|
|
72
|
+
if (!existsSync(join(cwd, '.git'))) return
|
|
73
|
+
|
|
74
|
+
const status = bun.spawnSync({
|
|
75
|
+
cmd: ['git', 'status', '--porcelain', '--', ...files],
|
|
76
|
+
cwd,
|
|
77
|
+
stdout: 'pipe',
|
|
78
|
+
stderr: 'pipe',
|
|
79
|
+
})
|
|
80
|
+
if (status.exitCode !== 0) return
|
|
81
|
+
if (new TextDecoder().decode(status.stdout).trim().length === 0) return
|
|
82
|
+
|
|
83
|
+
const add = bun.spawnSync({ cmd: ['git', 'add', '--', ...files], cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
84
|
+
if (add.exitCode !== 0) return
|
|
85
|
+
|
|
86
|
+
bun.spawnSync({
|
|
87
|
+
cmd: ['git', 'commit', '-m', message, '--only', '--', ...files],
|
|
88
|
+
cwd,
|
|
89
|
+
stdout: 'pipe',
|
|
90
|
+
stderr: 'pipe',
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Bun-availability shims kept tight to the two functions so the module
|
|
95
|
+
// has no module-level side effects (matters for the sync codepath, which
|
|
96
|
+
// is called during typeclaw.json reads on hot import).
|
|
97
|
+
function getBunAsync(): { spawn: typeof Bun.spawn } | undefined {
|
|
98
|
+
return (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getBunSync(): { spawnSync: typeof Bun.spawnSync } | undefined {
|
|
102
|
+
return (globalThis as { Bun?: { spawnSync: typeof Bun.spawnSync } }).Bun
|
|
103
|
+
}
|
package/src/hostd/daemon.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { kakaoChannelBlockSchema } from '@/secrets/schema'
|
|
|
11
11
|
import { SecretsBackend } from '@/secrets/storage'
|
|
12
12
|
|
|
13
13
|
import { isDaemonReachable } from './client'
|
|
14
|
+
import type { KakaoRenewalCallbacks, KakaoRenewalLogEvent } from './kakao-renewal-manager'
|
|
14
15
|
import { ensureDirs, registrationFilePath, registrationsDir, socketPath } from './paths'
|
|
15
16
|
import type {
|
|
16
17
|
HttpInfoResult,
|
|
@@ -54,6 +55,11 @@ export type DaemonOptions = {
|
|
|
54
55
|
// fields trigger broker spawn alongside supervisor registration. Tests omit
|
|
55
56
|
// it to keep the broker out of unrelated suites.
|
|
56
57
|
portbroker?: PortbrokerCallbacks
|
|
58
|
+
// KakaoTalk credential renewal capability. When provided, the daemon
|
|
59
|
+
// starts a per-container daily renewal tick on register and stops it on
|
|
60
|
+
// deregister. Omit to disable in tests / when the agent has no kakaotalk
|
|
61
|
+
// channel configured.
|
|
62
|
+
kakaoRenewal?: KakaoRenewalCallbacks
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
export type RestartPreflight = (input: {
|
|
@@ -94,6 +100,7 @@ export type DaemonLogEvent =
|
|
|
94
100
|
| { kind: 'shutdown-requested' }
|
|
95
101
|
| { kind: 'port-forward-event'; event: PortForwardEvent }
|
|
96
102
|
| { kind: 'tailscale-serve-event'; event: TailscaleServeEvent }
|
|
103
|
+
| KakaoRenewalLogEvent
|
|
97
104
|
|
|
98
105
|
export type Daemon = {
|
|
99
106
|
registered: () => string[]
|
|
@@ -284,6 +291,9 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
284
291
|
onTailscaleServeEvent: (event) => log({ kind: 'tailscale-serve-event', event }),
|
|
285
292
|
})
|
|
286
293
|
}
|
|
294
|
+
if (opts.kakaoRenewal) {
|
|
295
|
+
opts.kakaoRenewal.start({ containerName: payload.containerName, cwd: payload.cwd })
|
|
296
|
+
}
|
|
287
297
|
}
|
|
288
298
|
|
|
289
299
|
const handleRegister = async (req: RegisterPayload): Promise<RpcResponse> => {
|
|
@@ -309,6 +319,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
309
319
|
restartTokens.delete(req.containerName)
|
|
310
320
|
gcMisses.delete(req.containerName)
|
|
311
321
|
if (opts.portbroker) await opts.portbroker.stop(req.containerName, 'deregistered').catch(() => {})
|
|
322
|
+
if (opts.kakaoRenewal) await opts.kakaoRenewal.stop(req.containerName).catch(() => {})
|
|
312
323
|
await removeRegistrationFile(req.containerName)
|
|
313
324
|
if (hadCwd) log({ kind: 'deregister', containerName: req.containerName, reason: 'requested' })
|
|
314
325
|
return { ok: true }
|
|
@@ -574,6 +585,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
574
585
|
const hadCwd = cwds.delete(name)
|
|
575
586
|
restartTokens.delete(name)
|
|
576
587
|
if (opts.portbroker) await opts.portbroker.stop(name, 'deregistered').catch(() => {})
|
|
588
|
+
if (opts.kakaoRenewal) await opts.kakaoRenewal.stop(name).catch(() => {})
|
|
577
589
|
await removeRegistrationFile(name)
|
|
578
590
|
if (hadCwd) log({ kind: 'deregister', containerName: name, reason: 'gone' })
|
|
579
591
|
return { ok: true }
|
|
@@ -601,6 +613,10 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
601
613
|
const names = Array.from(cwds.keys())
|
|
602
614
|
await Promise.allSettled(names.map((n) => opts.portbroker!.stop(n, 'broker-stopped')))
|
|
603
615
|
}
|
|
616
|
+
if (opts.kakaoRenewal) {
|
|
617
|
+
const names = Array.from(cwds.keys())
|
|
618
|
+
await Promise.allSettled(names.map((n) => opts.kakaoRenewal!.stop(n)))
|
|
619
|
+
}
|
|
604
620
|
cwds.clear()
|
|
605
621
|
restartTokens.clear()
|
|
606
622
|
try {
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { renewCurrentAccount, type AttemptLoginFn } from '@/secrets/kakao-renewal'
|
|
2
|
+
import { createKeyStore, type KeyStore } from '@/secrets/keys'
|
|
3
|
+
|
|
4
|
+
import { keysDir } from './paths'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TICK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
7
|
+
|
|
8
|
+
export type KakaoRenewalCallbacks = {
|
|
9
|
+
start: (input: KakaoRenewalStartInput) => void
|
|
10
|
+
stop: (containerName: string) => Promise<void>
|
|
11
|
+
drain: () => Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type KakaoRenewalStartInput = {
|
|
15
|
+
containerName: string
|
|
16
|
+
cwd: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type KakaoRenewalLogEvent =
|
|
20
|
+
| { kind: 'kakao-renewal-tick-start'; containerName: string }
|
|
21
|
+
| { kind: 'kakao-renewal-tick-skipped'; containerName: string; reason: string; ageMs?: number }
|
|
22
|
+
| { kind: 'kakao-renewal-tick-ok'; containerName: string; accountId: string; previousUpdatedAt: string }
|
|
23
|
+
| {
|
|
24
|
+
kind: 'kakao-renewal-tick-reauth-required'
|
|
25
|
+
containerName: string
|
|
26
|
+
accountId: string
|
|
27
|
+
reason: string
|
|
28
|
+
message: string
|
|
29
|
+
}
|
|
30
|
+
| { kind: 'kakao-renewal-tick-transient-failure'; containerName: string; accountId: string; reason: string }
|
|
31
|
+
| { kind: 'kakao-renewal-tick-error'; containerName: string; error: string }
|
|
32
|
+
| { kind: 'kakao-renewal-restart-scheduled'; containerName: string; accountId: string }
|
|
33
|
+
| { kind: 'kakao-renewal-restart-failed'; containerName: string; accountId: string; reason: string }
|
|
34
|
+
|
|
35
|
+
export type KakaoRenewalManagerOptions = {
|
|
36
|
+
onLog?: (event: KakaoRenewalLogEvent) => void
|
|
37
|
+
tickIntervalMs?: number
|
|
38
|
+
keyStoreFactory?: () => KeyStore
|
|
39
|
+
attemptLogin?: AttemptLoginFn
|
|
40
|
+
schedule?: (fn: () => void, intervalMs: number) => { stop: () => void }
|
|
41
|
+
// Invoked after a successful renewal so the host can restart the container
|
|
42
|
+
// and the in-memory adapter picks up the fresh tokens. Without this, the
|
|
43
|
+
// cron writes new tokens to secrets.json but the live LOCO client keeps the
|
|
44
|
+
// old token in its closure and still hits 401 at the ~7-day wall. Production
|
|
45
|
+
// wires this to the same restart path the `restart` RPC uses; tests can
|
|
46
|
+
// observe it via a fake.
|
|
47
|
+
onRenewalOk?: (input: { containerName: string; cwd: string; accountId: string }) => Promise<void>
|
|
48
|
+
// Optional predicate: only start the renewal cron for containers whose
|
|
49
|
+
// `typeclaw.json` actually has a `channels.kakaotalk` block. Without this,
|
|
50
|
+
// every typeclaw agent on the host emits daily `no_account` skip logs.
|
|
51
|
+
shouldRenew?: (input: KakaoRenewalStartInput) => boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Per-container daily renewal tick. Mirrors portbroker-manager.ts: hostd calls
|
|
55
|
+
// start() on register and stop() on deregister, and the manager owns timer
|
|
56
|
+
// lifecycle plus the actual renewal work. The keystore lives on the host
|
|
57
|
+
// (~/.typeclaw/keys/<name>.key), unreachable from inside the container —
|
|
58
|
+
// that's load-bearing for encryption.ts's threat model.
|
|
59
|
+
export function createKakaoRenewalManager(opts: KakaoRenewalManagerOptions = {}): KakaoRenewalCallbacks {
|
|
60
|
+
const intervalMs = opts.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS
|
|
61
|
+
const keyStore = (opts.keyStoreFactory ?? (() => createKeyStore({ keysDir: keysDir() })))()
|
|
62
|
+
const log = opts.onLog ?? (() => {})
|
|
63
|
+
const schedule =
|
|
64
|
+
opts.schedule ??
|
|
65
|
+
((fn: () => void, ms: number) => {
|
|
66
|
+
const handle = setInterval(fn, ms)
|
|
67
|
+
return { stop: () => clearInterval(handle) }
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const timers = new Map<string, { stop: () => void }>()
|
|
71
|
+
// Track the latest registration input per container so a re-register
|
|
72
|
+
// arriving during an in-flight tick can both (a) prevent the in-flight tick
|
|
73
|
+
// from acting on stale cwd, and (b) trigger a fresh tick once the in-flight
|
|
74
|
+
// one finishes. The Map's value is the most recent input.
|
|
75
|
+
const latestInput = new Map<string, KakaoRenewalStartInput>()
|
|
76
|
+
// Track in-flight tick promises per container so stop()/drain() can await
|
|
77
|
+
// them. Without this, daemon shutdown abandons an in-flight attemptLogin
|
|
78
|
+
// HTTPS request mid-write.
|
|
79
|
+
const inFlight = new Map<string, Promise<void>>()
|
|
80
|
+
// Pending immediate-tick request: set when start() is called while a tick
|
|
81
|
+
// is in flight, so we re-fire one tick after the in-flight settles.
|
|
82
|
+
const pendingRerun = new Set<string>()
|
|
83
|
+
|
|
84
|
+
const runTick = async (input: KakaoRenewalStartInput): Promise<void> => {
|
|
85
|
+
log({ kind: 'kakao-renewal-tick-start', containerName: input.containerName })
|
|
86
|
+
try {
|
|
87
|
+
const result = await renewCurrentAccount({
|
|
88
|
+
containerName: input.containerName,
|
|
89
|
+
agentDir: input.cwd,
|
|
90
|
+
keyStore,
|
|
91
|
+
...(opts.attemptLogin ? { attemptLogin: opts.attemptLogin } : {}),
|
|
92
|
+
})
|
|
93
|
+
if (result.kind === 'skipped') {
|
|
94
|
+
log({
|
|
95
|
+
kind: 'kakao-renewal-tick-skipped',
|
|
96
|
+
containerName: input.containerName,
|
|
97
|
+
reason: result.reason,
|
|
98
|
+
...(result.ageMs !== undefined ? { ageMs: result.ageMs } : {}),
|
|
99
|
+
})
|
|
100
|
+
} else if (result.kind === 'ok') {
|
|
101
|
+
log({
|
|
102
|
+
kind: 'kakao-renewal-tick-ok',
|
|
103
|
+
containerName: input.containerName,
|
|
104
|
+
accountId: result.account_id,
|
|
105
|
+
previousUpdatedAt: result.previousUpdatedAt,
|
|
106
|
+
})
|
|
107
|
+
if (opts.onRenewalOk) {
|
|
108
|
+
log({
|
|
109
|
+
kind: 'kakao-renewal-restart-scheduled',
|
|
110
|
+
containerName: input.containerName,
|
|
111
|
+
accountId: result.account_id,
|
|
112
|
+
})
|
|
113
|
+
try {
|
|
114
|
+
await opts.onRenewalOk({
|
|
115
|
+
containerName: input.containerName,
|
|
116
|
+
cwd: input.cwd,
|
|
117
|
+
accountId: result.account_id,
|
|
118
|
+
})
|
|
119
|
+
} catch (err) {
|
|
120
|
+
log({
|
|
121
|
+
kind: 'kakao-renewal-restart-failed',
|
|
122
|
+
containerName: input.containerName,
|
|
123
|
+
accountId: result.account_id,
|
|
124
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} else if (result.kind === 'reauth_required') {
|
|
129
|
+
log({
|
|
130
|
+
kind: 'kakao-renewal-tick-reauth-required',
|
|
131
|
+
containerName: input.containerName,
|
|
132
|
+
accountId: result.account_id,
|
|
133
|
+
reason: result.reason,
|
|
134
|
+
message: result.message,
|
|
135
|
+
})
|
|
136
|
+
} else {
|
|
137
|
+
log({
|
|
138
|
+
kind: 'kakao-renewal-tick-transient-failure',
|
|
139
|
+
containerName: input.containerName,
|
|
140
|
+
accountId: result.account_id,
|
|
141
|
+
reason: result.reason,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// Defensive: renewCurrentAccount's contract is to return a structured
|
|
146
|
+
// result, but a malformed secrets.json or a disk error could surface
|
|
147
|
+
// here. Log and move on — the next tick retries.
|
|
148
|
+
log({
|
|
149
|
+
kind: 'kakao-renewal-tick-error',
|
|
150
|
+
containerName: input.containerName,
|
|
151
|
+
error: err instanceof Error ? err.message : String(err),
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Single-flight per container: dedupe overlapping ticks, but if a new
|
|
157
|
+
// tick request arrives while one is in flight, queue ONE rerun so the new
|
|
158
|
+
// registration's cwd (or a manual nudge) gets a chance after the in-flight
|
|
159
|
+
// settles. The Promise stored in inFlight resolves only after any queued
|
|
160
|
+
// rerun also completes, so stop()/drain() awaiting inFlight is enough to
|
|
161
|
+
// observe a quiescent manager.
|
|
162
|
+
const scheduleTick = (containerName: string): Promise<void> => {
|
|
163
|
+
const existing = inFlight.get(containerName)
|
|
164
|
+
if (existing) {
|
|
165
|
+
pendingRerun.add(containerName)
|
|
166
|
+
return existing
|
|
167
|
+
}
|
|
168
|
+
const promise = (async () => {
|
|
169
|
+
// Loop until no rerun was queued during this tick, so the LAST input
|
|
170
|
+
// recorded for the container is the one we end on. This handles the
|
|
171
|
+
// re-register-while-in-flight + cwd-change case described in the
|
|
172
|
+
// pendingRerun comment above.
|
|
173
|
+
while (true) {
|
|
174
|
+
const input = latestInput.get(containerName)
|
|
175
|
+
if (!input) return
|
|
176
|
+
await runTick(input)
|
|
177
|
+
if (!pendingRerun.has(containerName)) return
|
|
178
|
+
pendingRerun.delete(containerName)
|
|
179
|
+
}
|
|
180
|
+
})().finally(() => {
|
|
181
|
+
inFlight.delete(containerName)
|
|
182
|
+
pendingRerun.delete(containerName)
|
|
183
|
+
})
|
|
184
|
+
inFlight.set(containerName, promise)
|
|
185
|
+
return promise
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
start(input: KakaoRenewalStartInput): void {
|
|
190
|
+
if (opts.shouldRenew && !opts.shouldRenew(input)) return
|
|
191
|
+
const existing = timers.get(input.containerName)
|
|
192
|
+
if (existing) existing.stop()
|
|
193
|
+
latestInput.set(input.containerName, input)
|
|
194
|
+
const handle = schedule(() => {
|
|
195
|
+
void scheduleTick(input.containerName)
|
|
196
|
+
}, intervalMs)
|
|
197
|
+
timers.set(input.containerName, handle)
|
|
198
|
+
void scheduleTick(input.containerName)
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async stop(containerName: string): Promise<void> {
|
|
202
|
+
const handle = timers.get(containerName)
|
|
203
|
+
if (handle) {
|
|
204
|
+
timers.delete(containerName)
|
|
205
|
+
handle.stop()
|
|
206
|
+
}
|
|
207
|
+
latestInput.delete(containerName)
|
|
208
|
+
// Await any in-flight tick before resolving so the caller can rely on
|
|
209
|
+
// "stop returned → no work outstanding". Daemon shutdown depends on
|
|
210
|
+
// this; without it, a mid-tick `attemptLogin` HTTPS call is abandoned.
|
|
211
|
+
const promise = inFlight.get(containerName)
|
|
212
|
+
if (promise) await promise.catch(() => {})
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async drain(): Promise<void> {
|
|
216
|
+
for (const [, handle] of timers) handle.stop()
|
|
217
|
+
timers.clear()
|
|
218
|
+
latestInput.clear()
|
|
219
|
+
const promises = Array.from(inFlight.values())
|
|
220
|
+
await Promise.allSettled(promises)
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/hostd/paths.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { join } from 'node:path'
|
|
|
9
9
|
const CONTAINER_HOST_RUN_DIR = '/run/typeclaw-host'
|
|
10
10
|
const SOCKET_FILE = 'hostd.sock'
|
|
11
11
|
const REGISTRATIONS_DIR = 'registrations'
|
|
12
|
+
const KEYS_DIR = 'keys'
|
|
12
13
|
|
|
13
14
|
// Defense-in-depth: containerName arrives from RPC payloads (some of which
|
|
14
15
|
// originate inside the container). Docker already forbids slashes and most
|
|
@@ -51,6 +52,10 @@ export function registrationsDir(): string {
|
|
|
51
52
|
return join(runDir(), REGISTRATIONS_DIR)
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
export function keysDir(): string {
|
|
56
|
+
return join(homeRoot(), KEYS_DIR)
|
|
57
|
+
}
|
|
58
|
+
|
|
54
59
|
// Throws on any name that could traverse out of registrationsDir() or
|
|
55
60
|
// confuse the filesystem. Caller's responsibility to handle the error;
|
|
56
61
|
// don't catch-and-ignore — an invalid name is a protocol violation.
|
|
@@ -76,7 +81,9 @@ export async function ensureDirs(): Promise<void> {
|
|
|
76
81
|
await mkdir(runDir(), { recursive: true })
|
|
77
82
|
await mkdir(logDir(), { recursive: true })
|
|
78
83
|
await mkdir(registrationsDir(), { recursive: true })
|
|
84
|
+
await mkdir(keysDir(), { recursive: true })
|
|
79
85
|
await chmod(runDir(), 0o700).catch(() => {})
|
|
80
86
|
await chmod(logDir(), 0o700).catch(() => {})
|
|
81
87
|
await chmod(registrationsDir(), 0o700).catch(() => {})
|
|
88
|
+
await chmod(keysDir(), 0o700).catch(() => {})
|
|
82
89
|
}
|