typeclaw 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- 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/discord-bot.ts +8 -1
- package/src/channels/adapters/github/inbound.ts +44 -5
- package/src/channels/adapters/github/index.ts +32 -0
- package/src/channels/adapters/kakaotalk-format.ts +239 -0
- package/src/channels/adapters/kakaotalk.ts +54 -5
- package/src/channels/adapters/telegram-bot.ts +11 -1
- package/src/channels/router.ts +152 -28
- package/src/channels/types.ts +22 -0
- package/src/config/providers.ts +17 -4
- package/src/container/start.ts +17 -0
- package/src/doctor/channel-checks.ts +328 -0
- package/src/doctor/checks.ts +2 -0
- package/src/init/dockerfile.ts +45 -8
- package/src/run/index.ts +18 -1
- package/src/sandbox/availability.ts +35 -0
- package/src/sandbox/build.ts +128 -0
- package/src/sandbox/errors.ts +20 -0
- package/src/sandbox/index.ts +14 -0
- package/src/sandbox/policy.ts +47 -0
- package/src/sandbox/quote.ts +18 -0
- 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-channel-kakaotalk/SKILL.md +11 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +5 -4
- package/src/skills/typeclaw-claude-code/references/auth-flow.md +35 -0
- package/typeclaw.schema.json +2 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type SandboxMount =
|
|
2
|
+
| { type: 'ro-bind'; source: string; dest: string }
|
|
3
|
+
| { type: 'bind'; source: string; dest: string }
|
|
4
|
+
| { type: 'tmpfs'; dest: string }
|
|
5
|
+
| { type: 'dev'; dest: string }
|
|
6
|
+
|
|
7
|
+
export type SandboxNetwork = 'none' | 'inherit'
|
|
8
|
+
|
|
9
|
+
export type SandboxProcStrategy = 'tmpfs' | 'none'
|
|
10
|
+
|
|
11
|
+
export type SandboxEnvPolicy = {
|
|
12
|
+
set?: Record<string, string>
|
|
13
|
+
passthrough?: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type SandboxCommandFilter = {
|
|
17
|
+
allowPrefixes?: string[]
|
|
18
|
+
rejectShellMetacharacters?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SandboxProcessPolicy = {
|
|
22
|
+
newSession?: boolean
|
|
23
|
+
dieWithParent?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SandboxPolicy = {
|
|
27
|
+
bwrapPath?: string
|
|
28
|
+
cwd?: string
|
|
29
|
+
mounts?: SandboxMount[]
|
|
30
|
+
network?: SandboxNetwork
|
|
31
|
+
env?: SandboxEnvPolicy
|
|
32
|
+
commandFilter?: SandboxCommandFilter
|
|
33
|
+
process?: SandboxProcessPolicy
|
|
34
|
+
proc?: SandboxProcStrategy
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The env the sandbox always re-introduces after `--clearenv`. Anything not
|
|
38
|
+
// listed here (or explicitly named in `env.set` / `env.passthrough` by the
|
|
39
|
+
// consumer) is invisible inside the sandbox. This is the load-bearing leak
|
|
40
|
+
// guard: the container env holds FIREWORKS_API_KEY and GH_TOKEN, and env
|
|
41
|
+
// inheritance is the single highest-risk exfil path for prompt-injected bash.
|
|
42
|
+
// HOME points at /tmp because the sandbox mounts /tmp as a fresh tmpfs.
|
|
43
|
+
export const DEFAULT_SANDBOX_ENV: Record<string, string> = {
|
|
44
|
+
PATH: '/usr/local/bin:/usr/bin:/bin',
|
|
45
|
+
HOME: '/tmp',
|
|
46
|
+
LANG: 'C.UTF-8',
|
|
47
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// POSIX shell quoting for rendering a bwrap argv array into a single
|
|
2
|
+
// `bash -c`-safe string. Today's bash tool accepts a string `command` slot
|
|
3
|
+
// (`mutableArgs.command`), so the sandbox primitive renders its canonical
|
|
4
|
+
// argv into a quoted string the agent runtime can drop in unchanged.
|
|
5
|
+
//
|
|
6
|
+
// This is a local copy of the same helper in `src/update/index.ts`. It is
|
|
7
|
+
// deliberately not promoted to a shared module yet: two call sites do not
|
|
8
|
+
// justify the coupling, and this primitive is meant to stand alone with zero
|
|
9
|
+
// imports from the rest of the tree. Promote to `src/shared/shell.ts` only
|
|
10
|
+
// when a third independent consumer appears.
|
|
11
|
+
export function shellQuote(arg: string): string {
|
|
12
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(arg)) return arg
|
|
13
|
+
return `'${arg.replaceAll("'", "'\\''")}'`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatCommand(argv: readonly string[]): string {
|
|
17
|
+
return argv.map(shellQuote).join(' ')
|
|
18
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { ProviderCredential } from './schema'
|
|
2
|
+
|
|
3
|
+
// Emit the on-disk shape Claude Code consumes at ~/.claude/.credentials.json
|
|
4
|
+
// (Linux/Windows; macOS keeps this same JSON inside the Keychain entry
|
|
5
|
+
// "Claude Code-credentials"). The single required top-level key is
|
|
6
|
+
// `claudeAiOauth`. Field names use camelCase, not snake_case — diverging
|
|
7
|
+
// from Codex CLI's `tokens.access_token` shape but matching every
|
|
8
|
+
// third-party Claude Code integration we surveyed (ATLAS_OS, OmniRoute,
|
|
9
|
+
// jcode, paperclip, opencode-claude-auth).
|
|
10
|
+
//
|
|
11
|
+
// `expiresAt` is MILLISECONDS since epoch, not seconds and not ISO. The
|
|
12
|
+
// CLI carries no top-level expiry field outside `claudeAiOauth` — token
|
|
13
|
+
// expiry is recorded both as `expiresAt` here and embedded in the JWT
|
|
14
|
+
// `exp` claim of `accessToken`. The runtime exporter (export-claude-
|
|
15
|
+
// credentials-file.ts) uses the JWT exp for its newer-wins compare,
|
|
16
|
+
// mirroring the Codex CLI exporter's approach, because `expiresAt` is
|
|
17
|
+
// the field Claude Code itself rewrites on in-place refresh.
|
|
18
|
+
//
|
|
19
|
+
// `mcpOAuth` (MCP server OAuth state) may coexist alongside
|
|
20
|
+
// `claudeAiOauth` in the same file. This emitter accepts an optional
|
|
21
|
+
// `preserveMcpOAuth` block so callers that read-merge-write the file
|
|
22
|
+
// don't drop unrelated state. Codex CLI's file is fully owned by the
|
|
23
|
+
// OAuth credential and has no equivalent; this is the one extra
|
|
24
|
+
// complication versus emitCodexAuthJson.
|
|
25
|
+
export type ClaudeAiOauthBlock = {
|
|
26
|
+
accessToken: string
|
|
27
|
+
refreshToken: string
|
|
28
|
+
expiresAt: number
|
|
29
|
+
scopes?: string[]
|
|
30
|
+
subscriptionType?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type EmitClaudeCredentialsJsonOptions = {
|
|
34
|
+
preserveMcpOAuth?: unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function emitClaudeCredentialsJson(
|
|
38
|
+
credential: ProviderCredential,
|
|
39
|
+
options: EmitClaudeCredentialsJsonOptions = {},
|
|
40
|
+
): string {
|
|
41
|
+
if (credential.type !== 'oauth') {
|
|
42
|
+
throw new Error('emitClaudeCredentialsJson only accepts oauth-typed credentials')
|
|
43
|
+
}
|
|
44
|
+
const fields = credential as ProviderCredential & {
|
|
45
|
+
access?: unknown
|
|
46
|
+
refresh?: unknown
|
|
47
|
+
expires?: unknown
|
|
48
|
+
scopes?: unknown
|
|
49
|
+
subscriptionType?: unknown
|
|
50
|
+
}
|
|
51
|
+
const access = fields.access
|
|
52
|
+
const refresh = fields.refresh
|
|
53
|
+
if (typeof access !== 'string' || access.length === 0) {
|
|
54
|
+
throw new Error('credential is missing a non-empty `access` field')
|
|
55
|
+
}
|
|
56
|
+
if (typeof refresh !== 'string' || refresh.length === 0) {
|
|
57
|
+
throw new Error('credential is missing a non-empty `refresh` field')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Resolution order for `expiresAt`:
|
|
61
|
+
// 1. `expires` on the credential (pi-ai writes absolute ms epoch here).
|
|
62
|
+
// 2. JWT `exp` claim decoded from `access`.
|
|
63
|
+
// 3. 0 — Claude Code treats a missing/zero expiry as "expired" and
|
|
64
|
+
// triggers an immediate refresh on next use, which is the safe
|
|
65
|
+
// fallback when neither source is available.
|
|
66
|
+
const expiresAt = readExpiryMs(fields, access)
|
|
67
|
+
|
|
68
|
+
const claudeAiOauth: ClaudeAiOauthBlock = {
|
|
69
|
+
accessToken: access,
|
|
70
|
+
refreshToken: refresh,
|
|
71
|
+
expiresAt,
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(fields.scopes) && fields.scopes.every((s): s is string => typeof s === 'string')) {
|
|
74
|
+
claudeAiOauth.scopes = fields.scopes
|
|
75
|
+
}
|
|
76
|
+
if (typeof fields.subscriptionType === 'string' && fields.subscriptionType.length > 0) {
|
|
77
|
+
claudeAiOauth.subscriptionType = fields.subscriptionType
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Read-merge-write: preserve any existing `mcpOAuth` block the caller
|
|
81
|
+
// supplied. The emitter doesn't read disk itself (that's the exporter's
|
|
82
|
+
// job); the caller passes whatever it found at the existing file path.
|
|
83
|
+
const out: Record<string, unknown> = { claudeAiOauth }
|
|
84
|
+
if (options.preserveMcpOAuth !== undefined) {
|
|
85
|
+
out['mcpOAuth'] = options.preserveMcpOAuth
|
|
86
|
+
}
|
|
87
|
+
return `${JSON.stringify(out, null, 2)}\n`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Extracts the JWT `exp` claim (seconds since epoch) and converts to ms.
|
|
91
|
+
// Used by the runtime exporter's newer-wins compare and by emit's
|
|
92
|
+
// `expiresAt` fallback. Returns null on any decode failure; the caller
|
|
93
|
+
// treats that as "unknown freshness". Logic mirrors
|
|
94
|
+
// decodeCodexAccessTokenExpiryMs verbatim — Claude Code OAuth access
|
|
95
|
+
// tokens are standard JWTs with the same base64url-encoded payload
|
|
96
|
+
// shape Codex uses.
|
|
97
|
+
export function decodeClaudeAccessTokenExpiryMs(accessToken: string): number | null {
|
|
98
|
+
const parts = accessToken.split('.')
|
|
99
|
+
if (parts.length !== 3) return null
|
|
100
|
+
const middle = parts[1] ?? ''
|
|
101
|
+
if (middle === '') return null
|
|
102
|
+
const normalized = middle.replace(/-/g, '+').replace(/_/g, '/')
|
|
103
|
+
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
|
104
|
+
let payload: Record<string, unknown>
|
|
105
|
+
try {
|
|
106
|
+
const decoded = typeof atob === 'function' ? atob(padded) : Buffer.from(padded, 'base64').toString('utf8')
|
|
107
|
+
const parsed: unknown = JSON.parse(decoded)
|
|
108
|
+
if (!isPlainObject(parsed)) return null
|
|
109
|
+
payload = parsed
|
|
110
|
+
} catch {
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
const exp = payload['exp']
|
|
114
|
+
if (typeof exp !== 'number' || !Number.isFinite(exp)) return null
|
|
115
|
+
return Math.floor(exp * 1000)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readExpiryMs(fields: { expires?: unknown }, accessToken: string): number {
|
|
119
|
+
if (typeof fields.expires === 'number' && Number.isFinite(fields.expires)) {
|
|
120
|
+
return fields.expires
|
|
121
|
+
}
|
|
122
|
+
const fromJwt = decodeClaudeAccessTokenExpiryMs(accessToken)
|
|
123
|
+
if (fromJwt !== null) return fromJwt
|
|
124
|
+
return 0
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
128
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
129
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readlinkSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
statSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
13
|
+
|
|
14
|
+
import { decodeClaudeAccessTokenExpiryMs, emitClaudeCredentialsJson } from './claude-credentials-json'
|
|
15
|
+
import type { ProviderCredential, Providers } from './schema'
|
|
16
|
+
import { SecretsBackend } from './storage'
|
|
17
|
+
|
|
18
|
+
const FILE_MODE = 0o600
|
|
19
|
+
const DIR_MODE = 0o700
|
|
20
|
+
export const CLAUDE_CREDENTIALS_FILE_NAME = '.credentials.json'
|
|
21
|
+
export const CLAUDE_DEFAULT_CONFIG_DIR_NAME = '.claude'
|
|
22
|
+
export const CLAUDE_CREDENTIALS_RELATIVE_PATH = join(CLAUDE_DEFAULT_CONFIG_DIR_NAME, CLAUDE_CREDENTIALS_FILE_NAME)
|
|
23
|
+
|
|
24
|
+
export type ExportClaudeCredentialsFileResult =
|
|
25
|
+
| { action: 'skipped'; reason: SkipReason }
|
|
26
|
+
| { action: 'wrote'; path: string }
|
|
27
|
+
| { action: 'failed'; reason: string }
|
|
28
|
+
|
|
29
|
+
export type SkipReason =
|
|
30
|
+
| 'claude-code-disabled'
|
|
31
|
+
| 'no-anthropic-credential'
|
|
32
|
+
| 'credential-not-oauth'
|
|
33
|
+
| 'on-disk-is-fresher'
|
|
34
|
+
|
|
35
|
+
export type ExportClaudeCredentialsFileOptions = {
|
|
36
|
+
claudeCodeEnabled: boolean
|
|
37
|
+
providers: Providers
|
|
38
|
+
homeDir?: string
|
|
39
|
+
configDir?: string
|
|
40
|
+
now?: () => number
|
|
41
|
+
log?: (message: string) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Writes typeclaw's anthropic OAuth credential to
|
|
45
|
+
// $CLAUDE_CONFIG_DIR/.credentials.json (or $HOME/.claude/.credentials.json
|
|
46
|
+
// by default) when it's safe to do so. The Dockerfile entrypoint shim
|
|
47
|
+
// symlinks the same resolved credentials path to
|
|
48
|
+
// /agent/.typeclaw/home/.claude/.credentials.json on every boot, so the
|
|
49
|
+
// write follows the symlink and lands on the persistent host-side path —
|
|
50
|
+
// same contract as exportCodexAuthFile.
|
|
51
|
+
//
|
|
52
|
+
// Three guards, cheapest first. The first two return without ever touching
|
|
53
|
+
// the filesystem, which keeps the 90% case (users who don't enable
|
|
54
|
+
// Claude Code) at zero overhead on every container start.
|
|
55
|
+
export function exportClaudeCredentialsFileIfApplicable(
|
|
56
|
+
options: ExportClaudeCredentialsFileOptions,
|
|
57
|
+
): ExportClaudeCredentialsFileResult {
|
|
58
|
+
if (!options.claudeCodeEnabled) return { action: 'skipped', reason: 'claude-code-disabled' }
|
|
59
|
+
|
|
60
|
+
const credential = options.providers['anthropic']
|
|
61
|
+
if (credential === undefined) return { action: 'skipped', reason: 'no-anthropic-credential' }
|
|
62
|
+
if (credential.type !== 'oauth') return { action: 'skipped', reason: 'credential-not-oauth' }
|
|
63
|
+
|
|
64
|
+
const targetPath = resolveClaudeCredentialsPath({
|
|
65
|
+
...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
|
|
66
|
+
...(options.configDir !== undefined ? { configDir: options.configDir } : {}),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const existing = readExisting(targetPath)
|
|
71
|
+
if (!shouldOverwrite(existing, credential, options.now ?? Date.now)) {
|
|
72
|
+
return { action: 'skipped', reason: 'on-disk-is-fresher' }
|
|
73
|
+
}
|
|
74
|
+
const mcpOAuthOpt = existing?.mcpOAuth !== undefined ? { preserveMcpOAuth: existing.mcpOAuth } : {}
|
|
75
|
+
const contents = emitClaudeCredentialsJson(credential, mcpOAuthOpt)
|
|
76
|
+
writeAtomic(targetPath, contents)
|
|
77
|
+
return { action: 'wrote', path: targetPath }
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
80
|
+
options.log?.(`exportClaudeCredentialsFile: ${reason}`)
|
|
81
|
+
return { action: 'failed', reason }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type ExistingFile = {
|
|
86
|
+
onDiskAccessToken: string | null
|
|
87
|
+
onDiskExpiresAt: number | null
|
|
88
|
+
mcpOAuth: unknown
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Read once, parse once. Returns null when the file is missing OR
|
|
92
|
+
// unparseable. Returning null short-circuits both branches of the
|
|
93
|
+
// overwrite decision (the no-disk-file recovery path) AND drops any
|
|
94
|
+
// non-recoverable mcpOAuth state — but if the file is unparseable
|
|
95
|
+
// there's nothing to recover anyway. When parsing succeeds, we hand
|
|
96
|
+
// back the access token (for the newer-wins compare) and the raw
|
|
97
|
+
// mcpOAuth block (for read-merge-write preservation).
|
|
98
|
+
function readExisting(targetPath: string): ExistingFile | null {
|
|
99
|
+
let raw: string
|
|
100
|
+
try {
|
|
101
|
+
raw = readFileSync(targetPath, 'utf8')
|
|
102
|
+
} catch {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
let parsed: unknown
|
|
106
|
+
try {
|
|
107
|
+
parsed = JSON.parse(raw)
|
|
108
|
+
} catch {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
112
|
+
const obj = parsed as Record<string, unknown>
|
|
113
|
+
const claudeBlock = obj['claudeAiOauth']
|
|
114
|
+
let onDiskAccessToken: string | null = null
|
|
115
|
+
let onDiskExpiresAt: number | null = null
|
|
116
|
+
if (typeof claudeBlock === 'object' && claudeBlock !== null) {
|
|
117
|
+
const claudeObj = claudeBlock as Record<string, unknown>
|
|
118
|
+
const access = claudeObj['accessToken']
|
|
119
|
+
if (typeof access === 'string' && access.length > 0) onDiskAccessToken = access
|
|
120
|
+
const expiresAt = claudeObj['expiresAt']
|
|
121
|
+
if (typeof expiresAt === 'number' && Number.isFinite(expiresAt) && expiresAt > 0) {
|
|
122
|
+
onDiskExpiresAt = expiresAt
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { onDiskAccessToken, onDiskExpiresAt, mcpOAuth: obj['mcpOAuth'] }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Newer-wins: skip the write unless typeclaw's stored credential is
|
|
129
|
+
// strictly fresher than the on-disk expiry. Claude Code rotates tokens
|
|
130
|
+
// in-place (issue #53063 in anthropics/claude-code confirms it rewrites
|
|
131
|
+
// .credentials.json with a fresher accessToken/refreshToken/expiresAt
|
|
132
|
+
// on every successful refresh), so on a restart the file may legitimately
|
|
133
|
+
// be ahead of secrets.json. We must not clobber that.
|
|
134
|
+
//
|
|
135
|
+
// Ties skip: when expiries match there's nothing to gain from a write,
|
|
136
|
+
// and avoiding the I/O keeps the steady state at zero churn.
|
|
137
|
+
//
|
|
138
|
+
// existing === null OR existing.onDiskAccessToken === null OR expiry
|
|
139
|
+
// undecodable from both expiresAt and JWT → return true. That's the "we
|
|
140
|
+
// have a valid credential, the file is unusable, replace it" recovery case.
|
|
141
|
+
function shouldOverwrite(
|
|
142
|
+
existing: ExistingFile | null,
|
|
143
|
+
credential: ProviderCredential & { expires?: unknown; access?: unknown },
|
|
144
|
+
now: () => number,
|
|
145
|
+
): boolean {
|
|
146
|
+
if (existing === null) return true
|
|
147
|
+
if (existing.onDiskAccessToken === null) return true
|
|
148
|
+
const onDiskExpiry = readOnDiskExpiry(existing)
|
|
149
|
+
if (onDiskExpiry === null) return true
|
|
150
|
+
const credentialExpiry = readCredentialExpiry(credential, now)
|
|
151
|
+
return credentialExpiry > onDiskExpiry
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readOnDiskExpiry(existing: ExistingFile): number | null {
|
|
155
|
+
if (existing.onDiskExpiresAt !== null) return existing.onDiskExpiresAt
|
|
156
|
+
if (existing.onDiskAccessToken === null) return null
|
|
157
|
+
return decodeClaudeAccessTokenExpiryMs(existing.onDiskAccessToken)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Resolution order for the credential's expiry:
|
|
161
|
+
// 1. The `expires` field pi-ai writes (absolute ms epoch).
|
|
162
|
+
// 2. The JWT `exp` claim decoded from `access`.
|
|
163
|
+
// 3. Now — guarantees we still write on first boot when the credential
|
|
164
|
+
// lacks both, rather than silently skipping forever.
|
|
165
|
+
function readCredentialExpiry(credential: { expires?: unknown; access?: unknown }, now: () => number): number {
|
|
166
|
+
if (typeof credential.expires === 'number' && Number.isFinite(credential.expires)) {
|
|
167
|
+
return credential.expires
|
|
168
|
+
}
|
|
169
|
+
if (typeof credential.access === 'string') {
|
|
170
|
+
const fromJwt = decodeClaudeAccessTokenExpiryMs(credential.access)
|
|
171
|
+
if (fromJwt !== null) return fromJwt
|
|
172
|
+
}
|
|
173
|
+
return now()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function resolveClaudeCredentialsPath(options: { homeDir?: string; configDir?: string } = {}): string {
|
|
177
|
+
const configDir = resolveClaudeConfigDir(options.configDir)
|
|
178
|
+
if (configDir !== null) return join(configDir, CLAUDE_CREDENTIALS_FILE_NAME)
|
|
179
|
+
return join(options.homeDir ?? homedir(), CLAUDE_CREDENTIALS_RELATIVE_PATH)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function resolveClaudeConfigDir(configDir: string | undefined): string | null {
|
|
183
|
+
const raw = configDir ?? process.env['CLAUDE_CONFIG_DIR']
|
|
184
|
+
const trimmed = raw?.trim()
|
|
185
|
+
return trimmed === undefined || trimmed.length === 0 ? null : trimmed
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Atomic temp-then-rename, mirroring export-codex-auth-file.ts's
|
|
189
|
+
// writeAtomic. The directory is created with 0700 and the file with 0600
|
|
190
|
+
// because the credential carries a long-lived refresh token — leaking it
|
|
191
|
+
// via lax permissions defeats the whole point. The 0600 chmod after
|
|
192
|
+
// rename is belt-and-suspenders: writeFileSync's `mode` is applied at
|
|
193
|
+
// create time, but umask can mask it down on some filesystems.
|
|
194
|
+
//
|
|
195
|
+
// Symlink preservation: the entrypoint shim will install
|
|
196
|
+
// $HOME/.claude/.credentials.json as a symlink to
|
|
197
|
+
// /agent/.typeclaw/home/.claude/.credentials.json. POSIX rename(2)
|
|
198
|
+
// replaces the directory entry at the destination atomically and does
|
|
199
|
+
// NOT follow symlinks, so a naive renameSync against the symlink path
|
|
200
|
+
// would replace the symlink with a regular file, leaving the persistent
|
|
201
|
+
// path empty and Claude Code's in-place token refresh silently lost on
|
|
202
|
+
// every restart. Resolve the symlink target with readlinkSync and rename
|
|
203
|
+
// against the real path so the symlink itself is preserved. The temp
|
|
204
|
+
// file MUST live alongside the real target (same filesystem) because
|
|
205
|
+
// renameSync across filesystems fails with EXDEV.
|
|
206
|
+
function writeAtomic(targetPath: string, contents: string): void {
|
|
207
|
+
const realTarget = resolveSymlinkTarget(targetPath)
|
|
208
|
+
const dir = dirname(realTarget)
|
|
209
|
+
mkdirSync(dir, { recursive: true, mode: DIR_MODE })
|
|
210
|
+
const tmp = `${realTarget}.${process.pid}.${Date.now()}.tmp`
|
|
211
|
+
writeFileSync(tmp, contents, { encoding: 'utf8', mode: FILE_MODE })
|
|
212
|
+
try {
|
|
213
|
+
renameSync(tmp, realTarget)
|
|
214
|
+
} catch (err) {
|
|
215
|
+
try {
|
|
216
|
+
unlinkSync(tmp)
|
|
217
|
+
} catch {
|
|
218
|
+
// best-effort cleanup of the temp file when rename fails
|
|
219
|
+
}
|
|
220
|
+
throw err
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
statSync(realTarget)
|
|
224
|
+
chmodSync(realTarget, FILE_MODE)
|
|
225
|
+
} catch {
|
|
226
|
+
// ignore — file vanished between rename and chmod is benign
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Returns the absolute path renameSync should target. When `path` is a
|
|
231
|
+
// symlink (production: $HOME/.claude/.credentials.json -> /agent/...),
|
|
232
|
+
// returns the resolved absolute target so we write through the link
|
|
233
|
+
// instead of replacing it. Otherwise (tests, or first boot before the
|
|
234
|
+
// shim installs the symlink), returns the path unchanged. readlinkSync
|
|
235
|
+
// throws EINVAL when the path exists but isn't a symlink and ENOENT
|
|
236
|
+
// when nothing is there — both cases fall through to the original path.
|
|
237
|
+
function resolveSymlinkTarget(path: string): string {
|
|
238
|
+
let link: string
|
|
239
|
+
try {
|
|
240
|
+
link = readlinkSync(path)
|
|
241
|
+
} catch {
|
|
242
|
+
return path
|
|
243
|
+
}
|
|
244
|
+
return isAbsolute(link) ? link : resolve(dirname(path), link)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export type ExportClaudeCredentialsFileForAgentOptions = {
|
|
248
|
+
agentDir: string
|
|
249
|
+
claudeCodeEnabled: boolean
|
|
250
|
+
homeDir?: string
|
|
251
|
+
configDir?: string
|
|
252
|
+
log?: (message: string) => void
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Boot-time convenience wrapper for src/run/index.ts. Mirrors
|
|
256
|
+
// exportCodexAuthFileForAgent: takes agentDir, never throws, returns a
|
|
257
|
+
// result the caller can ignore. Secrets-file read failures are caught
|
|
258
|
+
// and surfaced as 'failed' so the agent boot is never blocked by a
|
|
259
|
+
// missing or malformed secrets.json.
|
|
260
|
+
export function exportClaudeCredentialsFileForAgent(
|
|
261
|
+
options: ExportClaudeCredentialsFileForAgentOptions,
|
|
262
|
+
): ExportClaudeCredentialsFileResult {
|
|
263
|
+
if (!options.claudeCodeEnabled) return { action: 'skipped', reason: 'claude-code-disabled' }
|
|
264
|
+
let providers: Providers
|
|
265
|
+
try {
|
|
266
|
+
providers = new SecretsBackend(join(options.agentDir, 'secrets.json')).tryReadProvidersSync()
|
|
267
|
+
} catch (err) {
|
|
268
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
269
|
+
options.log?.(`exportClaudeCredentialsFile: ${reason}`)
|
|
270
|
+
return { action: 'failed', reason }
|
|
271
|
+
}
|
|
272
|
+
return exportClaudeCredentialsFileIfApplicable({
|
|
273
|
+
claudeCodeEnabled: options.claudeCodeEnabled,
|
|
274
|
+
providers,
|
|
275
|
+
...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
|
|
276
|
+
...(options.configDir !== undefined ? { configDir: options.configDir } : {}),
|
|
277
|
+
...(options.log !== undefined ? { log: options.log } : {}),
|
|
278
|
+
})
|
|
279
|
+
}
|
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'
|
|
@@ -11,14 +11,16 @@ This means **you are messaging as a person, not as a bot.** Other participants s
|
|
|
11
11
|
|
|
12
12
|
## What KakaoTalk does NOT support
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
14
|
+
KakaoTalk renders messages as plain text — it has no rich-text formatting. **Write plain text from the start.** The adapter strips common markdown as a safety net before sending (so an accidental `**bold**` won't leak literal asterisks), but treat that as a last-resort guard, not a license to write markdown: the strip removes _markers_, it cannot make formatting-dependent layouts like tables readable. Compose for a plain-text surface and you control the result; lean on the stripper and you get whatever falls out.
|
|
15
|
+
|
|
16
|
+
Specifically, do not rely on any of the following — write the plain-text equivalent yourself:
|
|
17
|
+
|
|
18
|
+
- **Bold / italic / strikethrough** — emphasize with word choice or capitalization (sparingly), not `**asterisks**`.
|
|
19
|
+
- **Headings** — `# H1`, `## H2`, `### H3` carry no visual weight here. Use a short label line or just lead with the point.
|
|
20
|
+
- **Tables** — the stripper cannot rescue a pipe-delimited table; it would collapse into an unreadable line. Use bullet lists or short prose paragraphs instead.
|
|
21
|
+
- **Code fences** — for short snippets, paste the code inline as plain text. For long snippets, summarize and offer to send it via another channel.
|
|
22
|
+
- **Inline code** — just write `foo`, no backticks.
|
|
23
|
+
- **Links with display text** — send the bare URL on its own line; the KakaoTalk client auto-links it. (A `[label](url)` that slips through is reduced to `label (url)`, but a bare URL reads cleaner.)
|
|
22
24
|
- **Mentions** — there is no `@user` syntax that the protocol surfaces. Address people by name in the message body.
|
|
23
25
|
- **Threads / replies-with-quote** — every message is a top-level chat post. There is no per-message reply UI.
|
|
24
26
|
- **Outbound stickers / emoticons** — the KakaoTalk sticker store requires desktop-app purchase flows that the SDK does not replicate. Inbound stickers ARE surfaced (see below), but you cannot send one. If the user asks for a sticker, acknowledge the limit and offer text.
|
|
@@ -106,4 +108,4 @@ The adapter drops every inbound where `event.author_id` equals the logged-in acc
|
|
|
106
108
|
|
|
107
109
|
## When you cannot answer in KakaoTalk
|
|
108
110
|
|
|
109
|
-
If the user asks you to do something the adapter cannot do (
|
|
111
|
+
If the user asks you to do something the adapter cannot do (post in a thread, send a sticker, render a real table), say so plainly. Files are fine — those go through `attachments[]` as described above — but threading, stickers, and rich formatting are real limits. Markdown markers you emit get stripped to plain text automatically, so a stray `**` won't leak; the limit is that nothing renders as formatting, not that it crashes. Acknowledge the limit instead of silently dropping the request.
|
|
@@ -36,10 +36,11 @@ If `claude` is installed but no credential is set up, you have to broker the aut
|
|
|
36
36
|
|
|
37
37
|
**Decision rule, top to bottom:**
|
|
38
38
|
|
|
39
|
-
1.
|
|
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:
|
package/typeclaw.schema.json
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"anthropic/claude-haiku-4-5",
|
|
33
33
|
"anthropic/claude-sonnet-4-6",
|
|
34
34
|
"anthropic/claude-opus-4-7",
|
|
35
|
+
"anthropic/claude-opus-4-8",
|
|
35
36
|
"fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
|
|
36
37
|
"zai/glm-4.5-air",
|
|
37
38
|
"zai/glm-4.6",
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
"anthropic/claude-haiku-4-5",
|
|
60
61
|
"anthropic/claude-sonnet-4-6",
|
|
61
62
|
"anthropic/claude-opus-4-7",
|
|
63
|
+
"anthropic/claude-opus-4-8",
|
|
62
64
|
"fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
|
|
63
65
|
"zai/glm-4.5-air",
|
|
64
66
|
"zai/glm-4.6",
|