typeclaw 0.1.0

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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. package/typeclaw.schema.json +826 -0
@@ -0,0 +1,13 @@
1
+ export {
2
+ channelsSchema,
3
+ llmCredentialSchema,
4
+ llmCredentialsSchema,
5
+ parseSecretsFile,
6
+ secretsFileSchema,
7
+ type LlmCredential,
8
+ type LlmCredentials,
9
+ type ParseSecretsResult,
10
+ type SecretsFile,
11
+ } from './schema'
12
+
13
+ export { createSecretsStoreForAgent, SecretsBackend } from './storage'
@@ -0,0 +1,95 @@
1
+ import { existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { parseSecretsFile } from './schema'
5
+
6
+ const LEGACY_FILENAME = 'auth.json'
7
+ const TARGET_FILENAME = 'secrets.json'
8
+
9
+ // One-shot rename of an old agent folder's auth.json to secrets.json. Called
10
+ // from createSecretsStoreForAgent before the backend opens the file so the
11
+ // rest of the storage pipeline only ever sees secrets.json. The rename runs
12
+ // on every store construction because it's cheap (existsSync + early return
13
+ // in the common case) and the rename itself is the state — no flag file.
14
+ //
15
+ // Cases:
16
+ // 1. only auth.json exists -> renameSync to secrets.json
17
+ // 2. only secrets.json -> no-op
18
+ // 3. neither -> no-op (backend will seed secrets.json)
19
+ // 4. both exist, auth.json is the empty seed envelope -> unlink auth.json
20
+ // 5. both exist, secrets.json is the empty seed envelope -> renameSync auth.json over the empty seed
21
+ // 6. both exist, both carry credentials -> throw, refuse to merge
22
+ //
23
+ // The "both non-empty" hard error matters: if a user copied an old agent
24
+ // folder, edited auth.json by hand, AND a newer typeclaw later created
25
+ // secrets.json with real credentials, we don't know which is the source of
26
+ // truth. Loud failure beats silent merge.
27
+ export function migrateLegacyAuthJson(agentDir: string): void {
28
+ const legacyPath = join(agentDir, LEGACY_FILENAME)
29
+ const targetPath = join(agentDir, TARGET_FILENAME)
30
+
31
+ if (!existsSync(legacyPath)) return
32
+
33
+ if (!existsSync(targetPath)) {
34
+ renameWithRaceFallback(legacyPath, targetPath)
35
+ return
36
+ }
37
+
38
+ if (isEmptyEnvelope(legacyPath)) {
39
+ unlinkSync(legacyPath)
40
+ return
41
+ }
42
+
43
+ if (isEmptyEnvelope(targetPath)) {
44
+ // POSIX renameSync atomically replaces the destination; the empty
45
+ // secrets.json is the safer thing to lose vs an auth.json with
46
+ // credentials. Race-safe by the same reasoning as the no-target branch.
47
+ renameWithRaceFallback(legacyPath, targetPath)
48
+ return
49
+ }
50
+
51
+ throw new Error(
52
+ `Both ${LEGACY_FILENAME} and a non-empty ${TARGET_FILENAME} exist in ${agentDir}. ` +
53
+ `Inspect manually and remove the stale file before re-running.`,
54
+ )
55
+ }
56
+
57
+ // renameSync is atomic per syscall, but two concurrent createSecretsStoreForAgent
58
+ // callers can both observe `auth.json` exists and `secrets.json` does not, then
59
+ // race on the rename. One wins; the other gets ENOENT because the legacy file
60
+ // is already gone. That's effectively a successful migration from the loser's
61
+ // POV — recheck the target and swallow the ENOENT.
62
+ function renameWithRaceFallback(from: string, to: string): void {
63
+ try {
64
+ renameSync(from, to)
65
+ } catch (err) {
66
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT' && existsSync(to)) {
67
+ return
68
+ }
69
+ throw err
70
+ }
71
+ }
72
+
73
+ // "Empty envelope" = no actual credentials. Parsed shape with empty llm and
74
+ // empty channels. We do NOT try to be clever about "approximately empty" —
75
+ // exact emptiness is the only safe auto-delete / auto-overwrite case.
76
+ function isEmptyEnvelope(path: string): boolean {
77
+ let raw: string
78
+ try {
79
+ raw = readFileSync(path, 'utf8')
80
+ } catch {
81
+ return false
82
+ }
83
+ if (raw.trim() === '') return true
84
+
85
+ let parsed: unknown
86
+ try {
87
+ parsed = JSON.parse(raw)
88
+ } catch {
89
+ return false
90
+ }
91
+
92
+ const result = parseSecretsFile(parsed)
93
+ if (!result.ok) return false
94
+ return Object.keys(result.file.llm).length === 0 && Object.keys(result.file.channels).length === 0
95
+ }
@@ -0,0 +1,75 @@
1
+ import { z } from 'zod'
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({
8
+ type: z.literal('api_key'),
9
+ key: z.string().min(1),
10
+ })
11
+
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
18
+ .object({
19
+ type: z.literal('oauth'),
20
+ })
21
+ .catchall(z.unknown())
22
+
23
+ export const llmCredentialSchema = z.discriminatedUnion('type', [llmApiKeyCredentialSchema, llmOAuthCredentialSchema])
24
+
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)
28
+
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())
33
+
34
+ export const secretsFileSchema = z.object({
35
+ $schema: z.string().optional(),
36
+ version: z.literal(1),
37
+ llm: llmCredentialsSchema.default({}),
38
+ channels: channelsSchema.default({}),
39
+ })
40
+
41
+ export type LlmCredential = z.infer<typeof llmCredentialSchema>
42
+ export type LlmCredentials = z.infer<typeof llmCredentialsSchema>
43
+ export type SecretsFile = z.infer<typeof secretsFileSchema>
44
+
45
+ export type ParseSecretsResult = { ok: true; file: SecretsFile } | { ok: false; reason: string }
46
+
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.
53
+ //
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.
57
+ export function parseSecretsFile(raw: unknown): ParseSecretsResult {
58
+ const direct = secretsFileSchema.safeParse(raw)
59
+ if (direct.success) return { ok: true, file: direct.data }
60
+
61
+ const legacy = llmCredentialsSchema.safeParse(raw)
62
+ if (legacy.success) {
63
+ return { ok: true, file: { version: 1, llm: legacy.data, channels: {} } }
64
+ }
65
+
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('; ') }
70
+ }
71
+
72
+ function formatIssue(issue: { path: PropertyKey[]; message: string }): string {
73
+ const path = issue.path.length > 0 ? issue.path.map(String).join('.') : '<root>'
74
+ return `${path}: ${issue.message}`
75
+ }
@@ -0,0 +1,231 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ import {
5
+ type AuthStorage,
6
+ type AuthStorageBackend,
7
+ AuthStorage as AuthStorageImpl,
8
+ } from '@mariozechner/pi-coding-agent'
9
+ import lockfile from 'proper-lockfile'
10
+
11
+ import { migrateLegacyAuthJson } from './migrate'
12
+ import { type SecretsFile, parseSecretsFile } from './schema'
13
+
14
+ const SCHEMA_REL = './node_modules/typeclaw/secrets.schema.json'
15
+ const FILE_MODE = 0o600
16
+ const DIR_MODE = 0o700
17
+
18
+ const SYNC_LOCK_RETRIES = 10
19
+ const SYNC_LOCK_DELAY_MS = 20
20
+
21
+ const ASYNC_LOCK_OPTIONS = {
22
+ retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10000, randomize: true },
23
+ stale: 30000,
24
+ } as const
25
+
26
+ // SecretsBackend implements pi-coding-agent's AuthStorageBackend contract
27
+ // while keeping TypeClaw in control of the on-disk file shape.
28
+ //
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.
35
+ //
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)
42
+ //
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.
46
+ export class SecretsBackend implements AuthStorageBackend {
47
+ constructor(private readonly secretsPath: string) {}
48
+
49
+ withLock<T>(fn: (current: string | undefined) => { result: T; next?: string }): T {
50
+ this.ensureParentDir()
51
+ this.ensureFileExists()
52
+ let release: (() => void) | undefined
53
+ try {
54
+ release = this.acquireSyncLockWithRetry()
55
+ const envelope = this.readEnvelope()
56
+ const innerCurrent = JSON.stringify(envelope.llm, null, 2)
57
+
58
+ const { result, next } = fn(innerCurrent)
59
+ if (next !== undefined) {
60
+ const merged = mergeLlmIntoEnvelope(envelope, next)
61
+ this.writeEnvelopeAtomic(merged)
62
+ }
63
+ return result
64
+ } finally {
65
+ release?.()
66
+ }
67
+ }
68
+
69
+ async withLockAsync<T>(fn: (current: string | undefined) => Promise<{ result: T; next?: string }>): Promise<T> {
70
+ this.ensureParentDir()
71
+ this.ensureFileExists()
72
+ let release: (() => Promise<void>) | undefined
73
+ let lockCompromised = false
74
+ let lockCompromisedError: Error | undefined
75
+ const throwIfCompromised = (): void => {
76
+ if (lockCompromised) {
77
+ throw lockCompromisedError ?? new Error('Secrets store lock was compromised')
78
+ }
79
+ }
80
+ try {
81
+ release = await lockfile.lock(this.secretsPath, {
82
+ ...ASYNC_LOCK_OPTIONS,
83
+ onCompromised: (err: Error) => {
84
+ lockCompromised = true
85
+ lockCompromisedError = err
86
+ },
87
+ })
88
+ throwIfCompromised()
89
+ const envelope = this.readEnvelope()
90
+ const innerCurrent = JSON.stringify(envelope.llm, null, 2)
91
+
92
+ const { result, next } = await fn(innerCurrent)
93
+ throwIfCompromised()
94
+ if (next !== undefined) {
95
+ const merged = mergeLlmIntoEnvelope(envelope, next)
96
+ this.writeEnvelopeAtomic(merged)
97
+ }
98
+ throwIfCompromised()
99
+ return result
100
+ } finally {
101
+ if (release) {
102
+ try {
103
+ await release()
104
+ } catch {
105
+ // Ignore unlock errors when the lock is compromised — there is
106
+ // nothing useful we can do, and surfacing the secondary error would
107
+ // mask the primary failure (mirrors upstream behaviour).
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ private ensureParentDir(): void {
114
+ const dir = dirname(this.secretsPath)
115
+ if (!existsSync(dir)) {
116
+ mkdirSync(dir, { recursive: true, mode: DIR_MODE })
117
+ }
118
+ }
119
+
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
+ private ensureFileExists(): void {
125
+ if (existsSync(this.secretsPath)) return
126
+ const seed = newEmptyEnvelope()
127
+ writeFileSync(this.secretsPath, stringifyEnvelope(seed), 'utf8')
128
+ chmodSync(this.secretsPath, FILE_MODE)
129
+ }
130
+
131
+ private acquireSyncLockWithRetry(): () => void {
132
+ let lastError: unknown
133
+ for (let attempt = 1; attempt <= SYNC_LOCK_RETRIES; attempt++) {
134
+ try {
135
+ return lockfile.lockSync(this.secretsPath, { realpath: false })
136
+ } catch (error) {
137
+ const code =
138
+ typeof error === 'object' && error !== null && 'code' in error
139
+ ? String((error as { code: unknown }).code)
140
+ : undefined
141
+ if (code !== 'ELOCKED' || attempt === SYNC_LOCK_RETRIES) throw error
142
+ lastError = error
143
+ // Busy-wait so the call stays synchronous. Matches upstream's
144
+ // FileAuthStorageBackend.acquireLockSyncWithRetry.
145
+ const start = Date.now()
146
+ while (Date.now() - start < SYNC_LOCK_DELAY_MS) {
147
+ // intentionally empty
148
+ }
149
+ }
150
+ }
151
+ throw (lastError as Error | undefined) ?? new Error('Failed to acquire secrets store lock')
152
+ }
153
+
154
+ private readEnvelope(): SecretsFile {
155
+ const raw = existsSync(this.secretsPath) ? readFileSync(this.secretsPath, 'utf8') : ''
156
+ if (!raw.trim()) return newEmptyEnvelope()
157
+ let parsed: unknown
158
+ try {
159
+ parsed = JSON.parse(raw)
160
+ } catch (err) {
161
+ throw new Error(`secrets file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`)
162
+ }
163
+ const result = parseSecretsFile(parsed)
164
+ if (!result.ok) {
165
+ throw new Error(`secrets file is not a valid TypeClaw secrets file: ${result.reason}`)
166
+ }
167
+ return result.file
168
+ }
169
+
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
+ private writeEnvelopeAtomic(envelope: SecretsFile): void {
173
+ const tmp = `${this.secretsPath}.${process.pid}.${Date.now()}.tmp`
174
+ writeFileSync(tmp, stringifyEnvelope(envelope), { encoding: 'utf8', mode: FILE_MODE })
175
+ try {
176
+ renameSync(tmp, this.secretsPath)
177
+ } catch (err) {
178
+ try {
179
+ unlinkSync(tmp)
180
+ } catch {
181
+ // best-effort cleanup of the temp file when rename fails
182
+ }
183
+ throw err
184
+ }
185
+ chmodSync(this.secretsPath, FILE_MODE)
186
+ }
187
+ }
188
+
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
+ export function createSecretsStoreForAgent(secretsPath: string): AuthStorage {
198
+ migrateLegacyAuthJson(dirname(secretsPath))
199
+ return AuthStorageImpl.fromStorage(new SecretsBackend(secretsPath))
200
+ }
201
+
202
+ function newEmptyEnvelope(): SecretsFile {
203
+ return { $schema: SCHEMA_REL, version: 1, llm: {}, channels: {} }
204
+ }
205
+
206
+ function stringifyEnvelope(envelope: SecretsFile): string {
207
+ return `${JSON.stringify(envelope, null, 2)}\n`
208
+ }
209
+
210
+ function mergeLlmIntoEnvelope(envelope: SecretsFile, nextLlmJson: string): SecretsFile {
211
+ let parsed: unknown
212
+ try {
213
+ parsed = JSON.parse(nextLlmJson)
214
+ } catch (err) {
215
+ throw new Error(
216
+ `AuthStorage produced invalid JSON for the llm slice: ${err instanceof Error ? err.message : String(err)}`,
217
+ )
218
+ }
219
+ if (!isPlainObject(parsed)) {
220
+ throw new Error('AuthStorage produced a non-object llm slice')
221
+ }
222
+ return {
223
+ ...envelope,
224
+ $schema: envelope.$schema ?? SCHEMA_REL,
225
+ llm: parsed as SecretsFile['llm'],
226
+ }
227
+ }
228
+
229
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
230
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
231
+ }