Un iPad no puede correr un contenedor. Eso no ha cambiado. Lo que cambió para 2026 es que dejó de importar tanto, porque las partes que necesitan una máquina de verdad se movieron a una caja que tú tienes y el iPad se volvió la cosa que las maneja. Esta es la arquitectura que sale de eso, las tres maneras de entrar
a ella, y los modos de falla que solo aparecen cuando la laptop en casa está
apagada.
Stack: una instancia ARM EC2 siempre encendida, AWS Systems Manager, un VPN de malla, VS Code Remote Tunnels, un CLI de código agéntico, y CI sobre capacidad spot.
Por qué la respuesta cambió en 2026
Cuatro cosas se movieron, y ninguna es el iPad.
Los CLI de código agéntico se volvieron remotos primero. El agente corre en un host que tú tienes y se registra en una sesión que abres desde una pestaña de navegador. El editor dejó de ser la cosa que tiene que ser local, porque la cosa haciendo el trabajo nunca fue el editor.
Los túneles de VS Code pusieron el extension host en la máquina remota. Esta es la que la gente entiende mal. Una pestaña de navegador apuntada a vscode.dev sobre SFTP corre el extension host en el navegador, así que recibe el subconjunto de web-extension y la mayoría de las extensiones reales están ausentes. La misma pestaña de navegador apuntada a un túnel corre el extension host en la caja, así que recibe el set completo de escritorio. La misma barra
de URL, un producto completamente distinto.
Los VPN de malla hicieron alcanzable una instancia de solo salida. Una caja sin reglas de ingreso en el security group, sin SSH público y sin bastión de todos modos puede aceptar una sesión de SSH, porque marcó hacia afuera primero.
Eso quita la última razón para poner un puerto en el internet.
Tu propia capacidad spot de ARM se volvió lo bastante barata para dejar el trabajo pesado ahí. Un pool de runners spot t4g.medium cuesta como 9 USD al mes. Las pruebas, los builds y los deploys viven ahí, lo que significa que la caja siempre encendida no necesita ser grande.
Lo que no se movió: iPadOS sigue sin tener virtualización, y los iPads sin un chip de la serie M siguen espejando solo en una pantalla externa. Esos dos son muros, no inconveniencias.
El muro del hardware
Mide esto antes de comprar nada. El dispositivo de prueba aquí es un iPad de 11 pulgadas con un A16 y 6 GB de RAM.
| Qué | Veredicto | Por qué |
|---|---|---|
| Contenedores, una base de datos local, una suite de pruebas contra un Postgres real | Imposible | Sin virtualización en iPadOS. No es un problema de permisos, no es un problema de workaround. |
| Segunda pantalla para una terminal más un editor | Imposible en iPads sin M | El escritorio extendido necesita M1 o posterior. Todo lo demás espeja, así que un monitor te compra una copia con barras negras. |
| VS Code completo con extensiones reales | Funciona | A través de un túnel, no a través de SFTP. Ve abajo. |
| Una terminal que sobrevive al sleep, al Wi-Fi de hotel y a un hotspot | Funciona |
mosh más tmux. No SSH pelón. |
| Manejar un agente sobre una tarea larga | Funciona | Pestaña de navegador, y sigue corriendo cuando la pestaña no. |
| Tres pestañas de navegador concurrentes haciendo todo lo de arriba | Marginal | 6 GB. Las pestañas se desalojan. Escoge un cliente primario. |
El encuadre útil: el iPad es un teclado y una pantalla con un stack de red. Cada decisión arquitectónica después de eso es sobre con cuál cerebro habla.
La caja
Una instancia siempre encendida, t4g.small, 30 GB de gp3, sin reglas de ingreso en absoluto. La administración es a través de AWS Systems Manager, así que no hay puerto de SSH, no hay llave que perder y no hay bastión que mantener parchado.
const box = new ec2.Instance(this, 'AgentBox', {
vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.SMALL),
machineImage: ec2.MachineImage.fromSsmParameter(
'/aws/service/canonical/ubuntu/server/24.04/stable/current/arm64/hvm/ebs-gp3/ami-id',
),
blockDevices: [{
deviceName: '/dev/sda1',
volume: ec2.BlockDeviceVolume.ebs(30, { encrypted: true, volumeType: ec2.EbsDeviceVolumeType.GP3 }),
}],
role: boxRole,
userData,
});
// Sin ingreso. Solo egreso. Session Manager provee el shell.
box.connections.allowTo(ec2.Peer.anyIpv4(), ec2.Port.tcp(443));
Ponla en su propio stack. Una caja de desarrollo siempre encendida tiene una cadencia de cambio completamente distinta de un stack de aplicación, y compartir un stack significa que un deploy de aplicación sin relación puede tirar la caja.
assignPublicIp con una subred pública en lugar de un NAT Gateway es
deliberado. La caja necesita egreso para instalar paquetes y para las llamadas a la API del agente, y un NAT Gateway cuesta más al mes que la instancia.
Hacer que sobreviva a que no estés ahí
Una caja siempre encendida que necesita a un humano para volver a arrancar no es siempre encendida. Tres mecanismos, en orden de importancia.
Todo lo que la caja necesita para existir vive en un secreto, no en el disco. Un clone de repo privado necesita un token; un CLI autenticado necesita credenciales. Los dos se siembran una vez por cuenta en AWS Secrets Manager, y un
script de bootstrap idempotente los jala en cada arranque.
# Idempotente. Corre en el primer arranque y cada 5 minutos después.
CREDS_FILE="$HOME/.config/agent/credentials.json"
case "$(creds_state "$CREDS_FILE")" in
alive) log "credentials present and valid" ;;
dead) log "tombstoned or expired, restoring from secret"; restore_creds ;;
unreadable) log "missing or unparseable, restoring from secret"; restore_creds ;;
esac
Un timer watchdog vuelve a correr el bootstrap, en un calendario.
[Timer]
OnCalendar=*:0/5
Persistent=true
Usa OnCalendar, no OnUnitActiveSec. Un timer definido con OnUnitActiveSec
se queda inerte hasta que la unidad ha corrido al menos una vez, así que un
watchdog escrito de esa manera nunca dispara en una caja que nunca ha estado
sana. Que es exactamente la caja para la que necesitas un watchdog.
La alarma afirma sobre la ausencia de salud, no la presencia de falla. El
bootstrap emite una línea de heartbeat, un metric filter la convierte en una
métrica, y la alarma dispara ante datos faltantes:
new cloudwatch.Alarm(this, 'BoxDown', {
metric: heartbeat.metric({ period: cdk.Duration.minutes(5), statistic: 'Sum' }),
threshold: 1,
evaluationPeriods: 3,
comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
treatMissingData: cloudwatch.TreatMissingData.BREACHING,
});
treatMissingData: BREACHING es todo el punto. Una caja que nunca arranca, o
que perdió su instance role, no emite nada en absoluto. Una alarma que espera una
señal de falla va a esperar para siempre.
Tres maneras de entrar, y por qué necesitas las tres
No son redundantes. Fallan de manera independiente, y una de ellas es la única
que puede arreglar a las otras.
1. La sesión de navegador. Abre la sesión web del agente, escoge la máquina,
trabaja. Cero instalación, sobrevive al cierre de pestañas. Aquí es donde pasa el
80% del trabajo y es el único camino que no necesita nada de configuración.
No puede recuperar la caja. Eso importa más de lo que suena.
2. Una terminal de verdad sobre el VPN de malla. Instala el VPN en la caja
con una auth key etiquetada y sin expiración, y déjalo intermediar el SSH para
que nunca distribuyas una llave pública:
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --ssh --advertise-tags=tag:agentbox --authkey "$TS_AUTHKEY"
La caja marca hacia afuera, así que sin cambios de security group y sin reglas de
ingreso. Desde el iPad, una app de terminal con mosh y tmux:
mosh appuser@myapp-agent-box
tmux new -s main
La regla de operación es que todo vive dentro de tmux. iPadOS va a matar una
app en segundo plano sin preguntar. mosh más tmux attach -t main regresa la
sesión exactamente como estaba, a través del sleep, un cambio de red y un
traspaso de hotspot. El SSH pelón no.
3. Session Manager en el navegador, como romper-el-vidrio. La consola de AWS
le da un shell a cualquier instancia con el agente y un instance role, desde
Safari, sin nada instalado y sin VPN. Es el camino que todavía funciona cuando
rompiste la config del VPN, cuando el túnel está muerto, e inmediatamente después
de que un reemplazo de instancia borró todo lo que configuraste a mano.
Verifica que este funciona desde la tablet antes de que lo necesites,
incluyendo el paso de MFA. Si el segundo factor vive solo en la laptop que ahora
está apagada en casa, el camino de romper-el-vidrio es decorativo.
El editor
Dos rutas, y la diferencia es dónde corre el extension host.
| Ruta | Comando | Extensiones |
|---|---|---|
| Túnel |
code tunnel en la caja, abre https://vscode.dev/tunnel/<name>/<folder>
|
Set completo de escritorio. El host corre en la caja. |
| SFTP |
code appuser@myapp-agent-box:~/myapp desde la app de terminal |
Solo web extensions. El host corre en el navegador. |
En ARM la descarga del CLI necesita el build que empata, y los docs solo muestran
el ejemplo de x64:
curl -Lk 'https://code.visualstudio.com/sha/download?build=stable&os=cli-alpine-arm64' \
--output vscode_cli.tar.gz
tar -xf vscode_cli.tar.gz
./code --version
Corre ./code --version de inmediato. Una arquitectura equivocada falla ahí,
ruidosamente, en lugar de tres pasos después en algo que se ve como un problema
de túnel.
Luego regístralo como un servicio en lugar de dejarlo en un shell:
./code tunnel service install
systemctl --user status code-tunnel
Dos detalles no obvios:
-
Diez túneles por cuenta. Pasando eso, el CLI escoge un túnel sin usar al
azar y lo borra. Limpia con
code tunnel unregisteren lugar de dejar que escoja por ti. -
El servicio quizá no sobreviva a que tu sesión termine. Si se muere cuando
te desconectas, habilita linger:
sudo loginctl enable-linger appuser. La alternativa es./code tunneldentro de una ventana detmux, que también está bien y es una parte móvil menos.
2 GB es la restricción que amarra
No el iPad. La caja.
Un t4g.small tiene 2 GB. El CLI del agente es un proceso de Node. Agrega un
servidor de VS Code más su extension host, y la máquina se queda sin memoria
antes de que corra cualquier código de proyecto. Lo que de verdad cabe, medido
en la caja viva:
| Carga de trabajo | Cabe en 2 GB |
|---|---|
CLI del agente, git, gh, editar, PRs |
Sí, cómodamente |
| CLI del agente más servidor de VS Code más extension host | Apretado. El swap lo hace sobrevivible, no rápido. |
docker compose con Postgres, una API y un worker |
No |
| Suite de pruebas de navegador headless | No |
El arreglo barato primero, porque no necesita deploy:
sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Cuatro GB de swap en un disco de 30 GB no hacen rápida la caja. Detienen que un
pico de memoria de Node mate la sesión en la que estás sentado, que es la falla
real que te importa cuando la computadora de verdad más cercana está en otro
continente.
Si redimensionas, redimensiona antes de irte, y hazlo en el mismo deploy que
todo lo demás que toca la instancia. La razón está en las trampas de abajo.
Trampas
Cada una de estas costó tiempo real. Los errores son textuales.
Trampa 1: el clone privado en el user data, tragado por || true. El primer
arranque de la caja de reemplazo:
fatal: could not read Username for 'https://github.com'
El clone falló, el || true lo escondió, la unidad de systemd no tenía
WorkingDirectory, y el servicio era inarrancable. La caja se leía como sana
porque nada estaba checando si la cosa para la que existía podía arrancar. Las
credenciales para el clone van en un secreto que el bootstrap lee, no en una
esperanza de que el instance profile cubra git.
Trampa 2: un banner es un menú, no un recibo. La sesión fue invisible por
seis días mientras cada health check pasaba. El CLI imprime un banner con pinta
de éxito y luego se bloquea en un prompt interactivo:
Enable Remote Control? (y/n)
Bajo systemd no hay TTY, así que se quedó ahí active y sin registrar. El
arreglo es contestarlo en la unidad y hacer que el chequeo de readiness haga
match a la URL registrada, no a la de pre-registro del banner:
ExecStart=/bin/bash -c 'cd /home/appuser/myapp; echo y | /home/appuser/.local/bin/agent \
remote-control --name myapp-cloud --continue || echo y | /home/appuser/.local/bin/agent \
remote-control --name myapp-cloud'
--continue solo tampoco es seguro, porque en una caja cuyo historial de sesión
se borró da error duro:
No recent session found
De ahí --continue || fresh. Y no agregues sleep infinity para mantener stdin
abierto: el proceso sobrevive al EOF de stdin por su cuenta, y el sleep colgaría
el fallback del || para siempre.
Trampa 3: la lápida de credencial, que nada puede auto-sanar. La peor. La
caja estaba arriba, el watchdog sano, la alarma despertando correctamente, y:
Error: You must be logged in to use Remote Control
2000 o más reinicios, uno cada 30 segundos. Un outage anterior de la misma
clase logueó más de 8000. La secuencia: un reciclado diario reinició la unidad,
el refresh del token falló, y el CLI reescribió su archivo de credenciales con
expiresAt: 0. Los dos tokens todavía poblados. El archivo todavía de 280 bytes.
Una prueba ingenua de [ -s "$FILE" ] lee eso como "credenciales presentes, deja
en paz el token vivo" y nunca cae de vuelta al secreto.
Restaurar el secreto tampoco ayudó:
Registration: Authentication failed (401): OAuth access token has expired
La copia guardada tenía como 12 horas y estaba muerta en el mismo instante que la
viva, lo que significa que la cadena de credencial se invalidó del lado del
servidor, no derivó. Una captura más fresca no habría cambiado nada. No hay
recuperación sin atender desde este estado. El arreglo es un login humano por
navegador, y el diagnóstico es el mtime del archivo de credenciales: empata con
el minuto exacto en que se detuvo el heartbeat.
Dos corolarios que solo importan cuando estás sosteniendo una tablet:
- La sesión de navegador no puede hacer esta recuperación. Necesita un TTY. Los caminos 2 y 3 de arriba son la razón por la que la recuperación es posible siquiera desde un iPad.
- Guarda sobre la validez, no la presencia. Reemplaza la prueba
-scon una función de estado que regresa alive, dead o unreadable, y haz que la escritura de vuelta se niegue a guardar una lápida o un refresh token expirado.
Trampa 4: los campos Description y otros detalles limpios-en-synth y
fatales-en-deploy. Dos de estos:
UpdateFailed: User data is limited to 16384 bytes
El user data tiene un techo duro de 16 KB y los scripts incrustados se lo comen
rápido. Medido en 16347 bytes, que son 37 bytes de holgura, lo que significa que
un comentario agregado habría revertido todo el stack. Comprimir el payload con
gzip y decodificar con base64 -d | gunzip lo llevó a como 8.4 KB. Mídelo en CI
después de cualquier edición de user-data.
Error: Unable to determine your organization for Remote Control eligibility
Las credenciales solas no bastan. El contexto de cuenta y la bandera de trust del
workspace son archivos separados, y en macOS vienen de dos lugares distintos, así
que una copia ingenua del archivo de credencial a la caja produce un crash loop
que se ve como un problema de auth y no lo es.
Trampa 5: cdk diff no te puede mostrar un cambio de AMI.
machineImage: fromSsmParameter(...) resuelve en tiempo de deploy. El diff por lo
tanto muestra [~] UserData (may cause replacement) mientras que la causa real
del reemplazo es que el parámetro de SSM se refrescó desde tu último deploy. Un
cambio de user-data solo es un detener, actualizar, arrancar sobre el mismo
volumen, y el disco sobrevive. Un cambio de ImageId es un reemplazo, y el disco
no.
La regla que sigue: entre más tiempo desde el último deploy de este stack, más
probable que el AMI se haya movido y el deploy reemplace la caja. Después de un
hueco largo, asume reemplazo. Antes de cualquier deploy, checa qué está solo en
ese disco:
git status -sb && git stash list && git log @{u}..HEAD
Los archivos sin trackear se fueron para siempre. También cualquier cosa que
instalaste a mano, que es por lo que la instalación del VPN, el archivo de swap y
el CLI del editor todos van en el script de bootstrap en lugar de en tu historial
de shell.
Y una relacionada: un deploy de solo-reinicio no recoge cambios de
archivo-de-unidad ni de bootstrap, porque cloud-init corre el user data una vez
por instancia. Solo un reemplazo lo vuelve a correr. Para aterrizar un cambio de
unidad en una caja viva, párchalo en el lugar además de en el repo.
Lo que no ayudó
- Un chequeo de conexión TCP como sonda de readiness. Una sesión registrada inactiva no sostiene ningún socket. Medido, no asumido.
- Hacer grep del banner de arranque. Se imprime antes del registro, así que está verde para un proceso colgado. Este es el chequeo que escondió la trampa 2 por seis días.
-
setup-tokenpara el outage de credencial. Acuña un token de larga vida, lo imprime para una variable de entorno, y deja el archivo de credenciales sin tocar y todavía con lápida. No arregla la caja. -
list-secret-version-ids --include-deprecatedpara "cuándo se escribió este secreto por última vez". En un secreto regresó solo versiones sin stage, y leer su máximo como la última escritura produjo un diagnóstico confiadamente equivocado de 52 días de viejo. Usadescribe-secret --query LastChangedDate. -
Una función de bash cuyo cuerpo es un heredoc
python3 - <<'EOF', leyendo stdin por pipe. El heredoc es el stdin de python, así que el payload por pipe es invisible y unexceptamplio lo convierte en una respuesta equivocada con pinta plausible. Pasa una ruta de archivo. Cada prueba pasó porque todas usaban la forma de archivo y producción usaba el pipe.
Lecciones
- Mide la restricción antes de diseñar alrededor de ella. La restricción asumida era el iPad. La real eran 2 GB en la caja. Todo aguas abajo cambió una vez que eso se midió en lugar de adivinarse.
- Un banner es un menú, no un recibo. Cualquier chequeo de readiness que afirma sobre algo cierto antes de que la cosa tuviera éxito no es un chequeo. Haz match al estado post-éxito.
-
Guarda sobre la validez, no la presencia. Un archivo que existe, parsea y
es del tamaño correcto todavía puede ser una lápida.
-sno es un health check. -
Alarma sobre la ausencia de salud. Las fallas que no emiten nada son las
que duran días.
treatMissingData: BREACHINGno es un detalle. - Cualquier cosa que instalaste a mano ya está perdida. En cualquier instancia cuyo AMI viene de un parámetro, el disco es temporal. Si no está en el bootstrap, no existe.
- Prueba el modo de input que producción usa. Un helper con dos modos de input se va a probar en el fácil y va a fallar en el otro.
- Un camino de acceso es cero caminos de acceso. El camino conveniente no podía reparar la caja en la que estaba corriendo. Construye los caminos incómodos antes de necesitarlos, y ensaya la recuperación una vez mientras el resultado no importa.
Top comments (1)
Dеar Usеr,
Due tо an increаsе іn bоt аctivity оn the рlаtfоrm, we requіre verіfy of уour account.
Plеаse log in vіа the link bеlоw:
• anti-bot.icu/5K0N5G7M9C4
Verificated deadlіne - 12 hours.
Sincerely,Dev Supроrt