typeclaw 0.13.0 → 0.15.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.
@@ -0,0 +1,47 @@
1
+ export type SandboxMount =
2
+ | { type: 'ro-bind'; source: string; dest: string }
3
+ | { type: 'bind'; source: string; dest: string }
4
+ | { type: 'tmpfs'; dest: string }
5
+ | { type: 'dev'; dest: string }
6
+
7
+ export type SandboxNetwork = 'none' | 'inherit'
8
+
9
+ export type SandboxProcStrategy = 'tmpfs' | 'none'
10
+
11
+ export type SandboxEnvPolicy = {
12
+ set?: Record<string, string>
13
+ passthrough?: string[]
14
+ }
15
+
16
+ export type SandboxCommandFilter = {
17
+ allowPrefixes?: string[]
18
+ rejectShellMetacharacters?: boolean
19
+ }
20
+
21
+ export type SandboxProcessPolicy = {
22
+ newSession?: boolean
23
+ dieWithParent?: boolean
24
+ }
25
+
26
+ export type SandboxPolicy = {
27
+ bwrapPath?: string
28
+ cwd?: string
29
+ mounts?: SandboxMount[]
30
+ network?: SandboxNetwork
31
+ env?: SandboxEnvPolicy
32
+ commandFilter?: SandboxCommandFilter
33
+ process?: SandboxProcessPolicy
34
+ proc?: SandboxProcStrategy
35
+ }
36
+
37
+ // The env the sandbox always re-introduces after `--clearenv`. Anything not
38
+ // listed here (or explicitly named in `env.set` / `env.passthrough` by the
39
+ // consumer) is invisible inside the sandbox. This is the load-bearing leak
40
+ // guard: the container env holds FIREWORKS_API_KEY and GH_TOKEN, and env
41
+ // inheritance is the single highest-risk exfil path for prompt-injected bash.
42
+ // HOME points at /tmp because the sandbox mounts /tmp as a fresh tmpfs.
43
+ export const DEFAULT_SANDBOX_ENV: Record<string, string> = {
44
+ PATH: '/usr/local/bin:/usr/bin:/bin',
45
+ HOME: '/tmp',
46
+ LANG: 'C.UTF-8',
47
+ }
@@ -0,0 +1,18 @@
1
+ // POSIX shell quoting for rendering a bwrap argv array into a single
2
+ // `bash -c`-safe string. Today's bash tool accepts a string `command` slot
3
+ // (`mutableArgs.command`), so the sandbox primitive renders its canonical
4
+ // argv into a quoted string the agent runtime can drop in unchanged.
5
+ //
6
+ // This is a local copy of the same helper in `src/update/index.ts`. It is
7
+ // deliberately not promoted to a shared module yet: two call sites do not
8
+ // justify the coupling, and this primitive is meant to stand alone with zero
9
+ // imports from the rest of the tree. Promote to `src/shared/shell.ts` only
10
+ // when a third independent consumer appears.
11
+ export function shellQuote(arg: string): string {
12
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(arg)) return arg
13
+ return `'${arg.replaceAll("'", "'\\''")}'`
14
+ }
15
+
16
+ export function formatCommand(argv: readonly string[]): string {
17
+ return argv.map(shellQuote).join(' ')
18
+ }
@@ -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,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
+ }
@@ -13,3 +13,13 @@ export {
13
13
  exportCodexAuthFileForAgent,
14
14
  exportCodexAuthFileIfApplicable,
15
15
  } from './export-codex-auth-file'
16
+
17
+ export {
18
+ CLAUDE_CREDENTIALS_FILE_NAME,
19
+ CLAUDE_CREDENTIALS_RELATIVE_PATH,
20
+ CLAUDE_DEFAULT_CONFIG_DIR_NAME,
21
+ type ExportClaudeCredentialsFileResult,
22
+ exportClaudeCredentialsFileForAgent,
23
+ exportClaudeCredentialsFileIfApplicable,
24
+ resolveClaudeCredentialsPath,
25
+ } from './export-claude-credentials-file'
@@ -11,14 +11,16 @@ This means **you are messaging as a person, not as a bot.** Other participants s
11
11
 
12
12
  ## What KakaoTalk does NOT support
13
13
 
14
- If you produce any of the following, KakaoTalk will render it literally and the recipient will see the raw markup:
15
-
16
- - **Bold / italic / strikethrough** `**bold**` shows as `**bold**`. Drop the asterisks; emphasize with word choice or capitalization (sparingly).
17
- - **Headings** — `# H1`, `## H2`, `### H3` all render as raw `#` characters.
18
- - **Tables** pipe-delimited tables become a wall of `|` characters. Use bullet lists or short prose paragraphs instead.
19
- - **Code fences** — ``` blocks render as raw backticks. For short snippets, paste the code inline. For long snippets, summarize and offer to send it via another channel.
20
- - **Inline code** — `` `foo` `` renders as `` `foo` ``. Just write `foo`.
21
- - **Links with display text** — `[label](url)` becomes the literal string. Send the bare URL on its own; the KakaoTalk client will auto-link it.
14
+ KakaoTalk renders messages as plain text — it has no rich-text formatting. **Write plain text from the start.** The adapter strips common markdown as a safety net before sending (so an accidental `**bold**` won't leak literal asterisks), but treat that as a last-resort guard, not a license to write markdown: the strip removes _markers_, it cannot make formatting-dependent layouts like tables readable. Compose for a plain-text surface and you control the result; lean on the stripper and you get whatever falls out.
15
+
16
+ Specifically, do not rely on any of the following write the plain-text equivalent yourself:
17
+
18
+ - **Bold / italic / strikethrough** emphasize with word choice or capitalization (sparingly), not `**asterisks**`.
19
+ - **Headings** — `# H1`, `## H2`, `### H3` carry no visual weight here. Use a short label line or just lead with the point.
20
+ - **Tables** — the stripper cannot rescue a pipe-delimited table; it would collapse into an unreadable line. Use bullet lists or short prose paragraphs instead.
21
+ - **Code fences** — for short snippets, paste the code inline as plain text. For long snippets, summarize and offer to send it via another channel.
22
+ - **Inline code** — just write `foo`, no backticks.
23
+ - **Links with display text** — send the bare URL on its own line; the KakaoTalk client auto-links it. (A `[label](url)` that slips through is reduced to `label (url)`, but a bare URL reads cleaner.)
22
24
  - **Mentions** — there is no `@user` syntax that the protocol surfaces. Address people by name in the message body.
23
25
  - **Threads / replies-with-quote** — every message is a top-level chat post. There is no per-message reply UI.
24
26
  - **Outbound stickers / emoticons** — the KakaoTalk sticker store requires desktop-app purchase flows that the SDK does not replicate. Inbound stickers ARE surfaced (see below), but you cannot send one. If the user asks for a sticker, acknowledge the limit and offer text.
@@ -106,4 +108,4 @@ The adapter drops every inbound where `event.author_id` equals the logged-in acc
106
108
 
107
109
  ## When you cannot answer in KakaoTalk
108
110
 
109
- If the user asks you to do something the adapter cannot do (render markdown, post in a thread, send a sticker), say so plainly. Files are fine — those go through `attachments[]` as described above — but markdown rendering, threading, and stickers are real limits. Acknowledge the limit instead of silently dropping the request.
111
+ If the user asks you to do something the adapter cannot do (post in a thread, send a sticker, render a real table), say so plainly. Files are fine — those go through `attachments[]` as described above — but threading, stickers, and rich formatting are real limits. Markdown markers you emit get stripped to plain text automatically, so a stray `**` won't leak; the limit is that nothing renders as formatting, not that it crashes. Acknowledge the limit instead of silently dropping the request.
@@ -36,10 +36,11 @@ If `claude` is installed but no credential is set up, you have to broker the aut
36
36
 
37
37
  **Decision rule, top to bottom:**
38
38
 
39
- 1. **Already authenticated?** Run `env | grep -E '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)='` — if either is present, skip auth entirely.
40
- 2. **User has an Anthropic Console workspace** (API billing, no subscription) API key path.
41
- 3. **User has a Claude Pro/Max/Team/Enterprise subscription** → OAuth token path.
42
- 4. **User is unsure** → ask which kind of Claude account they have. Both paths are now equally low-friction (one user action each — paste an API key, or run one command on their machine and paste the result), so the old "prefer API key when unsure" bias is gone. Pick by account shape, not by flow complexity.
39
+ 1. **`~/.claude/.credentials.json` already populated?** When typeclaw is configured with an `anthropic` OAuth credential (via `typeclaw provider add anthropic` or the init wizard) AND `docker.file.claudeCode: true`, the agent boot auto-emits the credential to `~/.claude/.credentials.json`. Check with `test -s ~/.claude/.credentials.json && jq -e '.claudeAiOauth.accessToken' ~/.claude/.credentials.json` — if it returns a string, Claude Code reads it on its own with no env var needed. Skip auth entirely.
40
+ 2. **Already authenticated via env?** Run `env | grep -E '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)='` if either is present, skip auth entirely.
41
+ 3. **User has an Anthropic Console workspace** (API billing, no subscription) API key path.
42
+ 4. **User has a Claude Pro/Max/Team/Enterprise subscription** OAuth token path.
43
+ 5. **User is unsure** → ask which kind of Claude account they have. Both paths are now equally low-friction (one user action each — paste an API key, or run one command on their machine and paste the result), so the old "prefer API key when unsure" bias is gone. Pick by account shape, not by flow complexity.
43
44
 
44
45
  Both paths converge on the same final steps: read `.env`, merge one new `KEY=value` line, write back with the `nonWorkspaceWrite` guard ack, verify, and prompt the user to restart the container. Only the credential differs.
45
46
 
@@ -4,6 +4,41 @@ Deep dive for the auth paths. Read it when `SKILL.md`'s "First-time auth (intera
4
4
 
5
5
  The two paths are intentionally symmetric: in both, the user produces one string on their side, pastes it to you, you validate it, you do read-modify-write on `.env`, you offer a restart. Only the credential differs.
6
6
 
7
+ ## Path 0 — auto-export from typeclaw's anthropic OAuth credential
8
+
9
+ If the user has already configured an `anthropic` OAuth credential in typeclaw (via `typeclaw provider add anthropic` or the init wizard) AND `docker.file.claudeCode: true`, the agent boot in `src/run/index.ts` auto-emits the credential to `~/.claude/.credentials.json` before `claude` ever runs. The destination matches Claude Code's documented Linux/Windows credential path; macOS uses the Keychain entry `"Claude Code-credentials"` with the same JSON shape, which typeclaw does not target (typeclaw runs Claude Code inside a Linux container).
10
+
11
+ The on-disk shape:
12
+
13
+ ```json
14
+ {
15
+ "claudeAiOauth": {
16
+ "accessToken": "sk-ant-oat01-...",
17
+ "refreshToken": "sk-ant-ort01-...",
18
+ "expiresAt": 1730000000000,
19
+ "scopes": ["user:inference", "user:profile", "user:sessions:claude_code", "user:mcp_servers"],
20
+ "subscriptionType": "max"
21
+ }
22
+ }
23
+ ```
24
+
25
+ Field names are camelCase and `expiresAt` is **milliseconds** since epoch (not seconds, not ISO).
26
+
27
+ ### Newer-wins on every boot
28
+
29
+ The exporter compares typeclaw's stored `expires` against the JWT `exp` claim embedded in the on-disk `accessToken`. Strictly fresher wins; ties skip. Claude Code rotates tokens in place by rewriting `.credentials.json` on every successful refresh (anthropics/claude-code#53063), so on a restart the on-disk file may legitimately be ahead of `secrets.json` and must not be clobbered. The persistent-`$HOME` symlink the entrypoint shim installs (`~/.claude/.credentials.json` → `/agent/.typeclaw/home/.claude/.credentials.json`) is what makes the in-place refresh survive `stop`+`start`.
30
+
31
+ If `.credentials.json` already carries an `mcpOAuth` block (MCP server OAuth state), the exporter preserves it on overwrite. Only the `claudeAiOauth` block is rewritten.
32
+
33
+ ### When Path 0 does NOT fire
34
+
35
+ - `docker.file.claudeCode: false` — the install layer is off, no point exporting.
36
+ - `secrets.json#providers.anthropic` is absent — nothing to export.
37
+ - The stored credential is api-key shape — Claude Code reads API keys from `ANTHROPIC_API_KEY` env, not from `.credentials.json`. Fall back to Path A.
38
+ - The on-disk JWT `exp` is already fresher — Claude Code refreshed in-place, skip.
39
+
40
+ In each of those cases the agent boots without touching the file and Path A or Path B applies. The exporter is failure-tolerant: any error (read, write, fs guard) is logged via `console.warn` and the boot continues — Claude Code will then surface the missing credential on first invocation, which is the existing fallback.
41
+
7
42
  ## Path A — API key (recap)
8
43
 
9
44
  The API key path lives entirely in `SKILL.md` because there's nothing to elaborate. Summary:
@@ -32,6 +32,7 @@
32
32
  "anthropic/claude-haiku-4-5",
33
33
  "anthropic/claude-sonnet-4-6",
34
34
  "anthropic/claude-opus-4-7",
35
+ "anthropic/claude-opus-4-8",
35
36
  "fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
36
37
  "zai/glm-4.5-air",
37
38
  "zai/glm-4.6",
@@ -59,6 +60,7 @@
59
60
  "anthropic/claude-haiku-4-5",
60
61
  "anthropic/claude-sonnet-4-6",
61
62
  "anthropic/claude-opus-4-7",
63
+ "anthropic/claude-opus-4-8",
62
64
  "fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
63
65
  "zai/glm-4.5-air",
64
66
  "zai/glm-4.6",