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 +1 -1
- package/src/agent/system-prompt.ts +11 -1
- package/src/agent/tools/skip-response.ts +24 -32
- package/src/agent/tools/spawn-subagent.ts +2 -0
- package/src/channels/adapters/github/inbound.ts +44 -5
- package/src/channels/adapters/github/index.ts +32 -0
- package/src/channels/router.ts +21 -10
- package/src/doctor/channel-checks.ts +328 -0
- package/src/doctor/checks.ts +2 -0
- package/src/init/dockerfile.ts +24 -7
- package/src/run/index.ts +18 -1
- package/src/secrets/claude-credentials-json.ts +129 -0
- package/src/secrets/export-claude-credentials-file.ts +279 -0
- package/src/secrets/index.ts +10 -0
- package/src/skills/typeclaw-claude-code/SKILL.md +5 -4
- package/src/skills/typeclaw-claude-code/references/auth-flow.md +35 -0
package/package.json
CHANGED
|
@@ -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
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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.
|
|
59
|
-
'`channel_reply` / `channel_send` in the same
|
|
60
|
-
'
|
|
61
|
-
'
|
|
62
|
-
'
|
|
63
|
-
'this
|
|
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 === '
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
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
|
|
113
|
-
'
|
|
114
|
-
'
|
|
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
|
|
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
|
-
|
|
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
|
package/src/channels/router.ts
CHANGED
|
@@ -498,13 +498,15 @@ export type ChannelRouter = {
|
|
|
498
498
|
// turn cannot drop a future legitimate reply.
|
|
499
499
|
//
|
|
500
500
|
// Returns:
|
|
501
|
-
// - 'recorded' —
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
// reply
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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
|
+
}
|
package/src/doctor/checks.ts
CHANGED
|
@@ -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
|
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -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
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
#
|
|
290
|
-
#
|
|
291
|
-
#
|
|
292
|
-
# auth.json
|
|
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 {
|
|
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
|
+
}
|
package/src/secrets/index.ts
CHANGED
|
@@ -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.
|
|
40
|
-
2. **
|
|
41
|
-
3. **User has
|
|
42
|
-
4. **User
|
|
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:
|