typeclaw 0.1.2 → 0.1.4

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 (46) hide show
  1. package/README.md +4 -0
  2. package/auth.schema.json +238 -7
  3. package/package.json +1 -1
  4. package/secrets.schema.json +238 -7
  5. package/src/agent/auth.ts +19 -38
  6. package/src/agent/tools/channel-fetch-attachment.ts +6 -0
  7. package/src/agent/tools/channel-history.ts +10 -1
  8. package/src/agent/tools/channel-log.ts +32 -0
  9. package/src/agent/tools/channel-reply.ts +18 -1
  10. package/src/agent/tools/channel-send.ts +13 -1
  11. package/src/bundled-plugins/tool-result-cap/README.md +67 -0
  12. package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
  13. package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
  14. package/src/channels/adapters/kakaotalk.ts +25 -16
  15. package/src/channels/manager.ts +47 -38
  16. package/src/cli/channel.ts +3 -3
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/init.ts +2 -1
  19. package/src/cli/ui.ts +11 -0
  20. package/src/config/config.ts +61 -4
  21. package/src/container/index.ts +2 -0
  22. package/src/container/start.ts +98 -2
  23. package/src/doctor/checks.ts +7 -27
  24. package/src/doctor/commit.ts +44 -3
  25. package/src/doctor/plugin-bridge.ts +19 -0
  26. package/src/hostd/daemon.ts +28 -3
  27. package/src/hostd/protocol.ts +7 -0
  28. package/src/init/auto-upgrade.ts +368 -0
  29. package/src/init/dockerfile.ts +83 -14
  30. package/src/init/index.ts +123 -77
  31. package/src/init/kakaotalk-auth.ts +9 -3
  32. package/src/init/run-bun-install.ts +34 -0
  33. package/src/run/bundled-plugins.ts +7 -0
  34. package/src/run/index.ts +9 -0
  35. package/src/secrets/defaults.ts +67 -0
  36. package/src/secrets/hydrate.ts +99 -0
  37. package/src/secrets/index.ts +6 -12
  38. package/src/secrets/kakao-store.ts +129 -0
  39. package/src/secrets/migrate-kakaotalk.ts +82 -0
  40. package/src/secrets/migrate.ts +5 -4
  41. package/src/secrets/resolve.ts +57 -0
  42. package/src/secrets/schema.ts +162 -42
  43. package/src/secrets/storage.ts +253 -47
  44. package/src/skills/typeclaw-config/SKILL.md +47 -8
  45. package/typeclaw.schema.json +49 -2
  46. package/src/secrets/env.ts +0 -43
@@ -8,8 +8,17 @@ import {
8
8
  } from '@mariozechner/pi-coding-agent'
9
9
  import lockfile from 'proper-lockfile'
10
10
 
11
+ import { providerKeyDefaultEnv } from './defaults'
11
12
  import { migrateLegacyAuthJson } from './migrate'
12
- import { type SecretsFile, parseSecretsFile } from './schema'
13
+ import { resolveSecret, type Secret } from './resolve'
14
+ import {
15
+ type Channels,
16
+ type ProviderCredential,
17
+ type Providers,
18
+ type SecretsFile,
19
+ SECRETS_FILE_VERSION,
20
+ parseSecretsFile,
21
+ } from './schema'
13
22
 
14
23
  const SCHEMA_REL = './node_modules/typeclaw/secrets.schema.json'
15
24
  const FILE_MODE = 0o600
@@ -23,26 +32,44 @@ const ASYNC_LOCK_OPTIONS = {
23
32
  stale: 30000,
24
33
  } as const
25
34
 
26
- // SecretsBackend implements pi-coding-agent's AuthStorageBackend contract
27
- // while keeping TypeClaw in control of the on-disk file shape.
35
+ // SecretsBackend bridges TypeClaw's on-disk envelope (v2: providers with
36
+ // Secret-typed keys, channels with per-adapter field shapes) to upstream
37
+ // `AuthStorage`'s flat `Record<provider, AuthCredential>` contract.
28
38
  //
29
- // Upstream's FileAuthStorageBackend assumes the entire file IS the
30
- // AuthStorageData (a flat Record<string, AuthCredential>). TypeClaw needs the
31
- // file to also carry version + channels alongside the LLM slice, so we wrap:
32
- // every withLock cycle reads the full envelope, presents only file.llm to the
33
- // AuthStorage instance as if it were the whole file, and merges the result
34
- // back into the envelope on write.
39
+ // READ (withLock's `current` parameter):
40
+ // - Parse the envelope, walk `providers`, resolve each api-key `Secret` to
41
+ // a flat string via env-wins (process.env wins over file value).
42
+ // - OAuth credentials pass through untouched.
43
+ // - Capture the resolved string for each provider into `readSnapshot` so
44
+ // the write path can detect "unchanged" without re-resolving (the env
45
+ // can change between read and write, and re-resolving would misclassify
46
+ // env mutations as credential mutations).
47
+ // - Hand AuthStorage a flat-shape JSON string. Upstream is none the wiser.
35
48
  //
36
- // Locking and durability semantics mirror upstream's FileAuthStorageBackend:
37
- // - proper-lockfile, sync version uses busy-loop retry on ELOCKED so callers
38
- // stay synchronous (matching upstream's API contract)
39
- // - parent directory created with 0o700, file written with 0o600
40
- // - empty file is created on first access so proper-lockfile has something
41
- // to lock against (it requires the target to exist)
49
+ // WRITE (the `next` field):
50
+ // - AuthStorage hands back the full flat slice as JSON. We do NOT
51
+ // wholesale-replace the on-disk `providers` slice with this.
52
+ // - Instead, we DIFF at credential level against the prior envelope using
53
+ // the read-time `readSnapshot`:
54
+ // * Provider unchanged (flatKey === readSnapshot[providerId]) preserve
55
+ // on-disk Secret bytes verbatim (no flatten, no rewrap). This is the
56
+ // idempotency rule that prevents OAuth-refresh from accidentally
57
+ // persisting env-resolved api-key values into the file.
58
+ // * Provider changed → rewrap as Secret, preserving any prior `env`
59
+ // field the user authored.
60
+ // * Provider added → write as string shorthand (no env binding).
61
+ // * Provider removed → actually remove (do NOT resurrect).
62
+ // - OAuth credentials stay flat strings in the envelope (no Secret
63
+ // wrapping) — they're not env-injectable.
64
+ // - Unknown credential `type` values pass through verbatim, in case
65
+ // upstream adds a third type in a future release.
66
+ // - Empty/missing `key` from AuthStorage on api-key is treated as no-op
67
+ // (preserve prior on-disk Secret if any). The schema requires non-empty
68
+ // `value`, so writing an empty key would corrupt the file at next read.
42
69
  //
43
- // We additionally write atomically (temp + rename) for durability — upstream
44
- // uses plain writeFileSync, but we own a richer envelope and a half-write
45
- // would leave us with neither the old nor the new shape parseable.
70
+ // Locking and durability mirror upstream's FileAuthStorageBackend: sync
71
+ // busy-loop retry on ELOCKED to keep callers synchronous, 0o600 file, 0o700
72
+ // parent, atomic temp+rename.
46
73
  export class SecretsBackend implements AuthStorageBackend {
47
74
  constructor(private readonly secretsPath: string) {}
48
75
 
@@ -53,11 +80,11 @@ export class SecretsBackend implements AuthStorageBackend {
53
80
  try {
54
81
  release = this.acquireSyncLockWithRetry()
55
82
  const envelope = this.readEnvelope()
56
- const innerCurrent = JSON.stringify(envelope.llm, null, 2)
83
+ const { flatJson, readSnapshot } = flattenProvidersForAuthStorage(envelope.providers, process.env)
57
84
 
58
- const { result, next } = fn(innerCurrent)
85
+ const { result, next } = fn(flatJson)
59
86
  if (next !== undefined) {
60
- const merged = mergeLlmIntoEnvelope(envelope, next)
87
+ const merged = mergeProvidersIntoEnvelope(envelope, next, readSnapshot)
61
88
  this.writeEnvelopeAtomic(merged)
62
89
  }
63
90
  return result
@@ -87,12 +114,12 @@ export class SecretsBackend implements AuthStorageBackend {
87
114
  })
88
115
  throwIfCompromised()
89
116
  const envelope = this.readEnvelope()
90
- const innerCurrent = JSON.stringify(envelope.llm, null, 2)
117
+ const { flatJson, readSnapshot } = flattenProvidersForAuthStorage(envelope.providers, process.env)
91
118
 
92
- const { result, next } = await fn(innerCurrent)
119
+ const { result, next } = await fn(flatJson)
93
120
  throwIfCompromised()
94
121
  if (next !== undefined) {
95
- const merged = mergeLlmIntoEnvelope(envelope, next)
122
+ const merged = mergeProvidersIntoEnvelope(envelope, next, readSnapshot)
96
123
  this.writeEnvelopeAtomic(merged)
97
124
  }
98
125
  throwIfCompromised()
@@ -110,6 +137,92 @@ export class SecretsBackend implements AuthStorageBackend {
110
137
  }
111
138
  }
112
139
 
140
+ readChannelsSync(): Channels {
141
+ this.ensureParentDir()
142
+ this.ensureFileExists()
143
+ let release: (() => void) | undefined
144
+ try {
145
+ release = this.acquireSyncLockWithRetry()
146
+ return this.readEnvelope().channels
147
+ } finally {
148
+ release?.()
149
+ }
150
+ }
151
+
152
+ tryReadChannelsSync(): Channels | null {
153
+ if (!existsSync(this.secretsPath)) return null
154
+ let release: (() => void) | undefined
155
+ try {
156
+ release = this.acquireSyncLockWithRetry()
157
+ return this.readEnvelope().channels
158
+ } finally {
159
+ release?.()
160
+ }
161
+ }
162
+
163
+ writeChannelsSync(next: Channels): void {
164
+ this.ensureParentDir()
165
+ this.ensureFileExists()
166
+ let release: (() => void) | undefined
167
+ try {
168
+ release = this.acquireSyncLockWithRetry()
169
+ const envelope = this.readEnvelope()
170
+ const merged: SecretsFile = {
171
+ ...envelope,
172
+ $schema: envelope.$schema ?? SCHEMA_REL,
173
+ version: SECRETS_FILE_VERSION,
174
+ channels: next,
175
+ }
176
+ this.writeEnvelopeAtomic(merged)
177
+ } finally {
178
+ release?.()
179
+ }
180
+ }
181
+
182
+ async updateChannelsAsync<T>(
183
+ fn: (current: Record<string, unknown>) => Promise<{ result: T; next?: Record<string, unknown> }>,
184
+ ): Promise<T> {
185
+ this.ensureParentDir()
186
+ this.ensureFileExists()
187
+ let release: (() => Promise<void>) | undefined
188
+ let lockCompromised = false
189
+ let lockCompromisedError: Error | undefined
190
+ const throwIfCompromised = (): void => {
191
+ if (lockCompromised) {
192
+ throw lockCompromisedError ?? new Error('Secrets store lock was compromised')
193
+ }
194
+ }
195
+ try {
196
+ release = await lockfile.lock(this.secretsPath, {
197
+ ...ASYNC_LOCK_OPTIONS,
198
+ onCompromised: (err: Error) => {
199
+ lockCompromised = true
200
+ lockCompromisedError = err
201
+ },
202
+ })
203
+ throwIfCompromised()
204
+ const envelope = this.readEnvelope()
205
+ const { result, next } = await fn(envelope.channels as Record<string, unknown>)
206
+ throwIfCompromised()
207
+ if (next !== undefined) {
208
+ const merged: SecretsFile = {
209
+ ...envelope,
210
+ $schema: envelope.$schema ?? SCHEMA_REL,
211
+ channels: next as SecretsFile['channels'],
212
+ }
213
+ this.writeEnvelopeAtomic(merged)
214
+ }
215
+ throwIfCompromised()
216
+ return result
217
+ } finally {
218
+ if (release) {
219
+ try {
220
+ await release()
221
+ } catch {}
222
+ }
223
+ }
224
+ }
225
+
113
226
  private ensureParentDir(): void {
114
227
  const dir = dirname(this.secretsPath)
115
228
  if (!existsSync(dir)) {
@@ -117,10 +230,6 @@ export class SecretsBackend implements AuthStorageBackend {
117
230
  }
118
231
  }
119
232
 
120
- // proper-lockfile requires the target to exist before locking. We seed an
121
- // empty new-shape envelope so the very first call has something to lock,
122
- // and so the file is parseable by a third-party reader even before the
123
- // first credential is written.
124
233
  private ensureFileExists(): void {
125
234
  if (existsSync(this.secretsPath)) return
126
235
  const seed = newEmptyEnvelope()
@@ -140,11 +249,9 @@ export class SecretsBackend implements AuthStorageBackend {
140
249
  : undefined
141
250
  if (code !== 'ELOCKED' || attempt === SYNC_LOCK_RETRIES) throw error
142
251
  lastError = error
143
- // Busy-wait so the call stays synchronous. Matches upstream's
144
- // FileAuthStorageBackend.acquireLockSyncWithRetry.
145
252
  const start = Date.now()
146
253
  while (Date.now() - start < SYNC_LOCK_DELAY_MS) {
147
- // intentionally empty
254
+ // intentionally empty: synchronous busy-wait to match upstream contract
148
255
  }
149
256
  }
150
257
  }
@@ -167,8 +274,6 @@ export class SecretsBackend implements AuthStorageBackend {
167
274
  return result.file
168
275
  }
169
276
 
170
- // Atomic temp+rename, same pattern as src/hostd/daemon.ts:persistRegistration.
171
- // The temp file lives in the same directory so rename is intra-filesystem.
172
277
  private writeEnvelopeAtomic(envelope: SecretsFile): void {
173
278
  const tmp = `${this.secretsPath}.${process.pid}.${Date.now()}.tmp`
174
279
  writeFileSync(tmp, stringifyEnvelope(envelope), { encoding: 'utf8', mode: FILE_MODE })
@@ -186,44 +291,145 @@ export class SecretsBackend implements AuthStorageBackend {
186
291
  }
187
292
  }
188
293
 
189
- // createSecretsStoreForAgent is the single seam every TypeClaw caller should
190
- // use to obtain an AuthStorage tied to an agent folder's secrets file. Keeps
191
- // the upstream constructor (AuthStorage.fromStorage) usage isolated to one
192
- // module so a future change to upstream wiring only touches this file.
193
- //
194
- // Performs the one-shot auth.json -> secrets.json rename before opening the
195
- // backend, so callers never observe the legacy filename even on agents that
196
- // pre-date the rename.
197
294
  export function createSecretsStoreForAgent(secretsPath: string): AuthStorage {
198
295
  migrateLegacyAuthJson(dirname(secretsPath))
199
296
  return AuthStorageImpl.fromStorage(new SecretsBackend(secretsPath))
200
297
  }
201
298
 
202
299
  function newEmptyEnvelope(): SecretsFile {
203
- return { $schema: SCHEMA_REL, version: 1, llm: {}, channels: {} }
300
+ return { $schema: SCHEMA_REL, version: SECRETS_FILE_VERSION, providers: {}, channels: {} }
204
301
  }
205
302
 
206
303
  function stringifyEnvelope(envelope: SecretsFile): string {
207
304
  return `${JSON.stringify(envelope, null, 2)}\n`
208
305
  }
209
306
 
210
- function mergeLlmIntoEnvelope(envelope: SecretsFile, nextLlmJson: string): SecretsFile {
307
+ type ReadSnapshot = Map<string, string>
308
+
309
+ // Build the flat shape AuthStorage expects, resolving Secret-typed api-key
310
+ // keys to plain strings on the way out. Also capture each resolved api-key
311
+ // value into a snapshot keyed by providerId; the write path uses this
312
+ // snapshot (NOT a re-resolution against current process.env) to detect
313
+ // untouched providers. OAuth and unknown types are passed through verbatim
314
+ // and never enter the snapshot — they don't participate in env-wins.
315
+ function flattenProvidersForAuthStorage(
316
+ providers: Providers,
317
+ env: NodeJS.ProcessEnv,
318
+ ): { flatJson: string; readSnapshot: ReadSnapshot } {
319
+ const flat: Record<string, unknown> = {}
320
+ const readSnapshot: ReadSnapshot = new Map()
321
+ for (const [providerId, cred] of Object.entries(providers)) {
322
+ if (cred.type === 'api_key') {
323
+ const defaultEnv = providerKeyDefaultEnv(providerId)
324
+ const resolved = resolveSecret(cred.key, defaultEnv, env) ?? cred.key.value ?? ''
325
+ flat[providerId] = { type: 'api_key', key: resolved }
326
+ readSnapshot.set(providerId, resolved)
327
+ } else {
328
+ flat[providerId] = cred
329
+ }
330
+ }
331
+ return { flatJson: JSON.stringify(flat, null, 2), readSnapshot }
332
+ }
333
+
334
+ // Diff-and-preserve merge per the bridge idempotency rule. AuthStorage hands
335
+ // back the full flat provider slice; we walk it credential-by-credential and
336
+ // decide for each provider whether to:
337
+ // - preserve the prior on-disk Secret bytes verbatim (untouched provider,
338
+ // detected by comparing AuthStorage's flat value to the read-time
339
+ // snapshot, NOT a re-resolution against current env),
340
+ // - reconstruct as Secret with prior `env` preserved (api-key value changed),
341
+ // - write as new shape (provider added),
342
+ // and we drop providers that disappeared from the flat slice (real removal).
343
+ function mergeProvidersIntoEnvelope(
344
+ envelope: SecretsFile,
345
+ nextProvidersJson: string,
346
+ readSnapshot: ReadSnapshot,
347
+ ): SecretsFile {
211
348
  let parsed: unknown
212
349
  try {
213
- parsed = JSON.parse(nextLlmJson)
350
+ parsed = JSON.parse(nextProvidersJson)
214
351
  } catch (err) {
215
352
  throw new Error(
216
- `AuthStorage produced invalid JSON for the llm slice: ${err instanceof Error ? err.message : String(err)}`,
353
+ `AuthStorage produced invalid JSON for the providers slice: ${err instanceof Error ? err.message : String(err)}`,
217
354
  )
218
355
  }
219
356
  if (!isPlainObject(parsed)) {
220
- throw new Error('AuthStorage produced a non-object llm slice')
357
+ throw new Error('AuthStorage produced a non-object providers slice')
221
358
  }
359
+
360
+ const nextProviders: Providers = {}
361
+ for (const [providerId, raw] of Object.entries(parsed)) {
362
+ const reconstructed = reconstructProviderCredential(
363
+ raw,
364
+ envelope.providers[providerId],
365
+ readSnapshot.get(providerId),
366
+ )
367
+ if (reconstructed !== undefined) {
368
+ nextProviders[providerId] = reconstructed
369
+ }
370
+ }
371
+
222
372
  return {
223
373
  ...envelope,
224
374
  $schema: envelope.$schema ?? SCHEMA_REL,
225
- llm: parsed as SecretsFile['llm'],
375
+ version: SECRETS_FILE_VERSION,
376
+ providers: nextProviders,
377
+ }
378
+ }
379
+
380
+ function reconstructProviderCredential(
381
+ raw: unknown,
382
+ priorOnDisk: ProviderCredential | undefined,
383
+ resolvedAtRead: string | undefined,
384
+ ): ProviderCredential | undefined {
385
+ if (!isPlainObject(raw)) return undefined
386
+ const type = raw['type']
387
+
388
+ if (type === 'api_key') {
389
+ const flatKey = typeof raw['key'] === 'string' ? raw['key'] : ''
390
+
391
+ // Empty/missing key from AuthStorage on an api-key credential cannot
392
+ // round-trip: the schema requires `value` to be non-empty, so writing
393
+ // `{ value: '' }` would make the file unparseable on next read. Treat
394
+ // it as "no-op" and preserve the prior on-disk Secret if any. A real
395
+ // deletion comes through as the provider being absent from `next`,
396
+ // which is handled by the caller dropping it.
397
+ if (flatKey === '') {
398
+ if (priorOnDisk !== undefined) return priorOnDisk
399
+ return undefined
400
+ }
401
+
402
+ // Idempotency: if AuthStorage's flat key matches the resolved value
403
+ // captured at read time, the credential is untouched. Preserve the
404
+ // on-disk Secret verbatim — including any `env` binding and any string
405
+ // shorthand the user authored. Comparing against the read-time snapshot
406
+ // (not a re-resolution against current process.env) is what makes this
407
+ // safe against env mutations between read and write.
408
+ if (priorOnDisk && priorOnDisk.type === 'api_key' && resolvedAtRead === flatKey) {
409
+ return priorOnDisk
410
+ }
411
+
412
+ // Mutation path: rewrap as Secret, preserving the user's `env` binding
413
+ // when prior was also an api-key so the next boot's env-wins still
414
+ // consults the right variable.
415
+ if (priorOnDisk && priorOnDisk.type === 'api_key' && priorOnDisk.key.env !== undefined) {
416
+ return { type: 'api_key', key: { value: flatKey, env: priorOnDisk.key.env } satisfies Secret }
417
+ }
418
+
419
+ return { type: 'api_key', key: { value: flatKey } }
226
420
  }
421
+
422
+ if (type === 'oauth') {
423
+ // OAuth credentials are not env-injectable. Pass through verbatim,
424
+ // preserving every upstream-controlled field (access, refresh, expires,
425
+ // and any future additions covered by the catchall).
426
+ return raw as ProviderCredential
427
+ }
428
+
429
+ // Unknown credential type. Pass through verbatim as a defensive measure
430
+ // against upstream adding a third type in a future release. Better to
431
+ // round-trip user data than drop it.
432
+ return raw as ProviderCredential
227
433
  }
228
434
 
229
435
  function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -539,17 +539,56 @@ Do **not** edit `typeclaw.json` to a model the registry doesn't know, even if th
539
539
 
540
540
  `typeclaw.json` does **not** hold API keys or OAuth tokens. Credentials live in two gitignored files:
541
541
 
542
- - **`./.env`** (API key providers): the env var depends on which provider's model you've selected.
543
- - `OPENAI_API_KEY` — required for any `openai/...` model.
544
- - `FIREWORKS_API_KEY` — required for any `fireworks/...` model.
545
- - **`./secrets.json`** (OAuth providers): structured JSON file managed by `pi-coding-agent`'s `AuthStorage`, wrapped by `SecretsBackend`. Contains refresh + access tokens. The container refreshes tokens on its own with file locking; the host writes once at `typeclaw init` time when the user picks "OAuth (browser login)". (Pre-rename agent folders may carry the file as `auth.json`; it is migrated to `secrets.json` on the next agent boot.)
546
- - `openai-codex/...` models — credentials persisted under the `llm` slice as `{ "llm": { "openai-codex": { "type": "oauth", ... } } }`.
542
+ - **`./.env`** (any environment variable, including API keys): plain `KEY=value` lines, loaded by Docker via `--env-file` at container start. The canonical env-var names per provider:
543
+ - `OPENAI_API_KEY` — for any `openai/...` model.
544
+ - `FIREWORKS_API_KEY` — for any `fireworks/...` model.
545
+ - **`./secrets.json`** (structured store): a `v2` envelope managed by `SecretsBackend` (wraps `pi-coding-agent`'s `AuthStorage`). Two top-level slices:
546
+ - `providers.*`per-provider credentials. API-key providers store `{ type: 'api_key', key: <Secret> }`. OAuth providers store the `pi-coding-agent` token blob `{ type: 'oauth', access_token, refresh_token, expires_at, ... }`. The container auto-refreshes OAuth tokens with file locking; api-key writes only happen on explicit user-driven rotation.
547
+ - `channels.*` — per-adapter credentials, with named fields per adapter:
548
+ - `discord-bot: { token: <Secret> }`
549
+ - `slack-bot: { botToken: <Secret>, appToken: <Secret> }`
550
+ - `telegram-bot: { token: <Secret> }`
547
551
 
548
- If a user wants to switch from API key to OAuth (or vice versa) for a provider that supports both, the easiest path is to delete the relevant entry from `.env` / `secrets.json` and re-run `typeclaw init` from inside the agent folder — it'll prompt for the auth method again.
552
+ (Pre-v2 agent folders carry the older `llm` slice and channel-env-var-keyed shape; they are upgraded transparently on first read. Pre-rename folders may even carry the file as `auth.json`; it is renamed to `secrets.json` on the next boot.)
549
553
 
550
- If the user wants to rotate or change the key, edit `.env`, not `typeclaw.json`. After editing `.env`, the same restart rule applies: `typeclaw restart` on the host stage.
554
+ ### The `Secret` shape and env-wins resolution
551
555
 
552
- Never echo, log, or commit values from `.env`. `.env` is gitignored by default keep it that way.
556
+ Every secret-bearing field in `secrets.json` is a **`Secret`**: either a plain string or an object `{ value?, env? }`.
557
+
558
+ ```json
559
+ {
560
+ "version": 2,
561
+ "providers": {
562
+ "fireworks": { "type": "api_key", "key": "fw_xxx" },
563
+ "openai-codex": { "type": "oauth", "access_token": "...", "refresh_token": "...", "expires_at": 99 }
564
+ },
565
+ "channels": {
566
+ "slack-bot": {
567
+ "botToken": "xoxb-...",
568
+ "appToken": { "value": "xapp-...", "env": "MY_CUSTOM_SLACK_APP_TOKEN" }
569
+ }
570
+ }
571
+ }
572
+ ```
573
+
574
+ **Resolution at boot, in order:**
575
+
576
+ 1. `process.env[secret.env]` — explicit binding wins (the `env` field on the object form).
577
+ 2. `process.env[<canonical env name>]` — canonical-env fallback (`SLACK_BOT_TOKEN`, `FIREWORKS_API_KEY`, etc.).
578
+ 3. `secret.value` — the on-disk value.
579
+ 4. Otherwise the field is treated as missing.
580
+
581
+ **Env wins, the file is never auto-mutated.** When the env var is set, that value is used in-memory via `setRuntimeApiKey` (api-keys) or `process.env` injection (channels) — `secrets.json` is **not** rewritten to capture the env value. The user's file stays user-owned.
582
+
583
+ **Custom env-var binding** — the optional `env` field on the object form lets the user route a credential through an env var of their choosing (e.g., a CI system that exposes `MY_PROD_SLACK_TOKEN` instead of `SLACK_BOT_TOKEN`).
584
+
585
+ ### Switching credentials
586
+
587
+ If a user wants to switch from API key to OAuth (or vice versa) for a provider that supports both, the easiest path is to delete the relevant entry from `.env` / `secrets.json#providers` and re-run `typeclaw init` from inside the agent folder — it'll prompt for the auth method again.
588
+
589
+ If the user wants to rotate an api-key, edit either `.env` (env-wins picks it up immediately) or `secrets.json#providers.<provider>.key` (rewrite the `value` field, or remove the entry if the env var should take over). After either, `typeclaw restart` on the host stage.
590
+
591
+ Never echo, log, or commit values from `.env` or `secrets.json`. Both are gitignored by default — keep them that way.
553
592
 
554
593
  ## Editing `typeclaw.json` safely
555
594
 
@@ -708,13 +708,26 @@
708
708
  },
709
709
  "network": {
710
710
  "default": {
711
- "blockInternal": false
711
+ "blockInternal": true,
712
+ "autoAllowResolvers": true,
713
+ "allow": []
712
714
  },
713
715
  "type": "object",
714
716
  "properties": {
715
717
  "blockInternal": {
716
- "default": false,
718
+ "default": true,
719
+ "type": "boolean"
720
+ },
721
+ "autoAllowResolvers": {
722
+ "default": true,
717
723
  "type": "boolean"
724
+ },
725
+ "allow": {
726
+ "default": [],
727
+ "type": "array",
728
+ "items": {
729
+ "type": "string"
730
+ }
718
731
  }
719
732
  }
720
733
  },
@@ -792,6 +805,40 @@
792
805
  }
793
806
  }
794
807
  },
808
+ "tool-result-cap": {
809
+ "default": {
810
+ "enabled": true,
811
+ "imageMaxBytes": 262144,
812
+ "textMaxBytes": 65536,
813
+ "exemptTools": []
814
+ },
815
+ "type": "object",
816
+ "properties": {
817
+ "enabled": {
818
+ "default": true,
819
+ "type": "boolean"
820
+ },
821
+ "imageMaxBytes": {
822
+ "default": 262144,
823
+ "type": "integer",
824
+ "minimum": 1024,
825
+ "maximum": 9007199254740991
826
+ },
827
+ "textMaxBytes": {
828
+ "default": 65536,
829
+ "type": "integer",
830
+ "minimum": 1024,
831
+ "maximum": 9007199254740991
832
+ },
833
+ "exemptTools": {
834
+ "default": [],
835
+ "type": "array",
836
+ "items": {
837
+ "type": "string"
838
+ }
839
+ }
840
+ }
841
+ },
795
842
  "memory": {
796
843
  "default": {
797
844
  "idleMs": 10000,
@@ -1,43 +0,0 @@
1
- import { readFileSync, writeFileSync } from 'node:fs'
2
-
3
- // No-op when the file is missing or the key is absent: the caller has
4
- // already persisted to `secrets.json` and just wants `.env` to stop being a
5
- // second source of truth. Parsing matches `parseEnvKeys` in
6
- // `src/init/index.ts` — line-based, trim, skip blanks/comments, split on the
7
- // first `=`. Duplicate assignments to the same key are all removed because
8
- // dotenv resolves "last wins" so every duplicate carries the value we just
9
- // promoted.
10
- export function stripEnvKey(path: string, key: string): void {
11
- let original: string
12
- try {
13
- original = readFileSync(path, 'utf8')
14
- } catch (error) {
15
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
16
- throw error
17
- }
18
-
19
- const next = removeKeyFromEnvText(original, key)
20
- if (next === original) return
21
- writeFileSync(path, next)
22
- }
23
-
24
- export function removeKeyFromEnvText(content: string, key: string): string {
25
- const lines = content.split('\n')
26
- const kept: string[] = []
27
- for (const line of lines) {
28
- const trimmed = line.trim()
29
- if (trimmed === '' || trimmed.startsWith('#')) {
30
- kept.push(line)
31
- continue
32
- }
33
- const eq = trimmed.indexOf('=')
34
- if (eq <= 0) {
35
- kept.push(line)
36
- continue
37
- }
38
- const lineKey = trimmed.slice(0, eq).trim()
39
- if (lineKey === key) continue
40
- kept.push(line)
41
- }
42
- return kept.join('\n')
43
- }