typeclaw 0.1.4 → 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.
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs'
3
3
  import { readFile, writeFile } from 'node:fs/promises'
4
4
  import { isAbsolute, join, resolve } from 'node:path'
5
5
 
6
- import { configSchema, expandMountPath, type Config } from '@/config/config'
6
+ import { configSchema, expandMountPath, migrateLegacyConfigShape, type Config } from '@/config'
7
7
  import { send as sendToDaemon } from '@/hostd/client'
8
8
  import type { HttpInfoResult } from '@/hostd/protocol'
9
9
  import { ensureDaemon } from '@/hostd/spawn'
@@ -22,7 +22,7 @@ import { refreshPackageJson } from '@/init/packagejson'
22
22
  import { runBunUpdate, type UpdateRunner } from '@/init/run-bun-install'
23
23
  import { migrateKakaotalkCredentials } from '@/secrets'
24
24
 
25
- import { CONTAINER_PORT, findFreePort, isPortAllocatedError } from './port'
25
+ import { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, isPortAllocatedError, resolveTuiToken } from './port'
26
26
  import {
27
27
  classifyRmStderr,
28
28
  cleanupRunCorpse,
@@ -57,6 +57,7 @@ export type StartPlan = {
57
57
  runArgs: string[]
58
58
  needsBuild: boolean
59
59
  hostPort: number
60
+ tuiToken: string | null
60
61
  }
61
62
 
62
63
  export type PlanStartOptions = {
@@ -65,6 +66,8 @@ export type PlanStartOptions = {
65
66
  imageExists: boolean
66
67
  forceBuild?: boolean
67
68
  hostdControl?: HostDaemonControl
69
+ publishHost?: string
70
+ tuiToken?: string | null
68
71
  }
69
72
 
70
73
  export type HostDaemonControl = {
@@ -127,6 +130,7 @@ export type StartResult =
127
130
  containerId: string
128
131
  built: boolean
129
132
  hostPort: number
133
+ tuiToken: string | null
130
134
  hostd: HostDaemonStatus
131
135
  // True when the container was already running and start() became a no-op.
132
136
  // Callers that want to distinguish "I just launched it" from "it was up
@@ -298,7 +302,17 @@ export async function start({
298
302
  : { state: 'disabled' as const }
299
303
  let hostdControl = hostd.state === 'registered' ? hostd.control : undefined
300
304
 
301
- let plan = await planStart({ cwd, hostPort, imageExists: imageExisted, forceBuild, hostdControl })
305
+ const publishHost = await resolvePublishHost(exec)
306
+ const tuiToken = randomBytes(32).toString('base64url')
307
+ let plan = await planStart({
308
+ cwd,
309
+ hostPort,
310
+ imageExists: imageExisted,
311
+ forceBuild,
312
+ hostdControl,
313
+ publishHost,
314
+ tuiToken,
315
+ })
302
316
 
303
317
  let built = false
304
318
  if (plan.needsBuild) {
@@ -347,7 +361,15 @@ export async function start({
347
361
  hostd = await registerWithDaemon({ cwd, containerName, cliEntry, hostPort, reuseCurrentHostDaemon })
348
362
  hostdControl = hostd.state === 'registered' ? hostd.control : undefined
349
363
  }
350
- plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false, hostdControl })
364
+ plan = await planStart({
365
+ cwd,
366
+ hostPort,
367
+ imageExists: true,
368
+ forceBuild: false,
369
+ hostdControl,
370
+ publishHost,
371
+ tuiToken,
372
+ })
351
373
  run = await execRunWithConflictRetry(exec, plan.runArgs, cwd, containerName)
352
374
  }
353
375
 
@@ -370,6 +392,7 @@ export async function start({
370
392
  containerId,
371
393
  built,
372
394
  hostPort,
395
+ tuiToken,
373
396
  hostd: stripHostDaemonControl(hostd),
374
397
  alreadyRunning: false,
375
398
  autoUpgrade: upgrade,
@@ -403,6 +426,8 @@ export async function planStart({
403
426
  imageExists,
404
427
  forceBuild = false,
405
428
  hostdControl,
429
+ publishHost = '127.0.0.1',
430
+ tuiToken = null,
406
431
  }: PlanStartOptions): Promise<StartPlan> {
407
432
  const containerName = containerNameFromCwd(cwd)
408
433
  const imageTag = imageTagFromCwd(cwd)
@@ -416,7 +441,7 @@ export async function planStart({
416
441
  // the start() preflight force-removes any lingering corpse before the next
417
442
  // launch — so the only state Docker ever sees in `docker ps -a` is either
418
443
  // a running container or one the user has not started again yet.
419
- const runArgs = ['run', '-d', '--name', containerName, '-p', `127.0.0.1:${hostPort}:${CONTAINER_PORT}`]
444
+ const runArgs = ['run', '-d', '--name', containerName, '-p', `${publishHost}:${hostPort}:${CONTAINER_PORT}`]
420
445
 
421
446
  // Network egress filter: when `typeclaw.json#network.blockInternal` is true,
422
447
  // grant the container CAP_NET_ADMIN at boot so the entrypoint shim can
@@ -443,6 +468,9 @@ export async function planStart({
443
468
  for (const [key, value] of Object.entries(composeLabels(cwd, containerName))) {
444
469
  runArgs.push('--label', `${key}=${value}`)
445
470
  }
471
+ if (tuiToken !== null) {
472
+ runArgs.push('--label', `${TUI_TOKEN_LABEL}=${tuiToken}`, '-e', `TYPECLAW_TUI_TOKEN=${tuiToken}`)
473
+ }
446
474
 
447
475
  if (existsSync(join(cwd, ENV_FILE))) {
448
476
  runArgs.push('--env-file', join(cwd, ENV_FILE))
@@ -493,20 +521,27 @@ export async function planStart({
493
521
  runArgs,
494
522
  needsBuild: forceBuild || !imageExists,
495
523
  hostPort,
524
+ tuiToken,
496
525
  }
497
526
  }
498
527
 
528
+ async function resolvePublishHost(exec: DockerExec): Promise<string> {
529
+ const result = await exec(['version', '--format', '{{.Server.Platform.Name}}'])
530
+ if (result.exitCode === 0 && result.stdout.toLowerCase().includes('docker desktop')) return '0.0.0.0'
531
+ return '127.0.0.1'
532
+ }
533
+
499
534
  export async function refreshDockerfile(cwd: string): Promise<void> {
500
535
  const cfg = await loadTypeclawConfig(cwd)
501
536
  await writeFile(
502
537
  join(cwd, DOCKERFILE),
503
- buildDockerfile(cfg.dockerfile, { baseImageVersion: resolveBaseImageVersion(cwd) }),
538
+ buildDockerfile(cfg.docker.file, { baseImageVersion: resolveBaseImageVersion(cwd) }),
504
539
  )
505
540
  }
506
541
 
507
542
  export async function refreshGitignore(cwd: string): Promise<void> {
508
543
  const cfg = await loadTypeclawConfig(cwd)
509
- await writeFile(join(cwd, GITIGNORE_FILE), buildGitignore(cfg.gitignore))
544
+ await writeFile(join(cwd, GITIGNORE_FILE), buildGitignore(cfg.git.ignore))
510
545
  }
511
546
 
512
547
  // Commits TypeClaw-owned system file(s) if any are dirty in git. Skips silently
@@ -623,13 +658,15 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
623
658
  reason: `Container ${containerName} is running but its published host port could not be resolved.`,
624
659
  }
625
660
  }
626
- const plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false })
661
+ const tuiToken = await resolveTuiToken({ cwd, exec })
662
+ const plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false, tuiToken })
627
663
  return {
628
664
  ok: true,
629
665
  plan,
630
666
  containerId,
631
667
  built: false,
632
668
  hostPort,
669
+ tuiToken,
633
670
  hostd: { state: 'disabled' },
634
671
  alreadyRunning: true,
635
672
  autoUpgrade: { kind: 'skipped-already-running' },
@@ -747,7 +784,7 @@ async function loadConfigJson(cwd: string): Promise<unknown> {
747
784
  } catch {
748
785
  return {}
749
786
  }
750
- return JSON.parse(raw)
787
+ return migrateLegacyConfigShape(JSON.parse(raw)).json
751
788
  }
752
789
 
753
790
  type PreparedHostDaemonStatus =
@@ -390,7 +390,7 @@ function loadConfigStrictForTemplate(
390
390
  const result = validateConfig(cwd)
391
391
  if (!result.ok) return null
392
392
  const cfg = loadConfigSync(cwd)
393
- return { dockerfile: cfg.dockerfile, gitignore: cfg.gitignore }
393
+ return { dockerfile: cfg.docker.file, gitignore: cfg.git.ignore }
394
394
  }
395
395
 
396
396
  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
- url = `ws://localhost:${port}`
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(err instanceof Error ? err : new Error(`failed to connect to ${url}`))
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(`websocket connect timeout after ${timeoutMs}ms`))
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,
@@ -384,7 +384,7 @@ ${ghKeyringLayer}# Layer 2 (changes when the package list changes): the actual a
384
384
  # Cache mounts make a re-install nearly free when this layer is invalidated:
385
385
  # .deb files come straight from the host's BuildKit cache instead of being
386
386
  # refetched from Debian/GitHub mirrors. Package set is composed from the
387
- # \`dockerfile\` config block in typeclaw.json — toggles for tmux/python/gh/
387
+ # \`docker.file\` config block in typeclaw.json — toggles for tmux/python/gh/
388
388
  # ffmpeg fan out into the args below. Baseline (git/ca-certificates/curl/
389
389
  # gnupg) is always installed because downstream layers depend on it.
390
390
  #
@@ -417,7 +417,7 @@ ${renderEntrypointShimLayer()}
417
417
 
418
418
  function renderToggleAptInstallLayer(toggleAptArgs: string[]): string {
419
419
  return `# Layer 1 (toggle apt install): packages requested via typeclaw.json
420
- # #dockerfile toggles. Baseline + Chrome runtime libs are already in the
420
+ # #docker.file toggles. Baseline + Chrome runtime libs are already in the
421
421
  # base image; this layer only adds gh/tmux/python/ffmpeg if enabled.
422
422
  RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
423
423
  --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
@@ -432,7 +432,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
432
432
  // two cannot drift — the published image is a function of this source, not
433
433
  // a checked-in Dockerfile that needs hand-syncing. The base intentionally
434
434
  // stops before the per-agent layers (gh keyring, apt feature toggles,
435
- // dockerfile.append, ENV, ENTRYPOINT) so users can still toggle them via
435
+ // docker.file.append, ENV, ENTRYPOINT) so users can still toggle them via
436
436
  // typeclaw.json without forcing a base-image rebuild.
437
437
  //
438
438
  // Layer 2's apt-get install line installs only the baseline packages, NOT
@@ -587,7 +587,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
587
587
 
588
588
  function renderCustomDockerfileLines(lines: string[]): string {
589
589
  if (lines.length === 0) return ''
590
- return `# Custom lines from typeclaw.json#dockerfile.append.
590
+ return `# Custom lines from typeclaw.json#docker.file.append.
591
591
  ${lines.join('\n')}
592
592
 
593
593
  `
@@ -39,7 +39,7 @@ channels/
39
39
 
40
40
  function renderCustomGitignoreEntries(entries: string[]): string {
41
41
  if (entries.length === 0) return ''
42
- return `# Custom entries from typeclaw.json#gitignore.append.
42
+ return `# Custom entries from typeclaw.json#git.ignore.append.
43
43
  ${entries.join('\n')}
44
44
 
45
45
  `
package/src/init/index.ts CHANGED
@@ -3,10 +3,16 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { basename, dirname, join, relative, resolve } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
 
6
- import { config, configSchema, type Config } from '@/config'
7
- import { 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()