typeclaw 0.37.5 → 0.37.7
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/attention-escalation.ts +590 -0
- package/src/agent/plugin-tools.ts +23 -1
- package/src/agent/session-origin.ts +10 -7
- package/src/agent/subagents.ts +2 -0
- package/src/agent/system-prompt.ts +2 -2
- package/src/bundled-plugins/doc-render/index.ts +10 -0
- package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +171 -165
- package/src/bundled-plugins/doc-render/templates/lib.typ +339 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +95 -11
- package/src/bundled-plugins/github-cli-auth/git-command.ts +11 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +68 -7
- package/src/channels/manager.ts +77 -1
- package/src/channels/router.ts +72 -3
- package/src/cli/channel.ts +1 -1
- package/src/cli/compose.ts +11 -2
- package/src/cli/init.ts +8 -1
- package/src/cli/mount.ts +5 -5
- package/src/cli/restart.ts +3 -1
- package/src/cli/start.ts +3 -1
- package/src/cli/ui.ts +13 -0
- package/src/compose/restart.ts +1 -1
- package/src/compose/start.ts +4 -2
- package/src/config/config.ts +202 -9
- package/src/container/start.ts +17 -4
- package/src/cron/consumer.ts +10 -3
- package/src/doctor/checks.ts +13 -1
- package/src/init/dockerfile.ts +62 -11
- package/src/server/command-runner.ts +2 -0
- package/src/server/index.ts +9 -0
package/src/config/config.ts
CHANGED
|
@@ -1168,7 +1168,7 @@ function persistMigratedConfig(cwd: string, json: unknown, applied: readonly Mig
|
|
|
1168
1168
|
}
|
|
1169
1169
|
}
|
|
1170
1170
|
|
|
1171
|
-
export type ValidateConfigResult = { ok: true } | { ok: false; reason: string }
|
|
1171
|
+
export type ValidateConfigResult = { ok: true; warnings?: string[] } | { ok: false; reason: string }
|
|
1172
1172
|
|
|
1173
1173
|
// Missing file → ok (matches `loadMounts` in src/container/up.ts; `isInitialized`
|
|
1174
1174
|
// is the dedicated check for "not initialized"). Present but invalid → fail, so
|
|
@@ -1200,6 +1200,22 @@ export function validateConfig(cwd: string, options: ValidateConfigOptions = {})
|
|
|
1200
1200
|
const parsed = parseConfigJson(raw, { migrate: true, persistTarget: cwd })
|
|
1201
1201
|
if (!parsed.ok) return parsed
|
|
1202
1202
|
|
|
1203
|
+
// Append lines are advisory here — never fatal. The Dockerfile renderer
|
|
1204
|
+
// (renderCustomDockerfileLines) is the enforcement boundary: it STRIPS unsafe
|
|
1205
|
+
// lines so the container still comes up, and a bad line written by the
|
|
1206
|
+
// in-container agent can never brick `typeclaw start`. We surface the same
|
|
1207
|
+
// strip/warn decisions as warnings so the operator sees them pre-build.
|
|
1208
|
+
const warnings: string[] = []
|
|
1209
|
+
const appendLines = parsed.config.docker.file.append
|
|
1210
|
+
for (let i = 0; i < appendLines.length; i++) {
|
|
1211
|
+
const check = validateDockerfileAppendLine(appendLines[i]!)
|
|
1212
|
+
if (!check.ok) {
|
|
1213
|
+
warnings.push(`docker.file.append[${i}] will be stripped on start — ${check.reason}`)
|
|
1214
|
+
continue
|
|
1215
|
+
}
|
|
1216
|
+
if (check.warning) warnings.push(`docker.file.append[${i}] ${check.warning}`)
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1203
1219
|
if (!options.skipMounts) {
|
|
1204
1220
|
for (const mount of parsed.config.mounts) {
|
|
1205
1221
|
const check = validateMount(mount, cwd)
|
|
@@ -1207,7 +1223,7 @@ export function validateConfig(cwd: string, options: ValidateConfigOptions = {})
|
|
|
1207
1223
|
}
|
|
1208
1224
|
}
|
|
1209
1225
|
|
|
1210
|
-
return { ok: true }
|
|
1226
|
+
return warnings.length > 0 ? { ok: true, warnings } : { ok: true }
|
|
1211
1227
|
}
|
|
1212
1228
|
|
|
1213
1229
|
export type ParseConfigJsonResult = { ok: true; config: Config } | { ok: false; reason: string }
|
|
@@ -1253,11 +1269,15 @@ export function parseConfigJson(raw: string, options: ParseConfigJsonOptions = {
|
|
|
1253
1269
|
return { ok: true, config: result.data }
|
|
1254
1270
|
}
|
|
1255
1271
|
|
|
1256
|
-
// Verifies a mount's host path: exists, is a
|
|
1257
|
-
// writable when not declared `readOnly`. Symlinks are
|
|
1258
|
-
// default) so a broken symlink reads as "does not exist".
|
|
1259
|
-
// are
|
|
1260
|
-
//
|
|
1272
|
+
// Verifies a mount's host path: exists, is a regular file or directory, is
|
|
1273
|
+
// readable, and is writable when not declared `readOnly`. Symlinks are
|
|
1274
|
+
// followed (statSync's default) so a broken symlink reads as "does not exist".
|
|
1275
|
+
// File mounts are allowed so credentials and config can be exposed as a single
|
|
1276
|
+
// path (e.g. an SSH private key); sockets, FIFOs, and devices are rejected
|
|
1277
|
+
// because exposing them is an advanced, security-sensitive case we don't take
|
|
1278
|
+
// implicitly. Permission checks are skipped when running as root (uid 0) —
|
|
1279
|
+
// euidaccess returns success regardless, so the test would be vacuous and
|
|
1280
|
+
// inconsistent with non-root.
|
|
1261
1281
|
export function validateMount(mount: Mount, cwd: string): ValidateConfigResult {
|
|
1262
1282
|
const resolved = expandMountPath(mount.path, cwd)
|
|
1263
1283
|
const label = `mount "${mount.name}"`
|
|
@@ -1274,8 +1294,8 @@ export function validateMount(mount: Mount, cwd: string): ValidateConfigResult {
|
|
|
1274
1294
|
return { ok: false, reason: `${label}: cannot stat ${resolved}: ${detail}` }
|
|
1275
1295
|
}
|
|
1276
1296
|
|
|
1277
|
-
if (!stats.isDirectory()) {
|
|
1278
|
-
return { ok: false, reason: `${label}: path ${resolved} is not a directory` }
|
|
1297
|
+
if (!stats.isDirectory() && !stats.isFile()) {
|
|
1298
|
+
return { ok: false, reason: `${label}: path ${resolved} is not a file or directory` }
|
|
1279
1299
|
}
|
|
1280
1300
|
|
|
1281
1301
|
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0
|
|
@@ -1301,6 +1321,179 @@ export function validateMount(mount: Mount, cwd: string): ValidateConfigResult {
|
|
|
1301
1321
|
return { ok: true }
|
|
1302
1322
|
}
|
|
1303
1323
|
|
|
1324
|
+
// FROM/ENTRYPOINT/CMD/MAINTAINER are intentionally excluded — see the
|
|
1325
|
+
// structural blocks in validateDockerfileAppendLine for why.
|
|
1326
|
+
const ALLOWED_APPEND_INSTRUCTIONS = new Set([
|
|
1327
|
+
'RUN',
|
|
1328
|
+
'ENV',
|
|
1329
|
+
'ARG',
|
|
1330
|
+
'LABEL',
|
|
1331
|
+
'COPY',
|
|
1332
|
+
'ADD',
|
|
1333
|
+
'USER',
|
|
1334
|
+
'WORKDIR',
|
|
1335
|
+
'SHELL',
|
|
1336
|
+
'EXPOSE',
|
|
1337
|
+
'VOLUME',
|
|
1338
|
+
'STOPSIGNAL',
|
|
1339
|
+
'HEALTHCHECK',
|
|
1340
|
+
'ONBUILD',
|
|
1341
|
+
])
|
|
1342
|
+
|
|
1343
|
+
// Decode primitives that, paired with dynamic execution on the same line, form
|
|
1344
|
+
// the "decode an opaque blob and run it" anti-pattern that bricked a real build
|
|
1345
|
+
// (an agent base64-decoded the bash entrypoint shim and fed it to python3
|
|
1346
|
+
// exec). Matching is substring/case-insensitive — these are code tokens the
|
|
1347
|
+
// agent emits, not natural-language, so English literals are correct here (cf.
|
|
1348
|
+
// the protocol-token exception in AGENTS.md).
|
|
1349
|
+
const DECODE_PRIMITIVES = ['base64', 'b64decode', 'atob(', 'unhexlify', '.fromhex(', 'xxd -r']
|
|
1350
|
+
|
|
1351
|
+
// True dynamic-execution sinks — language constructs that run a STRING as code.
|
|
1352
|
+
// Deliberately NOT including interpreter flags like `python3 -c`/`node -e`: a
|
|
1353
|
+
// benign `python3 -c "print(base64.b64encode(...))"` legitimately mentions a
|
|
1354
|
+
// decode primitive without ever executing the decoded bytes. The footgun is
|
|
1355
|
+
// decode + a real exec sink (or decode piped to an interpreter, below).
|
|
1356
|
+
const EXEC_PRIMITIVES = ['exec(', 'eval(', 'new function(', 'function(']
|
|
1357
|
+
|
|
1358
|
+
// Decoded stdout piped straight into an interpreter: `base64 -d ... | sh`,
|
|
1359
|
+
// `... | python3`, etc. The pipe is the execution step here, so it pairs with
|
|
1360
|
+
// DECODE_PRIMITIVES independently of the EXEC_PRIMITIVES sinks above.
|
|
1361
|
+
const DECODE_PIPED_TO_INTERPRETER =
|
|
1362
|
+
/\|\s*(?:sudo\s+)?(?:ba)?sh\b|\|\s*(?:sudo\s+)?python3?\b|\|\s*(?:sudo\s+)?(?:node|perl|ruby)\b/i
|
|
1363
|
+
|
|
1364
|
+
// Risky-but-legitimate operator patterns: piping a remote script straight into
|
|
1365
|
+
// a shell, or ADDing a remote URL. Common enough in real build steps that a
|
|
1366
|
+
// hard block would frustrate power users, dangerous enough to flag.
|
|
1367
|
+
const APPEND_WARN_PATTERNS: Array<{ test: RegExp; note: string }> = [
|
|
1368
|
+
{
|
|
1369
|
+
test: /\b(?:curl|wget)\b[^|]*\|\s*(?:sudo\s+)?(?:ba)?sh\b/i,
|
|
1370
|
+
note: 'pipes a remote script directly into a shell (curl|bash); verify the source is trusted',
|
|
1371
|
+
},
|
|
1372
|
+
{
|
|
1373
|
+
test: /<\(\s*(?:curl|wget)\b/i,
|
|
1374
|
+
note: 'executes a remote script via process substitution; verify the source is trusted',
|
|
1375
|
+
},
|
|
1376
|
+
{
|
|
1377
|
+
test: /^ADD\s+https?:\/\//i,
|
|
1378
|
+
note: 'ADD of a remote URL fetches an unpinned artifact at build time; prefer a pinned COPY or checksum-verified RUN',
|
|
1379
|
+
},
|
|
1380
|
+
]
|
|
1381
|
+
|
|
1382
|
+
export type AppendLineCheck =
|
|
1383
|
+
| { ok: true; warning?: string }
|
|
1384
|
+
// `structural` blocks are unconditional (they break Dockerfile generation);
|
|
1385
|
+
// `semantic` blocks are waivable via the host env override.
|
|
1386
|
+
| { ok: false; reason: string; kind: 'structural' | 'semantic' }
|
|
1387
|
+
|
|
1388
|
+
// Pure, side-effect-free validator for ONE docker.file.append entry. The newline
|
|
1389
|
+
// rejection stays in the zod schema (dockerfileLineSchema) so it fires on every
|
|
1390
|
+
// parse including the agent's own config-write guard; this adds the contextual
|
|
1391
|
+
// policy the schema can't express cheaply. Returns the first problem found.
|
|
1392
|
+
export function validateDockerfileAppendLine(line: string): AppendLineCheck {
|
|
1393
|
+
const trimmed = line.trim()
|
|
1394
|
+
|
|
1395
|
+
if (trimmed === '') {
|
|
1396
|
+
return { ok: false, reason: 'is empty or whitespace-only', kind: 'structural' }
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// A trailing backslash is a line continuation: it would merge the generated
|
|
1400
|
+
// ENTRYPOINT (spliced right after the append block) into this instruction.
|
|
1401
|
+
if (/\\\s*$/.test(line)) {
|
|
1402
|
+
return {
|
|
1403
|
+
ok: false,
|
|
1404
|
+
reason:
|
|
1405
|
+
'ends with a line-continuation backslash, which would swallow the generated ENTRYPOINT; keep each entry self-contained',
|
|
1406
|
+
kind: 'structural',
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Heredoc syntax spans multiple lines by definition and cannot work in a
|
|
1411
|
+
// single spliced entry — it would consume the following generated lines.
|
|
1412
|
+
if (/<<-?\s*['"]?\w/.test(trimmed)) {
|
|
1413
|
+
return {
|
|
1414
|
+
ok: false,
|
|
1415
|
+
reason: 'uses heredoc syntax (<<EOF), which cannot be expressed as a single Dockerfile line',
|
|
1416
|
+
kind: 'structural',
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
if (trimmed.startsWith('#')) {
|
|
1421
|
+
// Parser directives (`# syntax=`, `# escape=`) only have meaning at the top
|
|
1422
|
+
// of a Dockerfile; spliced before ENTRYPOINT they are at best inert and at
|
|
1423
|
+
// worst confusing. Plain comments are fine.
|
|
1424
|
+
if (/^#\s*(syntax|escape|check)\s*=/i.test(trimmed)) {
|
|
1425
|
+
return {
|
|
1426
|
+
ok: false,
|
|
1427
|
+
reason: 'is a parser directive (# syntax=/# escape=), which is only valid at the top of a Dockerfile',
|
|
1428
|
+
kind: 'structural',
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
return { ok: true }
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
const instruction = trimmed.split(/\s+/, 1)[0]?.toUpperCase() ?? ''
|
|
1435
|
+
|
|
1436
|
+
if (instruction === 'FROM') {
|
|
1437
|
+
return {
|
|
1438
|
+
ok: false,
|
|
1439
|
+
reason: 'starts a new build stage (FROM), discarding everything TypeClaw layered before it',
|
|
1440
|
+
kind: 'structural',
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
if (instruction === 'ENTRYPOINT' || instruction === 'CMD') {
|
|
1444
|
+
return {
|
|
1445
|
+
ok: false,
|
|
1446
|
+
reason: `overrides the container ${instruction}, which TypeClaw owns (the entrypoint shim is appended right after this block)`,
|
|
1447
|
+
kind: 'structural',
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
if (!ALLOWED_APPEND_INSTRUCTIONS.has(instruction)) {
|
|
1451
|
+
// Reason is intentionally input-free: this string is forwarded verbatim into
|
|
1452
|
+
// operator-facing warnings (classifyDockerfileAppend -> dockerfileWarnings),
|
|
1453
|
+
// and the offending token is user-controlled — echoing it could leak a
|
|
1454
|
+
// secret/token-like first word from a malformed line into start/doctor output.
|
|
1455
|
+
return {
|
|
1456
|
+
ok: false,
|
|
1457
|
+
reason: 'does not begin with a recognized Dockerfile instruction',
|
|
1458
|
+
kind: 'structural',
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
const lower = trimmed.toLowerCase()
|
|
1463
|
+
|
|
1464
|
+
// The actual incident: mutating TypeClaw's own entrypoint shim. This is never
|
|
1465
|
+
// a supported customization surface — entrypoint changes belong in TypeClaw
|
|
1466
|
+
// source, not in a build-time patch script.
|
|
1467
|
+
if (lower.includes('typeclaw-entrypoint')) {
|
|
1468
|
+
return {
|
|
1469
|
+
ok: false,
|
|
1470
|
+
reason:
|
|
1471
|
+
'references the TypeClaw-owned entrypoint (typeclaw-entrypoint); patching it from docker.file.append is unsupported and brittle',
|
|
1472
|
+
kind: 'semantic',
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Decode-an-opaque-blob-and-execute-it. A benign decode (encoding output,
|
|
1477
|
+
// writing a file) or a bare `python3 -c "print(...)"` both pass; only decode
|
|
1478
|
+
// PAIRED with a real exec sink — or piped into an interpreter — is blocked.
|
|
1479
|
+
const hasDecode = DECODE_PRIMITIVES.some((p) => lower.includes(p))
|
|
1480
|
+
const hasExec = EXEC_PRIMITIVES.some((p) => lower.includes(p)) || DECODE_PIPED_TO_INTERPRETER.test(lower)
|
|
1481
|
+
if (hasDecode && hasExec) {
|
|
1482
|
+
return {
|
|
1483
|
+
ok: false,
|
|
1484
|
+
reason:
|
|
1485
|
+
'decodes an opaque payload and executes it (e.g. base64 + exec/eval), an obfuscated-code anti-pattern that has bricked builds',
|
|
1486
|
+
kind: 'semantic',
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
for (const { test, note } of APPEND_WARN_PATTERNS) {
|
|
1491
|
+
if (test.test(trimmed)) return { ok: true, warning: note }
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
return { ok: true }
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1304
1497
|
function formatZodError(error: z.ZodError): string {
|
|
1305
1498
|
return error.issues
|
|
1306
1499
|
.map((issue) => {
|
package/src/container/start.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
readInstalledTypeclawVersionFromAgent,
|
|
21
21
|
} from '@/init/auto-upgrade'
|
|
22
22
|
import { resolveBaseImageVersion } from '@/init/cli-version'
|
|
23
|
-
import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
|
|
23
|
+
import { buildDockerfile, classifyDockerfileAppend, DOCKERFILE } from '@/init/dockerfile'
|
|
24
24
|
import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
|
|
25
25
|
import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
|
|
26
26
|
import { refreshPackageJson } from '@/init/packagejson'
|
|
@@ -148,6 +148,10 @@ export type StartResult =
|
|
|
148
148
|
// registry. Non-fatal by design: a typo'd or unpublished plugin warns
|
|
149
149
|
// instead of blocking the launch.
|
|
150
150
|
skippedPlugins: string[]
|
|
151
|
+
// Non-fatal warnings from docker.file.append: unsafe lines stripped from
|
|
152
|
+
// the generated Dockerfile, plus warn-but-allow lines (curl|bash, remote
|
|
153
|
+
// ADD). Surfaced by the CLI so a stripped line is never a silent no-op.
|
|
154
|
+
dockerfileWarnings: string[]
|
|
151
155
|
}
|
|
152
156
|
| { ok: false; reason: string }
|
|
153
157
|
|
|
@@ -485,6 +489,7 @@ export async function start({
|
|
|
485
489
|
alreadyRunning: false,
|
|
486
490
|
autoUpgrade: upgrade,
|
|
487
491
|
skippedPlugins: pluginReconcile.skipped,
|
|
492
|
+
dockerfileWarnings: dockerfileRefresh.warnings,
|
|
488
493
|
}
|
|
489
494
|
} catch (error) {
|
|
490
495
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
@@ -701,18 +706,24 @@ async function resolvePublishHost(exec: DockerExec): Promise<string> {
|
|
|
701
706
|
// the cheapest correct signal: the build context for `docker build` is the
|
|
702
707
|
// Dockerfile itself, so equal contents definitionally produce an equivalent
|
|
703
708
|
// image.
|
|
704
|
-
export async function refreshDockerfile(
|
|
709
|
+
export async function refreshDockerfile(
|
|
710
|
+
cwd: string,
|
|
711
|
+
opts: { buildKit?: boolean } = {},
|
|
712
|
+
): Promise<{ changed: boolean; warnings: string[] }> {
|
|
705
713
|
const cfg = await loadTypeclawConfig(cwd)
|
|
706
714
|
const next = buildDockerfile(cfg.docker.file, {
|
|
707
715
|
baseImageVersion: resolveBaseImageVersion(cwd),
|
|
708
716
|
cjkFontsAuto: hostLocaleIsCjk(),
|
|
709
717
|
buildKit: opts.buildKit,
|
|
710
718
|
})
|
|
719
|
+
// Reuse the renderer's classifier so reported warnings match exactly what was
|
|
720
|
+
// stripped/kept in the Dockerfile above (single source of truth).
|
|
721
|
+
const { warnings } = classifyDockerfileAppend(cfg.docker.file.append)
|
|
711
722
|
const path = join(cwd, DOCKERFILE)
|
|
712
723
|
const prev = await readFile(path, 'utf8').catch(() => null)
|
|
713
|
-
if (prev === next) return { changed: false }
|
|
724
|
+
if (prev === next) return { changed: false, warnings }
|
|
714
725
|
await writeFile(path, next)
|
|
715
|
-
return { changed: true }
|
|
726
|
+
return { changed: true, warnings }
|
|
716
727
|
}
|
|
717
728
|
|
|
718
729
|
// Builds the agent image with a seamless buildx->legacy fallback. The preferred
|
|
@@ -836,6 +847,7 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
|
|
|
836
847
|
}
|
|
837
848
|
const tuiToken = await resolveTuiToken({ cwd, exec })
|
|
838
849
|
const plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false, tuiToken })
|
|
850
|
+
const { warnings: dockerfileWarnings } = classifyDockerfileAppend((await loadTypeclawConfig(cwd)).docker.file.append)
|
|
839
851
|
return {
|
|
840
852
|
ok: true,
|
|
841
853
|
plan,
|
|
@@ -847,6 +859,7 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
|
|
|
847
859
|
alreadyRunning: true,
|
|
848
860
|
autoUpgrade: { kind: 'skipped-already-running' },
|
|
849
861
|
skippedPlugins: [],
|
|
862
|
+
dockerfileWarnings,
|
|
850
863
|
}
|
|
851
864
|
}
|
|
852
865
|
|
package/src/cron/consumer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentSession } from '@/agent'
|
|
2
|
+
import { applyTurnThinkingLevel } from '@/agent/attention-escalation'
|
|
2
3
|
import { promptWithFallback, resolveFallbackChain } from '@/agent/model-fallback'
|
|
3
4
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
4
5
|
import { getConfig } from '@/config'
|
|
@@ -235,6 +236,12 @@ async function runPromptOnce(
|
|
|
235
236
|
if (created.hooks && turnEvent !== undefined) {
|
|
236
237
|
await created.hooks.runSessionTurnStart({ ...turnEvent, userPrompt: job.prompt, retrievalContext })
|
|
237
238
|
}
|
|
239
|
+
// Cron sessions are created fresh per fallback attempt, so the live getter
|
|
240
|
+
// is still the creation-time default here — safe to read without a separate
|
|
241
|
+
// captured field. The test-fake path omits `.session`; skip it then.
|
|
242
|
+
if (created.session !== undefined) {
|
|
243
|
+
applyTurnThinkingLevel(created.session, job.prompt, created.session.thinkingLevel)
|
|
244
|
+
}
|
|
238
245
|
// Bridge the CronSession wrapper into the AgentSession surface the
|
|
239
246
|
// fallback helper expects:
|
|
240
247
|
// prompt → CronSession.prompt (wrapper that calls AgentSession.prompt
|
|
@@ -338,16 +345,16 @@ async function runExec(job: ExecJob, cwd: string): Promise<void> {
|
|
|
338
345
|
const proc = Bun.spawn({
|
|
339
346
|
cmd: [cmd, ...args],
|
|
340
347
|
cwd,
|
|
341
|
-
stdout: '
|
|
348
|
+
stdout: 'ignore',
|
|
342
349
|
stderr: 'pipe',
|
|
343
350
|
env: {
|
|
344
351
|
...process.env,
|
|
345
352
|
TYPECLAW_PARENT_ORIGIN_JSON: JSON.stringify(parentOrigin),
|
|
346
353
|
},
|
|
347
354
|
})
|
|
348
|
-
const
|
|
355
|
+
const stderrText = new Response(proc.stderr).text()
|
|
356
|
+
const [code, stderr] = await Promise.all([proc.exited, stderrText])
|
|
349
357
|
if (code !== 0) {
|
|
350
|
-
const stderr = await new Response(proc.stderr).text()
|
|
351
358
|
throw new Error(`exec job ${job.id} exited with code ${code}: ${stderr.trim() || 'no stderr'}`)
|
|
352
359
|
}
|
|
353
360
|
}
|
package/src/doctor/checks.ts
CHANGED
|
@@ -210,7 +210,19 @@ function configValid(): DoctorCheck {
|
|
|
210
210
|
applies: (ctx) => ctx.hasAgentFolder,
|
|
211
211
|
async run(ctx) {
|
|
212
212
|
const result = validateConfig(ctx.cwd)
|
|
213
|
-
if (result.ok)
|
|
213
|
+
if (result.ok) {
|
|
214
|
+
if (result.warnings && result.warnings.length > 0) {
|
|
215
|
+
return {
|
|
216
|
+
status: 'warning',
|
|
217
|
+
message: `typeclaw.json valid; ${result.warnings.length} docker.file.append warning(s):\n${result.warnings.join('\n')}`,
|
|
218
|
+
fix: {
|
|
219
|
+
description:
|
|
220
|
+
'Review the docker.file.append entries above; unsafe lines are stripped from the Dockerfile on start.',
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { status: 'ok', message: 'typeclaw.json valid; mounts accessible' }
|
|
225
|
+
}
|
|
214
226
|
return {
|
|
215
227
|
status: 'error',
|
|
216
228
|
message: result.reason,
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateDockerfileAppendLine } from '@/config/config'
|
|
1
2
|
import type { DockerfileConfig, DockerfileFeatureToggle } from '@/config/config'
|
|
2
3
|
import {
|
|
3
4
|
CLAUDE_CREDENTIALS_FILE_NAME,
|
|
@@ -358,9 +359,9 @@ set -eu
|
|
|
358
359
|
# The persist root lives under /agent/.typeclaw/home/ (bind-mounted
|
|
359
360
|
# from the agent folder via the -v <cwd>:/agent flag in start.ts).
|
|
360
361
|
# Namespacing under .typeclaw/ keeps the agent's top-level layout clean and reserves
|
|
361
|
-
# a system-owned subtree we can extend later (e.g. ~/.gemini
|
|
362
|
-
#
|
|
363
|
-
#
|
|
362
|
+
# a system-owned subtree we can extend later (e.g. ~/.gemini/) without
|
|
363
|
+
# colliding with user files. The directory is gitignored by buildGitignore()
|
|
364
|
+
# so credentials never enter history.
|
|
364
365
|
#
|
|
365
366
|
# Three invariants this function enforces:
|
|
366
367
|
#
|
|
@@ -372,11 +373,11 @@ set -eu
|
|
|
372
373
|
# if a previous container life happened to write a real ~/.codex/
|
|
373
374
|
# dir before this code shipped.
|
|
374
375
|
#
|
|
375
|
-
# 2. We symlink
|
|
376
|
-
#
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
376
|
+
# 2. We symlink credential FILES for tools whose config dirs are mostly
|
|
377
|
+
# scratch/history (Codex, Claude). We do not redirect global config
|
|
378
|
+
# locations such as XDG_CONFIG_HOME or ~/.config here because tools like
|
|
379
|
+
# git also read config from those paths; first-party bundles that need
|
|
380
|
+
# persistence should set their own app-specific env vars instead.
|
|
380
381
|
#
|
|
381
382
|
# 3. We mkdir -p the target's parent on every boot. /agent is bind-
|
|
382
383
|
# mounted, so the host-side path may exist or not depending on
|
|
@@ -1264,6 +1265,9 @@ ${fromAndHeavyLayers}
|
|
|
1264
1265
|
|
|
1265
1266
|
ENV NODE_ENV=production
|
|
1266
1267
|
|
|
1268
|
+
# Persist first-party GWS config without changing global XDG/git config lookup.
|
|
1269
|
+
ENV GWS_CONFIG_HOME=/agent/workspace/.config/gws
|
|
1270
|
+
|
|
1267
1271
|
# Keep agent-messenger's fallback config dir inside workspace/ for any future
|
|
1268
1272
|
# SDK fallback paths. TypeClaw's KakaoTalk adapter does not write there:
|
|
1269
1273
|
# credentials live in secrets.json#channels.kakaotalk and container writes go
|
|
@@ -1697,10 +1701,57 @@ RUN ${aptCacheMount(buildKit)}apt-get update \\
|
|
|
1697
1701
|
`
|
|
1698
1702
|
}
|
|
1699
1703
|
|
|
1704
|
+
// Render-time enforcement is the security boundary: this is the sole bottleneck
|
|
1705
|
+
// where docker.file.append entries reach the generated Dockerfile, so unsafe
|
|
1706
|
+
// lines are STRIPPED here (never spliced into the build) rather than blocking
|
|
1707
|
+
// `typeclaw start`. docker.file.append is untrusted input even when the
|
|
1708
|
+
// in-container agent writes it — a bad line (e.g. the decode-and-exec footgun
|
|
1709
|
+
// that bricked a real build) becomes ineffective, not fatal. Validation
|
|
1710
|
+
// elsewhere only warns; this is what guarantees the line never runs.
|
|
1700
1711
|
function renderCustomDockerfileLines(lines: string[]): string {
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1712
|
+
const { kept, strippedCount } = classifyDockerfileAppend(lines)
|
|
1713
|
+
if (kept.length === 0 && strippedCount === 0) return ''
|
|
1714
|
+
// Durable, payload-free record so a stripped line isn't a silent no-op: the
|
|
1715
|
+
// operator sees the Dockerfile itself note the count (full reasons surface as
|
|
1716
|
+
// startup warnings). Never echo the stripped content — it may carry secrets.
|
|
1717
|
+
const strippedNote =
|
|
1718
|
+
strippedCount > 0
|
|
1719
|
+
? `# typeclaw stripped ${strippedCount} unsafe docker.file.append line(s); see startup warnings and typeclaw.json.
|
|
1720
|
+
`
|
|
1721
|
+
: ''
|
|
1722
|
+
if (kept.length === 0) return strippedNote ? `${strippedNote}\n` : ''
|
|
1723
|
+
return `${strippedNote}# Custom lines from typeclaw.json#docker.file.append.
|
|
1724
|
+
${kept.join('\n')}
|
|
1704
1725
|
|
|
1705
1726
|
`
|
|
1706
1727
|
}
|
|
1728
|
+
|
|
1729
|
+
export type DockerfileAppendClassification = {
|
|
1730
|
+
kept: string[]
|
|
1731
|
+
warnings: string[]
|
|
1732
|
+
strippedCount: number
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// Single source of truth for "what happens to each docker.file.append line",
|
|
1736
|
+
// reusing the config-layer classifier (validateDockerfileAppendLine). Both the
|
|
1737
|
+
// renderer (enforcement: drops unsafe lines) and refreshDockerfile (reporting:
|
|
1738
|
+
// surfaces warnings to the user) call this so the two never drift. Warn-but-
|
|
1739
|
+
// allow lines (curl|bash, remote ADD) are KEPT but produce a warning; structural
|
|
1740
|
+
// and semantic blocks are stripped with a warning.
|
|
1741
|
+
export function classifyDockerfileAppend(lines: string[]): DockerfileAppendClassification {
|
|
1742
|
+
const kept: string[] = []
|
|
1743
|
+
const warnings: string[] = []
|
|
1744
|
+
let strippedCount = 0
|
|
1745
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1746
|
+
const line = lines[i]!
|
|
1747
|
+
const check = validateDockerfileAppendLine(line)
|
|
1748
|
+
if (!check.ok) {
|
|
1749
|
+
strippedCount++
|
|
1750
|
+
warnings.push(`docker.file.append[${i}] stripped — ${check.reason}`)
|
|
1751
|
+
continue
|
|
1752
|
+
}
|
|
1753
|
+
if (check.warning) warnings.push(`docker.file.append[${i}] ${check.warning}`)
|
|
1754
|
+
kept.push(line)
|
|
1755
|
+
}
|
|
1756
|
+
return { kept, warnings, strippedCount }
|
|
1757
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type CreateSessionResult,
|
|
6
6
|
type SessionOrigin,
|
|
7
7
|
} from '@/agent'
|
|
8
|
+
import { applyTurnThinkingLevel } from '@/agent/attention-escalation'
|
|
8
9
|
import type { ChannelRouter } from '@/channels/router'
|
|
9
10
|
import type { McpManager } from '@/mcp'
|
|
10
11
|
import type { PermissionService } from '@/permissions'
|
|
@@ -431,6 +432,7 @@ export async function runPromptForCommand(args: {
|
|
|
431
432
|
retrievalContext.results.length > 0
|
|
432
433
|
? `${renderTurnTimeAnchor()}\n\n${args.text}\n\n${retrievalContext.results}`
|
|
433
434
|
: `${renderTurnTimeAnchor()}\n\n${args.text}`
|
|
435
|
+
applyTurnThinkingLevel(session, args.text, session.thinkingLevel)
|
|
434
436
|
try {
|
|
435
437
|
await session.prompt(turnText)
|
|
436
438
|
} finally {
|
package/src/server/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type CreateSessionOptions,
|
|
9
9
|
type CreateSessionResult,
|
|
10
10
|
} from '@/agent'
|
|
11
|
+
import { applyTurnThinkingLevel } from '@/agent/attention-escalation'
|
|
11
12
|
import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
|
|
12
13
|
import type { LiveSessionRegistry } from '@/agent/live-sessions'
|
|
13
14
|
import type { LiveSubagentRegistry } from '@/agent/live-subagents'
|
|
@@ -174,6 +175,11 @@ type QueuedPrompt = {
|
|
|
174
175
|
|
|
175
176
|
type SessionState = {
|
|
176
177
|
session: AgentSession
|
|
178
|
+
// The session's creation-time thinking level, captured once. An escalated turn
|
|
179
|
+
// moves `session.thinkingLevel` to `high`, so neither turn-driving path (drain
|
|
180
|
+
// loop, no-stream fallback) can use the live getter as the reset target — both
|
|
181
|
+
// read this captured default instead.
|
|
182
|
+
turnThinkingDefault: AgentSession['thinkingLevel']
|
|
177
183
|
sessionFileId: string
|
|
178
184
|
origin: SessionOrigin
|
|
179
185
|
sessionManager: { getSessionFile: () => string | undefined } | undefined
|
|
@@ -518,6 +524,7 @@ export function createServer({
|
|
|
518
524
|
|
|
519
525
|
const state: SessionState = {
|
|
520
526
|
session,
|
|
527
|
+
turnThinkingDefault: session.thinkingLevel,
|
|
521
528
|
sessionFileId,
|
|
522
529
|
origin,
|
|
523
530
|
sessionManager,
|
|
@@ -782,6 +789,7 @@ export function createServer({
|
|
|
782
789
|
retrievalContext.results.length > 0
|
|
783
790
|
? `${renderTurnTimeAnchor()}\n\n${msg.text}\n\n${retrievalContext.results}`
|
|
784
791
|
: `${renderTurnTimeAnchor()}\n\n${msg.text}`
|
|
792
|
+
applyTurnThinkingLevel(state.session, msg.text, state.turnThinkingDefault)
|
|
785
793
|
await state.session.prompt(turnText)
|
|
786
794
|
send(ws, doneMessage(state))
|
|
787
795
|
} catch (err) {
|
|
@@ -1086,6 +1094,7 @@ async function drain(
|
|
|
1086
1094
|
retrievalContext.results.length > 0
|
|
1087
1095
|
? `${renderTurnTimeAnchor()}\n\n${item.text}\n\n${retrievalContext.results}`
|
|
1088
1096
|
: `${renderTurnTimeAnchor()}\n\n${item.text}`
|
|
1097
|
+
applyTurnThinkingLevel(state.session, item.text, state.turnThinkingDefault)
|
|
1089
1098
|
await state.session.prompt(turnText)
|
|
1090
1099
|
send(ws, doneMessage(state))
|
|
1091
1100
|
} catch (err) {
|