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.
Files changed (134) hide show
  1. package/README.md +15 -13
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +13 -10
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +137 -7
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +809 -300
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +11 -3
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +13 -3
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +491 -19
  67. package/src/config/index.ts +15 -1
  68. package/src/config/models-mutation.ts +200 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +6 -1
  73. package/src/container/port.ts +10 -0
  74. package/src/container/require-running.ts +33 -0
  75. package/src/container/start.ts +81 -63
  76. package/src/cron/consumer.ts +22 -2
  77. package/src/cron/index.ts +45 -4
  78. package/src/cron/schema.ts +104 -0
  79. package/src/doctor/checks.ts +51 -34
  80. package/src/doctor/plugin-bridge.ts +28 -4
  81. package/src/git/system-commit.ts +103 -0
  82. package/src/hostd/daemon.ts +16 -0
  83. package/src/hostd/kakao-renewal-manager.ts +223 -0
  84. package/src/hostd/paths.ts +7 -0
  85. package/src/init/dockerfile.ts +36 -10
  86. package/src/init/gitignore.ts +1 -1
  87. package/src/init/index.ts +213 -85
  88. package/src/init/kakaotalk-auth.ts +18 -1
  89. package/src/init/models-dev.ts +26 -1
  90. package/src/init/run-owner-claim.ts +77 -0
  91. package/src/permissions/builtins.ts +70 -0
  92. package/src/permissions/grant.ts +99 -0
  93. package/src/permissions/index.ts +29 -0
  94. package/src/permissions/match-rule.ts +305 -0
  95. package/src/permissions/permissions.ts +196 -0
  96. package/src/permissions/resolve.ts +80 -0
  97. package/src/permissions/schema.ts +79 -0
  98. package/src/plugin/context.ts +8 -4
  99. package/src/plugin/define.ts +2 -0
  100. package/src/plugin/index.ts +2 -0
  101. package/src/plugin/manager.ts +41 -0
  102. package/src/plugin/registry.ts +9 -0
  103. package/src/plugin/types.ts +35 -1
  104. package/src/reload/client.ts +25 -1
  105. package/src/role-claim/client.ts +182 -0
  106. package/src/role-claim/code.ts +53 -0
  107. package/src/role-claim/controller.ts +194 -0
  108. package/src/role-claim/index.ts +19 -0
  109. package/src/role-claim/match-rule.ts +43 -0
  110. package/src/role-claim/pending.ts +100 -0
  111. package/src/run/channel-session-factory.ts +76 -5
  112. package/src/run/index.ts +68 -7
  113. package/src/secrets/encryption.ts +116 -0
  114. package/src/secrets/kakao-renewal.ts +248 -0
  115. package/src/secrets/kakao-store.ts +66 -7
  116. package/src/secrets/keys.ts +173 -0
  117. package/src/secrets/schema.ts +23 -0
  118. package/src/secrets/storage.ts +83 -0
  119. package/src/server/index.ts +198 -71
  120. package/src/shared/index.ts +4 -0
  121. package/src/shared/protocol.ts +27 -0
  122. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  123. package/src/skills/typeclaw-config/SKILL.md +104 -112
  124. package/src/skills/typeclaw-memory/SKILL.md +9 -9
  125. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  126. package/src/stream/types.ts +7 -1
  127. package/src/tui/client.ts +66 -5
  128. package/src/tui/index.ts +61 -9
  129. package/src/usage/aggregate.ts +117 -0
  130. package/src/usage/format.ts +30 -0
  131. package/src/usage/index.ts +68 -0
  132. package/src/usage/report.ts +354 -0
  133. package/src/usage/scan.ts +186 -0
  134. 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
- return async ({ existingSessionId, existingSessionFile, origin }) => {
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(deps.cwd, sessionDir, existingSessionId, existingSessionFile)
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
- console.warn(
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(`${sessionDir}/${existingSessionFile}`)
168
+ return SessionManager.open(path)
98
169
  } catch (err) {
99
170
  const reason = err instanceof Error ? err.message : String(err)
100
- console.warn(
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
- reloadRegistry.register(createConfigReloadable({ cwd }))
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 sessionId = `subagent-${entry.pluginName}-${crypto.randomUUID()}`
154
- const origin = {
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: { kind: 'cron', jobId: job.id, jobKind: 'prompt' },
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: { kind: 'cron' as const, jobId: job.id, jobKind: 'prompt' as const },
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 url = `ws://localhost:${server.port}`
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
+ }