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.
- package/package.json +1 -1
- package/scripts/dump-system-prompt.ts +12 -11
- package/src/agent/index.ts +15 -22
- package/src/agent/loop-guard.ts +170 -0
- package/src/agent/model-fallback.ts +2 -1
- package/src/agent/multimodal/index.ts +1 -1
- package/src/agent/multimodal/look-at.ts +118 -55
- package/src/agent/plugin-tools.ts +57 -0
- package/src/agent/subagents.ts +2 -1
- package/src/agent/system-prompt.ts +39 -26
- package/src/agent/tools/channel-fetch-attachment.ts +45 -16
- package/src/agent/tools/normalize-ref.ts +11 -0
- package/src/agent/tools/skip-response.ts +24 -32
- package/src/agent/tools/spawn-subagent.ts +2 -0
- package/src/bundled-plugins/reviewer/index.ts +11 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
- package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
- package/src/channels/adapters/discord-bot-classify.ts +32 -24
- package/src/channels/adapters/github/inbound.ts +63 -7
- package/src/channels/adapters/github/index.ts +32 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
- package/src/channels/adapters/kakaotalk-classify.ts +8 -1
- package/src/channels/adapters/kakaotalk.ts +19 -11
- package/src/channels/adapters/slack-bot-classify.ts +30 -14
- package/src/channels/adapters/slack-bot.ts +3 -2
- package/src/channels/adapters/telegram-bot-classify.ts +36 -13
- package/src/channels/adapters/telegram-bot.ts +3 -3
- package/src/channels/outbound-flood-filter.ts +57 -0
- package/src/channels/router.ts +114 -15
- package/src/channels/types.ts +52 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/mount.ts +157 -0
- package/src/cli/update.ts +6 -4
- package/src/config/mounts-mutation.ts +161 -0
- package/src/doctor/channel-checks.ts +328 -0
- package/src/doctor/checks.ts +2 -0
- package/src/init/dockerfile.ts +24 -7
- package/src/init/hatching.ts +1 -1
- package/src/plugin/index.ts +6 -0
- package/src/plugin/load-skill.ts +99 -0
- package/src/run/bundled-plugins.ts +2 -0
- package/src/run/index.ts +31 -1
- package/src/secrets/claude-credentials-json.ts +129 -0
- package/src/secrets/codex-auth-json.ts +67 -0
- package/src/secrets/export-claude-credentials-file.ts +279 -0
- package/src/secrets/export-codex-auth-file.ts +243 -0
- package/src/secrets/index.ts +16 -0
- package/src/server/command-runner.ts +2 -1
- package/src/server/index.ts +3 -2
- package/src/shared/index.ts +7 -1
- package/src/shared/local-time.ts +32 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
- package/src/skills/typeclaw-claude-code/SKILL.md +5 -4
- package/src/skills/typeclaw-claude-code/references/auth-flow.md +35 -0
- package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
- package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
- 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 {
|
|
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
|
+
}
|