typeclaw 0.37.4 → 0.37.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/doctor.ts +6 -1
- package/src/agent/plugin-tools.ts +23 -1
- package/src/agent/subagents.ts +146 -14
- package/src/agent/todo/scope.ts +4 -2
- package/src/agent/tools/channel-reply.ts +7 -9
- 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/bundled-plugins/memory/index.ts +9 -6
- package/src/bundled-plugins/memory/load-memory.ts +16 -2
- package/src/bundled-plugins/memory/slug.ts +19 -0
- package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
- package/src/channels/adapters/github/inbound.ts +68 -43
- package/src/channels/adapters/github/index.ts +57 -9
- package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
- package/src/channels/adapters/kakaotalk.ts +5 -1
- package/src/channels/adapters/mention-hints.ts +17 -0
- package/src/channels/manager.ts +77 -1
- package/src/channels/router.ts +181 -12
- package/src/cli/compose.ts +11 -2
- package/src/cli/dreams.ts +2 -2
- package/src/cli/inspect.ts +2 -2
- package/src/cli/logs.ts +2 -2
- package/src/cli/mount.ts +5 -5
- package/src/cli/require-agent-dir.ts +31 -0
- package/src/cli/restart.ts +2 -1
- package/src/cli/shell.ts +2 -2
- package/src/cli/start.ts +2 -1
- package/src/cli/stop.ts +2 -2
- package/src/cli/tui.ts +20 -6
- 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 +200 -9
- package/src/container/shared.ts +18 -0
- package/src/container/start.ts +1 -1
- package/src/cron/consumer.ts +3 -3
- package/src/hostd/client.ts +48 -52
- package/src/hostd/daemon.ts +82 -39
- package/src/hostd/paths.ts +22 -2
- package/src/hostd/spawn.ts +7 -0
- package/src/init/dockerfile.ts +11 -8
- package/src/init/kakaotalk-auth.ts +2 -2
- package/src/init/packagejson.ts +2 -2
- package/src/plugin/loader.ts +7 -4
- package/src/sandbox/session-tmp.ts +6 -1
- package/src/secrets/export-claude-credentials-file.ts +2 -2
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,18 @@ 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
|
+
const allowUnsafeAppend = process.env[ALLOW_UNSAFE_DOCKER_APPEND_ENV] === '1'
|
|
1204
|
+
const warnings: string[] = []
|
|
1205
|
+
const appendLines = parsed.config.docker.file.append
|
|
1206
|
+
for (let i = 0; i < appendLines.length; i++) {
|
|
1207
|
+
const check = validateDockerfileAppendLine(appendLines[i]!)
|
|
1208
|
+
if (!check.ok) {
|
|
1209
|
+
if (check.kind === 'semantic' && allowUnsafeAppend) continue
|
|
1210
|
+
return { ok: false, reason: `docker.file.append[${i}] ${check.reason}` }
|
|
1211
|
+
}
|
|
1212
|
+
if (check.warning) warnings.push(`docker.file.append[${i}] ${check.warning}`)
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1203
1215
|
if (!options.skipMounts) {
|
|
1204
1216
|
for (const mount of parsed.config.mounts) {
|
|
1205
1217
|
const check = validateMount(mount, cwd)
|
|
@@ -1207,7 +1219,7 @@ export function validateConfig(cwd: string, options: ValidateConfigOptions = {})
|
|
|
1207
1219
|
}
|
|
1208
1220
|
}
|
|
1209
1221
|
|
|
1210
|
-
return { ok: true }
|
|
1222
|
+
return warnings.length > 0 ? { ok: true, warnings } : { ok: true }
|
|
1211
1223
|
}
|
|
1212
1224
|
|
|
1213
1225
|
export type ParseConfigJsonResult = { ok: true; config: Config } | { ok: false; reason: string }
|
|
@@ -1253,11 +1265,15 @@ export function parseConfigJson(raw: string, options: ParseConfigJsonOptions = {
|
|
|
1253
1265
|
return { ok: true, config: result.data }
|
|
1254
1266
|
}
|
|
1255
1267
|
|
|
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
|
-
//
|
|
1268
|
+
// Verifies a mount's host path: exists, is a regular file or directory, is
|
|
1269
|
+
// readable, and is writable when not declared `readOnly`. Symlinks are
|
|
1270
|
+
// followed (statSync's default) so a broken symlink reads as "does not exist".
|
|
1271
|
+
// File mounts are allowed so credentials and config can be exposed as a single
|
|
1272
|
+
// path (e.g. an SSH private key); sockets, FIFOs, and devices are rejected
|
|
1273
|
+
// because exposing them is an advanced, security-sensitive case we don't take
|
|
1274
|
+
// implicitly. Permission checks are skipped when running as root (uid 0) —
|
|
1275
|
+
// euidaccess returns success regardless, so the test would be vacuous and
|
|
1276
|
+
// inconsistent with non-root.
|
|
1261
1277
|
export function validateMount(mount: Mount, cwd: string): ValidateConfigResult {
|
|
1262
1278
|
const resolved = expandMountPath(mount.path, cwd)
|
|
1263
1279
|
const label = `mount "${mount.name}"`
|
|
@@ -1274,8 +1290,8 @@ export function validateMount(mount: Mount, cwd: string): ValidateConfigResult {
|
|
|
1274
1290
|
return { ok: false, reason: `${label}: cannot stat ${resolved}: ${detail}` }
|
|
1275
1291
|
}
|
|
1276
1292
|
|
|
1277
|
-
if (!stats.isDirectory()) {
|
|
1278
|
-
return { ok: false, reason: `${label}: path ${resolved} is not a directory` }
|
|
1293
|
+
if (!stats.isDirectory() && !stats.isFile()) {
|
|
1294
|
+
return { ok: false, reason: `${label}: path ${resolved} is not a file or directory` }
|
|
1279
1295
|
}
|
|
1280
1296
|
|
|
1281
1297
|
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0
|
|
@@ -1301,6 +1317,181 @@ export function validateMount(mount: Mount, cwd: string): ValidateConfigResult {
|
|
|
1301
1317
|
return { ok: true }
|
|
1302
1318
|
}
|
|
1303
1319
|
|
|
1320
|
+
// Host env (not config) on purpose: an in-container agent can edit its own
|
|
1321
|
+
// typeclaw.json but cannot set the env of the host `typeclaw start` that runs
|
|
1322
|
+
// this gate, so it can never waive its own footgun. Only relaxes SEMANTIC
|
|
1323
|
+
// blocks; structural blocks always fire (they break Dockerfile generation).
|
|
1324
|
+
const ALLOW_UNSAFE_DOCKER_APPEND_ENV = 'TYPECLAW_ALLOW_UNSAFE_DOCKER_APPEND'
|
|
1325
|
+
|
|
1326
|
+
// FROM/ENTRYPOINT/CMD/MAINTAINER are intentionally excluded — see the
|
|
1327
|
+
// structural blocks in validateDockerfileAppendLine for why.
|
|
1328
|
+
const ALLOWED_APPEND_INSTRUCTIONS = new Set([
|
|
1329
|
+
'RUN',
|
|
1330
|
+
'ENV',
|
|
1331
|
+
'ARG',
|
|
1332
|
+
'LABEL',
|
|
1333
|
+
'COPY',
|
|
1334
|
+
'ADD',
|
|
1335
|
+
'USER',
|
|
1336
|
+
'WORKDIR',
|
|
1337
|
+
'SHELL',
|
|
1338
|
+
'EXPOSE',
|
|
1339
|
+
'VOLUME',
|
|
1340
|
+
'STOPSIGNAL',
|
|
1341
|
+
'HEALTHCHECK',
|
|
1342
|
+
'ONBUILD',
|
|
1343
|
+
])
|
|
1344
|
+
|
|
1345
|
+
// Decode primitives that, paired with dynamic execution on the same line, form
|
|
1346
|
+
// the "decode an opaque blob and run it" anti-pattern that bricked a real build
|
|
1347
|
+
// (an agent base64-decoded the bash entrypoint shim and fed it to python3
|
|
1348
|
+
// exec). Matching is substring/case-insensitive — these are code tokens the
|
|
1349
|
+
// agent emits, not natural-language, so English literals are correct here (cf.
|
|
1350
|
+
// the protocol-token exception in AGENTS.md).
|
|
1351
|
+
const DECODE_PRIMITIVES = ['base64', 'b64decode', 'atob(', 'unhexlify', '.fromhex(', 'xxd -r']
|
|
1352
|
+
|
|
1353
|
+
// True dynamic-execution sinks — language constructs that run a STRING as code.
|
|
1354
|
+
// Deliberately NOT including interpreter flags like `python3 -c`/`node -e`: a
|
|
1355
|
+
// benign `python3 -c "print(base64.b64encode(...))"` legitimately mentions a
|
|
1356
|
+
// decode primitive without ever executing the decoded bytes. The footgun is
|
|
1357
|
+
// decode + a real exec sink (or decode piped to an interpreter, below).
|
|
1358
|
+
const EXEC_PRIMITIVES = ['exec(', 'eval(', 'new function(', 'function(']
|
|
1359
|
+
|
|
1360
|
+
// Decoded stdout piped straight into an interpreter: `base64 -d ... | sh`,
|
|
1361
|
+
// `... | python3`, etc. The pipe is the execution step here, so it pairs with
|
|
1362
|
+
// DECODE_PRIMITIVES independently of the EXEC_PRIMITIVES sinks above.
|
|
1363
|
+
const DECODE_PIPED_TO_INTERPRETER =
|
|
1364
|
+
/\|\s*(?:sudo\s+)?(?:ba)?sh\b|\|\s*(?:sudo\s+)?python3?\b|\|\s*(?:sudo\s+)?(?:node|perl|ruby)\b/i
|
|
1365
|
+
|
|
1366
|
+
// Risky-but-legitimate operator patterns: piping a remote script straight into
|
|
1367
|
+
// a shell, or ADDing a remote URL. Common enough in real build steps that a
|
|
1368
|
+
// hard block would frustrate power users, dangerous enough to flag.
|
|
1369
|
+
const APPEND_WARN_PATTERNS: Array<{ test: RegExp; note: string }> = [
|
|
1370
|
+
{
|
|
1371
|
+
test: /\b(?:curl|wget)\b[^|]*\|\s*(?:sudo\s+)?(?:ba)?sh\b/i,
|
|
1372
|
+
note: 'pipes a remote script directly into a shell (curl|bash); verify the source is trusted',
|
|
1373
|
+
},
|
|
1374
|
+
{
|
|
1375
|
+
test: /<\(\s*(?:curl|wget)\b/i,
|
|
1376
|
+
note: 'executes a remote script via process substitution; verify the source is trusted',
|
|
1377
|
+
},
|
|
1378
|
+
{
|
|
1379
|
+
test: /^ADD\s+https?:\/\//i,
|
|
1380
|
+
note: 'ADD of a remote URL fetches an unpinned artifact at build time; prefer a pinned COPY or checksum-verified RUN',
|
|
1381
|
+
},
|
|
1382
|
+
]
|
|
1383
|
+
|
|
1384
|
+
export type AppendLineCheck =
|
|
1385
|
+
| { ok: true; warning?: string }
|
|
1386
|
+
// `structural` blocks are unconditional (they break Dockerfile generation);
|
|
1387
|
+
// `semantic` blocks are waivable via the host env override.
|
|
1388
|
+
| { ok: false; reason: string; kind: 'structural' | 'semantic' }
|
|
1389
|
+
|
|
1390
|
+
// Pure, side-effect-free validator for ONE docker.file.append entry. The newline
|
|
1391
|
+
// rejection stays in the zod schema (dockerfileLineSchema) so it fires on every
|
|
1392
|
+
// parse including the agent's own config-write guard; this adds the contextual
|
|
1393
|
+
// policy the schema can't express cheaply. Returns the first problem found.
|
|
1394
|
+
export function validateDockerfileAppendLine(line: string): AppendLineCheck {
|
|
1395
|
+
const trimmed = line.trim()
|
|
1396
|
+
|
|
1397
|
+
if (trimmed === '') {
|
|
1398
|
+
return { ok: false, reason: 'is empty or whitespace-only', kind: 'structural' }
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// A trailing backslash is a line continuation: it would merge the generated
|
|
1402
|
+
// ENTRYPOINT (spliced right after the append block) into this instruction.
|
|
1403
|
+
if (/\\\s*$/.test(line)) {
|
|
1404
|
+
return {
|
|
1405
|
+
ok: false,
|
|
1406
|
+
reason:
|
|
1407
|
+
'ends with a line-continuation backslash, which would swallow the generated ENTRYPOINT; keep each entry self-contained',
|
|
1408
|
+
kind: 'structural',
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Heredoc syntax spans multiple lines by definition and cannot work in a
|
|
1413
|
+
// single spliced entry — it would consume the following generated lines.
|
|
1414
|
+
if (/<<-?\s*['"]?\w/.test(trimmed)) {
|
|
1415
|
+
return {
|
|
1416
|
+
ok: false,
|
|
1417
|
+
reason: 'uses heredoc syntax (<<EOF), which cannot be expressed as a single Dockerfile line',
|
|
1418
|
+
kind: 'structural',
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
if (trimmed.startsWith('#')) {
|
|
1423
|
+
// Parser directives (`# syntax=`, `# escape=`) only have meaning at the top
|
|
1424
|
+
// of a Dockerfile; spliced before ENTRYPOINT they are at best inert and at
|
|
1425
|
+
// worst confusing. Plain comments are fine.
|
|
1426
|
+
if (/^#\s*(syntax|escape|check)\s*=/i.test(trimmed)) {
|
|
1427
|
+
return {
|
|
1428
|
+
ok: false,
|
|
1429
|
+
reason: 'is a parser directive (# syntax=/# escape=), which is only valid at the top of a Dockerfile',
|
|
1430
|
+
kind: 'structural',
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
return { ok: true }
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const instruction = trimmed.split(/\s+/, 1)[0]?.toUpperCase() ?? ''
|
|
1437
|
+
|
|
1438
|
+
if (instruction === 'FROM') {
|
|
1439
|
+
return {
|
|
1440
|
+
ok: false,
|
|
1441
|
+
reason: 'starts a new build stage (FROM), discarding everything TypeClaw layered before it',
|
|
1442
|
+
kind: 'structural',
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
if (instruction === 'ENTRYPOINT' || instruction === 'CMD') {
|
|
1446
|
+
return {
|
|
1447
|
+
ok: false,
|
|
1448
|
+
reason: `overrides the container ${instruction}, which TypeClaw owns (the entrypoint shim is appended right after this block)`,
|
|
1449
|
+
kind: 'structural',
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
if (!ALLOWED_APPEND_INSTRUCTIONS.has(instruction)) {
|
|
1453
|
+
return {
|
|
1454
|
+
ok: false,
|
|
1455
|
+
reason: `does not begin with a recognized Dockerfile instruction (got "${instruction}")`,
|
|
1456
|
+
kind: 'structural',
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const lower = trimmed.toLowerCase()
|
|
1461
|
+
|
|
1462
|
+
// The actual incident: mutating TypeClaw's own entrypoint shim. This is never
|
|
1463
|
+
// a supported customization surface — entrypoint changes belong in TypeClaw
|
|
1464
|
+
// source, not in a build-time patch script.
|
|
1465
|
+
if (lower.includes('typeclaw-entrypoint')) {
|
|
1466
|
+
return {
|
|
1467
|
+
ok: false,
|
|
1468
|
+
reason:
|
|
1469
|
+
'references the TypeClaw-owned entrypoint (typeclaw-entrypoint); patching it from docker.file.append is unsupported and brittle',
|
|
1470
|
+
kind: 'semantic',
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// Decode-an-opaque-blob-and-execute-it. A benign decode (encoding output,
|
|
1475
|
+
// writing a file) or a bare `python3 -c "print(...)"` both pass; only decode
|
|
1476
|
+
// PAIRED with a real exec sink — or piped into an interpreter — is blocked.
|
|
1477
|
+
const hasDecode = DECODE_PRIMITIVES.some((p) => lower.includes(p))
|
|
1478
|
+
const hasExec = EXEC_PRIMITIVES.some((p) => lower.includes(p)) || DECODE_PIPED_TO_INTERPRETER.test(lower)
|
|
1479
|
+
if (hasDecode && hasExec) {
|
|
1480
|
+
return {
|
|
1481
|
+
ok: false,
|
|
1482
|
+
reason:
|
|
1483
|
+
'decodes an opaque payload and executes it (e.g. base64 + exec/eval), an obfuscated-code anti-pattern that has bricked builds',
|
|
1484
|
+
kind: 'semantic',
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
for (const { test, note } of APPEND_WARN_PATTERNS) {
|
|
1489
|
+
if (test.test(trimmed)) return { ok: true, warning: note }
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
return { ok: true }
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1304
1495
|
function formatZodError(error: z.ZodError): string {
|
|
1305
1496
|
return error.issues
|
|
1306
1497
|
.map((issue) => {
|
package/src/container/shared.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
1
2
|
import { basename, resolve } from 'node:path'
|
|
2
3
|
|
|
3
4
|
export type DockerExecResult = { exitCode: number; stdout: string; stderr: string }
|
|
@@ -249,8 +250,25 @@ export function imageTagFromCwd(cwd: string): string {
|
|
|
249
250
|
}
|
|
250
251
|
|
|
251
252
|
// Docker container names must match [a-zA-Z0-9][a-zA-Z0-9_.-]*.
|
|
253
|
+
//
|
|
254
|
+
// Non-ASCII names (Korean/CJK/Cyrillic/accented Latin — the common Windows case
|
|
255
|
+
// where the profile folder is a localized display name, e.g. C:\Users\사용자\봇)
|
|
256
|
+
// have every out-of-charset character collapsed to a dash, so distinct folders
|
|
257
|
+
// reduce to the SAME string: '봇' and '집' both → 'tc--'. The container name keys
|
|
258
|
+
// hostd registration and the secrets key path, so that collision is silent
|
|
259
|
+
// host-side state clobbering, not cosmetics. For any name carrying a non-ASCII
|
|
260
|
+
// char we append a deterministic hash of the original (cf. makeUntitledSlug in
|
|
261
|
+
// the memory plugin) to keep distinct folders distinct; surviving ASCII stays a
|
|
262
|
+
// readable prefix. ASCII-only names take the original branch — never renamed.
|
|
252
263
|
function sanitizeContainerName(name: string): string {
|
|
253
264
|
const cleaned = name.replace(/[^a-zA-Z0-9_.-]/g, '-')
|
|
265
|
+
if (/[^\u0000-\u007f]/.test(name)) {
|
|
266
|
+
const hash = createHash('sha256').update(name).digest('hex').slice(0, 8)
|
|
267
|
+
const remnant = cleaned.replace(/-+/g, '-').replace(/^-+|-+$/g, '')
|
|
268
|
+
if (remnant === '') return `tc-${hash}`
|
|
269
|
+
const base = /^[a-zA-Z0-9]/.test(remnant) ? remnant : `tc-${remnant}`
|
|
270
|
+
return `${base}-${hash}`
|
|
271
|
+
}
|
|
254
272
|
if (cleaned === '' || !/^[a-zA-Z0-9]/.test(cleaned)) {
|
|
255
273
|
return `tc-${cleaned || 'agent'}`
|
|
256
274
|
}
|
package/src/container/start.ts
CHANGED
|
@@ -671,7 +671,7 @@ export async function planStart({
|
|
|
671
671
|
// so mounting an empty cache would only invite a confusing local_files_only
|
|
672
672
|
// miss if something inside the container reached for the model anyway.
|
|
673
673
|
if (agentUsesVector(cwd)) {
|
|
674
|
-
runArgs.push('-v', `${homeRoot()}
|
|
674
|
+
runArgs.push('-v', `${join(homeRoot(), 'models')}:/opt/models:ro`)
|
|
675
675
|
runArgs.push('-e', 'TYPECLAW_MODEL_CACHE=/opt/models')
|
|
676
676
|
}
|
|
677
677
|
|
package/src/cron/consumer.ts
CHANGED
|
@@ -338,16 +338,16 @@ async function runExec(job: ExecJob, cwd: string): Promise<void> {
|
|
|
338
338
|
const proc = Bun.spawn({
|
|
339
339
|
cmd: [cmd, ...args],
|
|
340
340
|
cwd,
|
|
341
|
-
stdout: '
|
|
341
|
+
stdout: 'ignore',
|
|
342
342
|
stderr: 'pipe',
|
|
343
343
|
env: {
|
|
344
344
|
...process.env,
|
|
345
345
|
TYPECLAW_PARENT_ORIGIN_JSON: JSON.stringify(parentOrigin),
|
|
346
346
|
},
|
|
347
347
|
})
|
|
348
|
-
const
|
|
348
|
+
const stderrText = new Response(proc.stderr).text()
|
|
349
|
+
const [code, stderr] = await Promise.all([proc.exited, stderrText])
|
|
349
350
|
if (code !== 0) {
|
|
350
|
-
const stderr = await new Response(proc.stderr).text()
|
|
351
351
|
throw new Error(`exec job ${job.id} exited with code ${code}: ${stderr.trim() || 'no stderr'}`)
|
|
352
352
|
}
|
|
353
353
|
}
|
package/src/hostd/client.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
+
import { connect, type Socket as NetSocket } from 'node:net'
|
|
2
3
|
|
|
3
|
-
import
|
|
4
|
+
import { isWindows } from '@/shared'
|
|
4
5
|
|
|
5
6
|
import { socketPath } from './paths'
|
|
6
7
|
import type { Request, Response } from './protocol'
|
|
7
8
|
|
|
8
9
|
const DEFAULT_TIMEOUT_MS = 3_000
|
|
9
10
|
|
|
10
|
-
export async function isDaemonReachable(
|
|
11
|
-
|
|
11
|
+
export async function isDaemonReachable(
|
|
12
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
13
|
+
opts: Pick<SendOptions, 'socket'> = {},
|
|
14
|
+
): Promise<boolean> {
|
|
15
|
+
const path = opts.socket ?? socketPath()
|
|
16
|
+
if (!isWindows() && !existsSync(path)) return false
|
|
12
17
|
try {
|
|
13
|
-
const reply = await send({ kind: 'list' }, { timeoutMs })
|
|
18
|
+
const reply = await send({ kind: 'list' }, { timeoutMs, socket: path })
|
|
14
19
|
return reply.ok
|
|
15
20
|
} catch {
|
|
16
21
|
return false
|
|
@@ -58,56 +63,47 @@ export async function send(req: Request, opts: SendOptions = {}): Promise<Respon
|
|
|
58
63
|
const path = opts.socket ?? socketPath()
|
|
59
64
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
60
65
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
return new Promise<Response>((resolve) => {
|
|
67
|
+
let buf = ''
|
|
68
|
+
let settled = false
|
|
69
|
+
let sock: NetSocket | null = null
|
|
70
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
66
71
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
const finish = (response: Response, destroy = false): void => {
|
|
73
|
+
if (settled) return
|
|
74
|
+
settled = true
|
|
75
|
+
if (timer) clearTimeout(timer)
|
|
76
|
+
if (sock) {
|
|
77
|
+
try {
|
|
78
|
+
if (destroy) sock.destroy()
|
|
79
|
+
else sock.end()
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
resolve(response)
|
|
83
|
+
}
|
|
70
84
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
sock
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
},
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
},
|
|
85
|
+
timer = setTimeout(() => finish({ ok: false, reason: `daemon ack timeout after ${timeoutMs}ms` }, true), timeoutMs)
|
|
86
|
+
sock = connect(path)
|
|
87
|
+
sock.on('connect', () => {
|
|
88
|
+
try {
|
|
89
|
+
sock?.write(`${JSON.stringify(req)}\n`)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
finish({ ok: false, reason: error instanceof Error ? error.message : String(error) }, true)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
sock.on('data', (chunk: Buffer) => {
|
|
95
|
+
buf += chunk.toString('utf8')
|
|
96
|
+
const newline = buf.indexOf('\n')
|
|
97
|
+
if (newline < 0) return
|
|
98
|
+
const line = buf.slice(0, newline)
|
|
99
|
+
try {
|
|
100
|
+
finish(JSON.parse(line) as Response)
|
|
101
|
+
} catch {
|
|
102
|
+
finish({ ok: false, reason: 'invalid response from daemon' }, true)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
sock.on('error', (error) => {
|
|
106
|
+
finish({ ok: false, reason: error instanceof Error ? error.message : String(error) }, true)
|
|
94
107
|
})
|
|
95
|
-
} catch (error) {
|
|
96
|
-
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
97
|
-
}
|
|
98
|
-
sock.data = state
|
|
99
|
-
sock.write(`${JSON.stringify(req)}\n`)
|
|
100
|
-
|
|
101
|
-
let timer: ReturnType<typeof setTimeout> | null = null
|
|
102
|
-
const timeoutPromise = new Promise<Response>((resolve) => {
|
|
103
|
-
timer = setTimeout(() => resolve({ ok: false, reason: `daemon ack timeout after ${timeoutMs}ms` }), timeoutMs)
|
|
104
108
|
})
|
|
105
|
-
try {
|
|
106
|
-
return await Promise.race([replyPromise, timeoutPromise])
|
|
107
|
-
} finally {
|
|
108
|
-
if (timer) clearTimeout(timer)
|
|
109
|
-
try {
|
|
110
|
-
sock.end()
|
|
111
|
-
} catch {}
|
|
112
|
-
}
|
|
113
109
|
}
|
package/src/hostd/daemon.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { chmod, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { createServer, type Server, type Socket as NetSocket } from 'node:net'
|
|
3
4
|
import { join } from 'node:path'
|
|
4
5
|
|
|
5
|
-
import type { Socket, UnixSocketListener } from 'bun'
|
|
6
|
-
|
|
7
6
|
import type { PortForward } from '@/config'
|
|
8
7
|
import { defaultDockerExec, type DockerExec } from '@/container'
|
|
9
8
|
import type { PortForwardEvent } from '@/portbroker'
|
|
10
9
|
import { kakaoChannelBlockSchema, lineChannelBlockSchema } from '@/secrets/schema'
|
|
11
10
|
import { SecretsBackend } from '@/secrets/storage'
|
|
11
|
+
import { isWindows } from '@/shared'
|
|
12
12
|
|
|
13
13
|
import { isDaemonReachable } from './client'
|
|
14
14
|
import type { KakaoRenewalCallbacks, KakaoRenewalLogEvent } from './kakao-renewal-manager'
|
|
@@ -121,8 +121,6 @@ const MAX_HTTP_REQUEST_BYTES = 64 * 1024
|
|
|
121
121
|
// it's already in use by some other local service.
|
|
122
122
|
const STABLE_HTTP_PORT = 8974
|
|
123
123
|
|
|
124
|
-
type ServerState = { buf: string }
|
|
125
|
-
|
|
126
124
|
function json(response: RpcResponse, status = 200): globalThis.Response {
|
|
127
125
|
return new Response(JSON.stringify(response), {
|
|
128
126
|
status,
|
|
@@ -208,12 +206,54 @@ function stringifyError(error: unknown): string {
|
|
|
208
206
|
return error instanceof Error ? error.message : String(error)
|
|
209
207
|
}
|
|
210
208
|
|
|
209
|
+
function errorCode(error: Error): unknown {
|
|
210
|
+
const direct = error as Error & { code?: unknown; cause?: unknown }
|
|
211
|
+
if (direct.code !== undefined) return direct.code
|
|
212
|
+
if (direct.cause instanceof Error) return errorCode(direct.cause)
|
|
213
|
+
return undefined
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function listenOnSocket(server: Server, path: string, onWindows: boolean): Promise<void> {
|
|
217
|
+
await new Promise<void>((resolve, reject) => {
|
|
218
|
+
const onError = (error: Error): void => {
|
|
219
|
+
server.off('error', onError)
|
|
220
|
+
const code = errorCode(error)
|
|
221
|
+
if (code === 'EADDRINUSE' || (onWindows && error.message.includes('Failed to listen at'))) {
|
|
222
|
+
reject(new Error(`another typeclaw host daemon is already listening at ${path}`))
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
reject(error)
|
|
226
|
+
}
|
|
227
|
+
server.once('error', onError)
|
|
228
|
+
server.listen(path, () => {
|
|
229
|
+
server.off('error', onError)
|
|
230
|
+
resolve()
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function closeSocketServer(server: Server, sockets: Set<NetSocket>): Promise<void> {
|
|
236
|
+
await new Promise<void>((resolve) => {
|
|
237
|
+
try {
|
|
238
|
+
server.close(() => resolve())
|
|
239
|
+
} catch {
|
|
240
|
+
resolve()
|
|
241
|
+
}
|
|
242
|
+
for (const socket of sockets) {
|
|
243
|
+
try {
|
|
244
|
+
socket.destroy()
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
211
250
|
export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
212
251
|
await ensureDirs()
|
|
213
252
|
const path = opts.socket ?? socketPath()
|
|
253
|
+
const onWindows = isWindows()
|
|
214
254
|
|
|
215
|
-
if (existsSync(path)) {
|
|
216
|
-
if (await isDaemonReachable(500)) {
|
|
255
|
+
if (!onWindows && existsSync(path)) {
|
|
256
|
+
if (await isDaemonReachable(500, { socket: path })) {
|
|
217
257
|
throw new Error(`another typeclaw host daemon is already listening at ${path}`)
|
|
218
258
|
}
|
|
219
259
|
try {
|
|
@@ -488,37 +528,37 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
488
528
|
}
|
|
489
529
|
}
|
|
490
530
|
|
|
491
|
-
const respond = (
|
|
531
|
+
const respond = (socket: NetSocket, response: RpcResponse): void => {
|
|
492
532
|
try {
|
|
493
|
-
|
|
533
|
+
socket.write(`${JSON.stringify(response)}\n`)
|
|
494
534
|
} catch {}
|
|
495
535
|
try {
|
|
496
|
-
|
|
536
|
+
socket.end()
|
|
497
537
|
} catch {}
|
|
498
538
|
}
|
|
499
539
|
|
|
500
|
-
const handleData = (
|
|
501
|
-
|
|
502
|
-
if (
|
|
503
|
-
respond(
|
|
540
|
+
const handleData = (socket: NetSocket, chunk: Buffer, state: { buf: string }): void => {
|
|
541
|
+
state.buf += chunk.toString('utf8')
|
|
542
|
+
if (state.buf.length > MAX_REQUEST_BUFFER_BYTES) {
|
|
543
|
+
respond(socket, { ok: false, reason: 'request exceeds buffer limit' })
|
|
504
544
|
return
|
|
505
545
|
}
|
|
506
|
-
let newline =
|
|
546
|
+
let newline = state.buf.indexOf('\n')
|
|
507
547
|
while (newline >= 0) {
|
|
508
|
-
const line =
|
|
509
|
-
|
|
548
|
+
const line = state.buf.slice(0, newline)
|
|
549
|
+
state.buf = state.buf.slice(newline + 1)
|
|
510
550
|
let req: Request
|
|
511
551
|
try {
|
|
512
552
|
req = JSON.parse(line) as Request
|
|
513
553
|
} catch {
|
|
514
|
-
respond(
|
|
554
|
+
respond(socket, { ok: false, reason: 'invalid request json' })
|
|
515
555
|
return
|
|
516
556
|
}
|
|
517
557
|
void dispatch(req).then(
|
|
518
|
-
(response) => respond(
|
|
519
|
-
(error) => respond(
|
|
558
|
+
(response) => respond(socket, response),
|
|
559
|
+
(error) => respond(socket, { ok: false, reason: error instanceof Error ? error.message : String(error) }),
|
|
520
560
|
)
|
|
521
|
-
newline =
|
|
561
|
+
newline = state.buf.indexOf('\n')
|
|
522
562
|
}
|
|
523
563
|
}
|
|
524
564
|
|
|
@@ -599,25 +639,28 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
599
639
|
}
|
|
600
640
|
|
|
601
641
|
// Boot-time restore: replay every persisted registration into the in-memory
|
|
602
|
-
// maps and revive portbroker for it. Runs before
|
|
642
|
+
// maps and revive portbroker for it. Runs before the IPC listener so the socket
|
|
603
643
|
// is never accepting RPCs against a half-restored registry. A bad file
|
|
604
644
|
// (parse error, schema mismatch) is logged-and-skipped — one corrupt
|
|
605
645
|
// registration must not gate every other container's recovery.
|
|
606
646
|
await restorePersistedRegistrations(applyRegistration, log, probeContainerAlive, removeRegistrationFile)
|
|
607
647
|
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
close: () => {},
|
|
616
|
-
error: () => {},
|
|
617
|
-
},
|
|
648
|
+
const sockets = new Set<NetSocket>()
|
|
649
|
+
const listener = createServer((socket) => {
|
|
650
|
+
const state = { buf: '' }
|
|
651
|
+
sockets.add(socket)
|
|
652
|
+
socket.on('data', (chunk: Buffer) => handleData(socket, chunk, state))
|
|
653
|
+
socket.on('close', () => sockets.delete(socket))
|
|
654
|
+
socket.on('error', () => {})
|
|
618
655
|
})
|
|
619
|
-
|
|
620
|
-
|
|
656
|
+
try {
|
|
657
|
+
await listenOnSocket(listener, path, onWindows)
|
|
658
|
+
} catch (error) {
|
|
659
|
+
httpServer.stop(true)
|
|
660
|
+
throw error
|
|
661
|
+
}
|
|
662
|
+
// Restrict POSIX sockets to the owning user; ~/.typeclaw/run is also 0700.
|
|
663
|
+
if (!onWindows) await chmod(path, 0o600).catch(() => {})
|
|
621
664
|
log({ kind: 'daemon-listening', socket: path })
|
|
622
665
|
|
|
623
666
|
const runGc = async (): Promise<void> => {
|
|
@@ -658,9 +701,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
658
701
|
stopped = true
|
|
659
702
|
log({ kind: 'daemon-stopping' })
|
|
660
703
|
clearInterval(gcTimer)
|
|
661
|
-
|
|
662
|
-
listener.stop(true)
|
|
663
|
-
} catch {}
|
|
704
|
+
await closeSocketServer(listener, sockets)
|
|
664
705
|
httpServer.stop(true)
|
|
665
706
|
if (opts.portbroker) {
|
|
666
707
|
const names = Array.from(cwds.keys())
|
|
@@ -672,9 +713,11 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
672
713
|
}
|
|
673
714
|
cwds.clear()
|
|
674
715
|
restartTokens.clear()
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
716
|
+
if (!onWindows) {
|
|
717
|
+
try {
|
|
718
|
+
if (existsSync(path)) await unlink(path)
|
|
719
|
+
} catch {}
|
|
720
|
+
}
|
|
678
721
|
},
|
|
679
722
|
}
|
|
680
723
|
return daemonHandle
|