diff --git a/CHANGELOG.md b/CHANGELOG.md index ed862d2..41a3907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,47 @@ All notable changes to RAME Code are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [0.3.0] - 2026-11-11 + +### Added + +- Long Missions can run bounded, resumable work with persistent jobs, progress evidence, host-observed measurements and multi-phase Programmes. A renewable phase lease prevents two VS Code windows from directing the same Programme phase at once and safely permits recovery after an expired owner. +- Autonomy controls now separate approval policy from command isolation. Full Access can remove routine prompts within its explicit grant, while protected verification always keeps its own mandatory boundary. Long jobs, bounded installs and local process limits have dedicated controls and records. +- Reinforced functional cases and differential references run one input at a time. Expected answers, future inputs and host records remain outside the evaluated process, and the host performs the comparison. +- LM Studio can offer a confirmed reload with a larger context after a short-context refusal. Local server errors retain the server's bounded message, distinguish setup failures from an unreachable server, and keep the selected model visible when discovery temporarily omits it. + +### Changed + +- The user guides (English and Spanish) explain every option of the Mission authorization card, and describe test isolation by level instead of saying that test scripts always run with the user's permissions. +- Full Access is now the top level of the chat's permission selector (Ask → Safe auto → Workspace full → Full Access). Choosing it asks for its usual confirmation with the parts, validity and environment chosen in Autonomy & Permissions; choosing a lower level turns it off at once. It never raises the profile underneath, so it covers only the parts you granted; when it expires, a notice shows the level you are back to. By default it lasts until you turn it off (no expiry); 1 hour to 30 days remain available in Autonomy & Permissions. The Meta and web pre-authorizations keep their own hours. + +### Fixed + +- Measured acceptance distinguishes host-observed metrics (`wall_ms`, `exit_code`) from values declared by the frozen project check (for example `p95_ms`, `errors`) through a private per-run channel. Every criterion and result states its class and warns that malicious code in the same process can alter project-declared figures; arbitrary project JSON and printed values have no authority. +- Protected verification uses an exact filesystem view and closed network on supported Linux, independently of Full Access or the terminal's `Total` setting. Without protected isolation, verification runs with a warning and results are marked unprotected («sin aislamiento protegido, la verificación se ejecuta con un aviso y los resultados se marcan como no protegidos»). +- Node 24 can load the host-owned case runner inside the protected view without granting access to the surrounding extension tree. +- Local HTTP 400 responses caused by an undersized LM Studio context now explain the required action instead of looking like a generic runtime failure. The same refusal sent inside a streamed reply (HTTP 200, as LM Studio sends it to Agent) is recognized too: the card says the request does not fit the model's context, offers the reload, and no longer reads «Glimmer answered». When LM Studio reports the context the model is loaded with, RAME now refuses a request that does not fit before sending it: LM Studio would otherwise retry by itself with the conversation cut to fit and stream the answer to the cut conversation. Turning cloud escalation on (or saving a key) after a local failure now offers «Run this request with Spark» on the same card; it still waits for approval. Rows of buttons have space above and below the text around them. The Mission's final report states the isolation each test really ran with, as the host noted it at run time (for example Project for the project's tests and the protected verifier for a frozen acceptance check), instead of a fixed «No sandbox» note that contradicted the host record. In «Web access for this Mission», the checkbox and each limit now sit on their own line. Expired Spark offers disappear instead of leaving an unusable action. + +### Known limitations + +- Protected verification requires Linux with Landlock ABI 4 or newer and `libseccomp.so.2`. On Windows, macOS, older/no Landlock or missing libseccomp, the check runs without isolation, with a prominent warning and an unprotected label in authorization, progress, result and evidence. Equivalent protected isolation on Windows and macOS remains the goal. +- Evaluated code necessarily sees its current input and can alter its own behavior or internal clocks. Only the host's external wall time and exit code are official measurements; those observations do not by themselves prove functional quality. +- Hidden cases provide operating-system-enforced separation of expected answers, future inputs and host evidence, but incomplete cases remain incomplete. Differential verification trusts the frozen original as its behavioral reference, so a defect shared with that original can pass. +- Programme ownership coordinates VS Code processes that share the same host storage. It is not a distributed lock across different computers or replicated stores. +- Local model behavior still depends on the server, model and loaded context. The LM Studio short-context path was checked with a real LM Studio server (refusal before sending at 4096, confirmed reload to 32768, then the same request completed). A server that cuts the conversation silently and does not report its loaded context cannot be detected yet; in LM Studio, set «Context Overflow» to «Stop at limit». + +### Security + +- Official measured acceptance and Programme baselines classify host observations separately from project declarations received only through the frozen check's private per-run channel. Existing Missions and Programmes that use `p95_ms`, `errors` or other valid project metrics continue without re-authorization. Same-process manipulation remains explicitly possible and visible. +- Protected and reinforced verifiers use the mandatory allowlisted filesystem view whenever it is available. Landlock confines reads and writes; seccomp denies TCP, UDP and Unix sockets plus cross-process inspection; recognized project secret files are excluded. Each functional/reference input runs fresh. Unsupported systems continue unprotected with the warning and label; Full Access/Total never weakens protection when available, and a protected→unprotected transition is recorded rather than stopping silently. +- Programme phases now use a renewable, machine-wide lease and monotonically increasing fencing token. Concurrent VS Code processes cannot start the same phase twice; expiry permits recovery, while late updates and releases from the previous owner are rejected. The lock is per machine, not distributed across computers. + +### Migration + +- No re-authorization is required for stored measured acceptance or Programmes naming valid project-declared metrics such as `p95_ms` or `errors`. Protected isolation currently requires Linux with Landlock ABI 4+ and `libseccomp`; elsewhere verification continues with the unprotected warning and label. + ## [0.2.0] - 2026-11-04 Experimental preview release. It introduces a modularized core, governed project memory, autonomous Missions, acceptance and causal verification, and a strict separation between Meta Advisory and Delegated execution. Modularization is not complete: the presentation assets `media/main.js` and `media/i18n.js` remain inherited exceptions. @@ -191,6 +232,7 @@ The integrated hackathon baseline. It was not yet modular: most of the extension - The local audit is bounded but is not tamper-proof. - Project test scripts run with the operating-system permissions of VS Code. -[Unreleased]: https://github.com/caimari/rame-code/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/caimari/rame-code/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/caimari/rame-code/releases/tag/v0.3.0 [0.2.0]: https://github.com/caimari/rame-code/releases/tag/v0.2.0 [0.1.0]: https://github.com/caimari/rame-code/releases/tag/v0.1.0 diff --git a/README.es.md b/README.es.md index b48a04e..0b7fbaf 100644 --- a/README.es.md +++ b/README.es.md @@ -202,7 +202,9 @@ Una Mission se verifica en uno de tres niveles, y el nivel queda escrito en sus - **Convencional (Mission estándar).** Una prueba permitida del proyecto pasa después del último cambio. - **Protegido (Mission verificada).** El host ejecuta él mismo la comprobación de aceptación congelada por el usuario, la observa hasta el final y exige que salga con 0. Antes de autorizar, RAME la ejecuta una vez bajo su propia traza y la analiza para encontrar los procesos que ejecuta y los módulos y ficheros que usa, y guarda ese alcance en la autorización junto con una huella de cada fichero del proyecto. Se congelan los auxiliares, los datos y los ficheros de prueba que ejecuta la orden, y se sellan las carpetas de pruebas que ejecuta o lista. Lo que vaya a quedar modificable se muestra en un diálogo modal para que lo confirmes. Una comprobación cuyas dependencias no se pueden determinar no se protege: RAME ofrece la verificación convencional como alternativa. Se rechazan las escrituras que cambiarían un fichero congelado, una carpeta sellada o cómo los resuelve Node. Después de cada ejecución del host, RAME compara todo el proyecto con su propio registro (el SHA-256 de los bytes de cada fichero): un cambio fuera del alcance autorizado nunca cuenta como aprobado. En Linux y macOS, los tiempos de cambio del núcleo muestran además un fichero cambiado y restaurado durante la ejecución; en Windows solo se afirma el estado al inicio y al final, y el informe lo dice. -- **Reforzado.** Una comprobación protegida más un fichero `*.rame-cases.json` congelado. El ejecutor de casos de RAME llama al código bajo prueba en un proceso aparte y el host compara él mismo cada resultado; los valores esperados no se envían a ese proceso y quedan ocultos para las herramientas del agente. Un `process.exit(0)` prematuro o una salida falsificada no pueden aprobar. Esto no hace secretos los valores esperados: no hay sandbox, y el código que se ejecuta con tus permisos puede encontrar el fichero en disco. En el nivel protegido, una comprobación es tan fiable como su código de salida: el código bajo prueba que se ejecuta en su mismo proceso puede terminarla antes de tiempo. +- **Reforzado.** Una comprobación protegida más un fichero `*.rame-cases.json` congelado. En Linux compatible, cada caso se ejecuta en un proceso nuevo confinado con Landlock/seccomp que solo recibe esa entrada y el código autorizado: no puede leer el fichero de casos, entradas futuras, valores esperados, registros del host, proyectos vecinos ni secretos reconocibles del proyecto, y no puede abrir sockets TCP, UDP o Unix. El host compara cada resultado, por lo que un `process.exit(0)` prematuro o una salida falsificada no pueden aprobar. La verificación protegida falla de forma cerrada si esta frontera no está disponible y no depende de los permisos de Agent o terminal: Full Access y Total no pueden debilitarla. Unos casos débiles todavía pueden ser insuficientes. En el nivel protegido, una comprobación sigue siendo tan fiable como su código de salida. + +La aceptación medida y los **Programmes** distinguen métricas observadas por el host (`wall_ms`, `exit_code`) de métricas declaradas por el proyecto como `p95_ms` y `errors`. Las declaraciones solo cuentan mediante el canal privado de cada ejecución de la comprobación congelada y se etiquetan con menor confianza: código malicioso en ese proceso puede alterarlas. Los registros antiguos siguen siendo válidos sin nueva autorización. Sin aislamiento protegido, la verificación se ejecuta con un aviso y los resultados se marcan como no protegidos; el objetivo sigue siendo un aislamiento equivalente en Windows y macOS. Cuando RAME no puede demostrar que se cumplieron las condiciones autorizadas, el resultado es **no verificable**: nunca es un aprobado, la Mission se bloquea con el motivo y la aceptación autorizada no cambia. @@ -212,7 +214,7 @@ Estas garantías se ponen a prueba con una batería de sabotaje **SIMULATED** (` - un auxiliar que el usuario confirmó como modificable; - `process.exit(0)` en el nivel protegido. -La traza de RAME se ejecuta junto al código bajo prueba y se puede falsificar, así que solo es una ayuda para descubrir; lo que decide es lo que comprueba el propio host (los hashes y la huella del proyecto). Separar procesos no es un sandbox. Si VS Code se cierra o se recarga durante una Mission, la ventana antigua no inicia ni escribe nada más. Consulta [Cómo verificarlo](docs/VERIFICATION.es.md). Los resultados se etiquetan como **REAL**, **SIMULATED** o **RECORDED**, y una simulación nunca se presenta como una ejecución real con Glimmer o con Meta. +La traza de RAME se ejecuta junto al código bajo prueba y se puede falsificar, así que solo es una ayuda para descubrir; deciden las observaciones del host, los hashes, la huella del proyecto y el verificador protegido. Si VS Code se cierra o se recarga durante una Mission, la ventana antigua no inicia ni escribe nada más. Consulta [Cómo verificarlo](docs/VERIFICATION.es.md). Los resultados se etiquetan como **REAL**, **SIMULATED** o **RECORDED**, y una simulación nunca se presenta como una ejecución real con Glimmer o con Meta. ## Evidencia del modelo (v0.2.0) diff --git a/README.md b/README.md index b41f9ee..539c717 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,9 @@ A Mission is verified at one of three levels, and the level is written in its ho - **Conventional (Standard Mission).** An allowlisted project test passes after the last change. - **Protected (Verified Mission).** The host itself runs the user's frozen acceptance check, observes it to its end and requires exit 0. Before authorizing, RAME runs the check once under its own trace and analyses it, to find the processes it runs and the modules and files it uses, and records that scope in the grant together with a fingerprint of every project file. Test helpers, data and the test files the command runs are frozen, and test folders it runs or lists are sealed. Anything that would stay writable is shown in a modal dialog for you to confirm. A check whose dependencies cannot be determined is not protected: RAME offers conventional verification instead. Writes that would change a frozen file, a sealed folder or how Node resolves them are refused. After every host run, RAME compares the whole project with its own record (the SHA-256 of every file's bytes): a change outside the authorized scope is never counted as a pass. On Linux and macOS, kernel change times also show a file changed and restored during the run; on Windows only the start and end state is claimed, and the report says so. -- **Reinforced.** A protected check plus a frozen `*.rame-cases.json` file. RAME's case runner calls the code under test in a separate process and the host compares every result itself; the expected values are not sent to that process and are hidden from the agent's tools. An early `process.exit(0)` or forged output cannot pass. This does not make the expected values secret: there is no sandbox, and code running with your permissions can find the file on disk. At the protected level, a check is only as trustworthy as its exit code: code under test that runs in the check's own process can end it early. +- **Reinforced.** A protected check plus a frozen `*.rame-cases.json` file. On supported Linux, every case runs in a fresh Landlock/seccomp-confined process. If that boundary is unavailable, verification still runs, shows the unprotected warning and labels every result accordingly; code under test could then read hidden cases and alter results. Full Access and Total cannot weaken isolation when it is available. Equivalent isolation on Windows and macOS remains the goal. + +Measured acceptance and phased **Programmes** distinguish host-observed metrics (`wall_ms`, `exit_code`) from project-declared metrics such as `p95_ms` and `errors`. Declarations count only through the frozen check's private per-run channel and are labelled lower-trust because malicious code in that process can alter them; arbitrary files, output and JSON do not count. Existing records remain valid without re-authorization. When RAME cannot show that the authorized conditions held, the result is **not verifiable**: never a pass, the Mission is blocked with the reason and the authorized acceptance stays unchanged. @@ -212,7 +214,7 @@ These guarantees are exercised by a **SIMULATED** sabotage battery (`npm run dem - a helper the user confirmed as writable; - `process.exit(0)` at the protected level. -RAME's trace runs beside the code under test and can be forged, so it is only a discovery aid; what the host checks itself (hashes and the project fingerprint) decides. Process separation is not a sandbox. When VS Code closes or reloads during a Mission, the old window starts nothing and writes nothing more. See [How to verify it](docs/VERIFICATION.md). Results are labelled **REAL**, **SIMULATED** or **RECORDED**, and a simulation is never presented as a real Glimmer or Meta run. +RAME's trace runs beside the code under test and can be forged, so it is only a discovery aid; what the host checks itself (host observations, hashes, the project fingerprint and the protected verifier) decides. When VS Code closes or reloads during a Mission, the old window starts nothing and writes nothing more. See [How to verify it](docs/VERIFICATION.md). Results are labelled **REAL**, **SIMULATED** or **RECORDED**, and a simulation is never presented as a real Glimmer or Meta run. ## Model evidence (v0.2.0) diff --git a/docs/JUDGES.es.md b/docs/JUDGES.es.md index aee2757..89606f8 100644 --- a/docs/JUDGES.es.md +++ b/docs/JUDGES.es.md @@ -48,7 +48,7 @@ El paquete público es una **extracción RECORDED de una ejecución REAL**. Se v - Fue supervisada y no utilizó Meta Model API. - El resumen del host dice 36 inferencias locales respondidas y la vista final contiene 37; se conservan ambas cifras. - En Windows se compara el estado inicial y final; no se afirma detectar cambios temporales deshechos. -- Una comprobación puede ser débil. El ejecutor reforzado reduce algunos ataques, pero no vuelve secretos los valores esperados. +- Una comprobación puede ser débil. En Linux compatible, el verificador reforzado oculta los valores esperados, entrega una entrada cada vez, deniega sockets y confina lecturas. En hosts no compatibles se ejecuta con aviso y etiqueta de «sin aislamiento»; el código evaluado podría leer casos ocultos y alterar resultados. El aislamiento equivalente en Windows y macOS sigue siendo un objetivo. - Chat directo con Meta tiene tres respuestas reales registradas. La matriz completa de privacidad y las rutas Advisory/Delegated/Agent cloud con herramientas siguen probadas con API simulada, no validadas de extremo a extremo contra el servicio real. - Es una release preview experimental v0.2.0, no una frontera de seguridad para producción. diff --git a/docs/JUDGES.md b/docs/JUDGES.md index e5f143b..bce7e93 100644 --- a/docs/JUDGES.md +++ b/docs/JUDGES.md @@ -48,7 +48,7 @@ The public Mission bundle is a **RECORDED extraction from a REAL run**. Its sour - The real run required human supervision and did not use Meta Model API. - The real run's host summary says 36 answered local inferences while its final evidence view contains 37. Both counts are preserved. - On Windows, acceptance integrity compares start and end state; temporary change-and-restore detection is not claimed. -- A check can be weak. The reinforced case runner narrows some attacks but does not make expected values secret. +- A check can be weak. On supported Linux the reinforced verifier hides expected values, gives one input at a time, denies sockets and confines reads. On unsupported hosts it runs with a prominent unprotected warning and label; evaluated code could then read hidden cases and alter results. Equivalent Windows/macOS isolation remains a goal. - Direct Meta Chat has three recorded real responses. The complete privacy matrix and Advisory/Delegated/cloud-Agent tool paths remain tested against a simulated API rather than validated end to end against the live service. - This is a v0.2.0 experimental preview release, not a production security boundary. diff --git a/docs/USER_GUIDE.es.md b/docs/USER_GUIDE.es.md index ad73edc..b4b8721 100644 --- a/docs/USER_GUIDE.es.md +++ b/docs/USER_GUIDE.es.md @@ -36,7 +36,7 @@ No empieces con código confidencial ni datos personales reales. RAME Code 0.2.0 ### No puede prometer - Que unas pruebas en verde demuestren que todo el producto es correcto. -- Un sandbox del sistema operativo. Los scripts de prueba se ejecutan con los permisos de tu usuario de VS Code. +- Un sandbox completo del sistema operativo. Los scripts de prueba se ejecutan con el nivel de aislamiento de **Seguridad › Comandos de terminal**: con Proyecto o Cerrado (Linux con Landlock) solo pueden escribir en el proyecto, la carpeta temporal y las cachés de paquetes, pero pueden leer tus archivos; con Total tienen tus permisos de usuario. - Control sin restricciones del terminal o del ordenador. - Memoria perfecta ni recuperación de cualquier paráfrasis antigua. - Verificación funcional completa para todos los lenguajes y plataformas. La verificación autónoma actual utiliza scripts Node/npm compatibles. @@ -205,7 +205,7 @@ Agente y Misión utilizan decisiones de permisos independientes: Cambiar el selector de Agente no amplía, reduce ni sustituye un permiso de Misión ya concedido. Una Misión puede utilizar internamente el motor de ejecución de Agente, pero eso no la convierte en una petición de Agente: su autoridad sigue procediendo de la tarjeta de Misión mostrada antes de comenzar. La interfaz identifica esas ejecuciones internas como trabajo de Misión y mantiene visible el permiso de la Misión, en lugar de presentar el selector de Agente como su autoridad. Durante una Misión, considera como fuente de verdad el permiso que muestran su estado y sus evidencias. -El workspace autónomo exige marcar **Confío en este proyecto**. La confianza se limita a esa Misión, carpeta, proveedor local y modelo. No es un sandbox del sistema operativo. Los scripts de prueba pueden acceder a archivos, red u otros procesos con tus permisos de usuario. +El workspace autónomo exige marcar **Confío en este proyecto**. La confianza se limita a esa Misión, carpeta, proveedor local y modelo. No es un sandbox completo del sistema operativo: los scripts de prueba se ejecutan con el nivel de aislamiento de **Seguridad › Comandos de terminal** (Proyecto o Cerrado: solo escriben en el proyecto, la carpeta temporal y las cachés de paquetes, y Cerrado además sin red; Total: con tus permisos de usuario). En todos los niveles pueden leer tus archivos. El informe final de la Misión indica el nivel con el que se ejecutó de verdad cada prueba. RAME sigue protegiendo determinadas rutas y vuelve a preguntar por operaciones fuera del permiso, como borrar, mover archivos o modificar la memoria del proyecto. Si cambia `package.json`, también deja de ser válido el entorno de pruebas autorizado anteriormente. @@ -250,7 +250,9 @@ Una comprobación en verde solo demuestra lo que esa comprobación cubre. Una pr "cases": [ { "call": "add", "args": [19, 23], "expect": 42 } ] } ``` -RAME llama entonces a esas funciones en un proceso aparte y compara él mismo los resultados; los valores esperados nunca se envían a ese proceso y las herramientas de RAME no se los muestran al agente. Una salida prematura o un falso mensaje de «todo aprobado» no pueden superarla. El informe de la Misión indica qué nivel se alcanzó y con qué versión de Node se ejecutó la comprobación. Los valores no son secretos: el código que se ejecuta con tus permisos todavía puede leer el archivo, así que haz casos variados. +RAME llama entonces a cada función en un proceso nuevo y compara él mismo el resultado. En Linux compatible, ese proceso queda confinado con Landlock y seccomp. Si esa frontera no está disponible, la comprobación se ejecuta sin aislamiento, con un aviso en autorización, progreso, resultado y evidencia, y el resultado se etiqueta «sin aislamiento». Full Access y Total no rebajan el aislamiento cuando está disponible. El objetivo sigue siendo una protección equivalente en Windows y macOS. + +**Aceptación medida y Programmes.** Cada criterio indica si es observado por el host (`wall_ms`, `exit_code`) o declarado por la comprobación congelada (`p95_ms`, `errors` y otros nombres válidos). Las declaraciones solo cuentan por el canal privado de cada ejecución y advierten que código malicioso del mismo proceso puede alterarlas. Ficheros, salida y JSON arbitrarios nunca cuentan. Los registros antiguos no requieren nueva autorización. **No verificable.** Después de cada ejecución de la comprobación, RAME compara el proyecto con lo que registró al autorizar. Si cambió un archivo que usa la comprobación (en Linux y macOS, aunque se restaurara antes del final), apareció un archivo en una carpeta sellada, cambió un archivo de datos o un documento que ya existía, o RAME vio a la comprobación usar algo fuera del alcance que aprobaste, la Misión se detiene como **no verificable**: no cuenta como aprobado y tu aceptación queda tal como la autorizaste. Si solo cambió otro código, RAME te muestra los archivos y te pregunta (una revisión de alcance); apruébalos solo si son código bajo prueba, no pruebas ni valores esperados. Consulta [Cómo verificarlo](VERIFICATION.es.md). @@ -258,6 +260,21 @@ RAME llama entonces a esas funciones en un proceso aparte y compara él mismo lo Revisa el objetivo, el nivel de verificación, el permiso y el posible permiso de asesoramiento de Meta. Pulsa **Autorizar e iniciar** solo cuando coincidan con tu intención. +**Qué hace cada opción de la tarjeta de autorización** + +| Opción | Qué hace | +| --- | --- | +| **Nivel de verificación → Misión estándar** | La tarea se da por hecha cuando pasan las pruebas normales del proyecto. | +| **Nivel de verificación → Misión verificada** | Además eliges una comprobación de aceptación y RAME **congela** sus archivos: si cambian, la Misión no se da por verificada. Es la única que muestra «verificación: protegida» (o «sin aislamiento» si el equipo no lo permite). | +| **Permiso → Supervisada** | Cada escritura y cada prueba te piden confirmación con una tarjeta. | +| **Permiso → Workspace autónomo** | Edita y ejecuta las pruebas permitidas sin preguntar, dentro del proyecto. Nunca toca `.git`, `.env` ni claves, no ejecuta comandos de terminal y sigue preguntando antes de borrar, mover o cambiar la memoria del proyecto. | +| **Confío en este proyecto** | Obligatoria para el workspace autónomo: confirmas que los scripts de prueba de este proyecto no son peligrosos, porque se ejecutarán solos. Corren con el nivel de aislamiento de **Seguridad › Comandos de terminal**. | +| **Ejecuciones internas por tarea, como máximo** | El máximo de ejecuciones del Agente por tarea. Si se agotan sin terminar, la Misión se bloquea en lugar de seguir sin fin. | +| **Acceso web para esta Misión** (casilla) | Permite que la Misión busque y lea páginas, solo lectura. Viene desactivada. | +| **Búsquedas / Lecturas de páginas, como máximo** | El presupuesto de búsquedas (5 por defecto) y de páginas leídas (10). En workspace autónomo no pregunta mientras quede presupuesto; en supervisada pregunta siempre. | +| **Dominios autorizados** | Opcional. Si pones dominios, solo lee páginas de ellos; si no, solo las que salen en sus propias búsquedas. | +| **Autonomía y permisos… (Meta)** | Autoriza de antemano consultas a Meta como asesor, con un límite de consultas y de bytes. Meta solo aconseja: no recibe archivos ni ejecuta nada. Si no lo autorizas y la Misión se atasca, te aparece una tarjeta para que decidas tú. | + Durante la Misión puedes consultar: - **Actividad:** propuestas de herramientas y acciones del host. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 943b42e..5e5c839 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -36,7 +36,7 @@ Do not start with confidential source code or real personal data. RAME Code 0.2. ### It cannot promise - That passing tests proves the whole product is correct. -- An operating-system sandbox. Project test scripts run with your VS Code user's permissions. +- A complete operating-system sandbox. Project test scripts run with the isolation level of **Security › Terminal commands**: with Project or Closed (Linux with Landlock) they can write only in the project, the temporary folder and package caches, but can still read your files; with Total they have your user permissions. - Unrestricted shell or computer control. - Perfect memory or recovery of every old paraphrase. - Complete functional verification for every language or platform. Autonomous verification currently uses supported Node/npm scripts. @@ -204,7 +204,7 @@ Agent and Mission use separate permission decisions: Changing the Agent selector does not expand, reduce or replace an existing Mission grant. A Mission may use the Agent execution engine internally, but that does not turn it into an Agent request: its authority still comes from the Mission card shown before it started. The interface identifies these internal runs as Mission work and keeps the Mission permission visible instead of presenting the Agent selector as their authority. During a Mission, use the permission shown in its status and evidence as the source of truth. -Autonomous workspace requires **I trust this project**. This trust is limited to that Mission, folder, local provider and model. It is not an operating-system sandbox. Test scripts can reach files, the network or other processes with your user permissions. +Autonomous workspace requires **I trust this project**. This trust is limited to that Mission, folder, local provider and model. It is not a complete operating-system sandbox: test scripts run with the isolation level of **Security › Terminal commands** (Project or Closed: writes only in the project, the temporary folder and package caches, and Closed also without network; Total: your user permissions). At every level they can read your files. The Mission's final report states the level each test really ran with. For a prepared project, this can be one initial authorization covering the permitted writes and checks for that specific Mission. It is never a general authorization for other folders or operations on the computer. An empty-folder Mission still uses two authorizations: the first may create the scaffold, package scripts and proposed tests; the second lets you review those new verification commands and authorize the implementation and checks. After that second authorization, allowlisted work inside the grant proceeds without an approval for every ordinary write or test. @@ -249,7 +249,9 @@ A green check proves only what that check covers. A weak test can miss broken be "cases": [ { "call": "add", "args": [19, 23], "expect": 42 } ] } ``` -RAME then calls those functions in a separate process and compares the results itself; the expected values are never sent to that process, and RAME's tools do not show them to the agent. An early exit or a fake "all passed" message cannot pass. The Mission report states which level was reached and the Node version the check ran on. The values are not secret: code running with your permissions can still read the file, so keep the cases varied. +RAME then calls each function in a fresh process and compares the result itself. On supported Linux that process is confined with Landlock and seccomp. If this boundary is unavailable, the check runs without isolation, with a warning in authorization, progress, result and evidence, and the result is labelled unprotected. Full Access and Total cannot weaken isolation when it is available. Equivalent protection on Windows and macOS remains the goal. + +**Measured acceptance and Programmes.** Numeric criteria state whether they are observed by the host (`wall_ms`, `exit_code`) or declared by the frozen project check (`p95_ms`, `errors`, and other valid names). Declarations count only through the private per-run channel and warn that malicious same-process code can alter them. Arbitrary files, command output and JSON never count. Older records need no new authorization. **Not verifiable.** After each run of the check, RAME compares the project with what it recorded when you authorized. If a file the check uses changed (on Linux and macOS, even if it was restored before the end), a file appeared in a sealed folder, an existing data or document file changed, or RAME saw the check use something outside the scope you approved, the Mission stops as **not verifiable**: it is not counted as a pass, and your acceptance stays as you authorized it. If only other code changed, RAME shows the files and asks you (a scope review); approve them only if they are code under test, not tests or expected values. See [How to verify it](VERIFICATION.md). @@ -257,6 +259,21 @@ RAME then calls those functions in a separate process and compares the results i Review the goal, verification level, permission and optional Meta Advisory grant. Select **Authorize and start** only when they match your intention. +**What each option on the authorization card does** + +| Option | What it does | +| --- | --- | +| **Verification level → Standard Mission** | A task is done when the project's normal tests pass. | +| **Verification level → Verified Mission** | You also choose an acceptance check and RAME **freezes** its files: if they change, the Mission is not considered verified. Only this level shows «verification: protected» (or «unprotected» when this computer cannot isolate it). | +| **Permission → Supervised** | Every write and every test asks for confirmation on a card. | +| **Permission → Autonomous workspace** | Edits and runs the allowlisted tests without asking, inside the project. It never touches `.git`, `.env` or keys, runs no terminal commands, and still asks before deleting, moving or changing project memory. | +| **I trust this project** | Required for autonomous workspace: you confirm that this project's test scripts are not dangerous, because they will run on their own. They run with the isolation level of **Security › Terminal commands**. | +| **Internal runs per task, at most** | The most Agent runs per task. If they run out before the task is done, the Mission stops as blocked instead of going on forever. | +| **Web access for this Mission** (checkbox) | Lets the Mission search and read pages, read-only. Off by default. | +| **Searches / Page reads, at most** | The budget of searches (5 by default) and page reads (10). In autonomous workspace it does not ask while budget remains; supervised always asks. | +| **Authorized domains** | Optional. If you list domains, it reads pages only from them; otherwise only pages from its own search results. | +| **Autonomy & Permissions… (Meta)** | Pre-authorizes Meta consultations as an advisor, with a limit on consultations and bytes. Meta only advises: it receives no files and runs nothing. If you do not authorize it and the Mission gets stuck, a card lets you decide. | + During the Mission, inspect: - **Activity:** tool proposals and host actions. diff --git a/docs/VERIFICATION.es.md b/docs/VERIFICATION.es.md index cd4853d..0d30f6a 100644 --- a/docs/VERIFICATION.es.md +++ b/docs/VERIFICATION.es.md @@ -32,7 +32,7 @@ El modelo nunca declara completada una Mission. Lo decide el host a partir de su |---|---|---|---| | **Convencional** | Mission estándar (sin comprobación de aceptación congelada) | Una prueba permitida del proyecto pasó después del último cambio, no queda nada incierto y la ejecución dijo que terminó | Las pruebas las puede cambiar el agente | | **Protegido** | Mission verificada con una comprobación de aceptación congelada | El host ejecuta él mismo la comprobación congelada, la observa hasta el final y exige que salga con 0; todos los ficheros congelados siguen intactos antes y después; todos los demás ficheros del proyecto se comparan con el registro propio del host tomado al autorizar | El código de salida viene de un proceso que también ejecuta el código bajo prueba, que puede terminarlo antes de tiempo (`process.exit(0)`) o alterarlo | -| **Reforzado** | Protegido, más un fichero `*.rame-cases.json` congelado | Además, el ejecutor de casos de RAME llama al código bajo prueba en otro proceso y el host compara cada resultado con los valores esperados que conserva | No hace secretos los valores esperados (no hay sandbox); los casos débiles siguen siendo débiles | +| **Reforzado** | Un fichero `*.rame-cases.json` congelado | Con aislamiento compatible, cada entrada se ejecuta en un proceso nuevo con lecturas confinadas y sockets denegados; si no, se ejecuta con aviso y etiqueta de «sin aislamiento» | Los casos débiles siguen siendo débiles; sin aislamiento, el código podría leer casos ocultos y alterar resultados | Hay un cuarto resultado que no es un nivel: **no verificable**. RAME lo da siempre que no puede demostrar que se respetaron las condiciones que autorizó el usuario. Nunca es un aprobado ni un fallo que se pida corregir al agente: la Mission se bloquea con el motivo y la aceptación autorizada no cambia. @@ -46,6 +46,9 @@ Lo que el host comprueba él mismo, sin la traza: - una huella de cada fichero del proyecto (fuera de `node_modules` y `.git`, como máximo 2.000 ficheros y 4 GiB): el SHA-256 de sus bytes, leídos en bloques de 1 MiB, nunca solo su tamaño. Se toma al autorizar y otra vez antes de la comprobación, después de ella y, en el nivel reforzado, después de los casos del host. Un fichero que el host no puede leer, o que cambia mientras se lee, es no verificable; - **durante la ejecución** (garantía B), en Linux y macOS: con cada huella el host guarda lo que el núcleo dice de cada fichero y carpeta (dispositivo, inodo, tiempos de modificación y de cambio de estado en nanosegundos). El tiempo de cambio de estado lo pone el núcleo en cada escritura, renombrado o cambio de metadatos y un proceso normal no puede volver a ponerlo atrás, así que se ve un fichero escrito y restaurado entre dos registros, aunque recupere su tiempo de modificación, y también un fichero creado y borrado en una carpeta de aceptación. No depende de eventos del sistema de ficheros, que pueden perderse. En Windows los programas pueden cambiar ese tiempo, así que allí solo se afirma el estado al inicio y al final (garantía A), y la nota de aceptación y el título del informe dicen «solo inicio y fin». - el código de salida de la orden que ejecutó y, en el nivel reforzado, cada resultado de caso que comparó. +- la aceptación numérica etiqueta por separado observaciones del host (`wall_ms`, `exit_code`) y métricas declaradas por la comprobación congelada mediante su canal privado por ejecución. El código del mismo proceso puede alterar las declaraciones; la salida y los JSON arbitrarios nunca cuentan. + +La verificación protegida es independiente de los perfiles de permisos de Agent, Mission y terminal. Construye una vista de ficheros permitidos con Landlock, deniega TCP con Landlock y todas las familias de sockets y la inspección entre procesos con seccomp, y sanea el entorno. Los secretos reconocibles (`.env*`, `.npmrc`, ficheros de credenciales/secretos y directorios habituales de credenciales) nunca son entradas ambientales del verificador. Cada entrada reforzada usa un proceso nuevo, sin entradas futuras ni valores esperados. Full Access y Total no debilitan esta frontera. ### Condiciones exactas de una comprobación protegida @@ -78,7 +81,7 @@ Al autorizar, RAME: ### Mientras se ejecuta la Mission - Las herramientas de RAME se niegan a cambiar los ficheros congelados y todo lo que cambiaría cómo los encuentra Node: el mismo nombre con otras mayúsculas, un hermano sin extensión o con otra extensión, el nombre de la carpeta de un `index.*` congelado, un `package.json` en la carpeta de un fichero congelado o por encima, `node_modules` a cualquier profundidad y cualquier cosa dentro de una carpeta sellada (o la propia carpeta, o una superior). -- En el nivel reforzado, las herramientas de RAME no pueden leer el fichero de casos congelado: sus valores esperados quedan ocultos para el agente. Los scripts de prueba del propio agente se ejecutan con tus permisos y aún podrían leerlo. +- En el nivel reforzado, ni las herramientas de RAME ni el proceso verificador oficial pueden leer el fichero de casos congelado. El verificador recibe una entrada cada vez y no puede leer proyectos vecinos, registros del host ni secretos reconocibles, ni comunicarse mediante sockets. - Cada ejecución de la comprobación por el host se compara con el manifiesto y con la huella del proyecto (ver la sección siguiente). - Una ejecución detenida por el usuario, por una pausa o al cerrar la ventana es una interrupción, no una comprobación fallida. @@ -174,9 +177,9 @@ Detectar estos patrones no es una defensa contra todas sus variantes. | El alcance protegido: entradas de proceso, cargas y lecturas clasificadas; ficheros del verificador congelados; carpetas de prueba selladas; ficheros modificables confirmados por el usuario; las dependencias indeterminadas impiden la protección | Que la observación vea todas las dependencias. Los ficheros dentro de `node_modules`, que está excluido, siguen fuera de la huella del proyecto | | Cada ejecución del host se compara con el manifiesto autorizado y con la huella propia del host del proyecto: un fichero nuevo no puede heredar autoridad de la traza y un cambio fuera del alcance nunca es un aprobado | Que la traza sea inalterable. No se confía en ella y todavía puede ocultar una dependencia, provocando un rechazo conservador o dejando sin observar una dependencia excluida | | Se rechazan las escrituras que cambiarían un fichero congelado, una carpeta sellada o cómo los resuelve Node | Que una comprobación que pasa signifique código correcto. Las comprobaciones débiles se superan con constantes (S6) | -| Nivel reforzado: el host ejecutó y comparó todos los casos autorizados; una salida prematura o resultados falsificados no pueden aprobar | Que los valores esperados sean secretos. No hay sandbox: el código que se ejecuta con tus permisos puede encontrar el fichero de casos en disco; una lectura que la traza no ve no se detecta | +| Nivel reforzado: cada caso autorizado se ejecutó en un proceso protegido nuevo y el host lo comparó; una salida prematura, resultados falsificados o lecturas directas de los ficheros de casos/referencia no pueden aprobar | Que unos casos débiles o incompletos demuestren todo el comportamiento; la verificación protegida requiere actualmente Linux compatible | | Una ventana descartada no inicia ni escribe nada más; cada barrera por separado | En Windows, un fichero que se cambia y se restaura dentro de una misma ejecución (allí solo inicio y fin); un cambio hecho y deshecho dentro del mismo instante de un sistema de ficheros con tiempos poco precisos (por ejemplo FAT); `node_modules` no se incluye en la huella | -| Las comprobaciones de aceptación nunca heredan `NODE_TEST_CONTEXT` (un `node --test` anidado saldría con 0 sin ejecutar ninguna prueba) | El aislamiento de procesos como sandbox de seguridad. La comprobación y el ejecutor de casos son procesos con los permisos del usuario | +| Las comprobaciones de aceptación nunca heredan `NODE_TEST_CONTEXT` (un `node --test` anidado saldría con 0 sin ejecutar ninguna prueba) | Un sandbox general para Agent, órdenes de terminal o scripts ordinarios del proyecto. La frontera obligatoria del verificador protegido/reforzado es más estrecha y específica de Linux; no se afirma que toda ejecución de RAME esté aislada | | Revisión opcional de Spark: el objetivo, los criterios y los fragmentos de ficheros se censuran y se les quitan las rutas; la tarjeta muestra exactamente lo que se envía, también las instrucciones fijas | La validación en una máquina Windows real (el entrecomillado de `NODE_OPTIONS` con las rutas de la precarga no está probado allí); el comportamiento contra la Meta Model API real | | Los hashes detectan un cambio | La autenticidad. Los hashes no son firmas y no demuestran que el host o sus datos fueran honestos | diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 1c38534..58d6b89 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -32,7 +32,7 @@ The model never declares a Mission complete. The host decides from its own evide |---|---|---|---| | **Conventional** | Standard Mission (no frozen acceptance check) | An allowlisted project test passed after the last change, nothing uncertain, the run reported done | The tests are the agent's to change | | **Protected** | Verified Mission with a frozen acceptance check | The host runs the frozen check itself, observes it to its end and needs exit 0; every frozen file is intact before and after the run; every other project file is compared with the host's own record from authorization | The exit code comes from a process that also runs the code under test, which can end it early (`process.exit(0)`) or patch it | -| **Reinforced** | Protected, plus a frozen `*.rame-cases.json` file | Additionally, RAME's case runner calls the code under test in a separate process and the host compares every result with the expected values it keeps | It does not make the expected values secret (no sandbox); weak cases stay weak | +| **Reinforced** | A frozen `*.rame-cases.json` file | With supported isolation, each input runs in a fresh read-confined, socket-denied process; otherwise it runs with the explicit unprotected warning and label | Weak cases stay weak; unprotected code could read hidden cases and alter results | A fourth outcome is not a level: **not verifiable**. RAME reports it whenever it cannot show that the conditions the user authorized were respected. It is never a pass and never a failure the agent is asked to fix: the Mission is blocked with the reason, and the authorized acceptance stays unchanged. @@ -46,6 +46,9 @@ What the host checks itself, without the trace: - a fingerprint of every project file (outside `node_modules` and `.git`, at most 2,000 files and 4 GiB): the SHA-256 of its bytes, read in 1 MiB blocks, never its size alone. It is recorded at authorization and taken again before the check, after it and, at the reinforced level, after the host-compared cases. A file the host cannot read, or that changes while it is read, is not verifiable; - **during the run** (guarantee B), on Linux and macOS: with every fingerprint the host keeps what the kernel reports about each file and folder (device, inode, modification and status-change times in nanoseconds). The status-change time is set by the kernel on every write, rename or metadata change and an ordinary process cannot set it back, so a file written and restored between two records, even with its old modification time, is seen, and so is a file added and removed in an acceptance folder. This does not rely on file-system events, which can be lost. On Windows programs can set that time, so only the state at start and end is claimed there (guarantee A), and the acceptance note and the report title say "start and end only". - the exit code of the command it ran, and, at the reinforced level, every case result it compared. +- numeric acceptance labels host observations (`wall_ms`, `exit_code`) separately from project-declared metrics received through the frozen check's private per-run channel. Same-process code can alter declarations; arbitrary output and project JSON never count. + +Protected verification is independent of Agent, Mission and terminal permission profiles. It constructs an allowlisted filesystem view with Landlock, denies TCP through Landlock and every socket family plus cross-process inspection through seccomp, and sanitizes the environment. Recognized secret files (`.env*`, `.npmrc`, credentials/secrets files and common credential directories) are never ambient verifier inputs. Each reinforced input gets a fresh process, so future inputs and expected values are not present. Full Access and Total do not weaken this boundary. ### Exact conditions for a protected check @@ -78,7 +81,7 @@ At authorization, RAME: ### While the Mission runs - RAME's tools refuse to change the frozen files and everything that would change how Node finds them: the same name in another letter case, an extensionless or other-extension sibling, the folder name of a frozen `index.*`, a `package.json` at or above a frozen file's folder, `node_modules` at any depth, and anything in a sealed folder (or the folder itself, or one above it). -- At the reinforced level, RAME's tools cannot read the frozen cases file: its expected values are hidden from the agent. The agent's own test scripts run with your permissions and could still read it. +- At the reinforced level, neither RAME's tools nor the official verifier process can read the frozen cases file. The verifier receives one input at a time and cannot read sibling projects, host records or recognized secret files, or communicate through sockets. - Every host run of the check is compared with the manifest and the project fingerprint (see the next section). - A run stopped by the user, by a pause or by closing the window is an interruption, not a failed check. @@ -174,9 +177,9 @@ Detecting these patterns is not a defence against every variant of them. | The protected scope: process entries, loads and reads classified; verifier files frozen; test folders sealed; writable files confirmed by the user; undetermined dependencies refuse protection | That observation sees every dependency. Files under excluded `node_modules` remain outside the project fingerprint | | Every host run is compared with the authorized manifest and the host's own fingerprint of the project: a new file cannot inherit authority from the trace and a change outside the scope is never a pass | That the trace is tamper-proof. It is not trusted and can still hide a dependency, causing a conservative refusal or leaving an excluded dependency unobserved | | Writes that would change a frozen file, a sealed folder or how Node resolves them are refused | That a passing check means correct code. Weak checks can be satisfied with constants (S6) | -| Reinforced level: every authorized case was run and compared by the host; early exit or forged results cannot pass | That the expected values are secret. There is no sandbox: code running with your permissions can find the cases file on disk; a read the trace does not see is not detected | +| Reinforced level: every authorized case ran in a fresh protected process and was compared by the host; early exit, forged output and direct reads of the cases/reference files cannot pass | That weak or incomplete cases prove all behavior; protected verification currently requires supported Linux | | A disposed window starts and writes nothing more; each barrier on its own | On Windows, a file changed and restored within one run (start and end only there); a change made and undone within the same timestamp tick of a file system with coarse times (for example FAT); `node_modules` is not fingerprinted | -| Acceptance checks never inherit `NODE_TEST_CONTEXT` (a nested `node --test` would exit 0 without running a test) | Process isolation as a security sandbox. The check and the case runner are processes with the user's permissions | +| Acceptance checks never inherit `NODE_TEST_CONTEXT` (a nested `node --test` would exit 0 without running a test) | A general sandbox for Agent, terminal commands or ordinary project scripts. The mandatory protected/reinforced verifier boundary is narrower and Linux-specific; it is not a claim that all RAME execution is sandboxed | | Optional Spark review: goal, criteria and file fragments are redacted and path-scrubbed; the card shows exactly what is sent, including the fixed instructions | Real-machine validation on Windows (the `NODE_OPTIONS` quoting of the preload paths is untested there); behaviour against the real Meta Model API | | Hashes detect a change | Authenticity. Hashes are not signatures and do not prove the host or its data were honest | diff --git a/media/advisorUx.js b/media/advisorUx.js new file mode 100644 index 0000000..d622028 --- /dev/null +++ b/media/advisorUx.js @@ -0,0 +1,219 @@ +/* The advisor model (Settings → Security → Advisor model) and the names on + * the consultation card. Everything shown comes from the host + * (state.remote.servers.advisor and state.remote.consult); text is set with + * textContent only. Choosing a model only asks: the host checks it against + * the main model and the catalog, and every consultation checks again. + * Loaded after autonomyUx.js: it wraps RAME_REMOTE.init/render so main.js + * needs no change. */ +(function () { + 'use strict'; + + var ES = { + 'Advisor model': 'Modelo asesor', + 'Model the main model may consult when it is stuck': 'Modelo al que puede consultar el modelo principal cuando se atasca', + "The main model (chosen in Models) does the work and keeps every tool. The advisor only answers in text: it never gets tools and never takes over. Only a Meta model that Meta's documentation describes as more capable than the main model can be chosen, never the main model itself; when there is none, the main model works without consultations. Every consultation keeps its card, pre-authorization or Full Access, its budget and its data limits.": + 'El modelo principal (elegido en Modelos) hace el trabajo y conserva todas las herramientas. El asesor solo responde con texto: nunca recibe herramientas ni toma el control. Solo se puede elegir un modelo de Meta que la documentación de Meta describa como más capaz que el modelo principal, nunca el propio modelo principal; si no hay ninguno, el modelo principal trabaja sin consultas. Cada consulta mantiene su tarjeta, preautorización o acceso total, su presupuesto y sus límites de datos.', + 'None: the main model works without consultations': 'Ninguno: el modelo principal trabaja sin consultas', + 'Main model now: {0} (local).': 'Modelo principal ahora: {0} (local).', + 'Main model now: Meta {0}.': 'Modelo principal ahora: Meta {0}.', + '{0} — cannot advise: {1}': '{0} — no puede asesorar: {1}', + '{0} — {1}': '{0} — {1}', + 'Advisor: {0}. The main model may consult it when it is stuck.': 'Asesor: {0}. El modelo principal puede consultarlo cuando se atasque.', + 'No advisor: {0}. The main model works without consultations.': 'Sin asesor: {0}. El modelo principal trabaja sin consultas.', + 'Ask {0} for advice — {1} remains in control': 'Pedir consejo a {0} — {1} conserva el control', + "Include the end of the failing check's output (last lines; secrets removed, local folders hidden). The exact text is shown again before anything is sent.": + 'Incluir el final de la salida de la comprobación que falla (últimas líneas; sin secretos y con las carpetas locales ocultas). El texto exacto se vuelve a mostrar antes de enviar nada.', + "Main model of this Mission: {0} (local). It does every task with RAME's tools.": 'Modelo principal de esta Misión: {0} (local). Hace cada tarea con las herramientas de RAME.', + "Main model of this Mission: Meta {0}. It does every task with RAME's tools: in every run of this Mission, the task text, the files it reads and its tools' results go to Meta, without another card.": + 'Modelo principal de esta Misión: Meta {0}. Hace cada tarea con las herramientas de RAME: en cada ejecución de esta Misión, el texto de la tarea, los ficheros que lee y los resultados de sus herramientas van a Meta, sin otra tarjeta.', + 'Advisor for its consultations: Meta {0}.': 'Asesor para sus consultas: Meta {0}.', + 'Internal runs per task, at most (autonomous workspace)': 'Ejecuciones internas por tarea, como máximo (workspace autónomo)', + '8 (default)': '8 (predeterminado)', + 'RAME never raises this limit. A run counts as progress only when a test that was failing now passes in a file state not reached before; otherwise, after 3 runs the Mission blocks (and, if allowed, asks the advisor).': + 'RAME nunca sube este límite. Una ejecución solo cuenta como avance cuando un test que fallaba pasa con los ficheros en un estado nuevo; si no, tras 3 ejecuciones la Misión se bloquea (y, si está permitido, consulta al asesor).', + 'No advisor for its consultations: {0}.': 'Sin asesor para sus consultas: {0}.', + }; + var PATTERNS = [ + [/^Advisor model: (\S+)\. The local model keeps the tools and may consult it when stuck, within your permissions and budgets\.$/, 'Modelo asesor: $1. El modelo local conserva las herramientas y puede consultarlo cuando se atasque, dentro de tus permisos y presupuestos.'], + [/^Advisor model: (\S+)\. Meta (\S+) keeps the tools and may consult it when stuck, within your permissions and budgets\.$/, 'Modelo asesor: $1. Meta $2 conserva las herramientas y puede consultarlo cuando se atasque, dentro de tus permisos y presupuestos.'], + [/^Advisor model saved as (\S+), but it cannot advise the main model now: (.+)\.$/, 'Modelo asesor guardado: $1, pero ahora no puede asesorar al modelo principal: $2.'], + [/^No advisor model: the main model works without consulting another model\.$/, 'Sin modelo asesor: el modelo principal trabaja sin consultar a otro modelo.'], + ]; + if (window.RAME_I18N && typeof window.RAME_I18N.extend === 'function') { + window.RAME_I18N.extend('es', ES); + if (typeof window.RAME_I18N.extendPatterns === 'function') window.RAME_I18N.extendPatterns('es', PATTERNS); + } + + var h = null; + + function post(message) { + if (h && h.vscode) h.vscode.postMessage(message); + } + + function el(id) { + return document.getElementById(id); + } + function t(text) { + return h ? h.t(text) : text; + } + function tf(text) { + var args = Array.prototype.slice.call(arguments, 1); + return h ? h.tf.apply(null, [text].concat(args)) : text; + } + function setText(id, text) { + var node = el(id); + if (node) node.textContent = text; + } + + function renderAdvisor(advisor) { + var select = el('advisorModelSelect'); + if (!advisor) return; + setText('advisorMain', advisor.mainProvider === 'cloud' ? tf('Main model now: Meta {0}.', advisor.main) : tf('Main model now: {0} (local).', advisor.main)); + if (select && document.activeElement !== select) { + while (select.firstChild) select.removeChild(select.firstChild); + var none = document.createElement('option'); + none.value = ''; + none.textContent = t('None: the main model works without consultations'); + select.appendChild(none); + var listed = false; + (advisor.options || []).forEach(function (option) { + var node = document.createElement('option'); + node.value = option.id; + node.textContent = option.eligible ? (option.reason ? tf('{0} — {1}', option.id, option.reason) : option.id) : tf('{0} — cannot advise: {1}', option.id, option.reason); + // A model that cannot advise this main model stays visible, with why, but cannot be chosen here. + node.disabled = !option.eligible && option.id !== advisor.selected; + if (option.id === advisor.selected) listed = true; + select.appendChild(node); + }); + if (advisor.selected && !listed) { + var kept = document.createElement('option'); + kept.value = advisor.selected; + kept.textContent = tf('{0} — cannot advise: {1}', advisor.selected, advisor.problem); + select.appendChild(kept); + } + select.value = advisor.selected || ''; + } + setText('advisorStatus', advisor.problem ? tf('No advisor: {0}. The main model works without consultations.', advisor.problem) : tf('Advisor: {0}. The main model may consult it when it is stuck.', advisor.selected)); + } + + var consultNow = null; + + function renderConsultNames(data) { + var consult = data && data.remote ? data.remote.consult : null; + var shown = data ? data.consult : null; + consultNow = consult && shown ? { digest: shown.digest, files: consult.authorizedFiles || [] } : null; + var row = el('consultOutputRow'); + var facts = el('consultFacts'); + if (!row && facts && facts.parentNode) { + row = document.createElement('label'); + row.id = 'consultOutputRow'; + var box = document.createElement('input'); + box.type = 'checkbox'; + box.id = 'consultIncludeOutput'; + box.addEventListener('change', function () { + if (consultNow) post({ type: 'consultIncludeFiles', digest: consultNow.digest, files: consultNow.files.slice(0, 3), output: Boolean(box.checked) }); + }); + row.appendChild(box); + row.appendChild(document.createTextNode(' ')); + var words = document.createElement('span'); + words.id = 'consultIncludeOutputText'; + row.appendChild(words); + facts.parentNode.insertBefore(row, facts.nextSibling); + } + if (row) { + row.hidden = !consult || consult.outputAvailable !== true; + setText('consultIncludeOutputText', t("Include the end of the failing check's output (last lines; secrets removed, local folders hidden). The exact text is shown again before anything is sent.")); + var check = el('consultIncludeOutput'); + if (check && document.activeElement !== check) check.checked = Boolean(consult && consult.outputIncluded); + } + if (!consult || !consult.model) return; + setText('consult-heading', tf('Ask {0} for advice — {1} remains in control', 'Meta ' + consult.model, consult.localModel)); + } + + // The Mission authorization card: which model will do its tasks, what goes to Meta, and which advisor its consultations use. + function renderMissionMain(advisor) { + var row = el('missionConsultRow'); + if (!row || !row.parentNode || !advisor || !advisor.main) return; + var line = el('missionMainModel'); + if (!line) { + line = document.createElement('p'); + line.id = 'missionMainModel'; + line.className = 'hint'; + line.setAttribute('role', 'note'); + row.parentNode.insertBefore(line, row); + } + var main = advisor.mainProvider === 'cloud' + ? tf("Main model of this Mission: Meta {0}. It does every task with RAME's tools: in every run of this Mission, the task text, the files it reads and its tools' results go to Meta, without another card.", advisor.main) + : tf("Main model of this Mission: {0} (local). It does every task with RAME's tools.", advisor.main); + line.textContent = main + ' ' + (advisor.problem ? tf('No advisor for its consultations: {0}.', advisor.problem) : tf('Advisor for its consultations: Meta {0}.', advisor.selected)); + } + + // The run budget per task, chosen on the authorization card (sent just before main.js sends the authorization). + function renderMissionRuns() { + var row = el('missionConsultRow'); + if (!row || !row.parentNode || el('missionRunsRow')) return; + var box = document.createElement('div'); + box.id = 'missionRunsRow'; + var label = document.createElement('label'); + label.setAttribute('for', 'missionRunsPerTask'); + label.textContent = t('Internal runs per task, at most (autonomous workspace)'); + var select = document.createElement('select'); + select.id = 'missionRunsPerTask'; + for (var n = 1; n <= 8; n++) { + var option = document.createElement('option'); + option.value = String(n); + option.textContent = n === 8 ? t('8 (default)') : String(n); + select.appendChild(option); + } + select.value = '8'; + var hint = document.createElement('p'); + hint.className = 'hint'; + hint.textContent = t('RAME never raises this limit. A run counts as progress only when a test that was failing now passes in a file state not reached before; otherwise, after 3 runs the Mission blocks (and, if allowed, asks the advisor).'); + box.appendChild(label); + box.appendChild(select); + box.appendChild(hint); + row.parentNode.insertBefore(box, row); + } + + var lastData = null; + + function render(data) { + lastData = data; + var servers = data && data.remote ? data.remote.servers : null; + renderAdvisor(servers ? servers.advisor : null); + renderMissionMain(servers ? servers.advisor : null); + renderMissionRuns(); + renderConsultNames(data); + } + + function init(helpers) { + h = helpers; + var select = el('advisorModelSelect'); + if (select) select.addEventListener('change', function () { + post({ type: 'advisorModelSet', model: select.value }); + }); + // Capture phase: before main.js sends the authorization, the chosen run budget (only when it is not the default). + document.addEventListener('click', function (event) { + var start = el('missionStart'); + var runs = el('missionRunsPerTask'); + var live = lastData && lastData.missionLive; + if (!start || event.target !== start || !runs || !live || !live.missionId) return; + var n = Number(runs.value); + if (n >= 1 && n <= 7) post({ type: 'missionRunsSet', missionId: live.missionId, runsPerTask: n }); + }, true); + } + + var base = window.RAME_REMOTE; + if (base) { + window.RAME_REMOTE = Object.assign({}, base, { + init: function (helpers) { + base.init(helpers); + init(helpers); + }, + render: function (data) { + base.render(data); + render(data); + }, + }); + } +})(); diff --git a/media/autonomyUx.js b/media/autonomyUx.js new file mode 100644 index 0000000..25c94b5 --- /dev/null +++ b/media/autonomyUx.js @@ -0,0 +1,636 @@ +/* Autonomy & Permissions (Settings → Security) and its part of a Mission's + * authorization card. Everything shown comes from the host + * (state.remote.autonomy); text is set with textContent only. Every button + * only asks: the host validates it and shows its own modal confirmation + * before anything is pre-authorized or Full Access is turned on. Loaded + * after localServersUx.js: it wraps RAME_REMOTE.init/render so main.js + * needs no change. */ +(function () { + 'use strict'; + + var ES = { + 'Autonomy & Permissions': 'Autonomía y permisos', + 'Mode for this workspace on this computer or server': 'Modo para este workspace en este equipo o servidor', + 'Ask always (default): each Meta consultation and each action that needs an authorization shows its card': + 'Preguntar siempre (predeterminado): cada consulta a Meta y cada acción que necesita autorización muestra su tarjeta', + 'Supervised autonomy: some external requests run without repeating the card, only within the permissions you give below': + 'Autonomía supervisada: algunas peticiones externas se hacen sin repetir la tarjeta, solo dentro de los permisos que des abajo', + 'Full Access: RAME works without authorization cards for the parts you choose, until you revoke it (separate from the Agent profile: Workspace full never turns it on)': + 'Acceso total (Full Access): RAME trabaja sin tarjetas de autorización en las partes que elijas, hasta que lo revoques (aparte del perfil del Agente: Workspace full nunca lo activa)', + 'Full Access': 'Acceso total', + 'Workspace files: read, create, change, move and delete (protected files and the acceptance check stay refused)': + 'Ficheros del workspace: leer, crear, cambiar, mover y borrar (los ficheros protegidos y la comprobación de aceptación siguen rechazados)', + 'Allowlisted tests': 'Tests permitidos', + 'Terminal commands (with the isolation level of Terminal commands; privilege elevation still asks)': 'Comandos de terminal (con el nivel de aislamiento de Comandos de terminal; la elevación de privilegios sigue preguntando)', + 'Meta as the main model: Agent sends your requests and the results of its tools (files it reads, test and command output, web pages) to the selected Meta model without the card that asks each time. Only this choice allows it; the parts above authorize local tools only. Billed by Meta.': + 'Meta como modelo principal: el Agente envía tus peticiones y los resultados de sus herramientas (ficheros que lee, salida de tests y comandos, páginas web) al modelo de Meta seleccionado sin la tarjeta que pregunta cada vez. Solo esta opción lo permite; las partes de arriba autorizan solo herramientas locales. Lo factura Meta.', + 'Meta {0} as the main model': 'Meta {0} como modelo principal', + 'Installs without administrator rights (downloads from the package registry)': 'Instalaciones sin permisos de administrador (descargas del registro de paquetes)', + 'Long jobs': 'Trabajos largos', + 'Web searches and page reads (read-only)': 'Búsquedas y lecturas web (solo lectura)', + 'Turn on Full Access…': 'Activar acceso total…', + 'Turn off Full Access': 'Desactivar acceso total', + "A confirmation lists what RAME may then do, where it runs, what can leave this computer and what may cost money. Never bypassed: your operating system's and providers' permissions, project memory (always a card), the Mission authorization card, the verifier's files and each tool's limits. With its Meta part, Meta is pre-authorized in the same confirmation (the selected model and key, these limits) and revoked with Full Access.": + 'Una confirmación detalla qué podrá hacer RAME, dónde se ejecuta, qué puede salir de este equipo y qué puede costar dinero. Nunca se salta: los permisos de tu sistema operativo y de tus proveedores, la memoria del proyecto (siempre con tarjeta), la tarjeta de autorización de la Misión, los ficheros del verificador ni los límites de cada herramienta. Con su parte de Meta, Meta queda preautorizado en la misma confirmación (el modelo y la clave elegidos, estos límites) y se revoca con el acceso total.', + 'Meta consultations (the advisor model) in Missions': 'Consultas a Meta (el modelo asesor) en Misiones', + 'Consultations per Mission, at most': 'Consultas por Misión, como máximo', + 'No count limit': 'Sin límite de cantidad', + 'Remote tokens for all Missions together, at most': 'Tokens remotos para todas las Misiones juntas, como máximo', + "No total (each Mission's limits)": 'Sin total (los límites de cada Misión)', + 'Valid for': 'Vigencia', + 'What Meta may receive': 'Qué puede recibir Meta', + "Only the Mission instructions and the host's notes": 'Solo las instrucciones de la Misión y las notas del host', + "Also files or fragments chosen on each Mission's card": 'También ficheros o fragmentos elegidos en la tarjeta de cada Misión', + 'Pre-authorize Meta…': 'Preautorizar Meta…', + 'Revoke': 'Revocar', + "Off by default. A confirmation shows what leaves this computer, to which provider and model, that it may be billed, the limits and how long it lasts. No count limit never means more data or no limits: each consultation keeps its byte and token limits, and each Mission its totals. RAME has no Meta price list, so the budget is in tokens. Each Mission's card still chooses 0 to this limit (0 by default). Never sent automatically: secrets, credentials, the verifier's hidden files, seeds, protected records, web pages. RAME never switches the main model of a Mission.": + 'Desactivado por defecto. Una confirmación muestra qué sale de este equipo, a qué proveedor y modelo, que puede facturarse, los límites y cuánto dura. Sin límite de cantidad nunca significa más datos ni quitar límites: cada consulta mantiene sus límites de bytes y tokens, y cada Misión sus totales. RAME no tiene tarifas de Meta, así que el presupuesto es en tokens. La tarjeta de cada Misión sigue eligiendo de 0 a este límite (0 por defecto). Nunca se envía automáticamente: secretos, credenciales, ficheros ocultos del verificador, semillas, registros protegidos ni páginas web. RAME nunca cambia el modelo principal de una Misión.', + 'Internet': 'Internet', + 'Agent and Chat, every window of this workspace: searches without a card, at most': 'Agente y Chat, todas las ventanas de este workspace: búsquedas sin tarjeta, como máximo', + 'Page reads without a card, at most': 'Lecturas de páginas sin tarjeta, como máximo', + 'Only these domains for page reads (optional; otherwise only pages your searches found)': 'Solo estos dominios para leer páginas (opcional; si no, solo las páginas que encontraron tus búsquedas)', + 'Pre-authorize web searches…': 'Preautorizar búsquedas web…', + 'Autonomous Missions may be given a web budget, valid for': 'Las Misiones autónomas pueden recibir un presupuesto web, con vigencia de', + 'Pre-authorize web for Missions…': 'Preautorizar web para Misiones…', + 'Separate from Meta. Only the query or the address leaves this computer: never a file, a key or private information, and a query that is not plain words shows its card. These tools only read (GET): publishing or changing data is not possible through them and keeps needing its own permission.': + 'Independiente de Meta. Solo sale de este equipo la consulta o la dirección: nunca un fichero, una clave ni información privada, y una consulta que no sea de palabras normales muestra su tarjeta. Estas herramientas solo leen (GET): publicar o modificar datos no es posible con ellas y sigue necesitando su propio permiso.', + 'Revoke all and stop what runs under them': 'Revocar todo y parar lo que se ejecuta con ellas', + 'External requests (history)': 'Peticiones externas (historial)', + 'Recorded before each request leaves: provider, destination, date, reason, what was sent and how it was approved.': + 'Se anota antes de que salga cada petición: proveedor, destino, fecha, motivo, qué se envió y cómo se aprobó.', + 'Files or fragments Meta may receive with each consultation (optional, at most 3; path or path:10-80)': + 'Ficheros o fragmentos que Meta puede recibir con cada consulta (opcional, como máximo 3; ruta o ruta:10-80)', + 'Autonomy & Permissions…': 'Autonomía y permisos…', + 'src/checksum.js, src/util.js:1-80': 'src/checksum.js, src/util.js:1-80', + '1 hour': '1 hora', + '8 hours': '8 horas', + '24 hours': '24 horas', + '7 days': '7 días', + '30 days': '30 días', + 'Workspace: {0}.': 'Workspace: {0}.', + 'Operations run on {0}. Permissions given here apply only there: another computer or Remote SSH server has its own.': + 'Las operaciones se ejecutan en {0}. Los permisos que des aquí solo valen allí: otro equipo o servidor Remote SSH tiene los suyos.', + 'Open a project folder to set its Autonomy & Permissions.': 'Abre una carpeta de proyecto para configurar su autonomía y permisos.', + 'Not pre-authorized: each consultation shows its card.': 'Sin preautorizar: cada consulta muestra su tarjeta.', + 'Pre-authorized: Meta {0}, up to {1} per Mission, {2}, until {3}.': 'Preautorizado: Meta {0}, hasta {1} por Misión, {2}, hasta {3}.', + 'Pre-authorized: Meta {0}, no count limit per Mission, {1}, until {2}.': 'Preautorizado: Meta {0}, sin límite de cantidad por Misión, {1}, hasta {2}.', + ' Tokens: {0} of {1} used.': ' Tokens: {0} de {1} usados.', + 'instructions only': 'solo instrucciones', + 'instructions and chosen files': 'instrucciones y ficheros elegidos', + 'Not in force: {0}.': 'Sin efecto: {0}.', + 'Not pre-authorized: each web search or page read shows its card.': 'Sin preautorizar: cada búsqueda o lectura web muestra su tarjeta.', + 'Every window of this workspace: {0}/{1} searches and {2}/{3} page reads used without a card, until {4}{5}.': 'Todas las ventanas de este workspace: {0}/{1} búsquedas y {2}/{3} lecturas usadas sin tarjeta, hasta {4}{5}.', + ', only on {0}': ', solo en {0}', + 'Autonomous Missions: no web budget can be given (not pre-authorized).': 'Misiones autónomas: no se puede dar presupuesto web (sin preautorizar).', + 'Autonomous Missions may be given a web budget until {0}.': 'Las Misiones autónomas pueden recibir presupuesto web hasta {0}.', + 'Off: every card appears as the profile and each pre-authorization say.': 'Desactivado: cada tarjeta aparece según el perfil y cada preautorización.', + 'On until {0}: {1} run without their cards.': 'Activado hasta {0}: {1} se ejecutan sin sus tarjetas.', + ' {0} long job(s) started under it are stopped if you revoke it.': ' {0} trabajo(s) largo(s) iniciados con él se paran si lo revocas.', + 'workspace files': 'ficheros del workspace', + 'tests': 'tests', + 'terminal commands': 'comandos de terminal', + 'installs': 'instalaciones', + 'long jobs': 'trabajos largos', + 'Meta consultations': 'consultas a Meta', + 'Full Access is on until {0}: {1} run without their cards. Choose a lower level to turn it off.': 'Acceso total activo hasta {0}: {1} sin tarjeta. Elige un nivel inferior para apagarlo.', + 'Full Access — on until {0}; choose a lower level to turn it off.': 'Acceso total — activo hasta {0}; elige un nivel inferior para apagarlo.', + 'Full Access — no cards for the parts you choose; asks for confirmation.': 'Acceso total — sin tarjetas para las partes que elijas; pide confirmación.', + 'you turn it off': 'que lo apagues', + 'Until you turn it off': 'Hasta que lo apagues', + 'Full Access parts and duration…': 'Partes y duración del acceso total…', + 'Full Access expired': 'Acceso total caducado', + 'Full Access expired: the permission is back to {0}.': 'El acceso total ha caducado: el permiso vuelve a {0}.', + 'Meta: when the main model cannot finish, consult Meta without cards (Agent, blocked Missions, the acceptance review)': 'Meta: cuando el modelo principal no consiga terminar, consultar a Meta sin tarjetas (Agente, Misiones bloqueadas, revisión de la aceptación)', + 'Consultations per Mission or conversation': 'Consultas por Misión o conversación', + 'Remote tokens in total, at most': 'Tokens remotos en total, como máximo', + "No total (each consultation's limits)": 'Sin total (los límites de cada consulta)', + "Instructions, the host's notes and test fragments you choose": 'Instrucciones, notas del host y fragmentos de test que elijas', + "Only instructions and the host's notes": 'Solo instrucciones y notas del host', + 'Full Access was not turned on: its Meta part needs cloud escalation ON, a saved Meta API key and a selected Meta model. Nothing changed.': 'No se activó el acceso total: su parte de Meta necesita la escalada a la nube activada, una clave de la API de Meta guardada y un modelo de Meta elegido. No ha cambiado nada.', + 'web': 'web', + 'No external request recorded for this workspace yet.': 'Aún no hay peticiones externas anotadas para este workspace.', + '{0} operation(s) running under a pre-authorization.': '{0} operación(es) en curso bajo una preautorización.', + 'on its card': 'en su tarjeta', + 'pre-authorized': 'preautorizada', + 'Mission grant': 'concesión de la Misión', + 'permission profile': 'perfil de permisos', + 'full-access': 'permitida por el acceso total', + 'Meta without a card: up to {0} consultation(s) per Mission are pre-authorized ({1}, until {2}).': 'Meta sin tarjeta: hasta {0} consulta(s) por Misión preautorizadas ({1}, hasta {2}).', + 'Meta without a card: no count limit per Mission is pre-authorized ({0}, until {1}); each consultation keeps its limits.': 'Meta sin tarjeta: preautorizado sin límite de cantidad por Misión ({0}, hasta {1}); cada consulta mantiene sus límites.', + 'Meta without a card: not pre-authorized for this workspace. If the Mission stops, the consultation card shows the exact text for your approval.': + 'Meta sin tarjeta: sin preautorizar en este workspace. Si la Misión se para, la tarjeta de consulta muestra el texto exacto para que lo apruebes.', + 'Web for an autonomous Mission: pre-authorized for this workspace.': 'Web para una Misión autónoma: preautorizada en este workspace.', + 'Web for an autonomous Mission: not pre-authorized, so its web budget cannot be given.': 'Web para una Misión autónoma: sin preautorizar, así que no se le puede dar presupuesto web.', + 'Full Access is on: the Mission\'s file operations and tests run without cards; this authorization card and project memory still ask.': + 'El acceso total está activado: las operaciones con ficheros y los tests de la Misión se ejecutan sin tarjetas; esta tarjeta de autorización y la memoria del proyecto siguen preguntando.', + 'Selected: no count limit on Meta consultations · {0}. Each consultation keeps its byte and token limits, and this Mission its totals.': + 'Seleccionado: sin límite de cantidad de consultas a Meta · {0}. Cada consulta mantiene sus límites de bytes y tokens, y esta Misión sus totales.', + 'Advice only: the main model keeps every tool. The advisor receives the Mission instructions, the host\'s notes and the files you list below, never secrets or the verifier\'s hidden files, and cannot execute actions.': + 'Solo consejo: el modelo principal conserva todas las herramientas. El asesor recibe las instrucciones de la Misión, las notas del host y los ficheros que indiques abajo, nunca secretos ni ficheros ocultos del verificador, y no puede ejecutar acciones.', + 'Advice only: the main model keeps every tool. The advisor receives no file contents and cannot execute actions.': 'Solo consejo: el modelo principal conserva todas las herramientas. El asesor no recibe contenido de ficheros y no puede ejecutar acciones.', + 'Choose at least one part for Full Access.': 'Elige al menos una parte para el acceso total.', + }; + + var REVOKED = { + 'Every pre-authorization of this workspace': 'Todas las preautorizaciones de este workspace', + 'The Meta pre-authorization': 'La preautorización de Meta', + 'The web pre-authorization for Missions': 'La preautorización web para Misiones', + 'Full Access': 'El acceso total', + 'The web pre-authorization of Agent and Chat': 'La preautorización web del Agente y el Chat', + }; + var STOPPED_ES = ', y se cancelaron, donde fue posible, las operaciones en curso con ella (lo que ya salió o se ejecutó no puede deshacerse)'; + + // Host messages of this module with variable parts. + var PATTERNS = [ + [/^Meta's advice was kept; Full Access retries the Mission with it \(automatic retry (\d+) of (\d+)\)\.$/, function (m) { return 'Se guardó el consejo de Meta; el acceso total reintenta la Misión con él (reintento automático ' + m[1] + ' de ' + m[2] + ').'; }], + [/^([\s\S]*?) ?Full Access already retried this Mission (\d+) times with Meta's advice; it waits for you now\.$/, function (m) { return (m[1] ? m[1] + ' ' : '') + 'El acceso total ya reintentó esta Misión ' + m[2] + ' veces con el consejo de Meta; ahora espera tu decisión.'; }], + [/^Meta consultations in a Mission need a pre-authorization in Settings → Security → Autonomy & Permissions \(([\s\S]*)\)\. Nothing was authorized\.$/, function (m) { return 'Las consultas a Meta en una Misión necesitan una preautorización en Ajustes → Seguridad → Autonomía y permisos (' + m[1] + '). No se autorizó nada.'; }], + [/^Web access for an autonomous Mission needs a pre-authorization in Settings → Security → Autonomy & Permissions \(([\s\S]*)\)\. Nothing was authorized\.$/, function (m) { return 'El acceso web de una Misión autónoma necesita una preautorización en Ajustes → Seguridad → Autonomía y permisos (' + m[1] + '). No se autorizó nada.'; }], + [/^Autonomy & Permissions: Ask always for ([\s\S]*?) on ([\s\S]*?)\. Every pre-authorization of this workspace was revoked([\s\S]*); each Meta consultation and each action shows its card again\.$/, function (m) { return 'Autonomía y permisos: preguntar siempre en ' + m[1] + ' (' + m[2] + '). Se revocaron todas las preautorizaciones de este workspace' + (m[3] ? STOPPED_ES : '') + '; cada consulta a Meta y cada acción vuelve a mostrar su tarjeta.'; }], + [/^Autonomy & Permissions: supervised autonomy for ([\s\S]*?) on ([\s\S]*?)\. Nothing is pre-authorized yet: choose below what RAME may do without a card, and for how long\.$/, function (m) { return 'Autonomía y permisos: autonomía supervisada en ' + m[1] + ' (' + m[2] + '). Aún no hay nada preautorizado: elige abajo qué puede hacer RAME sin tarjeta y durante cuánto tiempo.'; }], + [/^Full Access was turned off for ([\s\S]*?)([,;][\s\S]*)?\. Supervised autonomy: only the pre-authorizations below apply; everything else shows its card\.$/, function (m) { return 'Se desactivó el acceso total en ' + m[1] + (m[2] ? STOPPED_ES : '') + '. Autonomía supervisada: solo valen las preautorizaciones de abajo; todo lo demás muestra su tarjeta.'; }], + [/^Pre-authorized for Missions of ([\s\S]*?) on ([\s\S]*?): Meta ([\s\S]*?)\. Each Mission's card still chooses how many \(0 by default\)\.$/, function (m) { return 'Preautorizado para las Misiones de ' + m[1] + ' (' + m[2] + '): Meta ' + m[3] + '. La tarjeta de cada Misión sigue eligiendo cuántas (0 por defecto).'; }], + [/^Web budgets may be given to autonomous Missions of ([\s\S]*?) until ([\s\S]*?); each Mission's card sets its own budget\.$/, function (m) { return 'Las Misiones autónomas de ' + m[1] + ' pueden recibir presupuesto web hasta ' + m[2] + '; la tarjeta de cada Misión fija el suyo.'; }], + [/^Web pre-authorized for Agent and Chat in ([\s\S]*?), every window of this workspace on ([\s\S]*?): up to (\d+) search\(es\) and (\d+) page read\(s\) without a card, until ([\s\S]*?)\.$/, function (m) { return 'Web preautorizada para el Agente y el Chat en ' + m[1] + ', todas las ventanas de este workspace en ' + m[2] + ': hasta ' + m[3] + ' búsqueda(s) y ' + m[4] + ' lectura(s) sin tarjeta, hasta ' + m[5] + '.'; }], + [/^Full Access is on for ([\s\S]*?) on ([\s\S]*?) until ([\s\S]*?): ([\s\S]*?) run without their cards\. Project memory, privilege elevation and the Mission authorization still ask; revoke it at any time\.$/, function (m) { return 'Acceso total activado en ' + m[1] + ' (' + m[2] + ') hasta ' + m[3] + ': ' + m[4] + ' se ejecutan sin sus tarjetas. La memoria del proyecto, la elevación de privilegios y la autorización de la Misión siguen preguntando; puedes revocarlo cuando quieras.'; }], + [/^([\s\S]*?) was revoked in every window of this workspace on ([\s\S]*?): no new operation runs under it([\s\S]*)\. Anything else shows its card\.$/, function (m) { return (REVOKED[m[1]] || m[1]) + ' quedó revocado en todas las ventanas de este workspace en ' + m[2] + ': no se ejecuta ninguna operación nueva con ello' + (m[3] ? STOPPED_ES : '') + '. Todo lo demás muestra su tarjeta.'; }], + [/^Full Access was revoked or expired: the run that used it was stopped\. What already ran cannot be undone\.$/, function () { return 'El acceso total se revocó o caducó: se paró la ejecución que lo usaba. Lo que ya se ejecutó no puede deshacerse.'; }], + ]; + var EXACT_HOST = { + 'Choose supervised autonomy first (or Full Access). Nothing was pre-authorized.': 'Elige primero la autonomía supervisada (o el acceso total). No se preautorizó nada.', + 'Pre-authorizing Meta needs cloud escalation ON, a saved Meta API key and a selected Meta model. Nothing was pre-authorized.': 'Preautorizar Meta necesita el escalado a la nube activado, una clave de API de Meta guardada y un modelo de Meta elegido. No se preautorizó nada.', + 'Meta was not pre-authorized: the confirmation was not accepted. Each consultation still shows its card.': 'Meta no quedó preautorizado: no se aceptó la confirmación. Cada consulta sigue mostrando su tarjeta.', + 'Web for Missions was not pre-authorized: the confirmation was not accepted.': 'La web para Misiones no quedó preautorizada: no se aceptó la confirmación.', + 'Turn on Web access first (Settings → Security → Web access). Nothing was pre-authorized.': 'Activa primero el acceso web (Ajustes → Seguridad → Acceso web). No se preautorizó nada.', + 'Web searches were not pre-authorized: the confirmation was not accepted. Each one still shows its card.': 'Las búsquedas web no quedaron preautorizadas: no se aceptó la confirmación. Cada una sigue mostrando su tarjeta.', + 'Full Access was not turned on: the confirmation was not accepted. Every card still appears as before.': 'El acceso total no se activó: no se aceptó la confirmación. Cada tarjeta sigue apareciendo como antes.', + 'Turn on cloud escalation first.': 'Activa primero el escalado a la nube.', + 'Save a Meta API key first.': 'Guarda primero una clave de API de Meta.', + }; + + if (window.RAME_I18N && typeof window.RAME_I18N.extend === 'function') { + window.RAME_I18N.extend('es', ES); + window.RAME_I18N.extend('es', EXACT_HOST); + if (typeof window.RAME_I18N.extendPatterns === 'function') window.RAME_I18N.extendPatterns('es', PATTERNS); + } + + var h = null; + var lastData = null; + // The Full Access radio only shows its panel: the mode changes only after the host's confirmation. + var fullChosen = false; + // The Mission whose card already got Full Access's default count. + var defaultedFor = ''; + var FULL_PARTS = [['files', 'autonomyFullFiles'], ['tests', 'autonomyFullTests'], ['commands', 'autonomyFullCommands'], ['installs', 'autonomyFullInstalls'], ['jobs', 'autonomyFullJobs'], ['web', 'autonomyFullWeb'], ['meta', 'autonomyFullMeta']]; + var PART_WORDS = { files: 'workspace files', tests: 'tests', commands: 'terminal commands', installs: 'installs', jobs: 'long jobs', web: 'web', meta: 'Meta consultations' }; + var EXTRA_CONSULTS = [['10', '10'], ['100', '100'], ['1000', '1000'], ['-1', 'No count limit']]; + + function el(id) { + return document.getElementById(id); + } + // The parts of a Full Access grant in words, "Meta as the main model" included when it was chosen. + function fullParts(full) { + var words = FULL_PARTS.filter(function (p) { return full.tools[p[0]]; }).map(function (p) { return t(PART_WORDS[p[0]]); }); + if (full.mainModel) words.push(tf('Meta {0} as the main model', full.mainModel)); + return words.join(', '); + } + function t(text) { + return h ? h.t(text) : text; + } + function tf(text) { + var args = Array.prototype.slice.call(arguments, 1); + return h ? h.tf.apply(null, [text].concat(args)) : text; + } + function th(text) { + return h && h.tHost ? h.tHost(text) : text; + } + function setText(id, text) { + var node = el(id); + if (node) node.textContent = text; + } + function when(at) { + // A grant with no expiry (Full Access until the user turns it off): "until you turn it off". + if (at >= 8640000000000000) return t('you turn it off'); + try { + return new Date(at).toLocaleString(); + } catch (e) { + return String(at); + } + } + function post(message) { + if (h && h.vscode) h.vscode.postMessage(message); + } + function on(id, event, handler) { + var node = el(id); + if (node) node.addEventListener(event, handler); + } + function num(id, fallback, max) { + var value = parseInt(String((el(id) || {}).value || ''), 10); + return isNaN(value) ? fallback : Math.max(0, Math.min(max, value)); + } + function choice(id, allowed, fallback) { + var value = parseInt(String((el(id) || {}).value || ''), 10); + return allowed.indexOf(value) >= 0 ? value : fallback; + } + var APPROVAL = { card: 'on its card', 'pre-authorized': 'pre-authorized', 'mission-grant': 'Mission grant', profile: 'permission profile', 'full-access': 'Full Access' }; + + function metaText(meta) { + var scope = t(meta.dataScope === 'files' ? 'instructions and chosen files' : 'instructions only'); + var text = meta.maxPerMission === null + ? tf('Pre-authorized: Meta {0}, no count limit per Mission, {1}, until {2}.', meta.model, scope, when(meta.expiresAt)) + : tf('Pre-authorized: Meta {0}, up to {1} per Mission, {2}, until {3}.', meta.model, meta.maxPerMission, scope, when(meta.expiresAt)); + if (meta.tokenBudget !== null) text += tf(' Tokens: {0} of {1} used.', meta.usedTokens, meta.tokenBudget); + return text + (meta.problem ? ' ' + tf('Not in force: {0}.', th(meta.problem)) : ''); + } + + function renderSettings(view) { + var none = !view || view.project === null; + setText('autonomyProject', none ? t('Open a project folder to set its Autonomy & Permissions.') : tf('Workspace: {0}.', view.project)); + setText('autonomyEnv', view && view.env ? tf('Operations run on {0}. Permissions given here apply only there: another computer or Remote SSH server has its own.', view.env) : ''); + var mode = view ? view.mode : 'ask'; + if (mode === 'full') fullChosen = false; + var radios = { ask: el('autonomyAsk'), supervised: el('autonomySupervised'), full: el('autonomyFullMode') }; + var shown = fullChosen ? 'full' : mode; + Object.keys(radios).forEach(function (key) { + if (radios[key]) { + radios[key].checked = shown === key; + radios[key].disabled = none; + } + }); + var grants = el('autonomyGrants'); + if (grants) grants.hidden = none || shown === 'ask'; + var fullRow = el('autonomyFullRow'); + if (fullRow) fullRow.hidden = none || shown !== 'full'; + if (!view) return; + var full = view.full; + setText('autonomyFullNow', full + ? tf('On until {0}: {1} run without their cards.', when(full.expiresAt), fullParts(full)) + + (full.jobs > 0 ? tf(' {0} long job(s) started under it are stopped if you revoke it.', full.jobs) : '') + (full.problem ? ' ' + tf('Not in force: {0}.', th(full.problem)) : '') + : t('Off: every card appears as the profile and each pre-authorization say.')); + if (full) { + FULL_PARTS.forEach(function (p) { + var box = el(p[1]); + if (box) box.checked = Boolean(full.tools[p[0]]); + }); + var mainBox = el('autonomyFullMetaMain'); + if (mainBox) mainBox.checked = Boolean(full.mainModel); + } + var revokeFull = el('autonomyFullRevoke'); + if (revokeFull) revokeFull.disabled = !full; + var meta = view.meta; + setText('autonomyMetaNow', meta ? metaText(meta) : (view.metaUnavailable ? th(view.metaUnavailable) + ' ' : '') + t('Not pre-authorized: each consultation shows its card.')); + var grantMeta = el('autonomyMetaGrant'); + if (grantMeta) grantMeta.disabled = none || view.metaUnavailable !== ''; + var revokeMeta = el('autonomyMetaRevoke'); + if (revokeMeta) revokeMeta.disabled = !meta; + var session = view.sessionWeb; + setText('autonomySessionNow', session + ? tf('Every window of this workspace: {0}/{1} searches and {2}/{3} page reads used without a card, until {4}{5}.', session.searches, session.maxSearches, session.fetches, session.maxFetches, when(session.expiresAt), session.domains.length ? tf(', only on {0}', session.domains.join(', ')) : '') + : t('Not pre-authorized: each web search or page read shows its card.')); + var revokeWeb = el('autonomyWebRevoke'); + if (revokeWeb) revokeWeb.disabled = !session; + var missionWeb = view.missionWeb; + setText('autonomyMissionWebNow', missionWeb + ? tf('Autonomous Missions may be given a web budget until {0}.', when(missionWeb.expiresAt)) + (missionWeb.problem ? ' ' + tf('Not in force: {0}.', th(missionWeb.problem)) : '') + : t('Autonomous Missions: no web budget can be given (not pre-authorized).')); + var revokeMissionWeb = el('autonomyMissionWebRevoke'); + if (revokeMissionWeb) revokeMissionWeb.disabled = !missionWeb; + var all = el('autonomyRevokeAll'); + if (all) all.disabled = none; + var list = el('autonomyLog'); + if (list) { + while (list.firstChild) list.removeChild(list.firstChild); + if (view.log.length === 0) { + var empty = document.createElement('li'); + empty.textContent = t('No external request recorded for this workspace yet.'); + list.appendChild(empty); + } + view.log.forEach(function (entry) { + var item = document.createElement('li'); + item.textContent = when(entry.at) + ' · ' + entry.provider + ' → ' + entry.destination + ' · ' + t(APPROVAL[entry.approval] || entry.approval) + ' · ' + entry.reason + ' · ' + entry.data + (entry.bytes !== null ? ' (' + entry.bytes + ' B)' : '') + ' · ' + entry.outcome; + list.appendChild(item); + }); + } + if (view.inflight > 0) setText('autonomyProject', tf('Workspace: {0}.', view.project) + ' ' + tf('{0} operation(s) running under a pre-authorization.', view.inflight)); + } + + /** The card's choices beyond 5 (10, 100, 1000, no count limit), added once. */ + function ensureConsultOptions(select) { + if (!select || !select.options) return; + var have = {}; + for (var i = 0; i < select.options.length; i += 1) have[String(select.options[i].value)] = true; + EXTRA_CONSULTS.forEach(function (entry) { + if (have[entry[0]]) return; + var option = document.createElement('option'); + option.value = entry[0]; + option.textContent = t(entry[1]); + option.disabled = false; + select.appendChild(option); + }); + } + + function renderUnlimitedSummary(data) { + var select = el('missionConsults'); + var summary = el('missionConsultSummary'); + if (select && summary && String(select.value) === '-1') { + summary.textContent = tf('Selected: no count limit on Meta consultations · {0}. Each consultation keeps its byte and token limits, and this Mission its totals.', data && typeof data.cloudModel === 'string' ? data.cloudModel : 'Meta'); + } + } + + function renderMission(data, view) { + var live = data && data.missionLive ? data.missionLive : null; + var consultRow = el('missionConsultRow'); + var meta = view && view.meta && !view.meta.problem ? view.meta : null; + var parts = []; + var scope = meta ? t(meta.dataScope === 'files' ? 'instructions and chosen files' : 'instructions only') : ''; + parts.push(meta + ? (meta.maxPerMission === null + ? tf('Meta without a card: no count limit per Mission is pre-authorized ({0}, until {1}); each consultation keeps its limits.', scope, when(meta.expiresAt)) + : tf('Meta without a card: up to {0} consultation(s) per Mission are pre-authorized ({1}, until {2}).', meta.maxPerMission, scope, when(meta.expiresAt))) + : t('Meta without a card: not pre-authorized for this workspace. If the Mission stops, the consultation card shows the exact text for your approval.')); + var fullWeb = Boolean(view && view.full && !view.full.problem && view.full.tools.web); + parts.push((view && view.missionWeb && !view.missionWeb.problem) || fullWeb ? t('Web for an autonomous Mission: pre-authorized for this workspace.') : t('Web for an autonomous Mission: not pre-authorized, so its web budget cannot be given.')); + if (view && view.full && !view.full.problem) parts.push(t('Full Access is on: the Mission\'s file operations and tests run without cards; this authorization card and project memory still ask.')); + setText('missionAutonomyNote', parts.join(' ')); + var select = el('missionConsults'); + ensureConsultOptions(select); + if (select && meta) { + for (var i = 0; i < select.options.length; i += 1) { + var value = Number(select.options[i].value); + select.options[i].disabled = meta.maxPerMission === null ? false : value < 0 || value > meta.maxPerMission; + } + var chosen = Number(select.value); + if (meta.maxPerMission !== null && (chosen < 0 || chosen > meta.maxPerMission)) select.value = '0'; + // Full Access with Meta: each Mission's card starts at the pre-authorized maximum (once per Mission; the user may lower it). + var missionKey = live && live.missionId ? live.missionId : ''; + if (view.full && !view.full.problem && view.full.tools.meta && missionKey && defaultedFor !== missionKey) { + defaultedFor = missionKey; + select.value = meta.maxPerMission === null ? '-1' : String(meta.maxPerMission); + } + } + renderUnlimitedSummary(data); + var files = el('missionMetaFilesRow'); + var withFiles = Boolean(meta && meta.dataScope === 'files' && consultRow && !consultRow.hidden); + if (files) files.hidden = !withFiles; + var summary = el('missionConsultSummary'); + var hint = summary ? summary.nextElementSibling : null; + if (hint && hint.classList && hint.classList.contains('hint')) { + hint.textContent = t(withFiles + ? "Advice only: the main model keeps every tool. The advisor receives the Mission instructions, the host's notes and the files you list below, never secrets or the verifier's hidden files, and cannot execute actions." + : 'Advice only: the main model keeps every tool. The advisor receives no file contents and cannot execute actions.'); + } + // The live indicator: this Mission has pre-authorized external access. + var status = el('missionAutoStatus'); + var indicator = el('missionAccessIndicator'); + if (!indicator && status && status.parentNode) { + indicator = document.createElement('p'); + indicator.id = 'missionAccessIndicator'; + indicator.className = 'hint'; + indicator.setAttribute('role', 'status'); + status.parentNode.insertBefore(indicator, status.nextSibling); + } + if (indicator) { + var found = null; + var id = live && live.missionId; + (view ? view.missions : []).forEach(function (entry) { + if (entry.missionId === id) found = entry; + }); + indicator.textContent = found ? th(found.text) : ''; + indicator.hidden = !found; + } + } + + /** Settings → Security, at Autonomy & Permissions; with `full`, its Full Access panel (nothing changes before its confirmation). */ + function openAutonomy(full) { + var open = el('openSettings'); + if (open && typeof open.click === 'function') open.click(); + if (window.RAME_SETTINGS_TABS) window.RAME_SETTINGS_TABS.activate('cardSecurity'); + if (full) { + fullChosen = true; + if (lastData) render(lastData); + } + var section = el(full ? 'autonomyFullRow' : 'autonomySection') || el('autonomySection'); + if (section && section.scrollIntoView) section.scrollIntoView({ block: 'start' }); + post({ type: 'autonomyRefresh' }); + } + + function closePermissionMenu() { + var menu = el('permissionMenu'); + if (menu) menu.hidden = true; + var btn = el('permissionBtn'); + if (btn) btn.setAttribute('aria-expanded', 'false'); + } + + // What this script added to the chat's permission button and menu, created once. + var chatNodes = {}; + function menuItem(menu, id, handler) { + var item = chatNodes[id]; + if (!item) { + item = document.createElement('button'); + item.id = id; + item.type = 'button'; + item.setAttribute('role', 'menuitem'); + item.addEventListener('click', function () { + closePermissionMenu(); + handler(); + }); + menu.appendChild(item); + chatNodes[id] = item; + } + return item; + } + + /** The grant the Full Access panel describes now (its parts, Meta limits and validity), or null without any part. */ + function fullRequest() { + var tools = {}; + var any = false; + FULL_PARTS.forEach(function (p) { + tools[p[0]] = Boolean((el(p[1]) || {}).checked); + any = any || tools[p[0]]; + }); + var metaMain = Boolean((el('autonomyFullMetaMain') || {}).checked); + if (!any && !metaMain) return null; + var request = { type: 'autonomyFull', tools: tools, validityHours: choice('autonomyFullHours', [0, 1, 8, 24, 168, 720], 0) }; + // Only when chosen: no tool part implies it. + if (metaMain) request.metaMain = true; + // Its Meta part carries its limits: given in the same confirmation (defaults as in the markup). + if (tools.meta) { + request.meta = { + maxPerMission: choice('autonomyFullMetaMax', [1, 5, 10, 100, 1000, -1], -1), + dataScope: (el('autonomyFullMetaScope') || {}).value === 'instructions' ? 'instructions' : 'files', + tokenBudget: choice('autonomyFullMetaBudget', [0, 100000, 1000000, 10000000], 1000000), + }; + } + return request; + } + + // Full Access is the level above Workspace full in the chat's selector. + var fullActive = false; + // The expiry the user has already seen the notice for. + var endedSeen = 0; + var PROFILE_ITEMS = ['permAsk', 'permSafe', 'permFull']; + var PROFILE_NAMES = { ask: 'Ask', 'safe-auto': 'Safe auto', 'workspace-full': 'Workspace full' }; + + /** + * The chat's permission menu: Ask → Safe auto → Workspace full → Full Access. + * Choosing Full Access sends the grant its panel describes; the host asks + * for its confirmation (parts, validity, environment) and only then turns + * it on. It never raises the profile underneath (it covers only its own + * parts): when it ends, the selector shows that profile again. A lower + * level turns it off. + */ + function renderChat(view) { + var btn = el('permissionBtn'); + var menu = el('permissionMenu'); + if (!btn || !menu) return; + var full = view && view.mode === 'full' && view.full && !view.full.problem ? view.full : null; + var expired = !full && view && view.full && /^the Full Access expired/.test(view.full.problem || '') && view.full.expiresAt !== endedSeen ? view.full : null; + fullActive = Boolean(full); + var chip = chatNodes.autonomyFullChip; + if (!chip) { + chip = document.createElement('span'); + chip.id = 'autonomyFullChip'; + chip.className = 'chip'; + btn.appendChild(chip); + chatNodes.autonomyFullChip = chip; + } + chip.textContent = full ? t('Full Access') : t('Full Access expired'); + chip.hidden = !full && !expired; + // While it is in force the button names Full Access alone, not the profile underneath. + var profileChip = el('permissionChip'); + if (profileChip) profileChip.hidden = Boolean(full); + var label = full + ? t('Agent permission') + ' · ' + tf('Full Access is on until {0}: {1} run without their cards. Choose a lower level to turn it off.', when(full.expiresAt), fullParts(full)) + : expired ? t('Agent permission') + ' · ' + tf('Full Access expired: the permission is back to {0}.', t(PROFILE_NAMES[lastData && lastData.agentProfile] || 'Ask')) : t('Agent permission'); + btn.title = label; + btn.setAttribute('aria-label', label); + // Created once: the handler reads the latest state, never this render's. + var level = menuItem(menu, 'permFullAccess', function () { + if (fullActive) return; + var now = lastData && lastData.remote ? lastData.remote.autonomy : null; + var request = now && now.project ? fullRequest() : null; + if (request) post(request); + else openAutonomy(true); + }); + level.setAttribute('role', 'menuitemradio'); + level.setAttribute('aria-checked', full ? 'true' : 'false'); + level.textContent = full + ? tf('Full Access — on until {0}; choose a lower level to turn it off.', when(full.expiresAt)) + : t('Full Access — no cards for the parts you choose; asks for confirmation.'); + menuItem(menu, 'permFullAccessParts', function () { openAutonomy(true); }).textContent = t('Full Access parts and duration…'); + // main.js marks the profile after this render: un-mark it while Full Access is the level in force. + if (full) { + Promise.resolve().then(function () { + PROFILE_ITEMS.forEach(function (id) { + var item = el(id); + if (item) item.setAttribute('aria-checked', 'false'); + }); + }); + } + } + + /** A lower level chosen while Full Access is in force turns it off (after main.js has sent the profile). */ + function lowerLevel() { + if (!fullActive) return; + fullActive = false; + setTimeout(function () { post({ type: 'autonomyRevoke', what: 'full' }); }, 0); + } + + function render(data) { + lastData = data; + var view = data && data.remote ? data.remote.autonomy : null; + renderSettings(view || null); + renderMission(data, view || null); + renderChat(view || null); + } + + function init(helpers) { + h = helpers; + // The Mission's Meta files travel just before main.js sends the authorization (capture phase runs first). + document.addEventListener('click', function (event) { + var start = el('missionStart'); + if (!start || event.target !== start || !lastData || !lastData.missionLive || !lastData.missionLive.missionId) return; + var row = el('missionMetaFilesRow'); + var raw = row && !row.hidden ? String((el('missionMetaFiles') || {}).value || '') : ''; + post({ type: 'missionMetaScopeSet', missionId: lastData.missionLive.missionId, files: raw.split(',').map(function (f) { return f.trim(); }).filter(Boolean).slice(0, 8) }); + }, true); + on('autonomyAsk', 'change', function () { + fullChosen = false; + if (el('autonomyAsk').checked) post({ type: 'autonomyMode', mode: 'ask' }); + }); + on('autonomySupervised', 'change', function () { + fullChosen = false; + if (el('autonomySupervised').checked) post({ type: 'autonomyMode', mode: 'supervised' }); + }); + on('autonomyFullMode', 'change', function () { + // Only shows what Full Access would cover; nothing changes before its confirmation. + fullChosen = Boolean(el('autonomyFullMode').checked); + if (lastData) render(lastData); + }); + on('autonomyFullGrant', 'click', function () { + var request = fullRequest(); + if (!request) { + setText('autonomyFullNow', t('Choose at least one part for Full Access.')); + return; + } + post(request); + }); + // The chat's lower levels and the Settings profile list turn Full Access off. + PROFILE_ITEMS.forEach(function (id) { on(id, 'click', lowerLevel); }); + on('profileSelect', 'change', lowerLevel); + // Opening the menu acknowledges the expiry notice. + on('permissionBtn', 'click', function () { + var view = lastData && lastData.remote ? lastData.remote.autonomy : null; + if (view && view.full && view.full.problem) { + endedSeen = view.full.expiresAt; + renderChat(view); + } + }); + on('autonomyFullRevoke', 'click', function () { post({ type: 'autonomyRevoke', what: 'full' }); }); + on('autonomyMetaGrant', 'click', function () { + post({ + type: 'autonomyMeta', + maxPerMission: choice('autonomyMetaMax', [1, 2, 3, 4, 5, 10, 100, 1000, -1], 1), + validityHours: choice('autonomyMetaHours', [1, 8, 24, 168, 720], 8), + dataScope: (el('autonomyMetaScope') || {}).value === 'files' ? 'files' : 'instructions', + tokenBudget: choice('autonomyMetaBudget', [0, 100000, 1000000, 10000000], 0), + }); + }); + on('autonomyMetaRevoke', 'click', function () { post({ type: 'autonomyRevoke', what: 'meta' }); }); + on('autonomyWebGrant', 'click', function () { + post({ + type: 'autonomySessionWeb', + maxSearches: num('autonomyWebSearches', 5, 20), + maxFetches: num('autonomyWebFetches', 10, 40), + domains: String((el('autonomyWebDomains') || {}).value || '').split(',').map(function (d) { return d.trim(); }).filter(Boolean).slice(0, 20), + validityHours: choice('autonomyWebHours', [1, 8, 24, 168, 720], 1), + }); + }); + on('autonomyWebRevoke', 'click', function () { post({ type: 'autonomyRevoke', what: 'sessionWeb' }); }); + on('autonomyMissionWebGrant', 'click', function () { post({ type: 'autonomyMissionWeb', validityHours: choice('autonomyMissionWebHours', [1, 8, 24, 168, 720], 8) }); }); + on('autonomyMissionWebRevoke', 'click', function () { post({ type: 'autonomyRevoke', what: 'missionWeb' }); }); + on('autonomyRevokeAll', 'click', function () { post({ type: 'autonomyRevoke', what: 'all' }); }); + // After remoteUx's own listener (registered first): the summary of "no count limit". + on('missionConsults', 'change', function () { renderUnlimitedSummary(lastData); }); + // From the authorization card to Settings → Security, at this section. + on('missionAutonomyOpen', 'click', function () { openAutonomy(false); }); + var security = el('cardSecurity'); + if (security) security.addEventListener('toggle', function () { + if (security.open) post({ type: 'autonomyRefresh' }); + }); + post({ type: 'autonomyRefresh' }); + } + + var base = window.RAME_REMOTE; + if (base) { + window.RAME_REMOTE = Object.assign({}, base, { + init: function (helpers) { + base.init(helpers); + init(helpers); + }, + render: function (data) { + base.render(data); + render(data); + }, + }); + } +})(); diff --git a/media/evidenceUx.js b/media/evidenceUx.js index 06d80df..0444209 100644 --- a/media/evidenceUx.js +++ b/media/evidenceUx.js @@ -68,6 +68,7 @@ 'No frozen acceptance check': 'Sin comprobación de aceptación congelada', 'Frozen acceptance passed: {0}': 'Aceptación congelada superada: {0}', 'Frozen acceptance failed: {0}': 'Aceptación congelada fallida: {0}', + 'Frozen acceptance inconclusive (measured twice; not a failure): {0}': 'Aceptación congelada no concluyente (medida dos veces; no es un fallo): {0}', 'Frozen acceptance not run yet: {0}': 'Aceptación congelada aún sin ejecutar: {0}', 'Frozen acceptance can no longer be trusted': 'La aceptación congelada ya no es fiable', 'Mission completed by host': 'Misión completada por el host', @@ -254,6 +255,7 @@ out.push(m.acceptance === 'none' ? t('No frozen acceptance check') : m.acceptance === 'passed' ? tf('Frozen acceptance passed: {0}', cmd) : m.acceptance === 'failed' ? tf('Frozen acceptance failed: {0}', cmd) + : m.acceptance === 'inconclusive' ? tf('Frozen acceptance inconclusive (measured twice; not a failure): {0}', cmd) : m.acceptance === 'tampered' ? t('Frozen acceptance can no longer be trusted') : tf('Frozen acceptance not run yet: {0}', cmd)); out.push(t(m.completedByHost ? 'Mission completed by host' : 'Mission not completed by host')); diff --git a/media/i18n.js b/media/i18n.js index 4c04b2a..62f26aa 100644 --- a/media/i18n.js +++ b/media/i18n.js @@ -1,805 +1,775 @@ -/* RAME Code interface translations. English is the source language. Model - * prompts, approval digests and host records stay English. Unknown text is - * shown unchanged; captured pattern values are kept verbatim. */ (function () { - 'use strict'; +'use strict'; - var ES = { - // Header, threads, conversation - 'RAME Code actions': 'Acciones de RAME Code', - 'New thread': 'Nuevo hilo', - 'Refresh history': 'Actualizar historial', - 'Open Settings': 'Abrir ajustes', - 'Working…': 'Trabajando…', - 'Running…': 'Ejecutando…', - 'Thread list': 'Lista de hilos', - 'Threads': 'Hilos', - 'Back to thread list': 'Volver a la lista de hilos', - 'Conversation': 'Conversación', - 'Checking…': 'Comprobando…', - 'Scope / boundaries': 'Alcance / límites', - 'No thread yet. Start a new Agent thread below.': 'Aún no hay hilo. Empieza uno nuevo del Agente abajo.', - 'Activity': 'Actividad', - 'Result': 'Resultado', - 'Cloud escalation': 'Escalado a la nube', - 'Dismiss': 'Descartar', - 'Pending approval — review exact action': 'Aprobación pendiente — revisa la acción exacta', - 'Approve action': 'Aprobar acción', - 'Reject': 'Rechazar', - 'Run details': 'Detalles de la ejecución', - 'Starting…': 'Iniciando…', - 'Action log': 'Registro de acciones', - 'Message': 'Mensaje', - 'Describe the software goal.': 'Describe el objetivo de software.', - 'Workspace-full is high risk: patches and allowlisted tests run without prompts. Terminal commands, if you turn them on, still ask before each one, even here: a single command line can change files outside the project, install software or reach the internet.': - 'Acceso total es de alto riesgo: los parches y las pruebas permitidas se ejecutan sin preguntar. Los comandos de terminal, si los activas, siguen preguntando antes de cada uno, también aquí: una sola línea de comandos puede cambiar archivos fuera del proyecto, instalar programas o conectarse a Internet.', - 'Composer controls': 'Controles del mensaje', - 'Add context (coming later)': 'Añadir contexto (próximamente)', - 'Agent permission': 'Permiso del Agente', - 'Ask': 'Preguntar', - 'Safe auto': 'Auto seguro', - 'Workspace full': 'Acceso total', - 'Open Settings to change runtime': 'Abre Ajustes para cambiar el motor', - 'Runtime: open Settings to change': 'Motor: abre Ajustes para cambiarlo', - 'Runtime: Meta Model API — Cloud': 'Motor: Meta Model API — Nube', - 'Mode': 'Modo', - 'Agent': 'Agente', - 'Mission': 'Misión', - 'Chat': 'Chat', - 'Send message': 'Enviar mensaje', - 'Stop current run': 'Detener la ejecución actual', - 'Ask — every tool action needs approval.': 'Preguntar — cada acción de herramienta necesita tu aprobación.', - 'Safe auto — reads run automatically; writes and tests need approval.': - 'Auto seguro — las lecturas van solas; escrituras y pruebas necesitan aprobación.', - 'Workspace full — high risk: reads, patches, and allowlisted tests run without prompts.': - 'Acceso total — alto riesgo: lecturas, parches y pruebas permitidas se ejecutan sin preguntar.', - 'Permission profile': 'Perfil de permisos', - 'Shield': 'Escudo', - 'Permission': 'Permiso', - 'Agent permission profile': 'Perfil de permisos del Agente', - 'Ask (safest)': 'Preguntar (lo más seguro)', - 'Security / boundaries': 'Seguridad / límites', - 'Workspace-full is high risk: patches and allowlisted tests run without prompts. First Agent coding validation must target a disposable copy or test fixture, not the live RAME Code repository. Terminal commands, if you turn them on, still ask before each one, even here: a single command line can change files outside the project, install software or reach the internet.': - 'Acceso total es de alto riesgo: los parches y las pruebas permitidas se ejecutan sin preguntar. La primera validación del Agente debe hacerse sobre una copia desechable o un fixture de pruebas, no sobre el repositorio real de RAME Code. Los comandos de terminal, si los activas, siguen preguntando antes de cada uno, también aquí: una sola línea de comandos puede cambiar archivos fuera del proyecto, instalar programas o conectarse a Internet.', +var ES = { + 'RAME Code actions': 'Acciones de RAME Code', + 'New thread': 'Nuevo hilo', + 'Refresh history': 'Actualizar historial', + 'Open Settings': 'Abrir ajustes', + 'Working…': 'Trabajando…', + 'Running…': 'Ejecutando…', + 'Thread list': 'Lista de hilos', + 'Threads': 'Hilos', + 'Back to thread list': 'Volver a la lista de hilos', + 'Conversation': 'Conversación', + 'Checking…': 'Comprobando…', + 'Scope / boundaries': 'Alcance / límites', + 'No thread yet. Start a new Agent thread below.': 'Aún no hay hilo. Empieza uno nuevo del Agente abajo.', + 'Activity': 'Actividad', + 'Result': 'Resultado', + 'This environment has no protected isolation: the acceptance check will run without isolation. Code under test could read hidden cases and alter its results.': 'Este entorno no tiene aislamiento protegido: la comprobación de aceptación se ejecutará sin aislamiento. El código evaluado podría leer los casos ocultos y alterar sus resultados.', + 'verification: protected': 'verificación: protegida', + 'verification: unprotected': 'verificación: sin aislamiento', + 'Cloud escalation': 'Escalado a la nube', + 'Dismiss': 'Descartar', + 'Pending approval — review exact action': 'Aprobación pendiente — revisa la acción exacta', + 'Approve action': 'Aprobar acción', + 'Reject': 'Rechazar', + 'Run details': 'Detalles de la ejecución', + 'Starting…': 'Iniciando…', + 'Action log': 'Registro de acciones', + 'Message': 'Mensaje', + 'Describe the software goal.': 'Describe el objetivo de software.', + 'Workspace-full is high risk: patches and allowlisted tests run without prompts. Terminal commands, if you turn them on, still ask before each one, even here: a single command line can change files outside the project, install software or reach the internet.': + 'Acceso total es de alto riesgo: los parches y las pruebas permitidas se ejecutan sin preguntar. Los comandos de terminal, si los activas, siguen preguntando antes de cada uno, también aquí: una sola línea de comandos puede cambiar archivos fuera del proyecto, instalar programas o conectarse a Internet.', + 'Composer controls': 'Controles del mensaje', + 'Add context (coming later)': 'Añadir contexto (próximamente)', + 'Agent permission': 'Permiso del Agente', + 'Ask': 'Preguntar', + 'Safe auto': 'Auto seguro', + 'Workspace full': 'Acceso total', + 'Open Settings to change runtime': 'Abre Ajustes para cambiar el motor', + 'Runtime: open Settings to change': 'Motor: abre Ajustes para cambiarlo', + 'Runtime: Meta Model API — Cloud': 'Motor: Meta Model API — Nube', + 'Mode': 'Modo', + 'Agent': 'Agente', + 'Mission': 'Misión', + 'Chat': 'Chat', + 'Send message': 'Enviar mensaje', + 'Stop current run': 'Detener la ejecución actual', + 'Ask — every tool action needs approval.': 'Preguntar — cada acción de herramienta necesita tu aprobación.', + 'Safe auto — reads run automatically; writes and tests need approval.': + 'Auto seguro — las lecturas van solas; escrituras y pruebas necesitan aprobación.', + 'Workspace full — high risk: reads, patches, and allowlisted tests run without prompts.': + 'Acceso total — alto riesgo: lecturas, parches y pruebas permitidas se ejecutan sin preguntar.', + 'Permission profile': 'Perfil de permisos', + 'Shield': 'Escudo', + 'Permission': 'Permiso', + 'Agent permission profile': 'Perfil de permisos del Agente', + 'Ask (safest)': 'Preguntar (lo más seguro)', + 'Security / boundaries': 'Seguridad / límites', + 'Workspace-full is high risk: patches and allowlisted tests run without prompts. First Agent coding validation must target a disposable copy or test fixture, not the live RAME Code repository. Terminal commands, if you turn them on, still ask before each one, even here: a single command line can change files outside the project, install software or reach the internet.': + 'Acceso total es de alto riesgo: los parches y las pruebas permitidas se ejecutan sin preguntar. La primera validación del Agente debe hacerse sobre una copia desechable o un fixture de pruebas, no sobre el repositorio real de RAME Code. Los comandos de terminal, si los activas, siguen preguntando antes de cada uno, también aquí: una sola línea de comandos puede cambiar archivos fuera del proyecto, instalar programas o conectarse a Internet.', - // Settings - 'Settings': 'Ajustes', - 'Settings actions': 'Acciones de ajustes', - 'Back to Chat': 'Volver al chat', - '← Back': '← Volver', - 'Models': 'Modelos', - 'Interface': 'Interfaz', - 'Language': 'Idioma', - 'Interface language. Requests to models stay in English.': 'Idioma de la interfaz. Las peticiones a los modelos siguen en inglés.', - 'Runtime': 'Motor', - 'Meta Model API — Cloud': 'Meta Model API — Nube', - 'Muse Glimmer — Local': 'Muse Glimmer — Local', - 'One provider only. Change it here; the Chat screen shows a read-only status.': - 'Un solo proveedor. Se cambia aquí; la pantalla de chat solo muestra el estado.', - 'Generation': 'Generación', - 'Reasoning effort': 'Esfuerzo de razonamiento', - 'Model default': 'Por defecto del modelo', - 'Minimal': 'Mínimo', - 'Low': 'Bajo', - 'Medium': 'Medio', - 'High': 'Alto', - 'XHigh': 'Muy alto', - 'Max': 'Máximo', - 'Local maximum (empty = automatic)': 'Máximo local (vacío = automático)', - 'Meta maximum (empty = automatic)': 'Máximo de Meta (vacío = automático)', - 'Automatic': 'Automático', - 'Save maximums': 'Guardar máximos', - "Reasoning effort, output budget, context window, and the Agent's 12-step budget are separate settings. The output budget covers reasoning plus the visible reply and is lowered to fit the free context; your input is never cut to make room. Changes apply from the next request; a request already in progress keeps its settings.": - 'El esfuerzo de razonamiento, el presupuesto de salida, la ventana de contexto y el límite de 12 pasos del Agente son ajustes distintos. El presupuesto de salida cubre el razonamiento y la respuesta visible y se reduce para caber en el contexto libre; tu entrada nunca se recorta para hacer sitio. Los cambios se aplican desde la siguiente petición; una petición en curso conserva sus ajustes.', + 'Settings': 'Ajustes', + 'Settings actions': 'Acciones de ajustes', + 'Back to Chat': 'Volver al chat', + '← Back': '← Volver', + 'Models': 'Modelos', + 'Interface': 'Interfaz', + 'Language': 'Idioma', + 'Interface language. Requests to models stay in English.': 'Idioma de la interfaz. Las peticiones a los modelos siguen en inglés.', + 'Runtime': 'Motor', + 'Meta Model API — Cloud': 'Meta Model API — Nube', + 'Muse Glimmer — Local': 'Muse Glimmer — Local', + 'One provider only. Change it here; the Chat screen shows a read-only status.': + 'Un solo proveedor. Se cambia aquí; la pantalla de chat solo muestra el estado.', + 'Generation': 'Generación', + 'Reasoning effort': 'Esfuerzo de razonamiento', + 'Model default': 'Por defecto del modelo', + 'Minimal': 'Mínimo', + 'Low': 'Bajo', + 'Medium': 'Medio', + 'High': 'Alto', + 'XHigh': 'Muy alto', + 'Max': 'Máximo', + 'Local maximum (empty = automatic)': 'Máximo local (vacío = automático)', + 'Meta maximum (empty = automatic)': 'Máximo de Meta (vacío = automático)', + 'Automatic': 'Automático', + 'Save maximums': 'Guardar máximos', + "Reasoning effort, output budget, context window, and the Agent's 12-step budget are separate settings. The output budget covers reasoning plus the visible reply and is lowered to fit the free context; your input is never cut to make room. Changes apply from the next request; a request already in progress keeps its settings.": + 'El esfuerzo de razonamiento, el presupuesto de salida, la ventana de contexto y el límite de 12 pasos del Agente son ajustes distintos. El presupuesto de salida cubre el razonamiento y la respuesta visible y se reduce para caber en el contexto libre; tu entrada nunca se recorta para hacer sitio. Los cambios se aplican desde la siguiente petición; una petición en curso conserva sus ajustes.', - // Project memory - 'Project memory': 'Memoria del proyecto', - 'Used automatically by Agent and Mission.': 'Se usa automáticamente en Agente y Misión.', - 'Approved decisions for the working folder are added automatically. Nothing needs to be selected here.': 'Las decisiones aprobadas de la carpeta de trabajo se añaden automáticamente. No necesitas seleccionar nada aquí.', - 'View another open folder': 'Ver otra carpeta abierta', - 'Choose a folder…': 'Elige una carpeta…', - 'This only changes this Settings view; it never changes a thread or merges memories.': 'Esto solo cambia esta vista de Ajustes; nunca cambia un hilo ni mezcla memorias.', - 'No folder selected for this Settings view.': 'Ninguna carpeta seleccionada para esta vista de Ajustes.', - 'Memory shown for: {0}': 'Memoria mostrada de: {0}', - 'Pending memory change — review the exact change': 'Cambio de memoria pendiente — revisa el cambio exacto', - 'Approve this change': 'Aprobar este cambio', - 'Decisions in force': 'Decisiones vigentes', - 'Verified files': 'Archivos verificados', - 'History': 'Historial', - 'Advanced: add a decision manually': 'Avanzado: añadir una decisión manualmente', - 'Key': 'Clave', - 'Type': 'Tipo', - 'Value type': 'Tipo de valor', - 'Number': 'Número', - 'True / false': 'Verdadero / falso', - 'Text': 'Texto', - 'Value': 'Valor', - 'Review change': 'Revisar cambio', - 'Preparing…': 'Preparando…', - 'Saving…': 'Guardando…', - 'Rejecting…': 'Rechazando…', - 'Approving…': 'Aprobando…', - 'Loading…': 'Cargando…', - 'Enter a key and a value first.': 'Escribe primero una clave y un valor.', - 'Only a change you approve here or in an Agent approval card is saved. Each change is a new version; earlier versions stay in the history. Repository files, tool output, and model text never change memory.': - 'Solo se guarda un cambio que apruebes aquí o en una tarjeta de aprobación del Agente. Cada cambio es una versión nueva; las anteriores quedan en el historial. Los ficheros del repositorio, la salida de herramientas y el texto del modelo nunca cambian la memoria.', - 'No decisions yet.': 'Aún no hay decisiones.', - 'No verified files yet.': 'Aún no hay archivos verificados.', - 'you': 'ti', - 'Meta cloud model': 'el modelo de Meta en la nube', - 'local model': 'el modelo local', - 'current': 'al día', - 'STALE': 'DESFASADO', - 'changed since verified': 'cambiado desde la verificación', - 'missing': 'no existe', - 'decision removed': 'decisión eliminada', - 'not checked': 'sin comprobar', - 'Entered in Settings.': 'Introducido en Ajustes.', + 'Project memory': 'Memoria del proyecto', + 'Used automatically by Agent and Mission.': 'Se usa automáticamente en Agente y Misión.', + 'Approved decisions for the working folder are added automatically. Nothing needs to be selected here.': 'Las decisiones aprobadas de la carpeta de trabajo se añaden automáticamente. No necesitas seleccionar nada aquí.', + 'View another open folder': 'Ver otra carpeta abierta', + 'Choose a folder…': 'Elige una carpeta…', + 'This only changes this Settings view; it never changes a thread or merges memories.': 'Esto solo cambia esta vista de Ajustes; nunca cambia un hilo ni mezcla memorias.', + 'No folder selected for this Settings view.': 'Ninguna carpeta seleccionada para esta vista de Ajustes.', + 'Memory shown for: {0}': 'Memoria mostrada de: {0}', + 'Pending memory change — review the exact change': 'Cambio de memoria pendiente — revisa el cambio exacto', + 'Approve this change': 'Aprobar este cambio', + 'Decisions in force': 'Decisiones vigentes', + 'Verified files': 'Archivos verificados', + 'History': 'Historial', + 'Advanced: add a decision manually': 'Avanzado: añadir una decisión manualmente', + 'Key': 'Clave', + 'Type': 'Tipo', + 'Value type': 'Tipo de valor', + 'Number': 'Número', + 'True / false': 'Verdadero / falso', + 'Text': 'Texto', + 'Value': 'Valor', + 'Review change': 'Revisar cambio', + 'Preparing…': 'Preparando…', + 'Saving…': 'Guardando…', + 'Rejecting…': 'Rechazando…', + 'Approving…': 'Aprobando…', + 'Loading…': 'Cargando…', + 'Enter a key and a value first.': 'Escribe primero una clave y un valor.', + 'Only a change you approve here or in an Agent approval card is saved. Each change is a new version; earlier versions stay in the history. Repository files, tool output, and model text never change memory.': + 'Solo se guarda un cambio que apruebes aquí o en una tarjeta de aprobación del Agente. Cada cambio es una versión nueva; las anteriores quedan en el historial. Los ficheros del repositorio, la salida de herramientas y el texto del modelo nunca cambian la memoria.', + 'No decisions yet.': 'Aún no hay decisiones.', + 'No verified files yet.': 'Aún no hay archivos verificados.', + 'you': 'ti', + 'Meta cloud model': 'el modelo de Meta en la nube', + 'local model': 'el modelo local', + 'current': 'al día', + 'STALE': 'DESFASADO', + 'changed since verified': 'cambiado desde la verificación', + 'missing': 'no existe', + 'decision removed': 'decisión eliminada', + 'not checked': 'sin comprobar', + 'Entered in Settings.': 'Introducido en Ajustes.', - // Mission - 'No Mission for this project. Create one with a Mission plan.': 'No hay Misión para este proyecto. Crea una con un plan de Misión.', - 'Mission tasks': 'Tareas de la Misión', - 'Evidence': 'Evidencias', - 'Run task': 'Ejecutar tarea', - 'active': 'activa', - 'waiting for your approval': 'esperando tu aprobación', - 'interrupted': 'interrumpida', - 'completed': 'completada', - 'failed': 'fallida', - 'done': 'hecha', - 'pending': 'pendiente', + 'No Mission for this project. Create one with a Mission plan.': 'No hay Misión para este proyecto. Crea una con un plan de Misión.', + 'Mission tasks': 'Tareas de la Misión', + 'Evidence': 'Evidencias', + 'Run task': 'Ejecutar tarea', + 'active': 'activa', + 'waiting for your approval': 'esperando tu aprobación', + 'interrupted': 'interrumpida', + 'completed': 'completada', + 'failed': 'fallida', + 'done': 'hecha', + 'pending': 'pendiente', - // Cloud - 'Cloud': 'Nube', - 'Meta Model API key': 'Clave de Meta Model API', - 'Enter a separate Meta Model API key': 'Introduce una clave propia de Meta Model API', - 'Save key': 'Guardar clave', - 'Clear key': 'Borrar clave', - 'Test connection': 'Probar conexión', - 'Testing…': 'Comprobando…', - 'Muse Code subscription credentials are not reused. Sent to the extension host only when you click Save key. Stored only in VS Code SecretStorage; this view only ever learns whether a key exists.': - 'No se reutilizan las credenciales de la suscripción de Muse Code. La clave solo se envía al host de la extensión cuando pulsas Guardar clave. Se guarda solo en SecretStorage de VS Code; esta vista solo sabe si existe una clave.', - 'Available compatible Meta models': 'Modelos de Meta compatibles disponibles', - 'Cloud model': 'Modelo en la nube', - 'Refresh available models': 'Actualizar modelos disponibles', - 'Loading models…': 'Cargando modelos…', - 'Fallback catalog. Save a key or refresh to load live models.': 'Catálogo de reserva. Guarda una clave o actualiza para cargar los modelos reales.', - 'Only muse-spark and muse-wonder chat models are shown, newest-first as returned. Contributor variants are labeled Contributor (data may be used) and are never selected silently.': - 'Solo se muestran los modelos de chat muse-spark y muse-wonder, del más nuevo al más antiguo. Las variantes Contributor se marcan como Contributor (los datos pueden usarse) y nunca se eligen en silencio.', + 'Cloud': 'Nube', + 'Meta Model API key': 'Clave de Meta Model API', + 'Enter a separate Meta Model API key': 'Introduce una clave propia de Meta Model API', + 'Save key': 'Guardar clave', + 'Clear key': 'Borrar clave', + 'Test connection': 'Probar conexión', + 'Testing…': 'Comprobando…', + 'Muse Code subscription credentials are not reused. Sent to the extension host only when you click Save key. Stored only in VS Code SecretStorage; this view only ever learns whether a key exists.': + 'No se reutilizan las credenciales de la suscripción de Muse Code. La clave solo se envía al host de la extensión cuando pulsas Guardar clave. Se guarda solo en SecretStorage de VS Code; esta vista solo sabe si existe una clave.', + 'Available compatible Meta models': 'Modelos de Meta compatibles disponibles', + 'Cloud model': 'Modelo en la nube', + 'Refresh available models': 'Actualizar modelos disponibles', + 'Loading models…': 'Cargando modelos…', + 'Fallback catalog. Save a key or refresh to load live models.': 'Catálogo de reserva. Guarda una clave o actualiza para cargar los modelos reales.', + 'Only muse-spark and muse-wonder chat models are shown, newest-first as returned. Contributor variants are labeled Contributor (data may be used) and are never selected silently.': + 'Solo se muestran los modelos de chat muse-spark y muse-wonder, del más nuevo al más antiguo. Las variantes Contributor se marcan como Contributor (los datos pueden usarse) y nunca se eligen en silencio.', - // Local runtime - 'Local runtime': 'Motor local', - 'Local runtime is stopped.': 'El motor local está parado.', - 'Local runtime is starting…': 'El motor local está arrancando…', - 'Local runtime is stopping…': 'El motor local se está deteniendo…', - 'llama-server executable path': 'Ruta del ejecutable llama-server', - 'Model path (GGUF)': 'Ruta del modelo (GGUF)', - 'Multimodal projector path (optional)': 'Ruta del proyector multimodal (opcional)', - 'DFlash / draft-model path (optional)': 'Ruta de DFlash / modelo borrador (opcional)', - 'Model / display ID': 'ID del modelo / nombre visible', - 'Context size': 'Tamaño de contexto', - 'Port (loopback only)': 'Puerto (solo loopback)', - 'Idle timeout (minutes, 0 disables)': 'Apagado por inactividad (minutos, 0 lo desactiva)', - 'External loopback endpoint (optional, not owned)': 'Endpoint loopback externo (opcional, no gestionado)', - 'Use detected settings': 'Usar ajustes detectados', - 'Save': 'Guardar', - 'Start Muse Glimmer': 'Iniciar Muse Glimmer', - 'Starting': 'Arrancando', - 'Running': 'En marcha', - 'Stop': 'Detener', - 'Stopping': 'Deteniendo', - 'Stopping…': 'Deteniendo…', - 'Use external endpoint': 'Usar endpoint externo', - 'Clear external endpoint': 'Quitar endpoint externo', - "Bound to 127.0.0.1 and launched without a shell. Closing a terminal does not stop it. Timeout 0 keeps it running while VS Code stays open; reloading or closing VS Code stops RAME Code's server.": - 'Escucha solo en 127.0.0.1 y se inicia sin shell. Cerrar una terminal no lo detiene. El valor 0 lo mantiene activo mientras VS Code siga abierto; recargar o cerrar VS Code detiene el servidor de RAME Code.', - 'Escalation': 'Escalado', - 'Allow cloud escalation': 'Permitir escalado a la nube', - 'Default OFF. There is no silent fallback: when local work fails, the Chat screen offers a visible choice and only your explicit approval sends the current request to Meta.': - 'Desactivado por defecto. No hay alternativa silenciosa: si falla el trabajo local, el chat te ofrece una opción visible y solo tu aprobación explícita envía la petición actual a Meta.', - 'Privacy / Security': 'Privacidad / seguridad', - 'The API key stays in VS Code SecretStorage and this view receives only true/false. Agent and Chat send recent active-thread history and may recover a bounded set of relevant older excerpts. Other threads are never sent. When moving from local to Meta, extra older excerpts and host records stay local. Mission planning sends only your new message. Agent may read and change active-workspace files and run allowlisted tests only under the selected permission profile and policy boundary. Meta Billing/Usage is authoritative for cost; token counts below are observed usage, and any dollar figure is an estimate.': - 'La clave queda en SecretStorage y esta vista solo recibe verdadero/falso. Agente y Chat envían el historial reciente del hilo activo y pueden recuperar extractos antiguos relevantes y acotados. Nunca se envían otros hilos. Al pasar de local a Meta, los extractos y registros antiguos adicionales permanecen locales. La planificación de Misión solo envía el mensaje nuevo. El Agente actúa en el workspace con el perfil y la política elegidos. Meta Billing/Usage es la referencia del coste; los tokens son uso observado y cualquier cifra en dólares es estimada.', - 'Diagnostics': 'Diagnóstico', - 'No diagnostics.': 'Sin diagnóstico.', + 'Local runtime': 'Motor local', + 'Local runtime is stopped.': 'El motor local está parado.', + 'Local runtime is starting…': 'El motor local está arrancando…', + 'Local runtime is stopping…': 'El motor local se está deteniendo…', + 'llama-server executable path': 'Ruta del ejecutable llama-server', + 'Model path (GGUF)': 'Ruta del modelo (GGUF)', + 'Multimodal projector path (optional)': 'Ruta del proyector multimodal (opcional)', + 'DFlash / draft-model path (optional)': 'Ruta de DFlash / modelo borrador (opcional)', + 'Model / display ID': 'ID del modelo / nombre visible', + 'Context size': 'Tamaño de contexto', + 'Port (loopback only)': 'Puerto (solo loopback)', + 'Idle timeout (minutes, 0 disables)': 'Apagado por inactividad (minutos, 0 lo desactiva)', + 'External loopback endpoint (optional, not owned)': 'Endpoint loopback externo (opcional, no gestionado)', + 'Use detected settings': 'Usar ajustes detectados', + 'Save': 'Guardar', + 'Start Muse Glimmer': 'Iniciar Muse Glimmer', + 'Starting': 'Arrancando', + 'Running': 'En marcha', + 'Stop': 'Detener', + 'Stopping': 'Deteniendo', + 'Stopping…': 'Deteniendo…', + 'Use external endpoint': 'Usar endpoint externo', + 'Clear external endpoint': 'Quitar endpoint externo', + "Bound to 127.0.0.1 and launched without a shell. Closing a terminal does not stop it. Timeout 0 keeps it running while VS Code stays open; reloading or closing VS Code stops RAME Code's server.": + 'Escucha solo en 127.0.0.1 y se inicia sin shell. Cerrar una terminal no lo detiene. El valor 0 lo mantiene activo mientras VS Code siga abierto; recargar o cerrar VS Code detiene el servidor de RAME Code.', + 'Escalation': 'Escalado', + 'Allow cloud escalation': 'Permitir escalado a la nube', + 'Default OFF. There is no silent fallback: when local work fails, the Chat screen offers a visible choice and only your explicit approval sends the current request to Meta.': + 'Desactivado por defecto. No hay alternativa silenciosa: si falla el trabajo local, el chat te ofrece una opción visible y solo tu aprobación explícita envía la petición actual a Meta.', + 'Privacy / Security': 'Privacidad / seguridad', + 'The API key stays in VS Code SecretStorage and this view receives only true/false. Agent and Chat send recent active-thread history and may recover a bounded set of relevant older excerpts. Other threads are never sent. When moving from local to Meta, extra older excerpts and host records stay local. Mission planning sends only your new message. Agent may read and change active-workspace files and run allowlisted tests only under the selected permission profile and policy boundary. Meta Billing/Usage is authoritative for cost; token counts below are observed usage, and any dollar figure is an estimate.': + 'La clave queda en SecretStorage y esta vista solo recibe verdadero/falso. Agente y Chat envían el historial reciente del hilo activo y pueden recuperar extractos antiguos relevantes y acotados. Nunca se envían otros hilos. Al pasar de local a Meta, los extractos y registros antiguos adicionales permanecen locales. La planificación de Misión solo envía el mensaje nuevo. El Agente actúa en el workspace con el perfil y la política elegidos. Meta Billing/Usage es la referencia del coste; los tokens son uso observado y cualquier cifra en dólares es estimada.', + 'Diagnostics': 'Diagnóstico', + 'No diagnostics.': 'Sin diagnóstico.', - // Turns, cards, connection - 'You': 'Tú', - 'Assistant': 'Asistente', - 'Plan': 'Plan', - 'Assumptions': 'Supuestos', - 'Steps': 'Pasos', - 'Risks': 'Riesgos', - 'Cancelled': 'Cancelado', - 'Action cancelled': 'Acción cancelada', - 'Run stopped': 'Ejecución detenida', - 'Open Settings to start the local model': 'Abre Ajustes para iniciar el modelo local', - 'Unsupported turn.': 'Turno no admitido.', - 'Request failed.': 'La petición falló.', - 'No threads yet.': 'Aún no hay hilos.', - 'Untitled': 'Sin título', - 'Delete thread': 'Borrar hilo', - 'Delete this thread?': '¿Borrar este hilo?', - 'Delete': 'Borrar', - 'Deleting…': 'Borrando…', - 'Cancel': 'Cancelar', - 'Host record, verified by RAME Code': 'Registro del host, verificado por RAME Code', - 'Host record — verified by RAME Code, not written by the model': 'Registro del host — verificado por RAME Code, no escrito por el modelo', - 'Not connected — API key required': 'Sin conexión — hace falta una clave de API', - 'Key stored — live connection not yet verified': 'Clave guardada — conexión aún sin verificar', - 'Connection verified — Meta API responded in this session': 'Conexión verificada — Meta API respondió en esta sesión', - 'Plan ready': 'Plan listo', - 'Connected — verified': 'Conectado — verificado', - 'Error': 'Error', - 'Waiting for your approval': 'Esperando tu aprobación', - 'Completed': 'Completado', - 'the run stopped.': 'la ejecución se detuvo.', - 'no workspace open': 'ningún workspace abierto', - 'unknown': 'desconocido', - 'Last call': 'Última llamada', - 'Cloud total': 'Total nube', - 'Local total': 'Total local', - ' (Meta Billing/Usage is authoritative; no balance is shown.)': ' (Meta Billing/Usage es la referencia; no se muestra saldo.)', - 'Ask (safest) — every tool action needs your approval.': 'Preguntar (lo más seguro) — cada acción de herramienta necesita tu aprobación.', - 'Workspace full (high risk) — reads, patches, and allowlisted tests run without prompts.': - 'Acceso total (alto riesgo) — lecturas, parches y pruebas permitidas se ejecutan sin preguntar.', - 'The local model is not running. Start Muse Glimmer, choose Meta Model API, or configure an external OpenAI-compatible loopback server.': - 'El modelo local no está en marcha. Inicia Muse Glimmer, elige Meta Model API o configura un servidor loopback externo compatible con OpenAI.', - 'human': 'aprobada por ti', - 'profile-auto': 'automática por perfil', - 'mission-grant': 'permitida por la Misión', - 'rejected': 'rechazada', - 'policy': 'bloqueada por la política', + 'You': 'Tú', + 'Assistant': 'Asistente', + 'Plan': 'Plan', + 'Assumptions': 'Supuestos', + 'Steps': 'Pasos', + 'Risks': 'Riesgos', + 'Cancelled': 'Cancelado', + 'Action cancelled': 'Acción cancelada', + 'Run stopped': 'Ejecución detenida', + 'Open Settings to start the local model': 'Abre Ajustes para iniciar el modelo local', + 'Unsupported turn.': 'Turno no admitido.', + 'Request failed.': 'La petición falló.', + 'No threads yet.': 'Aún no hay hilos.', + 'Untitled': 'Sin título', + 'Delete thread': 'Borrar hilo', + 'Delete this thread?': '¿Borrar este hilo?', + 'Delete': 'Borrar', + 'Deleting…': 'Borrando…', + 'Cancel': 'Cancelar', + 'Host record, verified by RAME Code': 'Registro del host, verificado por RAME Code', + 'Host record — verified by RAME Code, not written by the model': 'Registro del host — verificado por RAME Code, no escrito por el modelo', + 'Not connected — API key required': 'Sin conexión — hace falta una clave de API', + 'Key stored — live connection not yet verified': 'Clave guardada — conexión aún sin verificar', + 'Connection verified — Meta API responded in this session': 'Conexión verificada — Meta API respondió en esta sesión', + 'Plan ready': 'Plan listo', + 'Connected — verified': 'Conectado — verificado', + 'Error': 'Error', + 'Waiting for your approval': 'Esperando tu aprobación', + 'Completed': 'Completado', + 'the run stopped.': 'la ejecución se detuvo.', + 'no workspace open': 'ningún workspace abierto', + 'unknown': 'desconocido', + 'Last call': 'Última llamada', + 'Cloud total': 'Total nube', + 'Local total': 'Total local', + ' (Meta Billing/Usage is authoritative; no balance is shown.)': ' (Meta Billing/Usage es la referencia; no se muestra saldo.)', + 'Ask (safest) — every tool action needs your approval.': 'Preguntar (lo más seguro) — cada acción de herramienta necesita tu aprobación.', + 'Workspace full (high risk) — reads, patches, and allowlisted tests run without prompts.': + 'Acceso total (alto riesgo) — lecturas, parches y pruebas permitidas se ejecutan sin preguntar.', + 'The local model is not running. Start Muse Glimmer, choose Meta Model API, or configure an external OpenAI-compatible loopback server.': + 'El modelo local no está en marcha. Inicia Muse Glimmer, elige Meta Model API o configura un servidor loopback externo compatible con OpenAI.', + 'human': 'aprobada por ti', + 'profile-auto': 'automática por perfil', + 'mission-grant': 'permitida por la Misión', + 'rejected': 'rechazada', + 'policy': 'bloqueada por la política', - // Templates of the view: {0}, {1}… are filled with values as they are. - 'Updated {0}': 'Actualizado {0}', - 'Success criterion: {0}': 'Criterio de éxito: {0}', - 'Open thread {0}': 'Abrir hilo {0}', - 'Delete thread {0}': 'Borrar hilo {0}', - 'Confirm deletion of {0}': 'Confirmar el borrado de {0}', - 'Confirm delete {0}': 'Confirmar el borrado de {0}', - 'Cancel delete {0}': 'Cancelar el borrado de {0}', - '{0}: in {1} / out {2} / cached {3} / total {4}': '{0}: entrada {1} / salida {2} / caché {3} / total {4}', - 'Meta Model API — Cloud ({0})': 'Meta Model API — Nube ({0})', - 'Runtime: {0}': 'Motor: {0}', - 'Tool: {0}': 'Herramienta: {0}', - 'Targets: {0}': 'Objetivos: {0}', - 'Exact action: {0}': 'Acción exacta: {0}', - 'Provider: {0} | Workspace: {1} | Thread: {2} | Digest: {3} — approval is one-use and hash-bound; the host verifies before running.': - 'Proveedor: {0} | Workspace: {1} | Hilo: {2} | Digest: {3} — la aprobación es de un solo uso y va ligada al hash; el host la verifica antes de ejecutar.', - 'Pending approval: {0}': 'Aprobación pendiente: {0}', - 'Targets: {0} | Digest: {1}': 'Objetivos: {0} | Digest: {1}', - 'Live catalog: {0} compatible model(s).': 'Catálogo real: {0} modelo(s) compatible(s).', - 'Fallback catalog: {0} compatible model(s).': 'Catálogo de reserva: {0} modelo(s) compatible(s).', - 'Save a key or refresh to load live models.': 'Guarda una clave o actualiza para cargar los modelos reales.', - 'Local runtime: running — {0}': 'Motor local: en marcha — {0}', - 'Local runtime: starting — {0}': 'Motor local: arrancando — {0}', - 'Local runtime: external endpoint — {0}': 'Motor local: endpoint externo — {0}', - '{0} · {1} · context {2} per request.': '{0} · {1} · contexto por petición: {2}.', - 'Sent as {0}. Model default sends no level (default: {1}). Reasoning cannot be turned off for this model.': - 'Se envía como {0}. «Por defecto del modelo» no envía ningún nivel (por defecto: {1}). En este modelo el razonamiento no se puede desactivar.', - 'No verified reasoning control for this model ({0}), so only Model default is offered.': - 'No hay control de razonamiento verificado para este modelo ({0}), así que solo se ofrece «Por defecto del modelo».', - 'Your saved level {0} does not apply here; Model default is used.': 'Tu nivel guardado ({0}) no se aplica aquí; se usa «Por defecto del modelo».', - 'Automatic: {0} for {1}, lowered to fit the free context. A manual maximum replaces it for that provider only.': - 'Automático: {0} para {1}, reducido para caber en el contexto libre. Un máximo manual lo sustituye solo para ese proveedor.', - 'Project: {0}': 'Proyecto: {0}', - '{0} = {1} — v{2}, approved {3}, proposed by {4}': '{0} = {1} — v{2}, aprobada el {3}, propuesta por {4}', - '{0} — stored history failed validation; not used and not changeable.': '{0} — el historial guardado no pasó la validación; no se usa ni se puede cambiar.', - 'v{0} = {1} — {2}, proposed by {3}': 'v{0} = {1} — {2}, propuesta por {3}', - '(removed)': '(eliminada)', - '{0} — {1}. {2} task(s) left; {3} of {4} runs used.': '{0} — {1}. Quedan {2} tarea(s); {3} de {4} ejecuciones usadas.', - 'in progress': 'en curso', - '({0} run(s))': '({0} ejecución(es))', - '(now v{0})': '(ahora v{0})', - 'Decisions used: {0}.': 'Decisiones usadas: {0}.', - 'Decisions used: none.': 'Decisiones usadas: ninguna.', - 'Resume task: {0}': 'Reanudar tarea: {0}', - 'Run task: {0}': 'Ejecutar tarea: {0}', + 'Updated {0}': 'Actualizado {0}', + 'Success criterion: {0}': 'Criterio de éxito: {0}', + 'Open thread {0}': 'Abrir hilo {0}', + 'Delete thread {0}': 'Borrar hilo {0}', + 'Confirm deletion of {0}': 'Confirmar el borrado de {0}', + 'Confirm delete {0}': 'Confirmar el borrado de {0}', + 'Cancel delete {0}': 'Cancelar el borrado de {0}', + '{0}: in {1} / out {2} / cached {3} / total {4}': '{0}: entrada {1} / salida {2} / caché {3} / total {4}', + 'Meta Model API — Cloud ({0})': 'Meta Model API — Nube ({0})', + 'Runtime: {0}': 'Motor: {0}', + 'Tool: {0}': 'Herramienta: {0}', + 'Targets: {0}': 'Objetivos: {0}', + 'Exact action: {0}': 'Acción exacta: {0}', + 'Provider: {0} | Workspace: {1} | Thread: {2} | Digest: {3} — approval is one-use and hash-bound; the host verifies before running.': + 'Proveedor: {0} | Workspace: {1} | Hilo: {2} | Digest: {3} — la aprobación es de un solo uso y va ligada al hash; el host la verifica antes de ejecutar.', + 'Pending approval: {0}': 'Aprobación pendiente: {0}', + 'Targets: {0} | Digest: {1}': 'Objetivos: {0} | Digest: {1}', + 'Live catalog: {0} compatible model(s).': 'Catálogo real: {0} modelo(s) compatible(s).', + 'Fallback catalog: {0} compatible model(s).': 'Catálogo de reserva: {0} modelo(s) compatible(s).', + 'Save a key or refresh to load live models.': 'Guarda una clave o actualiza para cargar los modelos reales.', + 'Local runtime: running — {0}': 'Motor local: en marcha — {0}', + 'Local runtime: starting — {0}': 'Motor local: arrancando — {0}', + 'Local runtime: external endpoint — {0}': 'Motor local: endpoint externo — {0}', + '{0} · {1} · context {2} per request.': '{0} · {1} · contexto por petición: {2}.', + 'Sent as {0}. Model default sends no level (default: {1}). Reasoning cannot be turned off for this model.': + 'Se envía como {0}. «Por defecto del modelo» no envía ningún nivel (por defecto: {1}). En este modelo el razonamiento no se puede desactivar.', + 'No verified reasoning control for this model ({0}), so only Model default is offered.': + 'No hay control de razonamiento verificado para este modelo ({0}), así que solo se ofrece «Por defecto del modelo».', + 'Your saved level {0} does not apply here; Model default is used.': 'Tu nivel guardado ({0}) no se aplica aquí; se usa «Por defecto del modelo».', + 'Automatic: {0} for {1}, lowered to fit the free context. A manual maximum replaces it for that provider only.': + 'Automático: {0} para {1}, reducido para caber en el contexto libre. Un máximo manual lo sustituye solo para ese proveedor.', + 'Project: {0}': 'Proyecto: {0}', + '{0} = {1} — v{2}, approved {3}, proposed by {4}': '{0} = {1} — v{2}, aprobada el {3}, propuesta por {4}', + '{0} — stored history failed validation; not used and not changeable.': '{0} — el historial guardado no pasó la validación; no se usa ni se puede cambiar.', + 'v{0} = {1} — {2}, proposed by {3}': 'v{0} = {1} — {2}, propuesta por {3}', + '(removed)': '(eliminada)', + '{0} — {1}. {2} task(s) left; {3} of {4} runs used.': '{0} — {1}. Quedan {2} tarea(s); {3} de {4} ejecuciones usadas.', + 'in progress': 'en curso', + '({0} run(s))': '({0} ejecución(es))', + '(now v{0})': '(ahora v{0})', + 'Decisions used: {0}.': 'Decisiones usadas: {0}.', + 'Decisions used: none.': 'Decisiones usadas: ninguna.', + 'Resume task: {0}': 'Reanudar tarea: {0}', + 'Run task: {0}': 'Ejecutar tarea: {0}', - // Conversation and modes - 'Chat only': 'Chat puro', - 'Recommended: talks naturally and uses tools when they are needed.': 'Recomendado: conversa con naturalidad y usa herramientas cuando hacen falta.', - 'Conversation only, with no tools and no file access.': 'Solo conversación, sin herramientas ni acceso a archivos.', - 'Multi-task work with a plan that can be resumed.': 'Trabajo en varias tareas con un plan que se puede reanudar.', - 'Mode changed to {0} for this message. The permission profile did not change.': 'Modo cambiado a {0} para este mensaje. El perfil de permisos no ha cambiado.', - 'Read-only activity verified by RAME Code ({0})': 'Lecturas verificadas por RAME Code ({0})', + 'Chat only': 'Chat puro', + 'Recommended: talks naturally and uses tools when they are needed.': 'Recomendado: conversa con naturalidad y usa herramientas cuando hacen falta.', + 'Conversation only, with no tools and no file access.': 'Solo conversación, sin herramientas ni acceso a archivos.', + 'Multi-task work with a plan that can be resumed.': 'Trabajo en varias tareas con un plan que se puede reanudar.', + 'Mode changed to {0} for this message. The permission profile did not change.': 'Modo cambiado a {0} para este mensaje. El perfil de permisos no ha cambiado.', + 'Read-only activity verified by RAME Code ({0})': 'Lecturas verificadas por RAME Code ({0})', - // Autonomous Mission (R9) - 'paused': 'pausada', - 'cancelled': 'cancelada', - 'blocked': 'bloqueada', - 'Permission for this Mission': 'Permiso para esta Misión', - 'Supervised: every write and test asks': 'Supervisada: cada escritura y prueba pregunta', - 'Autonomous workspace: edits and allowlisted tests run without asking': 'Workspace autónomo: ediciones y pruebas permitidas sin preguntar', - 'Authorize and start': 'Autorizar e iniciar', - 'Acceptance check': 'Comprobación de aceptación', - 'None': 'Ninguna', - 'With an acceptance check, RAME Code blocks its own tools from changing node_modules. node_modules is not hashed: test scripts, other processes, or you can still change the dependencies.': - 'Con una comprobación de aceptación, RAME bloquea a sus propias herramientas para que no modifiquen node_modules. node_modules no se verifica por hash: los scripts de prueba, otros procesos o tú mismo podéis seguir cambiando las dependencias.', - 'Acceptance check: {0} (frozen: {1})': 'Comprobación de aceptación: {0} (congelada: {1})', - 'No acceptance check: completion means the project and agent tests passed': 'Sin comprobación de aceptación: completada significa que pasaron las pruebas del proyecto y del agente', - 'I trust this project (required for autonomous workspace)': 'Confío en este proyecto (necesario para el workspace autónomo)', - 'Not a sandbox: test scripts run with your user permissions and can reach other files, the network, or other processes. RAME Code only checks the paths its own tools use. Use autonomous workspace only for a project you trust.': - 'No es un sandbox: los scripts de prueba se ejecutan con tus permisos de usuario y pueden llegar a otros archivos, a la red o a otros procesos. RAME Code solo comprueba las rutas que usan sus propias herramientas. Usa el workspace autónomo solo en proyectos de confianza.', - 'The permission covers only this Mission, this project, and the local model. Autonomous workspace never leaves the project through RAME\'s tools, never touches .git, .env, keys, or credentials, runs no shell, runs only the allowlisted tests of the current package.json (a changed package.json asks again), and still asks before deleting, moving, or changing project memory.': - 'El permiso cubre solo esta Misión, este proyecto y el modelo local. El workspace autónomo nunca sale del proyecto mediante las herramientas de RAME, nunca toca .git, .env, claves ni credenciales, no ejecuta shell, solo ejecuta las pruebas permitidas del package.json actual (si cambia, vuelve a preguntar) y sigue preguntando antes de borrar, mover o cambiar la memoria del proyecto.', - 'Autonomous workspace is only for a project you mark as trusted: its test scripts run with your permissions and RAME Code is not an operating-system sandbox.': - 'El workspace autónomo es solo para proyectos que marques como de confianza: sus scripts de prueba se ejecutan con tus permisos y RAME Code no es un sandbox del sistema operativo.', - 'Pause': 'Pausar', - 'Resume': 'Reanudar', - 'Cancel Mission': 'Cancelar Misión', - 'RAME Code chains bounded internal runs (12 model steps each) by itself and saves a checkpoint after each. A task is done only when an allowlisted test passes after its last change; several runs in a row without verifiable progress stop the Mission as blocked. A reload never repeats an action whose result is unknown.': - 'RAME Code encadena por sí solo ejecuciones internas acotadas (12 pasos del modelo cada una) y guarda un punto de control tras cada una. Una tarea solo termina cuando una prueba permitida pasa después de su último cambio; varias ejecuciones seguidas sin progreso verificable detienen la Misión como bloqueada. Una recarga nunca repite una acción de resultado desconocido.', - 'Permission: {0}': 'Permiso: {0}', - 'autonomous workspace': 'workspace autónomo', - 'supervised': 'supervisada', - 'Model: {0}': 'Modelo: {0}', - 'Internal run {0}': 'Ejecución interna {0}', - 'Step {0}/{1}': 'Paso {0}/{1}', - 'Elapsed {0}': 'Tiempo {0}', - 'Last progress: {0}': 'Último progreso: {0}', - 'Last test: {0}': 'Última prueba: {0}', - 'Meta consultations: {0}/{1} ({2})': 'Consultas a Meta: {0}/{1} ({2})', - 'Meta consultations: none': 'Consultas a Meta: ninguna', - 'Internal Mission run {0}': 'Ejecución interna de la Misión {0}', - 'This Mission is running in another VS Code window.': 'Esta Misión se está ejecutando en otra ventana de VS Code.', - 'Authorize the Mission before running it.': 'Autoriza la Misión antes de ejecutarla.', - 'Paused by you. Nothing runs until you resume it.': 'Pausada por ti. No se ejecuta nada hasta que la reanudes.', - 'Cancelled by you. It will not run again.': 'Cancelada por ti. No volverá a ejecutarse.', - 'Pausing the Mission…': 'Pausando la Misión…', - 'Cancelling the Mission…': 'Cancelando la Misión…', + 'paused': 'pausada', + 'cancelled': 'cancelada', + 'blocked': 'bloqueada', + 'Permission for this Mission': 'Permiso para esta Misión', + 'Supervised: every write and test asks': 'Supervisada: cada escritura y prueba pregunta', + 'Autonomous workspace: edits and allowlisted tests run without asking': 'Workspace autónomo: ediciones y pruebas permitidas sin preguntar', + 'Authorize and start': 'Autorizar e iniciar', + 'Acceptance check': 'Comprobación de aceptación', + 'None': 'Ninguna', + 'With an acceptance check, RAME Code blocks its own tools from changing node_modules. node_modules is not hashed: test scripts, other processes, or you can still change the dependencies.': + 'Con una comprobación de aceptación, RAME bloquea a sus propias herramientas para que no modifiquen node_modules. node_modules no se verifica por hash: los scripts de prueba, otros procesos o tú mismo podéis seguir cambiando las dependencias.', + 'Acceptance check: {0} (frozen: {1})': 'Comprobación de aceptación: {0} (congelada: {1})', + 'No acceptance check: completion means the project and agent tests passed': 'Sin comprobación de aceptación: completada significa que pasaron las pruebas del proyecto y del agente', + 'I trust this project (required for autonomous workspace)': 'Confío en este proyecto (necesario para el workspace autónomo)', + 'Test scripts run with the isolation level of Security → Terminal commands: Project or Closed (Linux) let them write only in the project, temporary and package-cache folders (Closed: no network); Total gives them your user permissions. They can still read your files. Use autonomous workspace only for a project you trust.': + 'Los scripts de prueba se ejecutan con el nivel de aislamiento de Seguridad → Comandos de terminal: Proyecto o Cerrado (Linux) solo les dejan escribir en las carpetas del proyecto, temporal y de cachés de paquetes (Cerrado: sin red); Total les da tus permisos de usuario. Siguen pudiendo leer tus ficheros. Usa el workspace autónomo solo en proyectos de confianza.', + 'The permission covers only this Mission, this project, and its main model. Autonomous workspace never leaves the project through RAME\'s tools, never touches .git, .env, keys, or credentials, runs no shell, runs only the allowlisted tests of the current package.json (a changed package.json asks again), and still asks before deleting, moving, or changing project memory.': + 'El permiso cubre solo esta Misión, este proyecto y su modelo principal. El workspace autónomo nunca sale del proyecto mediante las herramientas de RAME, nunca toca .git, .env, claves ni credenciales, no ejecuta shell, solo ejecuta las pruebas permitidas del package.json actual (si cambia, vuelve a preguntar) y sigue preguntando antes de borrar, mover o cambiar la memoria del proyecto.', + 'Autonomous workspace is only for a project you mark as trusted: its test scripts run with your permissions and RAME Code is not an operating-system sandbox.': + 'El workspace autónomo es solo para proyectos que marques como de confianza: sus scripts de prueba se ejecutan con tus permisos y RAME Code no es un sandbox del sistema operativo.', + 'Pause': 'Pausar', + 'Resume': 'Reanudar', + 'Cancel Mission': 'Cancelar Misión', + 'RAME Code chains bounded internal runs (12 model steps each) by itself and saves a checkpoint after each. A task is done only when an allowlisted test passes after its last change; several runs in a row without verifiable progress stop the Mission as blocked. A reload never repeats an action whose result is unknown.': + 'RAME Code encadena por sí solo ejecuciones internas acotadas (12 pasos del modelo cada una) y guarda un punto de control tras cada una. Una tarea solo termina cuando una prueba permitida pasa después de su último cambio; varias ejecuciones seguidas sin progreso verificable detienen la Misión como bloqueada. Una recarga nunca repite una acción de resultado desconocido.', + 'Permission: {0}': 'Permiso: {0}', + 'autonomous workspace': 'workspace autónomo', + 'supervised': 'supervisada', + 'Model: {0}': 'Modelo: {0}', + 'Internal run {0}': 'Ejecución interna {0}', + 'Step {0}/{1}': 'Paso {0}/{1}', + 'Elapsed {0}': 'Tiempo {0}', + 'Last progress: {0}': 'Último progreso: {0}', + 'Last test: {0}': 'Última prueba: {0}', + 'Meta consultations: {0}/{1} ({2})': 'Consultas a Meta: {0}/{1} ({2})', + 'Meta consultations: none': 'Consultas a Meta: ninguna', + 'Internal Mission run {0}': 'Ejecución interna de la Misión {0}', + 'This Mission is running in another VS Code window.': 'Esta Misión se está ejecutando en otra ventana de VS Code.', + 'Authorize the Mission before running it.': 'Autoriza la Misión antes de ejecutarla.', + 'Paused by you. Nothing runs until you resume it.': 'Pausada por ti. No se ejecuta nada hasta que la reanudes.', + 'Cancelled by you. It will not run again.': 'Cancelada por ti. No volverá a ejecutarse.', + 'Pausing the Mission…': 'Pausando la Misión…', + 'Cancelling the Mission…': 'Cancelando la Misión…', - // Mission card, Settings cards, About - 'The Agent changed files without running the test; asking it to run the test now…': 'El Agente cambió ficheros sin ejecutar la prueba; se le pide que la ejecute ahora…', - 'Tests: RAME Code currently runs only the package.json scripts of a Node/npm project (test, check, compile, lint, typecheck). In a project without them, the Agent can still edit, but a Mission cannot verify its tasks and will not complete.': 'Pruebas: por ahora RAME Code solo ejecuta los scripts de package.json de un proyecto Node/npm (test, check, compile, lint, typecheck). En un proyecto sin ellos, el Agente puede editar, pero una Misión no puede verificar sus tareas y no se completará.', - 'Working with the local runtime…': 'Trabajando con el motor local…', - 'Mission blocked': 'Misión bloqueada', - 'Review': 'Revisar', - 'Retry with guidance': 'Reintentar con indicaciones', - 'Mission record': 'Registro de la Misión', - 'Guidance for the next runs': 'Indicaciones para las próximas ejecuciones', - 'For example: change only the return line of checkUserStatement, then run npm:test.': 'Por ejemplo: cambia solo la línea de retorno de checkUserStatement y después ejecuta npm:test.', - 'Retry': 'Reintentar', - 'Internal runs': 'Ejecuciones internas', - 'Tests not run': 'Pruebas no ejecutadas', - 'Write what the Agent should do differently.': 'Escribe qué debe hacer el Agente de otra forma.', - 'Retrying…': 'Reintentando…', - 'Only a blocked Mission can be retried with guidance.': 'Solo se puede reintentar con indicaciones una Misión bloqueada.', - 'The Agent stopped without a change or a test; asking it to act with the steps left…': 'El Agente se detuvo sin cambios ni pruebas; se le pide que actúe con los pasos que quedan…', - 'Public version': 'Versión pública', - 'Author': 'Autor', - 'Repository': 'Repositorio', - 'Verification level': 'Nivel de verificación', - 'Standard Mission': 'Misión estándar', - 'The project\'s normal tests decide when each task is done.': 'Las pruebas normales del proyecto deciden cuándo está terminada cada tarea.', - 'Verified Mission': 'Misión verificada', - 'Files that define it': 'Ficheros que la definen', - 'Filter files': 'Filtrar ficheros', - 'acceptance/': 'acceptance/', - 'Acceptance files': 'Ficheros de aceptación', - 'Advanced verification': 'Verificación avanzada', - 'Final report': 'Informe final', - 'No sandbox: test scripts ran with your user permissions and could reach other files, the network, or other processes.': 'Sin sandbox: los scripts de prueba se ejecutaron con tus permisos de usuario y podían llegar a otros archivos, a la red o a otros procesos.', - 'Settings sections': 'Secciones de Ajustes', - 'General': 'General', - 'Security': 'Seguridad', - 'Advanced': 'Avanzado', - 'About': 'Acerca de', - 'Maximum output tokens': 'Máximo de tokens de salida', - 'Version': 'Versión', - 'A candidate is a build for testing. A release is a clean build of the tagged public version.': 'Una candidata es una compilación para pruebas. Una versión publicada es una compilación limpia de la versión pública etiquetada.', - 'Open the Mission thread': 'Abrir el hilo de la Misión', - 'Authorize, follow, pause, or cancel a Mission from its card in the Mission thread.': 'Autoriza, sigue, pausa o cancela una Misión desde su tarjeta en el hilo de la Misión.', - 'Meta consultations allowed for the next Mission you authorize (0 = none)': 'Consultas a Meta permitidas para la próxima Misión que autorices (0 = ninguna)', - 'Planning': 'Planificando', - 'Plan ready — awaiting authorization': 'Plan listo — esperando autorización', - 'Authorizing': 'Autorizando', - 'Paused': 'Pausada', - 'Blocked': 'Bloqueada', - 'Completed — acceptance passed': 'Completada — aceptación superada', - 'Completed — project tests passed': 'Completada — pruebas del proyecto superadas', - 'Interrupted': 'Interrumpida', - 'Failed': 'Fallida', - 'No Mission plan was created; see the reply': 'No se creó ningún plan de Misión; mira la respuesta', - 'Authorizing…': 'Autorizando…', - 'Mission started': 'Misión iniciada', - 'RAME Code did not confirm the start. Check the Mission state before trying again.': 'RAME Code no confirmó el inicio. Comprueba el estado de la Misión antes de volver a intentarlo.', - 'Pausing…': 'Pausando…', - 'Resuming…': 'Reanudando…', - 'Choose the acceptance check and at least one of its files, or choose Standard Mission.': 'Elige la comprobación de aceptación y al menos uno de sus ficheros, o elige Misión estándar.', - 'Goal': 'Objetivo', - 'Current task': 'Tarea actual', - '{0} ({1} of {2} done)': '{0} ({1} de {2} hechas)', - '{0} of {1} done': '{0} de {1} hechas', - 'Internal run': 'Ejecución interna', - 'none yet': 'ninguna todavía', - 'Step': 'Paso', - 'Time': 'Tiempo', - 'Last progress': 'Último progreso', - 'Last test': 'Última prueba', - 'Acceptance': 'Aceptación', - 'Why': 'Motivo', - 'Standard Mission: no acceptance check': 'Misión estándar: sin comprobación de aceptación', - '{0}: passed — {1}': '{0}: superada — {1}', - '{0}: failed — {1}': '{0}: fallida — {1}', - '{0}: its files changed — the Mission is blocked': '{0}: sus ficheros cambiaron — la Misión está bloqueada', - '{0}: not run yet (runs before the Mission can end)': '{0}: aún no ejecutada (se ejecuta antes de que la Misión pueda terminar)', - 'Choose a test script': 'Elige un script de prueba', - 'No test files were found in this project.': 'No se encontraron ficheros de prueba en este proyecto.', - 'No file matches the filter.': 'Ningún fichero coincide con el filtro.', - 'Choose at least one file of the acceptance check.': 'Elige al menos un fichero de la comprobación de aceptación.', - 'Checked by hash when you authorize: {0}. RAME Code\'s tools will not modify node_modules (not hashed).': 'Se verificarán por hash al autorizar: {0}. Las herramientas de RAME Code no modificarán node_modules (no se verifica por hash).', - 'Allowlisted test scripts: {0}': 'Scripts de prueba permitidos: {0}', - 'none': 'ninguno', - 'package.json sha256: {0}': 'sha256 de package.json: {0}', - 'Environment files hashed in a Verified Mission: {0}': 'Ficheros de entorno verificados por hash en una Misión verificada: {0}', - 'Acceptance command: {0}': 'Comando de aceptación: {0}', - 'Frozen {0} sha256: {1}': 'sha256 congelado de {0}: {1}', - 'Tasks done': 'Tareas terminadas', - 'Files changed': 'Archivos modificados', - 'Tests and exit codes': 'Pruebas y códigos de salida', - 'Frozen acceptance': 'Aceptación congelada', - 'none (Standard Mission)': 'ninguna (Misión estándar)', - 'Internal runs and time': 'Ejecuciones internas y tiempo', - '{0} run(s), {1} min': '{0} ejecución(es), {1} min', - 'Models and providers': 'Modelos y proveedores', - 'Meta consultations': 'Consultas a Meta', - 'Build revision': 'Revisión de compilación', - 'Channel': 'Canal', - 'release': 'versión publicada', - 'candidate': 'candidata', - 'Language: {0}': 'Idioma: {0}', - 'running': 'en marcha', - 'external endpoint': 'endpoint externo', - 'stopped': 'parado', - 'key saved': 'clave guardada', - 'no key': 'sin clave', - 'No Mission': 'Sin Misión', - '{0} · {1} decision(s) in force': '{0} · {1} decisión(es) en vigor', - 'Permission: {0} · Cloud escalation {1}': 'Permiso: {0} · Escalada a la nube {1}', - 'on': 'activada', - 'off': 'desactivada', - 'Output limits and diagnostics': 'Límites de salida y diagnóstico', - 'v{0} · {1}': 'v{0} · {1}', - 'Mission started.': 'Misión iniciada.', - 'Authorizing the Mission…': 'Autorizando la Misión…', + 'The Agent changed files without running the test; asking it to run the test now…': 'El Agente cambió ficheros sin ejecutar la prueba; se le pide que la ejecute ahora…', + 'Tests: RAME Code currently runs only the package.json scripts of a Node/npm project (test, check, compile, lint, typecheck). In a project without them, the Agent can still edit, but a Mission cannot verify its tasks and will not complete.': 'Pruebas: por ahora RAME Code solo ejecuta los scripts de package.json de un proyecto Node/npm (test, check, compile, lint, typecheck). En un proyecto sin ellos, el Agente puede editar, pero una Misión no puede verificar sus tareas y no se completará.', + 'Working with the local runtime…': 'Trabajando con el motor local…', + 'Mission blocked': 'Misión bloqueada', + 'Review': 'Revisar', + 'Retry with guidance': 'Reintentar con indicaciones', + 'Mission record': 'Registro de la Misión', + 'Guidance for the next runs': 'Indicaciones para las próximas ejecuciones', + 'For example: change only the return line of checkUserStatement, then run npm:test.': 'Por ejemplo: cambia solo la línea de retorno de checkUserStatement y después ejecuta npm:test.', + 'Retry': 'Reintentar', + 'Internal runs': 'Ejecuciones internas', + 'Tests not run': 'Pruebas no ejecutadas', + 'Write what the Agent should do differently.': 'Escribe qué debe hacer el Agente de otra forma.', + 'Retrying…': 'Reintentando…', + 'Only a blocked Mission can be retried with guidance.': 'Solo se puede reintentar con indicaciones una Misión bloqueada.', + 'The Agent stopped without a change or a test; asking it to act with the steps left…': 'El Agente se detuvo sin cambios ni pruebas; se le pide que actúe con los pasos que quedan…', + 'Public version': 'Versión pública', + 'Author': 'Autor', + 'Repository': 'Repositorio', + 'Verification level': 'Nivel de verificación', + 'Standard Mission': 'Misión estándar', + 'The project\'s normal tests decide when each task is done.': 'Las pruebas normales del proyecto deciden cuándo está terminada cada tarea.', + 'Verified Mission': 'Misión verificada', + 'Files that define it': 'Ficheros que la definen', + 'Filter files': 'Filtrar ficheros', + 'acceptance/': 'acceptance/', + 'Acceptance files': 'Ficheros de aceptación', + 'Advanced verification': 'Verificación avanzada', + 'Final report': 'Informe final', + 'Settings sections': 'Secciones de Ajustes', + 'General': 'General', + 'Security': 'Seguridad', + 'Advanced': 'Avanzado', + 'About': 'Acerca de', + 'Maximum output tokens': 'Máximo de tokens de salida', + 'Version': 'Versión', + 'A candidate is a build for testing. A release is a clean build of the tagged public version.': 'Una candidata es una compilación para pruebas. Una versión publicada es una compilación limpia de la versión pública etiquetada.', + 'Open the Mission thread': 'Abrir el hilo de la Misión', + 'Authorize, follow, pause, or cancel a Mission from its card in the Mission thread.': 'Autoriza, sigue, pausa o cancela una Misión desde su tarjeta en el hilo de la Misión.', + 'Meta consultations allowed for the next Mission you authorize (0 = none)': 'Consultas a Meta permitidas para la próxima Misión que autorices (0 = ninguna)', + 'Planning': 'Planificando', + 'Plan ready — awaiting authorization': 'Plan listo — esperando autorización', + 'Authorizing': 'Autorizando', + 'Paused': 'Pausada', + 'Blocked': 'Bloqueada', + 'Completed — acceptance passed': 'Completada — aceptación superada', + 'Completed — project tests passed': 'Completada — pruebas del proyecto superadas', + 'Interrupted': 'Interrumpida', + 'Failed': 'Fallida', + 'No Mission plan was created; see the reply': 'No se creó ningún plan de Misión; mira la respuesta', + 'Authorizing…': 'Autorizando…', + 'Mission started': 'Misión iniciada', + 'RAME Code did not confirm the start. Check the Mission state before trying again.': 'RAME Code no confirmó el inicio. Comprueba el estado de la Misión antes de volver a intentarlo.', + 'Pausing…': 'Pausando…', + 'Resuming…': 'Reanudando…', + 'Choose the acceptance check and at least one of its files, or choose Standard Mission.': 'Elige la comprobación de aceptación y al menos uno de sus ficheros, o elige Misión estándar.', + 'Goal': 'Objetivo', + 'Current task': 'Tarea actual', + '{0} ({1} of {2} done)': '{0} ({1} de {2} hechas)', + '{0} of {1} done': '{0} de {1} hechas', + 'Internal run': 'Ejecución interna', + 'none yet': 'ninguna todavía', + 'Step': 'Paso', + 'Time': 'Tiempo', + 'Last progress': 'Último progreso', + 'Last test': 'Última prueba', + 'Acceptance': 'Aceptación', + 'Why': 'Motivo', + 'Standard Mission: no acceptance check': 'Misión estándar: sin comprobación de aceptación', + '{0}: passed — {1}': '{0}: superada — {1}', + '{0}: failed — {1}': '{0}: fallida — {1}', + '{0}: inconclusive, not a failure — {1}': '{0}: no concluyente, no es un fallo — {1}', + 'Blocked: NOT COUNTED: the host measured the acceptance check twice and both measurements were inconclusive (the runs disagree more than allowed). The Mission is not passed; retry it when the machine is less busy, or review the criteria.': 'Bloqueada: NO CONTABILIZADA: el host midió la comprobación de aceptación dos veces y las dos mediciones fueron no concluyentes (las ejecuciones discrepan más de lo permitido). La Misión no está superada; reinténtala cuando el ordenador esté menos ocupado o revisa los criterios.', + '{0}: its files changed — the Mission is blocked': '{0}: sus ficheros cambiaron — la Misión está bloqueada', + '{0}: not run yet (runs before the Mission can end)': '{0}: aún no ejecutada (se ejecuta antes de que la Misión pueda terminar)', + 'Choose a test script': 'Elige un script de prueba', + 'No test files were found in this project.': 'No se encontraron ficheros de prueba en este proyecto.', + 'No file matches the filter.': 'Ningún fichero coincide con el filtro.', + 'Choose at least one file of the acceptance check.': 'Elige al menos un fichero de la comprobación de aceptación.', + 'Checked by hash when you authorize: {0}. RAME Code\'s tools will not modify node_modules (not hashed).': 'Se verificarán por hash al autorizar: {0}. Las herramientas de RAME Code no modificarán node_modules (no se verifica por hash).', + 'Allowlisted test scripts: {0}': 'Scripts de prueba permitidos: {0}', + 'none': 'ninguno', + 'package.json sha256: {0}': 'sha256 de package.json: {0}', + 'Environment files hashed in a Verified Mission: {0}': 'Ficheros de entorno verificados por hash en una Misión verificada: {0}', + 'Acceptance command: {0}': 'Comando de aceptación: {0}', + 'Frozen {0} sha256: {1}': 'sha256 congelado de {0}: {1}', + 'Tasks done': 'Tareas terminadas', + 'Files changed': 'Archivos modificados', + 'Tests and exit codes': 'Pruebas y códigos de salida', + 'Frozen acceptance': 'Aceptación congelada', + 'none (Standard Mission)': 'ninguna (Misión estándar)', + 'Internal runs and time': 'Ejecuciones internas y tiempo', + '{0} run(s), {1} min': '{0} ejecución(es), {1} min', + 'Models and providers': 'Modelos y proveedores', + 'Meta consultations': 'Consultas a Meta', + 'Build revision': 'Revisión de compilación', + 'Channel': 'Canal', + 'release': 'versión publicada', + 'candidate': 'candidata', + 'Language: {0}': 'Idioma: {0}', + 'running': 'en marcha', + 'external endpoint': 'endpoint externo', + 'stopped': 'parado', + 'key saved': 'clave guardada', + 'no key': 'sin clave', + 'No Mission': 'Sin Misión', + '{0} · {1} decision(s) in force': '{0} · {1} decisión(es) en vigor', + 'Permission: {0} · Cloud escalation {1}': 'Permiso: {0} · Escalada a la nube {1}', + 'on': 'activada', + 'off': 'desactivada', + 'Output limits and diagnostics': 'Límites de salida y diagnóstico', + 'v{0} · {1}': 'v{0} · {1}', + 'Mission started.': 'Misión iniciada.', + 'Authorizing the Mission…': 'Autorizando la Misión…', - // Cascade - 'Send this text to Meta': 'Enviar este texto a Meta', - 'Sending…': 'Enviando…', - 'Dismissing…': 'Descartando…', - 'Why: the local run stopped because {0}.': 'Motivo: la ejecución local se detuvo porque {0}.', - 'Model: Meta {0} | {1} characters | Digest: {2} — one use.': 'Modelo: Meta {0} | {1} caracteres | Digest: {2} — un solo uso.', - 'Consultation dismissed. Nothing was sent to Meta.': 'Consulta descartada. No se envió nada a Meta.', - 'That consultation approval is no longer valid. Nothing was sent.': 'Esa aprobación de consulta ya no es válida. No se envió nada.', - 'The project folder of this run is no longer open.': 'La carpeta del proyecto de esta ejecución ya no está abierta.', - 'Cascade finished: the local verification run passed its test.': 'Cascada terminada: la ejecución local de verificación pasó su prueba.', - 'Cascade finished without a passing local test.': 'Cascada terminada sin una prueba local superada.', - 'Cascade stopped before the local verification run.': 'Cascada detenida antes de la ejecución local de verificación.', - 'Cascade not used.': 'Cascada no usada.', - 'Asking Meta for advice needs cloud escalation ON in Settings; nothing was sent.': 'Pedir consejo a Meta requiere activar el escalado a la nube en Ajustes; no se envió nada.', + 'Send this text to Meta': 'Enviar este texto a Meta', + 'Sending…': 'Enviando…', + 'Dismissing…': 'Descartando…', + 'Why: the local run stopped because {0}.': 'Motivo: la ejecución local se detuvo porque {0}.', + 'Model: Meta {0} | {1} characters | Digest: {2} — one use.': 'Modelo: Meta {0} | {1} caracteres | Digest: {2} — un solo uso.', + 'Consultation dismissed. Nothing was sent to Meta.': 'Consulta descartada. No se envió nada a Meta.', + 'That consultation approval is no longer valid. Nothing was sent.': 'Esa aprobación de consulta ya no es válida. No se envió nada.', + 'The project folder of this run is no longer open.': 'La carpeta del proyecto de esta ejecución ya no está abierta.', + 'Cascade finished: the local verification run passed its test.': 'Cascada terminada: la ejecución local de verificación pasó su prueba.', + 'Cascade finished without a passing local test.': 'Cascada terminada sin una prueba local superada.', + 'Cascade stopped before the local verification run.': 'Cascada detenida antes de la ejecución local de verificación.', + 'Cascade not used.': 'Cascada no usada.', + 'Asking Meta for advice needs cloud escalation ON in Settings; nothing was sent.': 'Pedir consejo a Meta requiere activar el escalado a la nube en Ajustes; no se envió nada.', - // Host messages (exact) - 'Ready.': 'Listo.', - 'New Agent thread started. Type a message below.': 'Nuevo hilo del Agente. Escribe un mensaje abajo.', - 'That thread is no longer stored.': 'Ese hilo ya no está guardado.', - 'Stop the current run before deleting its thread.': 'Detén la ejecución actual antes de borrar su hilo.', - 'Thread deleted.': 'Hilo borrado.', - 'Showing thread list.': 'Mostrando la lista de hilos.', - 'Unknown mode. Choose Chat, Mission, or Agent.': 'Modo desconocido. Elige Chat, Misión o Agente.', - 'Unknown runtime. Choose a runtime in Settings.': 'Motor desconocido. Elige un motor en Ajustes.', - 'Type a message first.': 'Escribe primero un mensaje.', - 'The message is invalid.': 'El mensaje no es válido.', - 'No Meta Model API key is stored. Save one in Settings or choose a local runtime.': - 'No hay clave de Meta Model API guardada. Guarda una en Ajustes o elige un motor local.', - 'Choose the workspace folder for this Agent run…': 'Elige la carpeta del workspace para esta ejecución del Agente…', - 'No API key stored. Save a Meta Model API key to begin.': 'No hay clave de API guardada. Guarda una clave de Meta Model API para empezar.', - 'Plan ready.': 'Plan listo.', - 'Reply ready.': 'Respuesta lista.', - 'Agent reply ready.': 'Respuesta del Agente lista.', - 'The local model did not submit a valid Mission plan.': 'El modelo local no entregó un plan de Misión válido.', - 'The local model did not return a strict plan; its reply is shown as prose.': - 'El modelo local no devolvió un plan estricto; su respuesta se muestra como texto.', - 'Reasoning was not changed: the selection no longer matches the current runtime.': - 'No se cambió el razonamiento: la selección ya no coincide con el motor actual.', - 'Reasoning was not changed.': 'No se cambió el razonamiento.', - 'The current run keeps its settings.': 'La ejecución en curso conserva sus ajustes.', - 'Unknown permission profile. Kept Ask (safest).': 'Perfil de permisos desconocido. Se mantiene Preguntar (lo más seguro).', - 'No Agent action is waiting for approval.': 'Ninguna acción del Agente espera aprobación.', - 'That approval is no longer valid.': 'Esa aprobación ya no es válida.', - 'Agent action rejected.': 'Acción del Agente rechazada.', - 'Agent action rejected. Nothing ran.': 'Acción del Agente rechazada. No se ejecutó nada.', - 'A precondition changed; asking the Agent to re-read and re-propose…': 'Cambió una precondición; se pide al Agente que vuelva a leer y proponer…', - 'Action rejected by user. No changes were made.': 'Acción rechazada por ti. No se hizo ningún cambio.', - 'The workspace changed; the Agent run stopped.': 'El workspace cambió; la ejecución del Agente se detuvo.', - 'A request is already running. Cancel it before starting a Mission task.': 'Ya hay una petición en marcha. Cancélala antes de iniciar una tarea de la Misión.', - 'That Mission does not belong to the open project.': 'Esa Misión no pertenece al proyecto abierto.', - 'The Mission changed; review it again before continuing.': 'La Misión cambió; revísala de nuevo antes de seguir.', - 'The Mission thread no longer exists.': 'El hilo de la Misión ya no existe.', - 'The Mission’s project folder is no longer open.': 'La carpeta del proyecto de la Misión ya no está abierta.', - 'That folder is not open in this window.': 'Esa carpeta no está abierta en esta ventana.', - 'Approve or reject the pending memory change first.': 'Primero aprueba o rechaza el cambio de memoria pendiente.', - 'A Mission task is running. Wait for it to finish before changing the project.': 'Hay una tarea de la Misión en marcha. Espera a que termine antes de cambiar de proyecto.', - 'Several folders are open. Choose the project above to manage its memory.': 'Hay varias carpetas abiertas. Elige arriba el proyecto para gestionar su memoria.', - 'Open a folder to manage its project memory.': 'Abre una carpeta para gestionar la memoria de su proyecto.', - 'The project folder could not be resolved.': 'No se pudo resolver la carpeta del proyecto.', - 'Review the exact memory change below, then approve or reject it.': 'Revisa abajo el cambio exacto de memoria y apruébalo o recházalo.', - 'That memory approval is no longer valid.': 'Esa aprobación de memoria ya no es válida.', - 'Memory change rejected. Nothing was written.': 'Cambio de memoria rechazado. No se guardó nada.', - 'Artifact recorded.': 'Archivo registrado.', - 'Project memory could not be read; RAME Code will not overwrite it.': 'No se pudo leer la memoria del proyecto; RAME Code no la sobrescribirá.', - 'Request cancelled.': 'Petición cancelada.', - 'Local work failed. Escalation is waiting for approval.': 'Falló el trabajo local. El escalado espera tu aprobación.', - 'Escalation is waiting for approval.': 'El escalado espera tu aprobación.', - 'Cloud escalation is OFF, so nothing was sent.': 'El escalado a la nube está desactivado, así que no se envió nada.', - 'No escalation is waiting for approval.': 'Ningún escalado espera aprobación.', - 'A request is already running. Cancel it or wait before sending to Meta. Nothing was sent.': - 'Ya hay una petición en marcha. Cancélala o espera antes de enviar a Meta. No se envió nada.', - 'Cloud escalation is OFF. Nothing was sent.': 'El escalado a la nube está desactivado. No se envió nada.', - 'That thread is no longer stored. Nothing was sent.': 'Ese hilo ya no está guardado. No se envió nada.', - 'Escalation dismissed. Nothing was sent to Meta.': 'Escalado descartado. No se envió nada a Meta.', - 'No request is running.': 'No hay ninguna petición en marcha.', - 'The API key is invalid.': 'La clave de API no es válida.', - 'API key stored. Refreshing available models…': 'Clave de API guardada. Actualizando los modelos disponibles…', - 'Meta connection not tested: save an API key first.': 'Conexión con Meta no comprobada: guarda primero una clave de API.', - 'Meta connection verified. Live model catalog loaded.': 'Conexión con Meta verificada. Catálogo real de modelos cargado.', - 'Meta connection test failed. Check the key, balance, network, and Meta service status.': 'Falló la prueba de conexión con Meta. Revisa la clave, el saldo, la red y el estado del servicio de Meta.', - 'API key cleared. Save a key to run cloud requests.': 'Clave de API borrada. Guarda una clave para usar la nube.', - 'Unknown mode.': 'Modo desconocido.', - 'Settings saved.': 'Ajustes guardados.', - 'Refreshing available models…': 'Actualizando los modelos disponibles…', - 'Local settings are invalid.': 'Los ajustes locales no son válidos.', - 'Local settings saved.': 'Ajustes locales guardados.', - 'The external endpoint must stay on loopback.': 'El endpoint externo debe quedarse en loopback.', - 'External endpoint cleared.': 'Endpoint externo quitado.', - 'The local runtime is already starting or running.': 'El motor local ya está arrancando o en marcha.', - 'The local runtime is stopping. Try again in a moment.': 'El motor local se está deteniendo. Inténtalo de nuevo en un momento.', - 'Could not start the local runtime executable.': 'No se pudo iniciar el ejecutable del motor local.', - 'The local runtime stopped.': 'El motor local se detuvo.', - 'Stopping the local runtime…': 'Deteniendo el motor local…', - 'Local runtime stopped.': 'Motor local detenido.', - 'Ignoring an invalid view request.': 'Se ignora una petición no válida de la vista.', - 'Mission created. Start the first task when you are ready.': 'Misión creada. Inicia la primera tarea cuando quieras.', - 'RAME Code was reloaded while this task was running; nothing is repeated automatically.': - 'RAME Code se recargó mientras esta tarea se ejecutaba; no se repite nada automáticamente.', - 'Review what ran, then resume the task.': 'Revisa lo que se ejecutó y después reanuda la tarea.', - 'Check the workspace before resuming.': 'Revisa el workspace antes de reanudar.', - 'The file was not checked.': 'El archivo no se comprobó.', - 'The file no longer exists.': 'El archivo ya no existe.', - 'The file changed after it was verified.': 'El archivo cambió después de verificarlo.', - 'Project memory is being changed in another VS Code window; try again in a moment.': - 'La memoria del proyecto se está cambiando en otra ventana de VS Code; inténtalo de nuevo en un momento.', - 'Project memory could not be read, so RAME Code will not write to it.': 'No se pudo leer la memoria del proyecto, así que RAME Code no escribirá en ella.', - 'Stored Missions could not be read, so RAME Code will not write to them.': 'No se pudieron leer las Misiones guardadas, así que RAME Code no escribirá en ellas.', - 'That Mission no longer exists or could not be read.': 'Esa Misión ya no existe o no se pudo leer.', - 'The Mission changed in another window; review it again before continuing.': 'La Misión cambió en otra ventana; revísala de nuevo antes de seguir.', - 'This Mission has no current task.': 'Esta Misión no tiene tarea actual.', - 'This Mission is completed.': 'Esta Misión está completada.', - 'This Mission failed; create a new Mission to continue.': 'Esta Misión falló; crea una nueva para continuar.', - 'This Mission is waiting for an approval in its current run.': 'Esta Misión espera una aprobación en su ejecución actual.', - 'The Mission is not running.': 'La Misión no está en marcha.', - 'The decision key must be lowercase dotted words, for example shipping.free_threshold_eur.': - 'La clave debe ser de palabras en minúscula separadas por puntos, por ejemplo shipping.free_threshold_eur.', - 'expectedVersion must be a whole number of 0 or more.': 'expectedVersion debe ser un número entero de 0 o más.', - 'The proposal provenance is invalid.': 'El origen de la propuesta no es válido.', - 'The artifact path is invalid.': 'La ruta del archivo no es válida.', - 'The artifact evidence is invalid.': 'La evidencia del archivo no es válida.', - 'Only a passing test can verify an artifact.': 'Solo una prueba superada puede verificar un archivo.', - 'That authorization was already used.': 'Esa autorización ya se usó.', - 'That authorization does not match a pending proposal.': 'Esa autorización no corresponde a ninguna propuesta pendiente.', - 'Only a host approval can authorize a memory change.': 'Solo una aprobación del host puede autorizar un cambio de memoria.', - 'The authorization does not match the exact proposal.': 'La autorización no coincide con la propuesta exacta.', - 'The authorization belongs to another project, thread, or provider.': 'La autorización pertenece a otro proyecto, hilo o proveedor.', - 'The proposal expired before it was approved; propose it again.': 'La propuesta caducó antes de aprobarse; vuelve a proponerla.', - 'Chat sends the active thread’s recent history, a bounded host-selected set of relevant older excerpts, and your new message. Other threads are never sent. Older local excerpts are withheld when switching to Meta.': - 'El chat envía el historial reciente del hilo activo, extractos antiguos relevantes y acotados elegidos por el host, y tu mensaje nuevo. Los demás hilos nunca se envían. Los extractos locales antiguos no se envían al cambiar a Meta.', - 'Mission sends only your new message to Meta Model API. Earlier thread content is never sent as context.': - 'Misión envía solo tu mensaje nuevo a Meta Model API. El contenido anterior del hilo nunca se envía como contexto.', - }; + 'Ready.': 'Listo.', + 'New Agent thread started. Type a message below.': 'Nuevo hilo del Agente. Escribe un mensaje abajo.', + 'That thread is no longer stored.': 'Ese hilo ya no está guardado.', + 'Stop the current run before deleting its thread.': 'Detén la ejecución actual antes de borrar su hilo.', + 'Thread deleted.': 'Hilo borrado.', + 'Showing thread list.': 'Mostrando la lista de hilos.', + 'Unknown mode. Choose Chat, Mission, or Agent.': 'Modo desconocido. Elige Chat, Misión o Agente.', + 'Unknown runtime. Choose a runtime in Settings.': 'Motor desconocido. Elige un motor en Ajustes.', + 'Type a message first.': 'Escribe primero un mensaje.', + 'The message is invalid.': 'El mensaje no es válido.', + 'No Meta Model API key is stored. Save one in Settings or choose a local runtime.': + 'No hay clave de Meta Model API guardada. Guarda una en Ajustes o elige un motor local.', + 'Choose the workspace folder for this Agent run…': 'Elige la carpeta del workspace para esta ejecución del Agente…', + 'No API key stored. Save a Meta Model API key to begin.': 'No hay clave de API guardada. Guarda una clave de Meta Model API para empezar.', + 'Plan ready.': 'Plan listo.', + 'Reply ready.': 'Respuesta lista.', + 'Agent reply ready.': 'Respuesta del Agente lista.', + 'The local model did not submit a valid Mission plan.': 'El modelo local no entregó un plan de Misión válido.', + 'The local model did not return a strict plan; its reply is shown as prose.': + 'El modelo local no devolvió un plan estricto; su respuesta se muestra como texto.', + 'Reasoning was not changed: the selection no longer matches the current runtime.': + 'No se cambió el razonamiento: la selección ya no coincide con el motor actual.', + 'Reasoning was not changed.': 'No se cambió el razonamiento.', + 'The current run keeps its settings.': 'La ejecución en curso conserva sus ajustes.', + 'Unknown permission profile. Kept Ask (safest).': 'Perfil de permisos desconocido. Se mantiene Preguntar (lo más seguro).', + 'No Agent action is waiting for approval.': 'Ninguna acción del Agente espera aprobación.', + 'That approval is no longer valid.': 'Esa aprobación ya no es válida.', + 'Agent action rejected.': 'Acción del Agente rechazada.', + 'Agent action rejected. Nothing ran.': 'Acción del Agente rechazada. No se ejecutó nada.', + 'A precondition changed; asking the Agent to re-read and re-propose…': 'Cambió una precondición; se pide al Agente que vuelva a leer y proponer…', + 'Action rejected by user. No changes were made.': 'Acción rechazada por ti. No se hizo ningún cambio.', + 'The workspace changed; the Agent run stopped.': 'El workspace cambió; la ejecución del Agente se detuvo.', + 'A request is already running. Cancel it before starting a Mission task.': 'Ya hay una petición en marcha. Cancélala antes de iniciar una tarea de la Misión.', + 'That Mission does not belong to the open project.': 'Esa Misión no pertenece al proyecto abierto.', + 'The Mission changed; review it again before continuing.': 'La Misión cambió; revísala de nuevo antes de seguir.', + 'The Mission thread no longer exists.': 'El hilo de la Misión ya no existe.', + 'The Mission’s project folder is no longer open.': 'La carpeta del proyecto de la Misión ya no está abierta.', + 'That folder is not open in this window.': 'Esa carpeta no está abierta en esta ventana.', + 'Approve or reject the pending memory change first.': 'Primero aprueba o rechaza el cambio de memoria pendiente.', + 'A Mission task is running. Wait for it to finish before changing the project.': 'Hay una tarea de la Misión en marcha. Espera a que termine antes de cambiar de proyecto.', + 'Several folders are open. Choose the project above to manage its memory.': 'Hay varias carpetas abiertas. Elige arriba el proyecto para gestionar su memoria.', + 'Open a folder to manage its project memory.': 'Abre una carpeta para gestionar la memoria de su proyecto.', + 'The project folder could not be resolved.': 'No se pudo resolver la carpeta del proyecto.', + 'Review the exact memory change below, then approve or reject it.': 'Revisa abajo el cambio exacto de memoria y apruébalo o recházalo.', + 'That memory approval is no longer valid.': 'Esa aprobación de memoria ya no es válida.', + 'Memory change rejected. Nothing was written.': 'Cambio de memoria rechazado. No se guardó nada.', + 'Artifact recorded.': 'Archivo registrado.', + 'Project memory could not be read; RAME Code will not overwrite it.': 'No se pudo leer la memoria del proyecto; RAME Code no la sobrescribirá.', + 'Request cancelled.': 'Petición cancelada.', + 'Local work failed. Escalation is waiting for approval.': 'Falló el trabajo local. El escalado espera tu aprobación.', + 'Escalation is waiting for approval.': 'El escalado espera tu aprobación.', + 'Cloud escalation is OFF, so nothing was sent.': 'El escalado a la nube está desactivado, así que no se envió nada.', + 'No escalation is waiting for approval.': 'Ningún escalado espera aprobación.', + 'A request is already running. Cancel it or wait before sending to Meta. Nothing was sent.': + 'Ya hay una petición en marcha. Cancélala o espera antes de enviar a Meta. No se envió nada.', + 'Cloud escalation is OFF. Nothing was sent.': 'El escalado a la nube está desactivado. No se envió nada.', + 'That thread is no longer stored. Nothing was sent.': 'Ese hilo ya no está guardado. No se envió nada.', + 'Escalation dismissed. Nothing was sent to Meta.': 'Escalado descartado. No se envió nada a Meta.', + 'No request is running.': 'No hay ninguna petición en marcha.', + 'The API key is invalid.': 'La clave de API no es válida.', + 'API key stored. Refreshing available models…': 'Clave de API guardada. Actualizando los modelos disponibles…', + 'Meta connection not tested: save an API key first.': 'Conexión con Meta no comprobada: guarda primero una clave de API.', + 'Meta connection verified. Live model catalog loaded.': 'Conexión con Meta verificada. Catálogo real de modelos cargado.', + 'Meta connection test failed. Check the key, balance, network, and Meta service status.': 'Falló la prueba de conexión con Meta. Revisa la clave, el saldo, la red y el estado del servicio de Meta.', + 'API key cleared. Save a key to run cloud requests.': 'Clave de API borrada. Guarda una clave para usar la nube.', + 'Unknown mode.': 'Modo desconocido.', + 'Settings saved.': 'Ajustes guardados.', + 'Refreshing available models…': 'Actualizando los modelos disponibles…', + 'Local settings are invalid.': 'Los ajustes locales no son válidos.', + 'Local settings saved.': 'Ajustes locales guardados.', + 'The external endpoint must stay on loopback.': 'El endpoint externo debe quedarse en loopback.', + 'External endpoint cleared.': 'Endpoint externo quitado.', + 'The local runtime is already starting or running.': 'El motor local ya está arrancando o en marcha.', + 'The local runtime is stopping. Try again in a moment.': 'El motor local se está deteniendo. Inténtalo de nuevo en un momento.', + 'Could not start the local runtime executable.': 'No se pudo iniciar el ejecutable del motor local.', + 'The local runtime stopped.': 'El motor local se detuvo.', + 'Stopping the local runtime…': 'Deteniendo el motor local…', + 'Local runtime stopped.': 'Motor local detenido.', + 'Ignoring an invalid view request.': 'Se ignora una petición no válida de la vista.', + 'Mission created. Start the first task when you are ready.': 'Misión creada. Inicia la primera tarea cuando quieras.', + 'RAME Code was reloaded while this task was running; nothing is repeated automatically.': + 'RAME Code se recargó mientras esta tarea se ejecutaba; no se repite nada automáticamente.', + 'Review what ran, then resume the task.': 'Revisa lo que se ejecutó y después reanuda la tarea.', + 'Check the workspace before resuming.': 'Revisa el workspace antes de reanudar.', + 'The file was not checked.': 'El archivo no se comprobó.', + 'The file no longer exists.': 'El archivo ya no existe.', + 'The file changed after it was verified.': 'El archivo cambió después de verificarlo.', + 'Project memory is being changed in another VS Code window; try again in a moment.': + 'La memoria del proyecto se está cambiando en otra ventana de VS Code; inténtalo de nuevo en un momento.', + 'Project memory could not be read, so RAME Code will not write to it.': 'No se pudo leer la memoria del proyecto, así que RAME Code no escribirá en ella.', + 'Stored Missions could not be read, so RAME Code will not write to them.': 'No se pudieron leer las Misiones guardadas, así que RAME Code no escribirá en ellas.', + 'That Mission no longer exists or could not be read.': 'Esa Misión ya no existe o no se pudo leer.', + 'The Mission changed in another window; review it again before continuing.': 'La Misión cambió en otra ventana; revísala de nuevo antes de seguir.', + 'This Mission has no current task.': 'Esta Misión no tiene tarea actual.', + 'This Mission is completed.': 'Esta Misión está completada.', + 'This Mission failed; create a new Mission to continue.': 'Esta Misión falló; crea una nueva para continuar.', + 'This Mission is waiting for an approval in its current run.': 'Esta Misión espera una aprobación en su ejecución actual.', + 'The Mission is not running.': 'La Misión no está en marcha.', + 'The decision key must be lowercase dotted words, for example shipping.free_threshold_eur.': + 'La clave debe ser de palabras en minúscula separadas por puntos, por ejemplo shipping.free_threshold_eur.', + 'expectedVersion must be a whole number of 0 or more.': 'expectedVersion debe ser un número entero de 0 o más.', + 'The proposal provenance is invalid.': 'El origen de la propuesta no es válido.', + 'The artifact path is invalid.': 'La ruta del archivo no es válida.', + 'The artifact evidence is invalid.': 'La evidencia del archivo no es válida.', + 'Only a passing test can verify an artifact.': 'Solo una prueba superada puede verificar un archivo.', + 'That authorization was already used.': 'Esa autorización ya se usó.', + 'That authorization does not match a pending proposal.': 'Esa autorización no corresponde a ninguna propuesta pendiente.', + 'Only a host approval can authorize a memory change.': 'Solo una aprobación del host puede autorizar un cambio de memoria.', + 'The authorization does not match the exact proposal.': 'La autorización no coincide con la propuesta exacta.', + 'The authorization belongs to another project, thread, or provider.': 'La autorización pertenece a otro proyecto, hilo o proveedor.', + 'The proposal expired before it was approved; propose it again.': 'La propuesta caducó antes de aprobarse; vuelve a proponerla.', + 'Chat sends the active thread’s recent history, a bounded host-selected set of relevant older excerpts, and your new message. Other threads are never sent. Older local excerpts are withheld when switching to Meta.': + 'El chat envía el historial reciente del hilo activo, extractos antiguos relevantes y acotados elegidos por el host, y tu mensaje nuevo. Los demás hilos nunca se envían. Los extractos locales antiguos no se envían al cambiar a Meta.', + 'Mission sends only your new message to Meta Model API. Earlier thread content is never sent as context.': + 'Misión envía solo tu mensaje nuevo a Meta Model API. El contenido anterior del hilo nunca se envía como contexto.', +}; - function q(text) { - return '«' + text + '»'; - } +function q(text){return '«'+text+'»';} - var ES_TYPE = { number: 'número', boolean: 'verdadero/falso', string: 'texto' }; - var ES_WHO = { - 'you (Settings)': 'ti (Ajustes)', - 'the Meta cloud model': 'el modelo de Meta en la nube', - 'the local model': 'el modelo local', - }; - // A JSON scalar as the host prints it: the parse of a card is unambiguous. - var JSON_VALUE = '(not set|true|false|-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?|"(?:[^"\\\\]|\\\\.)*")'; - var KEY = '([a-z0-9_]+(?:\\.[a-z0-9_]+)*)'; - var WHO = '(you \\(Settings\\)|the Meta cloud model|the local model)'; - var REASON = '(?: Reason given: ("(?:[^"\\\\]|\\\\.)*"))?'; - var QUOTE = '(?: Your words: ("(?:[^"\\\\]|\\\\.)*")\\.)?'; +var ES_TYPE = { number: 'número', boolean: 'verdadero/falso', string: 'texto' }; +var ES_WHO = { + 'you (Settings)': 'ti (Ajustes)', + 'the Meta cloud model': 'el modelo de Meta en la nube', + 'the local model': 'el modelo local', +}; +var JSON_VALUE = '(not set|true|false|-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?|"(?:[^"\\\\]|\\\\.)*")'; +var KEY = '([a-z0-9_]+(?:\\.[a-z0-9_]+)*)'; +var WHO = '(you \\(Settings\\)|the Meta cloud model|the local model)'; +var REASON = '(?: Reason given: ("(?:[^"\\\\]|\\\\.)*"))?'; +var QUOTE = '(?: Your words: ("(?:[^"\\\\]|\\\\.)*")\\.)?'; - function value(text) { - return text === 'not set' ? 'sin valor' : text; - } +function value(text) { return text === 'not set' ? 'sin valor' : text; } +function reason(text) { return text === '"Entered in Settings."' ? '"Introducido en Ajustes."' : text; } - // Only the host's own Settings reason is translated; any other reason is - // the proposer's text and stays verbatim. - function reason(text) { - return text === '"Entered in Settings."' ? '"Introducido en Ajustes."' : text; - } +var ES_PATTERNS = [ + [/^verification: unprotected — ([\s\S]+)$/, function(m){return'verificación: sin aislamiento — '+m[1];}], + [/^verification changed from protected to unprotected: ([\s\S]+)$/, function(m){return'la verificación cambió de protegida a sin aislamiento: '+m[1];}], + [/^Not run: protected verification.+this is (Windows|macOS)\)\.$/, function(m){return'No ejecutado: la verificación protegida exige lecturas restringidas y red cerrada; solo está disponible en Linux, no en '+m[1]+'.'}], + [/^Opened “([\s\S]*)”\.$/, function (m) { return 'Abierto ' + q(m[1]) + '.'; }], + [/^Reasoning (\S+) is not available for (\S+)\.$/, function (m, tr) { return 'El razonamiento ' + tr(m[1]) + ' no está disponible para ' + m[2] + '.'; }], + [/^Reasoning set to ([\s\S]+?) for (\S+); the current run keeps its settings\.$/, function (m, tr) { return 'Razonamiento: ' + tr(m[1]) + ' para ' + m[2] + '; la ejecución en curso conserva sus ajustes.'; }], + [/^Reasoning set to ([\s\S]+?) for (\S+)\.$/, function (m, tr) { return 'Razonamiento: ' + tr(m[1]) + ' para ' + m[2] + '.'; }], + [/^(Local|Meta) maximum not saved, so nothing changed: ([\s\S]*)$/, function (m, tr) { return 'No se guardó el máximo ' + (m[1] === 'Local' ? 'local' : 'de Meta') + ', así que no cambió nada: ' + tr(m[2]); }], + [/^Maximum output: Local (automatic|\d+ tokens), Meta (automatic|\d+ tokens)\.$/, function (m) { + var w = function (v) { return v === 'automatic' ? 'automático' : v; }; + return 'Salida máxima: local ' + w(m[1]) + ', Meta ' + w(m[2]) + '.'; + }], + [/^You can ask Meta (\S+) for advice with the exact text shown, or dismiss\.$/, function (m) { return 'Puedes pedir consejo a Meta ' + m[1] + ' con el texto exacto mostrado, o descartarlo.'; }], + [/^Asking Meta (\S+) for advice with the approved text only…$/, function (m) { return 'Pidiendo consejo a Meta ' + m[1] + ' solo con el texto aprobado…'; }], + [/^The Meta advice could not be used \(([\s\S]*)\)\. Nothing else was sent and nothing changed\.$/, function (m) { return 'No se pudo usar el consejo de Meta (' + m[1] + '). No se envió nada más y nada cambió.'; }], + [/^Agent stopped at its step budget \((\d+) model steps\) without a final report\.$/, function (m) { return 'El Agente se detuvo en su límite de pasos (' + m[1] + ' pasos del modelo) sin informe final.'; }], + [/^Waiting for your approval: (\S+)\.$/, function (m) { return 'Esperando tu aprobación: ' + m[1] + '.'; }], + [/^Retrying an empty Agent response \((\d)\/2\)…$/, function (m) { return 'Reintentando una respuesta vacía del Agente (' + m[1] + '/2)…'; }], + [/^Correcting a blocked (\S+) proposal…$/, function (m) { return 'Corrigiendo una propuesta bloqueada de ' + m[1] + '…'; }], + [/^The (\S+) proposal was not prepared; returning the reason to the Agent…$/, function (m) { return 'La propuesta de ' + m[1] + ' no se preparó; se devuelve el motivo al Agente…'; }], + [/^Correcting malformed tool call \((\d)\/2\): waiting for the ([\s\S]+) \(120 s maximum\)…$/, function (m) { return 'Corrigiendo una llamada de herramienta mal formada (' + m[1] + '/2): esperando al ' + m[2] + ' (120 s como máximo)…'; }], + [/^The ([\s\S]+) reply was cut off at its (\d+)-token limit; asking for a shorter step \((\d)\/2\)…$/, function (m) { return 'La respuesta del ' + m[1] + ' se cortó en su límite de ' + m[2] + ' tokens; se pide un paso más corto (' + m[3] + '/2)…'; }], + [/^Request failed \(([\s\S]*)\)\.$/, function (m) { return 'La petición falló (' + m[1] + ').'; }], + [/^Local work failed \(([\s\S]*)\)\. Cloud escalation is OFF, so nothing was sent\.$/, function (m) { return 'Falló el trabajo local (' + m[1] + '). El escalado a la nube está desactivado, así que no se envió nada.'; }], + [/^Local model is not reachable at (\S+)\. Start it in Settings\.$/, function (m) { return 'No se puede conectar con el modelo local en ' + m[1] + '. Inícialo en Ajustes.'; }], + [/^That model is unavailable\. Using (\S+)\.$/, function (m) { return 'Ese modelo no está disponible. Se usa ' + m[1] + '.'; }], + [/^The stored model was unavailable; using (\S+)\.$/, function (m) { return 'El modelo guardado no estaba disponible; se usa ' + m[1] + '.'; }], + [/^Cloud model selected: ([\s\S]+)\.$/, function (m) { return 'Modelo en la nube elegido: ' + m[1] + '.'; }], + [/^External endpoint selected: (\S+)\. RAME does not own its lifecycle\.$/, function (m) { return 'Endpoint externo elegido: ' + m[1] + '. RAME no gestiona su ciclo de vida.'; }], + [/^External endpoint selected: (\S+) \(not owned\)\.$/, function (m) { return 'Endpoint externo elegido: ' + m[1] + ' (no gestionado).'; }], + [/^Starting the local runtime on (\S+)…$/, function (m) { return 'Arrancando el motor local en ' + m[1] + '…'; }], + [/^The local runtime did not become healthy within (\d+)s\.$/, function (m) { return 'El motor local no estuvo listo en ' + m[1] + ' s.'; }], + [/^Local runtime running on (\S+)\.$/, function (m) { return 'Motor local en marcha en ' + m[1] + '.'; }], + [/^Local runtime is running on (\S+) \(owned by RAME Code\)\.$/, function (m) { return 'El motor local está en marcha en ' + m[1] + ' (gestionado por RAME Code).'; }], + [/^Local runtime is running on (\S+) \(external, not owned\)\.$/, function (m) { return 'El motor local está en marcha en ' + m[1] + ' (externo, no gestionado).'; }], + [/^Project memory: ([\s\S]+)\.$/, function (m) { return 'Memoria del proyecto: ' + m[1] + '.'; }], + [/^The value is not a valid (number|boolean|string)\.$/, function (m) { return 'El valor no es un ' + ES_TYPE[m[1]] + ' válido.'; }], + [/^Not proposed: ([\s\S]*)$/, function (m, tr) { return 'No propuesto: ' + tr(m[1]); }], + [/^Not written: ([\s\S]*)$/, function (m, tr) { return 'No guardado: ' + tr(m[1]); }], + [new RegExp('^Saved ' + KEY + ' v(\\d+) = ' + JSON_VALUE + '\\.$'), function (m) { return 'Guardado: ' + m[1] + ' v' + m[2] + ' = ' + m[3] + '.'; }], + [new RegExp('^' + KEY + ' changed since this proposal was shown, so nothing was written; propose it again from the current version\\.$'), function (m) { + return m[1] + ' cambió después de mostrar esta propuesta, así que no se guardó nada; vuelve a proponerla desde la versión actual.'; + }], + [new RegExp('^' + KEY + ' is at version (\\d+), not (\\d+)\\. The proposal was based on an outdated version and was not prepared\\.$'), function (m) { + return m[1] + ' está en la versión ' + m[2] + ', no en la ' + m[3] + '. La propuesta partía de una versión antigua y no se preparó.'; + }], + [new RegExp('^' + KEY + ' is already ' + JSON_VALUE + ' at version (\\d+)\\.$'), function (m) { return m[1] + ' ya vale ' + m[2] + ' en la versión ' + m[3] + '.'; }], + [new RegExp('^' + KEY + ' changed to version (\\d+) after this proposal was prepared for version (\\d+)\\. Nothing was written\\.$'), function (m) { + return m[1] + ' pasó a la versión ' + m[2] + ' después de preparar esta propuesta para la versión ' + m[3] + '. No se guardó nada.'; + }], + [new RegExp('^' + KEY + ' has no value in force to delete\\.$'), function (m) { return m[1] + ' no tiene ningún valor vigente que borrar.'; }], + [new RegExp('^The stored history of ' + KEY + ' failed validation; it is kept as is and cannot be changed\\.$'), function (m) { + return 'El historial guardado de ' + m[1] + ' no pasó la validación; se conserva tal cual y no se puede cambiar.'; + }], + [new RegExp('^' + KEY + ' has no authorized value in this project\\.$'), function (m) { return m[1] + ' no tiene ningún valor autorizado en este proyecto.'; }], + [new RegExp('^' + KEY + ' is at version (\\d+), not (\\d+); verify the file against the version in force\\.$'), function (m) { + return m[1] + ' está en la versión ' + m[2] + ', no en la ' + m[3] + '; verifica el archivo con la versión vigente.'; + }], + [new RegExp('^' + KEY + ' is no longer at version (\\d+); the artifact was not bound\\.$'), function (m) { return m[1] + ' ya no está en la versión ' + m[2] + '; el archivo no se vinculó.'; }], + [/^The value must be a number, true\/false, or text of 1 to (\d+) characters\.$/, function (m) { return 'El valor debe ser un número, verdadero/falso o un texto de 1 a ' + m[1] + ' caracteres.'; }], + [/^This project already has (\d+) decisions\.$/, function (m) { return 'Este proyecto ya tiene ' + m[1] + ' decisiones.'; }], + [/^This Mission used all (\d+) task runs of its budget\.$/, function (m) { return 'Esta Misión gastó las ' + m[1] + ' ejecuciones de tarea de su presupuesto.'; }], + [new RegExp('^Verified against ' + KEY + ' v(\\d+) by (\\S+)\\.$'), function (m) { return 'Verificado con ' + m[1] + ' v' + m[2] + ' mediante ' + m[3] + '.'; }], + [new RegExp('^The history of ' + KEY + ' failed validation\\.$'), function (m) { return 'El historial de ' + m[1] + ' no pasó la validación.'; }], + [new RegExp('^' + KEY + ' has no value in force any more\\.$'), function (m) { return m[1] + ' ya no tiene ningún valor vigente.'; }], + [new RegExp('^Verified against v(\\d+), but ' + KEY + ' is now v(\\d+)\\.$'), function (m) { return 'Verificado con la v' + m[1] + ', pero ' + m[2] + ' está ahora en la v' + m[3] + '.'; }], + [/^Running task "([\s\S]*)" \(run (\d+); (\d+) of (\d+) Mission runs used\)\.$/, function (m) { + return 'Ejecutando la tarea ' + q(m[1]) + ' (ejecución ' + m[2] + '; ' + m[3] + ' de ' + m[4] + ' ejecuciones de la Misión usadas).'; + }], + [/^Task "([\s\S]*)" completed\. Next: "([\s\S]*)"\. Start it when you are ready\.$/, function (m) { + return 'Tarea ' + q(m[1]) + ' completada. Siguiente: ' + q(m[2]) + '. Iníciala cuando quieras.'; + }], + [/^All (\d+) tasks completed\.$/, function (m) { return 'Las ' + m[1] + ' tareas están completadas.'; }], + [/^The task run ended without completing: ([\s\S]*)$/, function (m, tr) { return 'La ejecución de la tarea terminó sin completarse: ' + tr(m[1]); }], + [/^The plan was not saved as a Mission: ([\s\S]*)$/, function (m, tr) { return 'El plan no se guardó como Misión: ' + tr(m[1]); }], + [/^Saved as a Mission with (\d+) task\(s\); review it and authorize it in the Mission card of this thread\.$/, function (m) { + return 'Guardado como Misión con ' + m[1] + ' tarea(s); revísala y autorízala en la tarjeta de Misión de este hilo.'; + }], + [/^Working with (\S+)…$/, function (m) { return 'Trabajando con ' + m[1] + '…'; }], + [/^A repeated (\S+) returned nothing new; telling the Agent…$/, function (m) { return 'Un ' + m[1] + ' repetido no aportó nada nuevo; se le indica al Agente…'; }], + [/^Blocked: (\d+) internal runs in a row without verifiable progress, or the run ceiling was reached \(last: ([\s\S]*)\)\.$/, function (m) { + return 'Bloqueada: ' + m[1] + ' ejecuciones internas seguidas sin progreso verificable, o se alcanzó el techo de ejecuciones (última: ' + m[2] + ').'; + }], + [/^Mission (blocked|paused|cancelled|interrupted|waiting approval|completed) — status note, not a final report\.$/, function (m) { + var names = { blocked: 'bloqueada', paused: 'pausada', cancelled: 'cancelada', interrupted: 'interrumpida', 'waiting approval': 'esperando aprobación', completed: 'completada' }; + return 'Misión ' + names[m[1]] + ' — nota de estado, no es un informe final.'; + }], + [/^Uncertain: ([\s\S]+) started but its result was not recorded\.$/, function (m) { return 'Incierto: ' + m[1] + ' empezó pero su resultado no se registró.'; }], + [/^Agent runs a bounded tool loop with the selected provider only \(local Glimmer or Meta (\S+), never switched silently\)\. It inspects and changes only the active workspace under the selected permission profile\. Arbitrary shell commands run only through the optional terminal tool, OFF by default, each shown before it runs; allowlisted test scripts run with your OS permissions and may access files, network including arbitrary internet, or shells; there is no OS sandbox for them\. No computer-wide control or memory safety beyond what is implemented is claimed\.$/, function (m) { + return 'El Agente ejecuta un bucle acotado de herramientas solo con el proveedor elegido (Glimmer local o Meta ' + m[1] + ', nunca cambiado en silencio). Solo inspecciona y cambia el workspace activo bajo el perfil de permisos elegido. Los comandos de shell arbitrarios solo pasan por la herramienta opcional de terminal, desactivada por defecto y mostrada antes de cada comando; los scripts de prueba permitidos se ejecutan con tus permisos del sistema y pueden acceder a ficheros, a la red (incluido internet) o a shells; no hay sandbox del sistema para ellos. No se afirma ningún control del equipo ni seguridad de memoria más allá de lo implementado.'; + }], + [new RegExp('^Set ' + KEY + ' from ' + JSON_VALUE + ' \\(v(\\d+)\\) to ' + JSON_VALUE + ' \\(v(\\d+)\\)\\. Project: ([^\\n]+?)\\. Proposed by ' + WHO + '\\.' + QUOTE + REASON + '$'), function (m) { + return 'Cambiar ' + m[1] + ' de ' + value(m[2]) + ' (v' + m[3] + ') a ' + m[4] + ' (v' + m[5] + '). Proyecto: ' + m[6] + '. Propuesto por ' + ES_WHO[m[7]] + '.' + + (m[8] ? ' Tus palabras: ' + m[8] + '.' : '') + + (m[9] ? ' Motivo indicado: ' + reason(m[9]) : ''); + }], + [new RegExp('^Remove ' + KEY + ' \\(now ' + JSON_VALUE + ', v(\\d+)\\)\\. Project: ([^\\n]+?)\\. Proposed by ' + WHO + '\\.' + QUOTE + REASON + '$'), function (m) { + return 'Eliminar ' + m[1] + ' (ahora ' + value(m[2]) + ', v' + m[3] + '). Proyecto: ' + m[4] + '. Propuesto por ' + ES_WHO[m[5]] + '.' + + (m[6] ? ' Tus palabras: ' + m[6] + '.' : '') + + (m[7] ? ' Motivo indicado: ' + reason(m[7]) : ''); + }], +]; - var ES_PATTERNS = [ - [/^Opened “([\s\S]*)”\.$/, function (m) { return 'Abierto ' + q(m[1]) + '.'; }], - [/^Reasoning (\S+) is not available for (\S+)\.$/, function (m, tr) { return 'El razonamiento ' + tr(m[1]) + ' no está disponible para ' + m[2] + '.'; }], - [/^Reasoning set to ([\s\S]+?) for (\S+); the current run keeps its settings\.$/, function (m, tr) { return 'Razonamiento: ' + tr(m[1]) + ' para ' + m[2] + '; la ejecución en curso conserva sus ajustes.'; }], - [/^Reasoning set to ([\s\S]+?) for (\S+)\.$/, function (m, tr) { return 'Razonamiento: ' + tr(m[1]) + ' para ' + m[2] + '.'; }], - [/^(Local|Meta) maximum not saved, so nothing changed: ([\s\S]*)$/, function (m, tr) { return 'No se guardó el máximo ' + (m[1] === 'Local' ? 'local' : 'de Meta') + ', así que no cambió nada: ' + tr(m[2]); }], - [/^Maximum output: Local (automatic|\d+ tokens), Meta (automatic|\d+ tokens)\.$/, function (m) { - var w = function (v) { return v === 'automatic' ? 'automático' : v; }; - return 'Salida máxima: local ' + w(m[1]) + ', Meta ' + w(m[2]) + '.'; - }], - [/^You can ask Meta (\S+) for advice with the exact text shown, or dismiss\.$/, function (m) { return 'Puedes pedir consejo a Meta ' + m[1] + ' con el texto exacto mostrado, o descartarlo.'; }], - [/^Asking Meta (\S+) for advice with the approved text only…$/, function (m) { return 'Pidiendo consejo a Meta ' + m[1] + ' solo con el texto aprobado…'; }], - [/^The Meta advice could not be used \(([\s\S]*)\)\. Nothing else was sent and nothing changed\.$/, function (m) { return 'No se pudo usar el consejo de Meta (' + m[1] + '). No se envió nada más y nada cambió.'; }], - [/^Agent stopped at its step budget \((\d+) model steps\) without a final report\.$/, function (m) { return 'El Agente se detuvo en su límite de pasos (' + m[1] + ' pasos del modelo) sin informe final.'; }], - [/^Waiting for your approval: (\S+)\.$/, function (m) { return 'Esperando tu aprobación: ' + m[1] + '.'; }], - [/^Retrying an empty Agent response \((\d)\/2\)…$/, function (m) { return 'Reintentando una respuesta vacía del Agente (' + m[1] + '/2)…'; }], - [/^Correcting a blocked (\S+) proposal…$/, function (m) { return 'Corrigiendo una propuesta bloqueada de ' + m[1] + '…'; }], - [/^The (\S+) proposal was not prepared; returning the reason to the Agent…$/, function (m) { return 'La propuesta de ' + m[1] + ' no se preparó; se devuelve el motivo al Agente…'; }], - [/^Correcting malformed tool call \((\d)\/2\): waiting for the ([\s\S]+) \(120 s maximum\)…$/, function (m) { return 'Corrigiendo una llamada de herramienta mal formada (' + m[1] + '/2): esperando al ' + m[2] + ' (120 s como máximo)…'; }], - [/^The ([\s\S]+) reply was cut off at its (\d+)-token limit; asking for a shorter step \((\d)\/2\)…$/, function (m) { return 'La respuesta del ' + m[1] + ' se cortó en su límite de ' + m[2] + ' tokens; se pide un paso más corto (' + m[3] + '/2)…'; }], - [/^Request failed \(([\s\S]*)\)\.$/, function (m) { return 'La petición falló (' + m[1] + ').'; }], - [/^Local work failed \(([\s\S]*)\)\. Cloud escalation is OFF, so nothing was sent\.$/, function (m) { return 'Falló el trabajo local (' + m[1] + '). El escalado a la nube está desactivado, así que no se envió nada.'; }], - [/^Local model is not reachable at (\S+)\. Start it in Settings\.$/, function (m) { return 'No se puede conectar con el modelo local en ' + m[1] + '. Inícialo en Ajustes.'; }], - [/^That model is unavailable\. Using (\S+)\.$/, function (m) { return 'Ese modelo no está disponible. Se usa ' + m[1] + '.'; }], - [/^The stored model was unavailable; using (\S+)\.$/, function (m) { return 'El modelo guardado no estaba disponible; se usa ' + m[1] + '.'; }], - [/^Cloud model selected: ([\s\S]+)\.$/, function (m) { return 'Modelo en la nube elegido: ' + m[1] + '.'; }], - [/^External endpoint selected: (\S+)\. RAME does not own its lifecycle\.$/, function (m) { return 'Endpoint externo elegido: ' + m[1] + '. RAME no gestiona su ciclo de vida.'; }], - [/^External endpoint selected: (\S+) \(not owned\)\.$/, function (m) { return 'Endpoint externo elegido: ' + m[1] + ' (no gestionado).'; }], - [/^Starting the local runtime on (\S+)…$/, function (m) { return 'Arrancando el motor local en ' + m[1] + '…'; }], - [/^The local runtime did not become healthy within (\d+)s\.$/, function (m) { return 'El motor local no estuvo listo en ' + m[1] + ' s.'; }], - [/^Local runtime running on (\S+)\.$/, function (m) { return 'Motor local en marcha en ' + m[1] + '.'; }], - [/^Local runtime is running on (\S+) \(owned by RAME Code\)\.$/, function (m) { return 'El motor local está en marcha en ' + m[1] + ' (gestionado por RAME Code).'; }], - [/^Local runtime is running on (\S+) \(external, not owned\)\.$/, function (m) { return 'El motor local está en marcha en ' + m[1] + ' (externo, no gestionado).'; }], - [/^Project memory: ([\s\S]+)\.$/, function (m) { return 'Memoria del proyecto: ' + m[1] + '.'; }], - [/^The value is not a valid (number|boolean|string)\.$/, function (m) { return 'El valor no es un ' + ES_TYPE[m[1]] + ' válido.'; }], - [/^Not proposed: ([\s\S]*)$/, function (m, tr) { return 'No propuesto: ' + tr(m[1]); }], - [/^Not written: ([\s\S]*)$/, function (m, tr) { return 'No guardado: ' + tr(m[1]); }], - [new RegExp('^Saved ' + KEY + ' v(\\d+) = ' + JSON_VALUE + '\\.$'), function (m) { return 'Guardado: ' + m[1] + ' v' + m[2] + ' = ' + m[3] + '.'; }], - [new RegExp('^' + KEY + ' changed since this proposal was shown, so nothing was written; propose it again from the current version\\.$'), function (m) { - return m[1] + ' cambió después de mostrar esta propuesta, así que no se guardó nada; vuelve a proponerla desde la versión actual.'; - }], - [new RegExp('^' + KEY + ' is at version (\\d+), not (\\d+)\\. The proposal was based on an outdated version and was not prepared\\.$'), function (m) { - return m[1] + ' está en la versión ' + m[2] + ', no en la ' + m[3] + '. La propuesta partía de una versión antigua y no se preparó.'; - }], - [new RegExp('^' + KEY + ' is already ' + JSON_VALUE + ' at version (\\d+)\\.$'), function (m) { return m[1] + ' ya vale ' + m[2] + ' en la versión ' + m[3] + '.'; }], - [new RegExp('^' + KEY + ' changed to version (\\d+) after this proposal was prepared for version (\\d+)\\. Nothing was written\\.$'), function (m) { - return m[1] + ' pasó a la versión ' + m[2] + ' después de preparar esta propuesta para la versión ' + m[3] + '. No se guardó nada.'; - }], - [new RegExp('^' + KEY + ' has no value in force to delete\\.$'), function (m) { return m[1] + ' no tiene ningún valor vigente que borrar.'; }], - [new RegExp('^The stored history of ' + KEY + ' failed validation; it is kept as is and cannot be changed\\.$'), function (m) { - return 'El historial guardado de ' + m[1] + ' no pasó la validación; se conserva tal cual y no se puede cambiar.'; - }], - [new RegExp('^' + KEY + ' has no authorized value in this project\\.$'), function (m) { return m[1] + ' no tiene ningún valor autorizado en este proyecto.'; }], - [new RegExp('^' + KEY + ' is at version (\\d+), not (\\d+); verify the file against the version in force\\.$'), function (m) { - return m[1] + ' está en la versión ' + m[2] + ', no en la ' + m[3] + '; verifica el archivo con la versión vigente.'; - }], - [new RegExp('^' + KEY + ' is no longer at version (\\d+); the artifact was not bound\\.$'), function (m) { return m[1] + ' ya no está en la versión ' + m[2] + '; el archivo no se vinculó.'; }], - [/^The value must be a number, true\/false, or text of 1 to (\d+) characters\.$/, function (m) { return 'El valor debe ser un número, verdadero/falso o un texto de 1 a ' + m[1] + ' caracteres.'; }], - [/^This project already has (\d+) decisions\.$/, function (m) { return 'Este proyecto ya tiene ' + m[1] + ' decisiones.'; }], - [/^This Mission used all (\d+) task runs of its budget\.$/, function (m) { return 'Esta Misión gastó las ' + m[1] + ' ejecuciones de tarea de su presupuesto.'; }], - [new RegExp('^Verified against ' + KEY + ' v(\\d+) by (\\S+)\\.$'), function (m) { return 'Verificado con ' + m[1] + ' v' + m[2] + ' mediante ' + m[3] + '.'; }], - [new RegExp('^The history of ' + KEY + ' failed validation\\.$'), function (m) { return 'El historial de ' + m[1] + ' no pasó la validación.'; }], - [new RegExp('^' + KEY + ' has no value in force any more\\.$'), function (m) { return m[1] + ' ya no tiene ningún valor vigente.'; }], - [new RegExp('^Verified against v(\\d+), but ' + KEY + ' is now v(\\d+)\\.$'), function (m) { return 'Verificado con la v' + m[1] + ', pero ' + m[2] + ' está ahora en la v' + m[3] + '.'; }], - [/^Running task "([\s\S]*)" \(run (\d+); (\d+) of (\d+) Mission runs used\)\.$/, function (m) { - return 'Ejecutando la tarea ' + q(m[1]) + ' (ejecución ' + m[2] + '; ' + m[3] + ' de ' + m[4] + ' ejecuciones de la Misión usadas).'; - }], - [/^Task "([\s\S]*)" completed\. Next: "([\s\S]*)"\. Start it when you are ready\.$/, function (m) { - return 'Tarea ' + q(m[1]) + ' completada. Siguiente: ' + q(m[2]) + '. Iníciala cuando quieras.'; - }], - [/^All (\d+) tasks completed\.$/, function (m) { return 'Las ' + m[1] + ' tareas están completadas.'; }], - [/^The task run ended without completing: ([\s\S]*)$/, function (m, tr) { return 'La ejecución de la tarea terminó sin completarse: ' + tr(m[1]); }], - [/^The plan was not saved as a Mission: ([\s\S]*)$/, function (m, tr) { return 'El plan no se guardó como Misión: ' + tr(m[1]); }], - [/^Saved as a Mission with (\d+) task\(s\); review it and authorize it in the Mission card of this thread\.$/, function (m) { - return 'Guardado como Misión con ' + m[1] + ' tarea(s); revísala y autorízala en la tarjeta de Misión de este hilo.'; - }], - [/^Working with (\S+)…$/, function (m) { return 'Trabajando con ' + m[1] + '…'; }], - [/^A repeated (\S+) returned nothing new; telling the Agent…$/, function (m) { return 'Un ' + m[1] + ' repetido no aportó nada nuevo; se le indica al Agente…'; }], - [/^Blocked: (\d+) internal runs in a row without verifiable progress, or the run ceiling was reached \(last: ([\s\S]*)\)\.$/, function (m) { - return 'Bloqueada: ' + m[1] + ' ejecuciones internas seguidas sin progreso verificable, o se alcanzó el techo de ejecuciones (última: ' + m[2] + ').'; - }], - [/^Mission (blocked|paused|cancelled|interrupted|waiting approval|completed) — status note, not a final report\.$/, function (m) { - var names = { blocked: 'bloqueada', paused: 'pausada', cancelled: 'cancelada', interrupted: 'interrumpida', 'waiting approval': 'esperando aprobación', completed: 'completada' }; - return 'Misión ' + names[m[1]] + ' — nota de estado, no es un informe final.'; - }], - [/^Uncertain: ([\s\S]+) started but its result was not recorded\.$/, function (m) { return 'Incierto: ' + m[1] + ' empezó pero su resultado no se registró.'; }], - [/^Agent runs a bounded tool loop with the selected provider only \(local Glimmer or Meta (\S+), never switched silently\)\. It inspects and changes only the active workspace under the selected permission profile\. Arbitrary shell commands run only through the optional terminal tool, OFF by default, each shown before it runs; allowlisted test scripts run with your OS permissions and may access files, network including arbitrary internet, or shells; there is no OS sandbox for them\. No computer-wide control or memory safety beyond what is implemented is claimed\.$/, function (m) { - return 'El Agente ejecuta un bucle acotado de herramientas solo con el proveedor elegido (Glimmer local o Meta ' + m[1] + ', nunca cambiado en silencio). Solo inspecciona y cambia el workspace activo bajo el perfil de permisos elegido. Los comandos de shell arbitrarios solo pasan por la herramienta opcional de terminal, desactivada por defecto y mostrada antes de cada comando; los scripts de prueba permitidos se ejecutan con tus permisos del sistema y pueden acceder a ficheros, a la red (incluido internet) o a shells; no hay sandbox del sistema para ellos. No se afirma ningún control del equipo ni seguridad de memoria más allá de lo implementado.'; - }], - [new RegExp('^Set ' + KEY + ' from ' + JSON_VALUE + ' \\(v(\\d+)\\) to ' + JSON_VALUE + ' \\(v(\\d+)\\)\\. Project: ([^\\n]+?)\\. Proposed by ' + WHO + '\\.' + QUOTE + REASON + '$'), function (m) { - return 'Cambiar ' + m[1] + ' de ' + value(m[2]) + ' (v' + m[3] + ') a ' + m[4] + ' (v' + m[5] + '). Proyecto: ' + m[6] + '. Propuesto por ' + ES_WHO[m[7]] + '.' + - (m[8] ? ' Tus palabras: ' + m[8] + '.' : '') + - (m[9] ? ' Motivo indicado: ' + reason(m[9]) : ''); - }], - [new RegExp('^Remove ' + KEY + ' \\(now ' + JSON_VALUE + ', v(\\d+)\\)\\. Project: ([^\\n]+?)\\. Proposed by ' + WHO + '\\.' + QUOTE + REASON + '$'), function (m) { - return 'Eliminar ' + m[1] + ' (ahora ' + value(m[2]) + ', v' + m[3] + '). Proyecto: ' + m[4] + '. Propuesto por ' + ES_WHO[m[5]] + '.' + - (m[6] ? ' Tus palabras: ' + m[6] + '.' : '') + - (m[7] ? ' Motivo indicado: ' + reason(m[7]) : ''); - }], - ]; +var TABLES = { es: { text: ES, patterns: ES_PATTERNS } }; - var TABLES = { es: { text: ES, patterns: ES_PATTERNS } }; +function lookup(language, text) { + var table = TABLES[language]; + if (!table || typeof text !== 'string') return null; + return Object.prototype.hasOwnProperty.call(table.text, text) ? table.text[text] : null; +} - function lookup(language, text) { - var table = TABLES[language]; - if (!table || typeof text !== 'string') { - return null; +function translate(language,text) {var found=lookup(language,text);return found===null?text:found;} + +function translateHostOne(language, text, depth) { + var found = lookup(language, text); + if (found !== null) return found; + var patterns = TABLES[language] ? TABLES[language].patterns : []; + for (var i = 0; i < patterns.length; i++) { + var match = patterns[i][0].exec(text); + if (match) { + return patterns[i][1](match, function (inner) { + return depth < 4 ? translateHost(language, inner, depth + 1) : inner; + }); } - return Object.prototype.hasOwnProperty.call(table.text, text) ? table.text[text] : null; } + return null; +} - /** Interface text: the exact English text, else unchanged. */ - function translate(language, text) { - var found = lookup(language, text); - return found === null ? text : found; +function translateHost(language, text, depth) { + if (!TABLES[language] || typeof text !== 'string' || text.length === 0) { + return text; } - - function translateHostOne(language, text, depth) { - var found = lookup(language, text); - if (found !== null) { - return found; - } - var patterns = TABLES[language] ? TABLES[language].patterns : []; - for (var i = 0; i < patterns.length; i++) { - var match = patterns[i][0].exec(text); - if (match) { - return patterns[i][1](match, function (inner) { - return depth < 4 ? translateHost(language, inner, depth + 1) : inner; - }); - } - } - return null; + var level = depth || 0; + var whole = translateHostOne(language, text, level); + if (whole !== null) { + return whole; } - - /** - * Host messages: the exact text, a known pattern, else sentence by - * sentence. Whatever is not recognized stays as the host wrote it. - */ - function translateHost(language, text, depth) { - if (!TABLES[language] || typeof text !== 'string' || text.length === 0) { - return text; - } - var level = depth || 0; - var whole = translateHostOne(language, text, level); - if (whole !== null) { - return whole; - } - var parts = text.split(/(?<=[.…])\s+(?=[A-Z(“"])/); - if (parts.length < 2) { - return text; - } - return parts - .map(function (part) { - var one = translateHostOne(language, part, level); - return one === null ? part : one; - }) - .join(' '); + var parts = text.split(/(?<=[.…])\s+(?=[A-Z(“"])/); + if (parts.length < 2) { + return text; } + return parts + .map(function (part) { + var one = translateHostOne(language, part, level); + return one === null ? part : one; + }) + .join(' '); +} - window.RAME_I18N = { - has: function (language) { - return Object.prototype.hasOwnProperty.call(TABLES, language); - }, - translate: translate, - translateHost: translateHost, - // Feature modules add their own interface strings; existing ones win. - extend: function (language, entries) { - var table = TABLES[language]; - Object.keys(entries || {}).forEach(function (k) { - if (table && !Object.prototype.hasOwnProperty.call(table.text, k)) table.text[k] = entries[k]; - }); - }, - // For tests: the English keys of a table. - keys: function (language) { - return TABLES[language] ? Object.keys(TABLES[language].text) : []; - }, - }; +window.RAME_I18N = { + has: function (language) { + return Object.prototype.hasOwnProperty.call(TABLES, language); + }, + translate: translate, + translateHost: translateHost, + extend: function (language, entries) { + var table = TABLES[language]; + Object.keys(entries || {}).forEach(function (k) { + if (table && !Object.prototype.hasOwnProperty.call(table.text, k)) table.text[k] = entries[k]; + }); + }, + extendPatterns: function (language, list) { + if (TABLES[language]) TABLES[language].patterns.push.apply(TABLES[language].patterns, list || []); + }, + keys: function (language) { + return TABLES[language] ? Object.keys(TABLES[language].text) : []; + }, +}; })(); diff --git a/media/localServersUx.js b/media/localServersUx.js index fc6e28f..eac5c8f 100644 --- a/media/localServersUx.js +++ b/media/localServersUx.js @@ -6,6 +6,19 @@ 'use strict'; var ES = { + 'Reload in LM Studio with more context': 'Recargar en LM Studio con más contexto', + 'Reload in LM Studio with a {0}-token context': 'Recargar en LM Studio con {0} tokens de contexto', + 'The request does not fit the local model\'s context': 'La petición no cabe en el contexto del modelo local', + 'The model is loaded with too little context for this thread. Nothing was cut. Reload it with more context using the button below, or start a new thread, then send again.': + 'El modelo está cargado con muy poco contexto para este hilo. No se recortó nada. Recárgalo con más contexto con el botón de abajo, o empieza un hilo nuevo, y vuelve a enviar.', + 'The model is loaded with too little context for this thread. Nothing was cut. Load it with a larger context in its server, or start a new thread, then send again.': + 'El modelo está cargado con muy poco contexto para este hilo. No se recortó nada. Cárgalo con más contexto en su servidor, o empieza un hilo nuevo, y vuelve a enviar.', + 'Waiting for your confirmation…': 'Esperando tu confirmación…', + '{0} (in use — the server does not list it now)': '{0} (en uso — el servidor ya no lo lista)', + '{0} (in use — Find models lists the others)': '{0} (en uso — «Buscar modelos» lista los demás)', + 'The local server has no model ready': 'El servidor local no tiene ningún modelo listo', + 'The server answered, but it has no model loaded for this request. Load the model in the server (in LM Studio: Developer → Load model, or lms load), or choose a model it lists in the runtime settings, then send again. A server setup problem is not a reason to run the request with Spark.': + 'El servidor respondió, pero no tiene ningún modelo cargado para esta petición. Carga el modelo en el servidor (en LM Studio: Developer → Load model, o lms load), o elige un modelo de los que lista en los ajustes del runtime, y vuelve a enviar. Un problema de configuración del servidor no es motivo para ejecutar la petición con Spark.', 'Model servers: Ollama, LM Studio, llama-server': 'Servidores de modelos: Ollama, LM Studio, llama-server', 'Use a model server you run yourself. RAME connects to it and never starts or stops it. LM Studio and llama-server can serve Muse Glimmer.': 'Usa un servidor de modelos que ejecutas tú. RAME se conecta a él y nunca lo arranca ni lo para. LM Studio y llama-server pueden servir Muse Glimmer.', @@ -24,24 +37,44 @@ 'My SearXNG': 'Mi SearXNG', 'SearXNG address (JSON format enabled)': 'Dirección de SearXNG (con formato JSON activado)', 'Save web access': 'Guardar acceso web', - 'Default OFF. Agent and Chat, local model only; a Mission has its own web permission, chosen when you authorize it. Each search or page read is shown and waits for your approval in Chat and, except in Workspace full, in Agent. Local and private addresses are refused, and a query or URL with a key or a file path is not sent. Page text is untrusted data, never instructions.': - 'Desactivado por defecto. Agente y Chat, solo con un modelo local; una Misión tiene su propio permiso web, que eliges al autorizarla. Cada búsqueda o lectura se muestra y espera tu aprobación en Chat y, salvo en Workspace full, en el Agente. Se rechazan las direcciones locales y privadas, y no se envía una consulta o URL con una clave o una ruta de fichero. El texto de las páginas es un dato no confiable, nunca instrucciones.', + 'Default OFF. Agent and Chat, with the main model (local or Meta: with Meta, the pages it reads go to Meta as tool results, never into an advisor\'s consultation); a Mission has its own web permission, chosen when you authorize it. Each search or page read is shown and waits for your approval in Chat and in Agent, whatever the profile, unless Autonomy & Permissions below pre-authorizes it. Local and private addresses are refused, and a query or URL with a key or a file path is not sent. Page text is untrusted data, never instructions.': + 'Desactivado por defecto. Agente y Chat, con el modelo principal (local o Meta: con Meta, las páginas que lee llegan a Meta como resultados de herramientas, nunca a una consulta del asesor); una Misión tiene su propio permiso web, que eliges al autorizarla. Cada búsqueda o lectura se muestra y espera tu aprobación en Chat y en el Agente, sea cual sea el perfil, salvo que Autonomía y permisos (más abajo) lo autorice de antemano. Se rechazan las direcciones locales y privadas, y no se envía una consulta o URL con una clave o una ruta de fichero. El texto de las páginas es un dato no confiable, nunca instrucciones.', 'Find models first.': 'Busca primero los modelos.', 'Web access for this Mission': 'Acceso web para esta Misión', 'Allow this Mission to search the web and read pages (read-only)': 'Permitir que esta Misión busque en la web y lea páginas (solo lectura)', 'Searches, at most': 'Búsquedas, como máximo', + 'Test isolation': 'Aislamiento de los tests', + 'not recorded': 'no registrado', + 'no isolation: the command runs with your permissions': 'sin aislamiento: el comando se ejecuta con tus permisos', + 'isolated: writes only in the project, temporary and package-cache folders; network allowed': 'aislado: escribe solo en las carpetas del proyecto, temporal y de cachés de paquetes; con red', + 'isolated and closed: writes only in the project, temporary and package-cache folders; no network': 'aislado y cerrado: escribe solo en las carpetas del proyecto, temporal y de cachés de paquetes; sin red', + 'protected verifier: exact project reads, no ambient files, no TCP': 'verificador protegido: lecturas exactas del proyecto, sin ficheros del entorno, sin TCP', 'Page reads, at most': 'Lecturas de páginas, como máximo', 'Authorized domains for page reads (optional; for example nodejs.org, docs.python.org)': 'Dominios autorizados para leer páginas (opcional; por ejemplo nodejs.org, docs.python.org)', 'nodejs.org, docs.python.org': 'nodejs.org, docs.python.org', - "Default OFF. Local model only. Within this budget an autonomous Mission searches and reads without asking each time; a supervised Mission still asks. Queries leave this computer: plain words only, never code, keys or file paths, and the host counts and records every request. Pages are read only from this Mission's own search results, or only from the domains above if you list some. 15 s and 2 MiB per page. Web access adds no write, terminal, memory or file permission, and web text is never sent to Meta.": - 'Desactivado por defecto. Solo con un modelo local. Dentro de este presupuesto, una Misión autónoma busca y lee sin preguntar cada vez; una Misión supervisada sigue preguntando. Las consultas salen de este ordenador: solo palabras normales, nunca código, claves ni rutas de ficheros, y el host cuenta y registra cada petición. Solo se leen páginas que devolvieron las búsquedas de esta Misión o, si indicas dominios, solo de esos dominios. 15 s y 2 MiB por página. El acceso web no añade permisos de escritura, terminal, memoria ni ficheros, y el texto web nunca se envía a Meta.', + 'Default OFF. Within this budget an autonomous Mission searches and reads without asking each time; a supervised Mission still asks. Queries leave this computer: plain words only, never code, keys or file paths, and the host counts and records every request. Pages are read only from this Mission\'s own search results, or only from the domains above if you list some. 15 s and 2 MiB per page. Web access adds no write, terminal, memory or file permission. Web text is never put in a consultation of the advisor; with Meta as the Mission\'s main model, the pages it reads go to Meta as tool results.': + 'Desactivado por defecto. Dentro de este presupuesto, una Misión autónoma busca y lee sin preguntar cada vez; una Misión supervisada sigue preguntando. Las consultas salen de este ordenador: solo palabras normales, nunca código, claves ni rutas de ficheros, y el host cuenta y registra cada petición. Solo se leen páginas que devolvieron las búsquedas de esta Misión o, si indicas dominios, solo de esos dominios. 15 s y 2 MiB por página. El acceso web no añade permisos de escritura, terminal, memoria ni ficheros. El texto web nunca entra en una consulta del asesor; con Meta como modelo principal de la Misión, las páginas que lee llegan a Meta como resultados de herramientas.', 'Terminal commands': 'Comandos de terminal', 'Agent works only inside the active workspace. Symlink and path escapes are blocked; .git, .env files, credential and key material, secret stores, auth files, and repository control files are protected. Tests run only from an exact derived allowlist with shell:false; arbitrary commands run only through the optional terminal tool (OFF by default), each shown first; allowlisted package scripts still execute with your OS permissions and may access files, network, or shells. Ask and Safe-auto require approval before they run, while Workspace-full may run them without prompting and is high risk. Cloud work needs your existing escalation consent and never switches providers silently. Tamper resistance is not claimed.': 'El Agente trabaja solo dentro del workspace activo. Se bloquean las salidas por enlaces simbólicos y rutas; .git, los ficheros .env, credenciales y claves, almacenes de secretos, ficheros de autenticación y de control del repositorio están protegidos. Las pruebas solo se ejecutan desde una lista exacta derivada, con shell:false; los comandos arbitrarios solo pasan por la herramienta opcional de terminal (desactivada por defecto), que muestra cada uno antes; los scripts permitidos siguen ejecutándose con tus permisos del sistema y pueden acceder a ficheros, red o shells. Preguntar y Auto seguro piden aprobación antes de ejecutarlos; Acceso total puede ejecutarlos sin preguntar y es de alto riesgo. El trabajo en la nube necesita tu consentimiento de escalado y nunca cambia de proveedor en silencio. No se afirma resistencia a manipulaciones.', 'Allow Agent to run terminal commands (bash, sh or PowerShell) in the project folder': 'Permitir que el Agente ejecute comandos de terminal (bash, sh o PowerShell) en la carpeta del proyecto', 'Save terminal access': 'Guardar acceso a terminal', - 'Default OFF. Only with a local model, and never in Mission runs. You see each command exactly before it runs and approve it, in every profile, Workspace full included. The card warns when a line chains commands, runs a script you cannot see, uses sudo or the network, or installs software, but a command line never shows all its effects. A command runs with your permissions: it can change any file, use the network or install software. There is no sandbox. Time and output are limited. Stop signals the command and its normal process group, but a deliberately detached process may survive.': - 'Desactivado por defecto. Solo con un modelo local y nunca en las ejecuciones de una Misión. Ves cada comando exacto antes de que se ejecute y lo apruebas, en todos los perfiles, también en Workspace full. La tarjeta avisa cuando una línea encadena comandos, ejecuta un script que no ves, usa sudo o la red, o instala programas, pero una línea de comandos nunca muestra todos sus efectos. Un comando se ejecuta con tus permisos: puede cambiar cualquier fichero, usar la red o instalar programas. No hay sandbox. El tiempo y la salida están limitados. Detener señala el comando y su grupo normal de procesos, pero un proceso desacoplado deliberadamente puede sobrevivir.', + 'What a command may do (isolation, the same in every autonomy mode)': 'Qué puede hacer un comando (aislamiento, igual en cualquier modo de autonomía)', + 'What local processes may do (isolation, the same in every autonomy mode)': 'Qué pueden hacer los procesos locales (aislamiento, igual en cualquier modo de autonomía)', + 'Project (default): write only in the project, temporary and package-cache folders; network allowed': 'Proyecto (predeterminado): escribir solo en las carpetas del proyecto, temporal y de cachés de paquetes; con red', + 'Closed: like Project, and no network': 'Cerrado: como Proyecto, y sin red', + 'Total: no isolation, a command runs with your permissions': 'Total: sin aislamiento, un comando se ejecuta con tus permisos', + 'Total: no isolation, local processes run with your permissions': 'Total: sin aislamiento, los procesos locales se ejecutan con tus permisos', + 'Default OFF. For the main model, local or Meta (with Meta, a command\'s output goes to Meta), and never in Mission runs. You see each command exactly before it runs and approve it, in every profile, Workspace full included, unless Full Access includes commands. The isolation level also protects allowlisted tests, non-container long jobs and non-administrator installs. Project and Closed fail closed when Linux Landlock cannot be applied; Closed also blocks TCP. Administrator installs remain explicit and open in a VS Code terminal without Landlock. Container jobs keep their own container isolation. Files you can read stay readable, DNS is not blocked, and programs already running keep their rights. Total runs with your permissions. Stop signals the normal process group, but a deliberately detached process may survive.': + 'Desactivado por defecto. Para el modelo principal, local o Meta (con Meta, la salida del comando llega a Meta), y nunca en ejecuciones de Misión. Cada comando se muestra exactamente y se aprueba salvo que Acceso total incluya comandos. El nivel de aislamiento también protege tests permitidos, trabajos largos sin contenedor e instalaciones no administrativas. Proyecto y Cerrado fallan de forma cerrada si no se puede aplicar Landlock; Cerrado bloquea además TCP. Las instalaciones administrativas siguen siendo explícitas y se abren en un terminal de VS Code sin Landlock. Los trabajos en contenedor conservan su propio aislamiento. Los ficheros legibles siguen siéndolo, DNS no se bloquea y los procesos ya iniciados conservan sus permisos. Total se ejecuta con tus permisos. Detener señala el grupo normal de procesos, pero uno desacoplado deliberadamente puede sobrevivir.', + 'Checking whether commands can be isolated on this system…': 'Comprobando si se pueden aislar los comandos en este sistema…', + 'Isolation available on this system (Linux Landlock).': 'Aislamiento disponible en este sistema (Landlock de Linux).', + 'Isolation is not available here: {0}. With Project or Closed, commands are not run; choose Total to run them without isolation.': 'El aislamiento no está disponible aquí: {0}. Con Proyecto o Cerrado los comandos no se ejecutan; elige Total para ejecutarlos sin aislamiento.', + 'Isolation is not available here: {0}. With Project or Closed, protected local processes are not run; choose Total to run them without isolation.': 'El aislamiento no está disponible aquí: {0}. Con Proyecto o Cerrado los procesos locales protegidos no se ejecutan; elige Total para ejecutarlos sin aislamiento.', + 'Total: commands run without isolation, with your permissions.': 'Total: los comandos se ejecutan sin aislamiento, con tus permisos.', + 'Total: local processes run without isolation, with your permissions.': 'Total: los procesos locales se ejecutan sin aislamiento, con tus permisos.', + 'Default OFF. For the main model, local or Meta (with Meta, a command\'s output goes to Meta), and never in Mission runs. You see each command exactly before it runs and approve it, in every profile, Workspace full included, unless Full Access includes commands. The card warns when a line chains commands, runs a script you cannot see, uses sudo or the network, or installs software, but a command line never shows all its effects. Isolation (Linux, Landlock): Project and Closed stop writes outside the project, temporary and package-cache folders, and sudo cannot gain rights; Closed also blocks TCP. Files you can read stay readable (as in Codex\'s sandbox), DNS is not blocked, and programs already running keep their rights. Where the isolation cannot be applied (Windows, macOS, no Landlock or no python3) a command is not run unless you choose Total. With Total a command runs with your permissions: it can change any file, use the network or install software. Time and output are limited. Stop signals the command and its normal process group, but a deliberately detached process may survive.': + 'Desactivado por defecto. Para el modelo principal, local o Meta (con Meta, la salida de un comando llega a Meta), y nunca en las ejecuciones de una Misión. Ves cada comando exacto antes de que se ejecute y lo apruebas, en todos los perfiles, también en Workspace full, salvo que el acceso total incluya los comandos. La tarjeta avisa cuando una línea encadena comandos, ejecuta un script que no ves, usa sudo o la red, o instala programas, pero una línea de comandos nunca muestra todos sus efectos. Aislamiento (Linux, Landlock): Proyecto y Cerrado impiden escribir fuera de las carpetas del proyecto, temporal y de cachés de paquetes, y sudo no puede ganar permisos; Cerrado además bloquea TCP. Los ficheros que puedes leer siguen siendo legibles (como en el sandbox de Codex), el DNS no se bloquea y los programas que ya se están ejecutando conservan sus permisos. Donde no se puede aplicar el aislamiento (Windows, macOS, sin Landlock o sin python3) un comando no se ejecuta salvo que elijas Total. Con Total un comando se ejecuta con tus permisos: puede cambiar cualquier fichero, usar la red o instalar programas. El tiempo y la salida están limitados. Detener señala el comando y su grupo normal de procesos, pero un proceso desacoplado deliberadamente puede sobrevivir.', 'In use: {0} on {1}.': 'En uso: {0} en {1}.', 'No external model server in use.': 'No se usa ningún servidor de modelos externo.', 'tools': 'herramientas', @@ -157,7 +190,18 @@ if (select) { var previous = select.value || servers.model; while (select.firstChild) select.removeChild(select.firstChild); - (scan ? scan.models : []).forEach(function (model) { + var models = scan ? scan.models : []; + // The saved model is always shown, even before "Find models" and when the server no longer lists it + // (an LM Studio name that existed only while the model was loaded). + var saved = servers.model && (!scan || scan.endpoint === servers.endpoint) ? servers.model : ''; + var savedListed = models.some(function (model) { return model.id === saved; }); + if (saved && !savedListed) { + var kept = document.createElement('option'); + kept.value = saved; + kept.textContent = tf(scan ? '{0} (in use — the server does not list it now)' : '{0} (in use — Find models lists the others)', saved); + select.appendChild(kept); + } + models.forEach(function (model) { var option = document.createElement('option'); option.value = model.id; option.textContent = describeModel(model); @@ -165,7 +209,7 @@ }); if (previous) select.value = previous; if (!select.value && select.options.length > 0) select.selectedIndex = 0; - select.disabled = !scan || scan.models.length === 0; + select.disabled = models.length === 0; } var lanRow = el('serverLanRow'); if (lanRow) lanRow.hidden = !(scan && scan.scope === 'private'); @@ -191,6 +235,20 @@ if (searx) searx.disabled = engine ? engine.value !== 'searxng' : false; var shell = el('shellEnabled'); if (shell && document.activeElement !== shell) shell.checked = Boolean(servers.shell && servers.shell.enabled); + var level = el('shellIsolation'); + var isolation = servers.shell && servers.shell.isolation ? servers.shell.isolation : 'project'; + if (level && document.activeElement !== level) level.value = isolation; + var levelNote = el('shellIsolationStatus'); + if (levelNote) { + var problem = servers.shell ? servers.shell.problem : null; + levelNote.textContent = isolation === 'total' + ? t('Total: local processes run without isolation, with your permissions.') + : problem === null || problem === undefined + ? t('Checking whether commands can be isolated on this system…') + : problem === '' + ? t('Isolation available on this system (Linux Landlock).') + : tf('Isolation is not available here: {0}. With Project or Closed, protected local processes are not run; choose Total to run them without isolation.', problem); + } renderIdentity(data); } @@ -289,7 +347,8 @@ post({ type: 'localIdentity', action: 'cancel-hash' }); }); on('shellSave', 'click', function () { - post({ type: 'shellAccessSet', enabled: Boolean(el('shellEnabled').checked) }); + var level = el('shellIsolation'); + post({ type: 'shellAccessSet', enabled: Boolean(el('shellEnabled').checked), isolation: level && ['project', 'closed', 'total'].indexOf(level.value) >= 0 ? level.value : 'project' }); }); on('webSave', 'click', function () { post({ @@ -494,6 +553,13 @@ if (server) server.addEventListener('change', pick('server')); var configure = el('localSourceConfigure'); if (configure) configure.addEventListener('click', function () { openCard('cardRuntime'); }); + var reload = el('localIssueReload'); + if (reload) { + reload.addEventListener('click', function () { + // The host asks for a modal confirmation before LM Studio is touched. + if (h && h.vscode && h.markPending(reload, t('Waiting for your confirmation…'))) h.vscode.postMessage({ type: 'localIssueReload' }); + }); + } var change = el('localSourceChange'); if (change) change.addEventListener('click', function () { openCard('cardModels'); }); var review = el('authorizationPendingReview'); @@ -544,6 +610,8 @@ if (busy.length > 0) settle(data && data.status); renderChoice(data); renderHeader(data); + renderSetupIssue(data); + renderReload(data); // main.js draws the Mission card, the permission chip and the controls after this: adjust them once it has. Promise.resolve().then(function () { renderAuthorization(data); @@ -552,6 +620,32 @@ }); } + // The local server answered that it has no model ready (remoteUx drew the card just before): + // say what to do in that server. Its delegation reason is empty, so no Spark button shows. + function renderSetupIssue(data) { + var issue = data && data.remote && data.remote.localIssue ? data.remote.localIssue : null; + if (!issue || issue.kind !== 'setup') return; + setText(el('local-issue-heading'), t('The local server has no model ready')); + setText(el('localIssueHint'), t('The server answered, but it has no model loaded for this request. Load the model in the server (in LM Studio: Developer → Load model, or lms load), or choose a model it lists in the runtime settings, then send again. A server setup problem is not a reason to run the request with Spark.')); + } + + // LM Studio refused for lack of context: offer to reload the model with the context the host proposes. + function renderReload(data) { + var issue = data && data.remote && data.remote.localIssue ? data.remote.localIssue : null; + var button = el('localIssueReload'); + if (!button) return; + var tokens = issue && typeof issue.reload === 'number' ? issue.reload : 0; + button.hidden = tokens === 0; + if (tokens > 0) setText(button, tf('Reload in LM Studio with a {0}-token context', tokens)); + // A context refusal: the server answered, but the model never worked on the request. + if (issue && issue.context === true) { + setText(el('local-issue-heading'), t('The request does not fit the local model\'s context')); + setText(el('localIssueHint'), t(tokens > 0 + ? 'The model is loaded with too little context for this thread. Nothing was cut. Reload it with more context using the button below, or start a new thread, then send again.' + : 'The model is loaded with too little context for this thread. Nothing was cut. Load it with a larger context in its server, or start a new thread, then send again.')); + } + } + // In a Mission thread the Agent selector does not govern: it shows the Mission's own permission and cannot be changed there. function renderMissionPermission(data) { var live = data && data.missionLive && data.missionLive.threadId === data.activeThreadId ? data.missionLive : null; diff --git a/media/main.css b/media/main.css index 1a703b4..89705c4 100644 --- a/media/main.css +++ b/media/main.css @@ -968,6 +968,24 @@ button.is-failed::after { align-items: flex-start; } +/* Autonomy & Permissions: one option per line, like the local source choice. */ +#autonomySection fieldset { + display: flex; + flex-direction: column; + gap: 8px; +} + +#autonomySection fieldset > label { + display: flex; + gap: 6px; + align-items: flex-start; + line-height: 1.35; +} + +#autonomySection fieldset > label:has(input:disabled) { + opacity: 0.6; +} + #localSourceBanner { margin: 0 0 4px; } @@ -999,3 +1017,11 @@ button.is-failed::after { outline-offset: 2px; border-radius: 4px; } + +/* Air above and below buttons: text never touches a row of buttons or a lone button (user request, 2026-11-11). */ +.row:has(> button:not([hidden])) { margin-block: 10px; } +:where(p, .hint, ul, ol, dl, pre, h3, h4) + button, button + :where(p, .hint, ul, ol, dl, pre) { margin-top: 10px; } + +/* Mission web access: the checkbox and each field label on their own line (the checkbox text ran into "Searches, at most", 2026-11-11). */ +#missionWebRow { display: flex; flex-direction: column; gap: 6px; } +#missionWebRow > label:has(> input[type="checkbox"]) { display: flex; align-items: flex-start; gap: 7px; line-height: 1.4; margin-bottom: 4px; } diff --git a/media/main.js b/media/main.js index 5f67b17..5e099f3 100644 --- a/media/main.js +++ b/media/main.js @@ -267,9 +267,6 @@ cancelled: 'cancelled', blocked: 'blocked', }; - // Mission phases as the view names them. "Completed — acceptance passed" - // only when the host's own acceptance run exited 0; a created plan is - // "Plan ready — awaiting authorization", never "Completed". var MISSION_PHASE_LABELS = { planning: 'Planning', plan_ready: 'Plan ready — awaiting authorization', @@ -1672,11 +1669,9 @@ if (!acc) { return live.grant ? t('Standard Mission: no acceptance check') : ''; } - if (acc.state === 'passed') { - return tf('{0}: passed — {1}', acc.commandId, acc.summary); - } - if (acc.state === 'failed') { - return tf('{0}: failed — {1}', acc.commandId, acc.summary); + var text = { passed: '{0}: passed — {1}', failed: '{0}: failed — {1}', inconclusive: '{0}: inconclusive, not a failure — {1}' }[acc.state]; + if (text) { + return tf(text, acc.commandId, acc.summary); } if (acc.state === 'tampered') { return tf('{0}: its files changed — the Mission is blocked', acc.commandId); @@ -1816,13 +1811,15 @@ if (!report) { return; } - factRow(missionReportFactsEl, t('Tasks done'), report.tasks.join('; ') || t('none')); - factRow(missionReportFactsEl, t('Files changed'), report.files.join(', ') || t('none')); - factRow(missionReportFactsEl, t('Tests and exit codes'), report.tests.join(' | ') || t('none')); - factRow(missionReportFactsEl, t('Frozen acceptance'), report.acceptance || t('none (Standard Mission)')); - factRow(missionReportFactsEl, t('Internal runs and time'), tf('{0} run(s), {1} min', report.internalRuns, report.minutes)); - factRow(missionReportFactsEl, t('Models and providers'), report.models.join('; ')); - factRow(missionReportFactsEl, t('Meta consultations'), report.metaConsultations); + var el = missionReportFactsEl; + factRow(el, t('Tasks done'), report.tasks.join('; ') || t('none')); + factRow(el, t('Files changed'), report.files.join(', ') || t('none')); + factRow(el, t('Tests and exit codes'), report.tests.join(' | ') || t('none')); + factRow(el, t('Test isolation'), (report.isolation || []).map(t).join(' | ') || t('not recorded')); + factRow(el, t('Frozen acceptance'), report.acceptance || t('none (Standard Mission)')); + factRow(el, t('Internal runs and time'), tf('{0} run(s), {1} min', report.internalRuns, report.minutes)); + factRow(el, t('Models and providers'), report.models.join('; ')); + factRow(el, t('Meta consultations'), report.metaConsultations); } // A blocked Mission gets a red card: why, how far it got, what never ran, @@ -2361,8 +2358,8 @@ missionId: live.missionId, revision: live.revision, mode: missionModeEl && missionModeEl.value === 'autonomous-workspace' ? 'autonomous-workspace' : 'supervised', - maxConsultations: Number.isInteger(consults) && consults >= 0 && consults <= 5 ? consults : 0, - advisoryLimits: consults > 0 && window.RAME_REMOTE ? window.RAME_REMOTE.advisoryLimits() : null, + maxConsultations: Number.isInteger(consults) && consults >= -1 && consults <= 1e3 ? consults : 0, + advisoryLimits: consults !== 0 && window.RAME_REMOTE ? window.RAME_REMOTE.advisoryLimits() : null, trustedProject: Boolean(missionTrustedEl && missionTrustedEl.checked), acceptanceCommand: acceptanceCommand, acceptanceFiles: acceptanceFiles, diff --git a/media/remoteUx.js b/media/remoteUx.js index a0ff974..f1bbcaa 100644 --- a/media/remoteUx.js +++ b/media/remoteUx.js @@ -18,13 +18,11 @@ "Advice only: Glimmer keeps the tools. Sent only for a failing allowed test, with your request, the failing check and its exit code, the host's content-free record, and project decisions; never file contents, and never a delegated run.": 'Solo consejo: Glimmer conserva las herramientas. Solo se envía ante una prueba permitida que falla, con tu petición, la comprobación que falla y su código de salida, el registro del host sin contenido y las decisiones del proyecto; nunca contenido de ficheros ni una ejecución delegada.', 'Mode': 'Modo', - 'Advisory: Spark advises, Glimmer acts': 'Consejo: Spark aconseja, Glimmer actúa', 'Delegated: Spark directs this one request': 'Delegado: Spark dirige solo esta petición', 'Working model': 'Modelo que trabaja', 'Consulted model': 'Modelo consultado', 'Model': 'Modelo', 'Tools': 'Herramientas', - 'Glimmer ({0}) proposes every action and RAME Code checks it; Spark gets no tools.': 'Glimmer ({0}) propone cada acción y RAME Code la comprueba; Spark no recibe herramientas.', 'Spark proposes the tools; RAME Code checks each one with your {0} profile. No extra permissions.': 'Spark propone las herramientas; RAME Code comprueba cada una con tu perfil {0}. Sin permisos adicionales.', 'Project': 'Proyecto', 'Failing check': 'Comprobación que falla', @@ -42,7 +40,9 @@ 'Dismissing…': 'Descartando…', 'Starting…': 'Iniciando…', 'Applying…': 'Aplicando…', - 'Why: the local run stopped because {0}.': 'Motivo: la ejecución local se detuvo porque {0}.', + 'Why: the run stopped because {0}.': 'Motivo: la ejecución se detuvo porque {0}.', + 'Advisory: {0} advises, {1} acts': 'Consejo: {0} aconseja, {1} actúa', + '{0} proposes every action and RAME Code checks it; {1} gets no tools.': '{0} propone cada acción y RAME Code la comprueba; {1} no recibe herramientas.', 'Model: Meta {0} | {1} characters | Digest: {2} — one use.': 'Modelo: Meta {0} | {1} caracteres | Digest: {2} — un solo uso.', 'The delegated run with Spark stopped: {0}. It was not retried and did not switch to Glimmer; {1} action(s) confirmed by RAME Code are kept.': 'La ejecución delegada con Spark se detuvo: {0}. No se reintentó ni pasó a Glimmer; se conservan {1} acción(es) confirmadas por RAME Code.', @@ -191,7 +191,6 @@ 'Maximum consultations for this Mission': 'Máximo de consultas para esta Misión', 'Maximum Meta consultations': 'Máximo de consultas a Meta', '0 — off': '0 — desactivado', - 'Advice only: Glimmer keeps every tool. Meta receives no file contents and cannot execute actions.': 'Solo consejo: Glimmer conserva todas las herramientas. Meta no recibe contenido de archivos ni puede ejecutar acciones.', 'Advisory limits': 'Límites del asesoramiento', 'Bytes per request, at most (empty = 12000)': 'Bytes máximos por petición (vacío = 12000)', 'Total bytes sent, at most (empty = 12000 × consultations)': 'Bytes totales máximos enviados (vacío = 12000 × consultas)', @@ -338,7 +337,7 @@ if (box) { box.hidden = consult === null; } - setText(el('consultReason'), consult ? h.tf('Why: the local run stopped because {0}.', consult.reason) : ''); + setText(el('consultReason'), consult ? h.tf('Why: the run stopped because {0}.', consult.reason) : ''); // The payload is shown exactly as it would be sent, never translated. setText(el('consultPayload'), consult ? consult.payload : ''); setText(el('consultMeta'), consult ? h.tf('Model: Meta {0} | {1} characters | Digest: {2} — one use.', consult.model, consult.payload.length, consult.digest.slice(0, 16)) : ''); @@ -351,10 +350,10 @@ } return; } - fact(facts, h.t('Mode'), h.t('Advisory: Spark advises, Glimmer acts')); + fact(facts, h.t('Mode'), h.tf('Advisory: {0} advises, {1} acts', extra.model, extra.localModel)); fact(facts, h.t('Working model'), extra.localModel); fact(facts, h.t('Consulted model'), 'Meta ' + extra.model); - fact(facts, h.t('Tools'), h.tf('Glimmer ({0}) proposes every action and RAME Code checks it; Spark gets no tools.', extra.localModel)); + fact(facts, h.t('Tools'), h.tf('{0} proposes every action and RAME Code checks it; {1} gets no tools.', extra.localModel, extra.model)); fact(facts, h.t('Project'), extra.project); fact(facts, h.t('Failing check'), extra.verifier); fact(facts, h.t('Data sent'), words(extra.dataClasses)); @@ -591,7 +590,7 @@ } if (remote.model) { fact(list, h.t('Remote advice'), h.tf('{0} · {1} of {2} consultation(s) sent · {3} valid answer(s)', 'Meta ' + remote.model, remote.attempts, remote.maxConsultations, remote.validResponses)); - fact(list, h.t('Consultations remaining'), String(Math.max(0, remote.maxConsultations - remote.attempts))); + fact(list, h.t('Consultations remaining'), remote.maxConsultations === '∞' ? '∞' : String(Math.max(0, remote.maxConsultations - remote.attempts))); var limits = remote.limits; if (limits) { fact(list, h.t('Bytes sent'), h.tf('{0} of {1} · {2} remaining · at most {3} per request', limits.sentBytes, limits.maxTotalBytes, Math.max(0, limits.maxTotalBytes - limits.sentBytes), limits.maxRequestBytes)); diff --git a/package-lock.json b/package-lock.json index ea5847f..05d8918 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rame-code", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rame-code", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^22.10.0", diff --git a/package.json b/package.json index 0c5ae3d..abb23dc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "rame-code", "displayName": "RAME Code", "description": "Local-first coding agent powered by Meta Muse with explicit permissions and host-verified evidence.", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "author": "Antoni Caimari Caldés", "publisher": "caimari", @@ -44,6 +44,18 @@ { "command": "rameCode.startMission", "title": "RAME Code: Start Mission" + }, + { + "command": "rameCode.showJobs", + "title": "RAME Code: Long Jobs" + }, + { + "command": "rameCode.installs", + "title": "RAME Code: Installs" + }, + { + "command": "rameCode.localLimits", + "title": "RAME Code: Local Model Time Limits" } ], "viewsContainers": { diff --git a/runtime/rame-isolate.py b/runtime/rame-isolate.py new file mode 100644 index 0000000..23c7038 --- /dev/null +++ b/runtime/rame-isolate.py @@ -0,0 +1,210 @@ +"""RAME Code: run one terminal command under Linux Landlock (shellIsolation.ts). + +usage: + python3 -I rame-isolate.py --probe + python3 -I rame-isolate.py [--no-net] --write DIR [--write DIR ...] -- command args... + python3 -I rame-isolate.py --protect-reads --read-file FILE [--read-tree DIR ...] + [--read-dir DIR ...] [--write DIR ...] [--no-net] -- command args... + +- writes: only inside the --write folders (plus /dev/null, /dev/tty, /dev/zero, /dev/urandom); +- reads and runs programs: everywhere (secrets readable by the user stay readable); +- --protect-reads: deny every read except system runtimes and the exact files/ + trees named by --read-file/--read-tree. --read-dir permits directory listing + and traversal, but not reading the files beneath it; +- --no-net: no TCP connect or bind (Landlock ABI >= 4). In protected-read + verifier mode libseccomp also denies every socket family, including UDP and + Unix sockets, plus cross-process inspection syscalls. +Applied to this process with no_new_privs (sudo and setuid programs cannot gain rights), then exec: +the command and all its children inherit it, and nothing they run can widen it again. +On any failure it exits 125 with a line starting "rame-isolate:" and runs nothing. +""" +import ctypes +import errno +import os +import sys + +SYS_CREATE, SYS_ADD, SYS_RESTRICT = 444, 445, 446 +PR_SET_NO_NEW_PRIVS = 38 +RULE_PATH_BENEATH = 1 +FS_NAMES = ['EXECUTE', 'WRITE_FILE', 'READ_FILE', 'READ_DIR', 'REMOVE_DIR', 'REMOVE_FILE', 'MAKE_CHAR', 'MAKE_DIR', + 'MAKE_REG', 'MAKE_SOCK', 'MAKE_FIFO', 'MAKE_BLOCK', 'MAKE_SYM', 'REFER', 'TRUNCATE', 'IOCTL_DEV'] +FS = {name: 1 << bit for bit, name in enumerate(FS_NAMES)} +NET_BIND_TCP, NET_CONNECT_TCP = 1, 2 + + +def fail(message): + sys.stderr.write(f'rame-isolate: {message}\n') + sys.stderr.flush() + os._exit(125) + + +class RulesetAttr(ctypes.Structure): + _fields_ = [('handled_access_fs', ctypes.c_uint64), ('handled_access_net', ctypes.c_uint64)] + + +class PathBeneath(ctypes.Structure): + _pack_ = 1 + _fields_ = [('allowed_access', ctypes.c_uint64), ('parent_fd', ctypes.c_int32)] + + +def landlock_abi(libc): + if not sys.platform.startswith('linux'): + return 0 + abi = libc.syscall(SYS_CREATE, None, ctypes.c_size_t(0), ctypes.c_uint32(1)) + return abi if abi > 0 else 0 + + +def has_seccomp(): + try: + ctypes.CDLL('libseccomp.so.2', use_errno=True) + return True + except OSError: + return False + + +def close_all_sockets_and_introspection(): + """Deny non-TCP exfiltration and cross-process inspection.""" + try: + seccomp = ctypes.CDLL('libseccomp.so.2', use_errno=True) + except OSError: + fail('libseccomp is required for protected verification') + seccomp.seccomp_init.argtypes = [ctypes.c_uint32] + seccomp.seccomp_init.restype = ctypes.c_void_p + seccomp.seccomp_syscall_resolve_name.argtypes = [ctypes.c_char_p] + seccomp.seccomp_syscall_resolve_name.restype = ctypes.c_int + seccomp.seccomp_rule_add.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_int, ctypes.c_uint] + seccomp.seccomp_load.argtypes = [ctypes.c_void_p] + seccomp.seccomp_release.argtypes = [ctypes.c_void_p] + context = seccomp.seccomp_init(0x7fff0000) # SCMP_ACT_ALLOW + if not context: + fail('seccomp_init failed for protected verification') + try: + deny = 0x00050000 | errno.EPERM # SCMP_ACT_ERRNO(EPERM) + names = ['socket', 'socketpair', 'connect', 'bind', 'listen', 'accept', 'accept4', + 'sendto', 'sendmsg', 'sendmmsg', 'recvfrom', 'recvmsg', 'recvmmsg', 'shutdown', + 'ptrace', 'process_vm_readv', 'process_vm_writev', 'bpf', 'perf_event_open'] + for name in names: + number = seccomp.seccomp_syscall_resolve_name(name.encode()) + if number >= 0 and seccomp.seccomp_rule_add(context, deny, number, 0) != 0: + fail(f'seccomp could not deny {name}') + if seccomp.seccomp_load(context) != 0: + fail('seccomp_load failed for protected verification') + finally: + seccomp.seccomp_release(context) + + +def main(argv): + try: + libc = ctypes.CDLL(None, use_errno=True) + except OSError: + fail('the C library could not be loaded') + abi = landlock_abi(libc) + if argv == ['--probe']: + print(f'landlock-abi={abi} seccomp={1 if has_seccomp() else 0}') + return + if '--' not in argv: + fail('missing -- before the command') + split = argv.index('--') + options, command = argv[:split], argv[split + 1:] + writes, read_files, read_trees, read_dirs, no_net, protect_reads = [], [], [], [], False, False + i = 0 + while i < len(options): + if options[i] == '--write' and i + 1 < len(options): + writes.append(options[i + 1]) + i += 2 + elif options[i] == '--read-file' and i + 1 < len(options): + read_files.append(options[i + 1]) + i += 2 + elif options[i] == '--read-tree' and i + 1 < len(options): + read_trees.append(options[i + 1]) + i += 2 + elif options[i] == '--read-dir' and i + 1 < len(options): + read_dirs.append(options[i + 1]) + i += 2 + elif options[i] == '--protect-reads': + protect_reads = True + i += 1 + elif options[i] == '--no-net': + no_net = True + i += 1 + else: + fail(f'unknown option {options[i]}') + if not command or (not protect_reads and not writes): + fail('a command and either --protect-reads or at least one --write folder are needed') + if abi < 1: + fail('Landlock is not available on this system, so the command was not run') + if no_net and abi < 4: + fail(f'Landlock ABI {abi} cannot block the network, so the command was not run') + known = FS_NAMES[:13] + (['REFER'] if abi >= 2 else []) + (['TRUNCATE'] if abi >= 3 else []) + (['IOCTL_DEV'] if abi >= 5 else []) + handled = sum(FS[name] for name in known) + attr = RulesetAttr(handled, NET_BIND_TCP | NET_CONNECT_TCP if no_net else 0) + size = ctypes.sizeof(attr) if abi >= 4 else 8 + ruleset = libc.syscall(SYS_CREATE, ctypes.byref(attr), ctypes.c_size_t(size), ctypes.c_uint32(0)) + if ruleset < 0: + fail(f'landlock_create_ruleset failed (errno {ctypes.get_errno()})') + file_rights = FS['EXECUTE'] | FS['READ_FILE'] | FS['WRITE_FILE'] | FS['TRUNCATE'] | FS['IOCTL_DEV'] + + def allow(path, rights, required): + try: + fd = os.open(path, os.O_PATH | os.O_CLOEXEC) + except OSError: + if required: + fail(f'cannot open {path}') + return + try: + granted = (rights if os.path.isdir(path) else rights & file_rights) & handled + beneath = PathBeneath(granted, fd) + if libc.syscall(SYS_ADD, ruleset, ctypes.c_int(RULE_PATH_BENEATH), ctypes.byref(beneath), ctypes.c_uint32(0)) != 0: + fail(f'landlock_add_rule failed for {path} (errno {ctypes.get_errno()})') + finally: + os.close(fd) + + reads = FS['EXECUTE'] | FS['READ_FILE'] | FS['READ_DIR'] + writes_only = sum(FS[name] for name in ['WRITE_FILE', 'REMOVE_DIR', 'REMOVE_FILE', 'MAKE_CHAR', 'MAKE_DIR', 'MAKE_REG', 'MAKE_SOCK', 'MAKE_FIFO', 'MAKE_BLOCK', 'MAKE_SYM', 'REFER', 'TRUNCATE'] if name in known) + if protect_reads: + # The interpreter has already loaded this launcher. The command may + # read/execute system runtimes, but no home, workspace, state or temp + # tree is ambiently readable. + for system in ['/usr', '/bin', '/sbin', '/lib', '/lib64', '/etc/ssl']: + allow(system, reads, False) + for system_file in ['/etc/ld.so.cache', '/etc/ld.so.conf', '/etc/localtime', '/etc/nsswitch.conf', '/etc/passwd', '/etc/group', '/etc/hosts', '/etc/resolv.conf']: + allow(system_file, FS['READ_FILE'], False) + for path in read_dirs: + allow(path, FS['READ_DIR'], True) + for path in read_trees: + allow(path, reads, True) + for path in read_files: + allow(path, FS['EXECUTE'] | FS['READ_FILE'], True) + for path in writes: + allow(path, writes_only, True) + else: + allow('/', reads, True) + for index, path in enumerate(writes): + # The first folder (the project) must exist; caches that do not exist are skipped. + allow(path, handled, index == 0) + for path in ['/dev/null', '/dev/tty', '/dev/zero', '/dev/urandom']: + allow(path, FS['READ_FILE'] | FS['WRITE_FILE'] | FS['IOCTL_DEV'], False) + if libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0: + fail('prctl(no_new_privs) failed') + if protect_reads: + close_all_sockets_and_introspection() + if libc.syscall(SYS_RESTRICT, ruleset, ctypes.c_uint32(0)) != 0: + fail(f'landlock_restrict_self failed (errno {ctypes.get_errno()})') + os.close(ruleset) + if protect_reads: + # Do not leak the extension host's credentials or unrelated state to + # evaluated code. These variables describe only this verifier run. + keep = {'PATH', 'LANG', 'LC_ALL', 'TMPDIR', 'TMP', 'TEMP', 'NODE_PATH', 'NODE_OPTIONS', + 'ELECTRON_RUN_AS_NODE', 'RAME_TRACE_FILE', 'RAME_TRACE_ROOT', 'RAME_TRACE_PARENT', + 'RAME_RESULTS_FILE', 'RAME_METRIC_REPEAT', 'RAME_METRIC_REPEATS', 'RAME_JOB'} + environment = {key: value for key, value in os.environ.items() if key in keep} + os.environ.clear() + os.environ.update(environment) + try: + os.execvp(command[0], command) + except OSError as error: + fail(f'cannot run {command[0]}: {error.strerror}') + + +if __name__ == '__main__': + main(sys.argv[1:]) diff --git a/runtime/rame-job-supervisor.cjs b/runtime/rame-job-supervisor.cjs new file mode 100644 index 0000000..ddd753a --- /dev/null +++ b/runtime/rame-job-supervisor.cjs @@ -0,0 +1,239 @@ +'use strict'; +// RAME Code long jobs: the supervisor. The host starts it detached as +// rame-job-supervisor.cjs +// so it outlives the window that started it (a VS Code reload ends the +// extension host, not this process). It runs exactly one approved command and +// leaves, in the job directory (outside the workspace): +// heartbeat the time of its last sign of life, refreshed often +// supervisor.json {pid, childPid, startedAt} once the command started +// log.txt, log.1.txt combined output, rotated so it stays bounded +// exit.json {exitCode, signal, timedOut, cancelled, ...}, written +// atomically and last +// It stops the command only at its approved deadline or when the host writes +// `cancel` (a user decision). It never starts anything a second time. +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { spawn } = require('node:child_process'); + +const [dir, expected] = process.argv.slice(2); +const KILL_GRACE_MS = 2000; +const file = (name) => path.join(dir, name); + +process.on('SIGHUP', () => {}); + +let finished = false; +function finish(record) { + if (finished) { + return; + } + finished = true; + try { + const tmp = file(`exit.json.${process.pid}.tmp`); + fs.writeFileSync(tmp, JSON.stringify({ exitCode: null, signal: null, timedOut: false, cancelled: false, ...record, endedAt: Date.now() })); + fs.renameSync(tmp, file('exit.json')); + } catch { + // The host then sees no result and reports the job as uncertain. + } + process.exit(0); +} + +let raw; +try { + raw = fs.readFileSync(file('spec.json')); +} catch { + process.exit(3); +} +if (typeof expected !== 'string' || crypto.createHash('sha256').update(raw).digest('hex') !== expected) { + finish({ refused: 'the job specification changed after it was approved; nothing ran' }); +} +let spec; +try { + spec = JSON.parse(raw.toString('utf8')); +} catch { + finish({ refused: 'the job specification is not valid JSON; nothing ran' }); +} +const isStrings = (list) => Array.isArray(list) && list.every((part) => typeof part === 'string'); +if ( + typeof spec !== 'object' || spec === null || + typeof spec.executable !== 'string' || !isStrings(spec.argv) || typeof spec.cwd !== 'string' || + !Number.isInteger(spec.timeoutMs) || spec.timeoutMs < 1 || + !Number.isInteger(spec.maxLogBytes) || spec.maxLogBytes < 1024 || + !Number.isInteger(spec.heartbeatMs) || spec.heartbeatMs < 50 || + typeof spec.env !== 'object' || spec.env === null || !Object.values(spec.env).every((value) => typeof value === 'string') +) { + finish({ refused: 'the job specification is incomplete; nothing ran' }); +} + +// Replaced atomically (a temporary file, then a rename): written in place, a +// host reading it between the truncation and the write saw an empty file and +// took a healthy job for one that had stopped reporting. +const beatTmp = file(`heartbeat.${process.pid}.tmp`); +const beatNow = () => { + const now = String(Date.now()); + try { + fs.writeFileSync(beatTmp, now); + fs.renameSync(beatTmp, file('heartbeat')); + } catch { + // Where a rename over a file being read can fail (Windows), in place is + // still better than no heartbeat; the host reads an empty one again. + try { + fs.writeFileSync(file('heartbeat'), now); + } catch { + // A missing heartbeat only makes the host more careful. + } + } +}; +beatNow(); + +// Output: one file up to maxLogBytes, then it becomes log.1.txt and a new one +// starts, so at most two files and the latest output is always kept. +let logFd = fs.openSync(file('log.txt'), 'a'); +let logBytes = 0; +const writeLog = (chunk) => { + try { + const part = chunk.length > spec.maxLogBytes ? chunk.subarray(chunk.length - spec.maxLogBytes) : chunk; + if (logBytes + part.length > spec.maxLogBytes) { + fs.closeSync(logFd); + fs.renameSync(file('log.txt'), file('log.1.txt')); + logFd = fs.openSync(file('log.txt'), 'w'); + logBytes = 0; + } + fs.writeSync(logFd, part); + logBytes += part.length; + } catch { + // Losing output never stops the job. + } +}; + +const cleanup = spec.cleanup && typeof spec.cleanup.executable === 'string' && isStrings(spec.cleanup.argv) ? spec.cleanup : null; +let cleaned = false; +// e.g. remove the job's container: stopping its client does not always stop it. +function runCleanup() { + if (cleanup === null || cleaned) { + return; + } + cleaned = true; + try { + require('node:child_process').spawnSync(cleanup.executable, cleanup.argv, { shell: false, stdio: 'ignore', timeout: 20000, windowsHide: true }); + } catch { + // The record still says what to clean up. + } +} + +const env = { ...process.env, ...spec.env }; +delete env.NODE_TEST_CONTEXT; +if (spec.keepElectronRunAsNode !== true) { + delete env.ELECTRON_RUN_AS_NODE; +} + +let child; +try { + child = spawn(spec.executable, spec.argv, { + cwd: spec.cwd, + env, + shell: false, + detached: process.platform !== 'win32', + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} catch (error) { + finish({ refused: `the command could not start: ${String(error && error.message ? error.message : error).slice(0, 300)}` }); +} + +function killTree(signal) { + if (child.pid === undefined) { + return; + } + if (process.platform === 'win32') { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + const root = /^[A-Za-z]:[\\/]/.test(process.env.SystemRoot || '') ? process.env.SystemRoot : 'C:\\Windows'; + try { + spawn(path.win32.join(root, 'System32', 'taskkill.exe'), ['/PID', String(child.pid), '/T', '/F'], { shell: false, windowsHide: true, stdio: 'ignore' }).on('error', () => {}); + return; + } catch { + // fall back to the direct child + } + } else { + try { + process.kill(-child.pid, signal); + return; + } catch { + // the group is gone + } + } + try { + child.kill(signal); + } catch { + // already exited + } +} + +let stopping = null; +let exited = null; +function stop(reason) { + if (stopping !== null || finished) { + return; + } + stopping = reason; + writeLog(Buffer.from(`\n[RAME Code: ${reason === 'cancelled' ? 'stopped at the user\'s request' : `stopped at its approved limit of ${Math.round(spec.timeoutMs / 1000)} s`}]\n`)); + runCleanup(); + killTree('SIGTERM'); + setTimeout(() => killTree('SIGKILL'), KILL_GRACE_MS); + setTimeout(() => done(), KILL_GRACE_MS + 1000); +} + +function done() { + if (finished) { + return; + } + // Leftover members of the group never outlive the job. + killTree('SIGKILL'); + runCleanup(); + try { + fs.closeSync(logFd); + } catch { + // already closed + } + finish({ + exitCode: exited ? exited.code : null, + signal: exited ? exited.signal : null, + timedOut: stopping === 'timedOut', + cancelled: stopping === 'cancelled', + }); +} + +const deadline = Date.now() + spec.timeoutMs; +child.on('error', (error) => { + if (exited === null) { + finish({ refused: `the command could not start: ${String(error && error.message ? error.message : error).slice(0, 300)}` }); + } +}); +child.on('spawn', () => { + try { + const tmp = file(`supervisor.json.${process.pid}.tmp`); + fs.writeFileSync(tmp, JSON.stringify({ pid: process.pid, childPid: child.pid, startedAt: Date.now() })); + fs.renameSync(tmp, file('supervisor.json')); + } catch { + // The host still sees the heartbeat. + } +}); +child.stdout.on('data', writeLog); +child.stderr.on('data', writeLog); +child.on('exit', (code, signal) => { + exited = { code, signal }; + // A grandchild may keep the pipes open: settle shortly anyway. + setTimeout(done, 500); +}); +child.on('close', () => done()); + +setInterval(() => { + beatNow(); + if (fs.existsSync(file('cancel'))) { + stop('cancelled'); + } else if (Date.now() >= deadline) { + stop('timedOut'); + } +}, spec.heartbeatMs); diff --git a/src/acceptanceManifest.ts b/src/acceptanceManifest.ts index 376bcb0..cca9962 100644 --- a/src/acceptanceManifest.ts +++ b/src/acceptanceManifest.ts @@ -34,7 +34,7 @@ import type { AgentFs } from './agentHost.js'; import { insideFolder, MAX_OBSERVED_PATHS, MAX_SEALED_FOLDERS } from './acceptanceTrace.js'; import { ACCEPTANCE_ENVIRONMENT_FILES } from './projectAdapters/npmProjectAdapter.js'; import { MAX_SNAPSHOT_FILES } from './projectSnapshot.js'; -import { HIDDEN_ANSWERS_MESSAGE } from './toolResultSummary.js'; +import { HIDDEN_ANSWERS_MESSAGE, HIDDEN_REFERENCE_MESSAGE } from './toolResultSummary.js'; export { MAX_SNAPSHOT_FILES as MAX_BASELINE_FILES } from './projectSnapshot.js'; const MAX_LIST = 200; @@ -202,7 +202,7 @@ export function hideFromAgent(fs: AgentFs, hidden: () => readonly string[]): Age writeText: fs.writeText.bind(fs), readText: async (rel: string) => { if (isHidden(rel)) { - throw new Error(HIDDEN_ANSWERS_MESSAGE); + throw new Error(/\.rame-reference\.json$/i.test(rel) ? HIDDEN_REFERENCE_MESSAGE : HIDDEN_ANSWERS_MESSAGE); } return fs.readText(rel); }, diff --git a/src/acceptanceRun.ts b/src/acceptanceRun.ts index 25a60b7..0652531 100644 --- a/src/acceptanceRun.ts +++ b/src/acceptanceRun.ts @@ -35,21 +35,27 @@ import { diffBaseline, judgeChanges, type AcceptanceManifest, type ProjectChange import { runTraced } from './acceptanceScopeHost.js'; import { parseTrace, scopeViolations, TRACE_RUNTIME_DIR, type ParsedTrace } from './acceptanceTrace.js'; import { CASES_FILE, parseCasesSpec, runHostCases } from './hostCases.js'; +import { type FrozenReference, HOST_SPEC_FILE, runDifferential } from './referenceCheck.js'; +import { metricSource, METRIC_RESULTS_ENV, parseResultsFile, type CheckResult } from './metricAcceptance.js'; +import { runMeasuredAcceptance } from './metricRun.js'; import type { MissionRecord } from './missionStore.js'; import type { ProviderHost } from './providerTypes.js'; +import { protectedProjectAccess, protectedVerifierSecret } from './protectedVerifier.js'; +import { currentVerificationMode, type VerificationMode } from './shellIsolation.js'; +import type { CommandIsolation } from './settingsStore.js'; import { changeTimesTrusted, FINGERPRINT_VERSION, foldersTouched, printsOf, snapshotProject, touchedBetween, type Snapshot } from './projectSnapshot.js'; -export interface AcceptanceResult { - passed: boolean; - summary: string; - output: string; - /** The criteria themselves changed. */ - tampered: boolean; - /** RAME cannot verify the run against what the user authorized. */ - unverifiable?: true; - interrupted?: true; - /** Files the user approved as writable in a scope review during this run. */ - approved?: string[]; +/** + * `tampered`: the criteria themselves changed. `unverifiable`: RAME cannot + * verify the run against what the user authorized. `approved`: files the + * user approved as writable in a scope review during this run. + */ +export type AcceptanceResult = CheckResult; + +/** A protected→unprotected transition is evidence, never a reason to stop the same Mission. */ +export function verificationChanged(mission: Pick, mode: VerificationMode): boolean { + const earlier = [...(mission.evidence ?? [])].reverse().find((entry) => /verification: (?:protected|unprotected)/.test(entry.text)); + return mode.kind === 'unprotected' && earlier?.text.includes('verification: protected') === true; } type Frozen = ReadonlyArray<{ path: string; hash: string }>; @@ -63,7 +69,52 @@ export async function runAcceptance(host: ProviderHost, mission: MissionRecord, const controller = new AbortController(); host.acceptanceController = controller; try { - return await runAcceptanceWith(host, mission, root, fs, controller); + const mode = await currentVerificationMode(); + const changed = verificationChanged(mission, mode); + const finish = (result: AcceptanceResult): AcceptanceResult => ({ + ...result, + summary: `${result.summary}; verification: ${mode.kind}`, + verification: { kind: mode.kind, reason: mode.reason, ...(changed ? { changed: true as const } : {}) }, + }); + const acceptance = mission.grant?.acceptance ?? null; + if (acceptance?.metrics === undefined) { + return finish(await runAcceptanceWith(host, mission, root, fs, controller, {}, mode)); + } + // Numeric criteria: every measured run uses the host's explicit verification mode; files the + // user approved in a scope review stay approved for the next run. An + // inconclusive measurement is measured again once, by the host alone. + let current = mission; + const frozen = [...acceptance.files.map((file) => file.path), 'package.json']; + const measured = await runMeasuredAcceptance(acceptance.metrics, root, frozen, async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'rame-metrics-')); + const file = path.join(dir, 'declared.json'); + let run: AcceptanceResult; + try { + run = await runAcceptanceWith(host, current, root, fs, controller, { [METRIC_RESULTS_ENV]: file }, mode); + if (run.passed) { + const declared = parseResultsFile(await readFile(file, 'utf8').catch(() => '')); + const needed = acceptance.metrics?.criteria.filter((criterion) => metricSource(criterion.metric) === 'project-declared') ?? []; + if (needed.length > 0 && !declared.ok) { + return { ...run, passed: false, summary: `project-declared metrics are invalid: ${declared.error}` }; + } + const values = declared.ok ? declared.values : {}; + run = { + ...run, + // Host observations always win a same-named project declaration. + hostMetrics: { ...values, ...(run.hostMetrics ?? {}) }, + metricSources: Object.fromEntries(acceptance.metrics!.criteria.map((criterion) => [criterion.metric, metricSource(criterion.metric)])), + }; + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + if (run.approved && acceptance.scope) { + const scope = { ...acceptance.scope, writable: [...new Set([...acceptance.scope.writable, ...run.approved])] }; + current = { ...current, grant: { ...current.grant!, acceptance: { ...acceptance, scope } } }; + } + return run; + }, { stopped: () => controller.signal.aborted || host.disposed }); + return finish(measured); } finally { if (host.acceptanceController === controller) { host.acceptanceController = null; @@ -86,7 +137,7 @@ function usedByCheck(trace: ParsedTrace | null, frozen: readonly string[], entri return new Set((trace?.edges ?? []).filter((edge) => edge.by === null || mine.has(edge.by)).map((edge) => edge.path)); } -async function runAcceptanceWith(host: ProviderHost, mission: MissionRecord, root: string, fs: AgentFs, controller: AbortController): Promise { +async function runAcceptanceWith(host: ProviderHost, mission: MissionRecord, root: string, fs: AgentFs, controller: AbortController, env: Record = {}, mode: VerificationMode = { kind: 'protected', reason: '' }): Promise { const acceptance = mission.grant?.acceptance ?? null; if (acceptance === null) { return { passed: true, summary: 'no acceptance check', output: '', tampered: false }; @@ -117,7 +168,23 @@ async function runAcceptanceWith(host: ProviderHost, mission: MissionRecord, roo if (controller.signal.aborted || host.disposed) { return stopped(id); } - const run = await runTraced(host, command, root, false, controller); + const hidden = new Set(manifest.hidden); + const access = protectedProjectAccess( + root, + // The implementation and its dependencies may have been created by the + // Mission after authorization. Give the verifier the current recorded + // project view, then let the integrity/scope review below decide whether + // each new path was authorized. Host-only specifications and recognized + // secrets remain absent from the child view. + [...before.files.keys()].filter((file) => !hidden.has(file) && !protectedVerifierSecret(file)), + [...manifest.writable, ...manifest.outputs], + null, + [TRACE_RUNTIME_DIR], + ); + if (!access.ok) { + return unverifiable(`the protected verifier view could not be built: ${access.error}`); + } + const run = await runTraced(host, command, root, false, controller, env, access.access, mode); if (run.interrupted || host.disposed) { // Paused, cancelled or closed while the check ran: not a result. The caller applies the stop. return stopped(id); @@ -151,7 +218,16 @@ async function runAcceptanceWith(host: ProviderHost, mission: MissionRecord, roo if (code !== 0) { return { passed: false, summary: `${id} exit=${code ?? 'unknown'} with every frozen file intact`, output: run.output, tampered: false }; } - const reinforced = await runFrozenCases(acceptance.files, root, fs, controller); + const reinforced = await runFrozenCases( + acceptance.files, + root, + fs, + controller, + acceptance.references ?? [], + host.settings.shell?.isolation ?? 'project', + [...mid.files.keys()].filter((file) => !hidden.has(file) && !protectedVerifierSecret(file)), + mode, + ); if (reinforced.kind === 'interrupted' || host.disposed) { return stopped(id); } @@ -190,8 +266,15 @@ async function runAcceptanceWith(host: ProviderHost, mission: MissionRecord, roo const level = reinforced.kind === 'passed' ? `; reinforced level: ${reinforced.summary}; ${integrity}` - : `; protected level (frozen check exit code; ${integrity}; no host-compared cases)`; - return { passed: true, summary: `${id} exit=0 with every frozen file intact${level}${node}`, output: run.output, tampered: false, ...(approved ? { approved } : {}) }; + : `; ${mode.kind === 'protected' ? 'protected verification' : 'verification without isolation'} (frozen check exit code; ${integrity}; no host-compared cases)`; + return { + passed: true, + summary: `${id} exit=0 with every frozen file intact${level}${node}`, + output: run.output, + tampered: false, + hostMetrics: { wall_ms: run.elapsedMs, exit_code: 0 }, + ...(approved ? { approved } : {}), + }; } /** One host record of the project; on failure, why (or that it was stopped). */ @@ -274,12 +357,20 @@ async function runFrozenCases( root: string, fs: AgentFs, controller: AbortController, + references: readonly FrozenReference[] = [], + isolation: CommandIsolation = 'project', + visibleFiles: readonly string[] = [], + mode: VerificationMode = { kind: 'protected', reason: '' }, ): Promise<{ kind: 'none' | 'passed' | 'failed' | 'tampered' | 'interrupted' | 'unverifiable'; summary: string }> { const specs = files.filter((file) => CASES_FILE.test(file.path)); - if (specs.length === 0) { + if (specs.length === 0 && references.length === 0) { return { kind: 'none', summary: '' }; } let total = 0; + const protectedAccess = protectedProjectAccess(root, visibleFiles, [], null, [TRACE_RUNTIME_DIR]); + if (!protectedAccess.ok) { + return { kind: 'unverifiable', summary: `the protected case view could not be built: ${protectedAccess.error}` }; + } for (const file of specs) { let text: string; try { @@ -300,7 +391,7 @@ async function runFrozenCases( const dir = await mkdtemp(path.join(os.tmpdir(), 'rame-cases-')); const traceFile = path.join(dir, 'trace.jsonl'); try { - const outcome = await runHostCases(root, parsed.spec, { signal: controller.signal, trace: { file: traceFile, root, preload: path.join(TRACE_RUNTIME_DIR, 'acceptance-trace.cjs') } }); + const outcome = await runHostCases(root, parsed.spec, { signal: controller.signal, isolation, protectedAccess: protectedAccess.access, verificationMode: mode, trace: { file: traceFile, root, preload: path.join(TRACE_RUNTIME_DIR, 'acceptance-trace.cjs') } }); if (outcome.interrupted) { return { kind: 'interrupted', summary: '' }; } @@ -308,7 +399,7 @@ async function runFrozenCases( if (trace.processes === 0) { return { kind: 'unverifiable', summary: `RAME could not observe the host-compared cases in ${file.path}` }; } - const leaks = trace.edges.filter((edge) => CASES_FILE.test(edge.path) && edge.kind !== 'entry').map((edge) => edge.path); + const leaks = trace.edges.filter((edge) => HOST_SPEC_FILE.test(edge.path) && edge.kind !== 'entry').map((edge) => edge.path); if (leaks.length > 0) { return { kind: 'unverifiable', summary: `the code under test read the expected values in ${[...new Set(leaks)].join(', ')} while the host-compared cases ran` }; } @@ -316,7 +407,9 @@ async function runFrozenCases( return { kind: 'failed', summary: `the host-compared cases in ${file.path} are incomplete: ${outcome.reason}` }; } if (outcome.status === 'failed') { - const first = outcome.failed.slice(0, 3).map((entry) => `case ${entry.index + 1} (${entry.call}) ${entry.reason}`).join('; '); + // The inputs of the failing cases help the agent find the fault; their expected values are never shown. + const input = (index: number) => (JSON.stringify(parsed.spec.cases[index]?.args ?? []) ?? '').slice(1, -1).slice(0, 120); + const first = outcome.failed.slice(0, 3).map((entry) => `case ${entry.index + 1} (${entry.call}(${input(entry.index)})) ${entry.reason}`).join('; '); return { kind: 'failed', summary: `${outcome.failed.length} of ${outcome.total} host-compared cases in ${file.path} failed: ${first}` }; } total += outcome.total; @@ -324,5 +417,27 @@ async function runFrozenCases( await rm(dir, { recursive: true, force: true }); } } - return { kind: 'passed', summary: `${total} host-compared case(s) passed outside the code under test` }; + // The differential comparison with the frozen original, on fresh inputs every time. + const differential: string[] = []; + for (const reference of references) { + const frozenSpec = files.find((file) => file.path === reference.spec); + let text: string; + try { + text = await fs.readText(reference.spec); + } catch { + return { kind: 'tampered', summary: `${reference.spec} could not be read` }; + } + if (frozenSpec === undefined || hashContent(text) !== frozenSpec.hash) { + return { kind: 'tampered', summary: `${reference.spec} changed since the Mission was authorized` }; + } + const outcome = await runDifferential(root, reference, text, { signal: controller.signal, isolation, protectedAccess: protectedAccess.access, verificationMode: mode }); + if (outcome.kind === 'interrupted') { + return { kind: 'interrupted', summary: '' }; + } + if (outcome.kind !== 'passed') { + return { kind: outcome.kind, summary: outcome.summary }; + } + differential.push(outcome.summary); + } + return { kind: 'passed', summary: [...(specs.length > 0 ? [`${total} host-compared case(s) passed outside the code under test`] : []), ...differential].join('; ') }; } diff --git a/src/acceptanceScopeHost.ts b/src/acceptanceScopeHost.ts index a904eed..4470cd5 100644 --- a/src/acceptanceScopeHost.ts +++ b/src/acceptanceScopeHost.ts @@ -12,10 +12,13 @@ import { currentFileHash } from './agentController.js'; import type { AgentFs } from './agentHost.js'; import { planAcceptanceDependencies } from './acceptanceDependencies.js'; import { MAX_BASELINE_FILES, type AcceptanceManifest } from './acceptanceManifest.js'; -import { acceptanceTraceEnv, classifyAcceptanceScope, parseTrace, type ParsedTrace } from './acceptanceTrace.js'; +import { acceptanceTraceEnv, classifyAcceptanceScope, parseTrace, TRACE_RUNTIME_DIR, type ParsedTrace } from './acceptanceTrace.js'; import { CASES_FILE } from './hostCases.js'; +import { REFERENCE_FILE } from './referenceCheck.js'; import { FINGERPRINT_VERSION, printsOf, snapshotProject } from './projectSnapshot.js'; import type { ProviderHost } from './providerTypes.js'; +import { protectedProjectAccess, protectedVerifierSecret } from './protectedVerifier.js'; +import { currentVerificationMode, UNPROTECTED_VERIFICATION_WARNING, verificationLabel, type ProtectedInvocationAccess, type VerificationMode } from './shellIsolation.js'; /** The grant schema's ceiling on frozen files (listed, environment and verifier support). */ export const MAX_FROZEN_ACCEPTANCE_FILES = 20; @@ -30,7 +33,11 @@ export async function runTraced( root: string, observeOnly = false, given?: AbortController, -): Promise<{ output: string; trace: ParsedTrace | null; interrupted: boolean }> { + /** Extra variables for this run only (for example the private trace file). */ + extraEnv: Record = {}, + protectedAccess?: ProtectedInvocationAccess, + verificationMode?: VerificationMode, +): Promise<{ output: string; trace: ParsedTrace | null; interrupted: boolean; elapsedMs: number }> { // Registered before any await: a Stop can never fall between two steps. const controller = given ?? new AbortController(); if (given === undefined) { @@ -40,14 +47,19 @@ export async function runTraced( const file = path.join(dir, 'trace.jsonl'); try { if (controller.signal.aborted) { - return { output: '', trace: null, interrupted: true }; + return { output: '', trace: null, interrupted: true, elapsedMs: 0 }; } let output: string; + const started = process.hrtime.bigint(); try { - output = await host.runAllowlistedTest(command, controller.signal, root, acceptanceTraceEnv(file, root, process.env.NODE_OPTIONS ?? '', undefined, observeOnly)); + const resultWrites = typeof extraEnv.RAME_RESULTS_FILE === 'string' && path.isAbsolute(extraEnv.RAME_RESULTS_FILE) ? [path.dirname(extraEnv.RAME_RESULTS_FILE)] : []; + const confined = protectedAccess === undefined + ? undefined + : { ...protectedAccess, readTrees: [...(protectedAccess.readTrees ?? []), dir], writeTrees: [...(protectedAccess.writeTrees ?? []), dir, ...resultWrites] }; + output = await host.runAllowlistedTest(command, controller.signal, root, { ...extraEnv, ...acceptanceTraceEnv(file, root, process.env.NODE_OPTIONS ?? '', undefined, observeOnly), TMPDIR: dir, TMP: dir, TEMP: dir }, confined, verificationMode); } catch (error) { if (controller.signal.aborted) { - return { output: '', trace: null, interrupted: true }; + return { output: '', trace: null, interrupted: true, elapsedMs: 0 }; } throw error; } @@ -57,7 +69,7 @@ export async function runTraced( } catch { // no trace written: the run could not be observed } - return { output, trace: text.length > 0 ? parseTrace(text) : null, interrupted: false }; + return { output, trace: text.length > 0 ? parseTrace(text) : null, interrupted: false, elapsedMs: Number(process.hrtime.bigint() - started) / 1_000_000 }; } finally { if (given === undefined && host.acceptanceController === controller) { host.acceptanceController = null; @@ -125,7 +137,8 @@ export async function protectAcceptanceScope( const controller = new AbortController(); host.acceptanceController = controller; try { - return await protectWith(host, fs, root, command, commandId, listed, listedPaths, projectName, controller); + const mode = await currentVerificationMode(); + return await protectWith(host, fs, root, command, commandId, listed, listedPaths, projectName, controller, mode); } finally { if (host.acceptanceController === controller) { host.acceptanceController = null; @@ -143,12 +156,25 @@ async function protectWith( listedPaths: string[], projectName: string, controller: AbortController, + mode: VerificationMode, ): Promise { const plan = await planAcceptanceDependencies(fs, listedPaths, projectName); host.status = `Observing what the acceptance check ${commandId} loads and reads…`; await host.postState(); // Observation only: missing code under test is stubbed so the check runs further. - const run = await runTraced(host, command, root, true, controller); + const beforeObservation = await snapshotProject(root, { signal: controller.signal }); + if (!beforeObservation.ok) { + return { ok: false, error: 'RAME could not build a read-confined view of the project. Nothing was authorized.' }; + } + if (beforeObservation.unreadable.length > 0) { + return { ok: false, error: `RAME cannot read ${beforeObservation.unreadable.slice(0, 3).join(', ')} to record it, so the check cannot be protected. Nothing was authorized.` }; + } + const hidden = new Set(listedPaths.filter((file) => CASES_FILE.test(file) || REFERENCE_FILE.test(file))); + const access = protectedProjectAccess(root, [...beforeObservation.files.keys()].filter((file) => !hidden.has(file) && !protectedVerifierSecret(file)), [], null, [TRACE_RUNTIME_DIR]); + if (!access.ok) { + return { ok: false, error: `The acceptance check cannot be read-confined: ${access.error}. Nothing was authorized.` }; + } + const run = await runTraced(host, command, root, true, controller, {}, access.access, mode); if (run.interrupted || host.disposed) { return { ok: false, error: 'The acceptance check was stopped while RAME observed it. Nothing was authorized.' }; } @@ -224,7 +250,7 @@ async function protectWith( sealed: scope.sealed, observed: scope.observed, entries: scope.entries, - hidden: listedPaths.filter((file) => CASES_FILE.test(file)), + hidden: listedPaths.filter((file) => CASES_FILE.test(file) || REFERENCE_FILE.test(file)), baseline: [...baseline.entries()], node: nodeVersions.join(', ').slice(0, 80), approvals: 0, @@ -234,11 +260,12 @@ async function protectWith( const exit = /^exit=(\d+)/.exec(run.output); const list = (files: string[]) => (files.length > 0 ? files.slice(0, 6).join(', ') : 'none'); const notes = [ - `acceptance ${commandId} protected: also froze ${list(freeze.map((file) => file.path))}; ` + + `acceptance ${commandId} ${mode.kind === 'protected' ? 'protected' : 'without protected isolation'}: also froze ${list(freeze.map((file) => file.path))}; ` + `writable code under test, confirmed by the user: ${list(scope.writable)}; outputs: ${list(scope.outputs)}; ` + (stubbed ? 'before any change: no result (the code under test did not exist yet)' : `before any change it exited ${exit ? exit[1] : 'unknown'}`), - `acceptance scope recorded by the host: sealed folders ${list(scope.sealed)}; ${scope.entries.length} process entry point(s); ` + + `acceptance scope recorded by the host; ${verificationLabel(mode)}: sealed folders ${list(scope.sealed)}; ${scope.entries.length} process entry point(s); ` + `${baseline.size} project file(s) fingerprinted (SHA-256 of their bytes); observed on Node ${manifest.node || 'unknown'}`, + ...(mode.kind === 'unprotected' ? [UNPROTECTED_VERIFICATION_WARNING] : []), ]; return { ok: true, freeze, manifest, notes: notes.map((text) => text.slice(0, 300)) }; } diff --git a/src/acceptanceTrace.ts b/src/acceptanceTrace.ts index 8e6b46c..d40e332 100644 --- a/src/acceptanceTrace.ts +++ b/src/acceptanceTrace.ts @@ -291,7 +291,8 @@ export function insideFolder(rel: string, folder: string): boolean { return target === dir || target.startsWith(`${dir}/`); } -const CASES_PATH = /\.rame-cases\.json$/i; +// Host-side acceptance descriptions: cases (expected values) and references (how inputs are generated). +const CASES_PATH = /\.rame-(?:cases|reference)\.json$/i; /** * At run time, what the trace shows outside the authorized scope: diff --git a/src/advisorHost.ts b/src/advisorHost.ts new file mode 100644 index 0000000..b34d151 --- /dev/null +++ b/src/advisorHost.ts @@ -0,0 +1,55 @@ +/** + * The advisor model on the host side: which model a consultation may go to + * for the main model that is working now (advisorModels.ts). + * + * Every consultation path asks here (Agent's advice, a blocked Mission's, + * the Mission's automatic consultations and its acceptance review), so none + * of them can send to the main model itself or to a model Meta does not + * document as more capable. Permissions, budgets and data controls stay + * where they were: this only says *which* model, never *whether*. + */ + +import { advisorRefusal, type MainModel } from './advisorModels.js'; +import type { ProviderHost } from './providerTypes.js'; +import type { ProviderId } from './settingsStore.js'; + +/** The model doing the work for `provider` (the selected one when omitted). */ +export function mainModelOf(host: ProviderHost, provider: ProviderId = host.settings.provider): MainModel { + return { provider, model: host.generationModel(provider) }; +} + +/** A Mission's main model, as its authorization bound it (the selected one before it was authorized). */ +export function missionMain(host: ProviderHost, missionId: string): MainModel { + const grant = host.missions.get(missionId)?.grant ?? null; + return grant !== null ? { provider: grant.provider, model: grant.model } : mainModelOf(host); +} + +/** The Meta model ids RAME knows are offered now (live catalog, or the fallback one). */ +export function catalogIds(host: ProviderHost): string[] { + return host.cloudCatalog.map((entry) => entry.id); +} + +/** The advisor for this main model, or why there is none. */ +export function advisorFor(host: ProviderHost, main: MainModel = mainModelOf(host)): { ok: true; model: string } | { ok: false; reason: string } { + const advisor = host.settings.advisorModel ?? ''; + const refusal = advisorRefusal(main, advisor, catalogIds(host)); + return refusal === null ? { ok: true, model: advisor.trim() } : { ok: false, reason: refusal }; +} + +/** + * The advisor model a Meta pre-authorization covers now ('' = none). A + * partial host without a catalog (unit tests of the policy alone) keeps + * the selected cloud model, as before advisors existed. + */ +export function advisorModelNow(host: ProviderHost): string { + if (typeof host.generationModel !== 'function' || !Array.isArray(host.cloudCatalog) || host.settings === undefined) { + return typeof host.selectedCloudModel === 'function' ? host.selectedCloudModel() : ''; + } + const advisor = advisorFor(host); + return advisor.ok ? advisor.model : ''; +} + +/** A short name for the main model in statuses ("Glimmer", or the model id). */ +export function mainModelName(main: MainModel): string { + return main.provider === 'local' ? `the local model ${main.model}` : `Meta ${main.model}`; +} diff --git a/src/advisorHtml.ts b/src/advisorHtml.ts new file mode 100644 index 0000000..a3f99d8 --- /dev/null +++ b/src/advisorHtml.ts @@ -0,0 +1,25 @@ +/** + * Settings → Security: the advisor model (beside Escalation) and the + * isolation level shared by commands, tests, long jobs and installs. Rendered and wired by + * media/advisorUx.js (advisor) and media/localServersUx.js (isolation); + * text only, every value comes from the host. + */ + +export const advisorHtml = ` +
+

Advisor model

+

+ + +

+

The main model (chosen in Models) does the work and keeps every tool. The advisor only answers in text: it never gets tools and never takes over. Only a Meta model that Meta's documentation describes as more capable than the main model can be chosen, never the main model itself; when there is none, the main model works without consultations. Every consultation keeps its card, pre-authorization or Full Access, its budget and its data limits.

+
`; + +export const isolationHtml = ` + + +

`; diff --git a/src/advisorModels.ts b/src/advisorModels.ts new file mode 100644 index 0000000..5c80d17 --- /dev/null +++ b/src/advisorModels.ts @@ -0,0 +1,118 @@ +/** + * The advisor model: the model the main model may consult when it is stuck. + * + * Free of the `vscode` API so it can be unit tested with plain `node:test`. + * + * Two roles, chosen apart: + * - the main model (Glimmer or another local model, or a Meta Model API + * model) does the work: it calls RAME's tools and directs the Mission; + * - the advisor, a Meta Model API model, only answers a consultation in + * text. It never gets tools and never takes over. + * + * A model advises only when Meta's documentation describes it as more + * capable than the main model. RAME never ranks by name or by "it is in the + * cloud", never consults the main model itself as if it were an escalation, + * and never invents a model: an id the catalog does not list, or whose + * capability Meta does not document, is not an advisor. + */ + +import { isCompatibleModelId, isContributorModelId } from './metaCatalog.js'; + +/** Where the capability facts below come from. */ +export const ADVISOR_FACTS_SOURCE = 'Meta Model API documentation (dev.meta.ai/docs/models, muse-spark, muse-wonder), read on 2026-11-08'; + +/** + * Capability rank documented by Meta for each Model API chat model (higher is + * more capable). Equal ranks are not ordered: Meta does not rank Spark 1.3, + * 1.2 and 1.1 against each other, so none of them advises another. + */ +export const DOCUMENTED_CAPABILITY: Readonly> = { + 'muse-wonder-1.0': { rank: 3, note: "Meta's most capable model (preview)" }, + 'muse-spark-1.3': { rank: 2, note: 'Spark, tuned for agentic work; recommended by Meta' }, + 'muse-spark-1.2': { rank: 2, note: 'Spark, previous version' }, + 'muse-spark-1.1': { rank: 2, note: 'Spark, original version' }, +}; + +/** + * A model running on this computer or a server of the user's (Glimmer, which + * Meta distills from Spark, or any other local model). Every documented Meta + * API model counts as more capable than it. + */ +export const LOCAL_RANK = 1; + +export interface MainModel { + provider: 'local' | 'cloud'; + model: string; +} + +export interface AdvisorOption { + id: string; + /** Whether it may advise this main model. */ + eligible: boolean; + /** Why not, when it may not; what Meta says about it, when it may. */ + reason: string; +} + +function rankOf(main: MainModel): number | null { + return main.provider === 'local' ? LOCAL_RANK : (DOCUMENTED_CAPABILITY[main.model]?.rank ?? null); +} + +/** + * Why `advisor` cannot advise `main` (null when it can). `catalog` is the + * list of Meta models RAME knows are offered (the live catalog, or the + * fallback one before a refresh). + */ +export function advisorRefusal(main: MainModel, advisor: string, catalog: readonly string[]): string | null { + const id: string = advisor.trim(); + if (id.length === 0) { + return 'no advisor model is chosen in Settings → Security → Escalation'; + } + if (!isCompatibleModelId(advisor.trim())) { + return `${id.slice(0, 80)} is not a Meta chat model`; + } + if (isContributorModelId(id)) { + return `${id} is a Contributor variant (Meta may use the data), never an advisor`; + } + if (!catalog.includes(id)) { + return `${id} is not in the Meta API catalog RAME loaded`; + } + if (main.provider === 'cloud' && main.model === id) { + return `${id} is the main model itself: consulting it is not an escalation`; + } + const documented = DOCUMENTED_CAPABILITY[id]; + if (documented === undefined) { + return `Meta's documentation, as RAME knows it, does not describe what ${id} can do`; + } + const mainRank = rankOf(main); + if (mainRank === null) { + return `Meta's documentation, as RAME knows it, does not rank the main model ${main.model.slice(0, 80)}, so no model is known to be more capable`; + } + if (documented.rank <= mainRank) { + return `${id} is not documented as more capable than the main model ${main.model.slice(0, 80)}`; + } + return null; +} + +/** Every catalog model, with whether it may advise this main model and why. */ +export function advisorOptions(main: MainModel, catalog: readonly string[]): AdvisorOption[] { + return catalog.slice(0, 100).map((id) => { + const refusal = advisorRefusal(main, id, catalog); + return { id, eligible: refusal === null, reason: refusal ?? DOCUMENTED_CAPABILITY[id]?.note ?? '' }; + }); +} + +/** The advisor of a new installation: Spark, as escalation always was (it cannot advise a Spark main model; Wonder is chosen by the user). */ +export const DEFAULT_ADVISOR = 'muse-spark-1.3'; + +/** The advisor stored before this setting existed: escalation went to the selected cloud model. */ +export function legacyAdvisor(cloudModel: string): string { + return cloudModel; +} + +/** A stored advisor id: '' (none) or a bounded Meta chat model id. */ +export function parseAdvisorModel(value: unknown): string | null { + if (value === '') { + return ''; + } + return typeof value === 'string' && isCompatibleModelId(value) ? value.trim() : null; +} diff --git a/src/advisorRequest.ts b/src/advisorRequest.ts new file mode 100644 index 0000000..7efc817 --- /dev/null +++ b/src/advisorRequest.ts @@ -0,0 +1,77 @@ +/** + * What an advisor consultation carries beyond the host's notes, and how the + * advisor model itself is asked (P5h, the user's decisions of 08-11-2026). + * + * Free of the `vscode` API so it can be unit tested with plain `node:test`. + * + * 1. The end of the failing check's output, so the advisor can see what + * failed. It is cut to its last lines, secrets are removed and the user's + * folders hidden; when anything that looks like a secret or a private path + * remains, or would have to be removed, nothing of it is sent. It goes on + * the consultation card as part of the exact text the user approves, and + * without a card only under a Meta pre-authorization whose data scope + * includes fragments (never with "instructions only"). + * 2. The advisor's own generation settings: its documented profile and the + * reasoning level stored for that model, never the main model's, with its + * own output cap. + */ + +import { redactSecrets, scrubLocalPaths } from './cloudEgress.js'; +import { automaticOutputTokens, cloudGenerationProfile, resolveReasoning } from './generationSettings.js'; +import type { GenerationSettings } from './generationSettings.js'; +import type { RunGeneration } from './providerTypes.js'; + +/** At most this much of the failing check's output, from its end. */ +export const MAX_EXCERPT_LINES = 30; +export const MAX_EXCERPT_CHARS = 1500; +/** The advisor answers in text: this caps every consultation's output, whatever the main model's settings. */ +export const ADVISOR_MAX_OUTPUT_TOKENS = 2048; + +/** Private paths that must never leave once the known folders are hidden. */ +const PRIVATE_PATH = /(?:\/home\/[^/\s<>]+|\/Users\/[^/\s<>]+|\/root(?=[/\s]|$)|[A-Za-z]:\\Users\\[^\\\s<>]+)/; + +export type Excerpt = { ok: true; text: string } | { ok: false; reason: string }; + +/** + * The end of a failing check's output, safe to show the advisor, or why + * nothing of it is sent. `folders` are hidden first (the project as + * ``, the home folder as `~`). + */ +export function safeCheckExcerpt(output: string, folders: Array<[string, string]>, apiKey = ''): Excerpt { + const tail = output.split(/\r?\n/).slice(-MAX_EXCERPT_LINES).join('\n').slice(-MAX_EXCERPT_CHARS).trim(); + if (tail.length === 0) { + return { ok: false, reason: 'the check printed nothing' }; + } + if (apiKey.length > 0 && tail.includes(apiKey)) { + return { ok: false, reason: 'it contained the API key' }; + } + const secrets = redactSecrets(tail); + if (secrets.found.length > 0) { + // Removing a secret would change what failed: when one is there, nothing of the output goes. + return { ok: false, reason: 'it looked like it contained a secret' }; + } + const scrubbed = scrubLocalPaths(tail, folders); + if (PRIVATE_PATH.test(scrubbed)) { + return { ok: false, reason: 'it named a private folder that could not be hidden' }; + } + return { ok: true, text: scrubbed }; +} + +/** + * How the advisor model is asked: its own documented profile and the + * reasoning level stored for it (Settings, per model), the manual output + * maximum of Meta requests if lower, and never more than the advisor's cap. + */ +export function advisorGeneration(settings: GenerationSettings, model: string): RunGeneration { + const profile = cloudGenerationProfile(model); + const reasoning = resolveReasoning(settings, 'cloud', model, profile); + const manual = settings.maxOutputTokens.cloud; + return { + provider: 'cloud', + model, + profile: { ...profile, autoOutputTokens: Math.min(ADVISOR_MAX_OUTPUT_TOKENS, automaticOutputTokens('cloud', reasoning.unavailable ? 'default' : reasoning.stored)) }, + reasoning: reasoning.level, + manualMax: manual === null ? ADVISOR_MAX_OUTPUT_TOKENS : Math.min(manual, ADVISOR_MAX_OUTPUT_TOKENS), + notes: reasoning.unavailable ? [`The reasoning level stored for ${model} is not available for it; the consultation uses its default.`] : [], + }; +} diff --git a/src/agentAudit.ts b/src/agentAudit.ts index 5bfadc3..5c8c54b 100644 --- a/src/agentAudit.ts +++ b/src/agentAudit.ts @@ -11,8 +11,8 @@ import { MAX_AUDIT_ENTRIES } from './agentPolicy.js'; import type { AgentProfileId } from './agentProfiles.js'; -/** mission-grant: allowed by the Mission's own authorization (not by the Agent profile). */ -export type AuditDecision = 'human' | 'profile-auto' | 'mission-grant' | 'rejected' | 'policy'; +/** mission-grant: allowed by the Mission's own authorization (not by the Agent profile); full-access: by Full Access (Autonomy & Permissions). */ +export type AuditDecision = 'human' | 'profile-auto' | 'mission-grant' | 'full-access' | 'rejected' | 'policy'; export interface AuditEntry { seq: number; @@ -111,7 +111,7 @@ export class AgentAuditLog { typeof record.tool !== 'string' || typeof record.digest !== 'string' || !/^[0-9a-f]{64}$/.test(record.digest) || - (record.decision !== 'human' && record.decision !== 'profile-auto' && record.decision !== 'mission-grant' && record.decision !== 'rejected' && record.decision !== 'policy') || + !['human', 'profile-auto', 'mission-grant', 'full-access', 'rejected', 'policy'].includes(record.decision as string) || typeof record.result !== 'string' || typeof record.seq !== 'number' ) { diff --git a/src/agentController.ts b/src/agentController.ts index 00038c1..0314d12 100644 --- a/src/agentController.ts +++ b/src/agentController.ts @@ -70,6 +70,7 @@ import { REPLACE_NOT_UNIQUE_MESSAGE, } from './toolResultSummary.js'; import { validateChosenWorkspace } from './workspaceSelect.js'; +import { isFullApproved } from './autonomyHost.js'; // ---- Local constants ---- @@ -227,7 +228,8 @@ export function recordAgentAudit( profile: host.settings.agentProfile, tool: pending.tool, digest: pending.digest, - decision, + // Let through by Full Access, not by the profile (see autoAuditRecord). + decision: decision === 'profile-auto' && pending.actionId.startsWith('full_') ? 'full-access' : decision, result, }); void host.extensionState.update(RAME_AUDIT_KEY, host.audit.list()); @@ -443,7 +445,7 @@ export function autoAuditRecord( workspaceId, threadId, preconditions: described.preconditions, - actionId: `auto_${digest.slice(0, 16)}`, + actionId: `${isFullApproved(call) ? 'full' : 'auto'}_${digest.slice(0, 16)}`, digest, createdAt: Date.now(), generation: host.agentGeneration, diff --git a/src/agentEngine.ts b/src/agentEngine.ts index 23f32cb..7ebb249 100644 --- a/src/agentEngine.ts +++ b/src/agentEngine.ts @@ -47,7 +47,8 @@ import { hashContent } from './agentPatch.js'; import { checkAgentRelPath, MAX_AGENT_STEPS } from './agentPolicy.js'; import { agentToolFs, frozenTarget } from './missionToolGuards.js'; import { externalToolsRefusal } from './localSourceState.js'; -import { executeExtraTool, extraToolDefinitions } from './agentExtraTools.js'; +import { limitsForModel } from './localLimits.js'; +import { boundCard, boundRefusal, confirmBound, executeBoundTool, executeExtraTool, extraToolAuto, extraToolDefinitions, isBoundTool } from './agentExtraTools.js'; import { DEFAULT_AGENT_PROFILE, isAutoAllowed, @@ -64,7 +65,9 @@ import { } from './agentTools.js'; import { applyPatchViaFs, + autoAuditRecord, checkReplacement, + recordAgentAudit, commitModelProposal, describeMemoryProposal, } from './agentController.js'; @@ -101,7 +104,6 @@ import { MEMORY_RULES_TEXT, } from './projectMemory.js'; import type { ProviderId } from './settingsStore.js'; -import { RAME_AUDIT_KEY } from './settingsStore.js'; import type { EvidenceApproval } from './modelEvidence.js'; import { beginRun, markFinal, recordAgentAction, recordAgentStep } from './modelEvidenceRecorder.js'; import { @@ -154,32 +156,6 @@ function historyText(text: string): string { ); } -function recordAgentAudit( - host: ProviderHost, - pending: PendingAgentAction, - decision: 'human' | 'profile-auto' | 'mission-grant' | 'rejected' | 'policy', - result: string, -): void { - const entry = host.audit.append({ - provider: pending.provider, - threadId: pending.threadId, - profile: host.settings.agentProfile, - tool: pending.tool, - digest: pending.digest, - decision, - result, - }); - void host.extensionState.update(RAME_AUDIT_KEY, host.audit.list()); - host.agentLogView = [ - ...host.agentLogView, - { - tool: entry.tool, - digest: entry.digest.slice(0, 16), - decision: entry.decision, - result: entry.result, - }, - ].slice(-20); -} function failAgentWithAudit( host: ProviderHost, call: ValidatedToolCall, provider: string, @@ -206,31 +182,6 @@ function recordBlockedProposal( recordAgentAudit(host, { ...desc, actionId: `blocked_${digest.slice(0, 16)}`, digest, createdAt: Date.now(), generation: host.agentGeneration, used: true }, 'policy', reason); } -function autoAuditRecord( - host: ProviderHost, - call: ValidatedToolCall, - described: { targets: string[]; body: string; preconditions: Record }, - provider: string, - workspaceId: string, - threadId: string, - digest: string, -): PendingAgentAction { - return { - tool: call.tool, - targets: described.targets, - body: described.body, - provider, - workspaceId, - threadId, - preconditions: described.preconditions, - actionId: `auto_${digest.slice(0, 16)}`, - digest, - createdAt: Date.now(), - generation: host.agentGeneration, - used: true, - }; -} - async function checkWorkspaceStillHeld( host: ProviderHost, expectedRoot: string, @@ -374,6 +325,12 @@ async function executeAgentTool( case 'web_fetch': case 'run_command': return executeExtraTool(host, call.tool, call.args as Record, provider, workspaceId, signal); + case 'start_job': + case 'job_status': + case 'install_package': + case 'check_installed': + case 'read_original': + return executeBoundTool(host, call, workspaceId, host.settings.agentProfile, signal); case 'create_directory': return executeCreateDirectory(fs, call, canonicalRoot ?? null); case 'move_file': @@ -738,6 +695,8 @@ export async function sendAgent( let lowBudgetNoted = false; const changedFiles = new Set(); const testRuns: Array<{ commandId: string; exitCode: number | null; writes: number; at: number; outputDigest: string }> = []; + // The end of the last failing test's output, for an advisor consultation (made safe there, never sent raw). + const failing: { last: { commandId: string; text: string } | null } = { last: null }; const memoryProposals = new Map(); const hostRecord: string[] = continuity.hostRecordLine ? [continuity.hostRecordLine] : []; // Model evidence: the inference each tool call came from, and how the host allowed its action. @@ -773,7 +732,11 @@ export async function sendAgent( if (!canonicalStale.ok) { return 'stale'; } - if (signal.aborted) { + // A Mission stop pressed before this run had a request to abort (between two + // internal runs, while the host saved the run's start or prepared it) is + // honoured here, before anything is sent: the Mission loop then pauses or + // cancels as for any stop. + if (signal.aborted || (host.missionAuto !== null && host.missionAuto.stop !== null)) { throw new DOMException('Aborted', 'AbortError'); } if (host.missionAuto !== null && !lowBudgetNoted && MAX_AGENT_STEPS - step <= MISSION_LOW_BUDGET_STEPS && workspaceWrites === 0 && testRuns.length === 0) { @@ -817,6 +780,10 @@ export async function sendAgent( if (!(error instanceof GenerationContextError)) { throw error; } + if (provider === 'local') { + // Same card as the server's own refusal (what it does not fit, and the LM Studio reload): nothing was sent. + throw new GenerationContextError(`The Agent stopped before model step ${step + 1}: ${error.message}`, error.neededTokens); + } const detail = `The Agent stopped before model step ${step + 1}: ${error.message}`; const current = host.store.get(threadId); if (current) { @@ -835,7 +802,7 @@ export async function sendAgent( try { const modelProvider = provider === 'cloud' ? guardedMetaAgent(host, threadId, text, apiKey) - : new LocalAgentModelProvider(host.localEndpoint() as string); + : new LocalAgentModelProvider(host.localEndpoint() as string, undefined, limitsForModel(host.settings.localLimits, model)); completion = await modelProvider.complete({ model, messages, @@ -913,7 +880,7 @@ export async function sendAgent( 'For filesystem_usage, use exactly {} as its arguments. ' + 'For apply_patch, use {"files":[{"path":"relative/path","expectedHash":"the sha256 returned by read_file, or e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 for a new file","newContent":"full replacement text"}]}.', }); - host.status = `Correcting malformed tool call (${protocolRepairs}/2): waiting for the ${providerLabel} (120 s maximum)\u2026`; + host.status = `Correcting malformed tool call (${protocolRepairs}/2): waiting for the ${providerLabel} (within its time limits)\u2026`; await host.postState(); continue agentSteps; } @@ -1044,6 +1011,13 @@ export async function sendAgent( return 'policy-stop'; } described = { targets: bound.described.targets, body: bound.described.body, preconditions: bound.described.preconditions }; + } else if (isBoundTool(call.tool)) { + const card = await boundCard(host, call, workspaceId); + if (!card.ok) { + extra.push(...boundRefusal(call, card.error)); + continue agentSteps; + } + described = card.described; } else if (call.tool === 'apply_patch') { described = { ...described, @@ -1160,7 +1134,7 @@ export async function sendAgent( commandId: String((call.args as Record).commandId ?? ''), packageJsonHash: hashContent(testContext.packageJsonRaw), }); - const auto = byGrant || isAutoAllowed(profile, call.tool); + const auto = byGrant || isAutoAllowed(profile, call.tool) || (await extraToolAuto(host, profile, call, workspaceId)); const autoDecision = byGrant ? ('mission-grant' as const) : ('profile-auto' as const); let approvedPending: PendingAgentAction | null = null; if (!auto) { @@ -1223,6 +1197,11 @@ export async function sendAgent( return 'rejected'; } approvedPending = pending; + const job = await confirmBound(host, call, described.body, workspaceId); + if (!job.ok) { + failAgentWithAudit(host, call, provider, threadId, workspaceId, 'policy', job.error, job.error); + return 'policy-stop'; + } if (call.tool === 'run_tests') { const reverified = await verifyBoundTestStillApproved( host, @@ -1337,6 +1316,9 @@ export async function sendAgent( } if (call.tool === 'run_tests') { const exit = /^exit=(\d+)/.exec(resultText); + if (!exit || Number(exit[1]) !== 0) { + failing.last = { commandId: String((call.args as Record).commandId ?? ''), text: resultText.slice(-4000) }; + } testRuns.push({ commandId: String((call.args as Record).commandId ?? ''), exitCode: exit ? Number(exit[1]) : null, @@ -1426,6 +1408,7 @@ export async function sendAgent( repeatedReads, // The last result of each test command, for the Mission's blocking verifiers. verifiers: [...new Map(testRuns.map((run) => [run.commandId, { commandId: run.commandId, exitCode: run.exitCode, afterLastWrite: run.writes === workspaceWrites }])).values()], + ...(failing.last !== null ? { failingOutput: failing.last } : {}), }; } let capabilityReason: string | null = null; @@ -1439,8 +1422,9 @@ export async function sendAgent( if (host.lastAgentOutcome !== null && host.lastAgentOutcome.threadId === threadId) { host.lastAgentOutcome = { ...host.lastAgentOutcome, capabilityReason }; } - if (provider === 'local' && capabilityReason !== null && host.cascadeVerification === null && host.activeMission === null) { - offerConsult(host, { threadId, root: target.root, task: text, reason: capabilityReason, hostRecord: [...hostRecord], scopeId: memoryScope.scopeId, verifier: lastTest }); + // The main model that ran, local or Meta, may consult its advisor (offerConsult picks it, never the main model itself). + if (capabilityReason !== null && host.cascadeVerification === null && host.activeMission === null) { + offerConsult(host, { threadId, root: target.root, task: text, reason: capabilityReason, hostRecord: [...hostRecord], scopeId: memoryScope.scopeId, verifier: lastTest, ...(failing.last !== null ? { checkOutput: failing.last.text } : {}) }); } void steps; void buildAgentSystemPrompt; diff --git a/src/agentExtraTools.ts b/src/agentExtraTools.ts index 920ec64..464ebe5 100644 --- a/src/agentExtraTools.ts +++ b/src/agentExtraTools.ts @@ -1,19 +1,98 @@ /** * The Agent's optional tools beyond the workspace: read-only web access - * (webTools.ts) and terminal commands (shellTool.ts). Both are OFF by - * default, offered only to a local model and never in a Mission's runs. + * (webTools.ts), terminal commands (shellTool.ts), long jobs (jobTools.ts) + * and installs (installTool.ts). None is ever offered in a Mission's runs; + * the one Mission-only tool, read_original (originalTool.ts), is offered + * only there. + * + * Long jobs and installs have their own approval card, rebuilt and compared + * right after the user approves it; the engine calls them through the + * `bound*` hooks below. */ -import type { AgentToolDefinition } from './agentTools.js'; +import { fullAccessNow, markFullApproved, rememberFullJob, useAgentWebNow } from './autonomyHost.js'; +import type { AgentChatMessage } from './agentModel.js'; +import type { AgentToolDefinition, ValidatedToolCall } from './agentTools.js'; +import { confirmInstallApproval, executeInstallTool, installAuto, installToolDefinitions, prepareInstallCard } from './installTool.js'; +import { confirmJobApproval, executeJobTool, jobRefusal, jobToolDefinitions, prepareJobCard } from './jobTools.js'; import type { ProviderHost } from './providerTypes.js'; import type { ProviderId } from './settingsStore.js'; +import { executeReadOriginal, originalToolDefinitions } from './originalTool.js'; import { executeShellTool, shellToolDefinitions } from './shellTool.js'; import { executeWebTool, webToolDefinitions } from './webTools.js'; export function extraToolDefinitions(host: ProviderHost, provider: ProviderId): AgentToolDefinition[] { - return [...webToolDefinitions(host, provider), ...shellToolDefinitions(host, provider)]; + return [...webToolDefinitions(host, provider), ...shellToolDefinitions(host, provider), ...jobToolDefinitions(host), ...installToolDefinitions(host), ...originalToolDefinitions(host)]; } export function executeExtraTool(host: ProviderHost, tool: 'web_search' | 'web_fetch' | 'run_command', args: Record, provider: ProviderId, root: string, signal: AbortSignal): Promise { return tool === 'run_command' ? executeShellTool(host, args, provider, root, signal) : executeWebTool(host, tool, args, provider, signal); } + +type Described = { targets: string[]; body: string; preconditions: Record }; + +/** Tools with their own card: start_job and install_package. */ +export const isBoundTool = (tool: string): boolean => tool === 'start_job' || tool === 'install_package'; + +export function boundCard(host: ProviderHost, call: ValidatedToolCall, root: string): Promise<{ ok: true; described: Described } | { ok: false; error: string }> { + return call.tool === 'start_job' ? prepareJobCard(host, call, root) : prepareInstallCard(host, call, root); +} + +export function confirmBound(host: ProviderHost, call: ValidatedToolCall, approvedBody: string, root: string): Promise<{ ok: true } | { ok: false; error: string }> { + return call.tool === 'start_job' ? confirmJobApproval(host, call, approvedBody, root) : call.tool === 'install_package' ? confirmInstallApproval(host, call, approvedBody, root) : Promise.resolve({ ok: true }); +} + +export const boundRefusal = (call: ValidatedToolCall, error: string): AgentChatMessage[] => jobRefusal(call, error); + +/** + * Whether a call may run without its card beyond the profile, checked right + * before it runs: Full Access for the parts the user chose (Autonomy & + * Permissions; a long job and an install still pass their own checks, and + * an install never with administrator rights), a web call within the + * Agent and Chat web pre-authorization (one slot consumed now), or an + * automatic install the user chose for Workspace full. + */ +export async function extraToolAuto(host: ProviderHost, profile: string, call: ValidatedToolCall, root: string): Promise { + const args = call.args as Record; + if (call.tool === 'web_search' || call.tool === 'web_fetch') { + return useAgentWebNow(host, call.tool, args, root); + } + const full = await fullAccessNow(host, call.tool, args, root); + if (full.ok) { + let ready = true; + if (call.tool === 'install_package') { + ready = await installAuto(host, profile, call, root, true); + } else if (call.tool === 'start_job') { + const card = await prepareJobCard(host, call, root); + ready = card.ok && (await confirmJobApproval(host, call, card.described.body, root)).ok; + if (ready) { + fullJobs.set(call, full); + } + } + if (ready) { + markFullApproved(call); + return true; + } + } + return installAuto(host, profile, call, root); +} + +/** Long jobs started under Full Access: remembered so that revoking it stops them. */ +const fullJobs = new WeakMap(); + +export function executeBoundTool(host: ProviderHost, call: ValidatedToolCall, root: string, profile: string, signal: AbortSignal): Promise { + if (call.tool === 'read_original') { + return executeReadOriginal(host, call.args as Record); + } + if (call.tool === 'start_job' || call.tool === 'job_status') { + return executeJobTool(host, call, root, signal).then(async (text) => { + const full = fullJobs.get(call); + const id = /^Started job (\S+)/.exec(text)?.[1]; + if (full && id) { + await rememberFullJob(host, full.scopeId, full.id, id); + } + return text; + }); + } + return executeInstallTool(host, call, root, profile, signal); +} diff --git a/src/agentHost.ts b/src/agentHost.ts index 5ba8c76..f62bbe8 100644 --- a/src/agentHost.ts +++ b/src/agentHost.ts @@ -166,6 +166,15 @@ export function describeAgentCall(call: ValidatedToolCall): { const notes = commandNotes(command); return { targets: [`terminal command in the project folder: ${command.slice(0, 600)}`, ...(notes.length > 0 ? [`Note: this line ${notes.join('; ')}. Approve only if you know what it does.`] : [])], body, preconditions: {} }; } + case 'start_job': + return { targets: [`long job: ${String((call.args as Record).commandId ?? '').slice(0, 80)}`], body, preconditions: {} }; + case 'install_package': + case 'check_installed': { + const args = call.args as Record; + return { targets: [`${call.tool === 'install_package' ? 'install' : 'check'}: ${String(args.manager ?? '')} ${String(args.name ?? '').slice(0, 120)}`], body, preconditions: {} }; + } + case 'job_status': + return { targets: [`long job status: ${String((call.args as Record).jobId ?? 'all jobs of this project')}`], body, preconditions: {} }; case 'search_text': { const dir = typeof (call.args as Record).dir === 'string' ? ((call.args as Record).dir as string) diff --git a/src/agentModel.ts b/src/agentModel.ts index 70ed875..a71d0b7 100644 --- a/src/agentModel.ts +++ b/src/agentModel.ts @@ -12,11 +12,12 @@ import { MetaHttpError } from './metaFailures.js'; import { isAbortError, withBoundedRequest } from './boundedRequest.js'; import { generationRequestFields, - localContextRefusal, type GenerationRequest, } from './generationSettings.js'; import { isLocalOrPrivateUrl, refuseRedirect } from './localServers.js'; +import { localHttpError, localStreamRefusal } from './localServerErrors.js'; import { META_API_ENDPOINT } from './meta-provider.js'; +import { limitedRequest, LocalLimitError, readStreamedCompletion, type LocalRequestLimits } from './localLimits.js'; import { parseUsage, type ProviderUsage } from './usage.js'; import { AgentToolProtocolError, @@ -156,6 +157,20 @@ export function buildAgentRequestBody( }; } +/** Join only adjacent system messages, in order, so local templates do not repeat their tool preamble. */ +export function mergeAdjacentSystemMessages(messages: AgentChatMessage[]): AgentChatMessage[] { + const merged: AgentChatMessage[] = []; + for (const message of messages) { + const previous = merged[merged.length - 1]; + if (message.role === 'system' && previous?.role === 'system' && typeof previous.content === 'string' && typeof message.content === 'string') { + merged[merged.length - 1] = { ...previous, content: `${previous.content}\n\n${message.content}` }; + } else { + merged.push(message); + } + } + return merged; +} + function finishReason(payload: unknown): unknown { if (typeof payload !== 'object' || payload === null) { return undefined; @@ -308,6 +323,8 @@ export async function createLocalAgentCompletion(options: { fetchImpl?: FetchLike; timeoutMs?: number; toolChoice?: AgentToolChoice; + /** This model's idle and total limits (localLimits.ts): the answer is then streamed. */ + limits?: LocalRequestLimits; }): Promise { if (!isLocalOrPrivateUrl(options.endpoint)) { throw new Error('The local endpoint must stay on this computer or the private network.'); @@ -315,6 +332,9 @@ export async function createLocalAgentCompletion(options: { assertAgentModelForProvider('local', options.model); const fetchImpl = options.fetchImpl ?? fetch; const url = `${options.endpoint.replace(/\/+$/, '')}/v1/chat/completions`; + if (options.limits !== undefined && options.timeoutMs === undefined) { + return limitedLocalAgentCompletion(options as typeof options & { limits: LocalRequestLimits }, fetchImpl, url); + } // The deadline and cancellation cover the body read as well as headers. const { response, payload } = await withBoundedRequest( options.signal, @@ -329,7 +349,7 @@ export async function createLocalAgentCompletion(options: { signal, redirect: 'manual', body: JSON.stringify( - buildAgentRequestBody(options.model, options.messages, options.generation, options.toolChoice ?? 'auto', options.tools), + buildAgentRequestBody(options.model, mergeAdjacentSystemMessages(options.messages), options.generation, options.toolChoice ?? 'auto', options.tools), ), }); } catch (error) { @@ -346,7 +366,86 @@ export async function createLocalAgentCompletion(options: { if (isLocalMalformedToolCall(response.status, payload)) { throw new LocalMalformedToolCallError(); } - throw localContextRefusal(payload) ?? new Error(`The local runtime returned HTTP ${response.status}.`); + throw localHttpError(response.status, payload, options.model); + } + return parseAgentPayload(payload, options.generation.maxCompletionTokens); +} + +/** + * The local Agent call under this model's limits: streamed, so the host can + * tell a model that is still producing output from one that went quiet or + * only repeats itself. The payload is rebuilt and parsed exactly as a + * non-streamed answer. + */ +async function limitedLocalAgentCompletion( + options: { model: string; messages: AgentChatMessage[]; generation: GenerationRequest; tools?: AgentToolDefinition[]; signal?: AbortSignal; toolChoice?: AgentToolChoice; limits: LocalRequestLimits }, + fetchImpl: FetchLike, + url: string, +): Promise { + const request = limitedRequest(options.signal, options.limits); + let status = 0; + let inStream = false; + let payload: unknown; + try { + let response: Response; + try { + response = await fetchImpl(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: request.signal, + redirect: 'manual', + body: JSON.stringify({ + ...buildAgentRequestBody(options.model, mergeAdjacentSystemMessages(options.messages), options.generation, options.toolChoice ?? 'auto', options.tools), + stream: true, + stream_options: { include_usage: true }, + }), + }); + } catch (error) { + if (isAbortError(error)) { + throw error; + } + throw new Error('The local runtime is unreachable.'); + } + refuseRedirect(response); + status = response.status; + if (response.ok && /text\/event-stream/i.test(response.headers.get('content-type') ?? '')) { + payload = await readStreamedCompletion(response, (delta) => request.output(delta)); + if (typeof payload === 'object' && payload !== null && 'error' in payload) { + // An error sent inside the stream: judged like the same error sent as HTTP 500. + status = 500; + inStream = true; + } + } else { + // Answered in one piece: no progress can be seen, only the total limit applies now. + request.unmeasurable(); + payload = await readPayload(response); + } + } catch (error) { + const stop = request.stopped(); + if (stop !== null) { + throw new LocalLimitError(stop, options.limits); + } + if (options.signal?.aborted) { + throw isAbortError(error) ? error : new DOMException('Aborted', 'AbortError'); + } + throw error; + } finally { + request.dispose(); + } + if (status < 200 || status > 299) { + if (isLocalMalformedToolCall(status, payload)) { + throw new LocalMalformedToolCallError(); + } + if (inStream) { + // A context or setup refusal keeps its meaning (and its actions) when it arrives in the stream. + const known = localStreamRefusal(payload, options.model); + if (known !== null) { + throw known; + } + const detail = (payload as { error?: { message?: unknown } } | undefined)?.error?.message; + throw new Error(`The local runtime reported an error${typeof detail === 'string' && detail.trim() ? `: ${detail.trim().slice(0, 200)}` : ''}.`); + } + throw localHttpError(status, payload, options.model); } return parseAgentPayload(payload, options.generation.maxCompletionTokens); } diff --git a/src/agentProfiles.ts b/src/agentProfiles.ts index 0931f7e..5f41632 100644 --- a/src/agentProfiles.ts +++ b/src/agentProfiles.ts @@ -6,9 +6,11 @@ * - `ask`: every tool action pauses for a visible approve/reject decision. * - `safe-auto`: low-risk workspace reads run automatically after policy * validation; writes and test commands still require approval. - * - `workspace-full`: policy-valid reads, patch applications, allowlisted - * tests and enabled read-only web calls may run without per-action prompts. - * The optional terminal remains separately gated and always needs approval. + * - `workspace-full`: policy-valid reads, patch applications and allowlisted + * tests may run without per-action prompts. The profile never grants + * anything outside the computer: web calls follow Autonomy & Permissions + * (a card, a web pre-authorization or Full Access), and the optional + * terminal is separately gated. * * Only the profile id is persisted; transient approvals are never stored. * Unknown or corrupt persisted state falls back to `ask`. @@ -66,10 +68,19 @@ export type WebToolName = (typeof WEB_TOOL_NAMES)[number]; export function isWebToolName(value: unknown): value is WebToolName { return (WEB_TOOL_NAMES as readonly unknown[]).includes(value); } +/** Long background jobs (jobTools.ts), offered only when the user enabled them. */ +export const JOB_TOOL_NAMES = ['start_job', 'job_status'] as const; +export type JobToolName = (typeof JOB_TOOL_NAMES)[number]; +/** Installs with permission (installTool.ts). */ +export const INSTALL_TOOL_NAMES = ['install_package', 'check_installed'] as const; +export type InstallToolName = (typeof INSTALL_TOOL_NAMES)[number]; +/** Read-only tools offered only in a Mission's runs: read_original (originalTool.ts). */ +export const MISSION_TOOL_NAMES = ['read_original'] as const; +export type MissionToolName = (typeof MISSION_TOOL_NAMES)[number]; /** Host-side tools offered only by Agent without a project folder (agentNoProject.ts). */ export const HOST_TOOL_NAMES = ['request_project_folder'] as const; export type HostToolName = (typeof HOST_TOOL_NAMES)[number]; -export type AgentToolName = AgentWorkspaceToolName | MemoryToolName | WebToolName | HostToolName; +export type AgentToolName = AgentWorkspaceToolName | MemoryToolName | WebToolName | HostToolName | JobToolName | InstallToolName | MissionToolName; export function isMemoryToolName(value: unknown): value is MemoryToolName { return (MEMORY_TOOL_NAMES as readonly unknown[]).includes(value); @@ -83,6 +94,7 @@ const READ_TOOLS: readonly AgentToolName[] = [ 'search_text', 'read_file', 'read_decisions', + 'read_original', ]; export interface AgentProfileOption { @@ -189,18 +201,24 @@ export function isAutoAllowed( profile: AgentProfileId, tool: AgentToolName, ): boolean { - if ((USER_APPROVAL_ONLY_TOOLS as readonly string[]).includes(tool) || tool === 'run_command') { - // Memory changes and terminal commands are never automatic, whatever the profile. + if ((USER_APPROVAL_ONLY_TOOLS as readonly string[]).includes(tool) || tool === 'run_command' || tool === 'start_job' || tool === 'install_package') { + // Memory changes, terminal commands, long jobs and installs are never automatic by profile alone + // (automatic installs are a separate choice of the user: installTool.ts). return false; } + if (tool === 'job_status' || tool === 'check_installed') { + // Read-only: a job's state and the end of its output. + return profile !== ASK_PROFILE_ID; + } if (profile === ASK_PROFILE_ID) { return false; } if (profile === SAFE_AUTO_PROFILE_ID) { return (READ_TOOLS as readonly string[]).includes(tool); } - // workspace-full: every allowlisted tool may run without a prompt once - // policy, patch preconditions, and the test allowlist all pass. - // Web tools too, once enabled: each call is still shown in the action log and host record (terminal commands never: above). - return (AGENT_TOOL_NAMES as readonly string[]).includes(tool) || tool === 'read_decisions' || isWebToolName(tool); + // workspace-full: every allowlisted workspace tool may run without a prompt + // once policy, patch preconditions, and the test allowlist all pass. Never + // the web: leaving the computer is for Autonomy & Permissions to allow + // (autonomyHost.ts), so Ask always holds whatever the profile. + return (AGENT_TOOL_NAMES as readonly string[]).includes(tool) || tool === 'read_decisions' || tool === 'read_original'; } diff --git a/src/agentTools.ts b/src/agentTools.ts index 20064ba..66ca4b1 100644 --- a/src/agentTools.ts +++ b/src/agentTools.ts @@ -7,6 +7,7 @@ * calls are all rejected before anything runs. */ +import { validateInstallRequest } from './installPlan.js'; import { MAX_PATCH_BYTES, MAX_PATCH_FILES, @@ -14,7 +15,7 @@ import { MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_OUTPUT_BYTES, } from './agentPolicy.js'; -import { HOST_TOOL_NAMES, MEMORY_TOOL_NAMES, WEB_TOOL_NAMES, type AgentToolName } from './agentProfiles.js'; +import { HOST_TOOL_NAMES, INSTALL_TOOL_NAMES, JOB_TOOL_NAMES, MEMORY_TOOL_NAMES, WEB_TOOL_NAMES, type AgentToolName } from './agentProfiles.js'; import type { ProviderUsage } from './usage.js'; export const AGENT_TOOL_LIST: readonly AgentToolName[] = [ @@ -459,6 +460,14 @@ export function validateToolArgs(tool: string, raw: unknown): Record; case 'read_decisions': return validateReadDecisionsArgs(raw); + case 'read_original': { + const keys = isRecord(raw) && Object.prototype.hasOwnProperty.call(raw, 'path') ? ['path'] : []; + const args = requireExactArgs(tool, raw, keys); + if (keys.length === 1 && (typeof args.path !== 'string' || args.path.length > 512)) { + failArgs(tool, 'path must be a project path of at most 512 characters'); + } + return keys.length === 1 ? { path: args.path as string } : {}; + } case 'propose_decision': return validateProposeDecisionArgs(raw) as unknown as Record; case 'bind_artifact': @@ -481,6 +490,48 @@ export function validateToolArgs(tool: string, raw: unknown): Record'); + } + if (args.timeoutSec !== undefined && (typeof args.timeoutSec !== 'number' || !Number.isInteger(args.timeoutSec) || args.timeoutSec < 1 || args.timeoutSec > 21600)) { + failArgs(tool, 'timeoutSec must be an integer from 1 to 21600'); + } + return args.timeoutSec === undefined ? { commandId: args.commandId } : { commandId: args.commandId, timeoutSec: args.timeoutSec }; + } + case 'job_status': { + const record = isRecord(raw) ? raw : {}; + const keys = ['jobId', 'waitSec'].filter((key) => Object.prototype.hasOwnProperty.call(record, key)); + const args = requireExactArgs(tool, raw, keys); + if (args.jobId !== undefined && (typeof args.jobId !== 'string' || !/^[a-z0-9-]{8,64}$/.test(args.jobId))) { + failArgs(tool, 'jobId must be a job id returned by start_job'); + } + if (args.waitSec !== undefined && (typeof args.waitSec !== 'number' || !Number.isInteger(args.waitSec) || args.waitSec < 0 || args.waitSec > 120)) { + failArgs(tool, 'waitSec must be an integer from 0 to 120'); + } + return { ...(args.jobId !== undefined ? { jobId: args.jobId } : {}), ...(args.waitSec !== undefined ? { waitSec: args.waitSec } : {}) }; + } + case 'install_package': + case 'check_installed': { + const record = isRecord(raw) ? raw : {}; + const extra = tool === 'install_package' ? 'reason' : 'waitSec'; + const keys = ['manager', 'name', ...['version', 'runtime', extra].filter((key) => Object.prototype.hasOwnProperty.call(record, key) || (key === 'reason' && tool === 'install_package'))]; + const args = requireExactArgs(tool, raw, keys); + const { reason, waitSec, ...request } = args; + const checked = validateInstallRequest(request); + if (!checked.ok) { + failArgs(tool, checked.error); + } + if (tool === 'install_package' && (typeof reason !== 'string' || reason.trim().length === 0 || reason.length > 300)) { + failArgs(tool, 'reason must say in 1 to 300 characters why it is needed'); + } + if (waitSec !== undefined && (typeof waitSec !== 'number' || !Number.isInteger(waitSec) || waitSec < 0 || waitSec > 120)) { + failArgs(tool, 'waitSec must be an integer from 0 to 120'); + } + return { ...checked.request, ...(tool === 'install_package' ? { reason: String(reason).trim() } : waitSec !== undefined ? { waitSec } : {}) }; + } case 'request_project_folder': { const args = requireExactArgs(tool, raw, ['reason']); if (typeof args.reason !== 'string' || args.reason.trim().length === 0 || args.reason.length > 300) { @@ -539,7 +590,7 @@ export function validateOneToolCall(raw: unknown): ValidatedToolCall { } if ( typeof fn.name !== 'string' || - (!(AGENT_TOOL_LIST as readonly string[]).includes(fn.name) && !(MEMORY_TOOL_NAMES as readonly string[]).includes(fn.name) && !(WEB_TOOL_NAMES as readonly string[]).includes(fn.name) && !(HOST_TOOL_NAMES as readonly string[]).includes(fn.name)) + (!(AGENT_TOOL_LIST as readonly string[]).includes(fn.name) && !(MEMORY_TOOL_NAMES as readonly string[]).includes(fn.name) && !(WEB_TOOL_NAMES as readonly string[]).includes(fn.name) && !(HOST_TOOL_NAMES as readonly string[]).includes(fn.name) && !(JOB_TOOL_NAMES as readonly string[]).includes(fn.name) && !(INSTALL_TOOL_NAMES as readonly string[]).includes(fn.name) && fn.name !== 'read_original') ) { fail('Unknown tool.'); } @@ -547,7 +598,7 @@ export function validateOneToolCall(raw: unknown): ValidatedToolCall { // as an empty string or null. This is unambiguous only for this exact // zero-argument tool; every other tool keeps the strict JSON contract. const argsDecoded = - (fn.name === 'filesystem_usage' || fn.name === 'read_decisions') && (fn.arguments === '' || fn.arguments === null) + (fn.name === 'filesystem_usage' || fn.name === 'read_decisions' || fn.name === 'read_original') && (fn.arguments === '' || fn.arguments === null) ? {} : parseArgumentsField(fn.arguments); const args = validateToolArgs(fn.name as string, argsDecoded); diff --git a/src/autonomyDialog.ts b/src/autonomyDialog.ts new file mode 100644 index 0000000..2365e4c --- /dev/null +++ b/src/autonomyDialog.ts @@ -0,0 +1,51 @@ +/** + * The VS Code side of Autonomy & Permissions: the modal confirmation of a + * pre-authorization, and where this host runs and the key that seals its + * policies. + * + * Only the user's click on the dialog's action pre-authorizes anything; + * closing it or pressing Escape refuses. + * + * The seal key is 32 random bytes kept in VS Code's SecretStorage (the + * system keychain), never in the state file it protects. Until it is + * loaded nothing is in force. If another window created a different key + * at the same moment, the stored one wins on its next change. + */ + +import { randomBytes } from 'node:crypto'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; + +import { liveExecEnv } from './autonomyEnv.js'; +import type { AutonomyRuntime } from './autonomyStore.js'; + +const SEAL_KEY = 'rameCode.autonomy.sealKey'; + +export async function confirmAutonomyDialog(request: { message: string; detail: string; action: string }): Promise { + const choice = await vscode.window.showWarningMessage(request.message, { modal: true, detail: request.detail }, request.action); + return choice === request.action; +} + +export function autonomyRuntime(secrets: vscode.SecretStorage, stateDir: string | undefined): AutonomyRuntime { + let key: Buffer | null = null; + const load = async (): Promise => { + let stored = await secrets.get(SEAL_KEY); + if (!stored || !/^[0-9a-f]{64}$/.test(stored)) { + await secrets.store(SEAL_KEY, randomBytes(32).toString('hex')); + stored = await secrets.get(SEAL_KEY); + } + key = stored && /^[0-9a-f]{64}$/.test(stored) ? Buffer.from(stored, 'hex') : null; + }; + const loading = load().catch(() => undefined); + secrets.onDidChange?.((event) => { + if (event.key === SEAL_KEY) { + void load().catch(() => undefined); + } + }); + return { + env: liveExecEnv(vscode.env?.remoteName, stateDir), + key: () => key, + ready: () => loading, + statePaths: stateDir ? [path.dirname(stateDir)] : [], + }; +} diff --git a/src/autonomyEntry.ts b/src/autonomyEntry.ts new file mode 100644 index 0000000..dbc0dc8 --- /dev/null +++ b/src/autonomyEntry.ts @@ -0,0 +1,19 @@ +/** + * Entry of the Autonomy & Permissions messages from the main view: resolves + * the open workspace the way Settings does and hands the message to the + * host module. Kept apart so `autonomyHost.ts` does not depend on the + * memory controller (which reports the workspace to it). + */ + +import { handleAutonomyMessage } from './autonomyHost.js'; +import { AUTONOMY_MESSAGE_TYPES, type AutonomyMessage } from './autonomyView.js'; +import { memoryScopeForUi } from './memoryController.js'; +import type { ProviderHost } from './providerTypes.js'; + +export function isAutonomyMessage(message: { type: string }): message is AutonomyMessage { + return AUTONOMY_MESSAGE_TYPES.includes(message.type); +} + +export function onAutonomyMessage(host: ProviderHost, message: AutonomyMessage): Promise { + return handleAutonomyMessage(host, message, () => memoryScopeForUi(host)); +} diff --git a/src/autonomyEnv.ts b/src/autonomyEnv.ts new file mode 100644 index 0000000..52969fe --- /dev/null +++ b/src/autonomyEnv.ts @@ -0,0 +1,121 @@ +/** + * The environment RAME Code's host runs in, where every authorized + * operation actually happens: this computer, a Remote SSH server, WSL, a + * container. Every pre-authorization and Full Access grant is bound to it, + * so a permission given on one computer never applies on another, and a + * change of computer, server identity, user or workspace invalidates it. + * + * Free of the `vscode` API so it can be unit tested with plain `node:test`. + * + * The identity is a hash of the kind of environment (`vscode.env.remoteName`, + * none for local), the host name, the operating-system user, the machine id + * (`/etc/machine-id`, or a random id kept in RAME Code's state folder on that + * machine) and, for a remote environment, the server's SSH host key when it + * can be read. Nothing of it is sent anywhere. + */ + +import { createHash, randomBytes } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export interface ExecEnv { + /** `env_` and 16 hex characters. */ + id: string; + /** `local`, `ssh-remote`, `wsl`, `dev-container`, … */ + kind: string; + host: string; + user: string; + /** How the user is told where operations run. */ + label: string; +} + +export interface EnvProbe { + remoteName?: string | undefined; + hostname(): string; + user(): string; + readFile(file: string): string | null; + /** A random id for this installation, created once on that machine. */ + installId(): string; +} + +const HOST_KEYS = ['/etc/ssh/ssh_host_ed25519_key.pub', '/etc/ssh/ssh_host_ecdsa_key.pub', '/etc/ssh/ssh_host_rsa_key.pub']; +const sha = (text: string): string => createHash('sha256').update(text, 'utf8').digest('hex'); + +export function describeEnv(kind: string, host: string, user: string): string { + switch (kind) { + case 'local': + return `this computer (${host}, user ${user})`; + case 'ssh-remote': + return `the Remote SSH server ${host} (user ${user})`; + case 'wsl': + return `WSL on ${host} (user ${user})`; + default: + return `the ${kind} environment ${host} (user ${user})`; + } +} + +export function computeExecEnv(probe: EnvProbe): ExecEnv { + const kind = (probe.remoteName ?? '').trim().slice(0, 40) || 'local'; + const host = probe.hostname().trim().slice(0, 120) || 'unknown-host'; + const user = probe.user().trim().slice(0, 128) || 'unknown'; + const machine = (probe.readFile('/etc/machine-id') ?? probe.readFile('/var/lib/dbus/machine-id') ?? '').trim() || probe.installId(); + let hostKey = ''; + if (kind !== 'local') { + for (const file of HOST_KEYS) { + const key = probe.readFile(file)?.trim().split(/\s+/)[1]; + if (key) { + hostKey = sha(key); + break; + } + } + } + return { id: `env_${sha(JSON.stringify(['rame-env:v1', kind, host, user, machine, hostKey])).slice(0, 16)}`, kind, host, user, label: describeEnv(kind, host, user) }; +} + +function readSmall(file: string): string | null { + try { + return fs.readFileSync(file, 'utf8').slice(0, 8192); + } catch { + return null; + } +} + +function installIdIn(stateDir: string | undefined): string { + const file = path.join(stateDir ?? path.join(os.tmpdir(), `rame-code-${typeof process.getuid === 'function' ? process.getuid() : 'user'}`), 'install-id'); + const known = readSmall(file)?.trim(); + if (known && /^[0-9a-f]{32}$/.test(known)) { + return known; + } + const fresh = randomBytes(16).toString('hex'); + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, fresh, { flag: 'wx', mode: 0o600 }); + return fresh; + } catch { + // Another window created it first, or the folder is read-only. + return readSmall(file)?.trim() || fresh; + } +} + +function osUserName(): string { + try { + return os.userInfo().username || 'unknown'; + } catch { + return process.env.USER ?? process.env.USERNAME ?? 'unknown'; + } +} + +/** The real environment of this process, recomputed at most every 2 s. */ +export function liveExecEnv(remoteName: string | undefined, stateDir: string | undefined): () => ExecEnv { + let cached: { at: number; env: ExecEnv } | null = null; + let installId: string | null = null; + return () => { + if (cached !== null && Date.now() - cached.at < 2000) { + return cached.env; + } + const env = computeExecEnv({ remoteName, hostname: () => os.hostname(), user: osUserName, readFile: readSmall, installId: () => (installId ??= installIdIn(stateDir)) }); + cached = { at: Date.now(), env }; + return env; + }; +} diff --git a/src/autonomyHost.ts b/src/autonomyHost.ts new file mode 100644 index 0000000..96161ef --- /dev/null +++ b/src/autonomyHost.ts @@ -0,0 +1,996 @@ +/** + * The host side of Autonomy & Permissions (see `autonomyPolicy.ts`). + * + * Every pre-authorization and Full Access grant is created here, only from + * a message of the view and only after the host's modal confirmation + * (`host.confirmAutonomy`; a host without it refuses). The model has no + * tool and no message that reaches this module. The checks that matter run + * right before each operation, reading the stored policy afresh, in this + * environment: `metaRefusalNow`, `missionWebRefusalNow`, `useAgentWebNow` + * for Agent and Chat web calls and `fullAccessNow` for the Agent's tools. + * + * Revoking aborts what was running under it, in this window at once and in + * the other windows of the same workspace and environment at their next + * check (each window watches the store while something of it is running): + * requests being sent, the Agent or Mission run that used Full Access, and + * the long jobs started under it. What already left or already happened + * cannot be taken back. + */ + +import * as os from 'node:os'; +import { randomBytes } from 'node:crypto'; + +import { + commandNeedsCard, + describeMetaMax, + describeScopedFile, + FULL_TOOL_WORDS, + FULL_TOOLS, + fullConfirmation, + fullMetaRefusal, + fullRefusal, + fullMainModelRefusal, + MAX_ADVICE_KEYS, + META_PROVIDER, + metaConfirmation, + metaRefusal, + missionCountRefusal, + missionWebConfirmation, + missionWebGrantId, + missionWebRefusal, + newPreauthId, + parseScopedFiles, + sessionWebConfirmation, + UNTIL_TURNED_OFF, + untilTurnedOff, + useAgentWeb, + validateSessionWeb, + type ExternalApproval, + type ExternalEntry, + type FullMetaNeed, + type FullTool, + type GrantContext, + type MetaBinding, + type MetaPreauth, + type ScopedFile, + type WorkspaceAutonomy, +} from './autonomyPolicy.js'; +import { advisorFor, advisorModelNow } from './advisorHost.js'; +import type { AutonomyStore } from './autonomyStore.js'; +import type { AutonomyMessage, AutonomyView } from './autonomyView.js'; +import { checkAgentRelPath, isProtectedRelPath } from './agentPolicy.js'; +import type { AgentFs } from './agentHost.js'; +import { redactSecrets } from './cloudEgress.js'; +import { scopeIdForRoot } from './decisionStore.js'; +import { domainAllowed, missionQueryRefusal, validateMissionWebRequest } from './missionWeb.js'; +import type { MissionRecord } from './missionStore.js'; +import type { ProviderHost } from './providerTypes.js'; +import { HOST_SPEC_FILE } from './referenceCheck.js'; +import { egressRefusal } from './webAccess.js'; + +/** What this module needs of the host; every field is optional so a partial host refuses instead of failing. */ +type AutonomyHost = ProviderHost & { + autonomy?: AutonomyStore; + confirmAutonomy?: (request: { message: string; detail: string; action: string }) => Promise; + stopMission?: (missionId: string, action: 'pause' | 'cancel') => Promise; +}; + +export const MAX_FRAGMENT_CHARS = 8000; +const HOUR = 3_600_000; +const WATCH_MS = 1000; + +export function osUser(): string { + try { + return os.userInfo().username || 'unknown'; + } catch { + return process.env.USER ?? process.env.USERNAME ?? 'unknown'; + } +} + +const storeOf = (host: object): AutonomyStore | undefined => (host as AutonomyHost).autonomy; + +function contextFor(store: AutonomyStore, scopeId: string): GrantContext { + return { now: Date.now(), user: osUser(), scopeId, envId: store.env().id }; +} + +// ----- The open workspace, as Settings last resolved it ----- + +const scopes = new WeakMap(); + +export function rememberAutonomyScope(host: object, scope: { scopeId: string; root: string; label: string } | null): void { + scopes.set(host, scope === null ? null : { scopeId: scope.scopeId, root: scope.root, label: scope.label }); +} + +/** The workspace scope a request outside a Mission is recorded under. */ +export function currentScopeId(host: object): string { + return scopes.get(host)?.scopeId ?? ''; +} + +/** The scope of the folder an Agent or Chat run works in. */ +async function scopeForRoot(host: ProviderHost, root: string): Promise { + if (typeof host.heldCanonicalRoot !== 'function') { + return currentScopeId(host); + } + try { + return scopeIdForRoot(await host.heldCanonicalRoot(root, host.agentFsFor(root))); + } catch { + // A folder that cannot be resolved is not the workspace anything was given for. + return ''; + } +} + +// ----- What is running under a grant, and the watch that stops it ----- + +const inflight = new Map }>(); +/** Each window's run that used Full Access: stopped when that grant is no longer in force. */ +const fullRuns = new Map(); +let watch: ReturnType | null = null; + +/** + * A signal aborted when the grant `id` is revoked: at once in this window, + * and within a second when another window revokes it (with `host`, this + * window watches the shared store while the request runs). Call `done` + * when the request ends. + */ +export function trackPreauthorized(id: string, scopeId = '', host?: object): { signal: AbortSignal; done: () => void } { + const controller = new AbortController(); + const entry = inflight.get(id) ?? { scopeId, store: host === undefined ? undefined : storeOf(host), controllers: new Set() }; + entry.controllers.add(controller); + inflight.set(id, entry); + if (entry.store !== undefined && entry.scopeId !== '') { + ensureWatch(); + } + return { + signal: controller.signal, + done: () => { + entry.controllers.delete(controller); + if (entry.controllers.size === 0) { + inflight.delete(id); + } + }, + }; +} + +function abortPreauthorized(ids: ReadonlyArray): number { + let aborted = 0; + for (const id of ids) { + for (const controller of (id ? inflight.get(id)?.controllers : undefined) ?? []) { + controller.abort(new DOMException('The pre-authorization was revoked.', 'AbortError')); + aborted += 1; + } + } + return aborted; +} + +const inflightCount = (): number => [...inflight.values()].reduce((sum, entry) => sum + entry.controllers.size, 0); + +/** The ids of the grants in force now for one workspace in this environment. */ +function liveIds(store: AutonomyStore, scopeId: string): Set { + const policy = store.get(scopeId); + const now = Date.now(); + const ids = new Set(); + for (const grant of [policy.meta, policy.missionWeb, policy.agentWeb, policy.full]) { + if (grant !== null && now < grant.expiresAt && grant.user === osUser()) { + ids.add(grant.id); + } + } + return ids; +} + +async function stopRunOf(host: AutonomyHost): Promise { + fullRuns.delete(host); + const missionId = host.missionAuto?.missionId ?? null; + if (missionId !== null && typeof host.stopMission === 'function') { + await host.stopMission(missionId, 'pause').catch(() => undefined); + } + return typeof host.cancelRequest === 'function' ? host.cancelRequest() : false; +} + +/** + * One look at the shared store: abort what runs in this window under a + * grant that is no longer in force (revoked in any window, expired or + * replaced). The watch calls it every second while something runs. + */ +export async function checkRevocations(): Promise { + let stopped = 0; + for (const [id, entry] of [...inflight]) { + if (entry.store !== undefined && entry.scopeId !== '' && !liveIds(entry.store, entry.scopeId).has(id)) { + stopped += abortPreauthorized([id]); + } + } + for (const [host, run] of [...fullRuns]) { + const self = host as AutonomyHost; + const own = storeOf(host); + if (!self.running && self.missionAuto === null) { + fullRuns.delete(host); + } else if (own !== undefined && !liveIds(own, run.scopeId).has(run.id)) { + stopped += (await stopRunOf(self)) ? 1 : 0; + self.status = 'Full Access was revoked or expired: the run that used it was stopped. What already ran cannot be undone.'; + await self.postState?.().catch(() => undefined); + } + } + if (inflight.size === 0 && fullRuns.size === 0 && watch !== null) { + clearInterval(watch); + watch = null; + } + return stopped; +} + +function ensureWatch(): void { + if (watch === null) { + watch = setInterval(() => void checkRevocations().catch(() => undefined), WATCH_MS); + watch.unref?.(); + } +} + +// ----- Meta ----- + +function metaContext(host: AutonomyHost, store: AutonomyStore, scopeId: string): Parameters[2] { + return { + ...contextFor(store, scopeId), + // Consultations go to the advisor of the main model (advisorHost.ts): that is the model a pre-authorization covers. + model: advisorModelNow(host), + credentialRevision: typeof host.metaCredential === 'string' ? host.metaCredential : '', + cloudEscalation: host.settings?.allowCloudEscalation === true, + }; +} + +/** Why a Meta consultation may not be sent without a card now (fresh read of the stored policy), or null. */ +export function metaRefusalNow(host: ProviderHost, scopeId: string, binding: MetaBinding | null): string | null { + const store = storeOf(host); + if (store === undefined) { + return 'no Meta pre-authorization is in force for this workspace'; + } + return metaRefusal(store.get(scopeId), binding, metaContext(host as AutonomyHost, store, scopeId)); +} + +/** The pre-authorization in force for Missions of this workspace now, or null. */ +export function metaInForce(host: ProviderHost, scopeId: string): MetaPreauth | null { + return metaRefusalNow(host, scopeId, null) === null ? (storeOf(host)?.get(scopeId).meta ?? null) : null; +} + +/** The most consultations a Mission of this workspace may be given now: 0 when none, null when no count limit. */ +export function metaAllowance(host: ProviderHost, scopeId: string): number | null { + const meta = metaInForce(host, scopeId); + return meta === null ? 0 : meta.maxPerMission; +} + +/** + * Reserve, before a consultation is sent, `bound` tokens of the + * pre-authorization's total budget (when it has one); false when it would + * go over. Atomic across windows: inside the store's exclusive update. + */ +export async function reserveMetaTokens(host: ProviderHost, scopeId: string, preauthId: string, bound: number): Promise { + const store = storeOf(host); + if (store === undefined) { + return 'no Meta pre-authorization is in force for this workspace'; + } + return store.change(scopeId, (policy) => { + const meta = policy.meta; + if (meta === null || meta.id !== preauthId) { + return 'this Mission was authorized under an earlier pre-authorization that was revoked or replaced'; + } + if (meta.tokenBudget !== null && meta.usedTokens + bound > meta.tokenBudget) { + return `the consultation could use up to ${bound} tokens and only ${Math.max(0, meta.tokenBudget - meta.usedTokens)} of the pre-authorization's ${meta.tokenBudget}-token budget remain`; + } + meta.usedTokens += bound; + return null; + }); +} + +/** Give back what a consultation reserved but did not use (what Meta reported replaces the bound). */ +export async function settleMetaTokens(host: ProviderHost, scopeId: string, preauthId: string, reserved: number, used: number): Promise { + await storeOf(host)?.change(scopeId, (policy) => { + if (policy.meta !== null && policy.meta.id === preauthId) { + policy.meta.usedTokens = Math.max(0, policy.meta.usedTokens - reserved + Math.max(0, used)); + } + }).catch(() => undefined); +} + +// ----- Meta under Full Access (Agent's advice, the acceptance review) ----- + +/** Why Full Access does not send this consultation without its card now (fresh read, nothing written), or null. */ +export function fullMetaRefusalNow(host: ProviderHost, scopeId: string, need: FullMetaNeed): string | null { + const store = storeOf(host); + if (store === undefined || scopeId === '') { + return 'Full Access is not available here'; + } + return fullMetaRefusal(store.get(scopeId), metaContext(host as AutonomyHost, store, scopeId), need); +} + +/** Whether Full Access in force includes `part` now (to say why a card still shows). */ +export function fullIncludesNow(host: ProviderHost, scopeId: string, part: FullTool): boolean { + const store = storeOf(host); + return store !== undefined && scopeId !== '' && fullRefusal(store.get(scopeId), contextFor(store, scopeId), part) === null; +} + +/** + * Whether an Agent run with Meta as the main model may go without the + * disclosure card under Full Access now: only its "Meta as the main model" + * part, for the Meta model selected now and this API key (fresh read of the + * stored, sealed policy). Its tool parts never authorize it. + */ +export function fullMainModelNow(host: ProviderHost, scopeId: string): boolean { + const store = storeOf(host); + return store !== undefined && scopeId !== '' && fullMainModelRefusal(store.get(scopeId), contextFor(store, scopeId), { model: host.selectedCloudModel(), credentialRevision: host.metaCredential }) === null; +} + +/** A consultation let through by Full Access: both grants it runs under, and the tokens it reserved. */ +export interface FullMetaUse { + fullId: string; + metaId: string; + reserved: number; +} + +/** + * Right before a consultation leaves: check Full Access and the Meta + * pre-authorization again and, in the same exclusive step of the shared + * store, reserve `bound` tokens of its budget and count the consultation + * (for Agent's advice, against its conversation). Two windows can never + * share the last consultation or the last tokens. + */ +export async function fullMetaNow(host: ProviderHost, scopeId: string, need: FullMetaNeed, bound: number): Promise<{ ok: true; use: FullMetaUse } | { ok: false; why: string }> { + const store = storeOf(host); + if (store === undefined || scopeId === '' || !Number.isFinite(bound) || bound < 0) { + return { ok: false, why: 'Full Access is not available here' }; + } + const self = host as AutonomyHost; + try { + return await store.change(scopeId, (policy): { ok: true; use: FullMetaUse } | { ok: false; why: string } => { + const refusal = fullMetaRefusal(policy, metaContext(self, store, scopeId), need); + const meta = policy.meta; + if (refusal !== null || meta === null || policy.full === null) { + return { ok: false, why: refusal ?? 'no Meta pre-authorization is in force for this workspace' }; + } + if (meta.tokenBudget !== null && meta.usedTokens + bound > meta.tokenBudget) { + return { ok: false, why: `the consultation could use up to ${bound} tokens and only ${Math.max(0, meta.tokenBudget - meta.usedTokens)} of the pre-authorization's ${meta.tokenBudget}-token budget remain` }; + } + meta.usedTokens += bound; + if (need.key !== undefined) { + meta.adviceUses[need.key] = (meta.adviceUses[need.key] ?? 0) + 1; + for (const key of Object.keys(meta.adviceUses).slice(0, Math.max(0, Object.keys(meta.adviceUses).length - MAX_ADVICE_KEYS))) { + delete meta.adviceUses[key]; + } + } + return { ok: true, use: { fullId: policy.full.id, metaId: meta.id, reserved: bound } }; + }); + } catch { + return { ok: false, why: 'Autonomy & Permissions could not be read or written' }; + } +} + +/** Aborted when either grant is revoked, here or in another window of this workspace; call `done` when the request ends. */ +export function trackFullMeta(host: ProviderHost, scopeId: string, use: FullMetaUse): { signal: AbortSignal; done: () => void } { + const full = trackPreauthorized(use.fullId, scopeId, host); + const meta = trackPreauthorized(use.metaId, scopeId, host); + return { signal: AbortSignal.any([full.signal, meta.signal]), done: () => { full.done(); meta.done(); } }; +} + +const metaScopes = new WeakMap>(); + +export function takeMissionMetaScope(host: object, missionId: string): string[] { + const byMission = metaScopes.get(host); + const files = byMission?.get(missionId) ?? []; + byMission?.delete(missionId); + return files; +} + +/** Files Meta may never receive automatically: the verifier's, the host's descriptions, protected paths. */ +function scopedFileRefusal(file: string, excluded: ReadonlySet): string | null { + const gate = checkAgentRelPath(file); + if (!gate.ok || isProtectedRelPath(file)) { + return `${file} is outside the project or protected`; + } + if (HOST_SPEC_FILE.test(file) || excluded.has(gate.rel)) { + return `${file} belongs to the acceptance check or the host's own description (hidden from the agent and never sent)`; + } + return null; +} + +/** + * The binding of a Mission's escalation, checked when the Mission is + * authorized: under the pre-authorization in force, within its maximum + * (null: no count limit), with files only when its scope allows them, and + * never one of the verifier's files. + */ +export async function missionMetaBinding( + host: ProviderHost, + scopeId: string, + maxConsultations: number | null, + rawFiles: readonly string[], + fs: AgentFs, + excluded: ReadonlySet, +): Promise<{ ok: true; binding: MetaBinding } | { ok: false; error: string }> { + const refusal = metaRefusalNow(host, scopeId, null); + const meta = storeOf(host)?.get(scopeId).meta ?? null; + if (refusal !== null || meta === null) { + return { ok: false, error: `Meta consultations in a Mission need a pre-authorization in Settings → Security → Autonomy & Permissions: ${refusal ?? 'none is in force'}.` }; + } + const over = missionCountRefusal(meta, maxConsultations); + if (over !== null) { + return { ok: false, error: over }; + } + const parsed = parseScopedFiles(rawFiles); + if (!parsed.ok) { + return { ok: false, error: parsed.error }; + } + if (parsed.files.length > 0 && meta.dataScope !== 'files') { + return { ok: false, error: 'The Meta pre-authorization covers only the Mission instructions; files need a pre-authorization that includes them.' }; + } + for (const file of parsed.files) { + const why = scopedFileRefusal(file.path, excluded); + if (why !== null) { + return { ok: false, error: `Meta cannot be given ${why}.` }; + } + if ((await fs.stat(file.path).catch(() => ({ kind: 'missing' as const }))).kind !== 'file') { + return { ok: false, error: `${file.path.slice(0, 120)} is not a file of this project.` }; + } + } + return { + ok: true, + binding: { preauthId: meta.id, user: meta.user, scopeId, envId: meta.envId, provider: META_PROVIDER, model: meta.model, credentialRevision: meta.credentialRevision, expiresAt: meta.expiresAt, files: parsed.files }, + }; +} + +/** + * The files of a binding as they are now, for one consultation. Not sent + * (null and why) when a file is gone, became protected or now holds + * something that looks like a secret: that would need a new approval of + * the exact text, never an automatic send. + */ +export async function scopedFilesSection(fs: AgentFs, project: string, files: readonly ScopedFile[], excluded: ReadonlySet): Promise<{ ok: true; text: string; names: string[] } | { ok: false; error: string }> { + const parts: string[] = []; + for (const file of files) { + const why = scopedFileRefusal(file.path, excluded); + if (why !== null) { + return { ok: false, error: why }; + } + let content: string; + try { + content = await fs.readText(file.path); + } catch { + return { ok: false, error: `${file.path} can no longer be read` }; + } + if (file.start !== undefined && file.end !== undefined) { + content = content.split('\n').slice(file.start - 1, file.end).join('\n'); + } + if (redactSecrets(content).found.length > 0) { + return { ok: false, error: `${describeScopedFile(file)} now contains something that looks like a key, token or password` }; + } + const shown = content.slice(0, MAX_FRAGMENT_CHARS); + parts.push(`--- ${describeScopedFile(file)}${shown.length < content.length ? ` (first ${MAX_FRAGMENT_CHARS} characters)` : ''}\n${shown}`); + } + return parts.length === 0 + ? { ok: true, text: '', names: [] } + : { ok: true, text: `\n\nProject files the user pre-authorized for this Mission's consultations (project ${project}):\n${parts.join('\n')}`, names: files.map(describeScopedFile) }; +} + +// ----- Web for autonomous Missions ----- + +export function missionWebRefusalNow(host: ProviderHost, scopeId: string, preauthId: string | null): string | null { + const store = storeOf(host); + if (store === undefined) { + return 'web budgets for autonomous Missions are not pre-authorized for this workspace (Autonomy & Permissions)'; + } + return missionWebRefusal(store.get(scopeId), preauthId, contextFor(store, scopeId)); +} + +/** The grant an autonomous Mission's web budget is bound to now (its own pre-authorization, or Full Access with web), or null. */ +export function missionWebPreauthId(host: ProviderHost, scopeId: string): string | null { + const store = storeOf(host); + return store === undefined ? null : missionWebGrantId(store.get(scopeId), contextFor(store, scopeId)); +} + +// ----- Web for Agent and Chat, shared by every window of the workspace ----- + +const canonical = (raw: string): string => { + try { + const url = new URL(raw); + url.hash = ''; + return url.toString(); + } catch { + return raw; + } +}; + +const hostnameOf = (url: string): string => { + try { + return new URL(url).hostname.replace(/^\[|\]$/g, ''); + } catch { + return ''; + } +}; + +/** Which grant let the web call about to run through, by host and call (read once by the web tool). */ +const webUses = new WeakMap>(); + +/** + * Agent or Chat is about to make a web call: true when Full Access with web, + * or the Agent and Chat web pre-authorization, lets it through (one slot + * consumed inside the store's exclusive update, so two calls from any + * windows never share one). Otherwise the call shows its card. + */ +const webQueues = new WeakMap>(); + +export function useAgentWebNow(host: object, tool: string, args: Record, root: string): Promise { + // In the order the calls were made: the first one asked is the first one served. + const next = (webQueues.get(host) ?? Promise.resolve()).then(() => useAgentWebInOrder(host, tool, args, root)); + webQueues.set(host, next.catch(() => undefined)); + return next; +} + +async function useAgentWebInOrder(host: object, tool: string, args: Record, root: string): Promise { + const store = storeOf(host); + if ((tool !== 'web_search' && tool !== 'web_fetch') || (host as ProviderHost).missionAuto || store === undefined || root.length === 0) { + return false; + } + const query = String(args.query ?? ''); + const url = String(args.url ?? ''); + const scopeId = await scopeForRoot(host as ProviderHost, root); + if (scopeId === '') { + return false; + } + const use = await store.change(scopeId, (policy) => + useAgentWeb( + policy, + { tool, queryRefusal: tool === 'web_search' ? (egressRefusal(query) ?? missionQueryRefusal(query)) : egressRefusal(url), host: hostnameOf(url), url: canonical(url) }, + contextFor(store, scopeId), + domainAllowed, + ), + ).catch(() => null); + if (use === null) { + return false; + } + const byCall = webUses.get(host) ?? new Map(); + byCall.set(`${tool}:${tool === 'web_search' ? query : url}`, { ...use, scopeId }); + webUses.set(host, byCall); + if (use.approval === 'full-access') { + noteFullRun(host as AutonomyHost, use.id, scopeId); + } + return true; +} + +/** How the web call about to run was let through, without using it up (the run reads it once with takeWebUse). */ +export function peekWebUse(host: object, tool: string, value: string): 'full-access' | 'pre-authorized' | null { + return webUses.get(host)?.get(`${tool}:${value}`)?.approval ?? null; +} + +/** The grant that let the web call about to run through (read once), or null when it ran by card or profile. */ +export function takeWebUse(host: object, tool: string, value: string): { approval: 'full-access' | 'pre-authorized'; id: string; scopeId: string } | null { + const byCall = webUses.get(host); + const use = byCall?.get(`${tool}:${value}`) ?? null; + byCall?.delete(`${tool}:${value}`); + return use; +} + +/** URLs a pre-authorized search returned: the only pages the Agent and Chat pre-authorization may read when it lists no domain. */ +export async function rememberAgentResults(host: object, scopeId: string, urls: readonly string[]): Promise { + const store = storeOf(host); + if (store === undefined || scopeId === '' || urls.length === 0 || store.get(scopeId).agentWeb === null) { + return; + } + await store.change(scopeId, (policy) => { + if (policy.agentWeb !== null) { + policy.agentWeb.found = [...new Set([...policy.agentWeb.found, ...urls.slice(0, 50).map(canonical)])].slice(-200); + } + }).catch(() => undefined); +} + +// ----- Full Access for the Agent's tools ----- + +const READ_ONLY_TOOLS = new Set(['list_files', 'inspect_workspace', 'filesystem_usage', 'search_history', 'search_text', 'read_file', 'read_original']); +const FILE_TOOLS = new Set([...READ_ONLY_TOOLS, 'create_directory', 'move_file', 'delete_file', 'set_executable', 'apply_patch', 'replace_in_file']); + +/** The part of Full Access a tool needs; null for tools it never covers (project memory, the folder dialog). */ +export function fullToolFor(tool: string): FullTool | null { + if (FILE_TOOLS.has(tool)) { + return 'files'; + } + switch (tool) { + case 'run_tests': + return 'tests'; + case 'run_command': + return 'commands'; + case 'install_package': + case 'check_installed': + return 'installs'; + case 'start_job': + case 'job_status': + return 'jobs'; + case 'web_search': + case 'web_fetch': + return 'web'; + default: + return null; + } +} + +function noteFullRun(host: AutonomyHost, id: string, scopeId: string): void { + fullRuns.set(host, { id, scopeId }); + ensureWatch(); +} + +/** + * Whether Full Access lets this Agent call run without its card now: + * checked on the stored policy, for this workspace, environment and user, + * right before the call. Calls it does not cover, or that still need their + * card (privilege elevation, RAME Code's own state), return why not. + */ +export async function fullAccessNow(host: ProviderHost, tool: string, args: Record, root: string): Promise<{ ok: true; id: string; scopeId: string } | { ok: false; why: string }> { + const store = storeOf(host); + const part = fullToolFor(tool); + if (store === undefined || part === null || root.length === 0) { + return { ok: false, why: part === null ? `${tool} always needs its card` : 'Full Access is not available here' }; + } + const scopeId = await scopeForRoot(host, root); + const policy = store.get(scopeId); + const refusal = fullRefusal(policy, contextFor(store, scopeId), part); + if (refusal !== null || policy.full === null) { + return { ok: false, why: refusal ?? 'Full Access is not on for this workspace' }; + } + if (part === 'commands') { + const why = commandNeedsCard(String(args.command ?? ''), store.runtime.statePaths ?? []); + if (why !== null) { + return { ok: false, why: `the command still needs its card: ${why}` }; + } + } + noteFullRun(host as AutonomyHost, policy.full.id, scopeId); + return { ok: true, id: policy.full.id, scopeId }; +} + +/** Calls the engine let through by Full Access (for the audit: approved by Full Access, not by the profile). */ +const fullApproved = new WeakSet(); +export const markFullApproved = (call: object): void => void fullApproved.add(call); +export const isFullApproved = (call: object): boolean => fullApproved.has(call); + +/** Remember a long job started under Full Access, so revoking it stops the job too (from any window, also after a reload). */ +export async function rememberFullJob(host: ProviderHost, scopeId: string, grantId: string, jobId: string): Promise { + await storeOf(host)?.change(scopeId, (policy) => { + if (policy.full !== null && policy.full.id === grantId && !policy.full.jobs.includes(jobId)) { + policy.full.jobs = [...policy.full.jobs, jobId].slice(-50); + } + }).catch(() => undefined); +} + +// ----- The record of what left this computer ----- + +/** Record a request before it is sent; returns its id ('' when this host keeps no record). */ +export async function recordExternal(host: ProviderHost, scopeId: string, entry: Omit & { outcome?: string }): Promise { + const store = storeOf(host); + if (store === undefined) { + return ''; + } + const id = `ext_${randomBytes(6).toString('hex')}`; + await store.change(scopeId, (policy) => { + policy.log.push({ ...entry, id, at: Date.now(), outcome: entry.outcome ?? 'sending' }); + }).catch(() => undefined); + return id; +} + +export async function finishExternal(host: ProviderHost, scopeId: string, id: string, outcome: string): Promise { + const store = storeOf(host); + if (store === undefined || id === '') { + return; + } + await store.change(scopeId, (policy) => { + const entry = policy.log.find((candidate) => candidate.id === id); + if (entry) { + entry.outcome = outcome.slice(0, 300); + } + }).catch(() => undefined); +} + +// ----- Settings messages ----- + +async function confirm(host: AutonomyHost, request: { message: string; detail: string; action: string }): Promise { + return host.confirmAutonomy ? (await host.confirmAutonomy(request)) === true : false; +} + +async function say(host: ProviderHost, status: string): Promise { + host.status = status; + await host.postState(); +} + +/** Revoke grants of one workspace in this environment: removed from the store at once, then what ran under them is stopped. */ +async function revoke(host: AutonomyHost, store: AutonomyStore, scopeId: string, what: ReadonlyArray<'meta' | 'missionWeb' | 'agentWeb' | 'full'>, toAsk: boolean): Promise<{ aborted: number; jobs: number }> { + let removed: WorkspaceAutonomy | null = null; + await store.change(scopeId, (policy) => { + removed = JSON.parse(JSON.stringify(policy)) as WorkspaceAutonomy; + // A Meta pre-authorization given with Full Access goes with it. + if (what.includes('full') && policy.meta?.fullId !== undefined && policy.meta.fullId === policy.full?.id) { + policy.meta = null; + } + for (const part of what) { + policy[part] = null; + } + policy.mode = toAsk ? 'ask' : policy.mode === 'full' && what.includes('full') ? 'supervised' : policy.mode; + }); + const before = removed as WorkspaceAutonomy | null; + const ids = what.map((part) => before?.[part]?.id ?? null); + if (what.includes('full') && before?.meta?.fullId !== undefined && before.meta.fullId === before.full?.id) { + ids.push(before.meta.id); + } + let aborted = abortPreauthorized(ids); + const run = fullRuns.get(host); + if (run !== undefined && ids.includes(run.id)) { + aborted += (await stopRunOf(host)) ? 1 : 0; + } + let jobs = 0; + for (const jobId of what.includes('full') ? (before?.full?.jobs ?? []) : []) { + const job = host.jobs?.get?.(jobId); + if (job && (job.status === 'starting' || job.status === 'running')) { + jobs += (await host.jobs.cancel(jobId).catch(() => ({ ok: false }))).ok ? 1 : 0; + } + } + return { aborted, jobs }; +} + +const stopped = (result: { aborted: number; jobs: number }): string => + `${result.aborted > 0 ? `, and ${result.aborted} operation(s) running under it were cancelled where possible (what already left or ran cannot be taken back)` : ''}${result.jobs > 0 ? `; ${result.jobs} long job(s) it started were stopped` : ''}`; + +function metaWords(meta: MetaPreauth): string { + return `Meta ${meta.model}, ${describeMetaMax(meta.maxPerMission)} consultation(s) per Mission, ${meta.dataScope === 'files' ? 'instructions and the files each card chooses' : 'instructions only'}${meta.tokenBudget !== null ? `, at most ${meta.tokenBudget} tokens in total` : ''}, ${untilWords(meta.expiresAt)}`; +} + +/** "until ", or "until you turn it off" for a grant with no expiry. */ +function untilWords(at: number): string { + return untilTurnedOff(at) ? 'until you turn it off' : `until ${new Date(at).toLocaleString()}`; +} + +export async function handleAutonomyMessage(host: ProviderHost, message: AutonomyMessage, resolveScope: () => Promise<{ ok: true; scopeId: string; root: string; label: string } | { ok: false; error: string }>): Promise { + const self = host as AutonomyHost; + if (message.type === 'missionMetaScopeSet') { + // Kept until this Mission is authorized; validated again there and bound to its grant. + const byMission = metaScopes.get(host) ?? new Map(); + byMission.set(message.missionId, message.files); + metaScopes.set(host, byMission); + return; + } + const scope = await resolveScope(); + rememberAutonomyScope(host, scope.ok ? scope : null); + if (message.type === 'autonomyRefresh') { + await host.postState(); + return; + } + if (!scope.ok || self.autonomy === undefined) { + await say(host, `${scope.ok ? 'Autonomy & Permissions are not available here.' : scope.error} Nothing was pre-authorized.`); + return; + } + const store = self.autonomy; + const env = store.env(); + const now = Date.now(); + const current = store.get(scope.scopeId); + const base = () => ({ id: newPreauthId(), user: osUser(), envId: env.id, envLabel: env.label, scopeId: scope.scopeId }); + switch (message.type) { + case 'autonomyMode': { + if (message.mode === 'ask') { + const result = await revoke(self, store, scope.scopeId, ['meta', 'missionWeb', 'agentWeb', 'full'], true); + await say(host, `Autonomy & Permissions: Ask always for ${scope.label} on ${env.label}. Every pre-authorization of this workspace was revoked${stopped(result)}; each Meta consultation and each action shows its card again.`); + return; + } + if (current.mode === 'full') { + const result = await revoke(self, store, scope.scopeId, ['full'], false); + await say(host, `Full Access was turned off for ${scope.label}${stopped(result)}. Supervised autonomy: only the pre-authorizations below apply; everything else shows its card.`); + return; + } + await store.change(scope.scopeId, (policy) => { + policy.mode = 'supervised'; + }); + await say(host, `Autonomy & Permissions: supervised autonomy for ${scope.label} on ${env.label}. Nothing is pre-authorized yet: choose below what RAME may do without a card, and for how long.`); + return; + } + case 'autonomyMeta': { + const model = advisorModelNow(host); + if (current.mode === 'ask') { + await say(host, 'Choose supervised autonomy first (or Full Access). Nothing was pre-authorized.'); + return; + } + if (!host.settings.allowCloudEscalation || !host.hasKey || model.length === 0) { + await say(host, `Pre-authorizing Meta needs cloud escalation ON, a saved Meta API key and an advisor model for the main model${model.length === 0 ? ` (${advisorWhyNot(host)})` : ''}. Nothing was pre-authorized.`); + return; + } + const validityMs = message.validityHours * HOUR; + const confirmed = await confirm(self, metaConfirmation({ project: scope.label, model, maxPerMission: message.maxPerMission, validityMs, dataScope: message.dataScope, now, env: env.label, tokenBudget: message.tokenBudget ?? null })); + if (!confirmed || host.disposed) { + await say(host, 'Meta was not pre-authorized: the confirmation was not accepted. Each consultation still shows its card.'); + return; + } + const at = Date.now(); + abortPreauthorized([current.meta?.id]); + const meta: MetaPreauth = { + ...base(), project: scope.label, provider: META_PROVIDER, model, credentialRevision: host.metaCredential, + maxPerMission: message.maxPerMission, dataScope: message.dataScope, tokenBudget: message.tokenBudget ?? null, usedTokens: 0, adviceUses: {}, grantedAt: at, expiresAt: at + validityMs, + }; + await store.change(scope.scopeId, (policy) => { + policy.meta = meta; + }); + await say(host, `Pre-authorized for Missions of ${scope.label} on ${env.label}: ${metaWords(meta)}. Each Mission's card still chooses how many (0 by default).`); + return; + } + case 'autonomyMissionWeb': { + if (current.mode === 'ask') { + await say(host, 'Choose supervised autonomy first (or Full Access). Nothing was pre-authorized.'); + return; + } + const validityMs = message.validityHours * HOUR; + if (!(await confirm(self, missionWebConfirmation({ project: scope.label, validityMs, now, env: env.label }))) || host.disposed) { + await say(host, 'Web for Missions was not pre-authorized: the confirmation was not accepted.'); + return; + } + const at = Date.now(); + await store.change(scope.scopeId, (policy) => { + policy.missionWeb = { ...base(), grantedAt: at, expiresAt: at + validityMs }; + }); + await say(host, `Web budgets may be given to autonomous Missions of ${scope.label} until ${new Date(at + validityMs).toLocaleString()}; each Mission's card sets its own budget.`); + return; + } + case 'autonomySessionWeb': { + if (current.mode === 'ask') { + await say(host, 'Choose supervised autonomy first (or Full Access). Nothing was pre-authorized.'); + return; + } + if (host.settings.web?.enabled !== true) { + await say(host, 'Turn on Web access first (Settings → Security → Web access). Nothing was pre-authorized.'); + return; + } + const invalid = validateSessionWeb(message); + const checked = validateMissionWebRequest({ enabled: true, maxSearches: message.maxSearches, maxFetches: message.maxFetches, domains: message.domains }); + if (invalid !== null || !checked.ok) { + await say(host, `${invalid ?? (checked.ok ? '' : checked.error)} Nothing was pre-authorized.`); + return; + } + const domains = checked.grant?.domains ?? []; + const validityMs = message.validityHours * HOUR; + if (!(await confirm(self, sessionWebConfirmation({ project: scope.label, maxSearches: message.maxSearches, maxFetches: message.maxFetches, domains, validityMs, now, env: env.label }))) || host.disposed) { + await say(host, 'Web searches were not pre-authorized: the confirmation was not accepted. Each one still shows its card.'); + return; + } + const at = Date.now(); + abortPreauthorized([current.agentWeb?.id]); + await store.change(scope.scopeId, (policy) => { + policy.agentWeb = { ...base(), project: scope.label, maxSearches: message.maxSearches, maxFetches: message.maxFetches, searches: 0, fetches: 0, domains, found: [], grantedAt: at, expiresAt: at + validityMs }; + }); + await say(host, `Web pre-authorized for Agent and Chat in ${scope.label}, every window of this workspace on ${env.label}: up to ${message.maxSearches} search(es) and ${message.maxFetches} page read(s) without a card, until ${new Date(at + validityMs).toLocaleString()}.`); + return; + } + case 'autonomyFull': { + const validityMs = message.validityHours * HOUR; + // 0 hours: until the user turns it off (never expires by itself). + const endsAt = (from: number): number => (message.validityHours === 0 ? UNTIL_TURNED_OFF : from + validityMs); + const model = advisorModelNow(host); + if (message.tools.meta && (!host.settings.allowCloudEscalation || !host.hasKey || model.length === 0 || message.meta === undefined)) { + await say(host, `Full Access was not turned on: its Meta part needs cloud escalation ON, a saved Meta API key and an advisor model for the main model${model.length === 0 ? ` (${advisorWhyNot(host)})` : ''}. Nothing changed.`); + return; + } + // Meta as the main model: for the Meta model selected now and this API key, chosen on its own. + const mainModel = message.metaMain === true ? host.selectedCloudModel() : null; + if (mainModel !== null && (!host.hasKey || mainModel.length === 0)) { + await say(host, 'Full Access was not turned on: "Meta as the main model" needs a saved Meta API key and a selected Meta model. Nothing changed.'); + return; + } + // With its Meta part, the Meta pre-authorization is given in the same confirmation, for the selected model, provider, key and this environment. + const at0 = Date.now(); + const withMeta: MetaPreauth | null = message.tools.meta && message.meta !== undefined + ? { + ...base(), project: scope.label, provider: META_PROVIDER, model, credentialRevision: host.metaCredential, maxPerMission: message.meta.maxPerMission, + dataScope: message.meta.dataScope, tokenBudget: message.meta.tokenBudget, usedTokens: 0, adviceUses: {}, grantedAt: at0, expiresAt: endsAt(at0), + } + : null; + const meta = withMeta ?? metaInForce(host, scope.scopeId); + const request = fullConfirmation({ + project: scope.label, env: env.label, tools: message.tools, validityMs, now, + enabled: { commands: host.settings.shell?.enabled === true, installs: host.settings.installs?.enabled !== false, jobs: host.settings.jobs?.enabled === true, web: host.settings.web?.enabled === true }, + meta: meta === null ? null : metaWords(meta), + mainModel, + isolation: host.settings.shell?.isolation ?? 'project', + }); + if (!(await confirm(self, request)) || host.disposed) { + await say(host, 'Full Access was not turned on: the confirmation was not accepted. Every card still appears as before.'); + return; + } + const at = Date.now(); + const replacedMeta = current.meta !== null && (withMeta !== null || (current.meta.fullId !== undefined && current.meta.fullId === current.full?.id)) ? current.meta.id : null; + abortPreauthorized([current.full?.id, replacedMeta]); + await store.change(scope.scopeId, (policy) => { + const previous = policy.full?.id; + policy.mode = 'full'; + policy.full = { + ...base(), project: scope.label, tools: { ...message.tools }, jobs: [], grantedAt: at, expiresAt: endsAt(at), + mainModel: mainModel === null ? null : { model: mainModel, credentialRevision: host.metaCredential }, + }; + if (withMeta !== null) { + policy.meta = { ...withMeta, fullId: policy.full.id, grantedAt: at, expiresAt: endsAt(at) }; + } else if (policy.meta?.fullId !== undefined && policy.meta.fullId === previous) { + // Given with the Full Access this one replaces, without a Meta part now: it goes with it. + policy.meta = null; + } + }); + const parts = [ + ...FULL_TOOLS.filter((tool) => message.tools[tool]).map((tool) => (tool === 'meta' && withMeta !== null ? `${FULL_TOOL_WORDS.meta} (${metaWords(withMeta)})` : FULL_TOOL_WORDS[tool])), + ...(mainModel === null ? [] : [`Agent with Meta ${mainModel} as the main model (its requests and tool results go to Meta)`]), + ]; + await say(host, `Full Access is on for ${scope.label} on ${env.label} ${untilWords(endsAt(at))}: ${parts.join(', ')} run without their cards. Project memory, privilege elevation and the Mission authorization still ask; revoke it at any time.`); + return; + } + case 'autonomyRevoke': { + const what = message.what; + const parts = what === 'all' ? (['meta', 'missionWeb', 'agentWeb', 'full'] as const) : what === 'sessionWeb' ? (['agentWeb'] as const) : ([what] as const); + const result = await revoke(self, store, scope.scopeId, parts, false); + const label = what === 'all' ? 'Every pre-authorization of this workspace' : what === 'meta' ? 'The Meta pre-authorization' : what === 'missionWeb' ? 'The web pre-authorization for Missions' : what === 'full' ? 'Full Access' : 'The web pre-authorization of Agent and Chat'; + await say(host, `${label} was revoked in every window of this workspace on ${env.label}: no new operation runs under it${stopped(result)}. Anything else shows its card.`); + return; + } + } +} + +// ----- What the view shows ----- + +function missionAccess(host: AutonomyHost, mission: MissionRecord): string { + const parts: string[] = []; + const escalation = mission.grant?.escalation; + if (escalation?.preauth) { + const problem = metaRefusalNow(host, mission.scopeId, escalation.preauth); + const count = escalation.maxConsultations === null ? `${escalation.used} consultation(s) used, no count limit` : `${escalation.used} of ${escalation.maxConsultations} consultation(s) used`; + parts.push(`Meta ${escalation.model}: ${count}, without a card ${untilWords(escalation.preauth.expiresAt)}; data: ${escalation.preauth.files.length > 0 ? `instructions and ${escalation.preauth.files.map(describeScopedFile).join(', ')}` : 'instructions only'}${problem !== null ? ` — not in force: ${problem}` : ''}`); + } + const web = mission.grant?.web; + if (web && mission.grant?.mode === 'autonomous-workspace') { + const problem = missionWebRefusalNow(host, mission.scopeId, web.preauthId ?? null); + parts.push(`web: ${web.searches}/${web.maxSearches} search(es), ${web.fetches}/${web.maxFetches} page read(s)${problem !== null ? ` — not in force: ${problem}` : ''}`); + } + return parts.length > 0 ? `External access pre-authorized for this Mission: ${parts.join(' · ')}.` : ''; +} + +export function autonomyView(host: ProviderHost): AutonomyView | null { + const self = host as AutonomyHost; + const scope = scopes.get(host) ?? null; + const store = self.autonomy; + if (store === undefined) { + return null; + } + const env = store.env(); + const metaUnavailable = !host.settings.allowCloudEscalation ? 'Turn on cloud escalation first.' : !host.hasKey ? 'Save a Meta API key first.' : ''; + if (scope === null) { + return { project: null, env: env.label, note: 'Open a project folder to set its Autonomy & Permissions.', mode: 'ask', meta: null, missionWeb: null, sessionWeb: null, full: null, metaUnavailable, model: '', missions: [], log: [], inflight: inflightCount() }; + } + const policy = store.get(scope.scopeId); + const ctx = contextFor(store, scope.scopeId); + const metaProblem = metaRefusalNow(host, scope.scopeId, null); + const webProblem = policy.missionWeb === null ? null : missionWebRefusal({ ...policy, full: null }, null, ctx); + const agentWeb = policy.agentWeb !== null && ctx.now < policy.agentWeb.expiresAt ? policy.agentWeb : null; + const missions = host.missions.list().filter((mission) => mission.scopeId === scope.scopeId && mission.grant !== null).slice(-16); + const full = policy.full; + return { + project: scope.label, + env: env.label, + note: '', + mode: policy.mode, + meta: policy.meta === null ? null : { + model: policy.meta.model, maxPerMission: policy.meta.maxPerMission, dataScope: policy.meta.dataScope, grantedAt: policy.meta.grantedAt, expiresAt: policy.meta.expiresAt, + tokenBudget: policy.meta.tokenBudget, usedTokens: policy.meta.usedTokens, problem: metaProblem ?? '', + }, + missionWeb: policy.missionWeb === null ? null : { grantedAt: policy.missionWeb.grantedAt, expiresAt: policy.missionWeb.expiresAt, problem: webProblem ?? '' }, + sessionWeb: agentWeb === null ? null : { maxSearches: agentWeb.maxSearches, maxFetches: agentWeb.maxFetches, searches: agentWeb.searches, fetches: agentWeb.fetches, domains: [...agentWeb.domains], expiresAt: agentWeb.expiresAt }, + full: full === null ? null : { tools: { ...full.tools }, grantedAt: full.grantedAt, expiresAt: full.expiresAt, jobs: full.jobs.length, problem: (FULL_TOOLS.some((tool) => full.tools[tool]) ? fullRefusal(policy, ctx, FULL_TOOLS.find((tool) => full.tools[tool]) ?? 'files') : null) ?? '', mainModel: full.mainModel?.model ?? null }, + metaUnavailable, + model: advisorModelNow(host), + missions: missions.flatMap((mission) => { + const text = missionAccess(self, mission); + return text.length > 0 ? [{ missionId: mission.id, text }] : []; + }), + log: [...policy.log].reverse().slice(0, 30).map((entry) => ({ at: entry.at, provider: entry.provider, destination: entry.destination, reason: entry.reason, data: entry.data, approval: entry.approval, outcome: entry.outcome, bytes: entry.bytes })), + inflight: inflightCount(), + }; +} + +export type { ExternalApproval }; + +/** Why there is no advisor now, for a status. */ +function advisorWhyNot(host: ProviderHost): string { + try { + const advisor = advisorFor(host); + return advisor.ok ? 'it is available' : advisor.reason; + } catch { + return 'no advisor model is chosen'; + } +} diff --git a/src/autonomyHtml.ts b/src/autonomyHtml.ts new file mode 100644 index 0000000..bf9904d --- /dev/null +++ b/src/autonomyHtml.ts @@ -0,0 +1,100 @@ +/** + * Markup of Autonomy & Permissions (Settings → Security) and of its part of + * a Mission's authorization card. Rendered and wired by media/autonomyUx.js; + * every value shown comes from the host. + */ + +const hoursOptions = (selected: number, week: boolean): string => + [[1, '1 hour'], [8, '8 hours'], [24, '24 hours'], ...(week ? [[168, '7 days'], [720, '30 days']] : [])] + .map(([value, label]) => ``) + .join(''); + +export const autonomyHtml = ` +
+

Autonomy & Permissions

+

+

+
+ Mode for this workspace on this computer or server + + + +
+ + +
+
+ External requests (history) +

Recorded before each request leaves: provider, destination, date, reason, what was sent and how it was approved.

+
    +
    +
    +`; + +export const missionAutonomyHtml = ` +
    + Autonomy & Permissions +

    + +
    +
    +`; diff --git a/src/autonomyPolicy.ts b/src/autonomyPolicy.ts new file mode 100644 index 0000000..f62cbca --- /dev/null +++ b/src/autonomyPolicy.ts @@ -0,0 +1,739 @@ +/** + * Autonomy & Permissions: what the user pre-authorized, per workspace and + * execution environment, so that some operations run without a card. + * + * Free of the `vscode` API so it can be unit tested with plain `node:test`. + * + * Three modes, per workspace and environment (never inherited by another + * project, computer or Remote SSH server), independent of the Agent's tool + * profile (choosing Workspace full never turns any of them on): + * - `ask` (default): every Meta consultation and every action that needs + * an authorization shows its card; + * - `supervised`: separate, bounded, expiring pre-authorizations for Meta + * consultations in Missions, for web budgets in autonomous Missions, and + * for web searches and page reads in Agent and Chat; + * - `full` (Full Access): on top of those, the parts the user chose + * (files, tests, terminal commands, installs, long jobs, web) run + * without their cards. It never bypasses the operating system's + * permissions (a command that elevates privileges shows its card), the + * provider's permissions, protected files, the verifier's files and + * acceptance check, the Mission's authorization card, project memory + * (always a card) or the time and output limits of each tool. + * Each one is given only by the user, through the view and a modal + * confirmation of the host, never by the model. Each has a random id and is + * bound to the user, the environment and the workspace (Meta: also the + * provider, the model and the key revision). Revoking one, or giving a new + * one, makes every earlier binding invalid. The host checks all of it right + * before each operation, reading the stored policy afresh. + */ + +import { randomBytes } from 'node:crypto'; + +export const AUTONOMY_STATE_KEY = 'rameCode.autonomy.v1'; +export type AutonomyMode = 'ask' | 'supervised' | 'full'; +export const META_PROVIDER = 'Meta Model API'; +export const VALIDITY_HOURS: readonly number[] = [1, 8, 24, 168, 720]; +/** Full Access may also last until the user turns it off (0 hours): stored as the last instant a Date can hold, so every expiry check still applies. */ +export const FULL_VALIDITY_HOURS: readonly number[] = [0, ...VALIDITY_HOURS]; +export const UNTIL_TURNED_OFF = 8_640_000_000_000_000; +export const untilTurnedOff = (at: number): boolean => at >= UNTIL_TURNED_OFF; +export const MAX_SCOPED_FILES = 3; +export const MAX_FRAGMENT_LINES = 400; +export const MAX_LOG_ENTRIES = 200; +export const SESSION_WEB_MAX_SEARCHES = 20; +export const SESSION_WEB_MAX_FETCHES = 40; +/** What a Meta pre-authorization may allow per Mission; null = no count limit (the byte and token limits remain). */ +export const META_MAX_CHOICES: ReadonlyArray = [1, 5, 10, 100, 1000, null]; +/** What a Mission's card may choose; -1 = no count limit (within the pre-authorization). */ +export const MISSION_CONSULT_CHOICES: readonly number[] = [0, 1, 2, 3, 4, 5, 10, 100, 1000, -1]; +export const MAX_FINITE_CONSULTATIONS = 1000; +/** A total of remote tokens for every Mission under one pre-authorization; null = only each Mission's own limits. */ +export const META_TOKEN_BUDGETS: ReadonlyArray = [null, 100_000, 1_000_000, 10_000_000]; +const MAX_FOUND_URLS = 200; +const MAX_FULL_JOBS = 50; + +/** + * The parts of Full Access. `meta`: Agent's advice after the main model stops and a + * Mission's acceptance review are sent without their card, only within the + * Meta pre-authorization in force (provider, model, key, data scope, counts + * and token budget); without one it allows nothing. + */ +export const FULL_TOOLS = ['files', 'tests', 'commands', 'installs', 'jobs', 'web', 'meta'] as const; +export type FullTool = (typeof FULL_TOOLS)[number]; +export type FullTools = Record; + +/** Where a grant applies: the environment's id and how the user saw it. */ +interface Bound { + id: string; + /** The operating-system user who gave it. */ + user: string; + envId: string; + envLabel: string; + scopeId: string; + grantedAt: number; + expiresAt: number; +} + +export interface MetaPreauth extends Bound { + project: string; + provider: typeof META_PROVIDER; + model: string; + /** Revision of the stored Meta API key: a new key never inherits it. */ + credentialRevision: string; + /** The most a Mission may be given; null = no count limit. Each Mission's card chooses 0 to this. */ + maxPerMission: number | null; + /** `instructions`: the Mission's text and the host's notes; `files`: also files chosen on each Mission's card. */ + dataScope: 'instructions' | 'files'; + /** Remote tokens all its Missions may use together; null = only each Mission's limits. */ + tokenBudget: number | null; + usedTokens: number; + /** Consultations sent under Full Access outside a Mission's own escalation, by Agent conversation or Mission: each within `maxPerMission`. */ + adviceUses: Record; + /** Given with Full Access (its Meta part, one confirmation): revoked and replaced with that grant. */ + fullId?: string; +} + +/** Permission to give autonomous Missions of this workspace a web budget. */ +export type MissionWebPreauth = Bound; + +/** Web searches and page reads for Agent and Chat, shared by every window of this workspace and environment. */ +export interface AgentWebPreauth extends Bound { + project: string; + maxSearches: number; + maxFetches: number; + searches: number; + fetches: number; + domains: string[]; + /** URLs its searches returned: the only pages it may read when no domain is listed. */ + found: string[]; +} + +/** Full Access: the parts the user chose run without their cards. */ +export interface FullAccess extends Bound { + project: string; + tools: FullTools; + /** + * Meta as the main model (its own choice, apart from the tool parts): an + * Agent run with this exact Meta model and API key sends its request and + * its tools' results without the disclosure card. Null (or absent, in a + * grant stored before it existed): not included, the card stays. + */ + mainModel?: { model: string; credentialRevision: string } | null; + /** Long jobs started under it, stopped when it is revoked (from any window, also after a reload). */ + jobs: string[]; +} + +export type ExternalApproval = 'card' | 'pre-authorized' | 'mission-grant' | 'profile' | 'full-access'; + +/** One request that left this computer, recorded before it was sent. */ +export interface ExternalEntry { + id: string; + at: number; + provider: string; + destination: string; + reason: string; + /** What was sent, in words (never the content itself). */ + data: string; + approval: ExternalApproval; + preauthId: string | null; + missionId: string | null; + bytes: number | null; + outcome: string; +} + +export interface WorkspaceAutonomy { + mode: AutonomyMode; + meta: MetaPreauth | null; + missionWeb: MissionWebPreauth | null; + agentWeb: AgentWebPreauth | null; + full: FullAccess | null; + log: ExternalEntry[]; +} + +/** A file or fragment a Mission's card allows Meta to receive. */ +export interface ScopedFile { + path: string; + start?: number; + end?: number; +} + +/** What a Mission's escalation was authorized under. */ +export interface MetaBinding { + preauthId: string; + user: string; + scopeId: string; + envId?: string; + provider: typeof META_PROVIDER; + model: string; + credentialRevision: string; + expiresAt: number; + files: ScopedFile[]; +} + +export const emptyAutonomy = (): WorkspaceAutonomy => ({ mode: 'ask', meta: null, missionWeb: null, agentWeb: null, full: null, log: [] }); + +export const newPreauthId = (): string => `pre_${randomBytes(8).toString('hex')}`; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +const str = (value: unknown, max: number): value is string => typeof value === 'string' && value.length > 0 && value.length <= max; +const time = (value: unknown): value is number => typeof value === 'number' && Number.isInteger(value) && value >= 0; +const ID = /^pre_[0-9a-f]{16}$/; +const ENV = /^env_[0-9a-f]{16}$/; +const DOMAIN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/; + +/** The common part of every grant, or null when it is not one of this workspace and environment. */ +function parseBound(raw: unknown, scopeId: string, envId: string): Bound | null { + if ( + !isRecord(raw) || typeof raw.id !== 'string' || !ID.test(raw.id) || !str(raw.user, 128) || raw.scopeId !== scopeId || + typeof raw.envId !== 'string' || !ENV.test(raw.envId) || raw.envId !== envId || !str(raw.envLabel, 300) || + !time(raw.grantedAt) || !time(raw.expiresAt) || raw.expiresAt <= raw.grantedAt + ) { + return null; + } + return { id: raw.id, user: raw.user, envId: raw.envId, envLabel: raw.envLabel, scopeId, grantedAt: raw.grantedAt, expiresAt: raw.expiresAt }; +} + +/** Conversations and Missions whose Full Access consultations are counted (the oldest are forgotten first). */ +export const MAX_ADVICE_KEYS = 64; +/** A blocked Mission is retried with Meta's advice under Full Access at most this many times in a row (then it waits for the user). */ +export const MAX_AUTO_RETRIES = 3; + +export const isMetaMax = (value: unknown): value is number | null => value === null || (typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= MAX_FINITE_CONSULTATIONS); + +function parseMeta(raw: unknown, scopeId: string, envId: string): MetaPreauth | null { + const bound = parseBound(raw, scopeId, envId); + if ( + bound === null || !isRecord(raw) || typeof raw.project !== 'string' || raw.provider !== META_PROVIDER || !str(raw.model, 128) || !str(raw.credentialRevision, 64) || + !isMetaMax(raw.maxPerMission) || (raw.dataScope !== 'instructions' && raw.dataScope !== 'files') || + !(raw.tokenBudget === null || (time(raw.tokenBudget) && raw.tokenBudget > 0)) || !time(raw.usedTokens) || + !(raw.adviceUses === undefined || (isRecord(raw.adviceUses) && Object.keys(raw.adviceUses).length <= MAX_ADVICE_KEYS && Object.entries(raw.adviceUses).every(([key, used]) => key.length > 0 && key.length <= 64 && time(used) && used <= MAX_FINITE_CONSULTATIONS))) + ) { + return null; + } + return { + ...bound, project: raw.project.slice(0, 200), provider: META_PROVIDER, model: raw.model, credentialRevision: raw.credentialRevision, + maxPerMission: raw.maxPerMission, dataScope: raw.dataScope, tokenBudget: raw.tokenBudget, usedTokens: raw.usedTokens, + adviceUses: { ...((raw.adviceUses as Record | undefined) ?? {}) }, + ...(typeof raw.fullId === 'string' && ID.test(raw.fullId) ? { fullId: raw.fullId } : {}), + }; +} + +function parseAgentWeb(raw: unknown, scopeId: string, envId: string): AgentWebPreauth | null { + const bound = parseBound(raw, scopeId, envId); + const count = (value: unknown, max: number) => time(value) && value <= max; + if ( + bound === null || !isRecord(raw) || typeof raw.project !== 'string' || + !count(raw.maxSearches, SESSION_WEB_MAX_SEARCHES) || !count(raw.maxFetches, SESSION_WEB_MAX_FETCHES) || + !count(raw.searches, raw.maxSearches as number) || !count(raw.fetches, raw.maxFetches as number) || + !Array.isArray(raw.domains) || raw.domains.length > 10 || !raw.domains.every((domain) => typeof domain === 'string' && DOMAIN.test(domain)) || + !Array.isArray(raw.found) || !raw.found.every((url) => typeof url === 'string' && url.length <= 2048) + ) { + return null; + } + return { + ...bound, project: raw.project.slice(0, 200), maxSearches: raw.maxSearches as number, maxFetches: raw.maxFetches as number, searches: raw.searches as number, fetches: raw.fetches as number, + domains: [...(raw.domains as string[])], found: (raw.found as string[]).slice(-MAX_FOUND_URLS), + }; +} + +function parseFull(raw: unknown, scopeId: string, envId: string): FullAccess | null { + const bound = parseBound(raw, scopeId, envId); + if (bound === null || !isRecord(raw) || typeof raw.project !== 'string' || !isRecord(raw.tools) || !Array.isArray(raw.jobs)) { + return null; + } + const tools = raw.tools; + const main = raw.mainModel === undefined || raw.mainModel === null ? null : parseMainModel(raw.mainModel); + if (main === undefined || Object.keys(tools).length !== FULL_TOOLS.length || !FULL_TOOLS.every((name) => typeof tools[name] === 'boolean') || (!FULL_TOOLS.some((name) => tools[name] === true) && main === null)) { + return null; + } + if (!raw.jobs.every((job) => typeof job === 'string' && job.length <= 64)) { + return null; + } + return { ...bound, project: raw.project.slice(0, 200), tools: Object.fromEntries(FULL_TOOLS.map((name) => [name, tools[name] === true])) as FullTools, jobs: (raw.jobs as string[]).slice(-MAX_FULL_JOBS), mainModel: main }; +} + +/** A stored "Meta as the main model" part; undefined when it is malformed (the whole grant is then refused). */ +function parseMainModel(raw: unknown): { model: string; credentialRevision: string } | undefined { + return isRecord(raw) && Object.keys(raw).length === 2 && typeof raw.model === 'string' && /^muse-[A-Za-z0-9._-]{1,120}$/.test(raw.model) && typeof raw.credentialRevision === 'string' && raw.credentialRevision.length > 0 && raw.credentialRevision.length <= 64 + ? { model: raw.model, credentialRevision: raw.credentialRevision } + : undefined; +} + +function parseEntry(raw: unknown): ExternalEntry | null { + if (!isRecord(raw) || !str(raw.id, 40) || !time(raw.at)) { + return null; + } + const text = (value: unknown, max: number) => (typeof value === 'string' ? value.slice(0, max) : ''); + const approval: ExternalApproval = (['card', 'pre-authorized', 'mission-grant', 'profile', 'full-access'] as const).find((known) => known === raw.approval) ?? 'card'; + return { + id: raw.id, at: raw.at, provider: text(raw.provider, 80), destination: text(raw.destination, 200), reason: text(raw.reason, 300), data: text(raw.data, 400), approval, + preauthId: typeof raw.preauthId === 'string' && ID.test(raw.preauthId) ? raw.preauthId : null, + missionId: typeof raw.missionId === 'string' && raw.missionId.length <= 64 ? raw.missionId : null, + bytes: time(raw.bytes) ? raw.bytes : null, + outcome: text(raw.outcome, 300), + }; +} + +/** + * The stored policy of one workspace in one environment. Anything + * malformed, or given in another environment, is dropped to the safe side: + * no pre-authorization, mode `ask`. `full` without its grant reads as + * `supervised`. + */ +export function parseWorkspaceAutonomy(raw: unknown, scopeId: string, envId: string): WorkspaceAutonomy { + if (!isRecord(raw)) { + return emptyAutonomy(); + } + const log = Array.isArray(raw.log) ? raw.log.slice(-MAX_LOG_ENTRIES).map(parseEntry).filter((entry): entry is ExternalEntry => entry !== null) : []; + const open = raw.mode === 'supervised' || raw.mode === 'full'; + const full = raw.mode === 'full' ? parseFull(raw.full, scopeId, envId) : null; + return { + mode: full !== null ? 'full' : open ? 'supervised' : 'ask', + meta: open ? parseMeta(raw.meta, scopeId, envId) : null, + missionWeb: open ? parseBound(raw.missionWeb, scopeId, envId) : null, + agentWeb: open ? parseAgentWeb(raw.agentWeb, scopeId, envId) : null, + full, + log, + }; +} + +/** What every check knows about the moment of the operation. */ +export interface GrantContext { + now: number; + user: string; + scopeId: string; + envId: string; +} + +/** Why a grant does not apply in this context, or null. */ +function boundRefusal(grant: Bound, ctx: GrantContext, what: string): string | null { + if (grant.scopeId !== ctx.scopeId) { + return `the ${what} was given for another workspace`; + } + if (ctx.now >= grant.expiresAt) { + return `the ${what} expired at ${new Date(grant.expiresAt).toISOString()}`; + } + if (grant.envId !== ctx.envId) { + return `the ${what} was given on another computer or server (${grant.envLabel})`; + } + if (grant.user !== ctx.user) { + return `the ${what} was given by another user of this computer`; + } + return null; +} + +/** Everything the host knows at the moment of a Meta request. */ +export interface MetaContext extends GrantContext { + /** The advisor model a consultation would go to now (advisorHost.ts). */ + model: string; + credentialRevision: string; + cloudEscalation: boolean; +} + +/** + * Why no Meta consultation may be sent without a card now, or null. With a + * binding, also checks that the Mission was authorized under this very + * pre-authorization and that its files stay within its scope. + */ +export function metaRefusal(policy: WorkspaceAutonomy, binding: MetaBinding | null, ctx: MetaContext): string | null { + const meta = policy.meta; + if (policy.mode === 'ask') { + return 'Autonomy & Permissions is set to Ask always for this workspace'; + } + if (meta === null) { + return 'no Meta pre-authorization is in force for this workspace (never given, revoked or replaced)'; + } + const bound = boundRefusal(meta, ctx, 'Meta pre-authorization'); + if (bound !== null) { + return bound; + } + if (meta.credentialRevision !== ctx.credentialRevision) { + return 'the Meta API key changed after the pre-authorization was given'; + } + if (meta.model !== ctx.model) { + return `the pre-authorization covers Meta ${meta.model}, and the advisor model now is ${ctx.model || 'none'}`; + } + if (!ctx.cloudEscalation) { + return 'cloud escalation is OFF in Settings'; + } + if (meta.tokenBudget !== null && meta.usedTokens >= meta.tokenBudget) { + return `the pre-authorization's budget of ${meta.tokenBudget} remote tokens is used up`; + } + if (binding !== null) { + if (binding.preauthId !== meta.id) { + return 'this Mission was authorized under an earlier pre-authorization that was revoked or replaced'; + } + if (binding.scopeId !== meta.scopeId || binding.user !== meta.user || binding.model !== meta.model || binding.provider !== meta.provider || binding.credentialRevision !== meta.credentialRevision || (binding.envId !== undefined && binding.envId !== meta.envId)) { + return 'this Mission was authorized for another workspace, computer, user, provider, model or key'; + } + if (binding.files.length > 0 && meta.dataScope !== 'files') { + return 'the pre-authorization covers only the Mission instructions, not files'; + } + } + return null; +} + +/** A Mission's count as the pre-authorization sees it: within it, or why not. */ +export function missionCountRefusal(meta: MetaPreauth, maxConsultations: number | null): string | null { + if (meta.maxPerMission === null) { + return null; + } + return maxConsultations === null || maxConsultations > meta.maxPerMission ? `The Meta pre-authorization allows at most ${meta.maxPerMission} consultation(s) per Mission.` : null; +} + +/** Why Full Access does not cover `tool` now, or null. */ +export function fullRefusal(policy: WorkspaceAutonomy, ctx: GrantContext, tool: FullTool): string | null { + const full = policy.full; + if (policy.mode !== 'full' || full === null) { + return 'Full Access is not on for this workspace'; + } + const bound = boundRefusal(full, ctx, 'Full Access'); + if (bound !== null) { + return bound; + } + return full.tools[tool] ? null : `Full Access does not include ${FULL_TOOL_WORDS[tool]}`; +} + +/** + * Why Full Access does not let an Agent run with Meta as the main model go + * without the disclosure card now (null when it does): it must include that + * part, for this very Meta model and API key, in this workspace and + * environment, and be in force. Its tool parts never do. + */ +export function fullMainModelRefusal(policy: WorkspaceAutonomy, ctx: GrantContext, now: { model: string; credentialRevision: string }): string | null { + const full = policy.full; + if (policy.mode !== 'full' || full === null) { + return 'Full Access is not on for this workspace'; + } + const bound = boundRefusal(full, ctx, 'Full Access'); + if (bound !== null) { + return bound; + } + const main = full.mainModel ?? null; + if (main === null) { + return 'Full Access does not include Meta as the main model (its other parts authorize local tools only)'; + } + if (main.model !== now.model) { + return `Full Access covers Meta ${main.model} as the main model, and the main model now is ${now.model}`; + } + return main.credentialRevision === now.credentialRevision ? null : 'Full Access was given for another Meta API key'; +} + +/** What a consultation sent under Full Access needs of the Meta pre-authorization. */ +export interface FullMetaNeed { + /** `files`: it carries project files (the acceptance review's test fragments). */ + dataScope: 'instructions' | 'files'; + /** Agent's advice: the conversation (or Mission) whose consultations are counted against `maxPerMission`. */ + key?: string; + /** The acceptance review: the count the user chose for it, which must fit `maxPerMission` (null = no count limit). */ + count?: number | null; +} + +/** + * Why a Meta consultation outside a Mission's own escalation may not be sent + * without its card now, or null: Full Access must include Meta, and the Meta + * pre-authorization in force must cover this provider, model, key, + * environment, data scope, count and token budget. + */ +export function fullMetaRefusal(policy: WorkspaceAutonomy, ctx: MetaContext, need: FullMetaNeed): string | null { + const full = fullRefusal(policy, ctx, 'meta'); + if (full !== null) { + return full; + } + const meta = metaRefusal(policy, null, ctx); + if (meta !== null || policy.meta === null) { + return meta ?? 'no Meta pre-authorization is in force for this workspace'; + } + if (need.dataScope === 'files' && policy.meta.dataScope !== 'files') { + return 'the Meta pre-authorization covers only instructions and the host\'s notes, not project files'; + } + if (need.count !== undefined) { + const over = missionCountRefusal(policy.meta, need.count); + if (over !== null) { + return over.replace(/\.$/, ''); + } + } + if (need.key !== undefined && policy.meta.maxPerMission !== null && (policy.meta.adviceUses[need.key] ?? 0) >= policy.meta.maxPerMission) { + return `the Meta pre-authorization allows at most ${policy.meta.maxPerMission} consultation(s) per Mission or Agent conversation, and this one has used them`; + } + return null; +} + +/** Why web budgets for autonomous Missions are not pre-authorized now, or null; Full Access with web counts as one. */ +export function missionWebRefusal(policy: WorkspaceAutonomy, preauthId: string | null, ctx: GrantContext): string | null { + const none = 'web budgets for autonomous Missions are not pre-authorized for this workspace (Autonomy & Permissions)'; + if (policy.mode === 'ask') { + return none; + } + const web = policy.missionWeb; + const what = 'web pre-authorization for Missions'; + const fullId = fullRefusal(policy, ctx, 'web') === null ? (policy.full?.id ?? null) : null; + const webId = web !== null && boundRefusal(web, ctx, what) === null ? web.id : null; + if (preauthId === null) { + return fullId !== null || webId !== null ? null : web !== null ? boundRefusal(web, ctx, what) : none; + } + if (preauthId === fullId || preauthId === webId) { + return null; + } + if (web !== null && preauthId === web.id) { + return boundRefusal(web, ctx, what); + } + if (policy.full !== null && preauthId === policy.full.id) { + return fullRefusal(policy, ctx, 'web'); + } + return 'this Mission was authorized under an earlier web pre-authorization that was revoked or replaced'; +} + +/** The id an autonomous Mission's web budget is bound to now: the Missions web pre-authorization, or Full Access with web. */ +export function missionWebGrantId(policy: WorkspaceAutonomy, ctx: GrantContext): string | null { + if (policy.missionWeb !== null && policy.mode !== 'ask' && boundRefusal(policy.missionWeb, ctx, 'web') === null) { + return policy.missionWeb.id; + } + return fullRefusal(policy, ctx, 'web') === null ? (policy.full?.id ?? null) : null; +} + +/** + * A web call of Agent or Chat the host is about to make: which grant lets + * it through without a card, or null (it shows its card). Under the Agent + * and Chat web pre-authorization it consumes one slot of `policy`, which + * the caller changes inside the store's queued, exclusive update, so two + * calls, from any windows, never share a slot. + */ +export function useAgentWeb( + policy: WorkspaceAutonomy, + request: { tool: 'web_search' | 'web_fetch'; queryRefusal: string | null; host: string; url: string }, + ctx: GrantContext, + domainAllowed: (host: string, domains: readonly string[]) => boolean, +): { approval: 'full-access' | 'pre-authorized'; id: string } | null { + // Full Access with web asks nothing: what must never leave is removed (a query) or refused (an address) when the call runs. + if (fullRefusal(policy, ctx, 'web') === null && policy.full !== null) { + return { approval: 'full-access', id: policy.full.id }; + } + if (request.queryRefusal !== null) { + return null; + } + const grant = policy.agentWeb; + if (policy.mode === 'ask' || grant === null || boundRefusal(grant, ctx, 'web pre-authorization') !== null) { + return null; + } + if (request.tool === 'web_search') { + if (grant.searches >= grant.maxSearches) { + return null; + } + grant.searches += 1; + return { approval: 'pre-authorized', id: grant.id }; + } + const readable = grant.domains.length > 0 ? domainAllowed(request.host, grant.domains) : grant.found.includes(request.url); + if (!readable || grant.fetches >= grant.maxFetches) { + return null; + } + grant.fetches += 1; + return { approval: 'pre-authorized', id: grant.id }; +} + +/** Parse what a Mission's card lists: `path` or `path:10-80`; at most 3, no duplicates. */ +export function parseScopedFiles(raw: readonly string[]): { ok: true; files: ScopedFile[] } | { ok: false; error: string } { + const files: ScopedFile[] = []; + for (const entry of raw.map((value) => value.trim()).filter((value) => value.length > 0)) { + const match = /^(.+?)(?::(\d{1,6})-(\d{1,6}))?$/.exec(entry); + const file = match?.[1]?.replace(/\\/g, '/').replace(/^\.\//, '') ?? ''; + if (file.length === 0 || file.length > 512) { + return { ok: false, error: `"${entry.slice(0, 80)}" is not a project path.` }; + } + const start = match?.[2] === undefined ? undefined : Number(match[2]); + const end = match?.[3] === undefined ? undefined : Number(match[3]); + if (start !== undefined && end !== undefined && (start < 1 || end < start || end - start + 1 > MAX_FRAGMENT_LINES)) { + return { ok: false, error: `The fragment ${entry.slice(0, 80)} must be lines from 1 on, at most ${MAX_FRAGMENT_LINES} of them.` }; + } + if (files.some((known) => known.path === file && known.start === start)) { + continue; + } + files.push({ path: file, ...(start !== undefined && end !== undefined ? { start, end } : {}) }); + } + if (files.length > MAX_SCOPED_FILES) { + return { ok: false, error: `At most ${MAX_SCOPED_FILES} files or fragments can be given to Meta.` }; + } + return { ok: true, files }; +} + +export const describeScopedFile = (file: ScopedFile): string => (file.start !== undefined ? `${file.path}:${file.start}-${file.end}` : file.path); + +/** A stored binding; null when absent, undefined when malformed (the whole grant is refused). */ +export function parseMetaBinding(raw: unknown): MetaBinding | null | undefined { + if (raw === undefined || raw === null) { + return null; + } + if ( + !isRecord(raw) || typeof raw.preauthId !== 'string' || !ID.test(raw.preauthId) || !str(raw.user, 128) || !str(raw.scopeId, 200) || + raw.provider !== META_PROVIDER || !str(raw.model, 128) || !str(raw.credentialRevision, 64) || !time(raw.expiresAt) || !Array.isArray(raw.files) || raw.files.length > MAX_SCOPED_FILES || + (raw.envId !== undefined && (typeof raw.envId !== 'string' || !ENV.test(raw.envId))) + ) { + return undefined; + } + const files: ScopedFile[] = []; + for (const file of raw.files as unknown[]) { + if (!isRecord(file) || !str(file.path, 512)) { + return undefined; + } + const range = file.start !== undefined || file.end !== undefined; + if (range && (!time(file.start) || !time(file.end) || file.start < 1 || file.end < file.start)) { + return undefined; + } + files.push({ path: file.path, ...(range ? { start: file.start as number, end: file.end as number } : {}) }); + } + return { + preauthId: raw.preauthId, user: raw.user, scopeId: raw.scopeId, ...(typeof raw.envId === 'string' ? { envId: raw.envId } : {}), + provider: META_PROVIDER, model: raw.model, credentialRevision: raw.credentialRevision, expiresAt: raw.expiresAt, files, + }; +} + +// ----- What the user reads before saying yes ----- + +const when = (at: number): string => new Date(at).toLocaleString(); +const hours = (ms: number): string => (ms % 86_400_000 === 0 ? `${ms / 86_400_000} day(s)` : `${Math.round(ms / 3_600_000)} hour(s)`); +export const describeMetaMax = (max: number | null): string => (max === null ? 'no count limit' : `at most ${max}`); +const REVOKE = 'You can revoke it at any time in Settings → Security → Autonomy & Permissions, from any window of this workspace on this computer or server.'; + +/** The modal confirmation of a Meta pre-authorization: every point the user must know. */ +export function metaConfirmation(input: { + project: string; model: string; maxPerMission: number | null; validityMs: number; dataScope: 'instructions' | 'files'; now: number; env?: string; tokenBudget?: number | null; +}): { message: string; detail: string; action: string } { + const budget = input.tokenBudget ?? null; + return { + message: `Allow RAME Code to consult Meta ${input.model} without asking each time, in the Missions of ${input.project}?`, + detail: [ + `• Information leaves this computer: it is sent to ${META_PROVIDER} (Meta), model ${input.model}${input.env ? `, from ${input.env}` : ''}.`, + input.dataScope === 'files' + ? '• It can include code and fragments of project files: the Mission goal and instructions, the host\'s notes on the run, the failing check, project decisions, and the files or fragments you choose on each Mission\'s card.' + : '• It can include code and fragments of project files quoted in the Mission goal and instructions, the host\'s notes on the run, the failing check and project decisions. No file is attached.', + '• Never sent automatically: secrets or credentials, the verifier\'s hidden files, seeds, protected records, web pages. A consultation that would need more than this asks again with the exact text.', + '• Consultations use your Meta API key and may be billed by Meta. RAME Code has no Meta price list, so it cannot cap the cost in money: it caps tokens.', + input.maxPerMission === null + ? '• No count limit per Mission: each Mission\'s card chooses how many (0 by default, or no count limit). Each consultation keeps its limits of bytes and tokens, and each Mission its totals.' + : `• At most ${input.maxPerMission} consultation(s) per Mission (each Mission's card chooses 0 to ${input.maxPerMission}; 0 by default).`, + budget === null ? '• No total token budget across Missions: each Mission\'s own byte and token limits apply.' : `• At most ${budget} remote tokens in total, across every Mission under this pre-authorization.`, + '• Outside Missions it allows nothing by itself. Only if you also turn on Full Access with its Meta part, Agent\'s advice after the main model stops and the acceptance review use these same limits (per Agent conversation or Mission) without their card.', + `• Valid for ${hours(input.validityMs)}, until ${when(input.now + input.validityMs)}, for this workspace, ${input.env ?? 'this computer'}, your user, this API key and this model only. Other projects, computers and servers do not inherit it.`, + `• ${REVOKE} A consultation being sent is cancelled when possible; what was already sent cannot be taken back.`, + '• RAME never switches the main model of a Mission: the advisor only advises, receives no tools, and the host\'s checks still decide.', + ].join('\n'), + action: 'Pre-authorize Meta', + }; +} + +export function missionWebConfirmation(input: { project: string; validityMs: number; now: number; env?: string }): { message: string; detail: string; action: string } { + return { + message: `Allow autonomous Missions of ${input.project} to be given a web budget?`, + detail: [ + '• Searches and page reads leave this computer to the search engine and the sites read, without a card for each one, within the budget each Mission\'s card sets.', + '• Queries must be plain words: never code, keys, credentials or file paths. Nothing else is sent: no files, no Meta, no write, terminal or memory permission comes with it.', + `• Valid for ${hours(input.validityMs)}, until ${when(input.now + input.validityMs)}, for this workspace, ${input.env ?? 'this computer'} and your user only.`, + `• ${REVOKE}`, + ].join('\n'), + action: 'Pre-authorize web for Missions', + }; +} + +export function sessionWebConfirmation(input: { project: string; maxSearches: number; maxFetches: number; domains: string[]; validityMs: number; now: number; env?: string }): { message: string; detail: string; action: string } { + return { + message: `Allow Agent and Chat in ${input.project} to search and read web pages without a card each time?`, + detail: [ + `• Up to ${input.maxSearches} search(es) and ${input.maxFetches} page read(s) in total, shared by every window of this workspace on ${input.env ?? 'this computer'}, until ${when(input.now + input.validityMs)}.`, + input.domains.length > 0 + ? `• Pages are read without a card only on ${input.domains.join(', ')}; any other page shows its card.` + : '• Pages are read without a card only when one of these searches returned them; any other page shows its card.', + '• A query is sent without a card only if it is plain words: code, keys, credentials, file paths or long quotes show the card (or are refused).', + '• Only the query or the address leaves this computer: no file, no credential, no private information. Web access stays read-only (GET); nothing is published or changed.', + `• ${REVOKE}`, + ].join('\n'), + action: 'Pre-authorize web searches', + }; +} + +export const FULL_TOOL_WORDS: Readonly> = { + files: 'workspace file operations', + tests: 'allowlisted tests', + commands: 'terminal commands', + installs: 'installs', + jobs: 'long jobs', + web: 'web searches and page reads', + meta: 'Meta consultations when the main model cannot finish', +}; + +/** What Full Access tells the user before it is turned on: every point they asked to know. */ +export function fullConfirmation(input: { + project: string; env: string; tools: FullTools; validityMs: number; now: number; + /** Whether each optional tool is turned on in Settings now. */ + enabled: { commands: boolean; installs: boolean; jobs: boolean; web: boolean }; + /** The Meta pre-authorization in force, in words, or null. */ + meta: string | null; + /** Meta as the main model: the Meta model it covers, or null when not chosen. */ + mainModel?: string | null; + /** The isolation level of terminal commands now (shellIsolation.ts). */ + isolation?: 'total' | 'project' | 'closed'; +}): { message: string; detail: string; action: string } { + const main = input.mainModel ?? null; + const isolation = input.isolation === 'total' + ? 'Their isolation level is Total: a command can change any file you can, use the network or start programs.' + : input.isolation === 'closed' + ? 'Their isolation level is Closed (Linux): writes only in the project, temporary and package-cache folders, and no network; where it cannot be applied a command does not run.' + : 'Their isolation level is Project (Linux): writes only in the project, temporary and package-cache folders, network allowed; where it cannot be applied a command does not run.'; + const on = (tool: FullTool) => input.tools[tool]; + const offNote = (tool: 'commands' | 'installs' | 'jobs' | 'web') => (input.enabled[tool] ? '' : ' (the tool itself is OFF in Settings now; Full Access does not turn it on)'); + const lines = [ + `• Where: every operation runs on ${input.env}, in the folder of ${input.project}. Another computer, Remote SSH server, user or workspace does not inherit it.`, + on('files') + ? '• Files: Agent and Mission runs read, create, change, move and delete files of this workspace without a card. Protected files (.git, .env, keys, credentials), paths outside the workspace and the frozen files of a Mission\'s acceptance check stay refused.' + : '• Files: not included; each write shows its card as the profile says.', + on('tests') ? '• Tests: the allowlisted test scripts of package.json run without a card, with your user permissions.' : '• Tests: not included.', + on('commands') + ? `• Terminal commands: run without a card, in the project folder, with your user permissions and their time and output limits${offNote('commands')}. ${isolation} A command that elevates privileges (sudo, su, doas, pkexec, runas) or touches RAME Code's own state still shows its card; commands that can only destroy stay refused.` + : '• Terminal commands: not included; each one shows its card.', + on('installs') + ? `• Installs: packages that need no administrator rights (npm without scripts, pip for your user, container images) are downloaded from their registry and installed without a card${offNote('installs')}. Installs that need administrator rights (apt, an elevated installer) still show their card.` + : '• Installs: not included.', + on('jobs') ? `• Long jobs: start without a card${offNote('jobs')}; they are stopped when you revoke Full Access.` : '• Long jobs: not included.', + on('web') + ? `• Internet: web searches and page reads (read-only) run without a card in Agent and Chat, and autonomous Missions may be given a web budget${offNote('web')}. A query may carry code, an error message or a file path; keys, tokens and passwords are removed from it, and your home and project folders hidden, before it leaves. A page whose address carries a key or a local path is not requested, and local or private addresses are refused. Publishing or changing anything online is not part of it.` + : '• Internet: not included.', + on('meta') + ? `• Meta: when the main model cannot finish, RAME consults ${META_PROVIDER} without a card: Agent's advice after the main model stops, a blocked Mission's consultation (the Mission is then retried with the advice, at most ${MAX_AUTO_RETRIES} times) and a Mission's acceptance review; each Mission's card offers its consultations by default. All within: ${input.meta ?? 'none'}, given and revoked with Full Access. The advice carries the run's or the Mission's text and the host's notes, never a file; the acceptance review carries the test fragments you choose when you prepare it, only if the data scope includes files. Secrets are removed and local paths hidden first. A failed consultation is not retried by itself. Delegating a run to Spark (Meta running the tools) is not part of it.` + : input.meta === null + ? `• Meta: not included. ${main === null ? 'Nothing is sent to Meta under Full Access; consultations' : 'No consultation of the advisor is sent under Full Access; they'} need their own pre-authorization below, with its limits and data scope.` + : `• Meta: not included outside Missions (Agent's advice and the acceptance review keep their cards). Missions keep their own pre-authorization: ${input.meta}.`, + main !== null + ? `• Meta as the main model: Agent runs with Meta ${main} in this workspace send, without the card that asks what may go to Meta, your request and the results of the tools ${main} uses (the content of the files it reads, test and command output, web pages it reads): that is how it works. Earlier local messages and project memory are not sent without their own card. Another Meta model or another API key needs a new authorization; Chat keeps its card.` + : '• Meta as the main model: not included. An Agent run with Meta as the main model keeps the card that asks what may be sent to Meta.', + `• Costs: Full Access itself costs nothing. ${main === null ? '' : `Every step of ${main} as the main model uses your API key and is billed by Meta, with no budget of this part (see Usage). `}${input.meta === null ? '' : 'Meta consultations use your API key and may be billed; RAME has no Meta price list, so the limit is the pre-authorization\'s tokens. '}Commands and installs may download content or use paid services you are signed in to.`, + '• Never bypassed: your operating system\'s permissions, your providers\' permissions, project memory (always a card), the Mission authorization card, the verifier\'s hidden files and the acceptance check, each tool\'s time and output limits.', + input.validityMs === 0 + ? '• Valid until you turn it off: choose a lower level in the chat\'s permission selector, or turn it off in Autonomy & Permissions. It does not expire by itself.' + : `• Valid for ${hours(input.validityMs)}, until ${when(input.now + input.validityMs)}.`, + `• ${REVOKE} Revoking stops the running Agent or Mission that used it, and the long jobs it started, where possible; what already happened cannot be undone.`, + ]; + return { message: `Turn on Full Access for ${input.project} on ${input.env}?`, detail: lines.join('\n'), action: 'Turn on Full Access' }; +} + +export function validateSessionWeb(input: { maxSearches: number; maxFetches: number; validityHours: number }): string | null { + const inRange = (value: number, max: number) => Number.isInteger(value) && value >= 0 && value <= max; + if (!inRange(input.maxSearches, SESSION_WEB_MAX_SEARCHES) || !inRange(input.maxFetches, SESSION_WEB_MAX_FETCHES) || input.maxSearches + input.maxFetches === 0) { + return `Pre-authorized web access needs 0 to ${SESSION_WEB_MAX_SEARCHES} searches and 0 to ${SESSION_WEB_MAX_FETCHES} page reads, and at least one of them.`; + } + return VALIDITY_HOURS.includes(input.validityHours) ? null : 'Choose how long the pre-authorization lasts: 1, 8 or 24 hours, 7 or 30 days.'; +} + +/** Commands that still show their card under Full Access: privilege elevation, RAME Code's own state, the system's stored secrets. */ +export function commandNeedsCard(command: string, statePaths: readonly string[]): string | null { + // Preceded by anything but a word character or a dash (a path, a quote, a subshell), followed by its arguments or the end. + if (/(?:^|[^\w-])(?:sudo|su|doas|pkexec|runas|gsudo)(?=$|[\s"';)&|`])/i.test(command) || /Start-Process\b[^\n]*-Verb\s+RunAs/i.test(command)) { + return 'it elevates privileges'; + } + const lower = command.toLowerCase(); + if (/globalstorage|shared-state|ramecode\.autonomy/.test(lower) || statePaths.some((entry) => entry.length > 0 && lower.includes(entry.toLowerCase()))) { + return "it mentions RAME Code's own state"; + } + if (/\b(?:secret-tool|gnome-keyring|kwallet|security\s+(?:find|dump)-\w+|cmdkey|vaultcmd)\b|credential\s*manager/i.test(command)) { + return "it reads the system's stored secrets"; + } + return null; +} diff --git a/src/autonomyStore.ts b/src/autonomyStore.ts new file mode 100644 index 0000000..1d6cdb1 --- /dev/null +++ b/src/autonomyStore.ts @@ -0,0 +1,133 @@ +/** + * Where each workspace's Autonomy & Permissions live: the same store as + * Missions (a file every VS Code window of this machine reads afresh, or + * `globalState` in tests), under one key, by execution environment and + * workspace scope. Every read goes to the store, so a revocation in one + * window holds in the others at their next check. Changes are queued in + * this window and, when the backend offers it, run in its machine-wide + * exclusive section. + * + * Free of the `vscode` API so it can be unit tested with plain `node:test`. + * + * Bound to the environment: the policy of a workspace is kept per + * environment (`autonomyEnv.ts`), so one given on this computer is never + * read on a Remote SSH server or another machine that shares the folder. + * + * Sealed: each policy is stored with an HMAC-SHA256 over its slot and + * content, with a random key kept outside this file (VS Code's + * SecretStorage in the extension). A policy changed by anything else, such + * as a command writing to this file, does not verify and reads as Ask + * always with nothing pre-authorized. Without the key nothing is in force. + */ + +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; + +import { AUTONOMY_STATE_KEY, MAX_LOG_ENTRIES, parseWorkspaceAutonomy, type WorkspaceAutonomy } from './autonomyPolicy.js'; +import { liveExecEnv, type ExecEnv } from './autonomyEnv.js'; + +export interface AutonomyBackend { + get(key: string): unknown; + update(key: string, value: unknown): PromiseLike; + exclusive?(work: () => Promise): Promise; +} + +/** Where this host runs and the key that seals its policies (null while it loads). */ +export interface AutonomyRuntime { + env(): ExecEnv; + key(): Buffer | null; + /** RAME Code's own state folders on this machine: a command that names one keeps its card under Full Access. */ + statePaths?: readonly string[]; + /** Resolves once the key has been loaded (or could not be). */ + ready?: () => Promise; +} + +const MAX_WORKSPACES = 64; +const PROCESS_KEY = randomBytes(32); + +/** For a host without SecretStorage (tests): this machine, and a key that lives as long as the process. */ +export const processRuntime = (): AutonomyRuntime => ({ env: liveExecEnv(undefined, undefined), key: () => PROCESS_KEY }); + +export class AutonomyStore { + private queue: Promise = Promise.resolve(); + + constructor( + private readonly backend: AutonomyBackend, + readonly runtime: AutonomyRuntime = processRuntime(), + ) {} + + env(): ExecEnv { + return this.runtime.env(); + } + + private slot(scopeId: string): string { + return `${this.env().id}/${scopeId}`; + } + + private all(): Record { + const raw = this.backend.get(AUTONOMY_STATE_KEY) as { version?: unknown; workspaces?: unknown } | undefined; + const workspaces = raw && typeof raw === 'object' && raw.version === 2 ? raw.workspaces : undefined; + return workspaces && typeof workspaces === 'object' && !Array.isArray(workspaces) ? { ...(workspaces as Record) } : {}; + } + + private mac(slot: string, policy: unknown): string | null { + const key = this.runtime.key(); + return key === null ? null : createHmac('sha256', key).update(`rame-autonomy:v2\n${slot}\n${JSON.stringify(policy)}`, 'utf8').digest('hex'); + } + + /** The stored content of a slot when its seal verifies; otherwise undefined (read as nothing pre-authorized). */ + private open(slot: string, record: unknown): unknown { + if (typeof record !== 'object' || record === null || typeof (record as { mac?: unknown }).mac !== 'string') { + return undefined; + } + const { policy, mac } = record as { policy: unknown; mac: string }; + const expected = this.mac(slot, policy); + if (expected === null || mac.length !== expected.length || !timingSafeEqual(Buffer.from(mac, 'utf8'), Buffer.from(expected, 'utf8'))) { + return undefined; + } + return policy; + } + + /** The policy of one workspace in this environment, read now from the store. */ + get(scopeId: string): WorkspaceAutonomy { + const slot = this.slot(scopeId); + return parseWorkspaceAutonomy(this.open(slot, this.all()[slot]), scopeId, this.env().id); + } + + /** A sealed record for one slot (the store's own writes, and test fixtures). */ + seal(scopeId: string, policy: WorkspaceAutonomy): { slot: string; record: { policy: WorkspaceAutonomy; mac: string } } { + const slot = this.slot(scopeId); + const plain = JSON.parse(JSON.stringify(policy)) as WorkspaceAutonomy; + const mac = this.mac(slot, plain); + if (mac === null) { + throw new Error('Autonomy & Permissions are not ready yet: their key is still loading. Nothing was changed.'); + } + return { slot, record: { policy: plain, mac } }; + } + + /** Read, change and write one workspace's policy; the change sees the latest stored value. */ + change(scopeId: string, work: (policy: WorkspaceAutonomy) => T): Promise { + const run = async (): Promise => { + const workspaces = this.all(); + const slot = this.slot(scopeId); + const policy = parseWorkspaceAutonomy(this.open(slot, workspaces[slot]), scopeId, this.env().id); + const result = work(policy); + policy.log = policy.log.slice(-MAX_LOG_ENTRIES); + workspaces[slot] = this.seal(scopeId, policy).record; + const keys = Object.keys(workspaces); + for (const key of keys.slice(0, Math.max(0, keys.length - MAX_WORKSPACES))) { + if (key !== slot) { + delete workspaces[key]; + } + } + await this.backend.update(AUTONOMY_STATE_KEY, { version: 2, workspaces }); + return result; + }; + const exclusive = this.backend.exclusive?.bind(this.backend); + const next = this.queue.then(async () => { + await this.runtime.ready?.(); + return exclusive ? exclusive(run) : run(); + }); + this.queue = next.then(() => undefined, () => undefined); + return next; + } +} diff --git a/src/autonomyView.ts b/src/autonomyView.ts new file mode 100644 index 0000000..86cf2bb --- /dev/null +++ b/src/autonomyView.ts @@ -0,0 +1,172 @@ +/** + * Messages of the Autonomy & Permissions section and what the view shows + * of it. Every message only asks: the host validates it, and nothing is + * pre-authorized without the host's modal confirmation. + * + * Free of the `vscode` API so it can be unit tested with plain `node:test`. + */ + +import { FULL_TOOLS, FULL_VALIDITY_HOURS, META_MAX_CHOICES, META_TOKEN_BUDGETS, VALIDITY_HOURS, type AutonomyMode, type FullTools } from './autonomyPolicy.js'; + +export type AutonomyMessage = + | { type: 'autonomyMode'; mode: Exclude } + | { type: 'autonomyMeta'; maxPerMission: number | null; validityHours: number; dataScope: 'instructions' | 'files'; tokenBudget?: number | null } + /** With its Meta part, the Meta limits come in the same request (one confirmation): -1 / 0 on the wire as for autonomyMeta. */ + | { type: 'autonomyFull'; tools: FullTools; validityHours: number; meta?: FullMetaChoice; metaMain?: true } + | { type: 'autonomyMissionWeb'; validityHours: number } + | { type: 'autonomySessionWeb'; maxSearches: number; maxFetches: number; domains: string[]; validityHours: number } + | { type: 'autonomyRevoke'; what: 'meta' | 'missionWeb' | 'sessionWeb' | 'full' | 'all' } + | { type: 'missionMetaScopeSet'; missionId: string; files: string[] } + | { type: 'autonomyRefresh' }; + +export const AUTONOMY_MESSAGE_TYPES: readonly string[] = ['autonomyMode', 'autonomyMeta', 'autonomyFull', 'autonomyMissionWeb', 'autonomySessionWeb', 'autonomyRevoke', 'missionMetaScopeSet', 'autonomyRefresh']; + +function exact(record: Record, keys: readonly string[]): boolean { + const own = Object.keys(record); + return own.length === keys.length && keys.every((key) => Object.prototype.hasOwnProperty.call(record, key)); +} + +const int = (value: unknown, min: number, max: number): value is number => typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max; +const hours = (value: unknown): value is number => typeof value === 'number' && VALIDITY_HOURS.includes(value); +const names = (value: unknown, max: number, len: number): value is string[] => + Array.isArray(value) && value.length <= max && value.every((entry) => typeof entry === 'string' && entry.length <= len); + +/** The Meta limits a user chooses: count per Mission (or conversation), data scope and total token budget. */ +export interface FullMetaChoice { + maxPerMission: number | null; + dataScope: 'instructions' | 'files'; + tokenBudget: number | null; +} + +/** On the wire, -1 is "no count limit" and 0 (or no field) "no total token budget"; only the listed choices. */ +function metaChoice(record: Record): FullMetaChoice | null { + const max = record.maxPerMission === -1 ? null : record.maxPerMission; + const budget = record.tokenBudget === undefined || record.tokenBudget === 0 ? null : record.tokenBudget; + const count = max === null || (int(max, 1, 1000) && ((max as number) <= 5 || META_MAX_CHOICES.includes(max as number))); + return count && (record.dataScope === 'instructions' || record.dataScope === 'files') && META_TOKEN_BUDGETS.includes(budget as number | null) + ? { maxPerMission: max as number | null, dataScope: record.dataScope, tokenBudget: budget as number | null } + : null; +} + +/** Parse one of these messages; null when the type is not one of them; throws when malformed. */ +export function parseAutonomyMessage(record: Record): AutonomyMessage | null { + const bad = (): never => { + throw new Error(`Invalid ${String(record.type)} message.`); + }; + switch (record.type) { + case 'autonomyMode': + return exact(record, ['type', 'mode']) && (record.mode === 'ask' || record.mode === 'supervised') ? { type: 'autonomyMode', mode: record.mode } : bad(); + case 'autonomyMeta': { + const keys = exact(record, ['type', 'maxPerMission', 'validityHours', 'dataScope']) || exact(record, ['type', 'maxPerMission', 'validityHours', 'dataScope', 'tokenBudget']); + const choice = metaChoice(record); + return keys && choice !== null && hours(record.validityHours) ? { type: 'autonomyMeta', ...choice, validityHours: record.validityHours as number } : bad(); + } + case 'autonomyFull': { + const tools = record.tools as Record | undefined; + // "Meta as the main model" is its own choice: present only as true, never implied by a tool part. + const metaMain = Object.prototype.hasOwnProperty.call(record, 'metaMain'); + if (metaMain && record.metaMain !== true) { + return bad(); + } + const valid = typeof record.validityHours === 'number' && FULL_VALIDITY_HOURS.includes(record.validityHours) && typeof tools === 'object' && tools !== null && !Array.isArray(tools) && + exact(tools, FULL_TOOLS) && FULL_TOOLS.every((name) => typeof tools[name] === 'boolean') && (metaMain || FULL_TOOLS.some((name) => tools[name] === true)); + if (!valid) { + return bad(); + } + const keys = ['type', 'tools', 'validityHours', ...(metaMain ? ['metaMain'] : [])]; + const parsed = { type: 'autonomyFull' as const, tools: Object.fromEntries(FULL_TOOLS.map((name) => [name, tools[name] === true])) as FullTools, validityHours: record.validityHours as number, ...(metaMain ? { metaMain: true as const } : {}) }; + // Its Meta part needs its limits, and only it has them. + if (tools.meta !== true) { + return exact(record, keys) ? parsed : bad(); + } + const meta = record.meta as Record | undefined; + const choice = exact(record, [...keys, 'meta']) && typeof meta === 'object' && meta !== null && !Array.isArray(meta) && exact(meta, ['maxPerMission', 'dataScope', 'tokenBudget']) ? metaChoice(meta) : null; + return choice !== null ? { ...parsed, meta: choice } : bad(); + } + case 'autonomyMissionWeb': + return exact(record, ['type', 'validityHours']) && hours(record.validityHours) ? { type: 'autonomyMissionWeb', validityHours: record.validityHours } : bad(); + case 'autonomySessionWeb': + return exact(record, ['type', 'maxSearches', 'maxFetches', 'domains', 'validityHours']) && int(record.maxSearches, 0, 100) && int(record.maxFetches, 0, 100) && names(record.domains, 20, 253) && hours(record.validityHours) + ? { type: 'autonomySessionWeb', maxSearches: record.maxSearches, maxFetches: record.maxFetches, domains: [...record.domains], validityHours: record.validityHours } + : bad(); + case 'autonomyRevoke': + return exact(record, ['type', 'what']) && ['meta', 'missionWeb', 'sessionWeb', 'full', 'all'].includes(record.what as string) + ? { type: 'autonomyRevoke', what: record.what as 'meta' | 'missionWeb' | 'sessionWeb' | 'full' | 'all' } + : bad(); + case 'missionMetaScopeSet': + return exact(record, ['type', 'missionId', 'files']) && typeof record.missionId === 'string' && /^mis_[0-9a-f]{24}$/.test(record.missionId) && names(record.files, 8, 600) + ? { type: 'missionMetaScopeSet', missionId: record.missionId, files: [...record.files] } + : bad(); + case 'autonomyRefresh': + return exact(record, ['type']) ? { type: 'autonomyRefresh' } : bad(); + default: + return null; + } +} + +export interface AutonomyView { + project: string | null; + /** Where operations run: this computer, a Remote SSH server, … */ + env: string; + note: string; + mode: AutonomyMode; + meta: { model: string; maxPerMission: number | null; dataScope: 'instructions' | 'files'; grantedAt: number; expiresAt: number; tokenBudget: number | null; usedTokens: number; problem: string } | null; + full: { tools: FullTools; grantedAt: number; expiresAt: number; jobs: number; problem: string; mainModel?: string | null } | null; + missionWeb: { grantedAt: number; expiresAt: number; problem: string } | null; + sessionWeb: { maxSearches: number; maxFetches: number; searches: number; fetches: number; domains: string[]; expiresAt: number } | null; + /** '' when Meta can be pre-authorized now; otherwise why not. */ + metaUnavailable: string; + model: string; + /** Pre-authorized external access of each Mission of this workspace that has some. */ + missions: Array<{ missionId: string; text: string }>; + log: Array<{ at: number; provider: string; destination: string; reason: string; data: string; approval: string; outcome: string; bytes: number | null }>; + inflight: number; +} + +const text = (value: unknown, max: number): string => (typeof value === 'string' ? value.slice(0, max) : ''); +const num = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0); +const obj = (value: unknown): Record | null => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : null); + +export function sanitizeAutonomyView(value: unknown): AutonomyView | null { + const v = obj(value); + if (v === null) { + return null; + } + const meta = obj(v.meta); + const missionWeb = obj(v.missionWeb); + const session = obj(v.sessionWeb); + const full = obj(v.full); + const tools = obj(full?.tools); + return { + project: typeof v.project === 'string' ? v.project.slice(0, 200) : null, + env: text(v.env, 300), + note: text(v.note, 400), + mode: v.mode === 'supervised' || v.mode === 'full' ? v.mode : 'ask', + meta: meta === null ? null : { + model: text(meta.model, 128), maxPerMission: meta.maxPerMission === null ? null : num(meta.maxPerMission), dataScope: meta.dataScope === 'files' ? 'files' : 'instructions', grantedAt: num(meta.grantedAt), expiresAt: num(meta.expiresAt), + tokenBudget: meta.tokenBudget === null || meta.tokenBudget === undefined ? null : num(meta.tokenBudget), usedTokens: num(meta.usedTokens), problem: text(meta.problem, 300), + }, + full: full === null ? null : { + tools: Object.fromEntries(FULL_TOOLS.map((name) => [name, tools?.[name] === true])) as FullTools, grantedAt: num(full.grantedAt), expiresAt: num(full.expiresAt), jobs: num(full.jobs), problem: text(full.problem, 300), mainModel: typeof full.mainModel === 'string' && /^muse-[A-Za-z0-9._-]{1,120}$/.test(full.mainModel) ? full.mainModel : null, + }, + missionWeb: missionWeb === null ? null : { grantedAt: num(missionWeb.grantedAt), expiresAt: num(missionWeb.expiresAt), problem: text(missionWeb.problem, 300) }, + sessionWeb: session === null ? null : { + maxSearches: num(session.maxSearches), maxFetches: num(session.maxFetches), searches: num(session.searches), fetches: num(session.fetches), + domains: (Array.isArray(session.domains) ? session.domains : []).slice(0, 10).map((entry) => text(entry, 253)), expiresAt: num(session.expiresAt), + }, + metaUnavailable: text(v.metaUnavailable, 300), + model: text(v.model, 128), + missions: (Array.isArray(v.missions) ? v.missions : []).slice(0, 16).flatMap((raw) => { + const entry = obj(raw); + return entry && typeof entry.missionId === 'string' ? [{ missionId: entry.missionId.slice(0, 64), text: text(entry.text, 400) }] : []; + }), + log: (Array.isArray(v.log) ? v.log : []).slice(0, 30).flatMap((raw) => { + const entry = obj(raw); + return entry === null ? [] : [{ + at: num(entry.at), provider: text(entry.provider, 80), destination: text(entry.destination, 200), reason: text(entry.reason, 300), data: text(entry.data, 400), + approval: text(entry.approval, 40), outcome: text(entry.outcome, 300), bytes: typeof entry.bytes === 'number' ? num(entry.bytes) : null, + }]; + }), + inflight: num(v.inflight), + }; +} diff --git a/src/cascade.ts b/src/cascade.ts index 1c28400..6899bd9 100644 --- a/src/cascade.ts +++ b/src/cascade.ts @@ -36,6 +36,8 @@ export interface ConsultInput { decisions: ReadonlyArray<{ key: string; value: unknown; version: number }>; /** The failing check that motivated the consultation: its command and exit code only. */ verifier?: { commandId: string; exitCode: number | null } | null; + /** The end of that check's output, already made safe (advisorRequest.ts safeCheckExcerpt); omitted = not sent. */ + checkOutput?: string; } function oneLine(text: string, max: number): string { @@ -54,7 +56,8 @@ export function buildConsultPayload(input: ConsultInput): string { 'A local coding agent could not finish a task in a software project. You cannot see the files and you cannot run tools.', `Task:\n${input.task.trim().slice(0, MAX_CONSULT_TASK_CHARS)}`, `Why the local run stopped: ${oneLine(input.reason, 400)}`, - ...(input.verifier ? [`Failing check: ${oneLine(input.verifier.commandId, 80)} exit=${input.verifier.exitCode ?? 'unknown'} (its output is not included)`] : []), + ...(input.verifier ? [`Failing check: ${oneLine(input.verifier.commandId, 80)} exit=${input.verifier.exitCode ?? 'unknown'} (${input.checkOutput ? 'the end of its output is below' : 'its output is not included'})`] : []), + ...(input.checkOutput ? [`End of the failing check's output (last lines; secrets removed, local folders hidden):\n${input.checkOutput}`] : []), `What the local run did (host record; file contents are not included):\n${record.length > 0 ? record.join('\n') : '- nothing ran'}`, `Project decisions in force (authorized by the user):\n${decisions.length > 0 ? decisions.join('\n') : '- none'}`, 'Return only the requested JSON: a short diagnosis, up to 6 concrete steps the local agent can take with its own file and test tools, and up to 4 checks that prove the result.', diff --git a/src/chatWeb.ts b/src/chatWeb.ts index fd7a104..1b11a43 100644 --- a/src/chatWeb.ts +++ b/src/chatWeb.ts @@ -17,9 +17,11 @@ * the user sends that answer on. */ +import { useAgentWebNow } from './autonomyHost.js'; import { waitForAgentDecision } from './agentController.js'; import { computeActionDigest, createPendingAction } from './agentApproval.js'; import { describeAgentCall } from './agentHost.js'; +import { limitsForModel } from './localLimits.js'; import { createLocalAgentCompletion, type AgentChatMessage, type AgentCompletion } from './agentModel.js'; import type { AgentToolDefinition } from './agentTools.js'; import type { PendingAgentAction } from './agentApproval.js'; @@ -77,7 +79,7 @@ export async function runChatWithWeb( const held = host.heldWorkspaceRoot(); const workspaceId = held.ok ? held.root : ''; for (let step = 0; step < CHAT_WEB_MAX_STEPS; step += 1) { - const completion = await observe(createLocalAgentCompletion({ endpoint: options.endpoint, model: options.model, messages, generation: options.generation, signal: options.signal, tools, toolChoice: 'auto' })); + const completion = await observe(createLocalAgentCompletion({ endpoint: options.endpoint, model: options.model, messages, generation: options.generation, signal: options.signal, tools, toolChoice: 'auto', limits: limitsForModel(host.settings.localLimits, options.model) })); usage.input += completion.usage.inputTokens; usage.output += completion.usage.outputTokens; host.recordUsage('local', completion.usage); @@ -91,6 +93,7 @@ export async function runChatWithWeb( }); for (const call of completion.toolCalls) { let result: string; + let bySession = false; const label = options.label ?? 'Chat'; if (options.extra && call.tool === options.extra.definition.function.name) { const done = await options.extra.run(call.args as Record); @@ -103,10 +106,10 @@ export async function runChatWithWeb( : `${call.tool} is not available: this ${label} has no project folder. Call request_project_folder if you need one, or answer with what you have.`; hostRecord.push(`• ${call.tool} → refused: not a ${label} tool`); options.onOutcome?.(call, `blocked by policy: not a ${label} tool`, null); - } else if (options.autoApprove?.(call.tool) === true) { + } else if (options.autoApprove?.(call.tool) === true || (bySession = await useAgentWebNow(host, call.tool, call.args as Record, workspaceId))) { const described = describeAgentCall(call); const pending = createPendingAction({ tool: call.tool, targets: described.targets, body: described.body, provider: 'local', workspaceId, threadId: options.threadId, preconditions: described.preconditions }, host.agentGeneration); - options.onApproval?.(pending, 'profile-auto', 'allowed by the permission profile'); + options.onApproval?.(pending, 'profile-auto', bySession ? 'within the web pre-authorization or Full Access (Autonomy & Permissions)' : 'allowed by the permission profile'); result = await executeWebTool(host, call.tool, call.args as Record, 'local', options.signal); hostRecord.push(`• ${call.tool} ${described.targets[0] ?? ''} → ${result.split('\n')[0] ?? ''}`.slice(0, 500)); options.onOutcome?.(call, result.split('\n')[0] ?? '', { digest: pending.digest, approval: 'profile' }); @@ -137,7 +140,7 @@ export async function runChatWithWeb( } // Out of rounds: one last answer, without tools. messages.push({ role: 'system', content: 'No more web calls in this reply. Answer now with what you found, citing the sources you used.' }); - const final = await observe(createLocalAgentCompletion({ endpoint: options.endpoint, model: options.model, messages, generation: options.generation, signal: options.signal, tools, toolChoice: 'none' })); + const final = await observe(createLocalAgentCompletion({ endpoint: options.endpoint, model: options.model, messages, generation: options.generation, signal: options.signal, tools, toolChoice: 'none', limits: limitsForModel(host.settings.localLimits, options.model) })); host.recordUsage('local', final.usage); return { text: final.text, hostRecord, usage }; } diff --git a/src/cloudDisclosureHost.ts b/src/cloudDisclosureHost.ts index b7d8c7c..abcf421 100644 --- a/src/cloudDisclosureHost.ts +++ b/src/cloudDisclosureHost.ts @@ -37,9 +37,12 @@ import { type DisclosurePlan, type DisclosureScope, } from './cloudDisclosure.js'; -import { processingClass } from './metaRecipient.js'; import type { AuthorizedDecisionSummary } from './continuityCheckpoint.js'; import type { ProviderHost } from './providerTypes.js'; +import { fullMainModelNow, recordExternal } from './autonomyHost.js'; +import { currentScope, runScope } from './cloudScope.js'; + +export { currentScope, runScope }; import type { ChatThread } from './threadStore.js'; /** The authorized decisions of the project the thread is bound to (none when unbound or closed). */ @@ -65,12 +68,6 @@ export async function decisionsForThread( } } -/** The scope a Meta request is under now: the selected model's processing class and the API key's revision. */ -export function currentScope(host: ProviderHost): DisclosureScope { - return { processing: processingClass(host.selectedCloudModel()), credential: host.metaCredential }; -} - -/** The tool-results permission key: one thread, one class, one credential. */ function grantKey(threadId: string, scope: DisclosureScope): string { return `${threadId}|${scope.processing}|${scope.credential}`; } @@ -92,7 +89,7 @@ export function credentialChanged(host: ProviderHost, revision: string): void { persist(host); } -async function planFor(host: ProviderHost, thread: ChatThread, text: string, mode: 'chat' | 'agent', delegated: boolean): Promise<{ plan: DisclosurePlan; decisions: AuthorizedDecisionSummary[] }> { +async function planFor(host: ProviderHost, thread: ChatThread, text: string, mode: 'chat' | 'agent', delegated: boolean): Promise<{ plan: DisclosurePlan; decisions: AuthorizedDecisionSummary[]; scopeId: string }> { const decisions = await decisionsForThread(host, thread); const scope = currentScope(host); const plan = planDisclosure(thread, text, threadDisclosure(host.cloudDisclosure, thread.id, scope), decisions, { @@ -101,7 +98,27 @@ async function planFor(host: ProviderHost, thread: ChatThread, text: string, mod delegated, askToolResults: mode === 'agent' && !delegated && !host.cloudToolGrants.has(grantKey(thread.id, scope)), }); - return { plan, decisions: decisions.list }; + return { plan, decisions: decisions.list, scopeId: decisions.scopeId }; +} + +/** + * A Meta main-model run that goes without the disclosure card: the run of a + * Mission whose authorization chose this Meta model as its main model (that + * card was the consent), or an Agent run under Full Access's own "Meta as + * the main model" part, given for this Meta model and API key in this + * workspace (its confirmation says that requests and tool results go to + * Meta). Full Access's tool parts never cover it, and its `meta` part only + * covers bounded advisor consultations. Local messages not answered on a + * card stay local, and project decisions go only if they were answered + * before (project memory always keeps its card). + */ +function coveredWithoutCard(host: ProviderHost, thread: ChatThread, mode: 'chat' | 'agent', scopeId: string): 'mission' | 'full-access' | null { + const active = host.activeMission; + const grant = active === null ? null : (host.missions.get(active.id)?.grant ?? null); + if (grant !== null && grant.provider === 'cloud' && grant.model === host.selectedCloudModel() && host.missions.get(grant.missionId)?.threadId === thread.id) { + return 'mission'; + } + return mode === 'agent' && fullMainModelNow(host, scopeId) ? 'full-access' : null; } /** @@ -130,16 +147,26 @@ export async function cloudGate( } const delegated = host.delegatedRun !== null && host.delegatedRun.threadId === threadId; const kind = mode === 'agent' ? 'agent' : 'chat'; - const { plan, decisions } = await planFor(host, thread, text, kind, delegated); - if (!plan.needed) { + const { plan, decisions, scopeId } = await planFor(host, thread, text, kind, delegated); + const covered = plan.needed ? coveredWithoutCard(host, thread, kind, scopeId) : null; + if (!plan.needed || covered !== null) { host.cloudRun = { threadId, ...scope, allowed: plan.decisionsAllowed, decisions: plan.decisionsAllowed ? decisions : [], - // A delegated run's own card lists tool output; otherwise this session's answer for this class and key. - toolResults: delegated || host.cloudToolGrants.get(grantKey(threadId, scope)) === true, + // A delegated run's own card lists tool output; a covered run sends its own; otherwise this session's answer for this class and key. + toolResults: delegated || covered !== null || host.cloudToolGrants.get(grantKey(threadId, scope)) === true, }; + if (covered !== null && scopeId !== '') { + await recordExternal(host, scopeId, { + provider: 'Meta Model API', destination: host.selectedCloudModel(), + reason: covered === 'mission' ? 'Mission run with Meta as its main model' : 'Agent run with Meta as the main model', + data: `this run's request and tool results; ${plan.messages.length} earlier local message(s) not sent; project decisions ${plan.decisionsAllowed ? 'as answered before' : 'not sent'}`, + approval: covered === 'mission' ? 'card' : 'full-access', preauthId: null, missionId: host.activeMission?.id ?? null, bytes: null, + outcome: covered === 'mission' ? 'sent under the Mission authorization' : 'sent under Full Access (Meta as the main model)', + }); + } recordRequestSent(host.cloudDisclosure, thread, currentRequestTurn(thread, text), Date.now(), scope); persist(host); return true; @@ -265,11 +292,6 @@ export function threadForRun(host: ProviderHost, threadId: string, provider: 'lo return provider === 'local' ? withoutRetrievalLines(thread) : cloudVisibleThread(thread, text, threadDisclosure(host.cloudDisclosure, threadId, runScope(host, threadId))); } -/** The scope of the Meta request running for this thread (fixed at its card), else the current one. */ -export function runScope(host: ProviderHost, threadId: string): DisclosureScope { - const run = host.cloudRun; - return run !== null && run.threadId === threadId ? { processing: run.processing, credential: run.credential } : currentScope(host); -} /** Local-session messages a Meta request leaves out, so the model can be told (0 for local runs). */ export function withheldForRun(host: ProviderHost, threadId: string, provider: 'local' | 'cloud', text: string): number { diff --git a/src/cloudEgress.ts b/src/cloudEgress.ts index 70d7771..0ad04d5 100644 --- a/src/cloudEgress.ts +++ b/src/cloudEgress.ts @@ -19,7 +19,7 @@ import * as os from 'node:os'; import type { AgentChatMessage } from './agentModel.js'; import { cloudVisibleThread, protectedLocalTexts, threadDisclosure } from './cloudDisclosure.js'; -import { runScope } from './cloudDisclosureHost.js'; +import { runScope } from './cloudScope.js'; import { MetaAgentModelProvider, type AgentModelProvider } from './modelProviders/agentProvider.js'; import type { ProviderHost } from './providerTypes.js'; import { RAME_AUDIT_KEY } from './settingsStore.js'; @@ -145,7 +145,9 @@ export function scrubLocalPaths(text: string, paths: ReadonlyArray { + const terminal = vscode.window.terminals.find((entry) => entry.name === title) ?? vscode.window.createTerminal({ name: title }); + terminal.show(false); + terminal.sendText(commandLine, true); + }); + context.subscriptions.push({ dispose: () => setInstallTerminal(null) }); + // Long jobs outlive a reload; tell the user about any whose result RAME no longer knows. + const jobNotice = setTimeout(() => void noticeUncertainJobs(main, vscode.window), 3000); + context.subscriptions.push({ dispose: () => clearTimeout(jobNotice) }); + context.subscriptions.push( output, main, @@ -54,6 +67,15 @@ export function activate(context: vscode.ExtensionContext): void { await main.navigate('settings'); await setMetaApiKey(context.secrets, main); }), + vscode.commands.registerCommand('rameCode.showJobs', async () => { + await runJobsMenu(main, vscode.window); + }), + vscode.commands.registerCommand('rameCode.installs', async () => { + await runInstallsMenu(main, vscode.window); + }), + vscode.commands.registerCommand('rameCode.localLimits', async () => { + await runLocalLimitsMenu(main, vscode.window); + }), vscode.commands.registerCommand('rameCode.startMission', async () => { await revealMain(); if (missionRunner.isRunning || chatRunner.isRunning) { diff --git a/src/generationController.ts b/src/generationController.ts index 385a7f0..1ee5d70 100644 --- a/src/generationController.ts +++ b/src/generationController.ts @@ -8,6 +8,7 @@ import * as remote from './remoteController.js'; import { withBoundedRequest } from './boundedRequest.js'; import { + GenerationContextError, REASONING_LABELS, UNKNOWN_LOCAL_PROPS, automaticOutputTokens, @@ -26,6 +27,9 @@ import type { ModelGenerationProfile, } from './generationSettings.js'; import { createLocalChatCompletion, createLocalMissionPlan } from './localProvider.js'; +import { LocalServerHttpError, LocalServerSetupError } from './localServerErrors.js'; +import { lmStudioReloadOffer } from './lmStudioReloadOffer.js'; +import { lmStudioLoadedContext } from './lmStudioContext.js'; import { isLocalOrPrivateUrl } from './localServers.js'; import { chatWebOffered, runChatWithWeb } from './chatWeb.js'; import { createChatCompletion, missionPlanSchema, validateMissionPlan } from './meta-provider.js'; @@ -126,24 +130,34 @@ export async function probeLocalProps( endpoint: string, ): Promise { let props: LocalServerProps = { ...UNKNOWN_LOCAL_PROPS }; + // `/props` is llama-server's. LM Studio and Ollama have none (LM Studio logs it as an error and + // answers 200, Ollama 404): for a server chosen as one of them, ask its model list instead. + const external = host.settings.externalLocalEndpoint.trim(); + const listOnly = external.length > 0 && endpoint === external && (host.settings.externalLocal.kind === 'lmstudio' || host.settings.externalLocal.kind === 'ollama'); if (isLocalOrPrivateUrl(endpoint)) { try { const payload = await withBoundedRequest( undefined, LOCAL_PROPS_TIMEOUT_MS, - 'Local /props timed out.', + listOnly ? 'Local /v1/models timed out.' : 'Local /props timed out.', async (signal) => { const response = await fetch( - `${endpoint.replace(/\/+$/, '')}/props`, + `${endpoint.replace(/\/+$/, '')}${listOnly ? '/v1/models' : '/props'}`, { method: 'GET', signal, redirect: 'manual' }, ); return response.ok ? ((await response.json()) as unknown) : null; }, ); - props = parseLlamaServerProps(payload); - if (payload !== null) { + props = listOnly ? { ...UNKNOWN_LOCAL_PROPS } : parseLlamaServerProps(payload); + const answered = listOnly + ? typeof payload === 'object' && payload !== null && Array.isArray((payload as { data?: unknown }).data) + : payload !== null; + if (answered) { host.localConfirmed = endpoint; } + if (answered && host.settings.externalLocal.kind === 'lmstudio') { + props = { ...props, contextWindow: await lmStudioContextOf(endpoint, requestedLocalModel(host.settings, endpoint)) }; + } } catch { props = { ...UNKNOWN_LOCAL_PROPS }; } @@ -152,6 +166,23 @@ export async function probeLocalProps( return props; } +/** + * The context LM Studio has the model loaded with, so a request that does not + * fit is refused here instead of being cut by LM Studio (lmStudioContext.ts). + * Null (unknown, no check) when LM Studio does not say. + */ +async function lmStudioContextOf(endpoint: string, model: string): Promise { + try { + const payload = await withBoundedRequest(undefined, LOCAL_PROPS_TIMEOUT_MS, 'LM Studio /api/v1/models timed out.', async (signal) => { + const response = await fetch(`${endpoint.replace(/\/+$/, '')}/api/v1/models`, { method: 'GET', signal, redirect: 'manual' }); + return response.ok ? ((await response.json()) as unknown) : null; + }); + return lmStudioLoadedContext(payload, model); + } catch { + return null; + } +} + /** * Profile of the selected provider/model from what is known right now. */ @@ -547,6 +578,17 @@ export async function handleSendError( } return; } + if (provider === 'local' && error instanceof LocalServerSetupError) { + // The server answered that it has no model ready: say what to do there; Spark is not the fix. + remote.localSetupIssue(host, threadId); + host.connection = 'error'; + host.status = error.message; + const current = host.store.get(threadId); + if (current) { + host.store.save(appendErrorTurn(current, error.message.slice(0, 500))); + } + return; + } if ( (error instanceof Error && error.name === 'AbortError') || (error instanceof DOMException && error.name === 'AbortError') @@ -582,7 +624,9 @@ export async function handleSendError( host.localEndpoint() ?? `http://127.0.0.1:${host.settings.local.port}`; const unreachable = localUnreachableMessage(displayEndpoint); + // A server that answered with an HTTP error was reached, whatever its own words say. const looksUnreachable = + !(error instanceof LocalServerHttpError) && /not running|no external endpoint|not reachable|unreachable|refused|failed to fetch|fetch failed|network|econn|socket|health/i.test( safe, ); @@ -593,6 +637,11 @@ export async function handleSendError( const delegated = provider === 'cloud' ? remote.delegationFailed(host, threadId, error, apiKey ?? '') : null; // The local actions shown depend on the request: see remote.localFailure. const local = provider === 'local' ? remote.localFailure(host, threadId, looksUnreachable, reason, apiKey !== undefined && apiKey.length > 0) : null; + // LM Studio refused for lack of context: the card also offers to reload the model with more (lmStudioReload.ts). + const reload = local !== null ? lmStudioReloadOffer(host, error) : null; + if (local !== null && host.localIssue !== null && error instanceof GenerationContextError) { + host.localIssue = { ...host.localIssue, context: true, ...(reload !== null ? { reload } : {}) }; + } const failed = looksUnreachable ? unreachable : `Local work failed (${safe}).`; if (local === 'offered') { host.connection = 'error'; @@ -621,8 +670,13 @@ export async function handleSendError( } const current = host.store.get(threadId); if (current) { + // The thread keeps why it failed, also when the status only says escalation is waiting. const errorNote = - provider === 'local' && looksUnreachable ? unreachable : host.status; + provider === 'local' && looksUnreachable + ? unreachable + : local === 'offered' + ? `${failed} Escalation is waiting for approval.` + : host.status; host.store.save(appendErrorTurn(current, errorNote.slice(0, 500))); } } diff --git a/src/generationSettings.ts b/src/generationSettings.ts index 6c5dc99..f47da43 100644 --- a/src/generationSettings.ts +++ b/src/generationSettings.ts @@ -209,9 +209,12 @@ export class OutputCutOffError extends Error { /** The request cannot fit the context window; nothing was cut or sent. */ export class GenerationContextError extends Error { - constructor(message: string) { + /** The input tokens the refused request needs, when known (the reload offer sizes itself on it). */ + readonly neededTokens: number | null; + constructor(message: string, neededTokens: number | null = null) { super(message); this.name = 'GenerationContextError'; + this.neededTokens = neededTokens; } } @@ -289,6 +292,7 @@ export function resolveOutputBudget( (provider === 'local' ? 'Start a new thread or raise the local context size.' : 'Start a new thread with a shorter request.'), + promptTokens, ); } if (budget > free) { diff --git a/src/hostCases.ts b/src/hostCases.ts index 4470751..a895f07 100644 --- a/src/hostCases.ts +++ b/src/hostCases.ts @@ -20,24 +20,25 @@ * * The expected values are never sent to the child: not on stdin, in its * arguments, its environment or its results channel, and a failed case is - * reported without them. The child runs under RAME's trace, so a run in which - * the code under test is seen reading a cases file is not counted. - * - * What this does not do: it is not a sandbox. The child runs with the user's - * permissions, so code under test can still find and read the cases file on - * disk (the trace is a discovery aid, not a barrier); weak cases stay weak; - * values must be JSON. The guarantee is that the host ran every authorized - * case and compared every result itself, not that the answers are secret. + * reported without them. Official verification is read-confined and + * network-closed on supported Linux: the child gets only the current input + * and authorized code, never the cases file, future inputs, expected values, + * host records or sibling projects. Each input gets a fresh process. + * Unsupported platforms run with an explicit unprotected label and warning. + * Weak cases stay weak; values are JSON. */ import { spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import * as path from 'node:path'; +import { isolatedInvocation, isolationRefusal, isolationSupport, protectedVerifierInvocation, verificationMode, type ProtectedInvocationAccess, type VerificationMode } from './shellIsolation.js'; +import type { CommandIsolation } from './settingsStore.js'; export const CASES_FILE = /\.rame-cases\.json$/i; export const MAX_CASES = 200; const MAX_REPORT_BYTES = 1024 * 1024; export const CHILD_SCRIPT = path.join(__dirname, '..', '..', 'runtime', 'rame-cases-child.cjs'); +const HOST_PACKAGE_JSON = path.join(__dirname, '..', '..', 'package.json'); export interface HostCase { call: string; @@ -93,9 +94,14 @@ export type CasesOutcome = | { status: 'failed'; total: number; failed: Array<{ index: number; call: string; reason: string }> } | { status: 'incomplete'; reason: string }; -/** Judge the child's report against the expected values the host kept. */ -export function judgeReport(report: string, spec: CasesSpec, tokens: { start: string; end: string }): CasesOutcome { - const results = new Map(); +export type CaseAnswer = { ok: boolean; value?: unknown; error?: string }; + +/** + * Read the child's report: one answer per call, between the two witness + * tokens, or why the run is incomplete. Nothing is judged here. + */ +export function readCaseAnswers(report: string, count: number, tokens: { start: string; end: string }): { status: 'complete'; answers: Map } | { status: 'incomplete'; reason: string } { + const results = new Map(); let started = false; let ended = false; for (const line of report.split('\n')) { @@ -126,9 +132,19 @@ export function judgeReport(report: string, spec: CasesSpec, tokens: { start: st if (!started) { return { status: 'incomplete', reason: 'RAME’s case runner never started' }; } - if (!ended || results.size !== spec.cases.length) { - return { status: 'incomplete', reason: `the run ended before every case returned (${results.size} of ${spec.cases.length}); for example the code under test ended the process early` }; + if (!ended || results.size !== count) { + return { status: 'incomplete', reason: `the run ended before every case returned (${results.size} of ${count}); for example the code under test ended the process early` }; } + return { status: 'complete', answers: results }; +} + +/** Judge the child's report against the expected values the host kept. */ +export function judgeReport(report: string, spec: CasesSpec, tokens: { start: string; end: string }): CasesOutcome { + const read = readCaseAnswers(report, spec.cases.length, tokens); + if (read.status === 'incomplete') { + return read; + } + const results = read.answers; const failed: Array<{ index: number; call: string; reason: string }> = []; spec.cases.forEach((expected, index) => { const result = results.get(index); @@ -144,17 +160,60 @@ export function judgeReport(report: string, spec: CasesSpec, tokens: { start: st return failed.length === 0 ? { status: 'passed', total: spec.cases.length } : { status: 'failed', total: spec.cases.length, failed }; } -/** Run the cases in a child process with RAME's shim, and judge them here. */ -export function runHostCases( - root: string, - spec: CasesSpec, - options: { signal?: AbortSignal; timeoutMs?: number; nodePath?: string; childScript?: string; trace?: { file: string; root: string; preload: string } } = {}, -): Promise { +export interface CaseRunnerOptions { + signal?: AbortSignal; + timeoutMs?: number; + nodePath?: string; + childScript?: string; + trace?: { file: string; root: string; preload: string }; + /** Extra environment for the child (for example NODE_PATH for a frozen reference copy). */ + env?: Record; + /** Host-verifier child isolation; omitted only by low-level unit tests. */ + isolation?: CommandIsolation; + /** Writable root when the child's cwd is deliberately read-only. */ + isolationRoot?: string; + /** Mandatory read-confined view for official host verification. */ + protectedAccess?: ProtectedInvocationAccess; + /** Official verifier trust decided once by the host; unprotected is explicit, never inferred from terminal settings. */ + verificationMode?: VerificationMode; +} + +/** + * Run RAME's case runner in `cwd`: it loads `module` and makes `calls`, + * answering only on its results channel. Resolves with the raw report and + * the witness tokens, or with why there is no report. + */ +async function spawnCaseBatch( + cwd: string, + module: string, + calls: Array<{ name: string; args: unknown[] }>, + options: CaseRunnerOptions = {}, +): Promise<{ report: string; tokens: { start: string; end: string } } | { report: null; reason: string; interrupted?: true }> { const tokens = { start: randomBytes(16).toString('hex'), end: randomBytes(16).toString('hex') }; + const level = options.isolation ?? 'total'; + const support = await isolationSupport(); + const mode = options.verificationMode ?? verificationMode(support); + const refusal = options.protectedAccess === undefined ? isolationRefusal(level, level === 'total' ? { ok: true, abi: 0 } : support) : null; + if (refusal !== null) return { report: null, reason: `the host verifier was not run without its required isolation: ${refusal}` }; + const node = options.nodePath ?? process.execPath; + const childArgs = [...(options.trace ? ['--require', options.trace.preload] : []), options.childScript ?? CHILD_SCRIPT]; + const protectedAccess = options.protectedAccess === undefined + ? undefined + : { + ...options.protectedAccess, + // Node 24 consults the package scope of the host-owned .cjs runner. + // Grant that one public runtime manifest, never its parent tree. + readFiles: [...options.protectedAccess.readFiles, HOST_PACKAGE_JSON, ...childArgs.filter((part) => path.isAbsolute(part))], + readTrees: [...(options.protectedAccess.readTrees ?? []), ...(options.trace ? [path.dirname(options.trace.file)] : [])], + writeTrees: [...(options.protectedAccess.writeTrees ?? []), ...(options.trace ? [path.dirname(options.trace.file)] : [])], + }; + const invocation = protectedAccess === undefined + ? isolatedInvocation(level, node, childArgs, options.isolationRoot ?? cwd, undefined, undefined, options.trace ? [path.dirname(options.trace.file)] : []) + : mode.kind === 'protected' ? protectedVerifierInvocation(node, childArgs, protectedAccess) : { executable: node, argv: childArgs }; return new Promise((resolve) => { let report = ''; let settled = false; - const finish = (outcome: CasesOutcome & { interrupted?: true }) => { + const finish = (outcome: { report: string; tokens: { start: string; end: string } } | { report: null; reason: string; interrupted?: true }) => { if (!settled) { settled = true; clearTimeout(timer); @@ -162,24 +221,25 @@ export function runHostCases( } }; const trace = options.trace; - const child = spawn(options.nodePath ?? process.execPath, [...(trace ? ['--require', trace.preload] : []), options.childScript ?? CHILD_SCRIPT], { - cwd: root, + const child = spawn(invocation.executable, invocation.argv, { + cwd, env: { - ...process.env, + ...(protectedAccess === undefined ? process.env : { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' }), ELECTRON_RUN_AS_NODE: '1', NODE_OPTIONS: '', ...(trace ? { RAME_TRACE_FILE: trace.file, RAME_TRACE_ROOT: trace.root, RAME_TRACE_PARENT: '' } : {}), + ...(options.env ?? {}), }, stdio: ['pipe', 'ignore', 'ignore', 'pipe'], windowsHide: true, }); const timer = setTimeout(() => { child.kill('SIGKILL'); - finish({ status: 'incomplete', reason: 'the cases did not finish in time' }); + finish({ report: null, reason: 'the cases did not finish in time' }); }, options.timeoutMs ?? 60_000); const onAbort = () => { child.kill('SIGKILL'); - finish({ status: 'incomplete', reason: 'stopped before it finished', interrupted: true }); + finish({ report: null, reason: 'stopped before it finished', interrupted: true }); }; options.signal?.addEventListener('abort', onAbort, { once: true }); const channel = child.stdio[3] as NodeJS.ReadableStream | null; @@ -188,12 +248,48 @@ export function runHostCases( report += chunk.toString('utf8'); } }); - child.on('error', () => finish({ status: 'incomplete', reason: 'the case runner could not start' })); + child.on('error', () => finish({ report: null, reason: 'the case runner could not start' })); child.on('close', () => { options.signal?.removeEventListener('abort', onAbort); - finish(judgeReport(report, spec, tokens)); + finish({ report, tokens }); }); - const calls = spec.cases.map((entry, id) => ({ id, name: entry.call, args: entry.args })); - child.stdin?.end(JSON.stringify({ module: spec.module, calls, start: tokens.start, end: tokens.end })); + child.stdin?.on('error', () => undefined); + child.stdin?.end(JSON.stringify({ module, calls: calls.map((entry, id) => ({ id, name: entry.name, args: entry.args })), start: tokens.start, end: tokens.end })); }); } + +/** + * Send one input to one fresh process. Future inputs, expected answers and + * seeds remain in the host; the official aggregate report is built here, + * never by code under test. + */ +export async function spawnCaseRunner( + cwd: string, + module: string, + calls: Array<{ name: string; args: unknown[] }>, + options: CaseRunnerOptions = {}, +): Promise<{ report: string; tokens: { start: string; end: string } } | { report: null; reason: string; interrupted?: true }> { + const tokens = { start: randomBytes(16).toString('hex'), end: randomBytes(16).toString('hex') }; + const messages: string[] = [JSON.stringify({ witness: tokens.start })]; + for (const [id, call] of calls.entries()) { + if (options.signal?.aborted) return { report: null, reason: 'stopped before it finished', interrupted: true }; + const run = await spawnCaseBatch(cwd, module, [call], options); + if (run.report === null) return run; + const read = readCaseAnswers(run.report, 1, run.tokens); + if (read.status === 'incomplete') return { report: null, reason: read.reason }; + const answer = read.answers.get(0); + if (answer === undefined) return { report: null, reason: 'the isolated case returned no answer' }; + messages.push(JSON.stringify({ id, ...answer })); + } + messages.push(JSON.stringify({ witness: tokens.end })); + return { report: `${messages.join('\n')}\n`, tokens }; +} + +/** Run the cases in a child process with RAME's shim, and judge them here. */ +export async function runHostCases(root: string, spec: CasesSpec, options: CaseRunnerOptions = {}): Promise { + const run = await spawnCaseRunner(root, spec.module, spec.cases.map((entry) => ({ name: entry.call, args: entry.args })), options); + if (run.report === null) { + return { status: 'incomplete', reason: run.reason, ...(run.interrupted ? { interrupted: true as const } : {}) }; + } + return judgeReport(run.report, spec, run.tokens); +} diff --git a/src/installPlan.ts b/src/installPlan.ts new file mode 100644 index 0000000..edfa85c --- /dev/null +++ b/src/installPlan.ts @@ -0,0 +1,208 @@ +/** + * Installing what a task needs, with the user's permission (roadmap 6.2): + * system packages (apt on Ubuntu/Debian, winget on Windows), npm and pip + * packages, and container images. This module is pure: from the model's + * request it builds the exact commands the card shows, says whether they + * need administrator rights or download anything, and how RAME checks the + * result afterwards. Nothing here runs a command. + * + * Rules: + * - executable plus argv, never a shell line built from the request; the + * executables are resolved by the host from absolute PATH entries outside + * the project (installTool.ts), so a program planted in the project or in a + * relative PATH entry never runs; + * - names and versions are strictly validated per package manager; + * - administrator rights always go through the operating system's own + * prompt (the user types the sudo password in a terminal; Windows shows + * UAC): RAME never stores or types a password; + * - an image already in Docker is copied locally into Podman instead of + * being downloaded again. + */ + +export type InstallManager = 'apt' | 'winget' | 'npm' | 'pip' | 'image'; +export const INSTALL_MANAGERS: readonly InstallManager[] = ['apt', 'winget', 'npm', 'pip', 'image']; + +export interface InstallRequest { + manager: InstallManager; + name: string; + version?: string; + /** image only: the container runtime that needs it. */ + runtime?: 'podman' | 'docker'; +} + +export interface InstallStep { + executable: string; + argv: string[]; + /** Needs administrator rights (sudo / an elevated installer). */ + elevated: boolean; + /** `docker save | podman load`: a local copy, nothing downloaded. */ + pipeTo?: { executable: string; argv: string[] }; +} + +export interface InstallPlan { + request: InstallRequest; + steps: InstallStep[]; + check: { executable: string; argv: string[] }; + elevated: boolean; + downloads: boolean; + /** What the card says, one line per fact. */ + lines: string[]; +} + +const NAMES: Record = { + apt: /^[a-z0-9][a-z0-9+.-]{0,99}$/, + winget: /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,127}$/, + npm: /^(?:@[a-z0-9][a-z0-9._-]{0,99}\/)?[a-z0-9][a-z0-9._-]{0,99}$/, + pip: /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/, + image: /^[a-z0-9][a-z0-9._\/:@-]{0,199}$/, +}; +const VERSION = /^[A-Za-z0-9][A-Za-z0-9.+~:_-]{0,63}$/; + +/** The request the model sent, validated; the error says what is wrong. */ +export function validateInstallRequest(raw: unknown): { ok: true; request: InstallRequest } | { ok: false; error: string } { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return { ok: false, error: 'an install needs manager and name' }; + } + const record = raw as Record; + const manager = record.manager as InstallManager; + if (!INSTALL_MANAGERS.includes(manager)) { + return { ok: false, error: `manager must be one of ${INSTALL_MANAGERS.join(', ')}` }; + } + if (typeof record.name !== 'string' || !NAMES[manager].test(record.name) || record.name.startsWith('-')) { + return { ok: false, error: `${String(record.name).slice(0, 80)} is not a valid ${manager} name` }; + } + if (record.version !== undefined && (typeof record.version !== 'string' || !VERSION.test(record.version) || manager === 'image')) { + return { ok: false, error: manager === 'image' ? 'an image has its tag in its name' : 'the version has characters a version cannot have' }; + } + if (manager === 'image' ? record.runtime !== 'podman' && record.runtime !== 'docker' : record.runtime !== undefined) { + return { ok: false, error: manager === 'image' ? 'an image needs runtime podman or docker' : 'runtime is only for images' }; + } + return { ok: true, request: { manager, name: record.name, ...(typeof record.version === 'string' ? { version: record.version } : {}), ...(manager === 'image' ? { runtime: record.runtime as 'podman' | 'docker' } : {}) } }; +} + +export interface InstallContext { + platform: NodeJS.Platform; + /** image only: the image is already in Docker, so Podman can load it locally. */ + dockerHasImage?: boolean; + /** image only: the exact Docker image (its id) the local copy takes. */ + dockerImageId?: string; +} + +/** The exact commands for this request on this computer, or why it cannot be installed here. */ +export function planInstall(request: InstallRequest, context: InstallContext): { ok: true; plan: InstallPlan } | { ok: false; error: string } { + const windows = context.platform === 'win32'; + const { name, version } = request; + switch (request.manager) { + case 'apt': { + if (context.platform !== 'linux') { + return { ok: false, error: 'apt is for Ubuntu or Debian; use winget on Windows' }; + } + const target = version ? `${name}=${version}` : name; + return done(request, [{ executable: 'sudo', argv: ['apt-get', 'install', '-y', '--no-install-recommends', target], elevated: true }], { executable: 'dpkg-query', argv: ['-W', '-f=${Status} ${Version}', name] }, true, [ + `system package ${target} from the configured apt sources (Ubuntu/Debian)`, + 'needs administrator rights: you type the sudo password yourself in a terminal; RAME Code never sees it', + ]); + } + case 'winget': { + if (!windows) { + return { ok: false, error: 'winget is for Windows; use apt on Ubuntu' }; + } + return done( + request, + [{ executable: 'winget', argv: ['install', '--id', name, '--exact', '--source', 'winget', '--silent', '--accept-package-agreements', '--accept-source-agreements', ...(version ? ['--version', version] : [])], elevated: true }], + { executable: 'winget', argv: ['list', '--id', name, '--exact', '--source', 'winget'] }, + true, + [ + `Windows program ${name}${version ? ` ${version}` : ''} from the winget community repository (not the Microsoft Store)`, + 'it accepts the package\'s license and the source\'s agreements for you (--accept-package-agreements, --accept-source-agreements): approve only if you accept them', + 'if the installer needs administrator rights, Windows asks you itself (UAC)', + ], + ); + } + case 'npm': { + const target = version ? `${name}@${version}` : name; + return done(request, [{ executable: 'npm', argv: ['install', '--global', '--ignore-scripts', target], elevated: false }], { executable: 'npm', argv: ['ls', '--global', '--depth=0', '--json', name] }, true, [ + `npm package ${target}, installed globally for your user (its install scripts do not run)`, + ]); + } + case 'pip': { + const target = version ? `${name}==${version}` : name; + const python = windows ? 'py' : 'python3'; + return done(request, [{ executable: python, argv: ['-m', 'pip', 'install', '--user', '--only-binary', ':all:', target], elevated: false }], { executable: python, argv: ['-m', 'pip', 'show', name] }, true, [ + `Python package ${target}, installed for your user with pip`, + 'wheels only (--only-binary :all:): no package code runs while it installs; a package published only as source is refused', + ]); + } + case 'image': { + const runtime = request.runtime as 'podman' | 'docker'; + if (runtime === 'podman' && context.dockerHasImage === true) { + return done(request, [{ executable: 'docker', argv: ['save', name], elevated: false, pipeTo: { executable: 'podman', argv: ['load'] } }], { executable: 'podman', argv: ['image', 'inspect', name] }, false, [ + `container image ${name}, copied from Docker into Podman on this computer: nothing is downloaded`, + ...(context.dockerImageId ? [`source: the Docker image ${context.dockerImageId.replace(/^sha256:/, '').slice(0, 16)}…; RAME checks that Podman then has the same image`] : []), + ]); + } + return done(request, [{ executable: runtime, argv: ['pull', name], elevated: false }], { executable: runtime, argv: ['image', 'inspect', name] }, true, [`container image ${name}, downloaded by ${runtime} from its registry`]); + } + } +} + +function done(request: InstallRequest, steps: InstallStep[], check: InstallPlan['check'], downloads: boolean, facts: string[]): { ok: true; plan: InstallPlan } { + const commands = steps.map((step) => [step.executable, ...step.argv].join(' ') + (step.pipeTo ? ` | ${[step.pipeTo.executable, ...step.pipeTo.argv].join(' ')}` : '')); + const elevated = steps.some((step) => step.elevated); + return { + ok: true, + plan: { + request, + steps, + check, + elevated, + downloads, + lines: [ + `install: ${commands.join(' && ')}`, + ...facts, + `afterwards RAME Code checks it: ${[check.executable, ...check.argv].join(' ')}`, + 'installing it does not authorize running it: jobs, commands and Missions that use it still ask', + ], + }, + }; +} + +/** What `check` printed means installed (and which version), or not installed. */ +export function readInstalled(request: InstallRequest, exitCode: number | null, stdout: string): { installed: boolean; version: string | null } { + if (exitCode !== 0) { + return { installed: false, version: null }; + } + switch (request.manager) { + case 'apt': { + const match = /install ok installed (\S+)/.exec(stdout); + return { installed: match !== null, version: match?.[1] ?? null }; + } + case 'winget': + return { installed: stdout.toLowerCase().includes(request.name.toLowerCase()), version: null }; + case 'npm': { + try { + const version = (JSON.parse(stdout) as { dependencies?: Record }).dependencies?.[request.name]?.version ?? null; + return { installed: version !== null, version }; + } catch { + return { installed: false, version: null }; + } + } + case 'pip': { + const match = /^Version:\s*(\S+)/m.exec(stdout); + return { installed: match !== null, version: match?.[1] ?? null }; + } + case 'image': + return { installed: stdout.trim().startsWith('['), version: null }; + } +} + +/** + * May this install run without a card? Only when the user turned on + * automatic installs and chose Workspace full, and only for a local copy + * that downloads nothing and needs no administrator rights (today: an image + * already in Docker copied into Podman). Anything downloaded from a public + * registry (npm, pip, image pulls, apt, winget) always waits for a card. + */ +export function installRunsWithoutCard(plan: InstallPlan, settings: { enabled: boolean; auto: boolean }, profile: string): boolean { + return settings.enabled && settings.auto && profile === 'workspace-full' && !plan.elevated && !plan.downloads; +} diff --git a/src/installTool.ts b/src/installTool.ts new file mode 100644 index 0000000..292a012 --- /dev/null +++ b/src/installTool.ts @@ -0,0 +1,407 @@ +/** + * The Agent's install tools (roadmap 6.2), built on `installPlan.ts`: + * - `install_package` says what the task needs and asks for permission on + * a card with the exact command; with permission RAME installs it and + * checks the result itself; + * - `check_installed` (a read) checks whether something is installed, and + * can wait for an install the user is finishing in a terminal. + * + * Rules: + * - offered by default in Agent, never in a Mission run; + * - every install waits for the user's approval, unless the user turned on + * automatic installs and chose Workspace full; even then, anything that + * needs administrator rights waits, and goes through the OS prompt; + * - `sudo` runs with `-n` (it never waits for a password); when it needs + * one, the command opens in a VS Code terminal where the user types it; + * - an install is reported as done only when the check finds it. + */ + +import { spawn } from 'node:child_process'; +import { realpathSync, statSync } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { resolveTestInvocation } from './agentCommands.js'; +import { isWithinRoot } from './agentPolicy.js'; + +import { runBoundedProcess } from './agentProcess.js'; +import type { AgentToolDefinition, ValidatedToolCall } from './agentTools.js'; +import { type InstallPlan, installRunsWithoutCard, planInstall, readInstalled, validateInstallRequest } from './installPlan.js'; +import type { ProviderHost } from './providerTypes.js'; +import { ISOLATION_WORDS, isolatedInvocation, isolationRefusal, isolationSupport, isolationUnavailableText } from './shellIsolation.js'; +import type { CommandIsolation } from './settingsStore.js'; + +const INSTALL_TIMEOUT_MS = 15 * 60 * 1000; +const MAX_OUTPUT = 64 * 1024; + +let openTerminal: ((title: string, commandLine: string) => void) | null = null; + +/** The extension gives RAME a way to open a terminal with a command for the user to run. */ +export function setInstallTerminal(open: ((title: string, commandLine: string) => void) | null): void { + openTerminal = open; +} + +export function installToolsOffered(host: ProviderHost): boolean { + return host.settings.installs?.enabled !== false && host.missionAuto === null && host.activeMission === null; +} + +const REQUEST_PROPERTIES = { + manager: { type: 'string', enum: ['apt', 'winget', 'npm', 'pip', 'image'] }, + name: { type: 'string' }, + version: { type: 'string' }, + runtime: { type: 'string', enum: ['podman', 'docker'] }, +}; + +export function installToolDefinitions(host: ProviderHost): AgentToolDefinition[] { + if (!installToolsOffered(host)) { + return []; + } + return [ + { + type: 'function', + function: { + name: 'install_package', + description: + 'Install something the task needs, with the user\'s permission: a system package (manager apt on Ubuntu/Debian, winget on Windows), an npm package (global, for the user), a Python package (pip, for the user), or a container image (manager image, with runtime podman or docker). ' + + 'Say why in reason. The user sees the exact command and approves it; administrator rights go through the user\'s own password or UAC prompt. RAME checks the result itself. Check first with check_installed; install only what is missing.', + parameters: { type: 'object', properties: { ...REQUEST_PROPERTIES, reason: { type: 'string' } }, required: ['manager', 'name', 'reason'], additionalProperties: false }, + }, + }, + { + type: 'function', + function: { + name: 'check_installed', + description: 'Check whether a package, program or image is installed (and its version). With waitSec (0 to 120), wait up to that long for an install the user is finishing.', + parameters: { type: 'object', properties: { ...REQUEST_PROPERTIES, waitSec: { type: 'integer' } }, required: ['manager', 'name'], additionalProperties: false }, + }, + }, + ]; +} + +function requestOf(call: ValidatedToolCall): ReturnType { + const { reason: _reason, waitSec: _wait, ...request } = call.args as Record; + return validateInstallRequest(request); +} + +const isFile = (candidate: string): boolean => { + try { + return statSync(candidate).isFile(); + } catch { + return false; + } +}; + +const real = (target: string): string | null => { + try { + return realpathSync(target); + } catch { + return null; + } +}; + +/** + * POSIX: a folder or program anyone may change, or one owned by another + * user (neither root nor the user), is not trusted. Windows: not checked. + */ +function untrustedOwnerOrMode(target: string, platform: NodeJS.Platform): boolean { + if (platform === 'win32') { + return false; + } + try { + const stat = statSync(target); + const worldWritable = (stat.mode & 0o002) !== 0 && (stat.mode & 0o1000) === 0; + const me = typeof process.getuid === 'function' ? process.getuid() : -1; + return worldWritable || (stat.uid !== 0 && stat.uid !== me); + } catch { + return true; + } +} + +/** + * The program that really runs: found only in absolute PATH entries outside + * the project, so a `sudo` or `pip` the agent wrote into the project (or one + * reachable through a relative entry such as `.`) is never executed. The + * folder and the program are also followed through symbolic links (neither + * may lead into the project) and must not be writable by anyone or owned by + * another user. npm on Windows runs as node.exe + npm-cli.js (a .cmd cannot + * run without a shell). + */ +export function trustedInvocation(executable: string, argv: readonly string[], root: string, platform: NodeJS.Platform = process.platform, pathEnv: string | undefined = process.env.PATH): { executable: string; argv: string[] } { + if (platform === 'win32' && executable === 'npm') { + return resolveTestInvocation({ executable, argv }, { platform, pathEnv, cwd: root, isFile }); + } + const join = platform === 'win32' ? path.win32.join : path.posix.join; + const absolute = platform === 'win32' ? path.win32.isAbsolute : path.posix.isAbsolute; + const roots = [root, real(root)].filter((entry): entry is string => entry !== null); + const inProject = (target: string): boolean => roots.some((projectRoot) => isWithinRoot(projectRoot, target, platform)); + for (const dir of (pathEnv ?? '').split(platform === 'win32' ? ';' : ':')) { + const entry = dir.trim().replace(/^"(.*)"$/, '$1'); + if (entry.length === 0 || !absolute(entry) || inProject(entry)) { + continue; + } + const realEntry = real(entry); + if (realEntry === null || inProject(realEntry) || untrustedOwnerOrMode(realEntry, platform)) { + continue; + } + for (const suffix of platform === 'win32' ? ['.exe'] : ['']) { + const candidate = join(entry, `${executable}${suffix}`); + if (!isFile(candidate)) { + continue; + } + const realCandidate = real(candidate); + if (realCandidate === null || inProject(realCandidate) || untrustedOwnerOrMode(realCandidate, platform)) { + continue; + } + return { executable: candidate, argv: [...argv] }; + } + } + throw new Error(`${executable} was not found in a safe PATH entry (relative entries and folders inside the project are ignored)`); +} + +async function runTrusted(executable: string, argv: readonly string[], root: string, timeoutMs: number, signal?: AbortSignal, isolation: CommandIsolation = 'total', extraWrites: readonly string[] = []): Promise<{ exitCode: number | null; stdout: string; stderr: string; timedOut: boolean }> { + let invocation: { executable: string; argv: string[] }; + try { + invocation = trustedInvocation(executable, argv, root); + } catch (error) { + return { exitCode: null, stdout: '', stderr: String((error as Error)?.message ?? error), timedOut: false }; + } + const isolated = isolatedInvocation(isolation, invocation.executable, invocation.argv, root, undefined, undefined, extraWrites); + return runBoundedProcess({ executable: isolated.executable, argv: isolated.argv, timeoutMs, maxOutputBytes: MAX_OUTPUT }, signal).catch((error: unknown) => ({ exitCode: null, stdout: '', stderr: String((error as Error)?.message ?? error), timedOut: false })); +} + +type InstallExecution = { plan: InstallPlan; isolation: CommandIsolation; writes: string[] }; + +async function installExecution(host: ProviderHost, plan: InstallPlan, root: string): Promise<{ ok: true; value: InstallExecution } | { ok: false; error: string }> { + const isolation = host.settings.shell?.isolation ?? 'project'; + if (plan.elevated) { + return { ok: true, value: { plan, isolation: 'total', writes: [] } }; + } + const refusal = isolationRefusal(isolation, isolation === 'total' ? { ok: true, abi: 0 } : await isolationSupport()); + if (refusal !== null) { + return { ok: false, error: isolationUnavailableText('installs', isolation, refusal) }; + } + if (isolation === 'closed' && plan.downloads) { + return { ok: false, error: 'Not run: this install downloads data, but Closed isolation blocks the network. Choose Project to keep file isolation with network, or Total consciously.' }; + } + if (isolation !== 'total' && plan.request.manager === 'image') { + return { ok: false, error: 'Not run: container runtimes write to storage managed outside the project, and RAME cannot safely grant only that storage. Choose Total consciously, or use an already configured container job.' }; + } + const writes: string[] = []; + if (isolation !== 'total' && plan.request.manager === 'npm') { + const prefix = await runTrusted('npm', ['prefix', '--global'], root, 20_000); + const folder = prefix.exitCode === 0 ? prefix.stdout.trim() : ''; + // If npm cannot report a prefix, grant no extra folder: Landlock then + // makes the install fail safely instead of widening the sandbox. + if (path.isAbsolute(folder)) writes.push(folder); + } + if (isolation !== 'total' && plan.request.manager === 'pip') { + const python = process.platform === 'win32' ? 'py' : 'python3'; + const locations = await runTrusted(python, ['-c', 'import site; print(site.getuserbase()); print(site.getusersitepackages())'], root, 20_000); + const folders = locations.exitCode === 0 ? locations.stdout.split(/\r?\n/).map((part) => part.trim()).filter((part) => path.isAbsolute(part)) : []; + if (folders.length >= 2) writes.push(...folders, path.join(folders[0] as string, 'bin')); + } + return { ok: true, value: { plan, isolation, writes } }; +} + +/** The image's id in that runtime (without `sha256:`), or `null` when it is not there. */ +async function imageId(runtime: 'docker' | 'podman', name: string, root: string): Promise { + const result = await runTrusted(runtime, ['image', 'inspect', '--format', '{{.Id}}', name], root, 20_000); + const id = result.stdout.trim().replace(/^sha256:/, ''); + return result.exitCode === 0 && /^[0-9a-f]{64}$/.test(id) ? id : null; +} + +async function planFor(call: ValidatedToolCall, root: string): Promise<{ ok: true; plan: InstallPlan } | { ok: false; error: string }> { + const request = requestOf(call); + if (!request.ok) { + return request; + } + const source = request.request.manager === 'image' && request.request.runtime === 'podman' ? await imageId('docker', request.request.name, root) : null; + return planInstall(request.request, { platform: process.platform, dockerHasImage: source !== null, ...(source !== null ? { dockerImageId: source } : {}) }); +} + +/** The approval card: why, and exactly what runs. */ +export async function prepareInstallCard(host: ProviderHost, call: ValidatedToolCall, root: string): Promise<{ ok: true; described: { targets: string[]; body: string; preconditions: Record } } | { ok: false; error: string }> { + if (!installToolsOffered(host)) { + return { ok: false, error: 'installs are off for this run (the user turned them off, or this is a Mission run)' }; + } + const planned = await planFor(call, root); + if (!planned.ok) { + return planned; + } + const execution = await installExecution(host, planned.plan, root); + if (!execution.ok) { + return execution; + } + const reason = String((call.args as { reason?: unknown }).reason ?? '').slice(0, 300); + const isolation = planned.plan.elevated ? 'not isolated: administrator installation opens in the VS Code terminal and runs only after the user acts' : ISOLATION_WORDS[execution.value.isolation]; + return { ok: true, described: { targets: [`why: ${reason}`, ...planned.plan.lines, `isolation: ${isolation}`], body: JSON.stringify({ reason, plan: planned.plan, isolation: execution.value.isolation, writes: execution.value.writes }), preconditions: {} } }; +} + +const approved = new WeakMap(); + +/** Right after the approval: the plan rebuilt now must still be the one the card showed. */ +export async function confirmInstallApproval(host: ProviderHost, call: ValidatedToolCall, approvedBody: string, root: string): Promise<{ ok: true } | { ok: false; error: string }> { + const planned = await planFor(call, root); + if (!planned.ok) { + return { ok: false, error: `The install can no longer run: ${planned.error}. Nothing was installed.` }; + } + const execution = await installExecution(host, planned.plan, root); + // Compare below with the actual setting embedded in the approved body; the + // host calls this immediately, so a settings change invalidates the card. + const reason = String((call.args as { reason?: unknown }).reason ?? '').slice(0, 300); + let shown: { isolation?: CommandIsolation; writes?: string[] }; + try { shown = JSON.parse(approvedBody) as typeof shown; } catch { return { ok: false, error: 'The install approval is not valid. Nothing was installed.' }; } + const level = shown.isolation ?? 'project'; + const current = execution.ok && level === execution.value.isolation ? execution.value : null; + if (current === null || JSON.stringify({ reason, plan: planned.plan, isolation: current.isolation, writes: current.writes }) !== approvedBody) { + return { ok: false, error: 'The install changed after it was approved. Nothing was installed; propose it again.' }; + } + approved.set(call, current); + return { ok: true }; +} + +const autoPlans = new WeakMap(); + +/** + * Automatic installs: only when the user turned them on, in Workspace full, + * and only for a local copy (no download, no administrator rights). The + * plan checked here is kept and must still be the same when it runs. + */ +export async function installAuto(host: ProviderHost, profile: string, call: ValidatedToolCall, root: string, fullAccess = false): Promise { + if (call.tool !== 'install_package' || !installToolsOffered(host) || (!fullAccess && (host.settings.installs?.auto !== true || profile !== 'workspace-full'))) { + return false; + } + const planned = await planFor(call, root); + // Full Access (Autonomy & Permissions): what a package manager downloads, never administrator rights. + if (!planned.ok || (fullAccess ? planned.plan.elevated : !installRunsWithoutCard(planned.plan, { enabled: true, auto: true }, profile))) { + return false; + } + const execution = await installExecution(host, planned.plan, root); + if (!execution.ok) return false; + autoPlans.set(call, execution.value); + return true; +} + +function pipe(step: NonNullable, root: string, signal: AbortSignal): Promise<{ exitCode: number | null; output: string }> { + let source: { executable: string; argv: string[] }; + let sink: { executable: string; argv: string[] }; + try { + source = trustedInvocation(step.executable, step.argv, root); + sink = trustedInvocation(step.pipeTo!.executable, step.pipeTo!.argv, root); + } catch (error) { + return Promise.resolve({ exitCode: null, output: String((error as Error)?.message ?? error) }); + } + return new Promise((resolve) => { + const from = spawn(source.executable, source.argv, { shell: false, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); + const to = spawn(sink.executable, sink.argv, { shell: false, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + let output = ''; + const keep = (chunk: Buffer) => (output = (output + chunk.toString('utf8')).slice(-MAX_OUTPUT)); + from.stdout.pipe(to.stdin); + from.stderr.on('data', keep); + to.stdout.on('data', keep); + to.stderr.on('data', keep); + const stop = () => (from.kill('SIGKILL'), to.kill('SIGKILL')); + signal.addEventListener('abort', stop, { once: true }); + const timer = setTimeout(stop, INSTALL_TIMEOUT_MS); + let first: number | null = null; + from.on('error', () => stop()); + to.on('error', () => (clearTimeout(timer), resolve({ exitCode: null, output }))); + from.on('exit', (code) => (first = code)); + to.on('exit', (code) => (clearTimeout(timer), signal.removeEventListener('abort', stop), resolve({ exitCode: first === 0 ? code : first ?? code, output }))); + }); +} + +async function check(plan: InstallPlan, root: string, signal?: AbortSignal): Promise<{ installed: boolean; version: string | null }> { + const result = await runTrusted(plan.check.executable, plan.check.argv, root, 60_000, signal); + return readInstalled(plan.request, result.exitCode, result.stdout); +} + +const what = (plan: InstallPlan): string => `${plan.request.manager} ${plan.request.name}${plan.request.version ? ` ${plan.request.version}` : ''}`; +const verified = (plan: InstallPlan, state: { version: string | null }): string => `Installed and verified by RAME Code: ${what(plan)}${state.version ? ` (version ${state.version})` : ''}.`; + +export async function executeInstallTool(host: ProviderHost, call: ValidatedToolCall, root: string, profile: string, signal: AbortSignal): Promise { + if (!installToolsOffered(host)) { + return 'Installs are off for this run: the user turned them off, or this is a Mission run.'; + } + if (call.tool === 'check_installed') { + const planned = await planFor(call, root); + if (!planned.ok) { + return `Not checked: ${planned.error}.`; + } + const deadline = Date.now() + Number((call.args as { waitSec?: number }).waitSec ?? 0) * 1000; + let state = await check(planned.plan, root, signal); + while (!state.installed && Date.now() < deadline && !signal.aborted) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + state = await check(planned.plan, root, signal); + } + return state.installed ? `Installed: ${what(planned.plan)}${state.version ? ` (version ${state.version})` : ''}.` : `Not installed: ${what(planned.plan)}.`; + } + let execution = approved.get(call) ?? null; + approved.delete(call); + const auto = autoPlans.get(call) ?? null; + autoPlans.delete(call); + if (execution === null && auto !== null) { + // Automatic: the plan must still be the one checked when the call was let through. + const planned = await planFor(call, root); + if (!planned.ok || JSON.stringify(planned.plan) !== JSON.stringify(auto.plan)) { + return 'Not installed: the install changed between the check and the run; propose it again.'; + } + execution = auto; + } + if (execution === null) { + return 'Not installed: this install was not approved on a card.'; + } + const { plan } = execution; + if ((await check(plan, root, signal)).installed && plan.request.version === undefined) { + return `Nothing to do: ${what(plan)} is already installed.`; + } + if (plan.elevated) { + const step = plan.steps[0]; + if (step === undefined) return 'Not installed: the approved installation has no command.'; + let line: string; + try { + line = [trustedInvocation(step.executable, [], root).executable, ...step.argv].join(' '); + } catch (error) { + return `Not installed: ${String((error as Error)?.message ?? error)}.`; + } + openTerminal?.('RAME Code: install (not isolated)', line); + return `Waiting for the user: ${what(plan)} needs administrator rights. Its approved command was opened in the VS Code terminal and is not Landlock-isolated; RAME Code never types or sees the password. Call check_installed after the user finishes.`; + } + for (const step of plan.steps) { + if (step.pipeTo) { + const result = await pipe(step, root, signal); + if (result.exitCode !== 0) { + return `The install failed (exit ${result.exitCode ?? 'unknown'}): ${result.output.slice(-1500)}`; + } + // The copy must be exactly the Docker image the plan named. + const expected = /source: the Docker image ([0-9a-f]{16})…/.exec(plan.lines.join('\n'))?.[1] ?? null; + const copied = await imageId('podman', plan.request.name, root); + if (expected !== null && (copied === null || !copied.startsWith(expected))) { + return `The copy is not the image the card named: Podman has ${copied?.slice(0, 16) ?? 'nothing'}…, the card said ${expected}…. Check it before using it.`; + } + continue; + } + // sudo never waits for a password here: -n fails at once when it needs one. + const argv = step.executable === 'sudo' ? ['-n', ...step.argv] : step.argv; + const result = await runTrusted(step.executable, argv, root, INSTALL_TIMEOUT_MS, signal, execution.isolation, execution.writes); + if (step.executable === 'sudo' && result.exitCode !== 0 && /password|terminal is required|askpass/i.test(result.stderr)) { + // The terminal's shell would look `sudo` up on the user's PATH, from the project folder: name the trusted one. + let line: string; + try { + line = [trustedInvocation('sudo', [], root).executable, ...step.argv].join(' '); + } catch (error) { + return `Not installed: ${String((error as Error)?.message ?? error)}.`; + } + if (openTerminal !== null) { + openTerminal('RAME Code: install', line); + return `Waiting for the user: ${what(plan)} needs administrator rights, so the command is open in the terminal "RAME Code: install" and the user types the password there (RAME Code never sees it). Call check_installed with waitSec 120 to see when it is done; do not install it again.`; + } + return `Needs administrator rights: ask the user to run this command in a terminal, then call check_installed: ${line}`; + } + if (result.exitCode !== 0) { + return `The install failed (exit ${result.exitCode ?? (result.timedOut ? 'timeout' : 'unknown')}): ${(result.stderr || result.stdout).slice(-1500)}`; + } + } + const state = await check(plan, root, signal); + return state.installed ? `${verified(plan, state)} Isolation: ${ISOLATION_WORDS[execution.isolation]}.` : `The install command finished under ${ISOLATION_WORDS[execution.isolation]}, but RAME Code could not find ${what(plan)} afterwards: it is not installed.`; +} diff --git a/src/jobCommands.ts b/src/jobCommands.ts new file mode 100644 index 0000000..bd0a360 --- /dev/null +++ b/src/jobCommands.ts @@ -0,0 +1,266 @@ +/** + * The user's controls for long jobs, as a VS Code command ("RAME Code: Long + * Jobs"): see every job, read the end of its log, stop it, settle an + * uncertain one (check again / abandon), turn long jobs on or off, and + * choose where they run (this computer or a container). Only the user takes + * these actions; the model can only start a job through its approval card. + * + * Free of the `vscode` API: the window functions are passed in. + */ + +import { LIMIT_RANGES, limitsForModel, parseLimitsByModel, validateLocalLimits } from './localLimits.js'; +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; + +import { type JobContainerSettings, validateContainerSettings } from './jobContainer.js'; +import { isFinalJob, type JobRecord } from './jobRecord.js'; +import type { ProviderHost } from './providerTypes.js'; + +export interface JobsUi { + showQuickPick(items: T[], options?: { placeHolder?: string; title?: string }): PromiseLike; + showInputBox(options: { title?: string; prompt?: string; value?: string; validateInput?: (value: string) => string | undefined | null }): PromiseLike; + showWarningMessage(message: string, options: { modal?: boolean; detail?: string }, ...actions: string[]): PromiseLike; + showInformationMessage(message: string, ...actions: string[]): PromiseLike; +} + +type JobsHost = Pick & { settings: ProviderHost['settings'] }; + +const ICON: Record = { starting: '$(loading~spin)', running: '$(sync~spin)', uncertain: '$(warning)', finished: '$(check)', cancelled: '$(circle-slash)', timed_out: '$(clock)', refused: '$(error)', abandoned: '$(trash)' }; + +function jobItem(job: JobRecord): { label: string; description: string; detail: string; job: JobRecord } { + const exit = job.exit?.exitCode; + return { + label: `${ICON[job.status] ?? ''} ${job.label} — ${job.status}${job.status === 'finished' && exit != null ? ` (exit ${exit})` : ''}`.trim(), + description: `${path.basename(job.spec.cwd)} · ${job.spec.cleanup ? 'container' : 'this computer'} · ${new Date(job.createdAt).toLocaleString()}`, + detail: job.note, + job, + }; +} + +/** + * A starting point for the container settings: a Node.js install from PATH + * (preferably one with npm), mounted read-only at /opt/node. Only a + * suggestion: the user sees and edits it before it is saved. + */ +export function suggestedContainerSettings(pathEnv: string | undefined = process.env.PATH): JobContainerSettings { + const dirs = process.platform === 'win32' ? [] : (pathEnv ?? '').split(path.delimiter).filter((dir) => path.isAbsolute(dir) && existsSync(path.join(dir, 'node'))); + const nodeBin = dirs.find((dir) => existsSync(path.join(dir, 'npm'))) ?? dirs[0]; + // /bin/node (nvm, distributions): mount the prefix, so npm finds its modules. + const toolMounts = nodeBin === undefined ? [] : path.basename(nodeBin) === 'bin' ? [{ hostPath: path.dirname(nodeBin), containerPath: '/opt/node' }] : [{ hostPath: nodeBin, containerPath: '/opt/node/bin' }]; + const onPath = (name: string): boolean => (pathEnv ?? '').split(path.delimiter).some((dir) => path.isAbsolute(dir) && existsSync(path.join(dir, process.platform === 'win32' ? `${name}.exe` : name))); + return { + runtime: onPath('podman') ? 'podman' : 'docker', + image: 'ubuntu:24.04', + memoryMb: 2048, + cpus: 2, + outputDir: 'job-output', + toolMounts, + pathEnv: `${toolMounts.length > 0 ? '/opt/node/bin:' : ''}/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`, + }; +} + +async function saveJobSettings(host: JobsHost, jobs: ProviderHost['settings']['jobs']): Promise { + host.settings = { ...host.settings, jobs }; + host.persistSettings(); + await host.postState(); +} + +async function jobActions(host: JobsHost, ui: JobsUi, job: JobRecord): Promise { + const actions = [ + { label: 'Show the end of its log', id: 'log' }, + ...(job.status === 'running' || job.status === 'starting' ? [{ label: 'Stop it', id: 'stop' }] : []), + ...(job.status === 'uncertain' ? [{ label: 'Check again', id: 'check' }] : []), + ...(job.status === 'uncertain' && job.spec.cleanup ? [{ label: 'Stop it (remove its container)', id: 'stop' }] : []), + ...(job.status === 'uncertain' ? [{ label: 'Abandon it (its result stays unknown; nothing is restarted)', id: 'abandon' }] : []), + ]; + const chosen = await ui.showQuickPick(actions, { title: `${job.label} — ${job.status}`, placeHolder: job.note }); + if (chosen === undefined) { + return; + } + if (chosen.id === 'log') { + host.outputChannel.clear(); + host.outputChannel.appendLine(`${job.label} (${job.id}) — ${job.status}: ${job.note}`); + host.outputChannel.appendLine((await host.jobs.tail(job.id, 16_384)) || '(no output yet)'); + host.outputChannel.show(true); + return; + } + if (chosen.id === 'check') { + const result = await host.jobs.decide(job.id, 'check-again'); + await ui.showInformationMessage(result.ok ? `${result.value.label}: ${result.value.status}. ${result.value.note}` : result.error); + return; + } + const verb = chosen.id === 'stop' ? 'Stop' : 'Abandon'; + const sure = await ui.showWarningMessage(`${verb} the job ${job.label}?`, { modal: true, detail: job.note }, verb); + if (sure !== verb) { + return; + } + const result = chosen.id === 'stop' ? await host.jobs.cancel(job.id) : await host.jobs.decide(job.id, 'abandon'); + await ui.showInformationMessage(result.ok ? `${result.value.label}: ${result.value.note}` : result.error); +} + +async function chooseIsolation(host: JobsHost, ui: JobsUi): Promise { + const where = await ui.showQuickPick( + [ + { label: 'On this computer', detail: 'Jobs run with your permissions (network, files, processes). No sandbox.', id: 'host' }, + { label: 'In a container (Podman or Docker)…', detail: 'No network, limited memory and CPUs, the project read-only except one output folder. The image must already be on this machine. Rootless Podman is preferred: being in the docker group is equivalent to root.', id: 'container' }, + ], + { title: 'Where long jobs run' }, + ); + if (where === undefined) { + return; + } + if (where.id === 'host') { + await saveJobSettings(host, { ...host.settings.jobs, container: null }); + return; + } + const value = await ui.showInputBox({ + title: 'Container for long jobs (JSON)', + prompt: 'runtime (podman or docker), image, memoryMb, cpus, outputDir (inside the project), toolMounts (read-only), pathEnv', + value: JSON.stringify(host.settings.jobs.container ?? suggestedContainerSettings()), + validateInput: (text) => { + try { + const checked = validateContainerSettings(JSON.parse(text)); + return checked.ok ? null : checked.error; + } catch { + return 'not valid JSON'; + } + }, + }); + if (value === undefined) { + return; + } + const checked = validateContainerSettings(JSON.parse(value)); + if (checked.ok) { + await saveJobSettings(host, { ...host.settings.jobs, container: checked.settings }); + } +} + +export async function runJobsMenu(host: JobsHost, ui: JobsUi): Promise { + const jobs = host.jobs.list(); + await Promise.all(jobs.filter((job) => job.status === 'running' || job.status === 'starting').map((job) => host.jobs.refresh(job.id))); + const current = host.jobs.list().reverse(); + const shown = [...current.filter((job) => !isFinalJob(job.status)), ...current.filter((job) => isFinalJob(job.status)).slice(0, 15)]; + const settings = host.settings.jobs; + const chosen = await ui.showQuickPick<{ label: string; description?: string; detail?: string; job?: JobRecord; id?: string }>( + [ + ...shown.map(jobItem), + { label: settings.enabled ? '$(debug-pause) Long jobs for Agent: ON — turn off' : '$(play) Long jobs for Agent: OFF — turn on', id: 'toggle' }, + { label: `$(server) Where jobs run: ${settings.container ? `container ${settings.container.image}` : 'this computer (no sandbox)'} — change`, id: 'isolation' }, + ], + { title: 'RAME Code: Long Jobs', placeHolder: shown.length === 0 ? 'No long jobs yet.' : 'Choose a job to see its log, stop it or settle it.' }, + ); + if (chosen === undefined) { + return; + } + if (chosen.job !== undefined) { + await jobActions(host, ui, chosen.job); + } else if (chosen.id === 'toggle') { + if (settings.enabled) { + await saveJobSettings(host, { ...settings, enabled: false }); + return; + } + const sure = await ui.showWarningMessage( + 'Turn on long jobs for Agent?', + { + modal: true, + detail: `Agent may then propose package scripts (bench, build, test…) that run in the background, keep running if the window reloads, and are never started twice. Each job waits for your approval on a card. ${settings.container ? `They run in the container ${settings.container.image}.` : 'They run on this computer with your permissions: there is no sandbox unless you choose a container.'}`, + }, + 'Turn on', + ); + if (sure === 'Turn on') { + await saveJobSettings(host, { ...settings, enabled: true }); + } + } else if (chosen.id === 'isolation') { + await chooseIsolation(host, ui); + } +} + +/** After a reload: tell the user about jobs whose result RAME no longer knows. */ +export async function noticeUncertainJobs(host: JobsHost, ui: JobsUi): Promise { + const uncertain = host.jobs.list().filter((job) => job.status === 'uncertain'); + if (uncertain.length === 0) { + return; + } + const answer = await ui.showInformationMessage( + `RAME Code lost track of ${uncertain.length === 1 ? `the long job ${uncertain[0]?.label}` : `${uncertain.length} long jobs`}; nothing was restarted. Review ${uncertain.length === 1 ? 'it' : 'them'}?`, + 'Review', + ); + if (answer === 'Review') { + await runJobsMenu(host, ui); + } +} + +/** "RAME Code: Installs": turn installs with permission on or off, and the automatic mode. */ +export async function runInstallsMenu(host: JobsHost, ui: JobsUi): Promise { + const installs = host.settings.installs; + const chosen = await ui.showQuickPick( + [ + { label: installs.enabled ? '$(check) Installs for Agent: ON (each one on a card) — turn off' : '$(circle-slash) Installs for Agent: OFF — turn on', id: 'enabled' }, + { label: installs.auto ? '$(zap) Automatic installs in Workspace full: ON — turn off' : '$(lock) Automatic installs in Workspace full: OFF — turn on', id: 'auto' }, + ], + { title: 'RAME Code: Installs', placeHolder: 'Administrator rights always go through your own password or UAC prompt.' }, + ); + if (chosen === undefined) { + return; + } + if (chosen.id === 'enabled') { + host.settings = { ...host.settings, installs: { enabled: !installs.enabled, auto: installs.enabled ? false : installs.auto } }; + } else if (installs.auto) { + host.settings = { ...host.settings, installs: { ...installs, auto: false } }; + } else { + const sure = await ui.showWarningMessage( + 'Let Agent install without a card in Workspace full?', + { modal: true, detail: 'npm and pip packages and container images (including downloads) would then install without asking, each one still recorded and checked. Anything that needs administrator rights still waits for your approval and your own password or UAC prompt.' }, + 'Turn on', + ); + if (sure !== 'Turn on') { + return; + } + host.settings = { ...host.settings, installs: { enabled: true, auto: true } }; + } + host.persistSettings(); + await host.postState(); +} + +/** + * The idle and total limits of Agent requests to one local model (B-30). + * Asked as "idle seconds, total seconds"; an empty answer goes back to the + * defaults (180 s and 10 min). The limits are never removed, only set within + * their ranges. + */ +export async function runLocalLimitsMenu(host: JobsHost & { generationModel(provider: 'local'): string }, ui: JobsUi): Promise { + const model = host.generationModel('local').trim(); + if (model.length === 0) { + await ui.showInformationMessage('Choose a local model first.'); + return; + } + const current = limitsForModel(host.settings.localLimits, model); + const answer = await ui.showInputBox({ + title: `RAME Code: limits for ${model}`, + prompt: `Idle seconds (no new output, including the wait for the first token), then total seconds of one request. Empty = 180, 600. Ranges: ${LIMIT_RANGES}. Cancel always works.`, + value: `${current.idleMs / 1000}, ${current.totalMs / 1000}`, + validateInput: (value) => (value.trim() === '' || parseLimitsAnswer(value) !== null ? null : `Two whole numbers, for example 180, 600 (${LIMIT_RANGES}).`), + }); + if (answer === undefined) { + return; + } + const limits = { ...host.settings.localLimits }; + const parsed = parseLimitsAnswer(answer); + if (answer.trim() === '' || parsed === null) { + delete limits[model]; + } else { + limits[model] = parsed; + } + host.settings = { ...host.settings, localLimits: parseLimitsByModel(limits) }; + host.persistSettings(); + await host.postState(); +} + +export function parseLimitsAnswer(value: string): { idleSec: number; totalSec: number } | null { + const match = /^\s*(\d{1,5})\s*[,; ]\s*(\d{1,5})\s*$/.exec(value); + if (match === null) { + return null; + } + const limits = validateLocalLimits({ idleSec: Number(match[1]), totalSec: Number(match[2]) }); + return limits === null ? null : { idleSec: limits.idleMs / 1000, totalSec: limits.totalMs / 1000 }; +} diff --git a/src/jobContainer.ts b/src/jobContainer.ts new file mode 100644 index 0000000..b905f89 --- /dev/null +++ b/src/jobContainer.ts @@ -0,0 +1,164 @@ +/** + * Container isolation for long jobs (piece D of the long-Mission plan). + * + * A job can run inside a container (Podman, preferably rootless, or Docker) + * the user configured, instead of on the computer with the user's permissions: + * - no network (`--network none`); + * - bounded memory (swap included), CPUs and processes; + * - no capabilities, no privilege escalation, a read-only root filesystem; + * - the project mounted READ-ONLY, except one output folder; + * - the image is never pulled (`--pull never`): only an image already on + * the machine runs, so nothing is downloaded behind the user's back; + * - a unique container name, so stopping it never depends on a pid. + * + * Rootless Podman maps the user's own uid into the container + * (`--userns=keep-id`); Docker runs the job as the user's uid:gid. Note that + * being in the `docker` group is equivalent to root on the machine: the + * container bounds the job, not RAME itself. Rootless Podman does not have + * that problem. + * + * Pure: builds the `run` argv and the card's description. + */ + +import * as path from 'node:path'; + +import { DEFAULT_JOB_HEARTBEAT_MS, DEFAULT_JOB_LOG_BYTES, type JobSpec } from './jobRecord.js'; + +export interface JobToolMount { + /** Absolute folder on the computer, mounted read-only (e.g. a Node.js install). */ + hostPath: string; + /** Absolute folder inside the container. */ + containerPath: string; +} + +export type ContainerRuntime = 'docker' | 'podman'; + +export interface JobContainerSettings { + runtime: ContainerRuntime; + image: string; + memoryMb: number; + cpus: number; + /** Project-relative folder the job may write to (created if missing). */ + outputDir: string; + toolMounts: JobToolMount[]; + /** PATH inside the container. */ + pathEnv: string; +} + +export const CONTAINER_WORKDIR = '/work'; +const DEFAULT_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'; +const PROTECTED_OUTPUT = /^(?:\.git|\.vscode|\.env[^/]*|node_modules|\.ssh|\.rame[^/]*)(?:\/|$)/i; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +const isAbsolutePosix = (value: unknown): value is string => typeof value === 'string' && path.posix.isAbsolute(value) && !value.split('/').includes('..') && !/[\0\n:,]/.test(value); + +/** Validate the user's container settings; the error says what is wrong. */ +export function validateContainerSettings(raw: unknown): { ok: true; settings: JobContainerSettings } | { ok: false; error: string } { + if (!isRecord(raw)) { + return { ok: false, error: 'container settings are missing' }; + } + const runtime = raw.runtime === undefined ? 'docker' : raw.runtime; + if (runtime !== 'docker' && runtime !== 'podman') { + return { ok: false, error: 'the container runtime must be podman or docker' }; + } + if (typeof raw.image !== 'string' || !/^[a-z0-9][a-z0-9._\/:@-]{0,199}$/i.test(raw.image) || raw.image.startsWith('-')) { + return { ok: false, error: 'the image must be a local image name such as ubuntu:24.04' }; + } + if (!Number.isInteger(raw.memoryMb) || (raw.memoryMb as number) < 64 || (raw.memoryMb as number) > 262_144) { + return { ok: false, error: 'memory must be 64 to 262144 MB' }; + } + if (typeof raw.cpus !== 'number' || !Number.isFinite(raw.cpus) || raw.cpus < 0.1 || raw.cpus > 256) { + return { ok: false, error: 'CPUs must be 0.1 to 256' }; + } + const outputDir = typeof raw.outputDir === 'string' ? raw.outputDir.replace(/\\/g, '/').replace(/\/+$/, '') : ''; + if (outputDir === '' || outputDir === '.' || path.posix.isAbsolute(outputDir) || outputDir.split('/').some((part) => part === '..' || part === '') || PROTECTED_OUTPUT.test(outputDir) || /[\0\n:,]/.test(outputDir)) { + return { ok: false, error: 'the output folder must be a plain folder inside the project (not .git, .vscode, .env, node_modules)' }; + } + const mounts = raw.toolMounts === undefined ? [] : raw.toolMounts; + if (!Array.isArray(mounts) || mounts.length > 8 || !mounts.every((entry) => isRecord(entry) && isAbsolutePosix(entry.hostPath) && isAbsolutePosix(entry.containerPath) && entry.containerPath !== CONTAINER_WORKDIR && !String(entry.containerPath).startsWith(`${CONTAINER_WORKDIR}/`))) { + return { ok: false, error: 'tool folders need absolute paths on both sides, outside /work' }; + } + const pathEnv = raw.pathEnv === undefined ? DEFAULT_PATH : raw.pathEnv; + if (typeof pathEnv !== 'string' || pathEnv.length === 0 || pathEnv.length > 1024 || !pathEnv.split(':').every((entry) => path.posix.isAbsolute(entry))) { + return { ok: false, error: 'PATH must list absolute folders' }; + } + return { + ok: true, + settings: { + runtime, + image: raw.image, + memoryMb: raw.memoryMb as number, + cpus: raw.cpus, + outputDir, + toolMounts: (mounts as JobToolMount[]).map((entry) => ({ hostPath: entry.hostPath, containerPath: entry.containerPath })), + pathEnv, + }, + }; +} + +/** One line for the approval card; it is part of what the user approves. */ +export function describeIsolation(container: JobContainerSettings | null): string { + if (container === null) { + return 'isolation: none — it runs on this computer with your permissions (network, files, processes); there is no sandbox'; + } + const tools = container.toolMounts.length > 0 ? `; read-only tools: ${container.toolMounts.map((entry) => `${entry.hostPath} → ${entry.containerPath}`).join(', ')}` : ''; + const runtime = container.runtime === 'podman' ? `${typeof process.getuid === 'function' && process.getuid() !== 0 ? 'rootless ' : ''}podman ` : ''; + return `isolation: ${runtime}container ${container.image} (local image only, never pulled): no network, ${container.memoryMb} MB of memory, ${container.cpus} CPU(s), the project read-only except ${container.outputDir}/${tools}`; +} + +export interface ContainerJobInput { + /** The command inside the container, run in /work. */ + executable: string; + argv: string[]; + projectRoot: string; + timeoutMs: number; + /** Unique, e.g. `rame-job-`. */ + containerName: string; + /** Docker: POSIX uid:gid to run as; `null` keeps the image's user. Podman maps the user's own uid. */ + user: string | null; + /** The runtime's executable; default: the configured runtime name. */ + docker?: string; + /** Host-owned folders mounted read-write outside /work (e.g. the host's results folder). */ + extraMounts?: JobToolMount[]; + /** Extra environment inside the container. */ + env?: Record; + /** Project files whose names may be visible but whose contents are host-only. */ + hiddenFiles?: string[]; +} + +/** The job spec that runs `input` inside the configured container. */ +export function buildContainerJob(input: ContainerJobInput, container: JobContainerSettings): JobSpec { + const docker = input.docker ?? container.runtime; + const output = path.join(input.projectRoot, ...container.outputDir.split('/')); + const argv = [ + 'run', '--rm', '--init', '--pull', 'never', + '--name', input.containerName, + '--network', 'none', + '--memory', `${container.memoryMb}m`, '--memory-swap', `${container.memoryMb}m`, + '--cpus', String(container.cpus), '--pids-limit', '512', + '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', + '--read-only', '--tmpfs', '/tmp:rw,size=256m', + ...(container.runtime === 'podman' ? ['--userns=keep-id'] : input.user !== null ? ['--user', input.user] : []), + '-e', 'HOME=/tmp', '-e', `PATH=${container.pathEnv}`, '-e', 'npm_config_cache=/tmp/.npm', '-e', 'npm_config_update_notifier=false', '-e', 'RAME_JOB=1', + '-v', `${input.projectRoot}:${CONTAINER_WORKDIR}:ro`, + '-v', `${output}:${CONTAINER_WORKDIR}/${container.outputDir}:rw`, + ...(input.hiddenFiles ?? []).flatMap((file) => ['-v', `/dev/null:${CONTAINER_WORKDIR}/${file}:ro`]), + ...container.toolMounts.flatMap((entry) => ['-v', `${entry.hostPath}:${entry.containerPath}:ro`]), + ...(input.extraMounts ?? []).flatMap((entry) => ['-v', `${entry.hostPath}:${entry.containerPath}:rw`]), + ...Object.entries(input.env ?? {}).flatMap(([key, value]) => ['-e', `${key}=${value}`]), + '-w', CONTAINER_WORKDIR, + '--', container.image, input.executable, ...input.argv, + ]; + return { + executable: docker, + argv, + cwd: input.projectRoot, + timeoutMs: input.timeoutMs, + maxLogBytes: DEFAULT_JOB_LOG_BYTES, + heartbeatMs: DEFAULT_JOB_HEARTBEAT_MS, + env: {}, + keepElectronRunAsNode: false, + // Stopping the docker client does not always stop the container: remove it by its unique name. + cleanup: { executable: docker, argv: ['rm', '-f', input.containerName] }, + }; +} diff --git a/src/jobRecord.ts b/src/jobRecord.ts new file mode 100644 index 0000000..170a476 --- /dev/null +++ b/src/jobRecord.ts @@ -0,0 +1,294 @@ +/** + * Long jobs (piece C of the long-Mission plan): commands that run for minutes + * or hours, outlive a reload of the window that started them, and are never + * started twice by the host. + * + * This module is pure: the record, its approval digest, and the decision the + * host takes after a reload from what it can observe of a job. The process + * side is `jobRunner.ts` and `runtime/rame-job-supervisor.cjs`. + */ + +import { createHash } from 'node:crypto'; +import * as path from 'node:path'; + +export const MAX_JOB_DURATION_MS = 6 * 60 * 60 * 1000; +export const DEFAULT_JOB_LOG_BYTES = 256 * 1024; +export const MAX_JOB_LOG_BYTES = 4 * 1024 * 1024; +export const DEFAULT_JOB_HEARTBEAT_MS = 1000; +export const MAX_RUNNING_JOBS_PER_SCOPE = 2; +export const MAX_STORED_JOBS = 50; + +/** + * `starting`/`running` are live; `uncertain` waits for the user; the rest are + * final. A final job is never changed again and never restarted. + */ +export type JobStatus = 'starting' | 'running' | 'uncertain' | 'finished' | 'cancelled' | 'timed_out' | 'refused' | 'abandoned'; +const STATUSES: readonly JobStatus[] = ['starting', 'running', 'uncertain', 'finished', 'cancelled', 'timed_out', 'refused', 'abandoned']; +const FINAL: readonly JobStatus[] = ['finished', 'cancelled', 'timed_out', 'refused', 'abandoned']; + +export const isFinalJob = (status: JobStatus): boolean => FINAL.includes(status); + +/** Exactly what the supervisor runs; its sha256 is what the user approved. */ +export interface JobSpec { + executable: string; + argv: string[]; + cwd: string; + timeoutMs: number; + maxLogBytes: number; + heartbeatMs: number; + env: Record; + keepElectronRunAsNode: boolean; + /** Run by the supervisor when it stops the job and when the job ends (e.g. remove its container). */ + cleanup?: { executable: string; argv: string[] } | null; +} + +/** What the supervisor writes last, in `exit.json`. */ +export interface JobExit { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + cancelled: boolean; + refused?: string; + endedAt: number; +} + +export interface JobRecord { + id: string; + scopeId: string; + /** Allowlisted command id, e.g. `npm:bench`. */ + label: string; + missionId: string | null; + spec: JobSpec; + /** sha256 of the canonical spec, the bytes of `spec.json`. */ + specDigest: string; + dir: string; + status: JobStatus; + createdAt: number; + updatedAt: number; + revision: number; + supervisorPid: number | null; + childPid: number | null; + exit: JobExit | null; + cancelRequested: boolean; + /** Plain explanation of the latest state change. */ + note: string; +} + +/** What the host can observe of a job directory. */ +export interface JobProbe { + now: number; + exit: JobExit | null; + started: { pid: number; childPid: number | null; startedAt: number } | null; + heartbeatAt: number | null; + /** Whether the command's pid answers a signal 0 (informative only: pids are reused). */ + childAlive: boolean | null; +} + +/** The canonical bytes of a spec, in a fixed key order. */ +export function canonicalJobSpec(spec: JobSpec): string { + const env: Record = {}; + for (const key of Object.keys(spec.env).sort()) { + env[key] = spec.env[key] as string; + } + return JSON.stringify({ + executable: spec.executable, + argv: [...spec.argv], + cwd: spec.cwd, + timeoutMs: spec.timeoutMs, + maxLogBytes: spec.maxLogBytes, + heartbeatMs: spec.heartbeatMs, + env, + keepElectronRunAsNode: spec.keepElectronRunAsNode, + cleanup: spec.cleanup ? { executable: spec.cleanup.executable, argv: [...spec.cleanup.argv] } : null, + }); +} + +const sha256 = (text: string): string => createHash('sha256').update(text, 'utf8').digest('hex'); + +export const jobSpecDigest = (spec: JobSpec): string => sha256(canonicalJobSpec(spec)); + +/** The digest an approval binds: where it runs, for whom, and exactly what. */ +export function jobApprovalDigest(input: { scopeId: string; label: string; missionId: string | null; spec: JobSpec }): string { + return sha256(JSON.stringify({ scopeId: input.scopeId, label: input.label, missionId: input.missionId, spec: jobSpecDigest(input.spec) })); +} + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +const isStrings = (value: unknown): value is string[] => Array.isArray(value) && value.every((part) => typeof part === 'string'); +const isPid = (value: unknown): value is number => Number.isInteger(value) && (value as number) > 0; + +/** Validate a spec before it is approved or after it is read back. */ +export function validateJobSpec(raw: unknown): { ok: true; spec: JobSpec } | { ok: false; error: string } { + if (!isRecord(raw) || typeof raw.executable !== 'string' || raw.executable.length === 0 || raw.executable.length > 256) { + return { ok: false, error: 'a job needs an executable' }; + } + if (!isStrings(raw.argv) || raw.argv.length > 96 || raw.argv.some((part) => part.length > 1024 || part.includes('\0'))) { + return { ok: false, error: 'a job needs a bounded list of arguments' }; + } + if (typeof raw.cwd !== 'string' || !path.isAbsolute(raw.cwd)) { + return { ok: false, error: 'a job needs an absolute working folder' }; + } + if (!Number.isInteger(raw.timeoutMs) || (raw.timeoutMs as number) < 1000 || (raw.timeoutMs as number) > MAX_JOB_DURATION_MS) { + return { ok: false, error: `a job's time limit must be 1 s to ${MAX_JOB_DURATION_MS / 3_600_000} h` }; + } + if (!Number.isInteger(raw.maxLogBytes) || (raw.maxLogBytes as number) < 1024 || (raw.maxLogBytes as number) > MAX_JOB_LOG_BYTES) { + return { ok: false, error: 'a job needs a bounded log size' }; + } + if (!Number.isInteger(raw.heartbeatMs) || (raw.heartbeatMs as number) < 50 || (raw.heartbeatMs as number) > 10_000) { + return { ok: false, error: 'a job needs a heartbeat of 50 ms to 10 s' }; + } + if (!isRecord(raw.env) || Object.keys(raw.env).length > 32 || !Object.values(raw.env).every((value) => typeof value === 'string')) { + return { ok: false, error: 'a job needs a small set of extra environment variables' }; + } + if (typeof raw.keepElectronRunAsNode !== 'boolean') { + return { ok: false, error: 'a job spec is incomplete' }; + } + const cleanup = raw.cleanup ?? null; + if (cleanup !== null && (!isRecord(cleanup) || typeof cleanup.executable !== 'string' || cleanup.executable.length === 0 || !isStrings(cleanup.argv) || cleanup.argv.length > 16)) { + return { ok: false, error: 'a job cleanup must be an executable with a few arguments' }; + } + return { + ok: true, + spec: { + executable: raw.executable, + argv: [...raw.argv], + cwd: raw.cwd, + timeoutMs: raw.timeoutMs as number, + maxLogBytes: raw.maxLogBytes as number, + heartbeatMs: raw.heartbeatMs as number, + env: { ...(raw.env as Record) }, + keepElectronRunAsNode: raw.keepElectronRunAsNode, + cleanup: cleanup === null ? null : { executable: cleanup.executable as string, argv: [...(cleanup.argv as string[])] }, + }, + }; +} + +export function parseJobExit(raw: unknown): JobExit | null { + if (!isRecord(raw) || typeof raw.endedAt !== 'number' || typeof raw.timedOut !== 'boolean' || typeof raw.cancelled !== 'boolean') { + return null; + } + if (!(raw.exitCode === null || Number.isInteger(raw.exitCode)) || !(raw.signal === null || typeof raw.signal === 'string')) { + return null; + } + return { + exitCode: raw.exitCode as number | null, + signal: raw.signal as string | null, + timedOut: raw.timedOut, + cancelled: raw.cancelled, + ...(typeof raw.refused === 'string' ? { refused: raw.refused.slice(0, 400) } : {}), + endedAt: raw.endedAt, + }; +} + +/** A stored record; `null` when damaged or when its folder is outside `jobsRoot`. */ +export function parseJobRecord(raw: unknown, jobsRoot: string): JobRecord | null { + if (!isRecord(raw) || typeof raw.id !== 'string' || !/^[a-z0-9-]{8,64}$/.test(raw.id)) { + return null; + } + const spec = validateJobSpec(raw.spec); + if (!spec.ok || typeof raw.specDigest !== 'string' || raw.specDigest !== jobSpecDigest(spec.spec)) { + return null; + } + if (typeof raw.dir !== 'string' || path.resolve(raw.dir) !== path.join(path.resolve(jobsRoot), raw.id)) { + return null; + } + if (!STATUSES.includes(raw.status as JobStatus) || typeof raw.scopeId !== 'string' || typeof raw.label !== 'string') { + return null; + } + if (typeof raw.createdAt !== 'number' || typeof raw.updatedAt !== 'number' || !Number.isInteger(raw.revision)) { + return null; + } + return { + id: raw.id, + scopeId: raw.scopeId, + label: raw.label.slice(0, 128), + missionId: typeof raw.missionId === 'string' ? raw.missionId : null, + spec: spec.spec, + specDigest: raw.specDigest, + dir: raw.dir, + status: raw.status as JobStatus, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + revision: raw.revision as number, + supervisorPid: isPid(raw.supervisorPid) ? raw.supervisorPid : null, + childPid: isPid(raw.childPid) ? raw.childPid : null, + exit: parseJobExit(raw.exit), + cancelRequested: raw.cancelRequested === true, + note: typeof raw.note === 'string' ? raw.note.slice(0, 600) : '', + }; +} + +/** After this long without a heartbeat, a live job is no longer assumed alive. */ +export const jobStaleAfterMs = (spec: JobSpec): number => spec.heartbeatMs * 5 + 2000; + +const seconds = (ms: number): string => (ms < 120_000 ? `${Math.round(ms / 1000)} s` : `${Math.round(ms / 60_000)} min`); + +export interface JobReconciliation { + status: JobStatus; + note: string; + exit: JobExit | null; + supervisorPid: number | null; + childPid: number | null; +} + +/** + * What a live (`starting`/`running`) or uncertain job is now, from what the + * host can observe. Only a result the supervisor wrote ends a job; a fresh + * heartbeat means it is still running (attach); anything else is uncertain, + * and an uncertain job is never restarted: the user decides. + */ +export function reconcileJob(record: JobRecord, probe: JobProbe): JobReconciliation { + const supervisorPid = probe.started?.pid ?? record.supervisorPid; + const childPid = probe.started?.childPid ?? record.childPid; + const base = { exit: record.exit, supervisorPid, childPid }; + if (isFinalJob(record.status)) { + return { ...base, status: record.status, note: record.note }; + } + const exit = probe.exit; + if (exit !== null) { + if (exit.refused !== undefined) { + return { ...base, exit, status: 'refused', note: `The job ${record.label} did not run: ${exit.refused}.` }; + } + if (exit.cancelled) { + return { ...base, exit, status: 'cancelled', note: `The job ${record.label} was stopped at the user's request.` }; + } + if (exit.timedOut) { + return { ...base, exit, status: 'timed_out', note: `The job ${record.label} was stopped at its approved limit of ${seconds(record.spec.timeoutMs)}; it did not finish.` }; + } + const how = exit.exitCode !== null ? `exit ${exit.exitCode}` : `signal ${exit.signal ?? 'unknown'}`; + return { ...base, exit, status: 'finished', note: `The job ${record.label} finished (${how}) after ${seconds(Math.max(0, exit.endedAt - record.createdAt))}.` }; + } + const stale = jobStaleAfterMs(record.spec); + if (probe.heartbeatAt !== null && probe.now - probe.heartbeatAt <= stale) { + return { + ...base, + status: probe.started !== null ? 'running' : 'starting', + // An absolute time: the stored note stays true however long it is kept. + note: probe.started !== null ? `The job ${record.label} is running (started at ${new Date(probe.started.startedAt).toISOString()}).` : `The job ${record.label} is starting.`, + }; + } + if (record.status === 'starting' && probe.heartbeatAt === null && probe.now - record.createdAt <= stale) { + return { ...base, status: 'starting', note: `The job ${record.label} is starting.` }; + } + const nothing = 'Nothing was restarted. Check the workspace, then decide: check again, or abandon it.'; + if (probe.started === null) { + return { + ...base, + status: 'uncertain', + note: `RAME Code lost track of the job ${record.label} while it was starting; it may or may not have started. ${nothing}`, + }; + } + const silent = probe.heartbeatAt !== null ? ` (last sign of life ${seconds(Math.max(0, probe.now - probe.heartbeatAt))} ago)` : ''; + const container = record.spec.cleanup ? ` Its cleanup (${record.spec.cleanup.executable} ${record.spec.cleanup.argv.join(' ')}) has not run: Stop runs it.` : ''; + const command = childPid === null + ? container + : probe.childAlive === true + ? ` Its command (pid ${childPid}) may still be running; RAME Code does not stop it on its own.${container}` + : probe.childAlive === false + ? ` Its command (pid ${childPid}) is no longer running.${container}` + : container; + return { + ...base, + status: 'uncertain', + note: `The job ${record.label} stopped reporting without recording a result${silent}.${command} ${nothing}`, + }; +} diff --git a/src/jobRunner.ts b/src/jobRunner.ts new file mode 100644 index 0000000..f8127ee --- /dev/null +++ b/src/jobRunner.ts @@ -0,0 +1,142 @@ +/** + * The process side of long jobs (see `jobRecord.ts`): start the detached + * supervisor, observe a job directory, ask a job to stop, and read the end of + * its log. Free of the `vscode` API. + */ + +import { spawn } from 'node:child_process'; +import { mkdir, open, readFile, stat, writeFile } from 'node:fs/promises'; +import * as path from 'node:path'; + +import { canonicalJobSpec, type JobProbe, type JobSpec, jobSpecDigest, parseJobExit } from './jobRecord.js'; + +export const JOB_SUPERVISOR = path.join(__dirname, '..', '..', 'runtime', 'rame-job-supervisor.cjs'); + +export interface SupervisorOptions { + /** Node (or Electron run as Node) that runs the supervisor; default `process.execPath`. */ + nodePath?: string; + supervisor?: string; +} + +/** + * Write the approved spec and start its supervisor, detached and unreferenced + * so it outlives this process. Resolves once the supervisor process exists. + */ +export async function launchJob(dir: string, spec: JobSpec, options: SupervisorOptions = {}): Promise { + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, 'spec.json'), canonicalJobSpec(spec), { flag: 'wx' }); + await new Promise((resolve, reject) => { + const child = spawn(options.nodePath ?? process.execPath, [options.supervisor ?? JOB_SUPERVISOR, dir, jobSpecDigest(spec)], { + cwd: dir, + detached: true, + stdio: 'ignore', + windowsHide: true, + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', NODE_OPTIONS: '' }, + }); + child.once('error', reject); + child.once('spawn', () => { + child.unref(); + resolve(); + }); + }); +} + +const readJson = async (file: string): Promise => { + try { + return JSON.parse(await readFile(file, 'utf8')) as unknown; + } catch { + return null; + } +}; + +/** Signal 0: does the pid exist? A reused pid also answers, so this is a hint. */ +export function pidAnswers(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +export async function probeJob(dir: string, now: number = Date.now()): Promise { + const exit = parseJobExit(await readJson(path.join(dir, 'exit.json'))); + const rawStarted = await readJson(path.join(dir, 'supervisor.json')); + const started = + typeof rawStarted === 'object' && rawStarted !== null && Number.isInteger((rawStarted as { pid?: unknown }).pid) + ? { + pid: (rawStarted as { pid: number }).pid, + childPid: Number.isInteger((rawStarted as { childPid?: unknown }).childPid) ? (rawStarted as { childPid: number }).childPid : null, + startedAt: Number((rawStarted as { startedAt?: unknown }).startedAt) || now, + } + : null; + const heartbeatAt = await readHeartbeat(path.join(dir, 'heartbeat')); + return { now, exit, started, heartbeatAt, childAlive: started?.childPid != null && exit === null ? pidAnswers(started.childPid) : null }; +} + +/** How often, and how far apart, a heartbeat found empty or unreadable as a number is read again. */ +const HEARTBEAT_REREADS = 3; +const HEARTBEAT_REREAD_MS = 20; + +/** + * The supervisor's last sign of life, or null. A file that exists but holds + * no valid time was caught mid-write (a supervisor that rewrites it in place: + * an older one, or one where a rename failed): it is read again a few times + * before it counts as no sign of life, because one torn read would otherwise + * mark a healthy job uncertain for good. A missing file is answered at once. + */ +async function readHeartbeat(file: string): Promise { + for (let attempt = 0; ; attempt++) { + try { + const value = Number((await readFile(file, 'utf8')).trim()); + if (Number.isFinite(value) && value > 0) { + return value; + } + } catch { + return null; + } + if (attempt >= HEARTBEAT_REREADS) { + return null; + } + await new Promise((resolve) => setTimeout(resolve, HEARTBEAT_REREAD_MS)); + } +} + +/** Ask the supervisor to stop the job (it checks at each heartbeat). */ +export async function requestJobStop(dir: string): Promise { + await writeFile(path.join(dir, 'cancel'), String(Date.now())); +} + +/** The last `maxBytes` of the job's output (across the rotated pair). */ +export async function readJobLogTail(dir: string, maxBytes: number): Promise { + let cut = false; + const tailOf = async (name: string, budget: number): Promise => { + if (budget <= 0) { + return Buffer.alloc(0); + } + let handle; + try { + const file = path.join(dir, name); + const size = (await stat(file)).size; + const length = Math.min(size, budget); + cut ||= length < size; + handle = await open(file, 'r'); + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, size - length); + return buffer; + } catch { + return Buffer.alloc(0); + } finally { + await handle?.close(); + } + }; + const latest = await tailOf('log.txt', maxBytes); + const earlier = await tailOf('log.1.txt', maxBytes - latest.length); + if (latest.length >= maxBytes && (await stat(path.join(dir, 'log.1.txt')).then(() => true, () => false))) { + cut = true; + } + const text = Buffer.concat([earlier, latest]).toString('utf8'); + // A cut tail starts at the next whole line. + const start = cut ? text.indexOf('\n') + 1 : 0; + return start > 0 && start < text.length ? text.slice(start) : text.replace(/^\uFFFD+/, ''); +} diff --git a/src/jobStore.ts b/src/jobStore.ts new file mode 100644 index 0000000..60b2b77 --- /dev/null +++ b/src/jobStore.ts @@ -0,0 +1,335 @@ +/** + * The host's record of long jobs (see `jobRecord.ts`), kept in the same kind + * of storage as Missions so it survives a reload. + * + * Rules: + * - a job is recorded as `starting` before its supervisor is launched, so a + * reload at any moment leaves a record, never an unknown process; + * - an approval binds one exact job (scope, command, Mission, spec); + * - the host never starts a job twice: while a job with the same command and + * Mission is live or uncertain, a new one is refused; + * - after a reload a live job is attached (it is still running), closed (its + * supervisor recorded a result), or marked `uncertain`; + * - only the user stops a job or settles an uncertain one. + */ + +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + isFinalJob, + jobApprovalDigest, + type JobRecord, + jobSpecDigest, + MAX_RUNNING_JOBS_PER_SCOPE, + MAX_STORED_JOBS, + parseJobRecord, + reconcileJob, + type JobSpec, + validateJobSpec, +} from './jobRecord.js'; +import { launchJob, probeJob, readJobLogTail, requestJobStop, type SupervisorOptions } from './jobRunner.js'; + +export const JOB_STATE_KEY = 'rame.jobs.v1'; + +/** Job folders live next to the shared state (outside every project), or in the temp folder. */ +export function defaultJobsRoot(sharedStateDir: string | undefined): string { + // Without a state folder (tests), one folder per user: its siblings (programmes, references) never land in the shared temp folder itself. + return sharedStateDir ? path.join(sharedStateDir, '..', 'jobs') : path.join(os.tmpdir(), `rame-code-${typeof process.getuid === 'function' ? process.getuid() : 'user'}`, 'jobs'); +} + +/** Run a job's cleanup (e.g. `docker rm -f `); resolves with its exit code. */ +function runCleanup(cleanup: { executable: string; argv: string[] }): Promise { + return new Promise((resolve) => { + try { + const child = spawn(cleanup.executable, cleanup.argv, { shell: false, stdio: 'ignore', windowsHide: true }); + const timer = setTimeout(() => (child.kill('SIGKILL'), resolve(null)), 20_000); + child.once('error', () => (clearTimeout(timer), resolve(null))); + child.once('exit', (code) => (clearTimeout(timer), resolve(code))); + } catch { + resolve(null); + } + }); +} + +export interface JobBackend { + get(key: string): unknown; + update(key: string, value: unknown): PromiseLike; + exclusive?(work: () => Promise): Promise; +} + +export type JobResult = { ok: true; value: T } | { ok: false; error: string }; + +export interface JobStoreOptions extends SupervisorOptions { + now?: () => number; + launch?: typeof launchJob; + probe?: typeof probeJob; +} + +export interface NewJob { + scopeId: string; + label: string; + missionId: string | null; + spec: JobSpec; +} + +const isLive = (job: JobRecord): boolean => job.status === 'starting' || job.status === 'running'; + +export class JobStore { + private queue: Promise = Promise.resolve(); + + constructor( + private readonly backend: JobBackend, + private readonly jobsRoot: string, + private readonly options: JobStoreOptions = {}, + ) {} + + private now(): number { + return this.options.now?.() ?? Date.now(); + } + + /** The folder that holds every job's folder (outside every project). */ + get root(): string { + return this.jobsRoot; + } + + list(): JobRecord[] { + const raw = this.backend.get(JOB_STATE_KEY) as { jobs?: unknown } | undefined; + const jobs = Array.isArray(raw?.jobs) ? raw.jobs : []; + return jobs.map((entry) => parseJobRecord(entry, this.jobsRoot)).filter((job): job is JobRecord => job !== null); + } + + get(id: string): JobRecord | null { + return this.list().find((job) => job.id === id) ?? null; + } + + private serial(work: () => Promise): Promise { + const run = () => this.backend.exclusive ? this.backend.exclusive(work) : work(); + const next = this.queue.then(run, run); + this.queue = next.catch(() => undefined); + return next; + } + + private async save(jobs: JobRecord[]): Promise { + await this.backend.update(JOB_STATE_KEY, { version: 1, jobs }); + } + + private change(id: string, mutate: (job: JobRecord) => JobResult): Promise> { + return this.serial(async () => { + const jobs = this.list(); + const index = jobs.findIndex((job) => job.id === id); + if (index < 0) { + return { ok: false, error: 'RAME Code has no record of this job.' }; + } + const draft = structuredClone(jobs[index] as JobRecord); + const result = mutate(draft); + if (!result.ok) { + return result; + } + result.value.revision += 1; + result.value.updatedAt = this.now(); + jobs[index] = result.value; + await this.save(jobs); + return result; + }); + } + + /** Start one approved job. The record is written before anything runs. */ + start(input: NewJob, approvedDigest: string): Promise> { + return this.serial(async () => { + const checked = validateJobSpec(input.spec); + if (!checked.ok) { + return { ok: false, error: `This job is not valid: ${checked.error}. Nothing was started.` }; + } + const job = { ...input, spec: checked.spec }; + if (jobApprovalDigest(job) !== approvedDigest) { + return { ok: false, error: 'This approval does not match the job. Nothing was started.' }; + } + const jobs = this.list(); + const same = jobs.find((entry) => !isFinalJob(entry.status) && entry.scopeId === job.scopeId && entry.label === job.label && entry.missionId === job.missionId); + if (same !== undefined) { + return { + ok: false, + error: + same.status === 'uncertain' + ? `The job ${same.label} has an unknown result (${same.id}); decide about it first. Nothing was started.` + : `The job ${same.label} is already ${same.status} (${same.id}). Nothing was started.`, + }; + } + if (jobs.filter((entry) => !isFinalJob(entry.status) && entry.scopeId === job.scopeId).length >= MAX_RUNNING_JOBS_PER_SCOPE) { + return { ok: false, error: `At most ${MAX_RUNNING_JOBS_PER_SCOPE} jobs can be live or unsettled in one project. Nothing was started.` }; + } + const id = randomUUID(); + const now = this.now(); + const record: JobRecord = { + id, + scopeId: job.scopeId, + label: job.label.slice(0, 128), + missionId: job.missionId, + spec: job.spec, + specDigest: jobSpecDigest(job.spec), + dir: path.join(path.resolve(this.jobsRoot), id), + status: 'starting', + createdAt: now, + updatedAt: now, + revision: 1, + supervisorPid: null, + childPid: null, + exit: null, + cancelRequested: false, + note: `The job ${job.label} is starting.`, + }; + const kept = [...jobs, record]; + while (kept.length > MAX_STORED_JOBS) { + const oldest = kept.findIndex((entry) => isFinalJob(entry.status)); + if (oldest < 0) { + break; + } + kept.splice(oldest, 1); + } + await this.save(kept); + try { + await (this.options.launch ?? launchJob)(record.dir, record.spec, this.options); + } catch (error) { + const reason = String((error as Error)?.message ?? error).slice(0, 300); + const failed = this.list(); + const index = failed.findIndex((entry) => entry.id === id); + if (index >= 0) { + Object.assign(failed[index] as JobRecord, { status: 'refused', note: `The job ${record.label} did not run: its supervisor could not start (${reason}).`, revision: record.revision + 1, updatedAt: this.now() }); + await this.save(failed); + } + return { ok: false, error: `The job ${record.label} did not run: its supervisor could not start (${reason}).` }; + } + return { ok: true, value: record }; + }); + } + + /** Observe a live job and record what changed; settled jobs are returned as they are. */ + async refresh(id: string): Promise> { + const job = this.get(id); + if (job === null) { + return { ok: false, error: 'RAME Code has no record of this job.' }; + } + if (!isLive(job)) { + return { ok: true, value: job }; + } + return this.observe(job); + } + + private async observe(job: JobRecord): Promise> { + const seen = reconcileJob(job, await (this.options.probe ?? probeJob)(job.dir, this.now())); + if (seen.status === job.status && seen.supervisorPid === job.supervisorPid && seen.childPid === job.childPid && (seen.exit === null) === (job.exit === null)) { + return { ok: true, value: { ...job, note: seen.note } }; + } + return this.change(job.id, (draft) => { + if (draft.status !== job.status) { + // Another window settled it meanwhile; keep what it recorded. + return { ok: true, value: draft }; + } + Object.assign(draft, { status: seen.status, note: seen.note, exit: seen.exit, supervisorPid: seen.supervisorPid, childPid: seen.childPid }); + return { ok: true, value: draft }; + }); + } + + /** After a reload: attach, close or mark uncertain every live job. Nothing is started. */ + async recover(): Promise<{ running: number; settled: number; uncertain: number }> { + const counts = { running: 0, settled: 0, uncertain: 0 }; + for (const job of this.list().filter(isLive)) { + const result = await this.observe(job); + const after = result.ok ? result.value : job; + if (isLive(after)) { + counts.running += 1; + } else if (after.status === 'uncertain') { + counts.uncertain += 1; + } else { + counts.settled += 1; + } + } + return counts; + } + + /** Follow a job until it is no longer live (settled or uncertain). */ + async waitFor(id: string, options: { pollMs?: number; signal?: AbortSignal } = {}): Promise> { + for (;;) { + const result = await this.refresh(id); + if (!result.ok || !isLive(result.value) || options.signal?.aborted === true) { + return result; + } + await new Promise((resolve) => setTimeout(resolve, options.pollMs ?? 1000)); + } + } + + /** The user's request to stop a live job; its supervisor stops the whole command. */ + async cancel(id: string): Promise> { + const job = this.get(id); + if (job === null) { + return { ok: false, error: 'RAME Code has no record of this job.' }; + } + if (job.status === 'uncertain' && job.spec.cleanup) { + // A container has a unique name: removing it can never hit an unrelated process. + const code = await runCleanup(job.spec.cleanup); + if (code !== 0) { + return { ok: false, error: `RAME Code could not run the cleanup of the job ${job.label} (${job.spec.cleanup.executable} ${job.spec.cleanup.argv.join(' ')}); check it yourself, then abandon the job.` }; + } + return this.change(id, (draft) => { + if (draft.status !== 'uncertain') { + return { ok: false, error: `The job ${draft.label} is ${draft.status}, not uncertain.` }; + } + draft.status = 'cancelled'; + draft.cancelRequested = true; + draft.note = `Stopped by the user after RAME Code lost track of the job ${draft.label} (its cleanup ran); its result is unknown and it does not count as a result.`; + return { ok: true, value: draft }; + }); + } + if (job.status === 'uncertain') { + return { + ok: false, + error: `RAME Code has no live supervisor for the job ${job.label}, so it cannot stop it safely${job.childPid !== null ? ` (its command had pid ${job.childPid}; that number may now belong to another process)` : ''}. Check it yourself, then abandon the job.`, + }; + } + if (!isLive(job)) { + return { ok: false, error: `The job ${job.label} is already ${job.status}.` }; + } + await requestJobStop(job.dir); + return this.change(id, (draft) => { + draft.cancelRequested = true; + draft.note = `The user asked to stop the job ${draft.label}; waiting for it to stop.`; + return { ok: true, value: draft }; + }); + } + + /** The user's decision about an uncertain job. */ + async decide(id: string, decision: 'check-again' | 'abandon'): Promise> { + const job = this.get(id); + if (job === null) { + return { ok: false, error: 'RAME Code has no record of this job.' }; + } + if (job.status !== 'uncertain') { + return { ok: false, error: `The job ${job.label} is ${job.status}, not uncertain.` }; + } + if (decision === 'check-again') { + return this.observe(job); + } + return this.change(id, (draft) => { + if (draft.status !== 'uncertain') { + return { ok: false, error: `The job ${draft.label} is ${draft.status}, not uncertain.` }; + } + draft.status = 'abandoned'; + draft.note = `Abandoned by the user: the result of the job ${draft.label} is unknown, it was not restarted and it does not count as a result.`; + return { ok: true, value: draft }; + }); + } + + tail(id: string, maxBytes = 4096): Promise { + const job = this.get(id); + return job === null ? Promise.resolve('') : readJobLogTail(job.dir, maxBytes); + } +} + +/** Bounded, model-facing summary of one job: its state and the end of its output. */ +export function describeJob(job: JobRecord, tail: string, maxTail = 2000): string { + const end = tail.length > maxTail ? tail.slice(tail.length - maxTail) : tail; + return `job ${job.label} (${job.id}): ${job.status}. ${job.note}${end.length > 0 ? `\nLast output:\n${end}` : ''}`; +} diff --git a/src/jobTools.ts b/src/jobTools.ts new file mode 100644 index 0000000..a4868d1 --- /dev/null +++ b/src/jobTools.ts @@ -0,0 +1,232 @@ +/** + * The Agent's long-job tools (P2b of the long-Mission plan): + * - `start_job` starts one package script (npm:bench, npm:build, npm:test…) + * in the background, through the supervisor of `jobRunner.ts`; + * - `job_status` reports a job's state and the end of its output. + * + * Rules: + * - OFF unless the user turns long jobs on; never offered in a Mission run + * (a Mission grant does not cover background jobs yet); + * - every job waits for the user's approval in every profile, Workspace + * full included; the card shows the script text, the time limit and the + * isolation, and binds the package.json hash and the job settings; + * - the job that runs is exactly the one rebuilt and compared with the + * card right after the approval; a call that was not approved that way + * never starts anything; + * - the job outlives a reload and is never started twice (`jobStore.ts`). + */ + +import { randomBytes } from 'node:crypto'; +import { statSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import * as path from 'node:path'; + +import { NPM_RUN_ARGV_PREFIX, resolveTestInvocation, SAFE_SCRIPT_NAMES } from './agentCommands.js'; +import type { AgentChatMessage } from './agentModel.js'; +import { hashContent } from './agentPatch.js'; +import type { AgentToolDefinition, ValidatedToolCall } from './agentTools.js'; +import { scopeIdForRoot } from './decisionStore.js'; +import { buildContainerJob, describeIsolation, type JobContainerSettings } from './jobContainer.js'; +import { DEFAULT_JOB_HEARTBEAT_MS, DEFAULT_JOB_LOG_BYTES, isFinalJob, jobApprovalDigest, MAX_JOB_DURATION_MS, type JobSpec } from './jobRecord.js'; +import { describeJob, type NewJob } from './jobStore.js'; +import type { ProviderHost } from './providerTypes.js'; +import { ISOLATION_WORDS, isolatedInvocation, isolationRefusal, isolationSupport, isolationUnavailableText } from './shellIsolation.js'; +import type { CommandIsolation } from './settingsStore.js'; + +/** Package scripts a job may run: the test scripts plus the usual long ones. */ +export const JOB_SCRIPT_NAMES: readonly string[] = [...SAFE_SCRIPT_NAMES, 'bench', 'benchmark', 'build', 'e2e', 'perf']; +export const DEFAULT_JOB_TIMEOUT_SEC = 1800; +export const MAX_JOB_TIMEOUT_SEC = MAX_JOB_DURATION_MS / 1000; +export const MAX_JOB_WAIT_SEC = 120; + +export function jobToolsOffered(host: ProviderHost): boolean { + return host.settings.jobs?.enabled === true && host.missionAuto === null && host.activeMission === null; +} + +export function jobToolDefinitions(host: ProviderHost): AgentToolDefinition[] { + if (!jobToolsOffered(host)) { + return []; + } + return [ + { + type: 'function', + function: { + name: 'start_job', + description: + `Start a long package script in the background (benchmarks, builds, long test suites): commandId is npm:\n` : ''; - const settingsTabsScript = options.settingsTabsUri ? `\n` : ''; - const composerUxScript = options.composerUxUri ? `\n` : ''; - const remoteUxScript = options.remoteUxUri ? `\n` : ''; - const evidenceUxScript = options.evidenceUxUri ? `\n` : ''; - const serversUxScript = options.localServersUxUri ? `\n` : ''; + const tag = (uri: string | undefined): string => (uri ? `\n` : ''); + const i18nScript = tag(options.i18nUri); + const settingsTabsScript = tag(options.settingsTabsUri); + const composerUxScript = tag(options.composerUxUri); + const remoteUxScript = tag(options.remoteUxUri); + const evidenceUxScript = tag(options.evidenceUxUri); + const serversUxScript = tag(options.localServersUxUri) + tag(options.autonomyUxUri) + tag(options.advisorUxUri); return ` @@ -376,8 +380,8 @@ dl.mission-facts dd { margin: 0; overflow-wrap: anywhere; }

    With an acceptance check, RAME Code blocks its own tools from changing node_modules. node_modules is not hashed: test scripts, other processes, or you can still change the dependencies.

    -

    Not a sandbox: test scripts run with your user permissions and can reach other files, the network, or other processes. RAME Code only checks the paths its own tools use. Use autonomous workspace only for a project you trust.

    -

    The permission covers only this Mission, this project, and the local model. Autonomous workspace never leaves the project through RAME's tools, never touches .git, .env, keys, or credentials, runs no shell, runs only the allowlisted tests of the current package.json (a changed package.json asks again), and still asks before deleting, moving, or changing project memory.

    +

    Test scripts run with the isolation level of Security → Terminal commands: Project or Closed (Linux) let them write only in the project, temporary and package-cache folders (Closed: no network); Total gives them your user permissions. They can still read your files. Use autonomous workspace only for a project you trust.

    +

    The permission covers only this Mission, this project, and its main model. Autonomous workspace never leaves the project through RAME's tools, never touches .git, .env, keys, or credentials, runs no shell, runs only the allowlisted tests of the current package.json (a changed package.json asks again), and still asks before deleting, moving, or changing project memory.

    @@ -438,7 +442,6 @@ dl.mission-facts dd { margin: 0; overflow-wrap: anywhere; }
    @@ -475,6 +478,7 @@ dl.mission-facts dd { margin: 0; overflow-wrap: anywhere; }

    +
    diff --git a/src/viewMessages.ts b/src/viewMessages.ts index 9dcdec4..2426e39 100644 --- a/src/viewMessages.ts +++ b/src/viewMessages.ts @@ -15,6 +15,7 @@ */ import type { ChosenAdvisoryLimits } from './missionAdvisoryLimits.js'; +import { MISSION_CONSULT_CHOICES } from './autonomyPolicy.js'; import { sanitizeEvidenceView, type ModelEvidenceView } from './modelEvidence.js'; import { sanitizeContinuityLogView, type ContinuityLogView } from './continuityLog.js'; import { sanitizeMissionAcceptanceReviewView, type MissionAcceptanceReviewView } from './missionAcceptanceReviewView.js'; @@ -270,7 +271,7 @@ function isChosenLimits(value: unknown): boolean { hasExactKeys(value as Record, keys) && keys.every((key) => { const entry = (value as Record)[key]; - return entry === null || (typeof entry === 'number' && Number.isInteger(entry) && entry >= 0 && entry <= 1_000_000); + return entry === null || (typeof entry === 'number' && Number.isInteger(entry) && entry >= 0 && entry <= 20_000_000); }) ); } @@ -736,9 +737,7 @@ export function parseViewInboundMessage(raw: unknown): ViewInboundMessage { record.revision < 1 || (record.mode !== 'supervised' && record.mode !== 'autonomous-workspace') || typeof record.maxConsultations !== 'number' || - !Number.isInteger(record.maxConsultations) || - record.maxConsultations < 0 || - record.maxConsultations > 5 + !MISSION_CONSULT_CHOICES.includes(record.maxConsultations) ) { fail('Invalid missionStart message.'); } @@ -1084,7 +1083,7 @@ export interface MissionView { startedAt: number | null; lastProgress: string; lastTest: string; - escalation: { used: number; max: number; model: string } | null; + escalation: { used: number; max: number | '∞'; model: string } | null; canStart: boolean; canResume: boolean; canPause: boolean; @@ -1111,7 +1110,7 @@ function sanitizeAutonomy(value: unknown): MissionView['autonomy'] { startedAt: typeof record.startedAt === 'number' ? record.startedAt : null, lastProgress: typeof record.lastProgress === 'string' ? record.lastProgress.slice(0, 300) : '', lastTest: typeof record.lastTest === 'string' ? record.lastTest.slice(0, 300) : '', - escalation: escalation === null ? null : { used: num(escalation.used), max: num(escalation.max), model: typeof escalation.model === 'string' ? escalation.model.slice(0, 128) : '' }, + escalation: escalation === null ? null : { used: num(escalation.used), max: escalation.max === '∞' ? '∞' : num(escalation.max), model: typeof escalation.model === 'string' ? escalation.model.slice(0, 128) : '' }, canStart: record.canStart === true, canResume: record.canResume === true, canPause: record.canPause === true, @@ -1457,7 +1456,7 @@ function sanitizeAbout(value: unknown): AboutView { }; } -const ACCEPTANCE_STATES: readonly AcceptanceState[] = ['none', 'not_run', 'passed', 'failed', 'tampered']; +const ACCEPTANCE_STATES: readonly AcceptanceState[] = ['none', 'not_run', 'passed', 'failed', 'inconclusive', 'tampered']; function sanitizeMissionLive(value: unknown): MissionLiveView | null { if (typeof value !== 'object' || value === null || Array.isArray(value)) { @@ -1540,6 +1539,7 @@ function sanitizeMissionLive(value: unknown): MissionLiveView | null { minutes: count(report.minutes), models: strings(report.models, 4, 200), metaConsultations: text(report.metaConsultations, 200), + isolation: strings(report.isolation, 4, 300), }, blocked: object(record.blocked) === null diff --git a/src/webAccess.ts b/src/webAccess.ts index 43a1069..4c2f282 100644 --- a/src/webAccess.ts +++ b/src/webAccess.ts @@ -25,7 +25,7 @@ import * as https from 'node:https'; import * as net from 'node:net'; import * as zlib from 'node:zlib'; -import { redactSecrets } from './cloudEgress.js'; +import { redactSecrets, scrubLocalPaths } from './cloudEgress.js'; export const WEB_TIMEOUT_MS = 15_000; export const WEB_MAX_BYTES = 2 * 1024 * 1024; @@ -148,6 +148,17 @@ export function egressRefusal(text: string): string | null { return null; } +/** + * Under Full Access with web: the query that may leave without a card. Keys, + * tokens and passwords are removed (never sent, never asked about), and the + * user's home and project folders hidden; code, error text and other paths + * stay, so a technical search still works. Empty when nothing is left. + */ +export function fullAccessQuery(text: string, folders: ReadonlyArray): string { + const withoutSecrets = redactSecrets(text).text.replace(/\[redacted by RAME Code:[^\]]*\]/g, ' '); + return scrubLocalPaths(withoutSecrets, folders).replace(/\s+/g, ' ').trim(); +} + function decodeURIComponentSafe(text: string): string { try { return decodeURIComponent(text); diff --git a/src/webTools.ts b/src/webTools.ts index c95df88..c0bc591 100644 --- a/src/webTools.ts +++ b/src/webTools.ts @@ -7,8 +7,8 @@ * web_search {query} one search, up to 8 results with their URLs * web_fetch {url} the readable text of one public page * - * Each call is shown as an action and, except in Workspace full, waits for - * the user's approval. The query or URL is refused when it holds a + * Each call is shown as an action and waits for the user's approval, in + * every profile, unless Autonomy & Permissions pre-authorizes it. The query or URL is refused when it holds a * secret-like value or a local path. What comes back is labelled as * untrusted web content with its source: it is data, never instructions, * and it can never write project memory (that needs the user's own words). @@ -18,17 +18,21 @@ import type { AgentToolDefinition } from './agentTools.js'; import type { ProviderHost } from './providerTypes.js'; import type { ProviderId } from './settingsStore.js'; import { cameFromSearch, domainAllowed, missionQueryRefusal, rememberSearchResults, type MissionWebGrant } from './missionWeb.js'; -import { egressRefusal, htmlToText, safeFetch, WEB_MAX_TEXT_CHARS, webSearch } from './webAccess.js'; +import * as os from 'node:os'; + +import { egressRefusal, fullAccessQuery, htmlToText, safeFetch, WEB_MAX_TEXT_CHARS, webSearch } from './webAccess.js'; +import { currentScopeId, finishExternal, missionWebRefusalNow, peekWebUse, recordExternal, rememberAgentResults, takeWebUse, trackPreauthorized } from './autonomyHost.js'; /** * Whether this run may use the web tools: outside a Mission, when the user - * enabled web access (Agent and Chat, local model); inside a Mission, only - * when that Mission's grant carries a web budget (local model). + * enabled web access (Agent and Chat); inside a Mission, only when that + * Mission's grant carries a web budget. Offered to the run's main model, + * local or Meta: with Meta, page text reaches Meta as a tool result (the + * Meta run's own data scope), and it is still never put in an advisor's + * consultation. */ export function webToolsOffered(host: ProviderHost, provider: ProviderId): boolean { - if (provider !== 'local') { - return false; - } + void provider; const auto = host.missionAuto; if (auto === null) { return host.settings.web?.enabled === true; @@ -83,20 +87,32 @@ export async function executeWebTool(host: ProviderHost, tool: 'web_search' | 'w const missionId = host.missionAuto?.missionId ?? null; const missionWeb = missionId === null ? null : (host.missions.get(missionId)?.grant?.web ?? null); if (tool === 'web_search') { - const query = String(args.query ?? ''); - const refusal = egressRefusal(query) ?? (missionWeb !== null ? missionQueryRefusal(query) : null); + const asked = String(args.query ?? ''); + // Full Access with web: keys removed and the user's folders hidden, then sent without a card; otherwise refused as before. + const full = missionId === null && peekWebUse(host, 'web_search', asked) === 'full-access'; + const query = full ? fullAccessQuery(asked, [...host.listOpenWorkspaceFolders().map((folder) => [folder.path, ''] as const), [os.homedir(), '~'] as const]) : asked; + const refusal = full ? (query.length === 0 ? 'nothing was left once keys and local folders were removed' : null) : egressRefusal(query) ?? (missionWeb !== null ? missionQueryRefusal(query) : null); if (refusal !== null) { + takeWebUse(host, 'web_search', asked); return `Not searched: the query was not sent because ${refusal}. Search with general terms only.`; } + const lapsed = missionPreauthLapsed(host); + if (lapsed !== null) { + return lapsed; + } if (missionWeb !== null && !(await spendMissionWeb(host, 'searches'))) { return budgetUsedUp(missionWeb, 'search'); } - const found = await webSearch(query, host.settings.web, { signal }); + const entry = await recordWeb(host, 'web_search', asked, `the query "${query.slice(0, 200)}"${query !== asked ? ' (keys removed and local folders hidden)' : ''}`, Buffer.byteLength(query, 'utf8')); + const found = await webSearch(query, host.settings.web, { signal: entry.signal(signal) }).finally(entry.done); + await finishExternal(host, entry.scopeId, entry.id, found.ok ? `${found.results.length} result(s)` : `failed: ${found.error}`.slice(0, 300)); if (!found.ok) { return `The search failed: ${found.error}`; } if (missionId !== null) { rememberSearchResults(missionId, found.results.map((entry) => entry.url)); + } else { + await rememberAgentResults(host, entry.scopeId, found.results.map((result) => result.url)); } if (found.results.length === 0) { return `${found.engine} returned no results for that query.`; @@ -111,6 +127,7 @@ export async function executeWebTool(host: ProviderHost, tool: 'web_search' | 'w const url = String(args.url ?? ''); const refusal = egressRefusal(url); if (refusal !== null) { + takeWebUse(host, 'web_fetch', url); return `Not fetched: the URL was not requested because ${refusal}.`; } let allowHost: ((host: string) => boolean) | undefined; @@ -124,11 +141,17 @@ export async function executeWebTool(host: ProviderHost, tool: 'web_search' | 'w } else if (!cameFromSearch(missionId, url)) { return 'Not fetched: in a Mission, a page can be read only if one of its own web searches returned that exact URL.'; } + const lapsed = missionPreauthLapsed(host); + if (lapsed !== null) { + return lapsed; + } if (!(await spendMissionWeb(host, 'fetches'))) { return budgetUsedUp(missionWeb, 'page read'); } } - const fetched = await safeFetch(url, { signal, ...(allowHost ? { allowHost } : {}) }); + const entry = await recordWeb(host, 'web_fetch', url, `the address ${url.slice(0, 300)}`, Buffer.byteLength(url, 'utf8')); + const fetched = await safeFetch(url, { signal: entry.signal(signal), ...(allowHost ? { allowHost } : {}) }).finally(entry.done); + await finishExternal(host, entry.scopeId, entry.id, fetched.ok ? `read ${fetched.page.body.length} characters` : `failed: ${fetched.error}`.slice(0, 300)); if (!fetched.ok) { return `The page could not be read: ${fetched.error}`; } @@ -144,6 +167,47 @@ export async function executeWebTool(host: ProviderHost, tool: 'web_search' | 'w ].filter((line) => line.length > 0).join('\n'); } +/** + * An autonomous Mission searches without a card only under the workspace's + * web pre-authorization it was given (Autonomy & Permissions), checked on + * the stored policy before each request: revoked, expired or replaced, it + * stops here. A supervised Mission asks for each call, so it needs none. + */ +function missionPreauthLapsed(host: ProviderHost): string | null { + const missionId = host.missionAuto?.missionId ?? null; + const mission = missionId === null ? null : host.missions.get(missionId); + if (mission === null || mission.grant?.mode !== 'autonomous-workspace' || !mission.grant.web) { + return null; + } + const preauthId = mission.grant.web.preauthId ?? null; + const problem = preauthId === null ? 'this Mission was not given its web budget under a pre-authorization' : missionWebRefusalNow(host, mission.scopeId, preauthId); + return problem === null ? null : `Not done: web access without a card no longer applies to this Mission (${problem}). Nothing was sent. Continue without the web, or report that the task is blocked and why.`; +} + +/** + * Record a web request before it is made (Autonomy & Permissions history). + * A request let through by a pre-authorization or Full Access is also + * aborted if that grant is revoked while it runs, from any window. + */ +async function recordWeb(host: ProviderHost, tool: 'web_search' | 'web_fetch', value: string, data: string, bytes: number): Promise<{ scopeId: string; id: string; signal: (own: AbortSignal) => AbortSignal; done: () => void }> { + const missionId = host.missionAuto?.missionId ?? null; + const mission = missionId === null ? null : host.missions.get(missionId); + const use = mission ? null : takeWebUse(host, tool, value); + const scopeId = mission?.scopeId ?? use?.scopeId ?? currentScopeId(host); + const grantId = mission?.grant?.mode === 'autonomous-workspace' ? (mission.grant.web?.preauthId ?? null) : (use?.id ?? null); + const tracked = grantId === null ? null : trackPreauthorized(grantId, scopeId, host); + const approval = mission ? 'mission-grant' : use !== null ? use.approval : 'card'; + let destination = tool === 'web_search' ? (host.settings.web?.search === 'searxng' ? 'your SearXNG' : 'DuckDuckGo') : ''; + if (tool === 'web_fetch') { + destination = hostOf(value) || 'a web page'; + } + const id = await recordExternal(host, scopeId, { + provider: tool === 'web_search' ? 'Web search' : 'Web page', destination, reason: `${mission ? 'Mission' : 'Agent or Chat'} ${tool}`, data, approval, preauthId: grantId, missionId, bytes, + }); + const signal = (own: AbortSignal): AbortSignal => (tracked === null || typeof AbortSignal.any !== 'function' ? own : AbortSignal.any([own, tracked.signal])); + return { scopeId, id, signal, done: () => tracked?.done() }; +} + function hostOf(url: string): string { try { return new URL(url).hostname.replace(/^\[|\]$/g, ''); diff --git a/test/acceptance-integrity.test.ts b/test/acceptance-integrity.test.ts index fb0db51..c8e58cd 100644 --- a/test/acceptance-integrity.test.ts +++ b/test/acceptance-integrity.test.ts @@ -12,7 +12,6 @@ import * as path from 'node:path'; import test from 'node:test'; import { MISSION_STATE_KEY, type MissionRecord } from '../src/missionStore.js'; -import { changeTimesTrusted } from '../src/projectSnapshot.js'; import { assistantText, fakeWindow, HostHarness, toolCallReply, type PostedState } from './helpers/hostHarness.js'; const sha = (text: string): string => createHash('sha256').update(text, 'utf8').digest('hex'); @@ -116,7 +115,7 @@ test('every new code file needs scope review, including one the untrusted trace }); }); -test('a file added and removed in an acceptance folder during the check (nothing left at the end): not verifiable', { skip: changeTimesTrusted() ? false : 'change times are not trusted on this platform' }, async () => { +test('the protected check cannot add even a temporary file to an acceptance folder', async () => { await withProject({ 'acceptance/accept.test.js': SIMPLE }, async (h, mission) => { const sneak = "const fs = require('node:fs');\nconst p = require('node:path').join(__dirname, '..', 'acceptance', 'extra.test.js');\n" + @@ -124,18 +123,20 @@ test('a file added and removed in an acceptance folder during the check (nothing h.model.enqueue(write('w', 'lib/sum.js', sneak), say('Done.'), say('Done, no test needed.')); await h.provider.startMission(mission.id, mission.revision, 'autonomous-workspace', null, true, FROZEN); assert.equal(stored(h).status, 'blocked', notes(h)); - assert.match(stored(h).note, /acceptance\/ \(a file was added, removed or renamed in this acceptance folder during the check\)/); + assert.match(stored(h).note, /acceptance check kept failing \(npm:check exit=1 with every frozen file intact; verification: protected\)/); + assert.equal(await h.readFile('acceptance/extra.test.js').catch(() => null), null); }); }); -test('reinforced level: a change made only while the host-compared cases run is still seen', async () => { +test('reinforced level: the case runner cannot change a non-writable project file', async () => { const CASES = JSON.stringify({ rameCases: 1, module: 'lib/sum.js', cases: [{ call: '', args: [2, 3], expect: 5 }] }); await withProject({ 'acceptance/accept.test.js': "require('../lib/sum.js');\n", 'acceptance/sum.rame-cases.json': CASES, 'notes.txt': 'kept' }, async (h, mission) => { const lateWriter = "module.exports = (a, b) => { require('node:fs').writeFileSync(require('node:path').join(__dirname, '..', 'notes.txt'), 'changed'); return a + b; };\n"; h.model.enqueue(write('w', 'lib/sum.js', lateWriter), say('Done.'), say('Done, no test needed.')); await h.provider.startMission(mission.id, mission.revision, 'autonomous-workspace', null, true, { commandId: 'npm:check', files: ['acceptance/accept.test.js', 'acceptance/sum.rame-cases.json'] }); assert.equal(stored(h).status, 'blocked', notes(h)); - assert.match(stored(h).note, /^Blocked: NOT VERIFIABLE: files changed outside the scope you authorized: notes\.txt \(it existed when you authorized the check and is not code/); + assert.match(stored(h).note, /host-compared cases.*failed:.*EACCES: permission denied, open .*notes\.txt/); + assert.equal(await h.readFile('notes.txt'), 'kept'); }); }); diff --git a/test/advisor-request.test.ts b/test/advisor-request.test.ts new file mode 100644 index 0000000..547d88e --- /dev/null +++ b/test/advisor-request.test.ts @@ -0,0 +1,152 @@ +/** + * What an advisor consultation carries and how the advisor is asked (P5h, + * after the real Wonder consultation of 08-11-2026, which noted that the + * failing check's output was not in the record): + * + * - the end of the failing check's output, made safe or not sent; on the + * card as exact text, and without a card only under a data scope that + * includes fragments; + * - the advisor's own generation settings and output cap. + * + * Everything is simulated: Meta is the harness's fakeCloud. + */ + +import assert from 'node:assert/strict'; +import * as os from 'node:os'; +import test from 'node:test'; + +import { fakeCloud, HostHarness } from './helpers/hostHarness.js'; +import { API_KEY_SECRET, KEY } from './helpers/autonomyBench.js'; +import { ADVISOR_MAX_OUTPUT_TOKENS, advisorGeneration, safeCheckExcerpt } from '../src/advisorRequest.js'; +import { onAutonomyMessage } from '../src/autonomyEntry.js'; +import { defaultGenerationSettings } from '../src/generationSettings.js'; +import { MISSION_STATE_KEY, type MissionRecord } from '../src/missionStore.js'; + +test('the end of a failing check\'s output: bounded, folders hidden, and nothing at all when a secret, the key or a private folder would remain', () => { + const project = '/tmp/ws-123/shop'; + const folders: Array<[string, string]> = [[project, ''], [os.homedir(), '~']]; + const long = Array.from({ length: 80 }, (_, i) => `line ${i}`).join('\n'); + const tail = safeCheckExcerpt(`${long}\nError: expected 5 at ${project}/test.js:3:8`, folders); + assert.equal(tail.ok, true); + const text = (tail as { text: string }).text; + assert.ok(text.split('\n').length <= 30 && text.length <= 1500); + assert.ok(!text.includes('line 10\n'), 'only the end'); + assert.match(text, /Error: expected 5 at \/test\.js:3:8$/); + assert.match(String((safeCheckExcerpt(`at ${os.homedir()}/notes.txt`, folders) as { text: string }).text), /^at ~\/notes\.txt$/); + assert.deepEqual(safeCheckExcerpt('Error: token sk-live_ABCDEFGHIJKLMNOPQRST0123 rejected', folders), { ok: false, reason: 'it looked like it contained a secret' }); + assert.deepEqual(safeCheckExcerpt('failed with key KEY-123456789', folders, 'KEY-123456789'), { ok: false, reason: 'it contained the API key' }); + assert.deepEqual(safeCheckExcerpt('cannot open /home/someone-else/.ssh/id_rsa', folders), { ok: false, reason: 'it named a private folder that could not be hidden' }); + assert.deepEqual(safeCheckExcerpt(' \n ', folders), { ok: false, reason: 'the check printed nothing' }); +}); + +test("the advisor is asked with its own settings: its documented profile, the reasoning stored for it (not the main model's), and its output cap", () => { + const settings = defaultGenerationSettings(); + settings.reasoningByModel['cloud:muse-spark-1.3'] = 'max'; + settings.maxOutputTokens.cloud = null; + const wonder = advisorGeneration(settings, 'muse-wonder-1.0'); + assert.equal(wonder.model, 'muse-wonder-1.0'); + assert.equal(wonder.reasoning, null, "Spark's stored level is not Wonder's"); + assert.equal(wonder.manualMax, ADVISOR_MAX_OUTPUT_TOKENS); + settings.reasoningByModel['cloud:muse-wonder-1.0'] = 'low'; + settings.maxOutputTokens.cloud = 1000; + const chosen = advisorGeneration(settings, 'muse-wonder-1.0'); + assert.equal(chosen.reasoning, 'low'); + assert.equal(chosen.manualMax, 1000, 'a lower manual maximum still applies'); + settings.maxOutputTokens.cloud = 50_000; + assert.equal(advisorGeneration(settings, 'muse-wonder-1.0').manualMax, ADVISOR_MAX_OUTPUT_TOKENS, 'never above the advisor cap'); +}); + +async function blockedMission(dataScope: 'instructions' | 'files' | null, checkScript: string, onCard?: (h: HostHarness) => Promise): Promise<{ wonder: Record | undefined; workspace: string }> { + const h = new HostHarness(); + await h.start({ provider: 'cloud', model: 'muse-spark-1.3', cloudModel: 'muse-spark-1.3', mode: 'mission', agentProfile: 'ask', allowCloudEscalation: true, advisorModel: 'muse-wonder-1.0' }); + h.secretValues.set(API_KEY_SECRET, KEY); + await h.provider.refreshHasKey(); + h.provider.confirmAutonomy = async () => true; + fakeCloud.requests.length = 0; + fakeCloud.handler = (body) => { + const usage = { prompt_tokens: 100, completion_tokens: 10, total_tokens: 110 }; + const reply = (content: string | null, calls?: unknown[]) => Response.json({ choices: [{ message: { role: 'assistant', content, ...(calls ? { tool_calls: calls } : {}) }, finish_reason: calls ? 'tool_calls' : 'stop' }], usage }); + if (body.model === 'muse-wonder-1.0') { + return reply(JSON.stringify({ diagnosis: 'The flag is missing.', steps: ['Ask the operator.'], checks: ['npm:check passes.'] })); + } + if (body.response_format) { + return reply(JSON.stringify({ goal: 'Make npm:check pass.', assumptions: [], steps: [{ id: 't1', title: 'Make npm:check pass', successCriterion: 'npm:check passes' }], risks: [] })); + } + const messages = body.messages as Array<{ role: string }>; + return messages.at(-1)?.role === 'tool' ? reply('Done for this run.') : reply(null, [{ id: `c${messages.length}`, type: 'function', function: { name: 'run_tests', arguments: JSON.stringify({ commandId: 'npm:check' }) } }]); + }; + try { + await h.writeFile('check.js', checkScript); + await h.writeFile('package.json', JSON.stringify({ name: 'm', private: true, scripts: { check: 'node check.js' } })); + if (dataScope !== null) { + await onAutonomyMessage(h.provider as never, { type: 'autonomyFull', tools: { files: true, tests: true, commands: false, installs: false, jobs: false, web: false, meta: true }, validityHours: 1, metaMain: true, meta: { maxPerMission: 1, dataScope, tokenBudget: 40000 } }); + } + await h.provider.sendMessage('Make npm:check pass.', 'mission', 'cloud'); + const mission = (h.memento.get(MISSION_STATE_KEY) as { missions: MissionRecord[] }).missions[0] as MissionRecord; + h.send({ type: 'missionRunsSet', missionId: mission.id, runsPerTask: 3 }); + await new Promise((resolve) => setTimeout(resolve, 20)); + await h.provider.startMission(mission.id, mission.revision, 'autonomous-workspace', null, true); + if (onCard) { + await onCard(h); + } + return { wonder: fakeCloud.requests.find((request) => request.body.model === 'muse-wonder-1.0')?.body, workspace: h.workspace }; + } finally { + fakeCloud.handler = null; + fakeCloud.requests.length = 0; + h.provider.cancelRequest(); + await h.dispose(); + } +} + +const CHECK = "console.error('release not approved: RAME_TRIAL_UNLOCK is not set in ' + __dirname);\nprocess.exit(1);\n"; + +test('a blocked Mission consults Wonder with the end of the failing check\'s output only under a data scope that includes fragments, with the project folder hidden, the advisor\'s own output cap, and no tools', async () => { + const withFiles = await blockedMission('files', CHECK); + assert.ok(withFiles.wonder, 'Wonder was consulted'); + const sent = JSON.stringify(withFiles.wonder); + assert.match(sent, /End of the failing check's output \(last lines; secrets removed, local folders hidden\)/); + assert.match(sent, /release not approved: RAME_TRIAL_UNLOCK is not set in /); + assert.equal(sent.includes(withFiles.workspace), false, 'the project folder is hidden'); + assert.ok(Number(withFiles.wonder.max_completion_tokens) <= ADVISOR_MAX_OUTPUT_TOKENS); + assert.equal(withFiles.wonder.tools, undefined, 'the advisor gets no tools'); + // "Only instructions and the host's notes": the consultation goes, without the output. + const instructions = await blockedMission('instructions', CHECK); + assert.ok(instructions.wonder); + assert.doesNotMatch(JSON.stringify(instructions.wonder), /RAME_TRIAL_UNLOCK|End of the failing check's output/); + assert.match(JSON.stringify(instructions.wonder), /its output is not included/); + // Output with something that looks like a secret: never sent, whatever the scope. + const secret = await blockedMission('files', "console.error('auth failed for token sk-live_ABCDEFGHIJKLMNOPQRST0123');\nprocess.exit(1);\n"); + assert.ok(secret.wonder); + assert.doesNotMatch(JSON.stringify(secret.wonder), /sk-live_|auth failed/); +}); + +test("on the consultation card the output is the user's choice: not in the exact text by default; included (and shown again) only when the user ticks it", async () => { + await blockedMission(null, CHECK, async (h) => { + const offer = h.provider.consultOffer; + assert.ok(offer, String(h.last().status)); + assert.match(offer.payload, /its output is not included/); + assert.doesNotMatch(offer.payload, /RAME_TRIAL_UNLOCK/, 'not by default'); + const view = () => (h.last().remote as { consult: { outputAvailable: boolean; outputIncluded: boolean } }).consult; + await h.provider.postState(); + assert.deepEqual([view().outputAvailable, view().outputIncluded], [true, false]); + h.send({ type: 'consultIncludeFiles', digest: offer.digest, files: [], output: true }); + await h.waitFor(() => h.provider.consultOffer?.outputIncluded === true); + const shown = h.provider.consultOffer!; + assert.notEqual(shown.digest, offer.digest, 'a new text needs a new approval'); + assert.match(shown.payload, /End of the failing check's output[^]*RAME_TRIAL_UNLOCK is not set in /); + assert.equal(shown.payload.includes(h.workspace), false); + assert.match(String(h.last().status), /it includes the end of the failing check's output/); + assert.deepEqual([view().outputAvailable, view().outputIncluded], [true, true]); + // Adding files without saying anything about the output keeps the choice. + const before = shown.createdAt; + await new Promise((resolve) => setTimeout(resolve, 5)); + h.send({ type: 'consultIncludeFiles', digest: shown.digest, files: [] }); + // The same text keeps its digest: the host renewed the offer when its time changed. + await h.waitFor(() => h.provider.consultOffer !== null && h.provider.consultOffer.createdAt !== before); + assert.equal(h.provider.consultOffer?.outputIncluded, true); + h.send({ type: 'consultIncludeFiles', digest: h.provider.consultOffer!.digest, files: [], output: false }); + await h.waitFor(() => h.provider.consultOffer?.outputIncluded === false); + assert.doesNotMatch(h.provider.consultOffer!.payload, /RAME_TRIAL_UNLOCK/); + assert.equal(fakeCloud.requests.filter((request) => request.body.model === 'muse-wonder-1.0').length, 0, 'nothing was sent to the advisor without approval'); + }); +}); diff --git a/test/agent-jobs.test.ts b/test/agent-jobs.test.ts new file mode 100644 index 0000000..4b5feb8 --- /dev/null +++ b/test/agent-jobs.test.ts @@ -0,0 +1,351 @@ +/** + * P2b of the long-Mission plan: the Agent's long-job tools. Off by default, + * never in a Mission run, every job approved on a card that shows the + * script, the time limit and where it runs (Workspace full included), the + * job that runs is the one the card showed, a reload attaches instead of + * restarting, and the user's controls (the "Long Jobs" command) work. + * SIMULATED model replies; the jobs run for real. + */ + +import assert from 'node:assert/strict'; +import { rmSync } from 'node:fs'; +import test from 'node:test'; + +import { isAutoAllowed } from '../src/agentProfiles.js'; +import { validateToolArgs } from '../src/agentTools.js'; +import { noticeUncertainJobs, runJobsMenu, suggestedContainerSettings, type JobsUi } from '../src/jobCommands.js'; +import type { JobRecord } from '../src/jobRecord.js'; +import { jobToolDefinitions } from '../src/jobTools.js'; +import { assistantText, HostHarness, toolCallReply } from './helpers/hostHarness.js'; + +type Request = { tools?: Array<{ function: { name: string } }>; messages?: Array<{ content: string | null; tool_call_id?: string }> }; +const call = (id: string, name: string, args: Record) => ({ kind: 'json' as const, body: toolCallReply([{ id, name, args }]) }); +const say = (text: string) => ({ kind: 'json' as const, body: assistantText(text) }); +const result = (h: HostHarness, id: string) => String((h.model.requests as Request[]).flatMap((r) => r.messages ?? []).find((m) => m.tool_call_id === id)?.content ?? ''); +const offered = (h: HostHarness, index = 0) => ((h.model.requests[index] as Request).tools ?? []).map((t) => t.function.name); + +const PACKAGE = JSON.stringify({ name: 'jobs', private: true, scripts: { test: 'node t.js', bench: 'node bench.js', deploy: 'node deploy.js' } }); +const BENCH = "require('node:fs').appendFileSync('starts.txt', 'start\\n'); let i = 0; const t = setInterval(() => { console.log(`bench ${++i}`); if (i >= Number(process.env.TICKS || 3)) { clearInterval(t); console.log('p95_ms 12'); } }, 100);\n"; + +async function withHost(settings: Record, body: (h: HostHarness) => Promise): Promise { + const h = new HostHarness(); + await h.start({ agentProfile: 'safe-auto', ...settings }); + const dirs: string[] = []; + try { + await h.writeFile('package.json', PACKAGE); + await h.writeFile('bench.js', BENCH); + await h.writeFile('t.js', 'process.exit(0);\n'); + await body(h); + } finally { + for (const job of h.provider.jobs.list()) { + if (job.status === 'running' || job.status === 'starting') { + await h.provider.jobs.cancel(job.id); + await h.provider.jobs.waitFor(job.id, { pollMs: 100 }); + } + dirs.push(job.dir); + } + h.provider.cancelRequest(); + await h.dispose(); + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }); + } + } +} + +/** Run one Agent request; approve (or reject) every card; `beforeApproval` runs while a card waits. */ +async function run(h: HostHarness, text: string, options: { approve?: boolean; beforeApproval?: () => Promise } = {}): Promise { + const cards: string[] = []; + let done = false; + const sent = h.provider.sendMessage(text, 'agent', 'local').then(() => (done = true)); + let seen = ''; + const deadline = Date.now() + 30000; + while (!done && Date.now() < deadline) { + const pending = h.last()?.agentPending as { actionId: string; digest: string; tool: string; targets: string[] } | null | undefined; + if (pending && pending.actionId !== seen) { + seen = pending.actionId; + cards.push(`${pending.tool} | ${pending.targets.join(' | ')}`); + await options.beforeApproval?.(); + if (options.approve === false) { + await h.provider.rejectAgentAction(pending.actionId, pending.digest); + } else { + await h.provider.approveAgentAction(pending.actionId, pending.digest); + } + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + await sent; + return cards; +} + +const ON = { jobs: { enabled: true, container: null } }; + +test('start_job and job_status arguments are exact and bounded', () => { + assert.deepEqual(validateToolArgs('start_job', { commandId: 'npm:bench' }), { commandId: 'npm:bench' }); + assert.deepEqual(validateToolArgs('start_job', { commandId: 'npm:bench', timeoutSec: 600 }), { commandId: 'npm:bench', timeoutSec: 600 }); + for (const bad of [{}, { commandId: 'bench' }, { commandId: 'npm:bench; rm -rf /' }, { commandId: 'npm:bench', timeoutSec: 0 }, { commandId: 'npm:bench', timeoutSec: 21601 }, { commandId: 'npm:bench', cwd: '/' }]) { + assert.throws(() => validateToolArgs('start_job', bad), /Invalid start_job arguments/, JSON.stringify(bad)); + } + assert.deepEqual(validateToolArgs('job_status', {}), {}); + assert.deepEqual(validateToolArgs('job_status', { jobId: '0b0c5a1e-1111-4222-8333-944445555666', waitSec: 30 }), { jobId: '0b0c5a1e-1111-4222-8333-944445555666', waitSec: 30 }); + for (const bad of [{ jobId: '../x' }, { waitSec: 121 }, { jobId: 'abcdefgh', extra: 1 }]) { + assert.throws(() => validateToolArgs('job_status', bad), /Invalid job_status arguments/, JSON.stringify(bad)); + } +}); + +test('no profile starts a job without the user; reading a job\'s status is a read', () => { + for (const profile of ['ask', 'safe-auto', 'workspace-full'] as const) { + assert.equal(isAutoAllowed(profile, 'start_job'), false, profile); + } + assert.equal(isAutoAllowed('ask', 'job_status'), false); + assert.equal(isAutoAllowed('safe-auto', 'job_status'), true); + assert.equal(isAutoAllowed('workspace-full', 'job_status'), true); +}); + +test('off by default: not offered, and a call anyway gets no card and starts nothing', async () => { + await withHost({}, async (h) => { + h.model.enqueue(call('j', 'start_job', { commandId: 'npm:bench' }), say('Okay.')); + const cards = await run(h, 'Run the benchmark.'); + assert.equal(offered(h).includes('start_job'), false); + assert.deepEqual(cards, []); + assert.match(result(h, 'j'), /^Not proposed: long jobs are off for this run/); + assert.deepEqual(h.provider.jobs.list(), []); + }); +}); + +test('on: never offered in a Mission run', async () => { + await withHost(ON, async (h) => { + assert.deepEqual(jobToolDefinitions(h.provider).map((tool) => tool.function.name), ['start_job', 'job_status']); + h.provider.missionAuto = { missionId: 'mis_000000000000000000000000', stop: null, holdAutonomy: false }; + assert.deepEqual(jobToolDefinitions(h.provider), []); + h.provider.missionAuto = null; + h.provider.activeMission = { id: 'mis_000000000000000000000000', revision: 1 }; + assert.deepEqual(jobToolDefinitions(h.provider), []); + h.provider.activeMission = null; + }); +}); + +test('on: the card shows the script, the time limit and where it runs; the job runs once in the background and job_status follows it', async () => { + await withHost({ ...ON, agentProfile: 'workspace-full' }, async (h) => { + h.model.enqueue(call('j', 'start_job', { commandId: 'npm:bench', timeoutSec: 600 }), say('Started.')); + const cards = await run(h, 'Run the benchmark in the background.'); + assert.equal(offered(h).includes('start_job'), true); + assert.deepEqual(cards, [ + 'start_job | long job npm:bench: npm run bench → node bench.js | in the background for up to 10 min; it keeps running if the window reloads, and RAME Code never starts it again on its own | isolation: isolated: writes only in the project, temporary and package-cache folders; network allowed', + ], 'Workspace full still asks for every job'); + const started = result(h, 'j'); + const id = /^Started job ([0-9a-f-]{36}) \(npm:bench\) in the background\. It keeps running if the window reloads\./.exec(started)?.[1]; + assert.ok(id, started); + const [job] = h.provider.jobs.list(); + assert.equal(job?.id, id); + assert.deepEqual(job?.spec.argv.slice(-3), ['run', '--ignore-scripts', 'bench'], 'npm lifecycle scripts never run'); + assert.match(job?.spec.executable ?? '', /python3$/, 'the persisted spec launches the isolated child, not an unprotected npm process'); + assert.ok(job?.spec.argv.some((part) => /rame-isolate\.py$/.test(part)), 'the isolation survives a window reload because it is part of the approved spec'); + assert.equal(job?.spec.timeoutMs, 600_000); + // job_status is a read: no card in safe-auto or workspace-full. + h.model.enqueue(call('s', 'job_status', { jobId: id, waitSec: 20 }), say('Finished: p95 12 ms.')); + assert.deepEqual(await run(h, 'How did the benchmark go?'), []); + assert.match(result(h, 's'), /^job npm:bench \([0-9a-f-]+\): finished\. The job npm:bench finished \(exit 0\)/); + assert.match(result(h, 's'), /Last output:\n[\s\S]*bench 3\np95_ms 12\n$/); + assert.equal(await h.readFile('starts.txt'), 'start\n'); + h.model.enqueue(call('l', 'job_status', {}), say('One job.')); + await run(h, 'List the jobs.'); + assert.match(result(h, 'l'), /^job npm:bench \([0-9a-f-]+\): finished\./); + }); +}); + +test('a rejected card starts nothing; an unknown or unlisted script is not proposed', async () => { + await withHost(ON, async (h) => { + h.model.enqueue(call('j', 'start_job', { commandId: 'npm:bench' }), say('Okay.')); + const cards = await run(h, 'Run the benchmark.', { approve: false }); + assert.equal(cards.length, 1); + assert.deepEqual(h.provider.jobs.list(), []); + }); + await withHost(ON, async (h) => { + h.model.enqueue(call('a', 'start_job', { commandId: 'npm:deploy' }), call('b', 'start_job', { commandId: 'npm:build' }), say('Okay.')); + const cards = await run(h, 'Deploy, then build.'); + assert.deepEqual(cards, []); + assert.match(result(h, 'a'), /^Not proposed: npm:deploy is not a job command; use npm: