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.
- package/README.md +16 -12
- 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/doctor.ts +173 -0
- package/src/agent/subagents.ts +24 -2
- 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/backup/README.md +81 -0
- package/src/bundled-plugins/backup/index.ts +209 -0
- package/src/bundled-plugins/backup/runner.ts +231 -0
- package/src/bundled-plugins/backup/subagents.ts +200 -0
- package/src/bundled-plugins/memory/index.ts +42 -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/channels/router.ts +29 -0
- package/src/cli/channel.ts +3 -3
- package/src/cli/compose.ts +92 -1
- package/src/cli/doctor.ts +100 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +2 -1
- package/src/cli/ui.ts +11 -0
- package/src/compose/doctor.ts +141 -0
- package/src/compose/index.ts +8 -0
- package/src/compose/logs.ts +32 -19
- package/src/config/config.ts +31 -0
- package/src/container/log-colors.ts +75 -0
- package/src/container/log-timestamps.ts +84 -0
- package/src/container/logs.ts +71 -5
- package/src/container/start.ts +113 -9
- package/src/cron/consumer.ts +29 -7
- package/src/doctor/checks.ts +426 -0
- package/src/doctor/commit.ts +71 -0
- package/src/doctor/index.ts +287 -0
- package/src/doctor/plugin-bridge.ts +147 -0
- package/src/doctor/report.ts +142 -0
- package/src/doctor/types.ts +87 -0
- 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/cli-version.ts +81 -0
- package/src/init/dockerfile.ts +234 -25
- package/src/init/index.ts +141 -87
- package/src/init/kakaotalk-auth.ts +9 -3
- package/src/init/run-bun-install.ts +34 -0
- package/src/plugin/hooks.ts +32 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +32 -3
- package/src/plugin/types.ts +65 -0
- package/src/run/bundled-plugins.ts +15 -0
- package/src/run/index.ts +19 -5
- 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/server/index.ts +103 -5
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +22 -0
- package/src/skills/typeclaw-config/SKILL.md +48 -9
- package/typeclaw.schema.json +84 -0
- package/src/secrets/env.ts +0 -43
package/src/secrets/schema.ts
CHANGED
|
@@ -1,72 +1,192 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
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:
|
|
13
|
+
key: secretFieldSchema,
|
|
10
14
|
})
|
|
11
15
|
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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(
|
|
37
|
-
|
|
85
|
+
version: z.literal(SECRETS_FILE_VERSION),
|
|
86
|
+
providers: providersSchema.default({}),
|
|
38
87
|
channels: channelsSchema.default({}),
|
|
39
88
|
})
|
|
40
89
|
|
|
41
|
-
export type
|
|
42
|
-
export type
|
|
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
|
|
48
|
-
// 1. The
|
|
49
|
-
// 2. The legacy
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
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
|
|
59
|
-
if (
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 {
|
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> {
|