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,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
|
|
|
@@ -33,7 +38,27 @@ export type BuildDockerfileOptions = {
|
|
|
33
38
|
// self-heals: it spawns Xvfb (and exports DISPLAY) if the binary is on
|
|
34
39
|
// PATH, and execs the agent directly otherwise. See APT_FEATURES.xvfb
|
|
35
40
|
// below and `buildEntrypointShim`.
|
|
36
|
-
|
|
41
|
+
// `bubblewrap` ships the `bwrap(1)` setuid-less namespace sandboxer. It is
|
|
42
|
+
// included in baseline (not behind a toggle) because per-tool sandboxing of
|
|
43
|
+
// agent bash calls is a runtime concern resolved by the agent, not by the
|
|
44
|
+
// agent author. See `src/sandbox/` for the bwrap command builder, and
|
|
45
|
+
// `docs/internals/sandbox.mdx` for why bwrap is the right
|
|
46
|
+
// shape for per-call isolation inside an already-containerized agent. The
|
|
47
|
+
// outer container's `--security-opt seccomp=unconfined` (added in the same
|
|
48
|
+
// commit as this line; see `src/container/start.ts:planStart`) is what lets
|
|
49
|
+
// bwrap create user/pid/mount namespaces from inside Docker. Without that
|
|
50
|
+
// flag the seccomp default profile blocks `unshare(CLONE_NEWUSER)` and bwrap
|
|
51
|
+
// fails at startup. The two changes are load-bearing together — do not drop
|
|
52
|
+
// one without the other.
|
|
53
|
+
const BASELINE_APT_PACKAGES = [
|
|
54
|
+
'git',
|
|
55
|
+
'ca-certificates',
|
|
56
|
+
'curl',
|
|
57
|
+
'gnupg',
|
|
58
|
+
'iptables',
|
|
59
|
+
'util-linux',
|
|
60
|
+
'bubblewrap',
|
|
61
|
+
] as const
|
|
37
62
|
|
|
38
63
|
// curl-impersonate is the only currently-working way to query DuckDuckGo from
|
|
39
64
|
// a non-browser client on residential IPs in 2026. DDG fingerprints incoming
|
|
@@ -283,13 +308,19 @@ set -eu
|
|
|
283
308
|
# no inbound exposure anyway.
|
|
284
309
|
# link_persistent_home_files symlinks credential files that tools write
|
|
285
310
|
# to $HOME into a bind-mounted location so they survive container
|
|
286
|
-
# restarts. The
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
#
|
|
290
|
-
#
|
|
291
|
-
#
|
|
292
|
-
# auth.json
|
|
311
|
+
# restarts. The container's $HOME (/root by default) lives on Docker's
|
|
312
|
+
# writable overlay and is wiped on every \`stop\`+\`start\` cycle, so
|
|
313
|
+
# without this symlink the operator would have to re-paste credentials
|
|
314
|
+
# after every restart.
|
|
315
|
+
#
|
|
316
|
+
# Two files are linked today, both following the same contract:
|
|
317
|
+
# - ~/.codex/auth.json — Codex CLI rotates OAuth tokens in place by
|
|
318
|
+
# rewriting auth.json with refreshed credentials.
|
|
319
|
+
# - $CLAUDE_CONFIG_DIR/.credentials.json, or ~/.claude/.credentials.json
|
|
320
|
+
# by default — Claude Code rotates OAuth tokens in place by rewriting
|
|
321
|
+
# .credentials.json on every successful refresh (anthropics/claude-code
|
|
322
|
+
# #53063). Linux/Windows path; macOS uses the Keychain entry "Claude
|
|
323
|
+
# Code-credentials" with the same JSON shape.
|
|
293
324
|
#
|
|
294
325
|
# The persist root lives under /agent/.typeclaw/home/ (bind-mounted
|
|
295
326
|
# from the agent folder via the -v <cwd>:/agent flag in start.ts).
|
|
@@ -329,6 +360,12 @@ link_persistent_home_files() {
|
|
|
329
360
|
persist_root="\${TYPECLAW_PERSIST_HOME_ROOT:-/agent/.typeclaw/home}"
|
|
330
361
|
mkdir -p "$persist_root/.codex" "$HOME/.codex"
|
|
331
362
|
ln -sfn "$persist_root/.codex/auth.json" "$HOME/.codex/auth.json"
|
|
363
|
+
claude_config_dir="\${CLAUDE_CONFIG_DIR:-}"
|
|
364
|
+
if [ -z "$claude_config_dir" ]; then
|
|
365
|
+
claude_config_dir="$HOME/${CLAUDE_DEFAULT_CONFIG_DIR_NAME}"
|
|
366
|
+
fi
|
|
367
|
+
mkdir -p "$persist_root/${CLAUDE_DEFAULT_CONFIG_DIR_NAME}" "$claude_config_dir"
|
|
368
|
+
ln -sfn "$persist_root/${CLAUDE_CREDENTIALS_RELATIVE_PATH}" "$claude_config_dir/${CLAUDE_CREDENTIALS_FILE_NAME}"
|
|
332
369
|
}
|
|
333
370
|
|
|
334
371
|
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,35 @@
|
|
|
1
|
+
import { SandboxUnavailableError } from './errors'
|
|
2
|
+
|
|
3
|
+
// Cached because the binary cannot appear or disappear during a single
|
|
4
|
+
// process lifetime, and a probe per bash call is wasted work. Keyed by the
|
|
5
|
+
// resolved bwrap path so a test (or a consumer pinning a non-default path)
|
|
6
|
+
// re-probes instead of reading another path's cached result.
|
|
7
|
+
const availabilityCache = new Map<string, boolean>()
|
|
8
|
+
|
|
9
|
+
export async function ensureBwrapAvailable(options?: { bwrapPath?: string }): Promise<void> {
|
|
10
|
+
const bwrap = options?.bwrapPath ?? 'bwrap'
|
|
11
|
+
const cached = availabilityCache.get(bwrap)
|
|
12
|
+
if (cached === true) return
|
|
13
|
+
if (cached === false) throw new SandboxUnavailableError()
|
|
14
|
+
|
|
15
|
+
const available = await probe(bwrap)
|
|
16
|
+
availabilityCache.set(bwrap, available)
|
|
17
|
+
if (!available) throw new SandboxUnavailableError()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function probe(bwrap: string): Promise<boolean> {
|
|
21
|
+
// Bun.spawn throws synchronously with ENOENT when the binary is not on
|
|
22
|
+
// PATH, rather than resolving with a non-zero exit code — so the
|
|
23
|
+
// "not installed" case lands in the catch, not in proc.exitCode.
|
|
24
|
+
try {
|
|
25
|
+
const proc = Bun.spawn([bwrap, '--version'], { stdout: 'ignore', stderr: 'ignore' })
|
|
26
|
+
await proc.exited
|
|
27
|
+
return proc.exitCode === 0
|
|
28
|
+
} catch {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function _resetBwrapAvailabilityCacheForTests(): void {
|
|
34
|
+
availabilityCache.clear()
|
|
35
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { SandboxPolicyError } from './errors'
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_SANDBOX_ENV,
|
|
4
|
+
type SandboxCommandFilter,
|
|
5
|
+
type SandboxEnvPolicy,
|
|
6
|
+
type SandboxMount,
|
|
7
|
+
type SandboxPolicy,
|
|
8
|
+
} from './policy'
|
|
9
|
+
import { formatCommand } from './quote'
|
|
10
|
+
|
|
11
|
+
export type SandboxedCommand = {
|
|
12
|
+
argv: string[]
|
|
13
|
+
commandString: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Pure: no I/O, no bwrap availability probe (that is `ensureBwrapAvailable`'s
|
|
17
|
+
// job). Given a bash command and a policy, returns the bwrap-wrapped argv plus
|
|
18
|
+
// a shell-quoted rendering of it. Knows nothing about subagents, origins, or
|
|
19
|
+
// the agent runtime — a consumer resolves a policy from whatever context it
|
|
20
|
+
// has and calls this. Throws SandboxPolicyError only when the consumer opted
|
|
21
|
+
// into the command-filter knobs and the command violates them.
|
|
22
|
+
export function buildSandboxedCommand(command: string, policy: SandboxPolicy = {}): SandboxedCommand {
|
|
23
|
+
if (policy.commandFilter !== undefined) {
|
|
24
|
+
applyCommandFilter(command, policy.commandFilter)
|
|
25
|
+
}
|
|
26
|
+
const argv = buildArgv(command, policy)
|
|
27
|
+
return { argv, commandString: formatCommand(argv) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildArgv(command: string, policy: SandboxPolicy): string[] {
|
|
31
|
+
const bwrap = policy.bwrapPath ?? 'bwrap'
|
|
32
|
+
const argv: string[] = [bwrap, '--unshare-all']
|
|
33
|
+
|
|
34
|
+
if (policy.network === 'inherit') {
|
|
35
|
+
// --unshare-all already unshared the net namespace; --share-net rejoins
|
|
36
|
+
// the outer container's network. Other namespaces (user/pid/mount/ipc/
|
|
37
|
+
// uts/cgroup) stay unshared. Default ('none' / undefined) leaves the net
|
|
38
|
+
// namespace isolated — prompt-injected bash cannot exfiltrate over the
|
|
39
|
+
// network without the consumer explicitly opting in.
|
|
40
|
+
argv.push('--share-net')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const proc = policy.process ?? {}
|
|
44
|
+
if (proc.newSession !== false) {
|
|
45
|
+
// Drops the controlling terminal so the contained process cannot push
|
|
46
|
+
// input back into the agent's tty via TIOCSTI. Mandated by
|
|
47
|
+
// docs/internals/sandbox.mdx. Harmless for a one-shot `bash -c`.
|
|
48
|
+
argv.push('--new-session')
|
|
49
|
+
}
|
|
50
|
+
if (proc.dieWithParent !== false) {
|
|
51
|
+
argv.push('--die-with-parent')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
argv.push('--clearenv')
|
|
55
|
+
for (const [key, value] of Object.entries(resolveEnv(policy.env))) {
|
|
56
|
+
argv.push('--setenv', key, value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
argv.push('--ro-bind', '/usr', '/usr', '--ro-bind', '/etc', '/etc', '--dev', '/dev', '--tmpfs', '/tmp')
|
|
60
|
+
|
|
61
|
+
if ((policy.proc ?? 'tmpfs') === 'tmpfs') {
|
|
62
|
+
// --tmpfs /proc, never --proc /proc (OrbStack's kernel blocks
|
|
63
|
+
// mount("proc",...) from user namespaces) and never --dev-bind /proc /proc
|
|
64
|
+
// (leaks the outer container's /proc/N/environ — including
|
|
65
|
+
// FIREWORKS_API_KEY — into the sandbox). See sandbox.mdx.
|
|
66
|
+
argv.push('--tmpfs', '/proc')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const mount of policy.mounts ?? []) {
|
|
70
|
+
appendMount(argv, mount)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (policy.cwd !== undefined) {
|
|
74
|
+
argv.push('--chdir', policy.cwd)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
argv.push('bash', '-c', command)
|
|
78
|
+
return argv
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function appendMount(argv: string[], mount: SandboxMount): void {
|
|
82
|
+
switch (mount.type) {
|
|
83
|
+
case 'ro-bind':
|
|
84
|
+
argv.push('--ro-bind', mount.source, mount.dest)
|
|
85
|
+
return
|
|
86
|
+
case 'bind':
|
|
87
|
+
argv.push('--bind', mount.source, mount.dest)
|
|
88
|
+
return
|
|
89
|
+
case 'tmpfs':
|
|
90
|
+
argv.push('--tmpfs', mount.dest)
|
|
91
|
+
return
|
|
92
|
+
case 'dev':
|
|
93
|
+
argv.push('--dev', mount.dest)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveEnv(env: SandboxEnvPolicy | undefined): Record<string, string> {
|
|
99
|
+
const resolved: Record<string, string> = { ...DEFAULT_SANDBOX_ENV, ...env?.set }
|
|
100
|
+
for (const key of env?.passthrough ?? []) {
|
|
101
|
+
const value = process.env[key]
|
|
102
|
+
if (value !== undefined) resolved[key] = value
|
|
103
|
+
}
|
|
104
|
+
return resolved
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Token-boundary match: the normalized command must equal a prefix exactly or
|
|
108
|
+
// start with `prefix + ' '`. Substring matching would let `git-evil ...` slip
|
|
109
|
+
// past a `git` prefix; this does not.
|
|
110
|
+
const ALLOWLIST_WHITESPACE = /\s+/g
|
|
111
|
+
const FORBIDDEN_METACHARS = /[;&|`$()<>\\\n]/
|
|
112
|
+
|
|
113
|
+
function applyCommandFilter(command: string, filter: SandboxCommandFilter): void {
|
|
114
|
+
if (filter.rejectShellMetacharacters === true && FORBIDDEN_METACHARS.test(command)) {
|
|
115
|
+
throw new SandboxPolicyError(
|
|
116
|
+
'command contains a forbidden shell metacharacter. This policy only permits simple commands without ; & | ` $ ( ) < > \\ or newlines.',
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
if (filter.allowPrefixes !== undefined) {
|
|
120
|
+
const normalized = command.trim().replace(ALLOWLIST_WHITESPACE, ' ')
|
|
121
|
+
const matched = filter.allowPrefixes.some((p) => normalized === p || normalized.startsWith(`${p} `))
|
|
122
|
+
if (!matched) {
|
|
123
|
+
throw new SandboxPolicyError(
|
|
124
|
+
`command does not match any allowed prefix. Allowed: ${filter.allowPrefixes.join(', ')}`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export class SandboxUnavailableError extends Error {
|
|
2
|
+
override readonly name = 'SandboxUnavailableError'
|
|
3
|
+
constructor() {
|
|
4
|
+
super(
|
|
5
|
+
'sandbox unavailable: bwrap binary not found on PATH. Refusing to run a command that requires sandboxing without the kernel boundary in place.',
|
|
6
|
+
)
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Raised by the optional command-filter knobs (allowPrefixes,
|
|
11
|
+
// rejectShellMetacharacters). These are consumer-opt-in restrictions layered
|
|
12
|
+
// ABOVE the always-on kernel containment, so a rejection here is a policy
|
|
13
|
+
// decision the consumer asked for — not a failure of the sandbox itself. The
|
|
14
|
+
// message is phrased for the model to read and self-correct from.
|
|
15
|
+
export class SandboxPolicyError extends Error {
|
|
16
|
+
override readonly name = 'SandboxPolicyError'
|
|
17
|
+
constructor(reason: string) {
|
|
18
|
+
super(`sandbox policy rejected command: ${reason}`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { buildSandboxedCommand, type SandboxedCommand } from './build'
|
|
2
|
+
export { ensureBwrapAvailable } from './availability'
|
|
3
|
+
export { formatCommand, shellQuote } from './quote'
|
|
4
|
+
export { SandboxPolicyError, SandboxUnavailableError } from './errors'
|
|
5
|
+
export {
|
|
6
|
+
DEFAULT_SANDBOX_ENV,
|
|
7
|
+
type SandboxCommandFilter,
|
|
8
|
+
type SandboxEnvPolicy,
|
|
9
|
+
type SandboxMount,
|
|
10
|
+
type SandboxNetwork,
|
|
11
|
+
type SandboxPolicy,
|
|
12
|
+
type SandboxProcessPolicy,
|
|
13
|
+
type SandboxProcStrategy,
|
|
14
|
+
} from './policy'
|