typeclaw 0.13.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -12,7 +12,17 @@ TypeClaw is domain-agnostic — your purpose is defined by \`IDENTITY.md\`, your
12
12
  - **AGENTS.md** *(read on demand)* — your operating manual. Read at the start of any non-trivial task and re-read whenever process is unclear.
13
13
  - **\`memory/topics/\`** *(always injected below, READ-ONLY)* — sharded long-term memory, owned by the dreaming subagent. To capture something memorable, surface it in your reply or let the memory-logger append to \`memory/streams/\`; never edit memory shards directly.
14
14
 
15
- If a task reveals durable guidance or identity/user context, update the owning file (IDENTITY / SOUL / USER / AGENTS) — never memory shards.
15
+ If a task reveals durable guidance or identity/user context, update the owning file (IDENTITY / SOUL / USER / AGENTS) — never memory shards. **Use this routing when you have something durable to record:**
16
+
17
+ - *role, function, scope of work, who you are to this user* → IDENTITY.md
18
+ - *voice, tone, register, language preferences, persona quirks* → SOUL.md
19
+ - *facts about the user (name, timezone, projects, preferences they hold across tasks)* → USER.md
20
+ - *working conventions, repeatable procedures, "always do X" rules, things future-you needs to read before acting* → AGENTS.md
21
+ - *one-off context for this conversation only* → don't write a file; it'll be captured in \`memory/streams/\` automatically
22
+
23
+ When in doubt between SOUL.md and AGENTS.md: if it describes *how you sound*, it's SOUL; if it describes *how you work*, it's AGENTS. Tone preferences ("be more terse") go to SOUL.md; process rules ("always run tests before committing") go to AGENTS.md.
24
+
25
+ **Edit discipline.** Prefer rewriting in place to growing files. SOUL.md should stay short — a paragraph or two; if it's drifting past a screen, you're using it as a scratchpad and the model that reads it will start ignoring the back half. IDENTITY.md is similar — a few lines of who you are, not a résumé. AGENTS.md is the one allowed to grow. Don't rewrite SOUL.md on the first piece of tone feedback in a session — wait until the user repeats a preference or asks you directly to update it; a single off-day request isn't a durable change.
16
26
 
17
27
  ## Your workspace
18
28
 
@@ -39,10 +39,13 @@ export type SkipResponseDetails = {
39
39
  // `skip_response` is preferred whenever the model has a reason worth
40
40
  // recording. See session-origin.ts for the prompt-level decision rule.
41
41
  //
42
- // Order-dependence with `channel_reply`/`channel_send`: once `skip_response`
43
- // fires in a turn, the router rejects any subsequent tool-source send for
44
- // the same turn with `SKIP_RESPONSE_LOCK_ERROR`. The model gets a clear
45
- // error and learns to commit on the next turn instead of mid-turn.
42
+ // Order-dependence with `channel_reply`/`channel_send` is asymmetric:
43
+ // - skip BEFORE any send → commits to silence; the router rejects any
44
+ // subsequent tool-source send this turn with `SKIP_RESPONSE_LOCK_ERROR`.
45
+ // - skip AFTER a send accepted as a terminal no-op (`recorded-after-send`).
46
+ // The earlier reply stands; this posts nothing and ends the turn. Rejecting
47
+ // it (the old behavior) drove a livelock: denied a clean silent exit, the
48
+ // model re-sent, got re-denied on the next skip, and repeated to the cap.
46
49
  export function createSkipResponseTool({
47
50
  router,
48
51
  sessionId,
@@ -55,12 +58,14 @@ export function createSkipResponseTool({
55
58
  'Decline to send a user-facing reply this turn, with a logged reason. Use this ' +
56
59
  'instead of narrating "I have nothing to add" / "I will stay quiet" in your visible ' +
57
60
  'response. The reason is written to host logs (visible via `typeclaw logs -f`) but ' +
58
- 'never delivered to the user. The contract is bidirectional: after calling this, any ' +
59
- '`channel_reply` / `channel_send` in the same turn will be rejected, AND calling this ' +
60
- 'after a `channel_reply` / `channel_send` has already landed in this turn will also ' +
61
- 'be rejected commit to silence or commit to replying, not both. Decide before you ' +
62
- 'send, and call this as your terminal tool when you decide to stay silent. Prefer ' +
63
- 'this over the `NO_REPLY` text sentinel whenever you have a reason worth recording.',
61
+ 'never delivered to the user. If you call this BEFORE sending anything this turn, it ' +
62
+ 'commits you to silence and any later `channel_reply` / `channel_send` in the same ' +
63
+ 'turn is rejected. If you call it AFTER a reply has already landed this turn (e.g. you ' +
64
+ 'posted an ack and now want to wait quietly for a backgrounded subagent), it is ' +
65
+ 'accepted as a terminal no-op: your earlier reply stands, nothing further is sent, and ' +
66
+ 'your turn ends. Either way, call this as your terminal tool when you decide to stop ' +
67
+ 'talking — do NOT keep sending "still working" updates. Prefer this over the ' +
68
+ '`NO_REPLY` text sentinel whenever you have a reason worth recording.',
64
69
  parameters: Type.Object({
65
70
  reason: Type.String({
66
71
  description:
@@ -85,33 +90,20 @@ export function createSkipResponseTool({
85
90
  }
86
91
 
87
92
  const result = router.markTurnSkipped({ parentSessionId: sessionId, reason })
88
- if (result.kind === 'send-already-happened') {
89
- // Symmetric counterpart of the send-after-skip lock in `router.send()`.
90
- // The model already committed to replying earlier in this turn; calling
91
- // skip_response now would land the reply AND claim silence at the same
92
- // time, which is the contract violation the lock exists to prevent.
93
- // Surface a clear error and refuse to stamp the flag so the rest of
94
- // the turn behaves as a normal reply turn.
95
- logger.warn(
96
- formatChannelToolFailure(
97
- 'skip_response',
98
- `channel send already happened this turn (reason=${JSON.stringify(reason)})`,
99
- ),
100
- )
101
- const details: SkipResponseDetails = {
102
- ok: false,
103
- suppressed: false,
104
- reason,
105
- error: 'send-already-happened',
106
- }
93
+ if (result.kind === 'recorded-after-send') {
94
+ // Reply-first skip: an ack already landed; this just ends the turn
95
+ // quietly. Not suppressed (the reply stands) and not an error (erroring
96
+ // here is what drove the historical re-send livelock). Router logged it.
97
+ const details: SkipResponseDetails = { ok: true, suppressed: false, reason }
107
98
  return {
108
99
  content: [
109
100
  {
110
101
  type: 'text' as const,
111
102
  text:
112
- 'skip_response denied: you already sent a channel reply in this turn. ' +
113
- 'Commit to silence or commit to replying, not both. ' +
114
- 'End your turn now; the reply you already sent stands.',
103
+ 'skip_response accepted: your earlier channel reply this turn stands, and ' +
104
+ 'no further message will be sent. End your turn now — do not send "still ' +
105
+ 'working" updates while a backgrounded subagent runs; the completion ' +
106
+ 'reminder will wake you when it finishes.',
115
107
  },
116
108
  ],
117
109
  details,
@@ -103,6 +103,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
103
103
  if (params.description !== undefined) payload.description = params.description
104
104
 
105
105
  const startedAt = now()
106
+ const spawnedByRole = permissions?.resolveRole(origin)
106
107
  const { handle, completion } = startSubagent(subagentName, {
107
108
  registry,
108
109
  createSessionForSubagent,
@@ -110,6 +111,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
110
111
  userPrompt: params.prompt,
111
112
  payload: subagent.payloadSchema ? payload : undefined,
112
113
  parentSessionId,
114
+ ...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
113
115
  ...(origin !== undefined ? { spawnedByOrigin: origin } : {}),
114
116
  taskId,
115
117
  })
@@ -45,7 +45,7 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
45
45
 
46
46
  const selfId = options.selfId()
47
47
  const selfLogin = options.selfLogin()
48
- const author = readAuthor(payload)
48
+ const author = readAuthor(event, payload)
49
49
  if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
50
50
  options.logger.info(
51
51
  `[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
@@ -363,13 +363,52 @@ function readRepository(payload: Record<string, unknown>): { owner: string; name
363
363
  return { owner: ownerLogin, name }
364
364
  }
365
365
 
366
- function readAuthor(payload: Record<string, unknown>): GithubUser | null {
367
- const candidates = [payload.comment, payload.issue, payload.pull_request, payload.discussion, payload.review]
368
- for (const candidate of candidates) {
366
+ function readAuthor(event: string, payload: Record<string, unknown>): GithubUser | null {
367
+ for (const candidate of eventAuthorCandidates(event, payload)) {
369
368
  const user = readUser(readRecord(candidate)?.user)
370
369
  if (user !== null) return user
371
370
  }
372
- return null
371
+ // Every GitHub webhook payload carries `sender` — the actor who triggered the
372
+ // delivery. It is the universal fallback so events not enumerated above (and
373
+ // any future ones the user adds to eventAllowlist) still drop self-authored
374
+ // deliveries instead of slipping past the guard.
375
+ return readUser(payload.sender)
376
+ }
377
+
378
+ // Maps each event to the entity whose `user` is the true author of THIS event,
379
+ // listed before broader containers. A pull_request_review payload ships both
380
+ // `pull_request` (the PR author) and `review` (the reviewer); the self-author
381
+ // drop must see the reviewer, so `review` must come first. PR #455's flat order
382
+ // (`pull_request` before `review`) made a self-review on someone else's PR
383
+ // resolve to the PR author, slip past the drop, and loop (see PR #460).
384
+ //
385
+ // `pull_request` and `pull_request_review_thread` carry only the `pull_request`
386
+ // container, whose `user` is the PR OPENER — not the actor of this delivery.
387
+ // For these events the self-author question is "who triggered the action?"
388
+ // (review_requested, edited, reopened, resolved, …), which is always
389
+ // `payload.sender`, never the opener. Mapping them to `[]` makes readAuthor
390
+ // skip the opener and fall through to the `sender` fallback. PR #462's
391
+ // `['pull_request']` resolved to the opener, so a human action on a
392
+ // bot-opened PR matched the bot and was wrongly dropped (the inbound landed
393
+ // as awareness-only "Recent context" and the agent never replied).
394
+ const PRIMARY_AUTHOR_KEYS: Record<string, readonly string[]> = {
395
+ issue_comment: ['comment'],
396
+ pull_request_review_comment: ['comment'],
397
+ discussion_comment: ['comment'],
398
+ commit_comment: ['comment'],
399
+ pull_request_review: ['review'],
400
+ pull_request_review_thread: [],
401
+ issues: ['issue'],
402
+ pull_request: [],
403
+ discussion: ['discussion'],
404
+ release: ['release'],
405
+ }
406
+
407
+ const FALLBACK_AUTHOR_KEYS = ['comment', 'review', 'issue', 'pull_request', 'discussion', 'release'] as const
408
+
409
+ function eventAuthorCandidates(event: string, payload: Record<string, unknown>): unknown[] {
410
+ const keys = PRIMARY_AUTHOR_KEYS[event] ?? FALLBACK_AUTHOR_KEYS
411
+ return keys.map((key) => payload[key])
373
412
  }
374
413
 
375
414
  // Matches by id OR login. Issue #452 captured a self-responding loop where
@@ -53,6 +53,14 @@ export type GithubAdapterOptions = {
53
53
  // Test-only: replaces the wall-clock sleep used for the registration
54
54
  // delay above. Production leaves it undefined and we use `setTimeout`.
55
55
  sleep?: (ms: number) => Promise<void>
56
+ // How often to proactively refresh the token and update GH_TOKEN
57
+ // when the adapter is running but has not made an outbound API call
58
+ // recently. Zero disables the background refresh entirely.
59
+ // Default: 30 minutes.
60
+ tokenRefreshIntervalMs?: number
61
+ // Test-only: replaces `setInterval` so tests can control when the
62
+ // background refresh fires without waiting on real wall-clock time.
63
+ setInterval?: (handler: () => void, ms: number) => { clear: () => void }
56
64
  }
57
65
 
58
66
  export type GithubAdapter = {
@@ -68,6 +76,7 @@ const consoleLogger: GithubAdapterLogger = {
68
76
  }
69
77
 
70
78
  const DEFAULT_WEBHOOK_REGISTRATION_DELAY_MS = 2_000
79
+ const DEFAULT_TOKEN_REFRESH_INTERVAL_MS = 30 * 60 * 1000
71
80
 
72
81
  export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapter {
73
82
  const logger = options.logger ?? consoleLogger
@@ -83,6 +92,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
83
92
  let selfLogin: string | null = null
84
93
  let started = false
85
94
  let managedHooks: ReadonlyArray<{ repo: string; hookId: number }> = []
95
+ let tokenRefreshTimer: { clear: () => void } | null = null
86
96
  const workspaceByChat = new Map<string, string>()
87
97
 
88
98
  const rememberWorkspace = (workspace: string, chat: string): void => {
@@ -168,6 +178,24 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
168
178
  // automatically when within 5 minutes of expiry.
169
179
  process.env.GH_TOKEN = await auth.token()
170
180
  started = true
181
+ // Keep GH_TOKEN warm even when the adapter is only receiving inbound
182
+ // webhooks and not making outbound API calls. This prevents `gh` CLI
183
+ // calls from the agent from failing with 401 after the token expires.
184
+ const tokenRefreshIntervalMs = options.tokenRefreshIntervalMs ?? DEFAULT_TOKEN_REFRESH_INTERVAL_MS
185
+ if (tokenRefreshIntervalMs > 0) {
186
+ const refresh = () => {
187
+ tokenFn().catch((err) => {
188
+ logger.error(`[github] periodic token refresh failed: ${err instanceof Error ? err.message : String(err)}`)
189
+ })
190
+ }
191
+ const setIntervalFn =
192
+ options.setInterval ??
193
+ ((handler: () => void, ms: number) => {
194
+ const timer = setInterval(handler, ms)
195
+ return { clear: () => clearInterval(timer) }
196
+ })
197
+ tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
198
+ }
171
199
  logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
172
200
  // Best-effort: App-only preflight that compares the installation's granted
173
201
  // permissions against the configured eventAllowlist and warns about gaps.
@@ -241,6 +269,10 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
241
269
  logDeregistrationOutcome(logger, deregistration)
242
270
  managedHooks = []
243
271
  }
272
+ if (tokenRefreshTimer !== null) {
273
+ tokenRefreshTimer.clear()
274
+ tokenRefreshTimer = null
275
+ }
244
276
  await auth.dispose()
245
277
  delete process.env.GH_TOKEN
246
278
  server = null
@@ -498,13 +498,15 @@ export type ChannelRouter = {
498
498
  // turn cannot drop a future legitimate reply.
499
499
  //
500
500
  // Returns:
501
- // - 'recorded' — the live session was found and the skip was stamped
502
- // - 'send-already-happened' a tool-source channel send already landed
503
- // in this turn; the skip is refused (symmetric with
504
- // the send-after-skip lock in `send()`) so the model
505
- // cannot land a reply AND claim silence. The flag is
506
- // NOT stamped, so the turn proceeds as a normal
507
- // reply turn.
501
+ // - 'recorded' — silence-first: no send had landed this turn, so the
502
+ // skip was stamped and later tool-source sends are
503
+ // locked out via the send-after-skip guard in `send()`
504
+ // - 'recorded-after-send' reply-first: a tool-source channel send already
505
+ // landed this turn and the agent is now going quiet for
506
+ // the rest of it (the normal ack-then-wait pattern). The
507
+ // delivered reply stands; this skip posts nothing and is
508
+ // a terminal no-op. NOT stamped as a skipped turn (a
509
+ // reply already landed), and logged inline by the impl.
508
510
  // - 'no-live-session' — no matching channel session (e.g. tool fired
509
511
  // outside a channel origin); the tool should
510
512
  // still log the reason but cannot suppress.
@@ -513,7 +515,7 @@ export type ChannelRouter = {
513
515
  reason: string
514
516
  }) =>
515
517
  | { kind: 'recorded'; keyId: string }
516
- | { kind: 'send-already-happened'; keyId: string }
518
+ | { kind: 'recorded-after-send'; keyId: string }
517
519
  | { kind: 'no-live-session' }
518
520
  stop: () => Promise<void>
519
521
  liveCount: () => number
@@ -2254,13 +2256,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2254
2256
  reason: string
2255
2257
  }):
2256
2258
  | { kind: 'recorded'; keyId: string }
2257
- | { kind: 'send-already-happened'; keyId: string }
2259
+ | { kind: 'recorded-after-send'; keyId: string }
2258
2260
  | { kind: 'no-live-session' } => {
2259
2261
  for (const live of liveSessions.values()) {
2260
2262
  if (live.destroyed) continue
2261
2263
  if (live.sessionId !== args.parentSessionId) continue
2262
2264
  if (live.successfulChannelSends > live.successfulSendsAtTurnStart) {
2263
- return { kind: 'send-already-happened', keyId: live.keyId }
2265
+ // Reply-first skip ("acked, now going quiet"): accept as a terminal
2266
+ // no-op, never stamp `skippedTurn`. The delivered reply stands and must
2267
+ // not be suppressed, so stamping (which `validateChannelTurn` reads to
2268
+ // drop the turn) would be wrong; the send-after-skip lock only needs to
2269
+ // arm on the silence-first path. Rejecting this instead deadlocks the
2270
+ // agentic loop: denied a clean silent exit the model re-sends, gets
2271
+ // re-denied, and repeats until the per-turn send cap trips. Logged here
2272
+ // since `validateChannelTurn` won't see a `skippedTurn` for it.
2273
+ logger.info(`[channels] ${live.keyId} skip_after_send reason=${JSON.stringify(args.reason)}`)
2274
+ return { kind: 'recorded-after-send', keyId: live.keyId }
2264
2275
  }
2265
2276
  live.skippedTurn = { turnSeq: live.turnSeq, reason: args.reason }
2266
2277
  return { kind: 'recorded', keyId: live.keyId }
@@ -0,0 +1,328 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { type AdapterId, type ChannelsConfig } from '@/channels'
4
+ import { loadConfigSync, validateConfig } from '@/config'
5
+ import { readEnvFile } from '@/init'
6
+ import { SecretsBackend } from '@/secrets'
7
+ import { channelFieldDefaultEnv } from '@/secrets/defaults'
8
+
9
+ import type { CheckContext, CheckResult, DoctorCheck } from './types'
10
+
11
+ // Host-stage channel adapter health checks. These cannot talk to Slack /
12
+ // Discord / Telegram / KakaoTalk / GitHub APIs — that work belongs to the
13
+ // container-stage `start()` preflight on each adapter (see
14
+ // `src/channels/manager.ts` and individual adapters). What doctor CAN do
15
+ // from the host is verify that the credentials the container will look for
16
+ // are actually present and resolvable, so the operator gets a clear,
17
+ // before-`typeclaw start` signal instead of a silent skip in the runtime
18
+ // logs.
19
+ //
20
+ // Every check is gated on `ctx.hasAgentFolder` (typeclaw.json is required to
21
+ // read the channels config) and additionally on the adapter being declared
22
+ // AND enabled in typeclaw.json. A missing or `enabled: false` adapter
23
+ // reports `skipped` so the operator can see the check ran without it
24
+ // turning into noise on minimal setups.
25
+
26
+ export function buildChannelChecks(): DoctorCheck[] {
27
+ return [
28
+ slackBotCredentials(),
29
+ discordBotCredentials(),
30
+ telegramBotCredentials(),
31
+ kakaotalkCredentials(),
32
+ githubCredentials(),
33
+ githubWebhookDelivery(),
34
+ ]
35
+ }
36
+
37
+ function slackBotCredentials(): DoctorCheck {
38
+ return {
39
+ name: 'channel.slack-bot.credentials',
40
+ category: 'channels',
41
+ description: 'slack-bot adapter has SLACK_BOT_TOKEN and SLACK_APP_TOKEN',
42
+ applies: (ctx) => ctx.hasAgentFolder,
43
+ run: (ctx) => runTokenAdapterCheck(ctx, 'slack-bot', ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']),
44
+ }
45
+ }
46
+
47
+ function discordBotCredentials(): DoctorCheck {
48
+ return {
49
+ name: 'channel.discord-bot.credentials',
50
+ category: 'channels',
51
+ description: 'discord-bot adapter has DISCORD_BOT_TOKEN',
52
+ applies: (ctx) => ctx.hasAgentFolder,
53
+ run: (ctx) => runTokenAdapterCheck(ctx, 'discord-bot', ['DISCORD_BOT_TOKEN']),
54
+ }
55
+ }
56
+
57
+ function telegramBotCredentials(): DoctorCheck {
58
+ return {
59
+ name: 'channel.telegram-bot.credentials',
60
+ category: 'channels',
61
+ description: 'telegram-bot adapter has TELEGRAM_BOT_TOKEN',
62
+ applies: (ctx) => ctx.hasAgentFolder,
63
+ run: (ctx) => runTokenAdapterCheck(ctx, 'telegram-bot', ['TELEGRAM_BOT_TOKEN']),
64
+ }
65
+ }
66
+
67
+ function kakaotalkCredentials(): DoctorCheck {
68
+ return {
69
+ name: 'channel.kakaotalk.credentials',
70
+ category: 'channels',
71
+ description: 'kakaotalk adapter has at least one account in secrets.json',
72
+ applies: (ctx) => ctx.hasAgentFolder,
73
+ async run(ctx) {
74
+ const channels = readDeclaredChannels(ctx)
75
+ if (channels === null) return configInvalidResult()
76
+ if (!isAdapterActive(channels, 'kakaotalk')) {
77
+ return { status: 'skipped', message: 'kakaotalk not configured' }
78
+ }
79
+ const block = readChannelsSecrets(ctx)?.kakaotalk
80
+ const accountCount = block?.accounts ? Object.keys(block.accounts).length : 0
81
+ if (accountCount === 0) {
82
+ return {
83
+ status: 'warning',
84
+ message: 'kakaotalk has no accounts in secrets.json',
85
+ details: ['Adapter will start but fail authentication and stay disconnected.'],
86
+ fix: { description: 'Run `typeclaw channel add kakaotalk` to log in an account.' },
87
+ }
88
+ }
89
+ return { status: 'ok', message: `kakaotalk has ${accountCount} account(s)` }
90
+ },
91
+ }
92
+ }
93
+
94
+ function githubCredentials(): DoctorCheck {
95
+ return {
96
+ name: 'channel.github.credentials',
97
+ category: 'channels',
98
+ description: 'github adapter has auth and webhookSecret in secrets.json',
99
+ applies: (ctx) => ctx.hasAgentFolder,
100
+ async run(ctx) {
101
+ const channels = readDeclaredChannels(ctx)
102
+ if (channels === null) return configInvalidResult()
103
+ if (!isAdapterActive(channels, 'github')) {
104
+ return { status: 'skipped', message: 'github not configured' }
105
+ }
106
+ const block = readChannelsSecrets(ctx)?.github
107
+ if (!block) {
108
+ return {
109
+ status: 'error',
110
+ message: 'github secrets missing from secrets.json',
111
+ details: ['Adapter requires both `auth` and `webhookSecret`.'],
112
+ fix: { description: 'Run `typeclaw channel set github` to configure GitHub auth.' },
113
+ }
114
+ }
115
+ const dotEnv = safeReadEnvFile(ctx.cwd)
116
+ const details: string[] = []
117
+ const webhookSecret = resolveSecretHostStage(block.webhookSecret, dotEnv)
118
+ if (webhookSecret === undefined || webhookSecret === '') {
119
+ details.push('webhookSecret is unset (resolves to empty string)')
120
+ }
121
+ if (block.auth.type === 'pat') {
122
+ const token = resolveSecretHostStage(block.auth.token, dotEnv)
123
+ if (token === undefined || token === '') {
124
+ details.push('auth.token (PAT) is unset (resolves to empty string)')
125
+ }
126
+ } else {
127
+ const key = resolveSecretHostStage(block.auth.privateKey, dotEnv)
128
+ if (key === undefined || key === '') {
129
+ details.push('auth.privateKey (App) is unset (resolves to empty string)')
130
+ }
131
+ }
132
+ if (details.length > 0) {
133
+ return {
134
+ status: 'error',
135
+ message: 'github credentials present but some fields resolve to empty',
136
+ details,
137
+ fix: { description: 'Run `typeclaw channel set github` to repopulate the missing fields.' },
138
+ }
139
+ }
140
+ return {
141
+ status: 'ok',
142
+ message: `github ${block.auth.type === 'pat' ? 'PAT' : 'App'} auth + webhookSecret resolved`,
143
+ }
144
+ },
145
+ }
146
+ }
147
+
148
+ function githubWebhookDelivery(): DoctorCheck {
149
+ return {
150
+ name: 'channel.github.webhook-delivery',
151
+ category: 'channels',
152
+ description: 'github webhook delivery has a public URL (webhookUrl or tunnel)',
153
+ applies: (ctx) => ctx.hasAgentFolder,
154
+ async run(ctx) {
155
+ const cfg = safeLoadConfig(ctx)
156
+ if (cfg === null) return configInvalidResult()
157
+ const github = cfg.channels.github
158
+ if (github === undefined || github.enabled === false) {
159
+ return { status: 'skipped', message: 'github not configured' }
160
+ }
161
+ const hasWebhookUrl = typeof github.webhookUrl === 'string' && github.webhookUrl.length > 0
162
+ const hasTunnel = cfg.tunnels.some((t) => t.for.kind === 'channel' && t.for.name === 'github')
163
+ if (hasWebhookUrl || hasTunnel) {
164
+ const source = hasWebhookUrl ? 'webhookUrl' : 'tunnel'
165
+ return { status: 'ok', message: `github webhook delivery configured via ${source}` }
166
+ }
167
+ if (github.repos.length === 0) {
168
+ return {
169
+ status: 'info',
170
+ message: 'github has no webhookUrl or tunnel, and no repos to register',
171
+ details: ['Webhooks will not be auto-registered until either webhookUrl or a tunnel binding is set.'],
172
+ }
173
+ }
174
+ return {
175
+ status: 'warning',
176
+ message: `github lists ${github.repos.length} repo(s) but has no public URL to deliver webhooks to`,
177
+ details: [
178
+ 'Either set `channels.github.webhookUrl` in typeclaw.json,',
179
+ 'or add a `tunnels[]` entry with `for: { kind: "channel", name: "github" }`.',
180
+ ],
181
+ fix: {
182
+ description: 'Configure webhookUrl or a github tunnel; see `typeclaw tunnel add` for managed tunnels.',
183
+ },
184
+ }
185
+ },
186
+ }
187
+ }
188
+
189
+ async function runTokenAdapterCheck(
190
+ ctx: CheckContext,
191
+ adapter: Extract<AdapterId, 'slack-bot' | 'discord-bot' | 'telegram-bot'>,
192
+ envNames: readonly string[],
193
+ ): Promise<CheckResult> {
194
+ const channels = readDeclaredChannels(ctx)
195
+ if (channels === null) return configInvalidResult()
196
+ if (!isAdapterActive(channels, adapter)) {
197
+ return { status: 'skipped', message: `${adapter} not configured` }
198
+ }
199
+ const dotEnv = safeReadEnvFile(ctx.cwd)
200
+ const channelSecrets = readChannelsSecrets(ctx)
201
+ const adapterSecrets = (channelSecrets?.[adapter] ?? {}) as Record<string, unknown>
202
+ const missing: string[] = []
203
+ for (const envName of envNames) {
204
+ if (hasTokenForEnv(adapter, envName, dotEnv, adapterSecrets)) continue
205
+ missing.push(envName)
206
+ }
207
+ if (missing.length > 0) {
208
+ return {
209
+ status: 'warning',
210
+ message: `${adapter} missing credentials: ${missing.join(', ')}`,
211
+ details: [
212
+ 'Adapter will be skipped at start until credentials are present.',
213
+ 'Resolution order: process.env wins over .env file value over secrets.json value.',
214
+ ],
215
+ fix: { description: 'Run `typeclaw channel set ' + adapter + '`, or add the env vars to .env.' },
216
+ }
217
+ }
218
+ return { status: 'ok', message: `${adapter} credentials present` }
219
+ }
220
+
221
+ // hasTokenForEnv resolves a single env-var-style credential the same way the
222
+ // runtime does, plus one host-stage-specific source: process.env > .env file >
223
+ // secrets.json. Empty strings count as unset, matching `src/secrets/resolve.ts`.
224
+ function hasTokenForEnv(
225
+ adapter: AdapterId,
226
+ envName: string,
227
+ dotEnv: Map<string, string>,
228
+ adapterSecrets: Record<string, unknown>,
229
+ ): boolean {
230
+ const fromProcess = process.env[envName]
231
+ if (fromProcess !== undefined && fromProcess !== '') return true
232
+ const fromDotEnv = dotEnv.get(envName)
233
+ if (fromDotEnv !== undefined && fromDotEnv !== '') return true
234
+ const fieldName = fieldNameForEnv(adapter, envName)
235
+ if (fieldName === undefined) return false
236
+ const secret = adapterSecrets[fieldName]
237
+ if (!isSecretShape(secret)) return false
238
+ const resolved = resolveSecretHostStage(secret, dotEnv, envName)
239
+ return resolved !== undefined && resolved !== ''
240
+ }
241
+
242
+ // resolveSecretHostStage mirrors `resolveSecret` precedence but adds a .env
243
+ // lookup before falling through to process.env. Doctor runs on the host and
244
+ // never executes the container, so .env values are not in process.env. For
245
+ // Secrets bound to a custom env var (e.g. `{ env: 'MY_TOKEN' }`), the runtime
246
+ // would resolve via process.env.MY_TOKEN inside the container — on the host
247
+ // that yields undefined even when the value is sitting in .env. So look up
248
+ // the custom env name in the parsed .env map first.
249
+ function resolveSecretHostStage(
250
+ secret: { value?: string; env?: string },
251
+ dotEnv: Map<string, string>,
252
+ defaultEnv?: string,
253
+ ): string | undefined {
254
+ const envName = secret.env ?? defaultEnv
255
+ if (envName !== undefined) {
256
+ const fromProcess = process.env[envName]
257
+ if (fromProcess !== undefined && fromProcess !== '') return fromProcess
258
+ const fromDotEnv = dotEnv.get(envName)
259
+ if (fromDotEnv !== undefined && fromDotEnv !== '') return fromDotEnv
260
+ }
261
+ return secret.value
262
+ }
263
+
264
+ function fieldNameForEnv(adapter: AdapterId, envName: string): string | undefined {
265
+ // Reverse-lookup using channelFieldDefaultEnv: scan the small set of known
266
+ // fields per adapter for the one whose default env matches. The set is
267
+ // tiny (1-2 entries) so the linear scan is fine.
268
+ const candidates: Record<string, readonly string[]> = {
269
+ 'slack-bot': ['botToken', 'appToken'],
270
+ 'discord-bot': ['token'],
271
+ 'telegram-bot': ['token'],
272
+ }
273
+ const fields = candidates[adapter]
274
+ if (!fields) return undefined
275
+ for (const field of fields) {
276
+ if (channelFieldDefaultEnv(adapter, field) === envName) return field
277
+ }
278
+ return undefined
279
+ }
280
+
281
+ function isSecretShape(value: unknown): value is { value?: string; env?: string } {
282
+ if (typeof value !== 'object' || value === null) return false
283
+ const obj = value as Record<string, unknown>
284
+ const hasValue = typeof obj['value'] === 'string'
285
+ const hasEnv = typeof obj['env'] === 'string'
286
+ return hasValue || hasEnv
287
+ }
288
+
289
+ function readDeclaredChannels(ctx: CheckContext): ChannelsConfig | null {
290
+ const cfg = safeLoadConfig(ctx)
291
+ return cfg?.channels ?? null
292
+ }
293
+
294
+ function safeLoadConfig(ctx: CheckContext): ReturnType<typeof loadConfigSync> | null {
295
+ const result = validateConfig(ctx.cwd)
296
+ if (!result.ok) return null
297
+ try {
298
+ return loadConfigSync(ctx.cwd)
299
+ } catch {
300
+ return null
301
+ }
302
+ }
303
+
304
+ function safeReadEnvFile(cwd: string): Map<string, string> {
305
+ try {
306
+ return readEnvFile(cwd)
307
+ } catch {
308
+ return new Map()
309
+ }
310
+ }
311
+
312
+ function readChannelsSecrets(ctx: CheckContext): ReturnType<SecretsBackend['tryReadChannelsSync']> {
313
+ try {
314
+ return new SecretsBackend(join(ctx.cwd, 'secrets.json')).tryReadChannelsSync()
315
+ } catch {
316
+ return null
317
+ }
318
+ }
319
+
320
+ function isAdapterActive(channels: ChannelsConfig, adapter: AdapterId): boolean {
321
+ const slot = channels[adapter]
322
+ if (slot === undefined) return false
323
+ return slot.enabled !== false
324
+ }
325
+
326
+ function configInvalidResult(): CheckResult {
327
+ return { status: 'skipped', message: 'config invalid (covered by config.valid)' }
328
+ }
@@ -21,6 +21,7 @@ import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
21
21
  import { detectMissingDeps } from '@/init/ensure-deps'
22
22
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
23
23
 
24
+ import { buildChannelChecks } from './channel-checks'
24
25
  import type { DoctorCheck } from './types'
25
26
 
26
27
  export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): DoctorCheck[] {
@@ -40,6 +41,7 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
40
41
  hostdRegistration(),
41
42
  containerState(dockerExec),
42
43
  containerHostPort(),
44
+ ...buildChannelChecks(),
43
45
  ]
44
46
  }
45
47
 
@@ -1,4 +1,9 @@
1
1
  import type { DockerfileConfig, DockerfileFeatureToggle } from '@/config/config'
2
+ import {
3
+ CLAUDE_CREDENTIALS_FILE_NAME,
4
+ CLAUDE_CREDENTIALS_RELATIVE_PATH,
5
+ CLAUDE_DEFAULT_CONFIG_DIR_NAME,
6
+ } from '@/secrets/export-claude-credentials-file'
2
7
 
3
8
  import { GHCR_BASE_IMAGE_REPO } from './cli-version'
4
9
 
@@ -283,13 +288,19 @@ set -eu
283
288
  # no inbound exposure anyway.
284
289
  # link_persistent_home_files symlinks credential files that tools write
285
290
  # to $HOME into a bind-mounted location so they survive container
286
- # restarts. The canonical case is Codex CLI's ~/.codex/auth.json: codex
287
- # rewrites the file in place to rotate OAuth tokens, and the official
288
- # CI/CD guidance is to persist auth.json so refresh-token state
289
- # compounds across runs. The container's $HOME (/root by default) lives
290
- # on Docker's writable overlay and is wiped on every \`stop\`+\`start\`
291
- # cycle, so without this symlink the operator would have to re-paste
292
- # auth.json after every restart.
291
+ # restarts. The container's $HOME (/root by default) lives on Docker's
292
+ # writable overlay and is wiped on every \`stop\`+\`start\` cycle, so
293
+ # without this symlink the operator would have to re-paste credentials
294
+ # after every restart.
295
+ #
296
+ # Two files are linked today, both following the same contract:
297
+ # - ~/.codex/auth.json Codex CLI rotates OAuth tokens in place by
298
+ # rewriting auth.json with refreshed credentials.
299
+ # - $CLAUDE_CONFIG_DIR/.credentials.json, or ~/.claude/.credentials.json
300
+ # by default — Claude Code rotates OAuth tokens in place by rewriting
301
+ # .credentials.json on every successful refresh (anthropics/claude-code
302
+ # #53063). Linux/Windows path; macOS uses the Keychain entry "Claude
303
+ # Code-credentials" with the same JSON shape.
293
304
  #
294
305
  # The persist root lives under /agent/.typeclaw/home/ (bind-mounted
295
306
  # from the agent folder via the -v <cwd>:/agent flag in start.ts).
@@ -329,6 +340,12 @@ link_persistent_home_files() {
329
340
  persist_root="\${TYPECLAW_PERSIST_HOME_ROOT:-/agent/.typeclaw/home}"
330
341
  mkdir -p "$persist_root/.codex" "$HOME/.codex"
331
342
  ln -sfn "$persist_root/.codex/auth.json" "$HOME/.codex/auth.json"
343
+ claude_config_dir="\${CLAUDE_CONFIG_DIR:-}"
344
+ if [ -z "$claude_config_dir" ]; then
345
+ claude_config_dir="$HOME/${CLAUDE_DEFAULT_CONFIG_DIR_NAME}"
346
+ fi
347
+ mkdir -p "$persist_root/${CLAUDE_DEFAULT_CONFIG_DIR_NAME}" "$claude_config_dir"
348
+ ln -sfn "$persist_root/${CLAUDE_CREDENTIALS_RELATIVE_PATH}" "$claude_config_dir/${CLAUDE_CREDENTIALS_FILE_NAME}"
332
349
  }
333
350
 
334
351
  start_xvfb() {
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 { exportCodexAuthFileForAgent, 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,
@@ -194,6 +198,19 @@ export async function startAgent({
194
198
  log: (message) => console.warn(message),
195
199
  })
196
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
+
197
214
  const claimController = createClaimController({
198
215
  cwd,
199
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,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'
@@ -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: