typeclaw 0.12.0 → 0.14.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 (62) hide show
  1. package/package.json +1 -1
  2. package/scripts/dump-system-prompt.ts +12 -11
  3. package/src/agent/index.ts +15 -22
  4. package/src/agent/loop-guard.ts +170 -0
  5. package/src/agent/model-fallback.ts +2 -1
  6. package/src/agent/multimodal/index.ts +1 -1
  7. package/src/agent/multimodal/look-at.ts +118 -55
  8. package/src/agent/plugin-tools.ts +57 -0
  9. package/src/agent/subagents.ts +2 -1
  10. package/src/agent/system-prompt.ts +39 -26
  11. package/src/agent/tools/channel-fetch-attachment.ts +45 -16
  12. package/src/agent/tools/normalize-ref.ts +11 -0
  13. package/src/agent/tools/skip-response.ts +24 -32
  14. package/src/agent/tools/spawn-subagent.ts +2 -0
  15. package/src/bundled-plugins/reviewer/index.ts +11 -0
  16. package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
  17. package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
  18. package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
  19. package/src/channels/adapters/discord-bot-classify.ts +32 -24
  20. package/src/channels/adapters/github/inbound.ts +63 -7
  21. package/src/channels/adapters/github/index.ts +32 -0
  22. package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
  23. package/src/channels/adapters/kakaotalk-classify.ts +8 -1
  24. package/src/channels/adapters/kakaotalk.ts +19 -11
  25. package/src/channels/adapters/slack-bot-classify.ts +30 -14
  26. package/src/channels/adapters/slack-bot.ts +3 -2
  27. package/src/channels/adapters/telegram-bot-classify.ts +36 -13
  28. package/src/channels/adapters/telegram-bot.ts +3 -3
  29. package/src/channels/outbound-flood-filter.ts +57 -0
  30. package/src/channels/router.ts +114 -15
  31. package/src/channels/types.ts +52 -1
  32. package/src/cli/builtins.ts +1 -0
  33. package/src/cli/index.ts +1 -0
  34. package/src/cli/mount.ts +157 -0
  35. package/src/cli/update.ts +6 -4
  36. package/src/config/mounts-mutation.ts +161 -0
  37. package/src/doctor/channel-checks.ts +328 -0
  38. package/src/doctor/checks.ts +2 -0
  39. package/src/init/dockerfile.ts +24 -7
  40. package/src/init/hatching.ts +1 -1
  41. package/src/plugin/index.ts +6 -0
  42. package/src/plugin/load-skill.ts +99 -0
  43. package/src/run/bundled-plugins.ts +2 -0
  44. package/src/run/index.ts +31 -1
  45. package/src/secrets/claude-credentials-json.ts +129 -0
  46. package/src/secrets/codex-auth-json.ts +67 -0
  47. package/src/secrets/export-claude-credentials-file.ts +279 -0
  48. package/src/secrets/export-codex-auth-file.ts +243 -0
  49. package/src/secrets/index.ts +16 -0
  50. package/src/server/command-runner.ts +2 -1
  51. package/src/server/index.ts +3 -2
  52. package/src/shared/index.ts +7 -1
  53. package/src/shared/local-time.ts +32 -0
  54. package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
  55. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
  56. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
  57. package/src/skills/typeclaw-claude-code/SKILL.md +5 -4
  58. package/src/skills/typeclaw-claude-code/references/auth-flow.md +35 -0
  59. package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
  60. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
  61. package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
  62. package/src/update/index.ts +95 -26
@@ -4,6 +4,7 @@ import explorerPlugin from '@/bundled-plugins/explorer'
4
4
  import guardPlugin from '@/bundled-plugins/guard'
5
5
  import memoryPlugin from '@/bundled-plugins/memory'
6
6
  import operatorPlugin from '@/bundled-plugins/operator'
7
+ import reviewerPlugin from '@/bundled-plugins/reviewer'
7
8
  import scoutPlugin from '@/bundled-plugins/scout'
8
9
  import securityPlugin from '@/bundled-plugins/security'
9
10
  import toolResultCapPlugin from '@/bundled-plugins/tool-result-cap'
@@ -41,5 +42,6 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
41
42
  { name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
42
43
  { name: 'explorer', version: undefined, source: '<bundled>', defined: explorerPlugin },
43
44
  { name: 'scout', version: undefined, source: '<bundled>', defined: scoutPlugin },
45
+ { name: 'reviewer', version: undefined, source: '<bundled>', defined: reviewerPlugin },
44
46
  { name: 'operator', version: undefined, source: '<bundled>', defined: operatorPlugin },
45
47
  ]
package/src/run/index.ts CHANGED
@@ -43,7 +43,11 @@ import type { CronHandlerContext } from '@/plugin/types'
43
43
  import { createContainerBroker, publishForwardResult } from '@/portbroker'
44
44
  import { ReloadRegistry } from '@/reload'
45
45
  import { createClaimController } from '@/role-claim'
46
- import { hydrateChannelEnvFromSecrets } from '@/secrets'
46
+ import {
47
+ exportClaudeCredentialsFileForAgent,
48
+ exportCodexAuthFileForAgent,
49
+ hydrateChannelEnvFromSecrets,
50
+ } from '@/secrets'
47
51
  import { createServer, type Server } from '@/server'
48
52
  import {
49
53
  createCommandRunner,
@@ -181,6 +185,32 @@ export async function startAgent({
181
185
  // stay in env, the file stays user-owned. See src/secrets/hydrate.ts.
182
186
  hydrateChannelEnvFromSecrets({ agentDir: cwd })
183
187
 
188
+ // When the user has `docker.file.codexCli: true` AND a typeclaw-managed
189
+ // openai-codex OAuth credential in secrets.json, write ~/.codex/auth.json
190
+ // so the Codex CLI in the container can run without a second login. The
191
+ // exporter is failure-tolerant by design: any error (gate miss, fs error,
192
+ // corrupt file) returns a non-fatal result and the agent boot continues.
193
+ // See src/secrets/export-codex-auth-file.ts for the newer-wins compare
194
+ // that prevents clobbering Codex CLI's in-place token refreshes.
195
+ exportCodexAuthFileForAgent({
196
+ agentDir: cwd,
197
+ codexCliEnabled: cwdConfig.docker.file.codexCli,
198
+ log: (message) => console.warn(message),
199
+ })
200
+
201
+ // Same shape as the codex exporter above, gated on `docker.file.claudeCode`
202
+ // and `secrets.json#providers.anthropic`. Writes ~/.claude/.credentials.json
203
+ // so the Claude Code CLI in the container can run without the user pasting
204
+ // a CLAUDE_CODE_OAUTH_TOKEN. See src/secrets/export-claude-credentials-
205
+ // file.ts for the newer-wins compare that prevents clobbering Claude
206
+ // Code's in-place token refreshes, and the read-merge-write that preserves
207
+ // any mcpOAuth state in the file.
208
+ exportClaudeCredentialsFileForAgent({
209
+ agentDir: cwd,
210
+ claudeCodeEnabled: cwdConfig.docker.file.claudeCode,
211
+ log: (message) => console.warn(message),
212
+ })
213
+
184
214
  const claimController = createClaimController({
185
215
  cwd,
186
216
  permissions: pluginsLoaded.permissions,
@@ -0,0 +1,129 @@
1
+ import type { ProviderCredential } from './schema'
2
+
3
+ // Emit the on-disk shape Claude Code consumes at ~/.claude/.credentials.json
4
+ // (Linux/Windows; macOS keeps this same JSON inside the Keychain entry
5
+ // "Claude Code-credentials"). The single required top-level key is
6
+ // `claudeAiOauth`. Field names use camelCase, not snake_case — diverging
7
+ // from Codex CLI's `tokens.access_token` shape but matching every
8
+ // third-party Claude Code integration we surveyed (ATLAS_OS, OmniRoute,
9
+ // jcode, paperclip, opencode-claude-auth).
10
+ //
11
+ // `expiresAt` is MILLISECONDS since epoch, not seconds and not ISO. The
12
+ // CLI carries no top-level expiry field outside `claudeAiOauth` — token
13
+ // expiry is recorded both as `expiresAt` here and embedded in the JWT
14
+ // `exp` claim of `accessToken`. The runtime exporter (export-claude-
15
+ // credentials-file.ts) uses the JWT exp for its newer-wins compare,
16
+ // mirroring the Codex CLI exporter's approach, because `expiresAt` is
17
+ // the field Claude Code itself rewrites on in-place refresh.
18
+ //
19
+ // `mcpOAuth` (MCP server OAuth state) may coexist alongside
20
+ // `claudeAiOauth` in the same file. This emitter accepts an optional
21
+ // `preserveMcpOAuth` block so callers that read-merge-write the file
22
+ // don't drop unrelated state. Codex CLI's file is fully owned by the
23
+ // OAuth credential and has no equivalent; this is the one extra
24
+ // complication versus emitCodexAuthJson.
25
+ export type ClaudeAiOauthBlock = {
26
+ accessToken: string
27
+ refreshToken: string
28
+ expiresAt: number
29
+ scopes?: string[]
30
+ subscriptionType?: string
31
+ }
32
+
33
+ export type EmitClaudeCredentialsJsonOptions = {
34
+ preserveMcpOAuth?: unknown
35
+ }
36
+
37
+ export function emitClaudeCredentialsJson(
38
+ credential: ProviderCredential,
39
+ options: EmitClaudeCredentialsJsonOptions = {},
40
+ ): string {
41
+ if (credential.type !== 'oauth') {
42
+ throw new Error('emitClaudeCredentialsJson only accepts oauth-typed credentials')
43
+ }
44
+ const fields = credential as ProviderCredential & {
45
+ access?: unknown
46
+ refresh?: unknown
47
+ expires?: unknown
48
+ scopes?: unknown
49
+ subscriptionType?: unknown
50
+ }
51
+ const access = fields.access
52
+ const refresh = fields.refresh
53
+ if (typeof access !== 'string' || access.length === 0) {
54
+ throw new Error('credential is missing a non-empty `access` field')
55
+ }
56
+ if (typeof refresh !== 'string' || refresh.length === 0) {
57
+ throw new Error('credential is missing a non-empty `refresh` field')
58
+ }
59
+
60
+ // Resolution order for `expiresAt`:
61
+ // 1. `expires` on the credential (pi-ai writes absolute ms epoch here).
62
+ // 2. JWT `exp` claim decoded from `access`.
63
+ // 3. 0 — Claude Code treats a missing/zero expiry as "expired" and
64
+ // triggers an immediate refresh on next use, which is the safe
65
+ // fallback when neither source is available.
66
+ const expiresAt = readExpiryMs(fields, access)
67
+
68
+ const claudeAiOauth: ClaudeAiOauthBlock = {
69
+ accessToken: access,
70
+ refreshToken: refresh,
71
+ expiresAt,
72
+ }
73
+ if (Array.isArray(fields.scopes) && fields.scopes.every((s): s is string => typeof s === 'string')) {
74
+ claudeAiOauth.scopes = fields.scopes
75
+ }
76
+ if (typeof fields.subscriptionType === 'string' && fields.subscriptionType.length > 0) {
77
+ claudeAiOauth.subscriptionType = fields.subscriptionType
78
+ }
79
+
80
+ // Read-merge-write: preserve any existing `mcpOAuth` block the caller
81
+ // supplied. The emitter doesn't read disk itself (that's the exporter's
82
+ // job); the caller passes whatever it found at the existing file path.
83
+ const out: Record<string, unknown> = { claudeAiOauth }
84
+ if (options.preserveMcpOAuth !== undefined) {
85
+ out['mcpOAuth'] = options.preserveMcpOAuth
86
+ }
87
+ return `${JSON.stringify(out, null, 2)}\n`
88
+ }
89
+
90
+ // Extracts the JWT `exp` claim (seconds since epoch) and converts to ms.
91
+ // Used by the runtime exporter's newer-wins compare and by emit's
92
+ // `expiresAt` fallback. Returns null on any decode failure; the caller
93
+ // treats that as "unknown freshness". Logic mirrors
94
+ // decodeCodexAccessTokenExpiryMs verbatim — Claude Code OAuth access
95
+ // tokens are standard JWTs with the same base64url-encoded payload
96
+ // shape Codex uses.
97
+ export function decodeClaudeAccessTokenExpiryMs(accessToken: string): number | null {
98
+ const parts = accessToken.split('.')
99
+ if (parts.length !== 3) return null
100
+ const middle = parts[1] ?? ''
101
+ if (middle === '') return null
102
+ const normalized = middle.replace(/-/g, '+').replace(/_/g, '/')
103
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
104
+ let payload: Record<string, unknown>
105
+ try {
106
+ const decoded = typeof atob === 'function' ? atob(padded) : Buffer.from(padded, 'base64').toString('utf8')
107
+ const parsed: unknown = JSON.parse(decoded)
108
+ if (!isPlainObject(parsed)) return null
109
+ payload = parsed
110
+ } catch {
111
+ return null
112
+ }
113
+ const exp = payload['exp']
114
+ if (typeof exp !== 'number' || !Number.isFinite(exp)) return null
115
+ return Math.floor(exp * 1000)
116
+ }
117
+
118
+ function readExpiryMs(fields: { expires?: unknown }, accessToken: string): number {
119
+ if (typeof fields.expires === 'number' && Number.isFinite(fields.expires)) {
120
+ return fields.expires
121
+ }
122
+ const fromJwt = decodeClaudeAccessTokenExpiryMs(accessToken)
123
+ if (fromJwt !== null) return fromJwt
124
+ return 0
125
+ }
126
+
127
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
128
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
129
+ }
@@ -0,0 +1,67 @@
1
+ import type { ProviderCredential } from './schema'
2
+
3
+ // Emit the on-disk shape Codex CLI consumes at ~/.codex/auth.json. Mirrors
4
+ // the modern (>= 0.93) shape: a single `tokens` object with access_token,
5
+ // refresh_token, and optional account_id. Codex re-derives token expiry
6
+ // from the JWT's `exp` claim on every load, so we deliberately omit a
7
+ // top-level `expires` field even though typeclaw stores one.
8
+ //
9
+ // Pre-0.93 codex used a different layout (top-level OPENAI_API_KEY +
10
+ // auth_mode discriminator + optional tokens). We don't emit that legacy
11
+ // shape — every codex install old enough to require it has been replaced
12
+ // by the version `docker.file.codexCli` installs in the container.
13
+ export function emitCodexAuthJson(credential: ProviderCredential): string {
14
+ if (credential.type !== 'oauth') {
15
+ throw new Error('emitCodexAuthJson only accepts oauth-typed credentials')
16
+ }
17
+ const fields = credential as ProviderCredential & {
18
+ access?: unknown
19
+ refresh?: unknown
20
+ accountId?: unknown
21
+ }
22
+ const access = fields.access
23
+ const refresh = fields.refresh
24
+ if (typeof access !== 'string' || access.length === 0) {
25
+ throw new Error('credential is missing a non-empty `access` field')
26
+ }
27
+ if (typeof refresh !== 'string' || refresh.length === 0) {
28
+ throw new Error('credential is missing a non-empty `refresh` field')
29
+ }
30
+
31
+ const tokens: Record<string, string> = { access_token: access, refresh_token: refresh }
32
+ if (typeof fields.accountId === 'string' && fields.accountId.length > 0) {
33
+ tokens['account_id'] = fields.accountId
34
+ }
35
+ return `${JSON.stringify({ tokens }, null, 2)}\n`
36
+ }
37
+
38
+ // Extracts the JWT `exp` claim (seconds since epoch) and converts to ms.
39
+ // Used by the runtime exporter's newer-wins compare: ~/.codex/auth.json
40
+ // carries no top-level expiry, but the JWT inside `tokens.access_token`
41
+ // does. Returns null on any decode failure; the caller treats that as
42
+ // "unknown freshness" and falls back to overwriting from typeclaw's copy.
43
+ export function decodeCodexAccessTokenExpiryMs(accessToken: string): number | null {
44
+ const parts = accessToken.split('.')
45
+ if (parts.length !== 3) return null
46
+ const middle = parts[1] ?? ''
47
+ if (middle === '') return null
48
+ // base64url → base64, then pad to a multiple of 4 (atob is strict).
49
+ const normalized = middle.replace(/-/g, '+').replace(/_/g, '/')
50
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
51
+ let payload: Record<string, unknown>
52
+ try {
53
+ const decoded = typeof atob === 'function' ? atob(padded) : Buffer.from(padded, 'base64').toString('utf8')
54
+ const parsed: unknown = JSON.parse(decoded)
55
+ if (!isPlainObject(parsed)) return null
56
+ payload = parsed
57
+ } catch {
58
+ return null
59
+ }
60
+ const exp = payload['exp']
61
+ if (typeof exp !== 'number' || !Number.isFinite(exp)) return null
62
+ return Math.floor(exp * 1000)
63
+ }
64
+
65
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
66
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
67
+ }
@@ -0,0 +1,279 @@
1
+ import {
2
+ chmodSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readlinkSync,
6
+ renameSync,
7
+ statSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import { homedir } from 'node:os'
12
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
13
+
14
+ import { decodeClaudeAccessTokenExpiryMs, emitClaudeCredentialsJson } from './claude-credentials-json'
15
+ import type { ProviderCredential, Providers } from './schema'
16
+ import { SecretsBackend } from './storage'
17
+
18
+ const FILE_MODE = 0o600
19
+ const DIR_MODE = 0o700
20
+ export const CLAUDE_CREDENTIALS_FILE_NAME = '.credentials.json'
21
+ export const CLAUDE_DEFAULT_CONFIG_DIR_NAME = '.claude'
22
+ export const CLAUDE_CREDENTIALS_RELATIVE_PATH = join(CLAUDE_DEFAULT_CONFIG_DIR_NAME, CLAUDE_CREDENTIALS_FILE_NAME)
23
+
24
+ export type ExportClaudeCredentialsFileResult =
25
+ | { action: 'skipped'; reason: SkipReason }
26
+ | { action: 'wrote'; path: string }
27
+ | { action: 'failed'; reason: string }
28
+
29
+ export type SkipReason =
30
+ | 'claude-code-disabled'
31
+ | 'no-anthropic-credential'
32
+ | 'credential-not-oauth'
33
+ | 'on-disk-is-fresher'
34
+
35
+ export type ExportClaudeCredentialsFileOptions = {
36
+ claudeCodeEnabled: boolean
37
+ providers: Providers
38
+ homeDir?: string
39
+ configDir?: string
40
+ now?: () => number
41
+ log?: (message: string) => void
42
+ }
43
+
44
+ // Writes typeclaw's anthropic OAuth credential to
45
+ // $CLAUDE_CONFIG_DIR/.credentials.json (or $HOME/.claude/.credentials.json
46
+ // by default) when it's safe to do so. The Dockerfile entrypoint shim
47
+ // symlinks the same resolved credentials path to
48
+ // /agent/.typeclaw/home/.claude/.credentials.json on every boot, so the
49
+ // write follows the symlink and lands on the persistent host-side path —
50
+ // same contract as exportCodexAuthFile.
51
+ //
52
+ // Three guards, cheapest first. The first two return without ever touching
53
+ // the filesystem, which keeps the 90% case (users who don't enable
54
+ // Claude Code) at zero overhead on every container start.
55
+ export function exportClaudeCredentialsFileIfApplicable(
56
+ options: ExportClaudeCredentialsFileOptions,
57
+ ): ExportClaudeCredentialsFileResult {
58
+ if (!options.claudeCodeEnabled) return { action: 'skipped', reason: 'claude-code-disabled' }
59
+
60
+ const credential = options.providers['anthropic']
61
+ if (credential === undefined) return { action: 'skipped', reason: 'no-anthropic-credential' }
62
+ if (credential.type !== 'oauth') return { action: 'skipped', reason: 'credential-not-oauth' }
63
+
64
+ const targetPath = resolveClaudeCredentialsPath({
65
+ ...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
66
+ ...(options.configDir !== undefined ? { configDir: options.configDir } : {}),
67
+ })
68
+
69
+ try {
70
+ const existing = readExisting(targetPath)
71
+ if (!shouldOverwrite(existing, credential, options.now ?? Date.now)) {
72
+ return { action: 'skipped', reason: 'on-disk-is-fresher' }
73
+ }
74
+ const mcpOAuthOpt = existing?.mcpOAuth !== undefined ? { preserveMcpOAuth: existing.mcpOAuth } : {}
75
+ const contents = emitClaudeCredentialsJson(credential, mcpOAuthOpt)
76
+ writeAtomic(targetPath, contents)
77
+ return { action: 'wrote', path: targetPath }
78
+ } catch (err) {
79
+ const reason = err instanceof Error ? err.message : String(err)
80
+ options.log?.(`exportClaudeCredentialsFile: ${reason}`)
81
+ return { action: 'failed', reason }
82
+ }
83
+ }
84
+
85
+ type ExistingFile = {
86
+ onDiskAccessToken: string | null
87
+ onDiskExpiresAt: number | null
88
+ mcpOAuth: unknown
89
+ }
90
+
91
+ // Read once, parse once. Returns null when the file is missing OR
92
+ // unparseable. Returning null short-circuits both branches of the
93
+ // overwrite decision (the no-disk-file recovery path) AND drops any
94
+ // non-recoverable mcpOAuth state — but if the file is unparseable
95
+ // there's nothing to recover anyway. When parsing succeeds, we hand
96
+ // back the access token (for the newer-wins compare) and the raw
97
+ // mcpOAuth block (for read-merge-write preservation).
98
+ function readExisting(targetPath: string): ExistingFile | null {
99
+ let raw: string
100
+ try {
101
+ raw = readFileSync(targetPath, 'utf8')
102
+ } catch {
103
+ return null
104
+ }
105
+ let parsed: unknown
106
+ try {
107
+ parsed = JSON.parse(raw)
108
+ } catch {
109
+ return null
110
+ }
111
+ if (typeof parsed !== 'object' || parsed === null) return null
112
+ const obj = parsed as Record<string, unknown>
113
+ const claudeBlock = obj['claudeAiOauth']
114
+ let onDiskAccessToken: string | null = null
115
+ let onDiskExpiresAt: number | null = null
116
+ if (typeof claudeBlock === 'object' && claudeBlock !== null) {
117
+ const claudeObj = claudeBlock as Record<string, unknown>
118
+ const access = claudeObj['accessToken']
119
+ if (typeof access === 'string' && access.length > 0) onDiskAccessToken = access
120
+ const expiresAt = claudeObj['expiresAt']
121
+ if (typeof expiresAt === 'number' && Number.isFinite(expiresAt) && expiresAt > 0) {
122
+ onDiskExpiresAt = expiresAt
123
+ }
124
+ }
125
+ return { onDiskAccessToken, onDiskExpiresAt, mcpOAuth: obj['mcpOAuth'] }
126
+ }
127
+
128
+ // Newer-wins: skip the write unless typeclaw's stored credential is
129
+ // strictly fresher than the on-disk expiry. Claude Code rotates tokens
130
+ // in-place (issue #53063 in anthropics/claude-code confirms it rewrites
131
+ // .credentials.json with a fresher accessToken/refreshToken/expiresAt
132
+ // on every successful refresh), so on a restart the file may legitimately
133
+ // be ahead of secrets.json. We must not clobber that.
134
+ //
135
+ // Ties skip: when expiries match there's nothing to gain from a write,
136
+ // and avoiding the I/O keeps the steady state at zero churn.
137
+ //
138
+ // existing === null OR existing.onDiskAccessToken === null OR expiry
139
+ // undecodable from both expiresAt and JWT → return true. That's the "we
140
+ // have a valid credential, the file is unusable, replace it" recovery case.
141
+ function shouldOverwrite(
142
+ existing: ExistingFile | null,
143
+ credential: ProviderCredential & { expires?: unknown; access?: unknown },
144
+ now: () => number,
145
+ ): boolean {
146
+ if (existing === null) return true
147
+ if (existing.onDiskAccessToken === null) return true
148
+ const onDiskExpiry = readOnDiskExpiry(existing)
149
+ if (onDiskExpiry === null) return true
150
+ const credentialExpiry = readCredentialExpiry(credential, now)
151
+ return credentialExpiry > onDiskExpiry
152
+ }
153
+
154
+ function readOnDiskExpiry(existing: ExistingFile): number | null {
155
+ if (existing.onDiskExpiresAt !== null) return existing.onDiskExpiresAt
156
+ if (existing.onDiskAccessToken === null) return null
157
+ return decodeClaudeAccessTokenExpiryMs(existing.onDiskAccessToken)
158
+ }
159
+
160
+ // Resolution order for the credential's expiry:
161
+ // 1. The `expires` field pi-ai writes (absolute ms epoch).
162
+ // 2. The JWT `exp` claim decoded from `access`.
163
+ // 3. Now — guarantees we still write on first boot when the credential
164
+ // lacks both, rather than silently skipping forever.
165
+ function readCredentialExpiry(credential: { expires?: unknown; access?: unknown }, now: () => number): number {
166
+ if (typeof credential.expires === 'number' && Number.isFinite(credential.expires)) {
167
+ return credential.expires
168
+ }
169
+ if (typeof credential.access === 'string') {
170
+ const fromJwt = decodeClaudeAccessTokenExpiryMs(credential.access)
171
+ if (fromJwt !== null) return fromJwt
172
+ }
173
+ return now()
174
+ }
175
+
176
+ export function resolveClaudeCredentialsPath(options: { homeDir?: string; configDir?: string } = {}): string {
177
+ const configDir = resolveClaudeConfigDir(options.configDir)
178
+ if (configDir !== null) return join(configDir, CLAUDE_CREDENTIALS_FILE_NAME)
179
+ return join(options.homeDir ?? homedir(), CLAUDE_CREDENTIALS_RELATIVE_PATH)
180
+ }
181
+
182
+ function resolveClaudeConfigDir(configDir: string | undefined): string | null {
183
+ const raw = configDir ?? process.env['CLAUDE_CONFIG_DIR']
184
+ const trimmed = raw?.trim()
185
+ return trimmed === undefined || trimmed.length === 0 ? null : trimmed
186
+ }
187
+
188
+ // Atomic temp-then-rename, mirroring export-codex-auth-file.ts's
189
+ // writeAtomic. The directory is created with 0700 and the file with 0600
190
+ // because the credential carries a long-lived refresh token — leaking it
191
+ // via lax permissions defeats the whole point. The 0600 chmod after
192
+ // rename is belt-and-suspenders: writeFileSync's `mode` is applied at
193
+ // create time, but umask can mask it down on some filesystems.
194
+ //
195
+ // Symlink preservation: the entrypoint shim will install
196
+ // $HOME/.claude/.credentials.json as a symlink to
197
+ // /agent/.typeclaw/home/.claude/.credentials.json. POSIX rename(2)
198
+ // replaces the directory entry at the destination atomically and does
199
+ // NOT follow symlinks, so a naive renameSync against the symlink path
200
+ // would replace the symlink with a regular file, leaving the persistent
201
+ // path empty and Claude Code's in-place token refresh silently lost on
202
+ // every restart. Resolve the symlink target with readlinkSync and rename
203
+ // against the real path so the symlink itself is preserved. The temp
204
+ // file MUST live alongside the real target (same filesystem) because
205
+ // renameSync across filesystems fails with EXDEV.
206
+ function writeAtomic(targetPath: string, contents: string): void {
207
+ const realTarget = resolveSymlinkTarget(targetPath)
208
+ const dir = dirname(realTarget)
209
+ mkdirSync(dir, { recursive: true, mode: DIR_MODE })
210
+ const tmp = `${realTarget}.${process.pid}.${Date.now()}.tmp`
211
+ writeFileSync(tmp, contents, { encoding: 'utf8', mode: FILE_MODE })
212
+ try {
213
+ renameSync(tmp, realTarget)
214
+ } catch (err) {
215
+ try {
216
+ unlinkSync(tmp)
217
+ } catch {
218
+ // best-effort cleanup of the temp file when rename fails
219
+ }
220
+ throw err
221
+ }
222
+ try {
223
+ statSync(realTarget)
224
+ chmodSync(realTarget, FILE_MODE)
225
+ } catch {
226
+ // ignore — file vanished between rename and chmod is benign
227
+ }
228
+ }
229
+
230
+ // Returns the absolute path renameSync should target. When `path` is a
231
+ // symlink (production: $HOME/.claude/.credentials.json -> /agent/...),
232
+ // returns the resolved absolute target so we write through the link
233
+ // instead of replacing it. Otherwise (tests, or first boot before the
234
+ // shim installs the symlink), returns the path unchanged. readlinkSync
235
+ // throws EINVAL when the path exists but isn't a symlink and ENOENT
236
+ // when nothing is there — both cases fall through to the original path.
237
+ function resolveSymlinkTarget(path: string): string {
238
+ let link: string
239
+ try {
240
+ link = readlinkSync(path)
241
+ } catch {
242
+ return path
243
+ }
244
+ return isAbsolute(link) ? link : resolve(dirname(path), link)
245
+ }
246
+
247
+ export type ExportClaudeCredentialsFileForAgentOptions = {
248
+ agentDir: string
249
+ claudeCodeEnabled: boolean
250
+ homeDir?: string
251
+ configDir?: string
252
+ log?: (message: string) => void
253
+ }
254
+
255
+ // Boot-time convenience wrapper for src/run/index.ts. Mirrors
256
+ // exportCodexAuthFileForAgent: takes agentDir, never throws, returns a
257
+ // result the caller can ignore. Secrets-file read failures are caught
258
+ // and surfaced as 'failed' so the agent boot is never blocked by a
259
+ // missing or malformed secrets.json.
260
+ export function exportClaudeCredentialsFileForAgent(
261
+ options: ExportClaudeCredentialsFileForAgentOptions,
262
+ ): ExportClaudeCredentialsFileResult {
263
+ if (!options.claudeCodeEnabled) return { action: 'skipped', reason: 'claude-code-disabled' }
264
+ let providers: Providers
265
+ try {
266
+ providers = new SecretsBackend(join(options.agentDir, 'secrets.json')).tryReadProvidersSync()
267
+ } catch (err) {
268
+ const reason = err instanceof Error ? err.message : String(err)
269
+ options.log?.(`exportClaudeCredentialsFile: ${reason}`)
270
+ return { action: 'failed', reason }
271
+ }
272
+ return exportClaudeCredentialsFileIfApplicable({
273
+ claudeCodeEnabled: options.claudeCodeEnabled,
274
+ providers,
275
+ ...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
276
+ ...(options.configDir !== undefined ? { configDir: options.configDir } : {}),
277
+ ...(options.log !== undefined ? { log: options.log } : {}),
278
+ })
279
+ }