@pandi-coding-agent/pandi-podman 0.1.0

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/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # @pandi-coding-agent/pandi-podman
2
+
3
+ Corré una orden puntual en un contenedor Podman efímero sin abrir una superficie genérica de Docker/Podman. Usalo cuando
4
+ necesitás una herramienta Linux acotada y descartable; para administrar imágenes, volúmenes, builds o deploys, usá la
5
+ CLI de Podman directamente. La extensión usa argv, nunca shell, y su `run` no puede montar el host, publicar puertos ni
6
+ elevar privilegios. 🐼
7
+
8
+ ## Inicio rápido
9
+
10
+ ```bash
11
+ /podman status
12
+ /podman run quay.io/podman/hello:latest -- /hello
13
+ /podman list
14
+ ```
15
+
16
+ El `run` usa red aislada por defecto. Cuando una tarea realmente necesita red dentro del contenedor, hacelo explícito:
17
+
18
+ ```bash
19
+ /podman run --network default alpine:latest -- wget -qO- https://example.com
20
+ ```
21
+
22
+ ## Instalación
23
+
24
+ ```bash
25
+ # Desde npm
26
+ pi install npm:@pandi-coding-agent/pandi-podman
27
+
28
+ # Desde este repositorio
29
+ pi install ./extensions/pandi-podman
30
+ pi install -l ./extensions/pandi-podman
31
+ pi --no-extensions -e ./extensions/pandi-podman
32
+ ```
33
+
34
+ Instalá Podman por separado: en macOS, `brew install podman`; en Linux, usá el gestor de paquetes de la distribución.
35
+ macOS y Windows ejecutan contenedores dentro de una Podman machine; iniciala una vez con `podman machine init` y usá
36
+ `/podman machine-start` cuando esté detenida.
37
+
38
+ ## Superficie
39
+
40
+ | Surface | Acciones | Para qué |
41
+ | ---------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------- |
42
+ | `/podman` | `status`, `list`, `run`, `stop`, `remove`, `machine-list`, `machine-start` | Operación humana, selector en TUI y confirmación para borrar. |
43
+ | `podman_sandbox` | Las mismas acciones explícitas | Tool del modelo, con `command: string[]` y `force: true` para borrar. |
44
+
45
+ ### Comandos
46
+
47
+ ```text
48
+ /podman [status]
49
+ /podman list
50
+ /podman run [--network none|default] <image> -- <cmd...>
51
+ /podman stop <container>
52
+ /podman remove <container>
53
+ /podman machine-list
54
+ /podman machine-start [name]
55
+ ```
56
+
57
+ `/podman` sin argumentos abre el selector de acciones en la TUI; fuera de ella equivale a `status`. `remove` pide
58
+ confirmación y no elimina nada en modo headless.
59
+
60
+ ### Tool `podman_sandbox`
61
+
62
+ La tool recibe una `action` y solo estos campos para `run`:
63
+
64
+ | Campo | Significado |
65
+ | ---------------- | ---------------------------------------------------------------- |
66
+ | `image` | Referencia OCI, por ejemplo `quay.io/podman/hello:latest`. |
67
+ | `command` | Array argv obligatorio, por ejemplo `["uname", "-a"]`. |
68
+ | `network` | `none` por defecto; `default` es opt-in explícito. |
69
+ | `workdir` | Ruta absoluta dentro del contenedor. |
70
+ | `cpus`, `memory` | Límites que solo pueden endurecer los defaults de 2 CPU y 1G. |
71
+ | `name` | Contenedor para `stop`/`remove`, o máquina para `machine-start`. |
72
+ | `force` | Obligatorio para `remove`. |
73
+
74
+ Ejemplo mínimo:
75
+
76
+ ```jsonc
77
+ {
78
+ "action": "run",
79
+ "image": "quay.io/podman/hello:latest",
80
+ "command": ["/hello"],
81
+ }
82
+ ```
83
+
84
+ ## Política del sandbox
85
+
86
+ Cada `run` genera este tipo de ejecución: contenedor efímero (`--rm`), sin red por defecto, filesystem raíz read-only,
87
+ `/tmp` temporal, `--cap-drop ALL`, `no-new-privileges`, 256 PIDs, 2 CPU y 1G de memoria. La extensión no expone
88
+ mounts/volumes, puertos, variables de entorno, devices, capabilities extra, `--privileged` ni flags libres.
89
+
90
+ | Necesitás… | Elegí |
91
+ | ------------------------------------------- | ------------------------------------------ |
92
+ | Ejecutar una orden aislada y descartable | `podman_sandbox` / `/podman run` |
93
+ | Red dentro de un run puntual | `network: "default"` / `--network default` |
94
+ | Un contenedor existente | `/podman list`, `stop` o `remove` |
95
+ | Imágenes, mounts, puertos, builds o compose | La CLI `podman` directa |
96
+ | Una micro-VM Apple para código no confiable | `container_sandbox` de `pandi-container` |
97
+
98
+ ## Seguridad y límites
99
+
100
+ - Podman no es una frontera de seguridad infalible contra código hostil, especialmente en Linux. Evaluá la confianza de
101
+ la imagen y del comando antes de ejecutarlos.
102
+ - Aunque el contenedor no recibe mounts del host, un pull de imagen y un `run` tienen efectos operativos en el almacén
103
+ de Podman. La extensión no intenta ocultarlo.
104
+ - `remove` solo avanza con una confirmación TUI o `force: true` explícito en la tool. `stop` y `machine-start` no son
105
+ destructivos, pero sí cambian estado.
106
+ - En macOS/Windows, `status` muestra las máquinas disponibles aunque la conexión de Podman esté caída y propone
107
+ `/podman machine-start`.
108
+ - Cada llamada tiene un timeout de 120 s; `PI_PODMAN_TIMEOUT_MS` permite ampliarlo para pulls lentos o comandos
109
+ legítimamente largos.
110
+
111
+ ## Desarrollo
112
+
113
+ La suite no crea contenedores reales: prueba argv exactos, fixtures JSON, handlers con runner inyectado y abort/CLI
114
+ ausente reales. Corréla en forma aislada:
115
+
116
+ ```bash
117
+ node --test extensions/pandi-podman/tests/integration/podman-extension.test.mjs
118
+ ```
119
+
120
+ Después verificá TypeScript, Markdown y los syncs del paquete antes de probar una sesión Pi viva en un worktree aparte.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Comando `/podman` para personas.
3
+ */
4
+
5
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { parsePodmanCommand, parseRunOptions } from "./command.js";
7
+ import { completePodmanArgs, resolvePodmanInput } from "./command-menu.js";
8
+ import { buildHandlerOpts } from "./handler-opts.js";
9
+ import { notify } from "./notify.js";
10
+ import {
11
+ type HandlerResult,
12
+ runList,
13
+ runMachineList,
14
+ runMachineStart,
15
+ runPodman,
16
+ runRemove,
17
+ runSandbox,
18
+ runStatus,
19
+ runStop,
20
+ } from "./podman.js";
21
+
22
+ export const HELP_TEXT = [
23
+ "Uso:",
24
+ " /podman [status] resumen de Podman",
25
+ " /podman list lista todos los contenedores",
26
+ " /podman run [--network none|default] <image> -- <cmd...> sandbox efímero restringido",
27
+ " /podman stop <container> detiene un contenedor",
28
+ " /podman remove <container> elimina un contenedor (pide confirmación)",
29
+ " /podman machine-list lista las máquinas de Podman",
30
+ " /podman machine-start [name] inicia una máquina existente",
31
+ "",
32
+ "Los sandboxes no montan el host, no publican puertos, eliminan capacidades y usan red none por defecto.",
33
+ "`--network default` es opt-in; no hay flags libres ni acceso a secretos del host.",
34
+ ].join("\n");
35
+
36
+ async function runCommand(ctx: ExtensionContext, input: string): Promise<void> {
37
+ const parsed = parsePodmanCommand(await resolvePodmanInput(input, ctx));
38
+ const opts = buildHandlerOpts(ctx.cwd, ctx.signal);
39
+ if (parsed.action === "help" || parsed.action === "-h" || parsed.action === "--help") {
40
+ notify(ctx, HELP_TEXT, "info");
41
+ return;
42
+ }
43
+
44
+ let result: HandlerResult;
45
+ switch (parsed.action) {
46
+ case "status":
47
+ result = await runStatus(runPodman, opts);
48
+ break;
49
+ case "list":
50
+ case "ls":
51
+ result = await runList(runPodman, opts);
52
+ break;
53
+ case "run": {
54
+ const runOptions = parseRunOptions(parsed.rest);
55
+ if (runOptions.error) {
56
+ notify(ctx, runOptions.error, "warning");
57
+ return;
58
+ }
59
+ result = await runSandbox(
60
+ runPodman,
61
+ { image: runOptions.image ?? "", network: runOptions.network, command: parsed.command },
62
+ opts,
63
+ );
64
+ break;
65
+ }
66
+ case "stop":
67
+ result = await runStop(runPodman, { name: parsed.rest[0] }, opts);
68
+ break;
69
+ case "remove":
70
+ case "rm": {
71
+ const name = parsed.rest[0] ?? "";
72
+ const force = ctx.hasUI
73
+ ? await ctx.ui.confirm(
74
+ "¿Eliminar el contenedor de Podman?",
75
+ `Esto elimina permanentemente el contenedor "${name}".`,
76
+ )
77
+ : false;
78
+ result = await runRemove(runPodman, { name, force }, opts);
79
+ break;
80
+ }
81
+ case "machine-list":
82
+ result = await runMachineList(runPodman, opts);
83
+ break;
84
+ case "machine-start":
85
+ result = await runMachineStart(runPodman, { name: parsed.rest[0] }, opts);
86
+ break;
87
+ default:
88
+ notify(ctx, `Subcomando desconocido: ${parsed.action}\n\n${HELP_TEXT}`, "warning");
89
+ return;
90
+ }
91
+ notify(ctx, result.text, result.ok ? "info" : "error");
92
+ }
93
+
94
+ export function registerPodmanCommand(pi: ExtensionAPI): void {
95
+ pi.registerCommand("podman", {
96
+ description: "Administrá sandboxes de Podman: status | list | run | stop | remove | machine-list | machine-start",
97
+ getArgumentCompletions: completePodmanArgs,
98
+ handler: async (args, ctx) => {
99
+ await runCommand(ctx, args);
100
+ },
101
+ });
102
+ }
@@ -0,0 +1,28 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const PODMAN_ACTIONS = [
4
+ { value: "status", description: "resumen de Podman y sus recursos" },
5
+ { value: "list", description: "lista todos los contenedores" },
6
+ { value: "run", description: "ejecuta un sandbox efímero restringido" },
7
+ { value: "stop", description: "detiene un contenedor" },
8
+ { value: "remove", description: "elimina un contenedor (pide confirmación)" },
9
+ { value: "machine-list", description: "lista máquinas de Podman" },
10
+ { value: "machine-start", description: "inicia una máquina de Podman" },
11
+ ] as const;
12
+
13
+ const ACTION_NAMES = PODMAN_ACTIONS.map(({ value }) => value);
14
+ export const PODMAN_SELECT_ITEMS = PODMAN_ACTIONS.map(({ value, description }) => `${value} — ${description}`);
15
+
16
+ export function completePodmanArgs(prefix: string): { value: string; label: string }[] | null {
17
+ if (prefix.trim().split(/\s+/).length > 1) return null;
18
+ const needle = prefix.trim().toLowerCase();
19
+ const matches = ACTION_NAMES.filter((action) => action.startsWith(needle));
20
+ return matches.length ? matches.map((value) => ({ value, label: value })) : null;
21
+ }
22
+
23
+ export async function resolvePodmanInput(input: string, ctx: ExtensionContext): Promise<string> {
24
+ const trimmed = input.trim();
25
+ if (trimmed || !ctx.hasUI || typeof ctx.ui?.select !== "function") return trimmed;
26
+ const choice = await ctx.ui.select("Acción de Podman", PODMAN_SELECT_ITEMS);
27
+ return choice?.split(/\s+/)[0] ?? "";
28
+ }
package/command.ts ADDED
@@ -0,0 +1,37 @@
1
+ /** Parseo deliberadamente pequeño para `/podman`; los comandos internos llegan como argv tras `--`. */
2
+
3
+ export function parsePodmanCommand(input: string): { action: string; rest: string[]; command: string[] } {
4
+ const trimmed = (input ?? "").trim();
5
+ const separator = trimmed.indexOf(" -- ");
6
+ const head = separator >= 0 ? trimmed.slice(0, separator) : trimmed;
7
+ const command =
8
+ separator >= 0
9
+ ? trimmed
10
+ .slice(separator + 4)
11
+ .split(/\s+/)
12
+ .filter(Boolean)
13
+ : [];
14
+ const tokens = head.split(/\s+/).filter(Boolean);
15
+ const action = (tokens.shift() ?? "status").toLowerCase();
16
+ return { action, rest: tokens, command };
17
+ }
18
+
19
+ /** `/podman run` solo acepta una política de red explícita; no es un passthrough de flags. */
20
+ export function parseRunOptions(tokens: string[]): { image?: string; network?: "none" | "default"; error?: string } {
21
+ let network: "none" | "default" | undefined;
22
+ const positional: string[] = [];
23
+ for (let index = 0; index < tokens.length; index += 1) {
24
+ const token = tokens[index];
25
+ if (token === "--network") {
26
+ const value = tokens[index + 1];
27
+ if (value !== "none" && value !== "default") return { error: "--network solo acepta none o default." };
28
+ network = value;
29
+ index += 1;
30
+ continue;
31
+ }
32
+ if (token.startsWith("-")) return { error: `run no admite la flag ${token}.` };
33
+ positional.push(token);
34
+ }
35
+ if (positional.length !== 1) return { error: "run requiere exactamente una image antes de --." };
36
+ return { image: positional[0], network };
37
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Opciones compartidas por comando y tool: cwd, signal y timeout de Podman.
3
+ */
4
+
5
+ import { DEFAULT_PODMAN_TIMEOUT_MS, parseTimeoutMs } from "./podman.js";
6
+
7
+ export function buildHandlerOpts(cwd: string, signal: AbortSignal | null | undefined) {
8
+ return {
9
+ cwd,
10
+ signal: signal ?? undefined,
11
+ timeoutMs: parseTimeoutMs(process.env.PI_PODMAN_TIMEOUT_MS, DEFAULT_PODMAN_TIMEOUT_MS),
12
+ };
13
+ }
package/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * pandi-podman: superficie reducida de Podman para Pi.
3
+ */
4
+
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { registerPodmanCommand } from "./command-handler.js";
7
+ import { registerPodmanSandboxTool } from "./tool-handler.js";
8
+
9
+ export * from "./public-api.js";
10
+
11
+ export default function podmanExtension(pi: ExtensionAPI): void {
12
+ registerPodmanCommand(pi);
13
+ registerPodmanSandboxTool(pi);
14
+ }
package/notify.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Helper de notificación al usuario, local a esta extensión para que siga
3
+ * siendo autocontenida.
4
+ *
5
+ * DUPLICACIÓN INTENCIONAL: vive una copia byte-idéntica en cada extensión que
6
+ * la necesita (pandi-plan, pandi-loop, pandi-goal, pandi-dynamic-workflows), en
7
+ * vez de un import cross-extension `../shared/`. Pi carga cada extensión de
8
+ * forma autocontenida (un solo archivo o un directorio con sus PROPIOS helpers,
9
+ * vía resolución de filesystem de jiti), así que un import `../shared/` solo
10
+ * resuelve mientras todo el paquete está co-instalado y se rompe bajo
11
+ * distribución por extensión. Mantené las copias sincronizadas a mano; la
12
+ * función es chica y estable.
13
+ *
14
+ * Desacoplado del SDK mediante un contexto STRUCTURAL mínimo (`NotifyContext`),
15
+ * así que no importa `ExtensionContext`; cualquier `ExtensionContext` real lo
16
+ * satisface.
17
+ *
18
+ * NOTE: esta familia autocontenida ahora también comparte el contrato
19
+ * endurecido de ruteo a stderr. pandi-docs lleva el mismo comportamiento con un
20
+ * import directo del contexto del SDK, y pandi-mdview todavía conserva un tipo de
21
+ * contexto solo de comando.
22
+ */
23
+
24
+ export type NotifyType = "info" | "warning" | "error";
25
+
26
+ export interface NotifyContext {
27
+ mode: string;
28
+ hasUI: boolean;
29
+ ui?: { notify(message: string, type?: NotifyType): void };
30
+ }
31
+
32
+ /**
33
+ * Muestra un mensaje al usuario.
34
+ *
35
+ * - modo print: escribe info en stdout, warnings/errors en stderr, y retorna.
36
+ * - interactivo con UI: delega en `ctx.ui.notify`.
37
+ * - headless sin UI: mantiene info en silencio, pero muestra warnings/errors en stderr.
38
+ *
39
+ * El guard de truthiness de `ctx.ui` preserva un no-op para test doubles
40
+ * estructurales que omiten `ui`, aunque la invariante real es que `hasUI`
41
+ * implica `ui`.
42
+ */
43
+ export function notify(ctx: NotifyContext, message: string, type: NotifyType = "info"): void {
44
+ if (ctx.mode === "print") {
45
+ (type === "info" ? console.log : console.error)(message);
46
+ return;
47
+ }
48
+ if (ctx.hasUI && ctx.ui) {
49
+ ctx.ui.notify(message, type);
50
+ return;
51
+ }
52
+ if (type !== "info") console.error(message);
53
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@pandi-coding-agent/pandi-podman",
3
+ "version": "0.1.0",
4
+ "description": "Extensión de Pi para ejecutar sandboxes efímeros y restringidos con Podman mediante /podman y la tool podman_sandbox.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/andrestobelem/pandi-extensions.git",
9
+ "directory": "extensions/pandi-podman"
10
+ },
11
+ "type": "module",
12
+ "keywords": [
13
+ "pi-package",
14
+ "pi",
15
+ "pi-extension",
16
+ "podman",
17
+ "container",
18
+ "sandbox"
19
+ ],
20
+ "files": [
21
+ "index.ts",
22
+ "public-api.ts",
23
+ "podman.ts",
24
+ "command-handler.ts",
25
+ "command.ts",
26
+ "command-menu.ts",
27
+ "handler-opts.ts",
28
+ "notify.ts",
29
+ "tool-handler.ts",
30
+ "tool-results.ts",
31
+ "README.md"
32
+ ],
33
+ "pi": {
34
+ "extensions": [
35
+ "./index.ts"
36
+ ]
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-ai": "^0.80.3",
40
+ "@earendil-works/pi-coding-agent": "^0.80.3",
41
+ "typebox": "^1.1.38"
42
+ }
43
+ }
package/podman.ts ADDED
@@ -0,0 +1,617 @@
1
+ /**
2
+ * Núcleo standalone de pandi-podman: spawn argv, parseo JSON y handlers puros.
3
+ *
4
+ * Podman nunca recibe una cadena de shell. Los handlers solo construyen el
5
+ * subconjunto reducido de argv que publica la extensión, para que la tool no
6
+ * se convierta en un passthrough de mounts, puertos o privilegios del host.
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import { StringDecoder } from "node:string_decoder";
11
+
12
+ export interface PodmanResult {
13
+ ok: boolean;
14
+ stdout: string;
15
+ stderr: string;
16
+ exitCode?: number;
17
+ /** true únicamente cuando venció timeoutMs. */
18
+ timedOut?: boolean;
19
+ /** true únicamente cuando la AbortSignal externa pidió terminar el proceso. */
20
+ aborted?: boolean;
21
+ /** Señal que cerró al hijo, incluida SIGKILL después de agotar la gracia. */
22
+ signal?: NodeJS.Signals;
23
+ stdoutTruncated?: boolean;
24
+ stderrTruncated?: boolean;
25
+ spawnError?: string;
26
+ }
27
+
28
+ export interface RunPodmanOptions {
29
+ cwd?: string;
30
+ signal?: AbortSignal;
31
+ timeoutMs?: number;
32
+ bin?: string;
33
+ }
34
+
35
+ export const DEFAULT_PODMAN_TIMEOUT_MS = 120_000;
36
+ export const MIN_PODMAN_TIMEOUT_MS = 1_000;
37
+ const MAX_PODMAN_OUTPUT_BYTES = 1_000_000;
38
+ const PODMAN_TERMINATION_GRACE_MS = 250;
39
+ const DEFAULT_CPUS = 2;
40
+ const DEFAULT_MEMORY = "1G";
41
+ const DEFAULT_PIDS_LIMIT = 256;
42
+
43
+ export function parseTimeoutMs(raw: string | undefined, fallback = DEFAULT_PODMAN_TIMEOUT_MS): number {
44
+ const value = Number(raw);
45
+ if (!Number.isFinite(value) || value <= 0) return fallback;
46
+ return Math.max(MIN_PODMAN_TIMEOUT_MS, Math.floor(value));
47
+ }
48
+
49
+ export type RunPodman = (args: string[], options?: RunPodmanOptions) => Promise<PodmanResult>;
50
+
51
+ function createBoundedOutput(): {
52
+ append(chunk: Buffer | string): void;
53
+ readonly text: string;
54
+ readonly truncated: boolean;
55
+ } {
56
+ const decoder = new StringDecoder("utf8");
57
+ const chunks: string[] = [];
58
+ let bytes = 0;
59
+ let truncated = false;
60
+ let finalized: string | undefined;
61
+ return {
62
+ append(chunk): void {
63
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
64
+ const remaining = MAX_PODMAN_OUTPUT_BYTES - bytes;
65
+ if (buffer.length > remaining) truncated = true;
66
+ if (remaining <= 0) return;
67
+ const kept = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
68
+ const decoded = decoder.write(kept);
69
+ if (decoded) chunks.push(decoded);
70
+ bytes += kept.length;
71
+ },
72
+ get text(): string {
73
+ if (finalized === undefined) {
74
+ if (!truncated) {
75
+ const tail = decoder.end();
76
+ if (tail) chunks.push(tail);
77
+ }
78
+ finalized = chunks.join("");
79
+ }
80
+ return finalized;
81
+ },
82
+ get truncated(): boolean {
83
+ return truncated;
84
+ },
85
+ };
86
+ }
87
+
88
+ /** Ejecuta la CLI sin shell; errores del proceso vuelven como datos para la UI/tool. */
89
+ export function runPodman(args: string[], options: RunPodmanOptions = {}): Promise<PodmanResult> {
90
+ const { cwd, signal, timeoutMs = DEFAULT_PODMAN_TIMEOUT_MS, bin = "podman" } = options;
91
+ return new Promise((resolve) => {
92
+ const stdout = createBoundedOutput();
93
+ const stderr = createBoundedOutput();
94
+ let settled = false;
95
+ let termination: "timeout" | "abort" | undefined;
96
+ let spawnError: string | undefined;
97
+ let timeoutTimer: NodeJS.Timeout | undefined;
98
+ let killTimer: NodeJS.Timeout | undefined;
99
+ const useProcessGroup = process.platform !== "win32";
100
+ const child = spawn(bin, args, { cwd, detached: useProcessGroup, windowsHide: true });
101
+
102
+ const sendSignal = (processSignal: NodeJS.Signals): void => {
103
+ if (useProcessGroup && child.pid && child.pid !== process.pid) {
104
+ try {
105
+ process.kill(-child.pid, processSignal);
106
+ return;
107
+ } catch {
108
+ // El fallback conserva el comportamiento en hosts sin group kill.
109
+ }
110
+ }
111
+ try {
112
+ child.kill(processSignal);
113
+ } catch {
114
+ // El proceso ya cerró.
115
+ }
116
+ };
117
+
118
+ const finish = (code: number | null, childSignal: NodeJS.Signals | null): void => {
119
+ if (settled) return;
120
+ settled = true;
121
+ if (timeoutTimer) clearTimeout(timeoutTimer);
122
+ if (killTimer) clearTimeout(killTimer);
123
+ if (signal) signal.removeEventListener("abort", onAbort);
124
+ resolve({
125
+ ok: !spawnError && !termination && code === 0,
126
+ stdout: stdout.text,
127
+ stderr: stderr.text,
128
+ exitCode: spawnError ? undefined : (code ?? undefined),
129
+ timedOut: termination === "timeout",
130
+ aborted: termination === "abort",
131
+ signal: childSignal ?? undefined,
132
+ stdoutTruncated: stdout.truncated,
133
+ stderrTruncated: stderr.truncated,
134
+ spawnError,
135
+ });
136
+ };
137
+
138
+ const terminate = (reason: "timeout" | "abort"): void => {
139
+ if (settled || termination) return;
140
+ termination = reason;
141
+ sendSignal("SIGTERM");
142
+ killTimer = setTimeout(() => {
143
+ if (!settled) sendSignal("SIGKILL");
144
+ }, PODMAN_TERMINATION_GRACE_MS);
145
+ killTimer.unref?.();
146
+ };
147
+
148
+ const onAbort = (): void => terminate("abort");
149
+
150
+ child.stdout?.on("data", (chunk: Buffer) => stdout.append(chunk));
151
+ child.stderr?.on("data", (chunk: Buffer) => stderr.append(chunk));
152
+ child.once("error", (error: Error) => {
153
+ spawnError = error.message;
154
+ });
155
+ child.once("close", finish);
156
+
157
+ timeoutTimer = setTimeout(() => terminate("timeout"), timeoutMs);
158
+ timeoutTimer.unref?.();
159
+ if (signal) {
160
+ if (signal.aborted) onAbort();
161
+ else signal.addEventListener("abort", onAbort, { once: true });
162
+ }
163
+ });
164
+ }
165
+
166
+ const CONTAINER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
167
+
168
+ export function validateContainerName(name: string): boolean {
169
+ return typeof name === "string" && CONTAINER_NAME_RE.test(name);
170
+ }
171
+
172
+ /** Acepta referencias OCI generales, pero nunca valores que Podman pueda interpretar como flags. */
173
+ export function validateImageReference(image: string): boolean {
174
+ return typeof image === "string" && image.length > 0 && image.length <= 512 && /^[^\s-][^\s]*$/.test(image);
175
+ }
176
+
177
+ function record(value: unknown): Record<string, unknown> | undefined {
178
+ return typeof value === "object" && value !== null && !Array.isArray(value)
179
+ ? (value as Record<string, unknown>)
180
+ : undefined;
181
+ }
182
+
183
+ function text(value: unknown): string | undefined {
184
+ return typeof value === "string" && value.length > 0 ? value : undefined;
185
+ }
186
+
187
+ function number(value: unknown): number | undefined {
188
+ if (typeof value === "number" && Number.isFinite(value)) return value;
189
+ if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
190
+ return undefined;
191
+ }
192
+
193
+ export interface ContainerEntry {
194
+ id: string;
195
+ name?: string;
196
+ image?: string;
197
+ state?: string;
198
+ status?: string;
199
+ createdAt?: string;
200
+ }
201
+
202
+ export function parseContainerList(jsonText: string): ContainerEntry[] {
203
+ try {
204
+ const parsed: unknown = JSON.parse(jsonText);
205
+ if (!Array.isArray(parsed)) return [];
206
+ return parsed
207
+ .map((value) => record(value))
208
+ .filter((value): value is Record<string, unknown> => Boolean(value))
209
+ .map((row) => {
210
+ const names = Array.isArray(row.Names) ? row.Names : [];
211
+ return {
212
+ id: text(row.Id) ?? text(row.ID) ?? text(row.id) ?? "",
213
+ name: text(names[0]) ?? text(row.Name) ?? text(row.Names),
214
+ image: text(row.Image) ?? text(row.image),
215
+ state: text(row.State) ?? text(row.state),
216
+ status: text(row.Status) ?? text(row.status),
217
+ createdAt: text(row.CreatedAt) ?? text(row.createdAt),
218
+ };
219
+ })
220
+ .filter((entry) => entry.id.length > 0);
221
+ } catch {
222
+ return [];
223
+ }
224
+ }
225
+
226
+ export function formatContainerList(entries: ContainerEntry[]): string {
227
+ if (entries.length === 0) return "No hay contenedores de Podman.";
228
+ return entries
229
+ .map((entry) => ` ${entry.name ?? entry.id.slice(0, 12)} (${entry.state ?? "?"}, ${entry.image ?? "imagen ?"})`)
230
+ .join("\n");
231
+ }
232
+
233
+ export interface MachineEntry {
234
+ name: string;
235
+ isDefault?: boolean;
236
+ running?: boolean;
237
+ cpus?: number;
238
+ memory?: number;
239
+ diskSize?: number;
240
+ vmType?: string;
241
+ }
242
+
243
+ export function parseMachineList(jsonText: string): MachineEntry[] {
244
+ try {
245
+ const parsed: unknown = JSON.parse(jsonText);
246
+ if (!Array.isArray(parsed)) return [];
247
+ return parsed
248
+ .map((value) => record(value))
249
+ .filter((value): value is Record<string, unknown> => Boolean(value))
250
+ .map((row) => ({
251
+ name: text(row.Name) ?? text(row.name) ?? "",
252
+ isDefault: row.Default === true || row.default === true,
253
+ running: row.Running === true || row.running === true,
254
+ cpus: number(row.CPUs) ?? number(row.cpus),
255
+ memory: number(row.Memory) ?? number(row.memory),
256
+ diskSize: number(row.DiskSize) ?? number(row.diskSize),
257
+ vmType: text(row.VMType) ?? text(row.vmType),
258
+ }))
259
+ .filter((entry) => entry.name.length > 0);
260
+ } catch {
261
+ return [];
262
+ }
263
+ }
264
+
265
+ function humanBytes(bytes: number | undefined): string {
266
+ if (bytes == null || bytes < 0) return "?";
267
+ const units = ["B", "K", "M", "G", "T"];
268
+ let value = bytes;
269
+ let unit = 0;
270
+ while (value >= 1024 && unit < units.length - 1) {
271
+ value /= 1024;
272
+ unit += 1;
273
+ }
274
+ const rounded = value >= 100 || Number.isInteger(value) ? Math.round(value) : Math.round(value * 10) / 10;
275
+ return `${rounded}${units[unit]}`;
276
+ }
277
+
278
+ export function formatMachineList(entries: MachineEntry[]): string {
279
+ if (entries.length === 0) return "No hay máquinas de Podman.";
280
+ return entries
281
+ .map((entry) => {
282
+ const defaultMark = entry.isDefault ? "* " : " ";
283
+ const bits = [
284
+ entry.running ? "running" : "stopped",
285
+ entry.vmType,
286
+ entry.cpus && `${entry.cpus}cpu`,
287
+ entry.memory && `mem=${humanBytes(entry.memory)}`,
288
+ ]
289
+ .filter(Boolean)
290
+ .join(", ");
291
+ return `${defaultMark}${entry.name} (${bits})`;
292
+ })
293
+ .join("\n");
294
+ }
295
+
296
+ export interface PodmanInfo {
297
+ version?: string;
298
+ rootless?: boolean;
299
+ containers?: number;
300
+ images?: number;
301
+ }
302
+
303
+ export function parseInfo(jsonText: string): PodmanInfo {
304
+ try {
305
+ const parsed = record(JSON.parse(jsonText));
306
+ const host = record(parsed?.host);
307
+ const security = record(host?.security);
308
+ const store = record(parsed?.store);
309
+ const containers = record(store?.containerStore);
310
+ const images = record(store?.imageStore);
311
+ const version = record(parsed?.version);
312
+ return {
313
+ version: text(version?.Version) ?? text(version?.version),
314
+ rootless: security?.rootless === true,
315
+ containers: number(containers?.number),
316
+ images: number(images?.number),
317
+ };
318
+ } catch {
319
+ return {};
320
+ }
321
+ }
322
+
323
+ // Constructores de argv: toda la política de superficie permitida queda visible y testeable acá.
324
+ export function buildInfoArgs(): string[] {
325
+ return ["info", "--format", "json"];
326
+ }
327
+
328
+ export function buildListArgs(): string[] {
329
+ return ["ps", "--all", "--format", "json"];
330
+ }
331
+
332
+ export function buildMachineListArgs(): string[] {
333
+ return ["machine", "list", "--format", "json"];
334
+ }
335
+
336
+ export function buildMachineStartArgs(name?: string): string[] {
337
+ return name ? ["machine", "start", name] : ["machine", "start"];
338
+ }
339
+
340
+ export function buildStopArgs(name: string): string[] {
341
+ return ["stop", name];
342
+ }
343
+
344
+ export function buildRemoveArgs(name: string): string[] {
345
+ return ["rm", "--force", name];
346
+ }
347
+
348
+ export interface SandboxParams {
349
+ image: string;
350
+ command: string[];
351
+ network?: "none" | "default";
352
+ workdir?: string;
353
+ cpus?: number;
354
+ memory?: string;
355
+ }
356
+
357
+ export function buildRunArgs(params: SandboxParams): string[] {
358
+ return [
359
+ "run",
360
+ "--rm",
361
+ "--network",
362
+ params.network ?? "none",
363
+ // Podman propaga proxy del host por defecto; desactivarlo preserva el contrato sin env del host.
364
+ "--http-proxy=false",
365
+ "--cap-drop",
366
+ "ALL",
367
+ "--security-opt",
368
+ "no-new-privileges",
369
+ "--pids-limit",
370
+ String(DEFAULT_PIDS_LIMIT),
371
+ "--read-only",
372
+ "--tmpfs",
373
+ "/tmp:rw,nosuid,nodev,noexec",
374
+ "--cpus",
375
+ String(params.cpus ?? DEFAULT_CPUS),
376
+ "--memory",
377
+ params.memory ?? DEFAULT_MEMORY,
378
+ ...(params.workdir ? ["--workdir", params.workdir] : []),
379
+ params.image,
380
+ ...params.command,
381
+ ];
382
+ }
383
+
384
+ const INSTALL_HINT =
385
+ process.platform === "darwin"
386
+ ? "No se encontró Podman. Instalalo con: brew install podman"
387
+ : "No se encontró Podman. Instalalo con el gestor de paquetes de tu sistema.";
388
+
389
+ function outputTruncationDetails(result: PodmanResult): Record<string, true> {
390
+ return {
391
+ ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
392
+ ...(result.stderrTruncated ? { stderrTruncated: true } : {}),
393
+ };
394
+ }
395
+
396
+ function describeOutputTruncation(result: PodmanResult): string | undefined {
397
+ const streams = [
398
+ result.stdoutTruncated ? "stdout" : undefined,
399
+ result.stderrTruncated ? "stderr" : undefined,
400
+ ].filter((stream): stream is string => Boolean(stream));
401
+ return streams.length
402
+ ? `La salida de ${streams.join(" y ")} fue truncada al límite de ${MAX_PODMAN_OUTPUT_BYTES} bytes.`
403
+ : undefined;
404
+ }
405
+
406
+ export function describePodmanError(result: PodmanResult, action: string): string {
407
+ const truncation = describeOutputTruncation(result);
408
+ const withTruncation = (message: string): string => (truncation ? `${message} ${truncation}` : message);
409
+ if (result.spawnError)
410
+ return /ENOENT/i.test(result.spawnError)
411
+ ? withTruncation(INSTALL_HINT)
412
+ : withTruncation(`No se pudo ejecutar \`podman ${action}\`: ${result.spawnError}`);
413
+ if (result.timedOut) return withTruncation(`\`podman ${action}\` agotó el tiempo de espera.`);
414
+ if (result.aborted) return withTruncation(`\`podman ${action}\` fue abortado.`);
415
+ if (result.signal) return withTruncation(`\`podman ${action}\` terminó por señal ${result.signal}.`);
416
+ const detail = (result.stderr || result.stdout).trim();
417
+ return withTruncation(
418
+ detail
419
+ ? `\`podman ${action}\` falló: ${detail}`
420
+ : `\`podman ${action}\` falló (salida ${result.exitCode ?? "?"}).`,
421
+ );
422
+ }
423
+
424
+ export interface HandlerResult {
425
+ ok: boolean;
426
+ text: string;
427
+ details: Record<string, unknown>;
428
+ }
429
+
430
+ export interface HandlerOpts {
431
+ cwd?: string;
432
+ signal?: AbortSignal;
433
+ timeoutMs?: number;
434
+ platform?: NodeJS.Platform;
435
+ }
436
+
437
+ function handlerError(action: string, text: string, details: Record<string, unknown> = {}): HandlerResult {
438
+ return { ok: false, text, details: { isError: true, action, ...details } };
439
+ }
440
+
441
+ function isMachinePlatform(platform: NodeJS.Platform = process.platform): boolean {
442
+ return platform === "darwin" || platform === "win32";
443
+ }
444
+
445
+ function validWorkdir(workdir: string | undefined): boolean {
446
+ return workdir == null || (workdir.startsWith("/") && !workdir.includes("\0"));
447
+ }
448
+
449
+ function memoryBytes(memory: string): number | undefined {
450
+ const match = /^(\d+(?:\.\d+)?)([KMG])$/i.exec(memory);
451
+ if (!match) return undefined;
452
+ const units = { K: 1024, M: 1024 ** 2, G: 1024 ** 3 } as const;
453
+ return Number(match[1]) * units[match[2].toUpperCase() as keyof typeof units];
454
+ }
455
+
456
+ function validateSandbox(params: SandboxParams): string | undefined {
457
+ if (!validateImageReference(params.image))
458
+ return "run requiere una referencia de image válida, sin espacios ni flags.";
459
+ if (
460
+ !Array.isArray(params.command) ||
461
+ params.command.length === 0 ||
462
+ params.command.some((arg) => typeof arg !== "string")
463
+ )
464
+ return "run requiere un array command no vacío (argv).";
465
+ if (params.network != null && params.network !== "none" && params.network !== "default")
466
+ return "network solo puede ser 'none' (predeterminado) o 'default' (opt-in).";
467
+ if (!validWorkdir(params.workdir)) return "workdir debe ser una ruta absoluta dentro del contenedor.";
468
+ if (params.cpus != null && (!Number.isFinite(params.cpus) || params.cpus <= 0 || params.cpus > DEFAULT_CPUS))
469
+ return `cpus debe estar entre 0 y ${DEFAULT_CPUS}; solo se permiten límites iguales o más estrictos.`;
470
+ if (params.memory != null) {
471
+ const bytes = memoryBytes(params.memory);
472
+ if (bytes == null || bytes < 16 * 1024 ** 2 || bytes > 1024 ** 3)
473
+ return "memory debe estar entre 16M y 1G; solo se permiten límites iguales o más estrictos.";
474
+ }
475
+ return undefined;
476
+ }
477
+
478
+ export async function runStatus(run: RunPodman, opts: HandlerOpts): Promise<HandlerResult> {
479
+ const infoResult = await run(buildInfoArgs(), opts);
480
+ const infoTruncation = describeOutputTruncation(infoResult);
481
+ if (!infoResult.ok) {
482
+ if (infoTruncation)
483
+ return handlerError("status", describePodmanError(infoResult, "info"), outputTruncationDetails(infoResult));
484
+ if (!isMachinePlatform(opts.platform))
485
+ return handlerError("status", describePodmanError(infoResult, "info"), outputTruncationDetails(infoResult));
486
+ const machinesResult = await run(buildMachineListArgs(), opts);
487
+ const machinesTruncation = describeOutputTruncation(machinesResult);
488
+ if (machinesTruncation)
489
+ return handlerError(
490
+ "status",
491
+ machinesResult.ok ? machinesTruncation : describePodmanError(machinesResult, "machine list"),
492
+ outputTruncationDetails(machinesResult),
493
+ );
494
+ if (!machinesResult.ok)
495
+ return handlerError("status", describePodmanError(infoResult, "info"), outputTruncationDetails(infoResult));
496
+ const machines = parseMachineList(machinesResult.stdout);
497
+ return handlerError(
498
+ "status",
499
+ `Podman no está disponible. Revisá las máquinas:\n${formatMachineList(machines)}\n\nIniciá una con: /podman machine-start [name]`,
500
+ { machines },
501
+ );
502
+ }
503
+ if (infoTruncation) return handlerError("status", infoTruncation, outputTruncationDetails(infoResult));
504
+ const info = parseInfo(infoResult.stdout);
505
+ let machines: MachineEntry[] | undefined;
506
+ if (isMachinePlatform(opts.platform)) {
507
+ const machinesResult = await run(buildMachineListArgs(), opts);
508
+ const machinesTruncation = describeOutputTruncation(machinesResult);
509
+ if (machinesTruncation)
510
+ return handlerError(
511
+ "status",
512
+ machinesResult.ok ? machinesTruncation : describePodmanError(machinesResult, "machine list"),
513
+ outputTruncationDetails(machinesResult),
514
+ );
515
+ if (machinesResult.ok) machines = parseMachineList(machinesResult.stdout);
516
+ }
517
+ const summary = [
518
+ `Podman ${info.version ?? "(versión desconocida)"}${info.rootless === true ? " · rootless" : ""}`,
519
+ info.containers != null ? `Contenedores: ${info.containers}` : null,
520
+ info.images != null ? `Imágenes: ${info.images}` : null,
521
+ machines ? `\nMáquinas:\n${formatMachineList(machines)}` : null,
522
+ ]
523
+ .filter(Boolean)
524
+ .join("\n");
525
+ return { ok: true, text: summary, details: { action: "status", info, ...(machines ? { machines } : {}) } };
526
+ }
527
+
528
+ export async function runList(run: RunPodman, opts: HandlerOpts): Promise<HandlerResult> {
529
+ const result = await run(buildListArgs(), opts);
530
+ if (!result.ok) return handlerError("list", describePodmanError(result, "ps"), outputTruncationDetails(result));
531
+ const truncation = describeOutputTruncation(result);
532
+ if (truncation) return handlerError("list", truncation, outputTruncationDetails(result));
533
+ const containers = parseContainerList(result.stdout);
534
+ return {
535
+ ok: true,
536
+ text: formatContainerList(containers),
537
+ details: { action: "list", count: containers.length, containers },
538
+ };
539
+ }
540
+
541
+ export async function runSandbox(run: RunPodman, params: SandboxParams, opts: HandlerOpts): Promise<HandlerResult> {
542
+ const error = validateSandbox(params);
543
+ if (error) return handlerError("run", error);
544
+ const result = await run(buildRunArgs(params), opts);
545
+ if (!result.ok) return handlerError("run", describePodmanError(result, "run"), outputTruncationDetails(result));
546
+ const output = result.stdout.trim() || "(sin salida) — sandbox efímero finalizado.";
547
+ const truncation = describeOutputTruncation(result);
548
+ return {
549
+ ok: true,
550
+ text: truncation ? `${output}\n\nAdvertencia: ${truncation}` : output,
551
+ details: {
552
+ action: "run",
553
+ image: params.image,
554
+ exitCode: result.exitCode,
555
+ ...outputTruncationDetails(result),
556
+ },
557
+ };
558
+ }
559
+
560
+ export async function runStop(run: RunPodman, params: { name?: string }, opts: HandlerOpts): Promise<HandlerResult> {
561
+ if (!params.name || !validateContainerName(params.name))
562
+ return handlerError("stop", "stop requiere un nombre de contenedor válido.");
563
+ const result = await run(buildStopArgs(params.name), opts);
564
+ if (!result.ok) return handlerError("stop", describePodmanError(result, "stop"));
565
+ return { ok: true, text: `Se detuvo el contenedor ${params.name}.`, details: { action: "stop", name: params.name } };
566
+ }
567
+
568
+ export async function runRemove(
569
+ run: RunPodman,
570
+ params: { name?: string; force?: boolean },
571
+ opts: HandlerOpts,
572
+ ): Promise<HandlerResult> {
573
+ if (!params.name || !validateContainerName(params.name))
574
+ return handlerError("remove", "remove requiere un nombre de contenedor válido.");
575
+ if (!params.force)
576
+ return handlerError("remove", `Me niego a eliminar el contenedor "${params.name}" sin force.`, {
577
+ needsForce: true,
578
+ name: params.name,
579
+ });
580
+ const result = await run(buildRemoveArgs(params.name), opts);
581
+ if (!result.ok) return handlerError("remove", describePodmanError(result, "rm"));
582
+ return {
583
+ ok: true,
584
+ text: `Se eliminó el contenedor ${params.name}.`,
585
+ details: { action: "remove", name: params.name },
586
+ };
587
+ }
588
+
589
+ export async function runMachineList(run: RunPodman, opts: HandlerOpts): Promise<HandlerResult> {
590
+ const result = await run(buildMachineListArgs(), opts);
591
+ if (!result.ok)
592
+ return handlerError("machine-list", describePodmanError(result, "machine list"), outputTruncationDetails(result));
593
+ const truncation = describeOutputTruncation(result);
594
+ if (truncation) return handlerError("machine-list", truncation, outputTruncationDetails(result));
595
+ const machines = parseMachineList(result.stdout);
596
+ return {
597
+ ok: true,
598
+ text: formatMachineList(machines),
599
+ details: { action: "machine-list", count: machines.length, machines },
600
+ };
601
+ }
602
+
603
+ export async function runMachineStart(
604
+ run: RunPodman,
605
+ params: { name?: string },
606
+ opts: HandlerOpts,
607
+ ): Promise<HandlerResult> {
608
+ if (params.name && !validateContainerName(params.name))
609
+ return handlerError("machine-start", "Nombre de máquina inválido.");
610
+ const result = await run(buildMachineStartArgs(params.name), opts);
611
+ if (!result.ok) return handlerError("machine-start", describePodmanError(result, "machine start"));
612
+ return {
613
+ ok: true,
614
+ text: `Se inició la máquina de Podman ${params.name ?? "predeterminada"}.`,
615
+ details: { action: "machine-start", name: params.name },
616
+ };
617
+ }
package/public-api.ts ADDED
@@ -0,0 +1,28 @@
1
+ export { parsePodmanCommand, parseRunOptions } from "./command.js";
2
+ export { completePodmanArgs, PODMAN_SELECT_ITEMS, resolvePodmanInput } from "./command-menu.js";
3
+ export {
4
+ buildInfoArgs,
5
+ buildListArgs,
6
+ buildMachineListArgs,
7
+ buildMachineStartArgs,
8
+ buildRemoveArgs,
9
+ buildRunArgs,
10
+ buildStopArgs,
11
+ describePodmanError,
12
+ formatContainerList,
13
+ formatMachineList,
14
+ parseContainerList,
15
+ parseInfo,
16
+ parseMachineList,
17
+ parseTimeoutMs,
18
+ runList,
19
+ runMachineList,
20
+ runMachineStart,
21
+ runPodman,
22
+ runRemove,
23
+ runSandbox,
24
+ runStatus,
25
+ runStop,
26
+ validateContainerName,
27
+ validateImageReference,
28
+ } from "./podman.js";
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Tool `podman_sandbox` invocable por el modelo.
3
+ */
4
+
5
+ import { StringEnum } from "@earendil-works/pi-ai";
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import { Type } from "typebox";
8
+ import { PODMAN_ACTIONS } from "./command-menu.js";
9
+ import { buildHandlerOpts } from "./handler-opts.js";
10
+ import {
11
+ runList,
12
+ runMachineList,
13
+ runMachineStart,
14
+ runPodman,
15
+ runRemove,
16
+ runSandbox,
17
+ runStatus,
18
+ runStop,
19
+ } from "./podman.js";
20
+ import { toolError, toToolResult } from "./tool-results.js";
21
+
22
+ const ACTIONS = PODMAN_ACTIONS.map(({ value }) => value);
23
+
24
+ export function registerPodmanSandboxTool(pi: ExtensionAPI): void {
25
+ pi.registerTool({
26
+ name: "podman_sandbox",
27
+ label: "Sandbox de Podman",
28
+ description:
29
+ "Ejecutá y administrá un subconjunto restringido de Podman. run crea solo un contenedor efímero, sin mounts, puertos, variables de entorno, privilegios ni flags libres; usa red none, rootfs read-only, capabilities eliminadas y límites de recursos por defecto. Podman siempre se invoca con argv, nunca mediante shell.",
30
+ promptSnippet: "Usá podman_sandbox para un contenedor efímero y restringido cuando el usuario pida Podman.",
31
+ promptGuidelines: [
32
+ "run requiere image y command como array argv; nunca pases un string de shell.",
33
+ "La red está deshabilitada por defecto. Usá network:'default' solo cuando el usuario o la tarea requiera explícitamente acceso de red.",
34
+ "No hay mounts, puertos, secrets/env, devices ni privilegios en esta surface. No intentes suplirlos con argumentos de texto.",
35
+ "remove nunca borra por defecto: pasá force:true solo después de que el usuario acepte eliminar ese contenedor.",
36
+ "Podman no debe tratarse como una frontera infalible contra código hostil, sobre todo en Linux. Para una micro-VM Apple preferí container_sandbox cuando aplique.",
37
+ ],
38
+ parameters: Type.Object({
39
+ action: StringEnum(ACTIONS),
40
+ name: Type.Optional(
41
+ Type.String({ description: "Nombre de contenedor para stop/remove o de máquina para machine-start." }),
42
+ ),
43
+ image: Type.Optional(
44
+ Type.String({ description: "Referencia OCI para run, por ejemplo quay.io/podman/hello:latest." }),
45
+ ),
46
+ command: Type.Optional(
47
+ Type.Array(Type.String(), { description: 'Para run: comando como argv, por ejemplo ["uname", "-a"].' }),
48
+ ),
49
+ network: Type.Optional(
50
+ StringEnum(["none", "default"] as const, { description: "none por defecto; default solo como opt-in." }),
51
+ ),
52
+ workdir: Type.Optional(Type.String({ description: "Ruta absoluta dentro del contenedor para run." })),
53
+ cpus: Type.Optional(
54
+ Type.Number({
55
+ minimum: 0.1,
56
+ maximum: 2,
57
+ description: "Límite de CPU; solo puede endurecer el default de 2.",
58
+ }),
59
+ ),
60
+ memory: Type.Optional(
61
+ Type.String({ description: "Límite entre 16M y 1G; solo puede endurecer el default de 1G." }),
62
+ ),
63
+ force: Type.Optional(Type.Boolean({ description: "Para remove: confirma eliminar el contenedor." })),
64
+ }),
65
+ executionMode: "sequential",
66
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
67
+ const opts = buildHandlerOpts(ctx.cwd, signal);
68
+ switch (params.action) {
69
+ case "status":
70
+ return toToolResult(await runStatus(runPodman, opts));
71
+ case "list":
72
+ return toToolResult(await runList(runPodman, opts));
73
+ case "run":
74
+ return toToolResult(
75
+ await runSandbox(
76
+ runPodman,
77
+ {
78
+ image: params.image ?? "",
79
+ command: params.command ?? [],
80
+ network: params.network,
81
+ workdir: params.workdir,
82
+ cpus: params.cpus,
83
+ memory: params.memory,
84
+ },
85
+ opts,
86
+ ),
87
+ );
88
+ case "stop":
89
+ return toToolResult(await runStop(runPodman, { name: params.name }, opts));
90
+ case "remove":
91
+ return toToolResult(await runRemove(runPodman, { name: params.name, force: params.force }, opts));
92
+ case "machine-list":
93
+ return toToolResult(await runMachineList(runPodman, opts));
94
+ case "machine-start":
95
+ return toToolResult(await runMachineStart(runPodman, { name: params.name }, opts));
96
+ default:
97
+ return toolError(`Acción desconocida: ${params.action}`);
98
+ }
99
+ },
100
+ });
101
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Adaptadores de resultado para `podman_sandbox`.
3
+ */
4
+
5
+ import type { HandlerResult } from "./podman.js";
6
+
7
+ export function toolResult(text: string, details: Record<string, unknown> = {}) {
8
+ return { content: [{ type: "text" as const, text }], details };
9
+ }
10
+
11
+ export function toolError(text: string, details: Record<string, unknown> = {}) {
12
+ return toolResult(text, { isError: true, ...details });
13
+ }
14
+
15
+ export function toToolResult(result: HandlerResult) {
16
+ return toolResult(result.text, result.details);
17
+ }