typeclaw 0.1.1 → 0.1.3

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 (74) hide show
  1. package/README.md +16 -12
  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/doctor.ts +173 -0
  7. package/src/agent/subagents.ts +24 -2
  8. package/src/agent/tools/channel-fetch-attachment.ts +6 -0
  9. package/src/agent/tools/channel-history.ts +10 -1
  10. package/src/agent/tools/channel-log.ts +32 -0
  11. package/src/agent/tools/channel-reply.ts +18 -1
  12. package/src/agent/tools/channel-send.ts +13 -1
  13. package/src/bundled-plugins/backup/README.md +81 -0
  14. package/src/bundled-plugins/backup/index.ts +209 -0
  15. package/src/bundled-plugins/backup/runner.ts +231 -0
  16. package/src/bundled-plugins/backup/subagents.ts +200 -0
  17. package/src/bundled-plugins/memory/index.ts +42 -1
  18. package/src/bundled-plugins/tool-result-cap/README.md +67 -0
  19. package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
  20. package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
  21. package/src/channels/adapters/kakaotalk.ts +25 -16
  22. package/src/channels/manager.ts +47 -38
  23. package/src/channels/router.ts +29 -0
  24. package/src/cli/channel.ts +3 -3
  25. package/src/cli/compose.ts +92 -1
  26. package/src/cli/doctor.ts +100 -0
  27. package/src/cli/index.ts +4 -0
  28. package/src/cli/init.ts +2 -1
  29. package/src/cli/ui.ts +11 -0
  30. package/src/compose/doctor.ts +141 -0
  31. package/src/compose/index.ts +8 -0
  32. package/src/compose/logs.ts +32 -19
  33. package/src/config/config.ts +31 -0
  34. package/src/container/log-colors.ts +75 -0
  35. package/src/container/log-timestamps.ts +84 -0
  36. package/src/container/logs.ts +71 -5
  37. package/src/container/start.ts +113 -9
  38. package/src/cron/consumer.ts +29 -7
  39. package/src/doctor/checks.ts +426 -0
  40. package/src/doctor/commit.ts +71 -0
  41. package/src/doctor/index.ts +287 -0
  42. package/src/doctor/plugin-bridge.ts +147 -0
  43. package/src/doctor/report.ts +142 -0
  44. package/src/doctor/types.ts +87 -0
  45. package/src/hostd/daemon.ts +28 -3
  46. package/src/hostd/protocol.ts +7 -0
  47. package/src/init/auto-upgrade.ts +368 -0
  48. package/src/init/cli-version.ts +81 -0
  49. package/src/init/dockerfile.ts +234 -25
  50. package/src/init/index.ts +141 -87
  51. package/src/init/kakaotalk-auth.ts +9 -3
  52. package/src/init/run-bun-install.ts +34 -0
  53. package/src/plugin/hooks.ts +32 -0
  54. package/src/plugin/index.ts +7 -0
  55. package/src/plugin/manager.ts +2 -0
  56. package/src/plugin/registry.ts +32 -3
  57. package/src/plugin/types.ts +65 -0
  58. package/src/run/bundled-plugins.ts +15 -0
  59. package/src/run/index.ts +19 -5
  60. package/src/secrets/defaults.ts +67 -0
  61. package/src/secrets/hydrate.ts +99 -0
  62. package/src/secrets/index.ts +6 -12
  63. package/src/secrets/kakao-store.ts +129 -0
  64. package/src/secrets/migrate-kakaotalk.ts +82 -0
  65. package/src/secrets/migrate.ts +5 -4
  66. package/src/secrets/resolve.ts +57 -0
  67. package/src/secrets/schema.ts +162 -42
  68. package/src/secrets/storage.ts +253 -47
  69. package/src/server/index.ts +103 -5
  70. package/src/shared/index.ts +3 -0
  71. package/src/shared/protocol.ts +22 -0
  72. package/src/skills/typeclaw-config/SKILL.md +48 -9
  73. package/typeclaw.schema.json +84 -0
  74. package/src/secrets/env.ts +0 -43
@@ -1,72 +1,192 @@
1
1
  import { z } from 'zod'
2
2
 
3
- // The api_key shape exactly matches pi-coding-agent's ApiKeyCredential. We
4
- // re-state it here (rather than import the upstream type into the schema)
5
- // because Zod schemas are the source of truth for validation and JSON Schema
6
- // generation, and pi-coding-agent does not export Zod schemas.
7
- const llmApiKeyCredentialSchema = z.object({
3
+ import { CHANNEL_ENV_TO_FIELD } from './defaults'
4
+ import { secretFieldSchema, type Secret } from './resolve'
5
+
6
+ // providers.<id> for api-key credentials: the `key` field is a Secret (string
7
+ // shorthand or `{ value?, env? }` object). resolveSecret turns this into a
8
+ // flat string at read time so AuthStorage (which expects `key: string`)
9
+ // stays happy. OAuth credentials carry stateful refresh/access tokens that
10
+ // are not env-injectable, so they pass through unchanged via catchall.
11
+ const apiKeyProviderSchema = z.object({
8
12
  type: z.literal('api_key'),
9
- key: z.string().min(1),
13
+ key: secretFieldSchema,
10
14
  })
11
15
 
12
- // pi-coding-agent's OAuth credential carries provider-specific fields
13
- // (access, refresh, expires, plus arbitrary upstream additions). We accept
14
- // them as a passthrough object so future upstream additions don't break parse.
15
- // Upstream is the runtime authority on OAuth shape; our job here is only to
16
- // route the slice safely through the file envelope.
17
- const llmOAuthCredentialSchema = z
16
+ const oauthProviderSchema = z
18
17
  .object({
19
18
  type: z.literal('oauth'),
20
19
  })
21
20
  .catchall(z.unknown())
22
21
 
23
- export const llmCredentialSchema = z.discriminatedUnion('type', [llmApiKeyCredentialSchema, llmOAuthCredentialSchema])
22
+ export const providerCredentialSchema = z.discriminatedUnion('type', [apiKeyProviderSchema, oauthProviderSchema])
23
+
24
+ export const providersSchema = z.record(z.string(), providerCredentialSchema)
25
+
26
+ // Per-adapter channel slots use named fields (`botToken`, `appToken`, `token`)
27
+ // instead of env-var-name keys. The Secret union per field carries the env-var
28
+ // override. Unknown adapter ids pass through via catchall so a future
29
+ // plugin-contributed adapter doesn't fail validation.
30
+ const slackBotChannelSchema = z.object({
31
+ botToken: secretFieldSchema.optional(),
32
+ appToken: secretFieldSchema.optional(),
33
+ })
34
+
35
+ const discordBotChannelSchema = z.object({
36
+ token: secretFieldSchema.optional(),
37
+ })
38
+
39
+ const telegramBotChannelSchema = z.object({
40
+ token: secretFieldSchema.optional(),
41
+ })
42
+
43
+ export const kakaoAccountRecordSchema = z.object({
44
+ account_id: z.string(),
45
+ oauth_token: z.string(),
46
+ user_id: z.string(),
47
+ refresh_token: z.string().optional(),
48
+ device_uuid: z.string(),
49
+ device_type: z.union([z.literal('pc'), z.literal('tablet')]),
50
+ auth_method: z.union([z.literal('login'), z.literal('extract')]).optional(),
51
+ created_at: z.string(),
52
+ updated_at: z.string(),
53
+ })
54
+
55
+ export const kakaoPendingLoginRecordSchema = z.object({
56
+ device_uuid: z.string(),
57
+ device_type: z.union([z.literal('pc'), z.literal('tablet')]),
58
+ email: z.string(),
59
+ created_at: z.string(),
60
+ })
24
61
 
25
- // Map keyed by provider id ("openai", "openai-codex", "fireworks", ...).
26
- // Exactly the shape pi-coding-agent persists today as the entire secrets file.
27
- export const llmCredentialsSchema = z.record(z.string(), llmCredentialSchema)
62
+ export const kakaoChannelBlockSchema = z.object({
63
+ currentAccount: z.string().nullable(),
64
+ accounts: z.record(z.string(), kakaoAccountRecordSchema),
65
+ pendingLogin: kakaoPendingLoginRecordSchema.optional(),
66
+ })
28
67
 
29
- // Empty channels schema today; channel adapter tokens (Slack/Discord/Telegram)
30
- // will move here in a follow-up plan. The catchall keeps forward compatibility
31
- // when a future TypeClaw reads a file written by an even-newer TypeClaw.
32
- export const channelsSchema = z.object({}).catchall(z.unknown())
68
+ export const channelsSchema = z
69
+ .object({
70
+ 'slack-bot': slackBotChannelSchema.optional(),
71
+ 'discord-bot': discordBotChannelSchema.optional(),
72
+ 'telegram-bot': telegramBotChannelSchema.optional(),
73
+ kakaotalk: kakaoChannelBlockSchema.optional(),
74
+ })
75
+ .catchall(z.unknown())
76
+
77
+ // version 2 = providers.* with Secret-typed api-key.key + per-adapter
78
+ // channel field shapes. version 1 = the previous shape (flat `llm.*`, channel
79
+ // slots keyed by env-var name). Legacy v1 input is upgraded transparently by
80
+ // parseSecretsFile; the first write persists v2.
81
+ export const SECRETS_FILE_VERSION = 2
33
82
 
34
83
  export const secretsFileSchema = z.object({
35
84
  $schema: z.string().optional(),
36
- version: z.literal(1),
37
- llm: llmCredentialsSchema.default({}),
85
+ version: z.literal(SECRETS_FILE_VERSION),
86
+ providers: providersSchema.default({}),
38
87
  channels: channelsSchema.default({}),
39
88
  })
40
89
 
41
- export type LlmCredential = z.infer<typeof llmCredentialSchema>
42
- export type LlmCredentials = z.infer<typeof llmCredentialsSchema>
90
+ export type ProviderCredential = z.infer<typeof providerCredentialSchema>
91
+ export type Providers = z.infer<typeof providersSchema>
92
+ export type Channels = z.infer<typeof channelsSchema>
93
+ export type KakaoAccountRecord = z.infer<typeof kakaoAccountRecordSchema>
94
+ export type PendingLoginRecord = z.infer<typeof kakaoPendingLoginRecordSchema>
95
+ export type KakaoChannelBlock = z.infer<typeof kakaoChannelBlockSchema>
43
96
  export type SecretsFile = z.infer<typeof secretsFileSchema>
44
97
 
45
98
  export type ParseSecretsResult = { ok: true; file: SecretsFile } | { ok: false; reason: string }
46
99
 
47
- // parseSecretsFile recognises two shapes:
48
- // 1. The new envelope: { version: 1, llm: {...}, channels: {...} }
49
- // 2. The legacy flat shape pi-coding-agent writes today: a top-level
50
- // Record<string, AuthCredential>. Treated as { version: 1, llm: <flat>,
51
- // channels: {} } so existing OAuth users transparently upgrade on the
52
- // next write that goes through this code path.
100
+ // parseSecretsFile recognises three shapes, in priority order:
101
+ // 1. The v2 envelope (current): { version: 2, providers, channels }
102
+ // 2. The v1 envelope (legacy): { version: 1, llm, channels } where channel
103
+ // slots are keyed by env-var name. Both `llm` and `channels` get
104
+ // reshaped llm -> providers, env-keyed channel slots -> field-keyed.
105
+ // 3. The pre-envelope flat shape (very legacy): Record<string, AuthCredential>
106
+ // at top level. Treated as { version: 2, providers: <flat>, channels: {} }
107
+ // so existing OAuth users transparently upgrade.
53
108
  //
54
- // An empty object {} is a legitimate legacy state a freshly-created
55
- // secrets file with no providers logged in yet. It upgrades cleanly to the
56
- // new envelope with empty llm and channels.
109
+ // Every legacy upgrade produces a v2-shaped SecretsFile in memory; the next
110
+ // write persists v2 to disk. The legacy branches stay forever as a quiet
111
+ // compatibility seam only the v2 form is documented.
57
112
  export function parseSecretsFile(raw: unknown): ParseSecretsResult {
58
- const direct = secretsFileSchema.safeParse(raw)
59
- if (direct.success) return { ok: true, file: direct.data }
113
+ const v2 = secretsFileSchema.safeParse(raw)
114
+ if (v2.success) return { ok: true, file: v2.data }
115
+
116
+ const v1 = legacyV1Schema.safeParse(raw)
117
+ if (v1.success) return { ok: true, file: upgradeV1ToV2(v1.data) }
118
+
119
+ const flat = legacyFlatProviderSchema.safeParse(raw)
120
+ if (flat.success) {
121
+ return { ok: true, file: upgradeV1ToV2({ version: 1, llm: flat.data, channels: {} }) }
122
+ }
60
123
 
61
- const legacy = llmCredentialsSchema.safeParse(raw)
62
- if (legacy.success) {
63
- return { ok: true, file: { version: 1, llm: legacy.data, channels: {} } }
124
+ return { ok: false, reason: v2.error.issues.map(formatIssue).join('; ') }
125
+ }
126
+
127
+ // Legacy v1 schema: `llm` (flat string-key) and `channels` (env-var-keyed
128
+ // flat map per adapter). Used only for upgrade reads; never written.
129
+ const legacyV1ApiKeySchema = z.object({
130
+ type: z.literal('api_key'),
131
+ key: z.string().min(1),
132
+ })
133
+
134
+ const legacyV1OAuthSchema = z
135
+ .object({
136
+ type: z.literal('oauth'),
137
+ })
138
+ .catchall(z.unknown())
139
+
140
+ const legacyV1CredentialSchema = z.discriminatedUnion('type', [legacyV1ApiKeySchema, legacyV1OAuthSchema])
141
+
142
+ const legacyV1LlmSchema = z.record(z.string(), legacyV1CredentialSchema)
143
+
144
+ const legacyV1ChannelsSchema = z.record(z.string(), z.record(z.string(), z.string()))
145
+
146
+ const legacyV1Schema = z.object({
147
+ $schema: z.string().optional(),
148
+ version: z.literal(1),
149
+ llm: legacyV1LlmSchema.default({}),
150
+ channels: legacyV1ChannelsSchema.default({}),
151
+ })
152
+
153
+ const legacyFlatProviderSchema = z.record(z.string(), legacyV1CredentialSchema)
154
+
155
+ function upgradeV1ToV2(legacy: z.infer<typeof legacyV1Schema>): SecretsFile {
156
+ const providers: Providers = {}
157
+ for (const [providerId, cred] of Object.entries(legacy.llm)) {
158
+ if (cred.type === 'api_key') {
159
+ providers[providerId] = { type: 'api_key', key: { value: cred.key } }
160
+ } else {
161
+ providers[providerId] = cred
162
+ }
64
163
  }
65
164
 
66
- // Neither shape matched. We surface the new-shape error because that's the
67
- // target the user is presumably moving toward; the legacy path is a quiet
68
- // compatibility seam, not a documented format.
69
- return { ok: false, reason: direct.error.issues.map(formatIssue).join('; ') }
165
+ const channels: Channels = {}
166
+ for (const [adapterId, envKeyedSlot] of Object.entries(legacy.channels)) {
167
+ const upgradedSlot: Record<string, Secret> = {}
168
+ for (const [envKey, value] of Object.entries(envKeyedSlot)) {
169
+ const mapping = CHANNEL_ENV_TO_FIELD[envKey]
170
+ if (mapping && mapping.adapterId === adapterId) {
171
+ upgradedSlot[mapping.fieldName] = { value }
172
+ } else {
173
+ // Unknown env-var-name key on a known adapter, or an adapter we don't
174
+ // recognise: pass through verbatim under the original key. Better to
175
+ // preserve user data than drop it; the catchall on channelsSchema
176
+ // makes this safe.
177
+ upgradedSlot[envKey] = { value }
178
+ }
179
+ }
180
+ channels[adapterId] = upgradedSlot
181
+ }
182
+
183
+ const result: SecretsFile = {
184
+ version: SECRETS_FILE_VERSION,
185
+ providers,
186
+ channels,
187
+ }
188
+ if (legacy.$schema !== undefined) result.$schema = legacy.$schema
189
+ return result
70
190
  }
71
191
 
72
192
  function formatIssue(issue: { path: PropertyKey[]; message: string }): string {
@@ -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> {