DeeSeek Harness Programación defensiva: informes de resultados, limpieza y credenciales
Tras escribir tantos plugins, es posible que te encuentres con un tipo de bug de borde que "no se detecta en pruebas normales, pero causa problemas al salir a producción".
La documentación oficial los clasifica como "reglas de categoría de defectos bien ganadas": cada una corresponde a defectos reales que se publicaron o estuvieron a punto de publicarse.
Este artículo trata los cinco patrones de programación defensiva más importantes: informe de resultados, detención completa de dispose, borrado de credenciales, eliminación de enlaces y aislamiento de callbacks.
En una frase: estos patrones evitan que "un simple caso límite tumbe a todo el Agent".
Primero mira la tabla comparativa
La siguiente tabla compara ejemplos malos y buenos lado a lado; cada uno proviene de una categoría de defecto real.
La documentación oficial llama a estos patrones reglas del tipo "lea este artículo antes de escribir código de ciclo de vida, concurrencia, subprocesos o limpieza".
Reporte independiente de resultados ortogonales
Un resultado puede tener varias propiedades a la vez: un proceso puede haber excedido el tiempo límite pero terminar con código de salida 0 porque capturó la señal de terminación.
Cada hecho independiente (timedOut、signal、exitCode) debe informarse por separado.
Nunca anides el informe de una bandera dentro de la rama de otra bandera; de lo contrario, el llamador podría confundir una ejecución terminada anticipadamente con un éxito normal.
Ejemplo
// Ejecuta un subproceso e informa ortogonalmente tres hechos independientes: timedOut, signal, exitCode.
import { spawn, type ChildProcess } from 'node:child_process'
export interface RunResult {
timedOut: boolean // Hecho independiente 1: si hubo timeout
signal: NodeJS.Signals | null // Hecho independiente 2: si fue terminado por señal
exitCode: number | null // Hecho independiente 3: código de salida
stdout: string
stderr: string
}
export function run(argv: string[], timeoutMs: number): Promise<RunResult> {
return new Promise((resolve, reject) => {
const child: ChildProcess = spawn(argv[0], argv.slice(1), {
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
child.stdout.on('data', (d: Buffer) => (stdout += d))
child.stderr.on('data', (d: Buffer) => (stderr += d))
// El hecho independiente 1 se mantiene por separado: el timeout es una bandera, independiente del código de salida.
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
child.kill('SIGTERM') // El timeout dispara la terminación
}, timeoutMs)
child.on('close', (code, signal) => {
clearTimeout(timer)
// Los tres campos se devuelven de forma independiente: el proceso puede tener timedOut=true y exitCode=0,
// porque capturó SIGTERM después del timeout y salió con 0.
resolve({ timedOut, signal, exitCode: code, stdout, stderr })
})
child.on('error', reject)
})
}
Con este enfoque, el llamador puede hacer juicios combinados:timedOutes verdadero peroexitCodees 0, lo que indica que "lo mataron a la fuerza pero el proceso lo encubrió con 0".
Si el informe estuviera anidado, esta combinación jamás podría expresarse.
dispose debe detenerse por completo
Si el proceso de limpieza solo envía una señal de terminación o cancelación y regresa sin esperar a que el trabajo se detenga de verdad, deja procesos huérfanos.
La lógica de limpieza debe usar un flujo asíncrono y esperar a que el subproceso salga (después de enviar la señal de terminación, esperardone)。
También se deben cerrar los registros de listeners y notificaciones antes de terminar el proceso, para mantener silenciosos los eventos de finalización tardíos.
Ejemplo
// dispose debe detenerse por completo: después de enviar la señal, esperar a que el subproceso salga, no solo enviar la señal y regresar.
import { once } from 'node:events'
import type { ChildProcess } from 'node:child_process'
export async function disposeQuiescent(child: ChildProcess): Promise<void> {
// 1. Primero elimina los listeners para mantener silenciosos los eventos de finalización tardíos.
child.removeAllListeners()
// 2. Solicita la detención: envía la señal de terminación.
child.kill('SIGTERM')
// 3. Espera a que el subproceso realmente salga; si no sale incluso con timeout, escala a SIGKILL.
// Promise.race garantiza que dispose no quede colgado para siempre por un subproceso que no quiere salir.
const forceKill = new Promise<void>((resolve) => {
setTimeout(() => {
child.kill('SIGKILL')
resolve()
}, 5_000)
})
await Promise.race([once(child, 'exit').then(() => undefined), forceKill])
}
Así, cuando dispose regresa, el subproceso ya salió o fue terminado a la fuerza con SIGKILL.
Borrado de credenciales: nunca expongas variables de entorno a salidas no confiables
Los comandos iniciados deben usar variables de entorno depuradas, eliminando aquellas cuyos nombres coincidan con*KEY*、*SECRET*、*TOKEN*o*PASSWORD*los elementos que coincidan con.
De lo contrario, las credenciales del harness podrían filtrarse a través de la salida del comando,envo archivos spill.
Los archivos temporales y spill deben colocarse en un directorio privado con permisos 0700, usar nombres de archivo aleatorios y abrirse de forma exclusiva y accesible solo por el propietario ('wx'、0o600)。
Ejemplo
// Limpia las variables de entorno antes de iniciar el comando, para evitar que las credenciales del harness se filtren a través de la salida del comando o env.
// Se eliminan todos los elementos sensibles que coincidan con *KEY* *SECRET* *TOKEN* *PASSWORD*.
const SENSITIVE_NAME = /.*(?:KEY|SECRET|TOKEN|PASSWORD).*/i
export function scrubEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const clean: Record<string, string> = {}
for (const [key, value] of Object.entries(env)) {
if (SENSITIVE_NAME.test(key)) continue // Las claves sensibles se omiten directamente
if (value !== undefined) clean[key] = value
}
return clean
}
// Uso: spawn(argv
En el entorno de ejemplo,
DEEPSEEK_API_KEYeste tipo de variables deben pasar por esta ruta de borrado.Permitir que la salida del comando,
envo los archivos spill la incluyan, equivale a entregar la clave a cualquier objeto que pueda leer la salida.
Eliminar enlaces simbólicos con unlink
Las rutas que pueden ser enlaces simbólicos o junctions de Windows deben primero verificarse conlstatSync().isSymbolicLink()y luego eliminarse conunlinkSync.
unlink solo elimina el enlace en sí y rechaza directorios reales, por lo que jamás sigue el enlace hasta su destino.
En Windows, llamar armSync(link)sobre un junction lanzaERR_FS_EISDIR; la eliminación recursiva podría atravesar el junction hasta su destino.
Solo para directorios reales se usarecursivedermSync。
Ejemplo
// Las rutas con forma de enlace se eliminan con unlink, nunca se sigue recursivamente el enlace hasta su destino.
import { lstatSync, rmSync, unlinkSync } from 'node:fs'
export function removePath(p: string): void {
// Primero verifica si es un enlace simbólico (o junction de Windows).
if (lstatSync(p).isSymbolicLink()) {
// unlink solo elimina el enlace en sí y rechaza directorios reales; no sigue enlaces.
unlinkSync(p)
return
}
// Solo después de confirmar que es un directorio real, se usa rmSync con recursive.
rmSync(p, { recursive: true })
}
Aislar excepciones de callbacks en el despachador
Si un listener proporcionado por el usuario lanza una excepción, no debe provocar que la promise en la que está se rechace, ni debe dejar sin ejecutar a los listeners que le siguen.
Envuelve el bucle de despacho con try/catch y registra el error.
Un suscriptor con mal comportamiento jamás debe romper el ciclo de vida principal.
Ejemplo
// El despachador aísla excepciones de callbacks: un mal suscriptor no puede romper el ciclo de vida principal.
export function dispatch(listeners: ReadonlyArray<() => void>): void {
for (const listener of listeners) {
try {
listener()
} catch (err) {
// Registra el error y continúa despachando; nunca hace reject ni deja sin ejecutar a los listeners posteriores.
console.error('[dispatch] a listener failed:', err)
}
}
}
Este patrón es omnipresente dentro de dsh:session/eventlas fallas de los observadores de se registran y aíslan, sin hacer fallar el append ya confirmado.
Garantiza que "un plugin que rompe un listener al escribirlo" no detenga el bucle completo del Agent.
Resumen de más patrones
La documentación oficial también registra otras dos reglas que vale la pena recordar:
| Patrón | Regla |
|---|---|
| Los convenios públicos deben cumplirse en ambos lados | Al recibir múltiples representaciones de un mismo resultado, se deben normalizar antes de devolverlas a través de la API pública; el consumidor no debe adivinar si la excepción proviene del proveedor, de la capa de envoltura o de su propia lógica de ensamblaje. |
| El estado asíncrono no es estado síncrono | No usesagent/statusowhenIdle()como el resultado de unafollowup(); el llamador que realmente posee una ejecución debe definir explícitamente el intervalo |
Las contrapartes de prueba de estas reglas (rutas de entrada reales, verificación de resultados reales, propiedad de recursos) se desarrollan en testing.md, como se mencionará en el próximo artículo.
Resumen y autoevaluación
La programación defensiva tapa de antemano los agujeros donde es fácil fallar en los bordes: informe ortogonal de resultados, detención completa de dispose, borrado de credenciales, eliminación segura de enlaces y aislamiento de excepciones de callbacks.
Preguntas de autoevaluación:
| Pregunta | Referencia |
|---|---|
| Si un proceso agota su tiempo límite pero el código de salida es 0, ¿qué significa? | Puede que haya capturado la señal de terminación y salido por sí mismo con 0; timedOut y exitCode son dos hechos independientes. |
| ¿Cuál es el riesgo de enviar solo SIGTERM y retornar al limpiar procesos hijos? | Dejar procesos huérfanos; se debe esperar la salida y, si hay tiempo de espera, escalar a SIGKILL. |
| ¿Qué se usa para eliminar una ruta que podría ser un enlace simbólico? | Primero evaluar con lstatSync, luego usar unlinkSync; solo usar rmSync recursive para directorios reales. |