# Sandbox-uri (/ro/docs/api/gateway/sandboxes)

Workspace-uri gVisor izolate în care agenții rulează Python, bash și LibreOffice și predau fișierele ca artefacte scanate și semnate.

Un sandbox este un container izolat cu un director `/workspace` persistent. Codul tău sau agentul tău rulează comenzi în el (Python 3, bash, LibreOffice, Node 24, bibliotecile uzuale pentru date și documente, fonturi cu acoperire completă pentru limba română), scrie rezultatele în `/workspace/out` și le primește înapoi ca artefacte: scanate, cu hash și descărcabile printr-un link semnat. Workspace-ul se păstrează între execuții până când sandbox-ul este distrus sau expiră.

Două backend-uri folosesc același API. **`v2` este implicit** din 18 octombrie 2026: un pod de lungă durată cu snapshot-uri de memorie (pauză/reluare, fork, kernel Python persistent, URL-uri de previzualizare), vezi [Sandbox-uri v2](#sandbox-uri-v2). `v1` (Cloud Run, workspace restaurat la fiecare exec, plus `POST /snapshot`) rămâne disponibil cu `backend: "v1"`. Dacă omiți `backend` și v2 nu este disponibil pentru cheia ta, gateway-ul trece pe v1; câmpul `backend` din fiecare obiect sandbox îți spune ce ai primit.

Fiecare execuție este contorizată; timpul de exec se plătește ulterior din portofel (vezi [Facturare](#facturare)), de aceea răspunsurile au `x-codai-cost-micro-usd: 0`. Fiecare rută cere o cheie `codai_` care atinge nivelul de unelte `l3_sandbox`.

## Modelul de securitate [#modelul-de-securitate]

- **Izolare gVisor.** Fiecare sandbox rulează sub gVisor, cu propria graniță de kernel: v2 ca pod dedicat pe GKE, v1 pe Cloud Run (gen2). Sistemul de fișiere al imaginii este doar-citire pentru codul tău și doar `/workspace` poate fi scris.
- **Fără rețea implicit.** Cu `egress: { mode: 'none' }` (implicit) nu există nicio rută spre exterior: IP-urile publice, rețelele private și serverul de metadate sunt inaccesibile.
- **Egress printr-un proxy cu listă de domenii permise.** `{ mode: 'allowlist', domains: [...] }` (cel mult 20 de nume de gazde, fără adrese IP, fără sufixe interne) sau `{ mode: 'preset', preset: 'pypi' | 'npm' }` permite HTTPS doar către acele gazde, prin proxy-ul de egress codai, care rezolvă singur DNS-ul și jurnalizează fiecare conexiune. Funcționează pe ambele backend-uri; proxy-ul deschide tuneluri simple și nu injectează niciodată credențiale.
- **Niciun secret înăuntru.** În sandbox nu există nicio cheie codai, credențială cloud sau token; variabilele de mediu numite `CODAI_*` sau `GOOGLE_*` sunt respinse.
- **Auditat.** Fiecare creare, execuție, transfer de fișier, artefact, snapshot și distrugere este scrisă într-un jurnal de audit append-only; fiecare execuție returnează o chitanță de reproductibilitate.
- Datele rămân în `europe-west1` (`x-codai-data-region`).

## Limite [#limite]

LibreOffice convertește toate formatele office în sandbox, de exemplu `soffice --headless --convert-to pdf --outdir /workspace/out raport.docx` (și `.xlsx`, `.pptx`, `.odt`, HTML, CSV). Fiecare comandă rulează sub un init mic care preia procesele ajutătoare din fundal, iar `exit_code` este codul real de ieșire al comenzii tale.

| Limită                                   | Valoare implicită                        | Eroare                                  |
| ---------------------------------------- | ---------------------------------------- | --------------------------------------- |
| Sandbox-uri pornite per cheie            | 3                                        | 429 `sandbox_concurrency_limit`         |
| Sandbox-uri v2 pornite per utilizator    | 5 (toate cheile la un loc)               | 429 `sandbox_concurrency_limit`         |
| Sandbox-uri v2 pe pauză per utilizator   | 10                                       | 429 `sandbox_paused_limit` (la pauză)   |
| Capacitatea platformei v2                | comună                                   | 503 `sandbox_capacity` (reîncearcă)     |
| Execuții simultane per sandbox           | 1                                        | 409 `sandbox_busy`                      |
| Durata de viață (`ttl_seconds`)          | implicit 1 h, maximum 24 h               | 400 `invalid_ttl`                       |
| Timeout per execuție (`timeout_seconds`) | 900 s                                    | 400                                     |
| Mărimea workspace-ului                   | 2 GB                                     | eveniment `error` `workspace_too_large` |
| Secunde de execuție per utilizator pe zi | 7 200                                    | 429 `sandbox_daily_limit`               |
| Încărcare fișier                         | 100 MB                                   | 413                                     |
| Listă de egress                          | 20 de domenii                            | 400                                     |
| stdout / stderr per execuție             | câte 1 MB, apoi un eveniment `truncated` | —                                       |

## Artefacte [#artefacte]

`GET /v1/sandboxes/{id}/artifacts` colectează fiecare fișier din `/workspace/out`. Fiecare artefact are:

- `mime`, detectat din conținut (nu din numele fișierului);
- `sha256` al octeților;
- `scan`: verdictul ClamAV, `clean`, `infected` sau `error`;
- `url`: un link de descărcare semnat, valabil 1 h, prezent doar când `scan` este `clean` (`expires_at` spune când expiră).

`POST /v1/sandboxes/run` returnează artefactele direct în rezultat.

## Chitanțe [#chitanțe]

Fiecare execuție încheiată (rezultatul blocant și evenimentul final `exit` din stream) include o `receipt`: digest-ul imaginii, șablonul, SHA-256 al intrărilor și al comenzii, fiecare fișier rezultat cu SHA-256 și mărime, plus generația workspace-ului înainte și după. Păstreaz-o lângă documentul produs ca dovadă a codului care l-a generat și a intrărilor folosite.

## Streaming [#streaming]

Trimite `Accept: text/event-stream` la `POST /v1/sandboxes/{id}/exec` ca să primești cadre `data:` pe măsură ce comanda rulează: `start`, apoi oricâte fragmente `stdout` / `stderr` (și `truncated` când un stream atinge 1 MB), apoi exact un `exit` (sau `error`). Fără acest header apelul blochează și returnează un singur rezultat JSON.

`Idempotency-Key` pe exec și run returnează, timp de 24 h, răspunsul salvat al unei cereri anterioare cu aceeași cheie, în loc să ruleze comanda din nou.

## Pornire rapidă: un PDF cu diacritice românești [#pornire-rapidă-un-pdf-cu-diacritice-românești]

Dintr-o singură cerere cu `POST /v1/sandboxes/run`: sandbox-ul este creat, scriptul rulează, `/workspace/out` este colectat și sandbox-ul este distrus.

```bash
curl https://ai.codai.ro/v1/sandboxes/run \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: act-2026-10-16-001" \
  -d @- <<'JSON'
{
  "language": "python",
  "timeout_seconds": 120,
  "code": "import os\nfrom reportlab.lib.pagesizes import A4\nfrom reportlab.pdfbase import pdfmetrics\nfrom reportlab.pdfbase.ttfonts import TTFont\nfrom reportlab.pdfgen import canvas\n\npdfmetrics.registerFont(TTFont('DejaVu', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'))\nos.makedirs('/workspace/out', exist_ok=True)\nc = canvas.Canvas('/workspace/out/act.pdf', pagesize=A4)\nc.setFont('DejaVu', 14)\nc.drawString(72, 770, 'Act adițional nr. 1 la contractul de prestări servicii')\nc.drawString(72, 745, 'Părțile convin: ședința se mută în București, str. Ștefan cel Mare.')\nc.save()\nprint('ok')\n"
}
JSON
```

Răspunsul este un rezultat de execuție în care `artifacts[0].url` descarcă `act.pdf` timp de o oră. Fontul DejaVu Sans include tot setul `ă â î ș ț`, așa că `ș` și `ț` cu virgulă dedesubt se afișează corect.

## SDK-uri [#sdk-uri]

```ts
import { Codai } from 'codai-sdk';

const client = new Codai({ apiKey: process.env.CODAI_API_KEY! });
const sb = await client.sandboxes.create({ ttl_seconds: 1800 });
await client.sandboxes.files.put(sb.id, 'in/date.csv', 'client,suma\nSC Exemplu SRL,1200\n');
for await (const ev of client.sandboxes.execStream(sb.id, {
  shell: 'python3 -c "import pandas as pd; print(pd.read_csv(\'in/date.csv\'))"',
})) {
  if (ev.type === 'stdout') process.stdout.write(ev.data);
  if (ev.type === 'exit') console.log('exit', ev.exit_code, ev.receipt.command_sha256);
}
const { data } = await client.sandboxes.artifacts(sb.id);
await client.sandboxes.delete(sb.id);
```

```python
from codai import Codai

client = Codai(api_key="...")
sb = client.sandboxes.create(ttl_seconds=1800)
client.sandboxes.files.put(sb["id"], "in/date.csv", "client,suma\nSC Exemplu SRL,1200\n")
for ev in client.sandboxes.exec_stream(sb["id"], language="python", code="print(open('in/date.csv').read())"):
    if ev["type"] == "stdout":
        print(ev["data"], end="")
result = client.sandboxes.run(shell="weasyprint in/page.html out/page.pdf",
                              inputs=[{"path": "page.html", "url": "https://example.com/page.html"}])
client.sandboxes.delete(sb["id"])
```

## Unealta de chat `codai_sandbox` [#unealta-de-chat-codai_sandbox]

Adaugă `{ "type": "codai_sandbox" }` în `tools` la `POST /v1/chat/completions` (opțional cu `egress` și `template`), iar gateway-ul face restul pe server: îi dă modelului `sandbox_bash`, `sandbox_python`, `sandbox_edit` și `sandbox_files`, le rulează într-un sandbox per `x-codai-session-id` (durată de viață 1 h, prelungită la fiecare folosire) și, la final, adaugă artefactele la mesajul final ca `codai_sandbox.artifacts`, plus o listă markdown cu linkuri semnate. Cheia are nevoie în continuare de nivelul de unelte `l3_sandbox`.

```bash
curl https://ai.codai.ro/v1/chat/completions \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "x-codai-session-id: acte-firma-42" \
  -d '{"model":"codai","tools":[{"type":"codai_sandbox"}],
       "messages":[{"role":"user","content":"Fă-mi un PDF cu o hotărâre AGA pentru SC Exemplu SRL."}]}'
```

Unealta integrată `exec_python` rulează acum pe același backend de sandbox; nu mai execută cod în procesul gateway-ului.

## Skill-uri pentru documente (compatibile cu Claude Skills) [#skill-uri-pentru-documente-compatibile-cu-claude-skills]

Agent Skills predefinite de la Anthropic (`docx`, `xlsx`, `pdf`, `pptx`) au nevoie de un container propriu Anthropic, care nu există pe cloud-urile din spatele `codai`, așa că codai le oferă pe propriul sandbox, pentru orice model. Două forme echivalente:

```json
{ "tools": [{ "type": "codai_sandbox", "skills": ["docx", "pdf"] }] }
```

```json
{ "container": { "skills": [{ "type": "anthropic", "skill_id": "xlsx", "version": "latest" }] } }
```

Ambele merg pe `/v1/chat/completions` și pe `/v1/messages`. Pornesc unealta `codai_sandbox` și adaugă un bloc fix de instrucțiuni pentru skill-urile cerute (python-docx, openpyxl, reportlab/pypdf, python-pptx, weasyprint), astfel încât modelul produce fișiere reale în `/workspace/out`, pe care le primești ca linkuri semnate. Skill-urile proprii (`type: "custom"`) și reutilizarea containerului (`container.id`) răspund cu 400; folosește `x-codai-session-id` ca să păstrezi un sandbox pe conversație.

## Unelte MCP [#unelte-mcp]

Serverul MCP codai (`https://ai.codai.ro/mcp`) expune `sandbox_create`, `sandbox_list`, `sandbox_exec`, `sandbox_files` (`list` / `read` / `write` / `artifacts`) și `sandbox_kill`, plus uneltele v2 `sandbox_run_code`, `sandbox_pause`, `sandbox_resume`, `sandbox_fork` și `sandbox_preview` (vezi [Sandbox-uri v2](#sandbox-uri-v2)). Ele apelează aceleași rute cu cheia ta, deci nivelul de unelte, limitele și contorizarea sunt aceleași ca la API-ul REST. Citirile cu `sandbox_files` întorc cel mult 256 KB direct în răspuns; fișierele mai mari le iei prin `op: "artifacts"` (link semnat + SHA-256).

## Facturare [#facturare]

Din 17 octombrie 2026 (00:00 UTC) timpul de exec se plătește din portofel cu **0,10 € pe oră de exec**, iar **primele 10 ore de exec pe utilizator pe lună calendaristică (UTC) sunt gratuite**; consumul de dinainte de această dată nu se facturează niciodată. Un sandbox pornit, dar nefolosit, nu costă nimic. Consumul se decontează pe intervale de 10 minute (`wallet_ledger`, sursa `sandbox_charge`). Dacă un interval nu poate fi plătit, crearea și exec-urile noi răspund `402 insufficient_balance` (cu `unpaid_micro_eur`) până când o alimentare a portofelului îl achită.

Această secțiune descrie prețul backend-ului `v1`. Sandbox-urile v2 (implicite) se facturează altfel, vezi [Facturare v2](#facturare-v2).

## Sandbox-uri v2 [#sandbox-uri-v2]

Disponibile pentru orice cheie din 18 octombrie 2026 și **backend-ul implicit** când omiți `backend`. API-ul REST de mai sus este comun: un sandbox își alege backend-ul o singură dată, la creare, iar `backend` (`v1` sau `v2`) apare în fiecare obiect sandbox. Dacă v2 nu este disponibil pentru cheia ta și nu l-ai cerut explicit, crearea trece pe v1; cu `backend: "v2"` explicit, cererea eșuează.

Un sandbox v2 este o mașină izolată de lungă durată, nu un workspace restaurat la fiecare exec: procesele continuă să ruleze între apeluri, iar întreaga stare (memorie, procese pornite, `/workspace`) poate fi salvată ca snapshot, pusă pe pauză, reluată și copiată prin fork. Pe v2:

- **Egress** funcționează ca pe v1: `none` (implicit), un preset `pypi` / `npm` sau o listă de cel mult 20 de domenii, prin proxy-ul de egress codai (tuneluri simple, fără injectare de credențiale). Fork-urile moștenesc egress-ul părintelui.
- **Unelte:** Python 3, bash, bibliotecile pentru documente și **Node 24 cu npm** în fiecare șablon.
- **`inputs`** la exec și **`GET /artifacts`** (scanate ClamAV, SHA-256, URL-uri semnate) se comportă exact ca pe v1.
- **Doar pe v1:** `POST /v1/sandboxes/{id}/snapshot` răspunde `409 not_supported_on_v2`; folosește pauza/fork-ul.

### Limite v2 [#limite-v2]

| Limită                                 | Valoare                 | Eroare                              |
| -------------------------------------- | ----------------------- | ----------------------------------- |
| Sandbox-uri pornite per cheie          | 3                       | 429 `sandbox_concurrency_limit`     |
| Sandbox-uri v2 pornite per utilizator  | 5, pe toate cheile tale | 429 `sandbox_concurrency_limit`     |
| Sandbox-uri v2 pe pauză per utilizator | 10                      | 429 `sandbox_paused_limit` la pauză |
| Copii per fork                         | 5                       | 400 `invalid_count`                 |
| Capacitatea platformei                 | rezervă comună          | 503 `sandbox_capacity`, reîncearcă  |

`503 sandbox_capacity` este temporar: reîncearcă cu pauze din ce în ce mai lungi sau creează cu `backend: "v1"`.

### Șabloane [#șabloane]

| `template`    | Ce conține                                                                                      |
| ------------- | ----------------------------------------------------------------------------------------------- |
| `codai-base`  | Imaginea implicită: Python 3, bash, Node 24 + npm și bibliotecile pentru documente.             |
| `brivio-acte` | `codai-base` plus LibreOffice, pregătit pentru documente de birou și pornit deja încălzit.      |
| `desktop`     | Un desktop Linux accesibil prin noVNC pe portul 6080 (îl deschizi cu un URL de previzualizare). |

Când un șablon are un snapshot încălzit, răspunsul la creare conține `restored: true`.

### Kernel persistent: `run_code` [#kernel-persistent-run_code]

`POST /v1/sandboxes/{id}/run_code` cu `{ "code": "...", "timeout_seconds": 60 }` rulează o celulă Python într-un singur kernel de lungă durată. Variabilele globale se păstrează între apeluri, peste pauză/reluare și în fork-uri. Răspunsul este `{ stdout, stderr, result, error, duration_ms, execution_count, kernel_restarted }`: `result` este repr-ul ultimei expresii (cel mult 64 KB), iar `error` este `{ name, value, traceback }` pentru o excepție neprinsă. O celulă care depășește timpul limită repornește kernelul (`kernel_restarted: true`, starea se pierde). `POST /v1/sandboxes/{id}/kernel/reset` îl repornește intenționat; fișierele rămân.

### Pauză și reluare [#pauză-și-reluare]

`POST /v1/sandboxes/{id}/pause` face un snapshot al sandbox-ului și îl oprește; statusul devine `paused`. Poți păstra cel mult 10 sandbox-uri v2 pe pauză (`429 sandbox_paused_limit`); șterge-le pe cele vechi ca să faci loc. La reluare, sandbox-ul intră din nou în limitele de sandbox-uri pornite. `POST /v1/sandboxes/{id}/resume` îl readuce și răspunde cu sandbox-ul plus `restored` și `ready_ms`. `restored: true` înseamnă că memoria și procesele au revenit exact cum erau; `restored: false` înseamnă o pornire la rece din workspace-ul salvat (fișierele sunt intacte, starea kernelului se pierde). codai măsoară asta în interiorul sandbox-ului; nu presupune niciodată.

### Fork [#fork]

`POST /v1/sandboxes/{id}/fork` cu `{ "count": 1..5, "ttl_seconds"?: n }` face un snapshot al unui sandbox pornit (care continuă să ruleze) și pornește `count` copii din acel snapshot. Fiecare copie este un sandbox complet, cu id, workspace și stare de kernel proprii, și se facturează ca oricare altul. Răspunsul este `{ object: "list", parent_id, data: [{ id, restored, ready_ms, error? }] }`: pentru fiecare copie, `restored: false` înseamnă că a pornit la rece, iar `error` înseamnă că a eșuat și nu rulează. Un fork nu poate duce o cheie peste 6 sandbox-uri active și nici pe tine peste 5 sandbox-uri v2 pornite (`429 sandbox_concurrency_limit`).

### URL-uri de previzualizare [#url-uri-de-previzualizare]

`POST /v1/sandboxes/{id}/preview` cu `{ "port": 3000, "ttl_seconds": 900 }` răspunde `{ port, url, expires_at }`: un URL HTTPS semnat pe `*.sbx.codai.ro` care trimite la acel port din sandbox (un server de dezvoltare sau noVNC pe 6080 în `desktop`). Porturi 1024–65535, cu excepția lui 8080; valabilitate între 60 s și 1 h (implicit 15 min). URL-ul nu cere cheie: oricine îl are ajunge la port până la expirare, așa că trimite-l doar celor care trebuie să vadă aplicația.

```ts
const sb = await client.sandboxes.create({
  template: 'codai-base',
  egress: { mode: 'preset', preset: 'pypi' },
}); // v2 implicit
await client.sandboxes.runCode(sb.id, {
  code: 'import pandas as pd\ndf = pd.DataFrame({"a": [1, 2]})',
});
const r = await client.sandboxes.runCode(sb.id, { code: 'df["a"].sum()' }); // r.result === '3'
const { data } = await client.sandboxes.fork(sb.id, { count: 3 });
await client.sandboxes.pause(sb.id);
const back = await client.sandboxes.resume(sb.id); // back.restored, back.ready_ms
```

```python
sb = client.sandboxes.create(backend="v2", template="desktop")
p = client.sandboxes.preview(sb["id"], 6080, ttl_seconds=600)
print(p["url"], p["expires_at"])
```

### Facturare v2 [#facturare-v2]

- **Pornit:** 0,10 € pe oră de ceas cât timp sandbox-ul există (ocupat sau nefolosit), cu **5 ore gratuite pe utilizator pe lună calendaristică (UTC)** (separat de orele de exec v1 de mai sus).
- **Pe pauză:** se stochează doar snapshot-ul, cu **0,05 € pe GB-lună**, contorizat orar.
- Sumele se decontează din portofel ca la v1 (`402 insufficient_balance` când un interval nu poate fi plătit).

<OpenAPIPage
  document="./openapi/ro/gateway.yaml"
  operations={[
    { path: '/v1/sandboxes', method: 'post' },
    { path: '/v1/sandboxes', method: 'get' },
    { path: '/v1/sandboxes/{id}', method: 'get' },
    { path: '/v1/sandboxes/{id}/exec', method: 'post' },
    { path: '/v1/sandboxes/{id}/files/{path}', method: 'put' },
    { path: '/v1/sandboxes/{id}/files/{path}', method: 'get' },
    { path: '/v1/sandboxes/{id}/files', method: 'get' },
    { path: '/v1/sandboxes/{id}/artifacts', method: 'get' },
    { path: '/v1/sandboxes/{id}/snapshot', method: 'post' },
    { path: '/v1/sandboxes/{id}', method: 'delete' },
    { path: '/v1/sandboxes/run', method: 'post' },
    { path: '/v1/sandboxes/{id}/run_code', method: 'post' },
    { path: '/v1/sandboxes/{id}/kernel/reset', method: 'post' },
    { path: '/v1/sandboxes/{id}/pause', method: 'post' },
    { path: '/v1/sandboxes/{id}/resume', method: 'post' },
    { path: '/v1/sandboxes/{id}/fork', method: 'post' },
    { path: '/v1/sandboxes/{id}/preview', method: 'post' },
  ]}
/>
