typeclaw 0.1.3 → 0.1.5

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/src/init/index.ts CHANGED
@@ -3,10 +3,16 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { basename, dirname, join, relative, resolve } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
 
6
- import { config, configSchema, type Config } from '@/config'
7
- import { DEFAULT_MODEL_REF, KNOWN_PROVIDERS, providerForModelRef, type KnownModelRef } from '@/config/providers'
6
+ import { config, configSchema, migrateLegacyConfigShape, type Config } from '@/config'
7
+ import {
8
+ DEFAULT_MODEL_REF,
9
+ KNOWN_PROVIDERS,
10
+ providerForModelRef,
11
+ type KnownModelRef,
12
+ type KnownProviderId,
13
+ } from '@/config/providers'
8
14
  import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
9
- import { type Channels, type Secret, SecretsBackend } from '@/secrets'
15
+ import { createSecretsStoreForAgent, type Channels, type Secret, SecretsBackend } from '@/secrets'
10
16
  import { createTui } from '@/tui'
11
17
 
12
18
  import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
@@ -23,7 +29,6 @@ export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
23
29
 
24
30
  const CONFIG_FILE = 'typeclaw.json'
25
31
  const CRON_FILE = 'cron.json'
26
- const SECRETS_FILE = '.env'
27
32
  const PACKAGE_FILE = 'package.json'
28
33
 
29
34
  const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as const
@@ -269,10 +274,10 @@ export async function defaultRunHatching({
269
274
  // the preferred port, otherwise we'd connect to the wrong service.
270
275
  const hostPort = launch.hostPort
271
276
 
272
- await waitForAgentFn(`http://localhost:${hostPort}`, { timeoutMs: 30_000 })
277
+ await waitForAgentFn(`http://127.0.0.1:${hostPort}`, { timeoutMs: 30_000 })
273
278
 
274
279
  const tui = tuiFactory({
275
- url: `ws://localhost:${hostPort}`,
280
+ url: buildTuiUrl(hostPort, launch.tuiToken),
276
281
  initialPrompt: HATCHING_PROMPT,
277
282
  })
278
283
  await tui.run()
@@ -282,6 +287,12 @@ export async function defaultRunHatching({
282
287
  }
283
288
  }
284
289
 
290
+ function buildTuiUrl(hostPort: number, token: string | null): string {
291
+ const url = new URL(`ws://127.0.0.1:${hostPort}`)
292
+ if (token !== null) url.searchParams.set('token', token)
293
+ return url.toString()
294
+ }
295
+
285
296
  // Probe the server's plain HTTP fallback (non-upgrade requests get a 200 with
286
297
  // body "typeclaw agent") instead of opening a WebSocket. Opening a WS here
287
298
  // would trigger createSession on the server and burn an LLM session just to
@@ -484,7 +495,7 @@ export async function writeDockerAssets(root: string): Promise<DockerAssetsResul
484
495
  const typeclawConfig = await readTypeclawConfig(root)
485
496
  await writeFile(
486
497
  join(root, DOCKERFILE),
487
- buildDockerfile(typeclawConfig.dockerfile, { baseImageVersion: resolveBaseImageVersion(root) }),
498
+ buildDockerfile(typeclawConfig.docker.file, { baseImageVersion: resolveBaseImageVersion(root) }),
488
499
  { flag: 'wx' },
489
500
  ).catch(ignoreExists)
490
501
 
@@ -502,7 +513,7 @@ async function readPackageJson(root: string): Promise<{ name?: string; dependenc
502
513
  async function readTypeclawConfig(root: string): Promise<Config> {
503
514
  try {
504
515
  const raw = await readFile(join(root, CONFIG_FILE), 'utf8')
505
- return configSchema.parse(JSON.parse(raw))
516
+ return configSchema.parse(migrateLegacyConfigShape(JSON.parse(raw)).json)
506
517
  } catch (error) {
507
518
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return configSchema.parse({})
508
519
  throw error
@@ -558,15 +569,10 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
558
569
  }
559
570
  }
560
571
 
561
- // Writes the LLM provider's API key to `.env` (under its provider-specific
562
- // env var, e.g. OPENAI_API_KEY or FIREWORKS_API_KEY) and the channel adapter
563
- // tokens to `secrets.json#channels`. Two stores on purpose: api-keys land in
564
- // `.env` to match the `--env-file .env` boot contract (env-wins: `auth.ts`
565
- // reads the value at runtime via `setRuntimeApiKey` and never persists it to
566
- // `secrets.json`, see `src/agent/auth.ts`); channel tokens skip the .env hop
567
- // entirely and land in `secrets.json#channels` as `{ value }` Secrets that
568
- // `hydrateChannelEnvFromSecrets` injects into `process.env` only when the
569
- // canonical env var is unset, see `src/secrets/hydrate.ts`.
572
+ // Writes LLM provider API keys to `secrets.json#providers` and channel adapter
573
+ // tokens to `secrets.json#channels`. Both paths go through the structured
574
+ // v2 secrets envelope so reruns can reuse existing values without depending on
575
+ // host-stage env files.
570
576
  export async function writeSecrets(
571
577
  root: string,
572
578
  {
@@ -578,9 +584,7 @@ export async function writeSecrets(
578
584
  telegramBotToken,
579
585
  }: {
580
586
  model?: KnownModelRef
581
- // Omitted on the OAuth path — credentials live in secrets.json instead.
582
- // The .env file still gets written (empty) so post-init callers that
583
- // read it don't ENOENT-crash.
587
+ // Omitted on the OAuth path — credentials live in secrets.json via the OAuth runner.
584
588
  apiKey?: string
585
589
  discordBotToken?: string
586
590
  slackBotToken?: string
@@ -590,12 +594,9 @@ export async function writeSecrets(
590
594
  ): Promise<void> {
591
595
  const providerId = providerForModelRef(model)
592
596
  const apiKeyEnv = KNOWN_PROVIDERS[providerId].apiKeyEnv
593
- const lines: string[] = []
594
597
  if (apiKey !== undefined && apiKeyEnv !== null) {
595
- lines.push(`${apiKeyEnv}=${apiKey}`)
598
+ createSecretsStoreForAgent(join(root, 'secrets.json')).set(providerId, { type: 'api_key', key: apiKey })
596
599
  }
597
- const body = lines.length > 0 ? `${lines.join('\n')}\n` : ''
598
- await writeFile(join(root, SECRETS_FILE), body)
599
600
 
600
601
  const channelTokens: Record<string, Record<string, Secret>> = {}
601
602
  if (discordBotToken !== undefined && discordBotToken !== '') {
@@ -623,6 +624,12 @@ export async function writeSecrets(
623
624
  backend.writeChannelsSync(merged)
624
625
  }
625
626
 
627
+ export async function readExistingProviderApiKey(root: string, providerId: KnownProviderId): Promise<string | null> {
628
+ const provider = KNOWN_PROVIDERS[providerId]
629
+ if (provider.apiKeyEnv === null) return null
630
+ return new SecretsBackend(join(root, 'secrets.json')).tryReadProviderApiKeySync(providerId)
631
+ }
632
+
626
633
  function isObjectRecord(value: unknown): value is Record<string, unknown> {
627
634
  return typeof value === 'object' && value !== null && !Array.isArray(value)
628
635
  }
@@ -16,22 +16,36 @@ export async function requestReload({
16
16
  timeoutMs = DEFAULT_TIMEOUT_MS,
17
17
  }: RequestReloadOptions): Promise<ReloadResult[]> {
18
18
  const ws = new WebSocket(url)
19
+ const displayUrl = redactUrl(url)
19
20
 
20
21
  await new Promise<void>((resolve, reject) => {
22
+ let timer: ReturnType<typeof setTimeout> | undefined
21
23
  const onOpen = () => {
22
24
  cleanup()
23
25
  resolve()
24
26
  }
25
27
  const onError = (err: unknown) => {
26
28
  cleanup()
27
- reject(err instanceof Error ? err : new Error(`failed to connect to ${url}`))
29
+ reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
30
+ }
31
+ const onClose = () => {
32
+ cleanup()
33
+ reject(new Error(`connection to ${displayUrl} closed before opening`))
28
34
  }
29
35
  const cleanup = () => {
36
+ if (timer !== undefined) clearTimeout(timer)
30
37
  ws.removeEventListener('open', onOpen)
31
38
  ws.removeEventListener('error', onError)
39
+ ws.removeEventListener('close', onClose)
32
40
  }
41
+ timer = setTimeout(() => {
42
+ cleanup()
43
+ ws.close()
44
+ reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
45
+ }, timeoutMs)
33
46
  ws.addEventListener('open', onOpen, { once: true })
34
47
  ws.addEventListener('error', onError, { once: true })
48
+ ws.addEventListener('close', onClose, { once: true })
35
49
  })
36
50
 
37
51
  try {
@@ -57,3 +71,13 @@ export async function requestReload({
57
71
  ws.close()
58
72
  }
59
73
  }
74
+
75
+ function redactUrl(url: string): string {
76
+ try {
77
+ const parsed = new URL(url)
78
+ if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
79
+ return parsed.toString()
80
+ } catch {
81
+ return url
82
+ }
83
+ }
package/src/run/index.ts CHANGED
@@ -87,6 +87,8 @@ export async function startAgent({
87
87
  // which is what we want, since there is no host daemon to honor it anyway.
88
88
  const containerName = process.env.TYPECLAW_CONTAINER_NAME
89
89
  const containerNameOpt = containerName !== undefined ? { containerName } : {}
90
+ const tuiToken = process.env.TYPECLAW_TUI_TOKEN
91
+ const tuiTokenOpt = tuiToken !== undefined && tuiToken !== '' ? { tuiToken } : {}
90
92
  reloadRegistry.register(createConfigReloadable({ cwd }))
91
93
 
92
94
  const pluginConfigsByName = loadPluginConfigsSync(cwd)
@@ -312,6 +314,7 @@ export async function startAgent({
312
314
  agentDir: cwd,
313
315
  pluginRuntime,
314
316
  ...containerNameOpt,
317
+ ...tuiTokenOpt,
315
318
  ...containerBrokerOpt,
316
319
  }).start()
317
320
 
@@ -343,7 +346,9 @@ export async function startAgent({
343
346
  }
344
347
  }
345
348
 
346
- const url = `ws://localhost:${server.port}`
349
+ const serverPort = server.port
350
+ if (serverPort === undefined) throw new Error('server did not report a listening port')
351
+ const url = buildLocalTuiUrl(serverPort, tuiTokenOpt.tuiToken ?? null)
347
352
  const tui = createTui({ url, initialPrompt })
348
353
  const tuiPromise = tui.run()
349
354
  return {
@@ -361,6 +366,13 @@ export async function startAgent({
361
366
  }
362
367
  }
363
368
 
369
+ function buildLocalTuiUrl(port: number, token: string | null): string {
370
+ if (token === null) return `ws://localhost:${port}`
371
+ const url = new URL(`ws://localhost:${port}`)
372
+ url.searchParams.set('token', token)
373
+ return url.toString()
374
+ }
375
+
364
376
  async function disposeMaterializedSkills(pluginRuntime: PluginRuntime): Promise<void> {
365
377
  const pending = pluginRuntime.drainPendingDisposal()
366
378
  const current = pluginRuntime.get().materializedSkills
@@ -160,6 +160,21 @@ export class SecretsBackend implements AuthStorageBackend {
160
160
  }
161
161
  }
162
162
 
163
+ tryReadProviderApiKeySync(providerId: string, env: NodeJS.ProcessEnv = process.env): string | null {
164
+ if (!existsSync(this.secretsPath)) return null
165
+ let release: (() => void) | undefined
166
+ try {
167
+ release = this.acquireSyncLockWithRetry()
168
+ const credential = this.readEnvelope().providers[providerId]
169
+ if (credential?.type !== 'api_key') return null
170
+ const resolved =
171
+ resolveSecret(credential.key, providerKeyDefaultEnv(providerId), env) ?? credential.key.value ?? ''
172
+ return resolved.trim() !== '' ? resolved : null
173
+ } finally {
174
+ release?.()
175
+ }
176
+ }
177
+
163
178
  writeChannelsSync(next: Channels): void {
164
179
  this.ensureParentDir()
165
180
  this.ensureFileExists()
@@ -31,6 +31,7 @@ export type ServerOptions = {
31
31
  agentDir?: string
32
32
  pluginRuntime?: PluginRuntime
33
33
  containerName?: string
34
+ tuiToken?: string
34
35
  // Optional in-process portbroker handler. When provided, requests to the
35
36
  // /portbroker WS path are routed to it instead of being treated as TUI
36
37
  // sessions. Omit to keep TUI-only behavior (used by tests + non-container
@@ -82,6 +83,7 @@ export function createServer({
82
83
  agentDir,
83
84
  pluginRuntime,
84
85
  containerName,
86
+ tuiToken,
85
87
  containerBroker,
86
88
  }: ServerOptions) {
87
89
  const sessionStates = new WeakMap<Ws, SessionState>()
@@ -97,6 +99,9 @@ export function createServer({
97
99
  if (server.upgrade(req, { data })) return
98
100
  return new Response('upgrade failed', { status: 400 })
99
101
  }
102
+ if (isWebSocketUpgrade(req) && tuiToken !== undefined && url.searchParams.get('token') !== tuiToken) {
103
+ return new Response('unauthorized', { status: 401 })
104
+ }
100
105
  const sessionId = crypto.randomUUID()
101
106
  const data: TuiWsData = { kind: 'tui', sessionId }
102
107
  if (server.upgrade(req, { data })) return
@@ -109,73 +114,80 @@ export function createServer({
109
114
  return
110
115
  }
111
116
  const ws = rawWs as Ws
112
- const sessionManager = sessionFactory?.createPersisted()
113
- const sessionFileId = sessionManager?.getSessionId() ?? ws.data.sessionId
114
- // Snapshot the runtime once so the entire session lifecycle for this
115
- // ws connection sees one consistent generation of registry+hooks. A
116
- // reload landing mid-connection swaps the live pointer; this session
117
- // keeps using the snapshot it was created with until close.
118
- const runtimeSnapshot = pluginRuntime?.get()
119
- const pluginsWiring =
120
- runtimeSnapshot !== undefined && agentDir !== undefined
121
- ? {
122
- registry: runtimeSnapshot.registry,
123
- hooks: runtimeSnapshot.hooks,
124
- sessionId: sessionFileId,
125
- agentDir,
126
- }
127
- : undefined
128
- const origin: SessionOrigin = { kind: 'tui', sessionId: sessionFileId }
129
- const result = await createSession({
130
- reloadRegistry,
131
- sessionManager,
132
- origin,
133
- ...(stream ? { stream } : {}),
134
- ...(channelRouter ? { channelRouter } : {}),
135
- ...(pluginsWiring ? { plugins: pluginsWiring } : {}),
136
- ...(containerName !== undefined ? { containerName } : {}),
137
- })
138
- const session = 'session' in result ? result.session : result
139
- const dispose = 'session' in result && result.dispose ? result.dispose : async () => {}
140
-
141
- const state: SessionState = {
142
- session,
143
- sessionFileId,
144
- origin,
145
- sessionManager,
146
- drainQueue: [],
147
- draining: false,
148
- unsubBroadcast: null,
149
- unsubPrompts: null,
150
- runtimeSnapshot: runtimeSnapshot ?? null,
151
- dispose,
152
- }
153
- sessionStates.set(ws, state)
117
+ try {
118
+ const sessionManager = sessionFactory?.createPersisted()
119
+ const sessionFileId = sessionManager?.getSessionId() ?? ws.data.sessionId
120
+ // Snapshot the runtime once so the entire session lifecycle for this
121
+ // ws connection sees one consistent generation of registry+hooks. A
122
+ // reload landing mid-connection swaps the live pointer; this session
123
+ // keeps using the snapshot it was created with until close.
124
+ const runtimeSnapshot = pluginRuntime?.get()
125
+ const pluginsWiring =
126
+ runtimeSnapshot !== undefined && agentDir !== undefined
127
+ ? {
128
+ registry: runtimeSnapshot.registry,
129
+ hooks: runtimeSnapshot.hooks,
130
+ sessionId: sessionFileId,
131
+ agentDir,
132
+ }
133
+ : undefined
134
+ const origin: SessionOrigin = { kind: 'tui', sessionId: sessionFileId }
135
+ const result = await createSession({
136
+ reloadRegistry,
137
+ sessionManager,
138
+ origin,
139
+ ...(stream ? { stream } : {}),
140
+ ...(channelRouter ? { channelRouter } : {}),
141
+ ...(pluginsWiring ? { plugins: pluginsWiring } : {}),
142
+ ...(containerName !== undefined ? { containerName } : {}),
143
+ })
144
+ const session = 'session' in result ? result.session : result
145
+ const dispose = 'session' in result && result.dispose ? result.dispose : async () => {}
146
+
147
+ const state: SessionState = {
148
+ session,
149
+ sessionFileId,
150
+ origin,
151
+ sessionManager,
152
+ drainQueue: [],
153
+ draining: false,
154
+ unsubBroadcast: null,
155
+ unsubPrompts: null,
156
+ runtimeSnapshot: runtimeSnapshot ?? null,
157
+ dispose,
158
+ }
159
+ sessionStates.set(ws, state)
154
160
 
155
- if (runtimeSnapshot !== undefined && agentDir !== undefined) {
156
- await runtimeSnapshot.hooks.runSessionStart({ sessionId: sessionFileId, agentDir })
157
- }
161
+ if (runtimeSnapshot !== undefined && agentDir !== undefined) {
162
+ await runtimeSnapshot.hooks.runSessionStart({ sessionId: sessionFileId, agentDir })
163
+ }
158
164
 
159
- forwardSessionEvents(ws, session)
160
-
161
- if (stream) {
162
- state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
163
- enqueuePrompt(ws, state, msg, agentDir),
164
- )
165
-
166
- state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
167
- const payload: ServerMessage = {
168
- type: 'notification',
169
- payload: msg.payload,
170
- ...(msg.replyTo !== undefined ? { replyTo: msg.replyTo } : {}),
171
- ...(msg.meta !== undefined ? { meta: msg.meta } : {}),
172
- }
173
- send(ws, payload)
174
- })
175
- }
165
+ forwardSessionEvents(ws, session)
176
166
 
177
- send(ws, { type: 'connected', sessionId: sessionFileId })
178
- console.log(`session ${sessionFileId}: open`)
167
+ if (stream) {
168
+ state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
169
+ enqueuePrompt(ws, state, msg, agentDir),
170
+ )
171
+
172
+ state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
173
+ const payload: ServerMessage = {
174
+ type: 'notification',
175
+ payload: msg.payload,
176
+ ...(msg.replyTo !== undefined ? { replyTo: msg.replyTo } : {}),
177
+ ...(msg.meta !== undefined ? { meta: msg.meta } : {}),
178
+ }
179
+ send(ws, payload)
180
+ })
181
+ }
182
+
183
+ send(ws, { type: 'connected', sessionId: sessionFileId })
184
+ console.log(`session ${sessionFileId}: open`)
185
+ } catch (err) {
186
+ const message = err instanceof Error ? err.message : String(err)
187
+ console.error(`session ${ws.data.sessionId}: open failed: ${message}`)
188
+ send(ws, { type: 'error', message })
189
+ ws.close()
190
+ }
179
191
  },
180
192
  async message(rawWs, raw) {
181
193
  if (rawWs.data.kind === 'portbroker') {
@@ -289,6 +301,10 @@ export function createServer({
289
301
  return { start }
290
302
  }
291
303
 
304
+ function isWebSocketUpgrade(req: Request): boolean {
305
+ return req.headers.get('upgrade')?.toLowerCase() === 'websocket'
306
+ }
307
+
292
308
  function forwardSessionEvents(ws: Ws, session: AgentSession): void {
293
309
  const toolStartedAt = new Map<string, number>()
294
310