typeclaw 0.1.2 → 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.
- package/README.md +4 -0
- package/auth.schema.json +238 -7
- package/package.json +1 -1
- package/secrets.schema.json +238 -7
- package/src/agent/auth.ts +19 -38
- package/src/agent/tools/channel-fetch-attachment.ts +6 -0
- package/src/agent/tools/channel-history.ts +10 -1
- package/src/agent/tools/channel-log.ts +32 -0
- package/src/agent/tools/channel-reply.ts +18 -1
- package/src/agent/tools/channel-send.ts +13 -1
- package/src/bundled-plugins/tool-result-cap/README.md +67 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
- package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
- package/src/channels/adapters/kakaotalk.ts +25 -16
- package/src/channels/manager.ts +47 -38
- package/src/cli/channel.ts +3 -3
- package/src/cli/index.ts +3 -0
- package/src/cli/init.ts +2 -1
- package/src/cli/ui.ts +11 -0
- package/src/config/config.ts +15 -4
- package/src/container/start.ts +90 -1
- package/src/hostd/daemon.ts +28 -3
- package/src/hostd/protocol.ts +7 -0
- package/src/init/auto-upgrade.ts +368 -0
- package/src/init/dockerfile.ts +25 -14
- package/src/init/index.ts +123 -77
- package/src/init/kakaotalk-auth.ts +9 -3
- package/src/init/run-bun-install.ts +34 -0
- package/src/run/bundled-plugins.ts +7 -0
- package/src/run/index.ts +9 -0
- package/src/secrets/defaults.ts +67 -0
- package/src/secrets/hydrate.ts +99 -0
- package/src/secrets/index.ts +6 -12
- package/src/secrets/kakao-store.ts +129 -0
- package/src/secrets/migrate-kakaotalk.ts +82 -0
- package/src/secrets/migrate.ts +5 -4
- package/src/secrets/resolve.ts +57 -0
- package/src/secrets/schema.ts +162 -42
- package/src/secrets/storage.ts +253 -47
- package/src/skills/typeclaw-config/SKILL.md +47 -8
- package/typeclaw.schema.json +36 -2
- package/src/secrets/env.ts +0 -43
package/src/secrets/storage.ts
CHANGED
|
@@ -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
|
|
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
|
|
27
|
-
//
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// -
|
|
41
|
-
//
|
|
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
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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
|
|
83
|
+
const { flatJson, readSnapshot } = flattenProvidersForAuthStorage(envelope.providers, process.env)
|
|
57
84
|
|
|
58
|
-
const { result, next } = fn(
|
|
85
|
+
const { result, next } = fn(flatJson)
|
|
59
86
|
if (next !== undefined) {
|
|
60
|
-
const merged =
|
|
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
|
|
117
|
+
const { flatJson, readSnapshot } = flattenProvidersForAuthStorage(envelope.providers, process.env)
|
|
91
118
|
|
|
92
|
-
const { result, next } = await fn(
|
|
119
|
+
const { result, next } = await fn(flatJson)
|
|
93
120
|
throwIfCompromised()
|
|
94
121
|
if (next !== undefined) {
|
|
95
|
-
const merged =
|
|
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:
|
|
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
|
-
|
|
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(
|
|
350
|
+
parsed = JSON.parse(nextProvidersJson)
|
|
214
351
|
} catch (err) {
|
|
215
352
|
throw new Error(
|
|
216
|
-
`AuthStorage produced invalid JSON for the
|
|
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
|
|
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
|
-
|
|
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
|
|
543
|
-
- `OPENAI_API_KEY` —
|
|
544
|
-
- `FIREWORKS_API_KEY` —
|
|
545
|
-
- **`./secrets.json`** (
|
|
546
|
-
- `
|
|
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
|
-
|
|
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
|
-
|
|
554
|
+
### The `Secret` shape and env-wins resolution
|
|
551
555
|
|
|
552
|
-
|
|
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
|
|
package/typeclaw.schema.json
CHANGED
|
@@ -708,12 +708,12 @@
|
|
|
708
708
|
},
|
|
709
709
|
"network": {
|
|
710
710
|
"default": {
|
|
711
|
-
"blockInternal":
|
|
711
|
+
"blockInternal": true
|
|
712
712
|
},
|
|
713
713
|
"type": "object",
|
|
714
714
|
"properties": {
|
|
715
715
|
"blockInternal": {
|
|
716
|
-
"default":
|
|
716
|
+
"default": true,
|
|
717
717
|
"type": "boolean"
|
|
718
718
|
}
|
|
719
719
|
}
|
|
@@ -792,6 +792,40 @@
|
|
|
792
792
|
}
|
|
793
793
|
}
|
|
794
794
|
},
|
|
795
|
+
"tool-result-cap": {
|
|
796
|
+
"default": {
|
|
797
|
+
"enabled": true,
|
|
798
|
+
"imageMaxBytes": 262144,
|
|
799
|
+
"textMaxBytes": 65536,
|
|
800
|
+
"exemptTools": []
|
|
801
|
+
},
|
|
802
|
+
"type": "object",
|
|
803
|
+
"properties": {
|
|
804
|
+
"enabled": {
|
|
805
|
+
"default": true,
|
|
806
|
+
"type": "boolean"
|
|
807
|
+
},
|
|
808
|
+
"imageMaxBytes": {
|
|
809
|
+
"default": 262144,
|
|
810
|
+
"type": "integer",
|
|
811
|
+
"minimum": 1024,
|
|
812
|
+
"maximum": 9007199254740991
|
|
813
|
+
},
|
|
814
|
+
"textMaxBytes": {
|
|
815
|
+
"default": 65536,
|
|
816
|
+
"type": "integer",
|
|
817
|
+
"minimum": 1024,
|
|
818
|
+
"maximum": 9007199254740991
|
|
819
|
+
},
|
|
820
|
+
"exemptTools": {
|
|
821
|
+
"default": [],
|
|
822
|
+
"type": "array",
|
|
823
|
+
"items": {
|
|
824
|
+
"type": "string"
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
},
|
|
795
829
|
"memory": {
|
|
796
830
|
"default": {
|
|
797
831
|
"idleMs": 10000,
|
package/src/secrets/env.ts
DELETED
|
@@ -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
|
-
}
|