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
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
2
2
|
|
|
3
3
|
import { createSession as defaultCreateSession } from '@/agent'
|
|
4
|
+
import { capJsonlFileInPlace } from '@/bundled-plugins/tool-result-cap/cap-jsonl'
|
|
5
|
+
import type { CapOptions } from '@/bundled-plugins/tool-result-cap/cap-result'
|
|
4
6
|
import type { CreateSessionForChannel, ChannelRouter } from '@/channels'
|
|
7
|
+
import type { PermissionService } from '@/permissions'
|
|
5
8
|
import type { ReloadRegistry } from '@/reload'
|
|
6
9
|
import type { SessionFactory } from '@/sessions'
|
|
7
10
|
import type { Stream } from '@/stream'
|
|
8
11
|
|
|
9
12
|
import type { PluginRuntime } from './plugin-runtime'
|
|
10
13
|
|
|
14
|
+
export type FactoryLogger = {
|
|
15
|
+
info: (message: string) => void
|
|
16
|
+
warn: (message: string) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const consoleLogger: FactoryLogger = {
|
|
20
|
+
info: (m) => console.info(m),
|
|
21
|
+
warn: (m) => console.warn(m),
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
export type BuildChannelSessionFactoryDeps = {
|
|
12
25
|
cwd: string
|
|
13
26
|
sessionFactory: SessionFactory
|
|
@@ -20,12 +33,35 @@ export type BuildChannelSessionFactoryDeps = {
|
|
|
20
33
|
// their inbound messages came from.
|
|
21
34
|
getChannelRouter: () => ChannelRouter
|
|
22
35
|
containerName?: string
|
|
36
|
+
// When set, rehydrating a session JSONL caps oversized tool results in the
|
|
37
|
+
// file before pi-coding-agent reads it. `null` disables the load-time pass
|
|
38
|
+
// (tool-result-cap.enabled=false in config, or no plugin block at all).
|
|
39
|
+
rehydrateCapOptions: CapOptions | null
|
|
40
|
+
logger?: FactoryLogger
|
|
41
|
+
// Forwarded to createSession so the resolved role / permissions for the
|
|
42
|
+
// session origin get rendered into the agent's system prompt. Optional so
|
|
43
|
+
// the production wiring can plumb in pluginsLoaded.permissions while tests
|
|
44
|
+
// (or stand-alone callers) keep the previous no-annotation behavior.
|
|
45
|
+
permissions?: PermissionService
|
|
23
46
|
// Test seam: lets a fake stand in for the agent session creator so tests
|
|
24
47
|
// can assert exactly which CreateSessionOptions the factory builds without
|
|
25
48
|
// needing a live LLM, plugin runtime, or session manager on disk.
|
|
26
49
|
createSession?: typeof defaultCreateSession
|
|
27
50
|
}
|
|
28
51
|
|
|
52
|
+
// Tight basename validation so a tampered or corrupt channels/sessions.json
|
|
53
|
+
// can't point the load-time rewrite (or SessionManager.open) at a file
|
|
54
|
+
// outside `sessionDir`. We never receive sessionFile from a remote source
|
|
55
|
+
// during normal operation, but the file is operator-editable, so defense-
|
|
56
|
+
// in-depth is cheap. Match pi-coding-agent's filename convention loosely:
|
|
57
|
+
// no path separators, no NUL, must end in `.jsonl`.
|
|
58
|
+
function isValidSessionFileBasename(name: string): boolean {
|
|
59
|
+
if (name.length === 0 || name.length > 255) return false
|
|
60
|
+
if (name.includes('/') || name.includes('\\') || name.includes('\0')) return false
|
|
61
|
+
if (name === '.' || name === '..' || name.startsWith('.')) return false
|
|
62
|
+
return name.endsWith('.jsonl')
|
|
63
|
+
}
|
|
64
|
+
|
|
29
65
|
// The production wiring for channel-routed sessions. Channel inbounds arrive
|
|
30
66
|
// at the router, the router calls this factory to get an AgentSession, and
|
|
31
67
|
// the agent uses `channel_send` to reply. If `channelRouter` is missing here
|
|
@@ -35,11 +71,19 @@ export type BuildChannelSessionFactoryDeps = {
|
|
|
35
71
|
// "channel-aware" sessions that need the same full plumbing.
|
|
36
72
|
export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps): CreateSessionForChannel {
|
|
37
73
|
const createSession = deps.createSession ?? defaultCreateSession
|
|
38
|
-
|
|
74
|
+
const logger = deps.logger ?? consoleLogger
|
|
75
|
+
return async ({ existingSessionId, existingSessionFile, origin, originRef }) => {
|
|
39
76
|
const sessionDir = deps.sessionFactory.sessionDir()
|
|
40
77
|
const sessionManager =
|
|
41
78
|
existingSessionId !== undefined
|
|
42
|
-
? tryReopenOrCreate(
|
|
79
|
+
? tryReopenOrCreate(
|
|
80
|
+
deps.cwd,
|
|
81
|
+
sessionDir,
|
|
82
|
+
existingSessionId,
|
|
83
|
+
existingSessionFile,
|
|
84
|
+
deps.rehydrateCapOptions,
|
|
85
|
+
logger,
|
|
86
|
+
)
|
|
43
87
|
: SessionManager.create(deps.cwd, sessionDir)
|
|
44
88
|
|
|
45
89
|
const snap = deps.pluginRuntime.get()
|
|
@@ -49,6 +93,7 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
|
|
|
49
93
|
stream: deps.stream,
|
|
50
94
|
channelRouter: deps.getChannelRouter(),
|
|
51
95
|
origin,
|
|
96
|
+
originRef,
|
|
52
97
|
...(snap.hasAnyPluginContent
|
|
53
98
|
? {
|
|
54
99
|
plugins: {
|
|
@@ -60,6 +105,7 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
|
|
|
60
105
|
}
|
|
61
106
|
: {}),
|
|
62
107
|
...(deps.containerName !== undefined ? { containerName: deps.containerName } : {}),
|
|
108
|
+
...(deps.permissions !== undefined ? { permissions: deps.permissions } : {}),
|
|
63
109
|
})
|
|
64
110
|
|
|
65
111
|
return {
|
|
@@ -86,18 +132,43 @@ function tryReopenOrCreate(
|
|
|
86
132
|
sessionDir: string,
|
|
87
133
|
existingSessionId: string,
|
|
88
134
|
existingSessionFile: string | undefined,
|
|
135
|
+
capOptions: CapOptions | null,
|
|
136
|
+
logger: FactoryLogger,
|
|
89
137
|
): SessionManager {
|
|
90
138
|
if (existingSessionFile === undefined) {
|
|
91
|
-
|
|
139
|
+
logger.warn(
|
|
92
140
|
`[channels] session ${existingSessionId} has no sessionFile (v2 mapping not yet migrated); creating new`,
|
|
93
141
|
)
|
|
94
142
|
return SessionManager.create(cwd, sessionDir)
|
|
95
143
|
}
|
|
144
|
+
if (!isValidSessionFileBasename(existingSessionFile)) {
|
|
145
|
+
logger.warn(
|
|
146
|
+
`[channels] session ${existingSessionId} has invalid sessionFile (${JSON.stringify(existingSessionFile)}); creating new`,
|
|
147
|
+
)
|
|
148
|
+
return SessionManager.create(cwd, sessionDir)
|
|
149
|
+
}
|
|
150
|
+
const path = `${sessionDir}/${existingSessionFile}`
|
|
151
|
+
if (capOptions !== null) {
|
|
152
|
+
try {
|
|
153
|
+
const stats = capJsonlFileInPlace(path, capOptions)
|
|
154
|
+
if (stats.entriesMutated > 0) {
|
|
155
|
+
logger.info(
|
|
156
|
+
`[channels] rehydrate-cap ${existingSessionFile}: entriesMutated=${stats.entriesMutated} imagesReplaced=${stats.imagesReplaced} textsTruncated=${stats.textsTruncated} bytesElided=${stats.bytesElided}`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
// Capping is best-effort: if the rewrite fails, fall through to the
|
|
161
|
+
// regular open path so the session still rehydrates uncapped rather
|
|
162
|
+
// than being killed by a transient FS error.
|
|
163
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
164
|
+
logger.warn(`[channels] rehydrate-cap failed for ${existingSessionFile}: ${reason}; continuing with open`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
96
167
|
try {
|
|
97
|
-
return SessionManager.open(
|
|
168
|
+
return SessionManager.open(path)
|
|
98
169
|
} catch (err) {
|
|
99
170
|
const reason = err instanceof Error ? err.message : String(err)
|
|
100
|
-
|
|
171
|
+
logger.warn(
|
|
101
172
|
`[channels] could not rehydrate session ${existingSessionId} from ${existingSessionFile}: ${reason}; creating new`,
|
|
102
173
|
)
|
|
103
174
|
return SessionManager.create(cwd, sessionDir)
|
package/src/run/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
2
2
|
|
|
3
3
|
import { createSession, createSessionWithDispose } from '@/agent'
|
|
4
|
+
import type { SessionOrigin } from '@/agent/session-origin'
|
|
4
5
|
import {
|
|
5
6
|
createSubagentConsumer,
|
|
6
7
|
defaultCreateSessionForSubagent,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
type SubagentConsumer,
|
|
10
11
|
type SubagentRegistry,
|
|
11
12
|
} from '@/agent/subagents'
|
|
13
|
+
import { resolveCapOptionsFromConfig } from '@/bundled-plugins/tool-result-cap'
|
|
12
14
|
import { createChannelManager, createChannelsReloadable, type ChannelManager } from '@/channels'
|
|
13
15
|
import { createConfigReloadable, getConfig, loadConfigSync, loadPluginConfigsSync } from '@/config'
|
|
14
16
|
import {
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
import { loadPlugins, type LoadPluginsResult, pluginCronJobs, type PluginRegistry, summarizeLoaded } from '@/plugin'
|
|
26
28
|
import { createContainerBroker, publishForwardResult } from '@/portbroker'
|
|
27
29
|
import { ReloadRegistry } from '@/reload'
|
|
30
|
+
import { createClaimController } from '@/role-claim'
|
|
28
31
|
import { hydrateChannelEnvFromSecrets } from '@/secrets'
|
|
29
32
|
import { createServer, type Server } from '@/server'
|
|
30
33
|
import { createSessionFactory, type SessionFactory } from '@/sessions'
|
|
@@ -87,7 +90,8 @@ export async function startAgent({
|
|
|
87
90
|
// which is what we want, since there is no host daemon to honor it anyway.
|
|
88
91
|
const containerName = process.env.TYPECLAW_CONTAINER_NAME
|
|
89
92
|
const containerNameOpt = containerName !== undefined ? { containerName } : {}
|
|
90
|
-
|
|
93
|
+
const tuiToken = process.env.TYPECLAW_TUI_TOKEN
|
|
94
|
+
const tuiTokenOpt = tuiToken !== undefined && tuiToken !== '' ? { tuiToken } : {}
|
|
91
95
|
|
|
92
96
|
const pluginConfigsByName = loadPluginConfigsSync(cwd)
|
|
93
97
|
const cwdConfig = loadConfigSync(cwd)
|
|
@@ -96,7 +100,10 @@ export async function startAgent({
|
|
|
96
100
|
agentDir: cwd,
|
|
97
101
|
configsByName: pluginConfigsByName,
|
|
98
102
|
bundled: BUNDLED_PLUGINS,
|
|
103
|
+
...(cwdConfig.roles !== undefined ? { roles: cwdConfig.roles } : {}),
|
|
99
104
|
})
|
|
105
|
+
|
|
106
|
+
reloadRegistry.register(createConfigReloadable({ cwd, permissions: pluginsLoaded.permissions }))
|
|
100
107
|
const pluginRegistry = pluginsLoaded.registry
|
|
101
108
|
const pluginHooks = pluginsLoaded.hooks
|
|
102
109
|
|
|
@@ -128,6 +135,12 @@ export async function startAgent({
|
|
|
128
135
|
// stay in env, the file stays user-owned. See src/secrets/hydrate.ts.
|
|
129
136
|
hydrateChannelEnvFromSecrets({ agentDir: cwd })
|
|
130
137
|
|
|
138
|
+
const claimController = createClaimController({
|
|
139
|
+
cwd,
|
|
140
|
+
permissions: pluginsLoaded.permissions,
|
|
141
|
+
rolesProvider: () => getConfig().roles,
|
|
142
|
+
})
|
|
143
|
+
|
|
131
144
|
const channelManager = createChannelManager({
|
|
132
145
|
agentDir: cwd,
|
|
133
146
|
channelsConfigRef: () => getConfig().channels,
|
|
@@ -139,8 +152,12 @@ export async function startAgent({
|
|
|
139
152
|
reloadRegistry,
|
|
140
153
|
pluginRuntime,
|
|
141
154
|
getChannelRouter: () => channelManager.router,
|
|
155
|
+
rehydrateCapOptions: resolveCapOptionsFromConfig(pluginConfigsByName['tool-result-cap']),
|
|
156
|
+
permissions: pluginsLoaded.permissions,
|
|
142
157
|
...containerNameOpt,
|
|
143
158
|
}),
|
|
159
|
+
permissions: pluginsLoaded.permissions,
|
|
160
|
+
claimHandler: claimController.claimHandler,
|
|
144
161
|
})
|
|
145
162
|
|
|
146
163
|
const createSessionForSubagent: import('@/agent/subagents').CreateSessionForSubagent = async (
|
|
@@ -150,16 +167,21 @@ export async function startAgent({
|
|
|
150
167
|
const snap = pluginRuntime.get()
|
|
151
168
|
const entry = snap.pluginSubagentByShim.get(subagent)
|
|
152
169
|
if (entry) {
|
|
153
|
-
const
|
|
154
|
-
const
|
|
170
|
+
const sessionManager = SessionManager.create(cwd, sessionFactory.sessionDir())
|
|
171
|
+
const sessionId = sessionManager.getSessionId()
|
|
172
|
+
const origin: SessionOrigin = {
|
|
155
173
|
kind: 'subagent' as const,
|
|
156
174
|
subagent: subagentOptions?.name ?? entry.subagentName,
|
|
157
175
|
parentSessionId: subagentOptions?.parentSessionId ?? '<unknown>',
|
|
176
|
+
...(subagentOptions?.spawnedByRole !== undefined ? { spawnedByRole: subagentOptions.spawnedByRole } : {}),
|
|
177
|
+
...(subagentOptions?.spawnedByOrigin !== undefined ? { spawnedByOrigin: subagentOptions.spawnedByOrigin } : {}),
|
|
158
178
|
}
|
|
159
179
|
const created = await createSessionWithDispose({
|
|
160
180
|
systemPromptOverride: entry.pluginSubagent.systemPrompt,
|
|
181
|
+
sessionManager,
|
|
161
182
|
channelRouter: channelManager.router,
|
|
162
183
|
origin,
|
|
184
|
+
permissions: pluginsLoaded.permissions,
|
|
163
185
|
plugins: {
|
|
164
186
|
registry: snap.registry,
|
|
165
187
|
hooks: snap.hooks,
|
|
@@ -172,6 +194,10 @@ export async function startAgent({
|
|
|
172
194
|
...(entry.pluginSubagent.customTools ? { customTools: entry.pluginSubagent.customTools } : {}),
|
|
173
195
|
toolNamePrefix: `__plugin_${entry.pluginName}_${entry.subagentName}`,
|
|
174
196
|
},
|
|
197
|
+
...(entry.pluginSubagent.profile !== undefined ? { profile: entry.pluginSubagent.profile } : {}),
|
|
198
|
+
...(entry.pluginSubagent.toolResultBudget !== undefined
|
|
199
|
+
? { toolResultBudget: entry.pluginSubagent.toolResultBudget }
|
|
200
|
+
: {}),
|
|
175
201
|
})
|
|
176
202
|
return {
|
|
177
203
|
...created,
|
|
@@ -179,6 +205,7 @@ export async function startAgent({
|
|
|
179
205
|
sessionId,
|
|
180
206
|
agentDir: cwd,
|
|
181
207
|
origin,
|
|
208
|
+
getTranscriptPath: () => sessionManager.getSessionFile(),
|
|
182
209
|
}
|
|
183
210
|
}
|
|
184
211
|
return defaultCreateSessionForSubagent(subagent, subagentOptions)
|
|
@@ -211,12 +238,24 @@ export async function startAgent({
|
|
|
211
238
|
const snap = pluginRuntime.get()
|
|
212
239
|
const sessionManager = SessionManager.create(cwd, sessionFactory.sessionDir())
|
|
213
240
|
const sessionId = sessionManager.getSessionId()
|
|
241
|
+
const cronOrigin: SessionOrigin = {
|
|
242
|
+
kind: 'cron',
|
|
243
|
+
jobId: job.id,
|
|
244
|
+
jobKind: 'prompt',
|
|
245
|
+
...(job.scheduledByRole !== undefined ? { scheduledByRole: job.scheduledByRole } : {}),
|
|
246
|
+
// Honor the persisted audit snapshot when present (TUI-authored
|
|
247
|
+
// crons, or jobs scheduled by a future `cron_schedule` tool).
|
|
248
|
+
// Hand-authored entries fall back to the config-file synthetic
|
|
249
|
+
// marker so the audit trail records "user edited cron.json".
|
|
250
|
+
scheduledByOrigin: (job.scheduledByOrigin as SessionOrigin | undefined) ?? { kind: 'config-file' },
|
|
251
|
+
}
|
|
214
252
|
const session = await createSession({
|
|
215
253
|
reloadRegistry,
|
|
216
254
|
sessionManager,
|
|
217
255
|
stream,
|
|
218
256
|
channelRouter: channelManager.router,
|
|
219
|
-
origin:
|
|
257
|
+
origin: cronOrigin,
|
|
258
|
+
permissions: pluginsLoaded.permissions,
|
|
220
259
|
...(snap.hasAnyPluginContent
|
|
221
260
|
? {
|
|
222
261
|
plugins: {
|
|
@@ -234,7 +273,7 @@ export async function startAgent({
|
|
|
234
273
|
dispose: () => session.dispose(),
|
|
235
274
|
sessionId,
|
|
236
275
|
agentDir: cwd,
|
|
237
|
-
origin:
|
|
276
|
+
origin: cronOrigin,
|
|
238
277
|
...(snap.hasAnyPluginContent ? { hooks: snap.hooks } : {}),
|
|
239
278
|
getTranscriptPath: () => sessionManager.getSessionFile(),
|
|
240
279
|
}
|
|
@@ -262,13 +301,24 @@ export async function startAgent({
|
|
|
262
301
|
reloadRegistry.register(createChannelsReloadable({ manager: channelManager }))
|
|
263
302
|
await channelManager.start()
|
|
264
303
|
|
|
265
|
-
pluginsLoaded.setSpawnSubagent(async (name, payload) => {
|
|
304
|
+
pluginsLoaded.setSpawnSubagent(async (name, payload, options) => {
|
|
305
|
+
// Resolve the spawning session's role from its origin so the subagent
|
|
306
|
+
// inherits it. Callers (hooks like session.idle) pass the parent origin
|
|
307
|
+
// verbatim; we look up the role rather than letting the caller forge it,
|
|
308
|
+
// closing the laundering vector the design doc calls out for cron.
|
|
309
|
+
const spawnedByRole =
|
|
310
|
+
options?.spawnedByOrigin !== undefined
|
|
311
|
+
? pluginsLoaded.permissions.resolveRole(options.spawnedByOrigin)
|
|
312
|
+
: undefined
|
|
266
313
|
await invokeSubagent(name, {
|
|
267
314
|
registry: pluginRuntime.get().subagents,
|
|
268
315
|
createSessionForSubagent,
|
|
269
316
|
agentDir: cwd,
|
|
270
317
|
userPrompt: '',
|
|
271
318
|
payload,
|
|
319
|
+
...(options?.parentSessionId !== undefined ? { parentSessionId: options.parentSessionId } : {}),
|
|
320
|
+
...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
|
|
321
|
+
...(options?.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
|
|
272
322
|
})
|
|
273
323
|
})
|
|
274
324
|
pluginsLoaded.markBooted()
|
|
@@ -311,7 +361,9 @@ export async function startAgent({
|
|
|
311
361
|
channelRouter: channelManager.router,
|
|
312
362
|
agentDir: cwd,
|
|
313
363
|
pluginRuntime,
|
|
364
|
+
claimController,
|
|
314
365
|
...containerNameOpt,
|
|
366
|
+
...tuiTokenOpt,
|
|
315
367
|
...containerBrokerOpt,
|
|
316
368
|
}).start()
|
|
317
369
|
|
|
@@ -343,7 +395,9 @@ export async function startAgent({
|
|
|
343
395
|
}
|
|
344
396
|
}
|
|
345
397
|
|
|
346
|
-
const
|
|
398
|
+
const serverPort = server.port
|
|
399
|
+
if (serverPort === undefined) throw new Error('server did not report a listening port')
|
|
400
|
+
const url = buildLocalTuiUrl(serverPort, tuiTokenOpt.tuiToken ?? null)
|
|
347
401
|
const tui = createTui({ url, initialPrompt })
|
|
348
402
|
const tuiPromise = tui.run()
|
|
349
403
|
return {
|
|
@@ -361,6 +415,13 @@ export async function startAgent({
|
|
|
361
415
|
}
|
|
362
416
|
}
|
|
363
417
|
|
|
418
|
+
function buildLocalTuiUrl(port: number, token: string | null): string {
|
|
419
|
+
if (token === null) return `ws://localhost:${port}`
|
|
420
|
+
const url = new URL(`ws://localhost:${port}`)
|
|
421
|
+
url.searchParams.set('token', token)
|
|
422
|
+
return url.toString()
|
|
423
|
+
}
|
|
424
|
+
|
|
364
425
|
async function disposeMaterializedSkills(pluginRuntime: PluginRuntime): Promise<void> {
|
|
365
426
|
const pending = pluginRuntime.drainPendingDisposal()
|
|
366
427
|
const current = pluginRuntime.get().materializedSkills
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// AES-256-GCM authenticated encryption for at-rest secrets. The threat model
|
|
4
|
+
// is narrow and conditional: "agent folder leaked but the effective TypeClaw
|
|
5
|
+
// home (default ~/.typeclaw/, overridable via TYPECLAW_HOME) did not." That
|
|
6
|
+
// covers accidental `git add secrets.json`, agent-folder backups, shared
|
|
7
|
+
// mounts that expose only the agent dir. It does NOT cover (a) full host
|
|
8
|
+
// compromise (the live OAuth tokens in the same secrets.json grant equivalent
|
|
9
|
+
// capability), (b) whole-$HOME backups that capture both the agent dir and
|
|
10
|
+
// ~/.typeclaw/, or (c) misconfiguration where TYPECLAW_HOME points inside the
|
|
11
|
+
// leaked scope (e.g. inside the agent folder itself).
|
|
12
|
+
//
|
|
13
|
+
// AAD binds the ciphertext to the specific (containerName, accountId, version)
|
|
14
|
+
// it was produced for, so a ciphertext copied between accounts or containers
|
|
15
|
+
// fails authentication on decrypt even if the same key happens to unlock both.
|
|
16
|
+
|
|
17
|
+
const ALGORITHM = 'AES-256-GCM' as const
|
|
18
|
+
const KEY_BYTES = 32
|
|
19
|
+
const IV_BYTES = 12
|
|
20
|
+
const AUTH_TAG_BYTES = 16
|
|
21
|
+
const ENVELOPE_VERSION = 1
|
|
22
|
+
|
|
23
|
+
export type EncryptedEnvelope = {
|
|
24
|
+
v: typeof ENVELOPE_VERSION
|
|
25
|
+
alg: typeof ALGORITHM
|
|
26
|
+
kid: string
|
|
27
|
+
iv: string
|
|
28
|
+
ciphertext: string
|
|
29
|
+
authTag: string
|
|
30
|
+
createdAt: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type EncryptionContext = {
|
|
34
|
+
containerName: string
|
|
35
|
+
accountId: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class EncryptionError extends Error {
|
|
39
|
+
constructor(
|
|
40
|
+
message: string,
|
|
41
|
+
public readonly code: 'decrypt_failed' | 'envelope_invalid' | 'key_size_invalid' | 'algorithm_unsupported',
|
|
42
|
+
) {
|
|
43
|
+
super(message)
|
|
44
|
+
this.name = 'EncryptionError'
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function generateKey(): Buffer {
|
|
49
|
+
return randomBytes(KEY_BYTES)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function fingerprintKey(key: Buffer): string {
|
|
53
|
+
if (key.length !== KEY_BYTES) {
|
|
54
|
+
throw new EncryptionError(`key must be ${KEY_BYTES} bytes, got ${key.length}`, 'key_size_invalid')
|
|
55
|
+
}
|
|
56
|
+
return `sha256:${createHash('sha256').update(key).digest('hex').slice(0, 16)}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function encrypt(plaintext: string, key: Buffer, context: EncryptionContext): EncryptedEnvelope {
|
|
60
|
+
if (key.length !== KEY_BYTES) {
|
|
61
|
+
throw new EncryptionError(`key must be ${KEY_BYTES} bytes, got ${key.length}`, 'key_size_invalid')
|
|
62
|
+
}
|
|
63
|
+
const iv = randomBytes(IV_BYTES)
|
|
64
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
|
65
|
+
cipher.setAAD(buildAad(context))
|
|
66
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
|
67
|
+
const authTag = cipher.getAuthTag()
|
|
68
|
+
return {
|
|
69
|
+
v: ENVELOPE_VERSION,
|
|
70
|
+
alg: ALGORITHM,
|
|
71
|
+
kid: fingerprintKey(key),
|
|
72
|
+
iv: iv.toString('base64'),
|
|
73
|
+
ciphertext: ciphertext.toString('base64'),
|
|
74
|
+
authTag: authTag.toString('base64'),
|
|
75
|
+
createdAt: new Date().toISOString(),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function decrypt(envelope: EncryptedEnvelope, key: Buffer, context: EncryptionContext): string {
|
|
80
|
+
if (envelope.v !== ENVELOPE_VERSION) {
|
|
81
|
+
throw new EncryptionError(`unsupported envelope version: ${envelope.v}`, 'envelope_invalid')
|
|
82
|
+
}
|
|
83
|
+
if (envelope.alg !== ALGORITHM) {
|
|
84
|
+
throw new EncryptionError(`unsupported algorithm: ${envelope.alg}`, 'algorithm_unsupported')
|
|
85
|
+
}
|
|
86
|
+
if (key.length !== KEY_BYTES) {
|
|
87
|
+
throw new EncryptionError(`key must be ${KEY_BYTES} bytes, got ${key.length}`, 'key_size_invalid')
|
|
88
|
+
}
|
|
89
|
+
const iv = Buffer.from(envelope.iv, 'base64')
|
|
90
|
+
if (iv.length !== IV_BYTES) {
|
|
91
|
+
throw new EncryptionError(`iv must be ${IV_BYTES} bytes, got ${iv.length}`, 'envelope_invalid')
|
|
92
|
+
}
|
|
93
|
+
const authTag = Buffer.from(envelope.authTag, 'base64')
|
|
94
|
+
if (authTag.length !== AUTH_TAG_BYTES) {
|
|
95
|
+
throw new EncryptionError(`authTag must be ${AUTH_TAG_BYTES} bytes, got ${authTag.length}`, 'envelope_invalid')
|
|
96
|
+
}
|
|
97
|
+
const ciphertext = Buffer.from(envelope.ciphertext, 'base64')
|
|
98
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
|
99
|
+
decipher.setAAD(buildAad(context))
|
|
100
|
+
decipher.setAuthTag(authTag)
|
|
101
|
+
try {
|
|
102
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
|
103
|
+
return plaintext.toString('utf8')
|
|
104
|
+
} catch (err) {
|
|
105
|
+
// GCM auth failure produces an opaque "Unsupported state or unable to
|
|
106
|
+
// authenticate data" — wrap it so callers can distinguish a wrong key /
|
|
107
|
+
// tampered blob from invariant violations on the envelope shape.
|
|
108
|
+
throw new EncryptionError(`decrypt failed: ${err instanceof Error ? err.message : String(err)}`, 'decrypt_failed')
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Changing this format breaks decryption of every previously-stored ciphertext.
|
|
113
|
+
// See the module-header threat-model comment for the binding rationale.
|
|
114
|
+
function buildAad(context: EncryptionContext): Buffer {
|
|
115
|
+
return Buffer.from(`typeclaw:kakaotalk-password:v1:${context.containerName}:${context.accountId}`, 'utf8')
|
|
116
|
+
}
|