diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e508023..8a01e3b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,15 +97,15 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Cache Bun packages uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/.bun/install/cache - key: ${{ runner.os }}-codeapi-api-bun-1.4.2-${{ hashFiles('api/bun.lock') }} + key: ${{ runner.os }}-codeapi-api-bun-1.4.4-${{ hashFiles('api/bun.lock') }} restore-keys: | - ${{ runner.os }}-codeapi-api-bun-1.4.2- + ${{ runner.os }}-codeapi-api-bun-1.4.4- - name: Install dependencies run: bun ci @@ -154,7 +154,7 @@ jobs: --workdir /work/api \ --env SANDBOX_CLEANUP_TMPFS_TEST=1 \ --env SANDBOX_LOG_LEVEL=error \ - oven/bun:1.4.2-debian \ + oven/bun:1.4.4-debian \ bun test src/cleanup.integration.test.ts nsjail-ipc-tests: @@ -171,7 +171,7 @@ jobs: run: python3 tests/test_tool_call_pipes.py - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Render native sandbox policy working-directory: api run: | @@ -278,15 +278,15 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Cache Bun packages uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/.bun/install/cache - key: ${{ runner.os }}-codeapi-service-bun-1.4.2-${{ hashFiles('service/bun.lock') }} + key: ${{ runner.os }}-codeapi-service-bun-1.4.4-${{ hashFiles('service/bun.lock') }} restore-keys: | - ${{ runner.os }}-codeapi-service-bun-1.4.2- + ${{ runner.os }}-codeapi-service-bun-1.4.4- - name: Install dependencies run: bun ci @@ -402,7 +402,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Install service dependencies working-directory: service @@ -474,7 +474,7 @@ jobs: docker buildx inspect --bootstrap - name: Build Lambda MicroVM runner image - # The target pins Bun 1.4.2 and installs api/bun.lock frozen. Building + # The target pins Bun 1.4.4 and installs api/bun.lock frozen. Building # natively catches architecture-specific package and Docker layer # failures without retaining a second multi-gigabyte image copy. run: | diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 7c626767..ad1c36f3 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -33,6 +33,7 @@ on: - javascript-packages.txt - launcher/** - packages/code/src/** + - packages/extraction/** - service/** - shared/** pull_request: @@ -70,6 +71,8 @@ jobs: run: | images=$(jq -c . <<'JSON' [ + {"image": "code-interpreter-extraction", "dockerfile": "packages/extraction/Dockerfile", "target": "runtime", + "description": "Authenticated isolated PDF/DOCX extraction service", "timeout": 15}, {"image": "code-interpreter-api", "dockerfile": "service/Dockerfile", "target": "api", "description": "Code API HTTP server (Compose service api)", "timeout": 30}, {"image": "code-interpreter-worker", "dockerfile": "service/Dockerfile", "target": "worker", diff --git a/api/Dockerfile b/api/Dockerfile index fe8095de..564c5845 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -51,7 +51,7 @@ ENV SANDBOX_PIPE_ADDON=/usr/local/lib/anonymous-pipes.node COPY docker/python-runtime /python-runtime RUN python3 /python-runtime/install.py COPY --from=node:22.23.4-slim /usr/local/bin/node /usr/local/bin/node -COPY --from=oven/bun:1.4.2-debian /usr/local/bin/bun /usr/local/bin/bun +COPY --from=oven/bun:1.4.4-debian /usr/local/bin/bun /usr/local/bin/bun # ============================================================================ # Stage 1b: Build language runtime packages (only consumed by sandbox-runner-baked) @@ -65,7 +65,7 @@ FROM buildpack-deps:bookworm AS package-builder ENV DEBIAN_FRONTEND=noninteractive ENV PYTHON_VERSION=3.14.4 -ENV BUN_VERSION=1.4.2 +ENV BUN_VERSION=1.4.4 COPY docker/apt-install.sh /usr/local/bin/apt-install RUN chmod +x /usr/local/bin/apt-install @@ -88,7 +88,7 @@ RUN chmod +x /package-init.sh && FORCE_REBUILD=true /package-init.sh # ============================================================================ # Stage 2: Build sandbox rootfs # ============================================================================ -FROM oven/bun:1.4.2-debian AS sandbox-build +FROM oven/bun:1.4.4-debian AS sandbox-build ENV DEBIAN_FRONTEND=noninteractive diff --git a/api/src/package-init.test.ts b/api/src/package-init.test.ts index a9431bd2..ecb54ba6 100644 --- a/api/src/package-init.test.ts +++ b/api/src/package-init.test.ts @@ -28,7 +28,7 @@ for (const oldRuntimeExists of [false, true]) { args: ['hello world', '--version'], }, { - directory: 'bun/1.4.2', + directory: 'bun/1.4.4', binary: 'bun', args: ['run', 'hello world', '--version'], }, @@ -131,7 +131,7 @@ for (const oldRuntimeExists of [false, true]) { FORCE_REBUILD: 'false', PYTHON_VERSION: '3.14.4', NODE_VERSION: '24.22.1', - BUN_VERSION: '1.4.2', + BUN_VERSION: '1.4.4', BASH_PACKAGE_VERSION: '5.2.0', JS_PACKAGE_MANIFEST: manifest, }; diff --git a/build-packages.sh b/build-packages.sh index 59d54750..71015682 100755 --- a/build-packages.sh +++ b/build-packages.sh @@ -10,7 +10,7 @@ # Environment Variables: # PYTHON_VERSION=3.14.4 # Python version to install # NODE_VERSION=24.22.1 # Node.js version to install -# BUN_VERSION=1.4.2 # Bun version to install +# BUN_VERSION=1.4.4 # Bun version to install # BASH_PACKAGE_VERSION=5.2.0 # Bash package registration version (semver-like x.y.z) # SKIP_PYTHON_PACKAGES=1 # Skip Python pip packages # SKIP_JS_PACKAGES=1 # Skip JavaScript npm packages for Node/Bun @@ -28,7 +28,7 @@ cd "$SCRIPT_DIR" PYTHON_VERSION="${PYTHON_VERSION:-3.14.4}" NODE_VERSION="${NODE_VERSION:-24.22.1}" -BUN_VERSION="${BUN_VERSION:-1.4.2}" +BUN_VERSION="${BUN_VERSION:-1.4.4}" PACKAGES_DIR="./data/pkgs" JS_PACKAGE_MANIFEST="${JS_PACKAGE_MANIFEST:-${SCRIPT_DIR}/javascript-packages.txt}" diff --git a/docker/Dockerfile.worker-sandbox b/docker/Dockerfile.worker-sandbox index 7d0b545a..4787c51b 100644 --- a/docker/Dockerfile.worker-sandbox +++ b/docker/Dockerfile.worker-sandbox @@ -69,7 +69,7 @@ FROM buildpack-deps:bookworm AS package-builder ENV DEBIAN_FRONTEND=noninteractive ENV PYTHON_VERSION=3.14.4 -ENV BUN_VERSION=1.4.2 +ENV BUN_VERSION=1.4.4 COPY docker/apt-install.sh /usr/local/bin/apt-install RUN chmod +x /usr/local/bin/apt-install @@ -92,7 +92,7 @@ RUN chmod +x /package-init.sh && FORCE_REBUILD=true /package-init.sh # ============================================================================ # Stage 2: Build worker # ============================================================================ -FROM oven/bun:1.4.2 AS worker-builder +FROM oven/bun:1.4.4 AS worker-builder WORKDIR /app COPY service/package.json service/bun.lock ./ RUN bun install --frozen-lockfile @@ -105,7 +105,7 @@ RUN bun build ./src/worker-server.ts --minify --outdir .build --target bun --ext # ============================================================================ # Stage 3: Build sandbox API # ============================================================================ -FROM oven/bun:1.4.2 AS sandbox-builder +FROM oven/bun:1.4.4 AS sandbox-builder WORKDIR /app COPY api/package.json ./ RUN bun install --frozen-lockfile @@ -118,7 +118,7 @@ RUN bun build ./src/index.ts --minify --outdir .build --target bun --external '@ # ============================================================================ # Stage 4: Build sandbox rootfs (full OS layer for the microVM guest) # ============================================================================ -FROM oven/bun:1.4.2-debian AS sandbox-rootfs +FROM oven/bun:1.4.4-debian AS sandbox-rootfs ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/package-init.sh b/docker/package-init.sh index 2273721c..7551504e 100644 --- a/docker/package-init.sh +++ b/docker/package-init.sh @@ -21,7 +21,7 @@ PYTHON_ALIAS="python${PYTHON_SITE_VERSION}" PYTHON_PACKAGE_INSTALLER="${PYTHON_PACKAGE_INSTALLER:-uv}" UV_VERSION="${UV_VERSION:-0.11.26}" NODE_VERSION="${NODE_VERSION:-24.22.1}" -BUN_VERSION="${BUN_VERSION:-1.4.2}" +BUN_VERSION="${BUN_VERSION:-1.4.4}" BASH_PACKAGE_VERSION="${BASH_PACKAGE_VERSION:-5.2.0}" INSTALL_FAILED=false JS_PACKAGE_MANIFEST="${JS_PACKAGE_MANIFEST:-${SCRIPT_DIR}/javascript-packages.txt}" diff --git a/docs/SECURITY-MAINTENANCE.md b/docs/SECURITY-MAINTENANCE.md index f33e509f..05631de6 100644 --- a/docs/SECURITY-MAINTENANCE.md +++ b/docs/SECURITY-MAINTENANCE.md @@ -120,7 +120,7 @@ does not mount. The namespace-disabled native policy tests cover those APIs when shared memory exists, not their availability in the default jail. With runtime packages built by `build-packages.sh` (or a compatible `/pkgs` -tree containing Python 3.14.4 and Bun 1.4.2), run from the repository root: +tree containing Python 3.14.4 and Bun 1.4.4), run from the repository root: ```sh bun service/scripts/dump-pipe-preamble.ts > /tmp/codeapi-pipe-client.py @@ -180,7 +180,7 @@ Remaining service audit findings at this change: - `braces` (GHSA-vfj7-8cjw-p6xm): development-only dependency with no published fix. Do not feed untrusted patterns to build tooling; track an upstream fix. -Bun 1.4.2 subprocess spawning with piped stdio also uses socketpairs and is +Bun 1.4.4 subprocess spawning with piped stdio also uses socketpairs and is denied by this policy. Basic JS/TS execution remains supported. Replacing Bun subprocess IPC requires a separate runtime change; do not re-enable socketpairs to provide that compatibility. diff --git a/docs/fork/patches.md b/docs/fork/patches.md index c1b3edd6..7a6ebbd8 100644 --- a/docs/fork/patches.md +++ b/docs/fork/patches.md @@ -1019,3 +1019,53 @@ not a prose-pinning CI test. v1.10.10 as the ancestor. Upstream-only additions are no longer fork patches. Rebuild path ownership at the final reviewed commit; source CI and review qualify the candidate separately from image publication and runtime rollout. + + +### Re-audit basis: upstream v1.10.13 continuation + +- Fork baseline: `80810a0934e96eb1dd9bbca84f288fb345791f65`. +- Upstream release commit: `2fa9f0d43e8e38b7d6240bc76e2483c6a47c64c7`; + previous upstream ancestor: `77bcd2ec2e63be523bfab2dfa11159a2ceec0088`. +- Adopt upstream workspace-import parent creation and root race prevention, + extraction gateway/credential isolation, response-close outcome accounting + and Bun 1.4.4 alignment. The optional extraction package stays outside the + primary deployment. The public execution contract is unchanged. +- All incoming paths match upstream except the existing shared CI, sandbox + Dockerfiles, launcher Dockerfile and chart values. Their incoming changes + only advance Bun pins; preserve the fork's native lifecycle checks, + spec-guard permissions, external ownership and chart settings. +- No fork behavior is retired in this continuation. Retain issuer-keyed trust, + values-free sinks, nonfatal telemetry, missing-bucket readiness, bounded + refusal-only retry, caller cancellation and guest cleanup ownership, + selected default-disabled lifecycle observation and restricted metrics peers. + Existing behavioral tests remain the semantic proof for these shared seams. +- The fork release job remains disabled. The existing seven-image publication + workflow remains the delivery mechanism; upstream release ancestry is + retained without creating fork stable tags or releases. +- The execution and recovery contract is in the + [release plan](../project/2026-10-25-upstream-v1.10.13-plan.md). + Recompute both final-tree comparisons at the reviewed candidate; unexplained + paths or unsupported retirement claims hold qualification. + +### Qualification and consumer-fixture path accounting + +`scripts/qualification/codeapi.mjs` and `scripts/qualification/codeapi.test.mjs` +own the independent bounded operator API/artifact, resource and sustained-load +verification controls. They do not change service admission or deployment. +Retain their budget, ownership, guard, terminality and cleanup tests. Drop when +an upstream operator suite provides equivalent bounded qualification. + +`tests/librechat/consumer-e2e.patch`, `tests/librechat/prepare.mjs` and +`tests/librechat/prepare.test.mjs` own the disposable consumer harness. It keeps +custom operational verification out of the LibreChat application fork while +preserving its upstream E2E coverage. This fixture is test-only, with no +production consumer or service activation. Drop when upstream supplies the +same isolated consumer acceptance path. + +Additional execution references, not replay units: + +- `docs/project/2026-10-24-codeapi-capabilities.md` +- `docs/project/2026-10-24-codeapi-capacity-contract-plan.md` +- `docs/project/2026-10-24-codeapi-sustained-load-plan.md` +- `docs/project/2026-10-25-codeapi-production-qualification-plan.md` +- `docs/project/2026-10-25-upstream-v1.10.13-plan.md` diff --git a/docs/project/2026-10-25-upstream-v1.10.13-plan.md b/docs/project/2026-10-25-upstream-v1.10.13-plan.md new file mode 100644 index 00000000..d8069400 --- /dev/null +++ b/docs/project/2026-10-25-upstream-v1.10.13-plan.md @@ -0,0 +1,85 @@ +# Upstream release reconciliation and Klicker chatbot prototype + +## Approval summary + +Approval mode: executable batch. The user requested the latest released CodeAPI service, followed by a KlickerUZH chatbot-tool prototype and consultation with Claude. Adopt upstream stable v1.10.13 with its ancestry, retain the smallest required fork behavior, qualify immutable images, and advance staging before production through the established GitOps path. The fork continues using upstream release versions with commits on top and never creates stable tags or releases of its own. + +The source delta adopts upstream workspace-import correctness, extraction boundary changes and Bun runtime alignment. Existing trust, values-free logging, caller cancellation, bounded lifecycle observation, external secret/replica ownership and ready-runner policy remain required. Extraction and remote workspace capabilities remain opt-in; updating their source does not activate a service or expand the deployed contract. + +After service qualification, reuse the existing Klicker CodeAPI roadmap and text-tracer proposal to test one question: can an admitted Tutor tool produce a bounded Python result through the existing chat tool interface? Begin with a disposable synthetic local experiment around that seam. Preserve the default-off policy, existing access and conversation ownership, bounded execution, untrusted output handling and cancellation. General egress, package installation, notebook/session persistence, course-data access and production chatbot activation are excluded. + +The request authorizes source implementation, local verification, review, ordinary task-branch publication and the named latest-service upgrade sequence. Existing protected-branch checks and recorded human production release gates still apply. No new identities, secret scopes, capacity, cluster access from CI, destructive tests or unrelated promotion is included. Stop only a dependent action on failed verification, missing capability, changed behavior or a required human gate. Complete independent discovery and prototype preparation meanwhile. + +Terminal: reviewed upstream source and exact images, staged then production functional acceptance where release gates permit, plus a runnable synthetic prototype and findings linked to the existing Klicker plan. If a required release gate blocks deployment, preserve the concrete candidate and report it without claiming completion. Main owns integration, secrets, live effects and final proof. + +## Execution details + +### Source and working context + +Repository: uzh-bf/code-interpreter. Target: main. Task branch: rs/upstream-v1.10.13, worktree trees/storage-reliability-investigation/trees/upstream-v1.10.13. Use the existing docs/project artifact root. Main uses standard execution mode and reuses accepted source/runtime evidence only for unchanged contracts. The prior qualification source is merged in [PR #50](https://github.com/uzh-bf/code-interpreter/pull/50); its finite runtime evidence remains in ignored private receipts. + +The upstream release is [v1.10.13](https://github.com/LibreChat-AI/code-interpreter/releases/tag/v1.10.13), commit 2fa9f0d43e8e38b7d6240bc76e2483c6a47c64c7. Fork baseline is 80810a0934e96eb1dd9bbca84f288fb345791f65; merge base is v1.10.10 at 77bcd2ec2e63be523bfab2dfa11159a2ceec0088. A clean textual merge is preliminary evidence only. Preserve upstream source/tests, update docs/fork/patches.md and account for both merge-base-to-fork and current-upstream-to-candidate final trees. Retire a fork patch only on proven behavioral equivalence. + +No product primitive changes in the service reconciliation. For the prototype, compose the existing admitted chat turn, Tutor capability policy and bounded execution result. The authenticated server owns principal, endpoint and signed claims. The model supplies code only and cannot choose credentials, user identity, endpoint or trust policy. The existing conversation owns persisted visible output; a prototype does not create a new durable workspace or grading primitive. + +### Delegation Map and slice boundaries + +| Slice | Owner and route | Dependencies / exact edit scope | Acceptance | +| --- | --- | --- | --- | +| Inventory existing Klicker ownership | Native trusted explorer (completed) | Read current `origin/v3`, existing CodeAPI roadmap/client/text-tracer plans and PR metadata; no edits | Distinguish CODE-question grading from chatbot tools and preserve other branches | +| Reconcile upstream and ledger | Main; critical-path coupling | This plan, incoming upstream paths enumerated by `git diff --name-only 77bcd2e..v1.10.13`, and `docs/fork/patches.md`; no extra production module | Merge ancestry and semantic fork verification, immutable reviewed candidate, draft PR | +| Qualify images and values | Main; cross-system integration | Existing fork image workflow unchanged; GitLab `uzh-bf/cloud/helm-charts` target `main`, only `codeapi/stg/values.yaml` then `codeapi/prd/values.yaml` | Same merged SHA passes CI and image production; digest/architecture checks; render/inverse and source reviews | +| Verify rollout | Main; live authority/credentials | No tracked scripts added. Private `upgrade-run.mjs` and `upgrade-guard.mjs` under this plan's ignored receipt directory may wrap the existing qualifier | STG passes before PRD human gate; exact image/runtime, contract, canary and health acceptance | +| Prototype construction | Trusted native executor, after main settles contract | Only `/private/tmp/klicker-codeapi-prototype/{package.json,pnpm-lock.yaml,tool.mjs,probe.test.mjs,README.md}`; generated install artifacts ignored; no Klicker production edit | Real AI SDK callback with local fake, generated keys, negative/limit/cancel tests | +| Prototype integration findings | Main; product seam and evidence interpretation | This plan links prior Klicker plans; new `docs/project/2026-10-25-klicker-codeapi-prototype-findings.md` only if useful evidence exists | Runnable command, observation, conclusion, limits and minimal future patch map | + +Source, GitOps runtime values and the throwaway prototype are separate packages with their respective gates. Source commits preserve upstream history. No prototype code is promoted into Klicker production by this batch. + +### Feature-wide test portfolio + +| Risk | Decision | Existing checks / acceptance | +| --- | --- | --- | +| Workspace imports, roots and races | Extend existing through upstream merge | Upstream `packages/code/src/{workspace-import,project-roots,root-access,storage}.test.ts`; `npm test` in `packages/code` | +| Extraction isolation and credentials | Extend existing through upstream merge | `packages/extraction/src/*.test.ts`, `tests/container.sh`; npm format/type/test/container scripts | +| Bun version alignment | None new | Upstream CI/Docker/runtime pins at 1.4.4; changed shell checks, API build/tests and service tests/build using Bun 1.4.4 | +| Retained fork trust, lifecycle, logs and ownership | None new | Existing auth/openapi/router-cancellation/logger/telemetry/API lifecycle tests and deployment scripts; hosted guest/architecture checks | +| Probe controls | None new | `node --test scripts/qualification/codeapi.test.mjs`; private wrapper syntax and synthetic dry run before live admission | +| Prototype admission/auth/output/cancel | Add new, disposable only | `probe.test.mjs` exercises exported SDK tool callback, verifies generated signed claims, denial with zero calls, one-call admission, errors, limits and abort | + +Rebuild both merge-base-to-fork and v1.10.13-to-candidate path inventories at the reviewed source head. Assign every fork path once to a ledger behavior or documentary reference; unexplained paths or unsupported patch retirement hold acceptance. Add a v1.10.13 re-audit disposition to the ledger. Preserve upstream tests rather than introducing wording-pinning tests. + +### Image promotion, rollout and recovery + +Fork `build-codeapi-images.yml` publishes seven images on main independently of `ci.yml`, including mutable main tags. Require successful CI and image production for the same merged SHA. Verify OCI source/revision labels and component digest plus applicable amd64/arm64 architecture. Deploy only verified digests; never select mutable main tags. Preserve the disabled fork release job and upstream-only publication guards. + +Prepare and review the STG values change and its exact Git inverse against current Helm main. Merge with history/branch retained only after required CI and source review. Argo values already follow main; keep the chart registration fixed if rendering proves the image/runtime update needs no chart feature. Observe convergence for at most 15 minutes. Verify STG before preparing and merging PRD values. PRD additionally requires its existing recorded human release authorization. No unrelated source promotion, new registration, Pulumi execution or Kubernetes access in CI. + +For each rollout, preflight idle queues and a Ready runner, matching desired/runtime images, healthy deployment/storage and available existing connectivity. On acceptance failure stop admission, allow at most 10 minutes for admitted work to settle, then use the prepared Git inverse only when queues and known synthetic work are idle. Observe recovery for at most 15 minutes and require the preserved images, healthy deployment/runner, canary and empty owned work. A failed drain or recovery stops dependent actions and reports the exact blocker; no force eviction, live patch or indefinite wait. + +### Bounded upgrade acceptance + +Run one normal `contract` qualification per changed environment using `scripts/qualification/codeapi.mjs`, two synthetic callers and existing exact Infisical scopes. No sustained/resource/destructive test. Its existing envelope limits each run to 160 HTTP calls, 40 execution reservations, 16 owned file references and 120000 requested guest milliseconds; 12-minute admission and 15-minute whole-run limits. Normal requests are sequential; cleanup may have four requests concurrently. HTTP timeout is 60 seconds, cleanup request timeout 15 seconds; the final three minutes are reserved for settlement/cleanup. No automatic retry or replacement run. + +The standard contract covers Python, Node, JavaScript/Bun, TypeScript/Bun and Bash, empty/mismatched/unsupported admissions, synthetic CSV upload/list/metadata, ownership denial, exact bytes through two executions, guest timeout and recovery, three owned deletions and 404 checks. The private wrapper may add one no-file JavaScript execution printing Bun.version, using the same aggregate budget and requiring 1.4.4. Existing defaults Node 24.22.1 and Python 3.14.4 remain source/image checks. No claim is made for unexercised optional runtimes or public consumer paths. + +Refresh a run-bound guard at least every 10 seconds; admission requires a successful observation no older than 20 seconds. Match deployment template/images, original pod health, queue bounds and deployment health, respecting only the existing reviewed owned-HPA transition allowance. Stop on guard/receipt loss, unexpected image/config/identity/replica ownership, unhealthy original resources, API/result mismatch or uncertain artifact ownership. Clean only owned synthetic references. Missing tunnels are reported rather than established. Prove each applicable canary from its producing completed Job/logs. Cancellation is checked through retained offline regressions only: this package enables no diagnostics and makes no new live guest-reaping claim. + +All real-environment findings, identifiers, timing, image receipts, operational plans and secret-bearing bindings stay under ignored docs/project/_local/reviews/2026-10-25-upstream-and-klicker/. Never put those observations in tracked plans, commits, PR/MR descriptions or CI output. Generic source facts, synthetic fixtures and validation commands may be tracked. Inspect every staged hunk and description before publication. No migration is required. No application runtime is needed for source/schema/render checks; if a managed runtime is touched for the prototype, use its owner lifecycle and stop it after verification. + +### Prototype scope and continuation + +Read the existing Klicker chat-codeapi roadmap, client foundation and text-tracer plans and their current PR metadata before selecting a working branch. Resolve the current target using repository instructions and live PR base; an old v3-ai selection cannot silently override current v3 rules. Do not modify another active owner's branch or existing dirty prototype. Use project/ as Klicker's existing artifact root. The service plan references that owner instead of copying its full feature roadmap. + +Existing ownership: Klicker roadmap `trees/chat-codeapi-roadmap/project/2026-08-23-klicker-chat-codeapi-roadmap.md`, client foundation `trees/codeapi-client-foundation/project/2026-08-30-codeapi-client-foundation-plan.md`, text tracer `trees/rs/chat-codeapi-text-tracer/project/2026-09-05-chat-codeapi-text-tracer-plan.md`. No implemented chatbot CodeAPI tool was found. [PR #5142](https://github.com/uzh-bf/klicker-uzh/pull/5142) is CODE-question grading; [PR #5676](https://github.com/uzh-bf/klicker-uzh/pull/5676) overlaps authoritative history. Neither supplies this tool; preserve them. Current repository target is v3; old v3-ai tracer selection is stale for new production work. + +Claude's source-backed consultation identifies `withCalculatorTool` in `apps/chat/src/lib/server/calculatorTool.ts`, composed in the current chat route, as the native tool precedent. Retain its calculator for simple arithmetic; reserve Python for bounded procedures/simulation. The future app patch would add `codeapiTool.ts` and one focused test and compose it beside the calculator; it is a later normal implementation, not part of the throwaway experiment. Existing generic tool rendering, five-step turn limit, conversation/access/usage ownership remain intact. A lecturer capability/schema/UI is deferred. + +Prototype question: can an AI SDK 7.0.52 `tool` callback composed into the existing ToolSet pattern use the unchanged `/v1/exec` contract safely with local transport? The fake returns synthetic canned responses and never executes Python. This proves adapter wiring, not Python, deployed service, actual route/database/history, model choice or browser acceptance. + +Use isolated Node 24 with pnpm, AI SDK 7.0.52 and jose 5.9.4, matching current Klicker pins. A generated in-memory Ed25519 key signs short-lived (at most 120 seconds) claims bound to a synthetic principal, issuer, audience, tenant and `external:klicker` source. The fake verifies signature, algorithm, issuer, audience, expiry and required claims; invalid claims reject. No reusable credential or secret value is saved. The model input is only code (at most 16KiB); endpoint/principal/claims remain server-owned. Default disabled or non-Tutor or unallowlisted bot creates no tool and zero calls. An admitted request owns one execution, reserved before awaiting I/O; duplicate concurrent calls reject with no retry. + +Transport has a 30-second deadline tied to caller AbortSignal, 128KiB response cap and 16KiB stdout/stderr caps with explicit truncation. Exercise success, Python nonzero exit, timeout, 401/403, 429, 503/504, malformed JSON/shape, oversize, disabled/denied, simultaneous duplicate and caller abort. Reject unsupported input fields, redirects and invalid response structures. Return typed bounded observations; never interpret HTTP200 alone as Python success. Use shortened injectable timers only for local deterministic timeout tests, preserving the 30-second production proposal. Observe fake-request abortion without claiming guest process termination. No real provider calls, environment traffic, real accounts, course/student data, files, general egress, schema or production activation. + +## Progress + +The user authorized the sequence. The latest upstream release and clean merge preview are verified. [PR #50](https://github.com/uzh-bf/code-interpreter/pull/50) is merged with history and source branch retained; merged-source CI and image production pass. Existing Klicker discovery and Claude consultation are complete. The planner approved the corrected plan before source merge; its initial five findings and the Claude consultation are preserved privately. Existing upstream remote URL redirects to the verified release repository; annotated release tags are peeled to their commit for integration. diff --git a/helm/codeapi/values.yaml b/helm/codeapi/values.yaml index 0eac308d..efef4f0f 100644 --- a/helm/codeapi/values.yaml +++ b/helm/codeapi/values.yaml @@ -361,7 +361,7 @@ workerSandbox: pullPolicy: Always pythonVersion: "3.14.4" nodeVersion: "24.22.1" - bunVersion: "1.4.2" + bunVersion: "1.4.4" bashPackageVersion: "5.2.0" backoffLimit: 2 resources: diff --git a/launcher/Dockerfile b/launcher/Dockerfile index 4b7b0b95..aaceeff7 100644 --- a/launcher/Dockerfile +++ b/launcher/Dockerfile @@ -50,7 +50,7 @@ RUN gcc -shared -fPIC -O2 -Wall -Wextra -Werror -I/usr/local/include/node -o /us && gcc -O2 -static -o /usr/local/bin/sandbox-rootfs-setup /tmp/rootfs-setup.c \ && chmod 0555 /usr/local/bin/spec-guard /usr/local/bin/sandbox-rootfs-setup -FROM oven/bun:1.4.2-debian AS sandbox-build +FROM oven/bun:1.4.4-debian AS sandbox-build ENV DEBIAN_FRONTEND=noninteractive diff --git a/packages/code/README.md b/packages/code/README.md index 8f911996..f11e0072 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -630,6 +630,13 @@ with `--allow-workspace-writes` or `overwrite: false` to require an atomic create that returns `EDIT_CONFLICT` if the target already exists. Code API dispatches that mode only after the worker and server negotiate `create` in `writeFileModes`. +`write_file` and `import_file` create missing parent directories one level at +a time through `mkdirat` on a held root descriptor, so a swapped symlink cannot +redirect them outside the root. Selected projects, linked-worktree lanes and +conversation worktrees hold that descriptor for every request; a root +registered by path alone (`--worker-dir`, `--workspace`, `--environment`) holds +one for just the write or import whose parent is missing. A root the worker +cannot hold by descriptor is never advertised `import_file`. `import_file` copies one stored chat upload into a writable workspace as a binary-safe file. It is on by default whenever the worker can perform it: @@ -644,7 +651,7 @@ and relays a short-lived grant scoped to that object alone; the worker holds no storage credential. The worker applies `write_file`'s path rules, refuses any `.git` segment (including case, Unicode-ignorable, trailing-dot and `GIT~1` aliases) and symlinked parents, creates missing parents as 0755 under a held -root, streams into a same-directory temporary file with `bytes` as a hard cap, +root descriptor, streams into a same-directory temporary file with `bytes` as a hard cap, verifies size and SHA-256, syncs, and installs the file atomically with mode 0644. Identical existing bytes return `unchanged: true` without a transfer; different bytes return `EDIT_CONFLICT` unless `overwrite: true`. A failure diff --git a/packages/code/src/workspace-import.test.ts b/packages/code/src/workspace-import.test.ts index 18d37b86..d77e9173 100644 --- a/packages/code/src/workspace-import.test.ts +++ b/packages/code/src/workspace-import.test.ts @@ -258,6 +258,47 @@ test('imports binary bytes atomically into new 0755 parents as a 0644 file, what assert.equal(stub.hits, 1); }); +test('a pathname-only root takes chat attachments into a folder it creates, as LibreChat sends them', async (t) => { + const bytes = randomBytes(8192); + const stub = await egressStub(t, bytes); + const { root, tools } = await workspace(t, stub, { held: false }); + assert.ok(tools.capabilities.workspaces[0]?.operations?.includes('import_file')); + const previousUmask = process.umask(0o077); + t.after(() => process.umask(previousUmask)); + + const ignore = await tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + path: '.librechat/attachments/.gitignore', + content: '*\n', + overwrite: false, + }); + assert.equal((ignore as { created: boolean }).created, true); + const request = importRequest(bytes, { path: '.librechat/attachments/report.pdf', overwrite: false }); + assert.equal((await tools.execute(request) as { created: boolean }).created, true); + assert.deepEqual(await readFile(join(root, '.librechat', 'attachments', 'report.pdf')), bytes); + + const direct = importRequest(bytes, { path: 'uploads/2026/archive.zip' }); + assert.equal((await tools.execute(direct) as { created: boolean }).created, true); + assert.equal((await stat(join(root, 'uploads'))).mode & 0o777, 0o755); + assert.equal((await stat(join(root, 'uploads', '2026'))).mode & 0o777, 0o755); + assert.equal((await stat(join(root, 'uploads', '2026', 'archive.zip'))).mode & 0o777, 0o644); + assert.equal(stub.hits, 2); +}); + +test('a failed import on a pathname-only root removes the parents it created', async (t) => { + const stub = await egressStub(t, Buffer.from('tampered bytes')); + const { root, tools } = await workspace(t, stub, { held: false }); + + const mismatch = await rejection( + tools.execute(importRequest(Buffer.from('expected bytes'), { path: '.librechat/attachments/file.bin' })), + ); + assert.equal(mismatch.code, 'INVALID_REQUEST'); + assert.equal(mismatch.mutationMayHaveCommitted, false); + assert.deepEqual(await readdir(root), []); +}); + test('re-importing identical bytes is idempotent and never downloads again', async (t) => { const bytes = randomBytes(4096); const stub = await egressStub(t, bytes); diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index 16e2ad13..266defee 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -1812,55 +1812,67 @@ test('writes create missing parent directories inside a held workspace root', as assert.equal((await stat(join(root, 'src', 'feature', 'deep'))).isDirectory(), true); }); -test('parent creation never follows symlinks, crosses files, or leaves the workspace', async (t) => { - const parent = await realpath( - await mkdtemp(join(tmpdir(), 'librechat-code-workspace-parent-')), - ); - t.after(() => rm(parent, { recursive: true, force: true })); - const root = join(parent, 'root'); - const outside = join(parent, 'outside'); - await mkdir(join(root, 'src'), { recursive: true }); - await mkdir(outside); - await writeFile(join(root, 'file.txt'), 'file'); - await symlink(outside, join(root, 'linked-outside')); - await symlink(join(root, 'src'), join(root, 'alias')); - await symlink(join(parent, 'missing-target'), join(root, 'dangling')); - const tools = await heldRootTools(root); +/** A root registered by path alone, as `--worker-dir`, `--workspace` and `--environment` do. */ +async function pathnameRootTools(root: string): Promise { + return LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root, writable: true }], + }); +} - for (const [path, code] of [ - ['linked-outside/new/notes.txt', 'INVALID_PATH'], - ['alias/new/notes.txt', 'INVALID_PATH'], - ['dangling/new/notes.txt', 'INVALID_PATH'], - ['file.txt/new/notes.txt', 'INVALID_PATH'], - ['../outside/new/notes.txt', 'INVALID_REQUEST'], - ['new/../../outside/notes.txt', 'INVALID_REQUEST'], - ] as const) { - await assert.rejects( - tools.execute({ - protocolVersion: 1, - operation: 'write_file', - workspaceId: 'primary', - path, - content: 'blocked', - }), - (error: unknown) => - error instanceof WorkspaceToolError && - error.code === code && - !error.mutationMayHaveCommitted, - path, +for (const [kind, createTools] of [ + ['held', heldRootTools], + ['pathname-only', pathnameRootTools], +] as const) { + test(`parent creation never follows symlinks, crosses files, or leaves a ${kind} workspace`, async (t) => { + const parent = await realpath( + await mkdtemp(join(tmpdir(), 'librechat-code-workspace-parent-')), ); - } - assert.deepEqual(await readdir(outside), []); - assert.deepEqual(await readdir(join(root, 'src')), []); - assert.deepEqual((await readdir(root)).sort(), [ - 'alias', - 'dangling', - 'file.txt', - 'linked-outside', - 'src', - ]); - await assert.rejects(stat(join(parent, 'missing-target'))); -}); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(parent, 'root'); + const outside = join(parent, 'outside'); + await mkdir(join(root, 'src'), { recursive: true }); + await mkdir(outside); + await writeFile(join(root, 'file.txt'), 'file'); + await symlink(outside, join(root, 'linked-outside')); + await symlink(join(root, 'src'), join(root, 'alias')); + await symlink(join(parent, 'missing-target'), join(root, 'dangling')); + const tools = await createTools(root); + + for (const [path, code] of [ + ['linked-outside/new/notes.txt', 'INVALID_PATH'], + ['alias/new/notes.txt', 'INVALID_PATH'], + ['dangling/new/notes.txt', 'INVALID_PATH'], + ['file.txt/new/notes.txt', 'INVALID_PATH'], + ['../outside/new/notes.txt', 'INVALID_REQUEST'], + ['new/../../outside/notes.txt', 'INVALID_REQUEST'], + ] as const) { + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + path, + content: 'blocked', + }), + (error: unknown) => + error instanceof WorkspaceToolError && + error.code === code && + !error.mutationMayHaveCommitted, + path, + ); + } + assert.deepEqual(await readdir(outside), []); + assert.deepEqual(await readdir(join(root, 'src')), []); + assert.deepEqual((await readdir(root)).sort(), [ + 'alias', + 'dangling', + 'file.txt', + 'linked-outside', + 'src', + ]); + await assert.rejects(stat(join(parent, 'missing-target'))); + }); +} test('a write finishes once it has created parents, even if cancelled', async (t) => { const root = await realpath(await mkdtemp(join(tmpdir(), 'librechat-code-workspace-'))); @@ -1914,12 +1926,65 @@ test('a write cancelled before creating parents leaves the workspace unchanged', assert.deepEqual(await readdir(root), []); }); -test('a pathname-only workspace root reports a missing parent instead of creating it', async (t) => { - const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); +test('a pathname-only workspace root creates missing parents like a held root', async (t) => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'librechat-code-workspace-'))); t.after(() => rm(root, { recursive: true, force: true })); - const tools = await LocalWorkspaceTools.create({ - workspaces: [{ id: 'primary', root, writable: true }], - }); + const tools = await pathnameRootTools(root); + + for (const [path, overwrite] of [ + ['.librechat/attachments/.gitignore', false], + ['missing/deep/notes.txt', undefined], + ] as const) { + const result = await tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + path, + content: '*\n', + ...(overwrite === undefined ? {} : { overwrite }), + }); + assert.deepEqual(result, { + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + path, + created: true, + bytesWritten: 2, + }); + assert.equal(await readFile(join(root, ...path.split('/')), 'utf8'), '*\n'); + } + assert.equal((await stat(join(root, '.librechat', 'attachments'))).isDirectory(), true); + + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + path: '.librechat/attachments/.gitignore', + content: '!keep\n', + overwrite: false, + }), + (error: unknown) => + error instanceof WorkspaceToolError && + error.code === 'EDIT_CONFLICT' && + !error.mutationMayHaveCommitted, + 'a create never replaces a file in a folder it just created', + ); + assert.equal(await readFile(join(root, '.librechat', 'attachments', '.gitignore'), 'utf8'), '*\n'); +}); + +test('a pathname-only root that has been replaced by a symlink creates nothing', async (t) => { + const parent = await realpath( + await mkdtemp(join(tmpdir(), 'librechat-code-workspace-parent-')), + ); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(parent, 'root'); + const elsewhere = join(parent, 'elsewhere'); + await mkdir(root); + await mkdir(elsewhere); + const tools = await pathnameRootTools(root); + await rm(root, { recursive: true }); + await symlink(elsewhere, root); await assert.rejects( tools.execute({ @@ -1931,10 +1996,10 @@ test('a pathname-only workspace root reports a missing parent instead of creatin }), (error: unknown) => error instanceof WorkspaceToolError && - error.code === 'NOT_FOUND' && - error.message === 'Workspace path does not exist', + error.code === 'INVALID_PATH' && + !error.mutationMayHaveCommitted, ); - assert.deepEqual(await readdir(root), []); + assert.deepEqual(await readdir(elsewhere), []); }); test('a missing workspace path is NOT_FOUND only when reached through the workspace', async (t) => { diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 5aecbfd9..ec5a4218 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -5,7 +5,7 @@ import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path import type { FileHandle } from 'node:fs/promises'; import type { CommandFailureDiagnostic } from './command-diagnostics.js'; -import { matchesWorkspaceRoot } from './root-identity.js'; +import { captureWorkspaceRootIdentity, matchesWorkspaceRoot } from './root-identity.js'; import type { WorkspaceRootIdentity } from './root-identity.js'; import { @@ -612,6 +612,8 @@ interface WorkspaceRoot { identity?: WorkspaceRootIdentity; root: string; writable: boolean; + /** Writable, an importer is configured, and this host can create the parents an import needs. */ + imports: boolean; } async function verifyDirectoryPathHasNoSymlinks( @@ -704,8 +706,10 @@ async function classifyMissingWorkspacePath( * Creates each missing ancestor of a write target as a real directory beneath * the root, one verified level at a time, and syncs each new entry into its * parent. Creation needs a held root descriptor, so `mkdirat` is anchored to - * the root; a pathname-only root creates nothing and its write reports the - * missing parent. An existing symlink or file stops the walk. + * the root; a pathname-only root borrows one through + * {@link withAnchoredParentCreation}, and where none can be held creates + * nothing and its write reports the missing parent. An existing symlink or + * file stops the walk. * * Created directories are a committed side effect, never rolled back: once one * exists, a later failure of this write is reported as a possible mutation, so @@ -756,6 +760,82 @@ async function createMissingParentDirectories( } } +/** The identity to hold `root` by, while it is still a real directory at that exact path. */ +async function anchorableRootIdentity(root: string): Promise { + try { + const identity = await captureWorkspaceRootIdentity(root); + return identity.path === root ? identity : undefined; + } catch { + return undefined; + } +} + +/** + * Whether this host can hold `root` by descriptor, which creating a missing + * parent needs. Exercises the native `openat` path, not only the open. + */ +async function canAnchorWorkspaceRoot( + root: string, + identity?: WorkspaceRootIdentity, +): Promise { + const held = identity ?? (await anchorableRootIdentity(root)); + if (held == null) return false; + try { + return (await withWorkspaceRoot(root, held, () => realpath(root))) === root; + } catch { + return false; + } +} + +/** Whether `path` names a file whose parent directory is missing beneath `root`. */ +async function hasMissingParent(root: string, path: string): Promise { + let parent: string; + try { + parent = dirname(resolveWorkspacePath(root, path)); + } catch { + return false; + } + if (parent === root) return false; + try { + await lstat(parent); + return false; + } catch (error) { + return isMissingEntry(error); + } +} + +/** + * Runs a write or import on a pathname-only root (`--worker-dir`, `--workspace` + * or `--environment`) under a descriptor held for just this request when its + * parent is missing, so the mutation, parent creation included, takes the same + * anchored path a selected project root does and a swapped symlink cannot + * redirect it outside the root. A held root or an existing parent runs the + * mutation as before; a root that is no longer a real directory at its path + * runs it unanchored and is refused there, creating nothing. A root that + * changes between its capture and its open is refused as `INVALID_PATH`. + */ +async function withAnchoredParentCreation( + root: string, + path: string, + action: () => Promise, +): Promise { + if (holdsWorkspaceRoot() || !(await hasMissingParent(root, path))) return action(); + const identity = await anchorableRootIdentity(root); + if (identity == null) return action(); + try { + return await withWorkspaceRoot(root, identity, action); + } catch (error) { + /** + * Raised only while opening the root, before `action` ran: the root changed after its + * identity was captured. Never retry unanchored against whatever replaced it. + */ + if (error instanceof WorkspaceRootAccessError) { + throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); + } + throw error; + } +} + function afterCreatedDirectories( created: readonly string[], error: WorkspaceToolError, @@ -2295,16 +2375,43 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { options: LocalWorkspaceToolsOptions, ): Promise { const roots = new Map(); + for (const workspace of options.workspaces) { + let canonicalRoot: string; + try { + canonicalRoot = await realpath(workspace.root); + if (workspace.identity && !await matchesWorkspaceRoot(canonicalRoot, workspace.identity)) throw new Error(); + await withWorkspaceRoot(canonicalRoot, workspace.identity, async () => undefined); + if (!(await stat(canonicalRoot)).isDirectory()) throw new Error(); + } catch { + throw new WorkspaceToolError( + 'Invalid workspace registration', + 'REGISTRATION_INVALID', + ); + } + const writable = workspace.writable === true; + roots.set(workspace.id, { + identity: workspace.identity, + root: canonicalRoot, + writable, + /** Imports land in folders that may not exist yet, so only where parents can be created. */ + imports: + writable && + options.importer != null && + (await canAnchorWorkspaceRoot(canonicalRoot, workspace.identity)), + }); + } const anyWritable = options.workspaces.some( (workspace) => workspace.writable === true, ); - const mutations = [ - ...WRITE_OPERATIONS, - ...(options.importer != null ? IMPORT_OPERATIONS : []), + const anyImports = [...roots.values()].some((root) => root.imports); + const mutationsFor = (root: WorkspaceRoot | undefined) => [ + ...(root?.writable ? WRITE_OPERATIONS : []), + ...(root?.imports ? IMPORT_OPERATIONS : []), ]; const operations: BridgeWorkspaceToolCapabilities['operations'] = [ ...READ_OPERATIONS, - ...(anyWritable ? mutations : []), + ...(anyWritable ? WRITE_OPERATIONS : []), + ...(anyImports ? IMPORT_OPERATIONS : []), ]; const workspaces: BridgeWorkspaceDescriptor[] = options.workspaces.map( (workspace) => ({ @@ -2314,7 +2421,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { ? { operations: [ ...READ_OPERATIONS, - ...(workspace.writable === true ? mutations : []), + ...mutationsFor(roots.get(workspace.id)), ], } : {}), @@ -2337,25 +2444,6 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { 'REGISTRATION_INVALID', ); } - for (const workspace of options.workspaces) { - let canonicalRoot: string; - try { - canonicalRoot = await realpath(workspace.root); - if (workspace.identity && !await matchesWorkspaceRoot(canonicalRoot, workspace.identity)) throw new Error(); - await withWorkspaceRoot(canonicalRoot, workspace.identity, async () => undefined); - if (!(await stat(canonicalRoot)).isDirectory()) throw new Error(); - } catch { - throw new WorkspaceToolError( - 'Invalid workspace registration', - 'REGISTRATION_INVALID', - ); - } - roots.set(workspace.id, { - identity: workspace.identity, - root: canonicalRoot, - writable: workspace.writable === true, - }); - } const tools = new LocalWorkspaceTools( roots, operations, @@ -2364,7 +2452,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { capabilities.editFileModes, capabilities.editFileFeatures, capabilities.listFileFeatures, - anyWritable ? options.importer : undefined, + anyImports ? options.importer : undefined, ); tools.repositoryInstructions = options.repositoryInstructions === true; return tools; @@ -2413,13 +2501,16 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { 'WRITE_DISABLED', ); } - if (this.importer == null) { + const { importer } = this; + if (importer == null || !workspace.imports) { throw new WorkspaceToolError( 'Workspace file import is not configured on this worker', 'WRITE_UNAVAILABLE', ); } - return importWorkspaceFile(root, request, this.importer, signal); + return withAnchoredParentCreation(root, request.path, () => + importWorkspaceFile(root, request, importer, signal), + ); } if ( @@ -2440,7 +2531,9 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { ); } if (request.operation === 'write_file') { - return writeWorkspaceFile(root, request, signal); + return withAnchoredParentCreation(root, request.path, () => + writeWorkspaceFile(root, request, signal), + ); } return request.operation === 'preview_edit' ? previewWorkspaceEdit(root, request, signal) diff --git a/packages/extraction/Dockerfile b/packages/extraction/Dockerfile index cfc48c14..0ae3d5ce 100644 --- a/packages/extraction/Dockerfile +++ b/packages/extraction/Dockerfile @@ -6,8 +6,8 @@ COPY packages/extraction/tsconfig.json ./ COPY packages/extraction/src ./src RUN npm run build RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev linux-libc-dev && rm -rf /var/lib/apt/lists/* -COPY packages/extraction/native/guard.c ./guard.c -RUN gcc -O2 -Wall -Wextra -Werror -o guard guard.c +COPY packages/extraction/native/guard.c packages/extraction/native/gateway.c ./ +RUN gcc -O2 -Wall -Wextra -Werror -o guard guard.c && gcc -O2 -Wall -Wextra -Werror -o gateway gateway.c COPY packages/extraction/tests/block-landlock.c ./block-landlock.c RUN gcc -O2 -Wall -Wextra -Werror -o block-landlock block-landlock.c @@ -17,14 +17,16 @@ COPY packages/extraction/worker/requirements.txt /opt/extraction/requirements.tx RUN pip install --no-cache-dir -r /opt/extraction/requirements.txt COPY --from=build /usr/local/bin/node /usr/local/bin/node COPY --from=build --chmod=0555 /build/guard /opt/extraction/guard +COPY --from=build --chmod=0555 /build/gateway /opt/extraction/gateway COPY --from=build --chmod=0555 /build/dist /opt/extraction/dist COPY --chmod=0444 packages/extraction/package.json /opt/extraction/package.json COPY --chmod=0444 packages/extraction/worker/extract.py /opt/extraction/extract.py RUN groupadd -g 10001 extraction && useradd -u 10001 -g extraction -M extraction && \ - mkdir /socket /jobs && chown extraction:extraction /socket /jobs && chmod 0700 /jobs && chmod 0770 /socket + mkdir -p /run/extraction /jobs && chown extraction:extraction /run/extraction /jobs && chmod 0700 /jobs && chmod 0755 /run/extraction USER 10001:10001 WORKDIR /opt/extraction -HEALTHCHECK --interval=10s --timeout=2s --start-period=10s --retries=3 CMD ["node", "dist/health.js"] +HEALTHCHECK --interval=10s --timeout=2s --start-period=10s --retries=3 CMD ["/opt/extraction/gateway", "node", "dist/health.js"] +ENTRYPOINT ["/opt/extraction/gateway"] CMD ["node", "dist/main.js"] FROM runtime AS qualification diff --git a/packages/extraction/compose.yaml b/packages/extraction/compose.yaml index f6bbf9f4..87d90964 100644 --- a/packages/extraction/compose.yaml +++ b/packages/extraction/compose.yaml @@ -1,22 +1,47 @@ services: + extraction-init: + image: ${EXTRACTION_IMAGE:-librechat-extraction:local} + build: + context: ../.. + dockerfile: packages/extraction/Dockerfile + target: runtime + command: [node, dist/provision.js] + user: '10001:10001' + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + network_mode: none + mem_limit: 128m + cpus: 1 + pids_limit: 16 + healthcheck: + disable: true + volumes: + - extraction-token:/run/extraction extraction: image: ${EXTRACTION_IMAGE:-librechat-extraction:local} build: context: ../.. dockerfile: packages/extraction/Dockerfile target: runtime + depends_on: + extraction-init: + condition: service_completed_successfully init: true user: '10001:10001' read_only: true cap_drop: [ALL] security_opt: [no-new-privileges:true] - network_mode: none + networks: [extraction] mem_limit: 768m cpus: 2 pids_limit: 32 tmpfs: - /jobs:rw,noexec,nosuid,nodev,size=96m,nr_inodes=1024,uid=10001,gid=10001,mode=0700 volumes: - - extraction-socket:/socket + - extraction-token:/run/extraction:ro +networks: + extraction: + internal: true volumes: - extraction-socket: + extraction-token: diff --git a/packages/extraction/native/gateway.c b/packages/extraction/native/gateway.c new file mode 100644 index 00000000..a91bbaa7 --- /dev/null +++ b/packages/extraction/native/gateway.c @@ -0,0 +1,63 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__x86_64__) +#define ARCH AUDIT_ARCH_X86_64 +#elif defined(__aarch64__) +#define ARCH AUDIT_ARCH_AARCH64 +#else +#error Unsupported architecture +#endif + +#define DENY(nr) BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, nr, 0, 1), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM) +int main(int argc, char **argv) { + if (argc < 2 || geteuid() == 0) goto fail; + /* No inherited socket, including a caller-supplied socket on standard IO. */ + for (int fd = 0; fd < 3; fd++) { + struct stat stat; + if (fstat(fd, &stat)) goto fail; + if (S_ISSOCK(stat.st_mode)) { + int null = open("/dev/null", O_RDWR | O_CLOEXEC); + if (null < 0 || dup2(null, fd) < 0) goto fail; + close(null); + } + } + if (syscall(SYS_close_range, 3, ~0U, 0)) goto fail; + struct sock_filter filter[] = { + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, ARCH, 1, 0), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), +#ifdef __x86_64__ + BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0x40000000, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), +#endif + DENY(__NR_socketpair), + DENY(__NR_sendmsg), DENY(__NR_recvmsg), DENY(__NR_sendmmsg), DENY(__NR_recvmmsg), + /* Only IP sockets. Docker's default filter remains in force underneath. */ + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_socket, 0, 5), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0])), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AF_INET, 2, 0), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AF_INET6, 1, 0), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW) + }; + struct sock_fprog program = {.len = sizeof(filter) / sizeof(filter[0]), .filter = filter}; + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) || prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program)) goto fail; + execvp(argv[1], &argv[1]); +fail: + fputs("EXTRACTION_GATEWAY_UNAVAILABLE\n", stderr); + return 125; +} diff --git a/packages/extraction/native/guard.c b/packages/extraction/native/guard.c index ee1d2cbc..77d11e91 100644 --- a/packages/extraction/native/guard.c +++ b/packages/extraction/native/guard.c @@ -59,6 +59,9 @@ static void restrict_syscalls(void) { BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), #endif DENY(__NR_socket), DENY(__NR_socketpair), DENY(__NR_connect), + DENY(__NR_sendmsg), DENY(__NR_recvmsg), DENY(__NR_sendmmsg), DENY(__NR_recvmmsg), + DENY(__NR_sendto), DENY(__NR_recvfrom), DENY(__NR_setsockopt), DENY(__NR_getsockopt), + DENY(__NR_shutdown), DENY(__NR_getsockname), DENY(__NR_getpeername), DENY(__NR_bind), DENY(__NR_listen), DENY(__NR_accept), DENY(__NR_accept4), DENY(__NR_clone), DENY(__NR_clone3), #ifdef __NR_fork @@ -119,6 +122,11 @@ static void restrict_syscalls(void) { int main(int argc, char **argv) { if (argc != 5 || geteuid() == 0) fail(); /* Paths are supervisor-owned, never supplied in the HTTP protocol. */ + struct stat input, output, errors, null; + if (fstat(0, &input) || fstat(1, &output) || fstat(2, &errors) || stat("/dev/null", &null)) fail(); + if (!S_ISCHR(input.st_mode) || input.st_rdev != null.st_rdev || + !S_ISCHR(errors.st_mode) || errors.st_rdev != null.st_rdev || + !S_ISREG(output.st_mode) || output.st_nlink != 1 || output.st_size != 0) fail(); if (chdir(argv[1])) fail(); pid_t parent = getppid(); if (parent == 1 || prctl(PR_SET_PDEATHSIG, SIGKILL) || getppid() != parent) fail(); diff --git a/packages/extraction/src/backend.ts b/packages/extraction/src/backend.ts index a3831c65..b6d3c430 100644 --- a/packages/extraction/src/backend.ts +++ b/packages/extraction/src/backend.ts @@ -46,67 +46,79 @@ export class RestrictedBackend implements Backend { signal: AbortSignal ): Promise { signal.throwIfAborted(); - const child = spawn( - this.runtime.guard, - [job, this.runtime.python, this.runtime.worker, mode], - { - detached: true, - stdio: ['ignore', 'pipe', 'pipe'], - env: {}, - } + const status = await open( + join(job, 'status'), + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW | + constants.O_RDWR, + 0o600 ); - let output = ''; - let count = 0; - let overflow = false; - const kill = () => { - if ( - child.pid && - child.exitCode === null && - child.signalCode === null - ) { - try { - process.kill(-child.pid, 'SIGKILL'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ESRCH') - this.healthy = false; + try { + signal.throwIfAborted(); + const child = spawn( + this.runtime.guard, + [job, this.runtime.python, this.runtime.worker, mode], + { + detached: true, + stdio: ['ignore', status.fd, 'ignore'], + env: {}, } - } - }; - signal.addEventListener('abort', kill, { once: true }); - if (signal.aborted) kill(); - let spawnError = false; - child.on('error', () => { - spawnError = true; - }); - child.stdout.on('data', (chunk: Buffer) => { - count += chunk.length; - if (count > 1024) { - overflow = true; - kill(); - } else output += chunk.toString('ascii'); - }); - child.stderr.on('data', (chunk: Buffer) => { - count += chunk.length; - if (count > 2048) { - overflow = true; - kill(); - } - }); - const code = await new Promise(resolve => - child.once('close', resolve) - ); - signal.removeEventListener('abort', kill); - signal.throwIfAborted(); - if (spawnError || overflow) throw new ExtractionError('UNAVAILABLE'); - if (code !== 0) { - const value = output.trim(); - if (parserErrors.has(value)) - throw new ExtractionError(value as 'INVALID_DOCUMENT'); - throw new ExtractionError( - code === null ? 'RESOURCE_LIMIT' : 'UNAVAILABLE' ); + const kill = () => { + if ( + child.pid && + child.exitCode === null && + child.signalCode === null + ) { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') + this.healthy = false; + } + } + }; + signal.addEventListener('abort', kill, { once: true }); + if (signal.aborted) kill(); + let spawnError = false; + child.on('error', () => { + spawnError = true; + }); + const code = await new Promise(resolve => + child.once('close', resolve) + ); + signal.removeEventListener('abort', kill); + signal.throwIfAborted(); + const stat = await status.stat(); + if ( + spawnError || + !stat.isFile() || + stat.nlink !== 1 || + stat.size > 1024 + ) + throw new ExtractionError('UNAVAILABLE'); + const buffer = Buffer.alloc(1025); + const { bytesRead } = await status.read( + buffer, + 0, + buffer.length, + 0 + ); + if (bytesRead > 1024) throw new ExtractionError('UNAVAILABLE'); + const output = buffer.toString('ascii', 0, bytesRead); + if (code !== 0) { + const value = output.trim(); + if (parserErrors.has(value)) + throw new ExtractionError(value as 'INVALID_DOCUMENT'); + throw new ExtractionError( + code === null ? 'RESOURCE_LIMIT' : 'UNAVAILABLE' + ); + } + return output; + } finally { + await status.close(); } - return output; } async probe(): Promise { const job = await this.create(); @@ -131,7 +143,7 @@ export class RestrictedBackend implements Backend { await this.execute(job, format, signal); const file = await open( join(job, 'result'), - constants.O_RDONLY | constants.O_NOFOLLOW + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK ); try { const stat = await file.stat(); @@ -142,7 +154,11 @@ export class RestrictedBackend implements Backend { ) { throw new ExtractionError('OUTPUT_LIMIT'); } - const bytes = await file.readFile(); + const buffer = Buffer.alloc(stat.size + 1); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + if (bytesRead > policy.resultBytes) + throw new ExtractionError('OUTPUT_LIMIT'); + const bytes = buffer.subarray(0, bytesRead); signal.throwIfAborted(); validateResult(bytes, format, digest); return bytes; diff --git a/packages/extraction/src/config.ts b/packages/extraction/src/config.ts new file mode 100644 index 00000000..f2d94238 --- /dev/null +++ b/packages/extraction/src/config.ts @@ -0,0 +1,9 @@ +import { isIP } from 'node:net'; + +export function endpoint() { + const host = process.env.EXTRACTION_HOST ?? '0.0.0.0'; + const port = process.env.EXTRACTION_PORT ?? '8000'; + if (!isIP(host) || !/^[1-9]\d{0,4}$/.test(port) || Number(port) > 65535) + throw new Error('INVALID_ENDPOINT'); + return { host, port: Number(port) }; +} diff --git a/packages/extraction/src/credential.test.ts b/packages/extraction/src/credential.test.ts new file mode 100644 index 00000000..5298c089 --- /dev/null +++ b/packages/extraction/src/credential.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { test } from 'node:test'; +import { provision } from './provision.js'; +import { readToken } from './credential.js'; +import { endpoint } from './config.js'; + +test('atomic provisioning is concurrent, idempotent and restart safe', async () => { + const root = await mkdtemp(join(tmpdir(), 'credential-')); + await chmod(root, 0o755); + const path = join(root, 'token'); + try { + await Promise.all(Array.from({ length: 8 }, () => provision(path))); + const token = await readToken(path); + assert.match(token, /^[a-f0-9]{64}$/); + await provision(path); + assert.equal(await readToken(path), token); + } finally { + await rm(root, { recursive: true }); + } +}); + +test('existing invalid tokens, symlinks and non-files fail closed without regeneration', async () => { + const root = await mkdtemp(join(tmpdir(), 'credential-')); + await chmod(root, 0o755); + try { + const target = join(root, 'valid'); + await writeFile(target, 'a'.repeat(64), { mode: 0o444 }); + for (const [name, value, mode] of [ + ['short', 'a', 0o444], + ['uppercase', 'A'.repeat(64), 0o444], + ['oversize', 'a'.repeat(4096), 0o444], + ['writable', 'a'.repeat(64), 0o644], + ] as const) { + const path = join(root, name); + await writeFile(path, value, { mode }); + await assert.rejects(provision(path)); + assert.equal(await readFile(path, 'utf8'), value); + } + for (const [name, destination] of [ + ['link', target], + ['dangling', join(root, 'missing')], + ]) { + const path = join(root, name!); + await symlink(destination!, path); + await assert.rejects(provision(path)); + } + const directory = join(root, 'directory'); + await mkdir(directory); + await assert.rejects(readToken(directory)); + } finally { + await rm(root, { recursive: true }); + } +}); + +test('endpoint defaults and deployment overrides are bounded literal IP addresses', () => { + const previous = { + host: process.env.EXTRACTION_HOST, + port: process.env.EXTRACTION_PORT, + }; + try { + delete process.env.EXTRACTION_HOST; + delete process.env.EXTRACTION_PORT; + assert.deepEqual(endpoint(), { host: '0.0.0.0', port: 8000 }); + process.env.EXTRACTION_HOST = '::'; + process.env.EXTRACTION_PORT = '9000'; + assert.deepEqual(endpoint(), { host: '::', port: 9000 }); + for (const port of ['0', '65536', '08', '-1', 'x']) { + process.env.EXTRACTION_PORT = port; + assert.throws(endpoint); + } + process.env.EXTRACTION_PORT = '8000'; + process.env.EXTRACTION_HOST = '/socket/forbidden'; + assert.throws(endpoint); + } finally { + for (const [key, value] of [ + ['EXTRACTION_HOST', previous.host], + ['EXTRACTION_PORT', previous.port], + ]) { + if (value === undefined) delete process.env[key!]; + else process.env[key!] = value; + } + } +}); diff --git a/packages/extraction/src/credential.ts b/packages/extraction/src/credential.ts new file mode 100644 index 00000000..afee844f --- /dev/null +++ b/packages/extraction/src/credential.ts @@ -0,0 +1,25 @@ +import { constants } from 'node:fs'; +import { open } from 'node:fs/promises'; + +export const tokenFile = () => + process.env.EXTRACTION_TOKEN_FILE ?? '/run/extraction/token'; + +export async function readToken(path: string = tokenFile()): Promise { + const file = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + try { + const stat = await file.stat(); + if (!stat.isFile() || stat.size !== 64 || (stat.mode & 0o777) !== 0o444) + throw new Error('INVALID_TOKEN_FILE'); + const bytes = Buffer.alloc(65); + const { bytesRead } = await file.read(bytes, 0, bytes.length, 0); + const token = bytes.toString('utf8', 0, bytesRead); + if (bytesRead !== 64 || !/^[a-f0-9]{64}$/.test(token)) + throw new Error('INVALID_TOKEN_FILE'); + return token; + } finally { + await file.close(); + } +} diff --git a/packages/extraction/src/health.ts b/packages/extraction/src/health.ts index 641575c4..fa18be07 100644 --- a/packages/extraction/src/health.ts +++ b/packages/extraction/src/health.ts @@ -1,15 +1,26 @@ import { request } from 'node:http'; -const req = request( - { - socketPath: '/socket/extraction.sock', - path: '/v1/capabilities', - timeout: 1000, - }, - res => { - res.resume(); - res.once('end', () => process.exit(res.statusCode === 200 ? 0 : 1)); - } -); -req.on('timeout', () => req.destroy()); -req.on('error', () => process.exit(1)); -req.end(); +import { endpoint } from './config.js'; +import { readToken } from './credential.js'; + +try { + const { host, port } = endpoint(); + const req = request( + { + host: + host === '::' ? '::1' : host === '0.0.0.0' ? '127.0.0.1' : host, + port, + path: '/v1/capabilities', + headers: { Authorization: `Bearer ${await readToken()}` }, + timeout: 1000, + }, + res => { + res.resume(); + res.once('end', () => process.exit(res.statusCode === 200 ? 0 : 1)); + } + ); + req.on('timeout', () => req.destroy()); + req.on('error', () => process.exit(1)); + req.end(); +} catch { + process.exit(1); +} diff --git a/packages/extraction/src/main.ts b/packages/extraction/src/main.ts index 424a6ed6..2d103a81 100644 --- a/packages/extraction/src/main.ts +++ b/packages/extraction/src/main.ts @@ -1,9 +1,9 @@ -import { chmod, lstat, readdir, unlink } from 'node:fs/promises'; -import { connect } from 'node:net'; +import { readdir } from 'node:fs/promises'; import { RestrictedBackend } from './backend.js'; +import { endpoint } from './config.js'; +import { readToken } from './credential.js'; import { extractionServer } from './server.js'; -// Deployment-owned locations, not caller-selected paths or runtimes. const backend = new RestrictedBackend({ jobs: '/jobs', guard: '/opt/extraction/guard', @@ -11,46 +11,16 @@ const backend = new RestrictedBackend({ worker: '/opt/extraction/extract.py', }); try { + const address = endpoint(); + const token = await readToken(); if ((await readdir('/jobs')).length) throw new Error('DIRTY_SCRATCH'); - try { - const stat = await lstat('/socket/extraction.sock'); - if (!stat.isSocket()) throw new Error('INVALID_SOCKET'); - await new Promise((resolve, reject) => { - const client = connect('/socket/extraction.sock'); - client.setTimeout(1000, () => { - client.destroy(); - reject(new Error('SOCKET_EXISTS')); - }); - client.once('connect', () => { - client.destroy(); - reject(new Error('SOCKET_EXISTS')); - }); - client.once('error', error => { - if ((error as NodeJS.ErrnoException).code === 'ECONNREFUSED') - resolve(); - else reject(error); - }); - }); - await unlink('/socket/extraction.sock'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } await backend.probe(); - const server = extractionServer(backend); + const server = extractionServer(backend, token); server.on('error', () => { console.error('EXTRACTION_UNAVAILABLE'); process.exit(1); }); - process.umask(0o007); - server.listen('/socket/extraction.sock', async () => { - try { - await chmod('/socket/extraction.sock', 0o660); - } catch { - console.error('EXTRACTION_UNAVAILABLE'); - process.exit(1); - } - console.log('Extraction service ready'); - }); + server.listen(address, () => console.log('Extraction service ready')); const shutdown = () => { server.close(); server.closeAllConnections(); diff --git a/packages/extraction/src/provision.ts b/packages/extraction/src/provision.ts new file mode 100644 index 00000000..0bdc329b --- /dev/null +++ b/packages/extraction/src/provision.ts @@ -0,0 +1,70 @@ +import { randomBytes } from 'node:crypto'; +import { constants } from 'node:fs'; +import { link, lstat, open, unlink } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { readToken, tokenFile } from './credential.js'; + +export async function provision(path: string = tokenFile()): Promise { + try { + await readToken(path); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + const directory = dirname(path); + const stat = await lstat(directory); + if (!stat.isDirectory() || (stat.mode & 0o777) !== 0o755) + throw new Error('INVALID_TOKEN_DIRECTORY'); + const temporary = join( + directory, + `.token-${randomBytes(16).toString('hex')}` + ); + const file = await open( + temporary, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + 0o600 + ); + try { + try { + await file.writeFile(randomBytes(32).toString('hex'), 'ascii'); + await file.chmod(0o444); + await file.sync(); + } finally { + await file.close(); + } + try { + // Publish complete bytes without replacing a concurrent provisioner's token. + await link(temporary, path); + const directoryFile = await open( + directory, + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW + ); + try { + await directoryFile.sync(); + } finally { + await directoryFile.close(); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + await readToken(path); + } finally { + await unlink(temporary); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + provision().catch(() => { + console.error('EXTRACTION_PROVISION_UNAVAILABLE'); + process.exitCode = 1; + }); +} diff --git a/packages/extraction/src/server.test.ts b/packages/extraction/src/server.test.ts index 60f9bc63..8aa55c37 100644 --- a/packages/extraction/src/server.test.ts +++ b/packages/extraction/src/server.test.ts @@ -27,15 +27,15 @@ async function fixture(deadline = 500) { await rm(job, { recursive: true }); }, }; - const server = extractionServer(backend, deadline); - const socketPath = join(root, 'service.sock'); - server.listen(socketPath); + const server = extractionServer(backend, 'a'.repeat(64), deadline); + server.listen(0, '127.0.0.1'); await once(server, 'listening'); + const port = (server.address() as import('node:net').AddressInfo).port; return { backend, events, root, - socketPath, + port, server, close: async () => { server.closeAllConnections(); @@ -45,23 +45,25 @@ async function fixture(deadline = 500) { }; } const headers = { + Authorization: `Bearer ${'a'.repeat(64)}`, 'Content-Type': 'application/octet-stream', 'Content-Length': '4', 'X-Extraction-Version': '1', 'X-Extraction-Format': 'pdf', }; -async function send( - socketPath: string, - overrides = {}, - path = '/v1/extract-text' -) { +async function send(port: number, overrides = {}, path = '/v1/extract-text') { return new Promise<{ status: number; body: string }>((resolve, reject) => { const req = request( { - socketPath, + host: '127.0.0.1', + port, path, - method: 'POST', - headers: { ...headers, ...overrides }, + method: path === '/v1/capabilities' ? 'GET' : 'POST', + headers: Object.fromEntries( + Object.entries({ ...headers, ...overrides }).filter( + ([, value]) => value !== undefined + ) + ), }, res => { let body = ''; @@ -78,9 +80,9 @@ async function send( test('binary upload executes once and cleanup completes before success', async () => { const f = await fixture(); try { - assert.equal((await send(f.socketPath)).status, 200); + assert.equal((await send(f.port)).status, 200); assert.deepEqual(f.events, ['create', 'run', 'cleanup']); - assert.deepEqual(await readdir(f.root), ['service.sock']); + assert.deepEqual(await readdir(f.root), []); } finally { await f.close(); } @@ -94,15 +96,15 @@ test('closed parameters and declared input limit are rejected before admission', { 'X-Extraction-Url': 'https://example.com' }, { 'Content-Type': 'text/plain' }, ]) { - assert.equal((await send(f.socketPath, changes)).status, 400); + assert.equal((await send(f.port, changes)).status, 400); } assert.equal( - (await send(f.socketPath, {}, '/v1/extract-text?path=x')).status, + (await send(f.port, {}, '/v1/extract-text?path=x')).status, 404 ); assert.equal( ( - await send(f.socketPath, { + await send(f.port, { 'Content-Length': String(policy.inputBytes + 1), }) ).status, @@ -120,8 +122,8 @@ test('cleanup failure suppresses success and quarantines backend', async () => { throw new ExtractionError('UNAVAILABLE'); }; try { - assert.equal((await send(f.socketPath)).status, 503); - assert.equal((await send(f.socketPath)).status, 503); + assert.equal((await send(f.port)).status, 503); + assert.equal((await send(f.port)).status, 503); assert.equal(f.events.filter(e => e === 'run').length, 1); } finally { await f.close(); @@ -146,7 +148,7 @@ test('deadline aborts execution and waits for its close before cleanup', async ( return Buffer.alloc(0); }; try { - assert.equal((await send(f.socketPath)).status, 504); + assert.equal((await send(f.port)).status, 504); assert.deepEqual(f.events, ['create', 'closed', 'cleanup']); } finally { await f.close(); @@ -157,7 +159,8 @@ test('upload disconnect and stalled upload both release scratch and admission', try { for (const disconnect of [true, false]) { const req = request({ - socketPath: f.socketPath, + host: '127.0.0.1', + port: f.port, path: '/v1/extract-text', method: 'POST', headers, @@ -168,10 +171,10 @@ test('upload disconnect and stalled upload both release scratch and admission', await new Promise(resolve => setTimeout(resolve, 20)); if (disconnect) req.destroy(); await new Promise(resolve => setTimeout(resolve, 80)); - assert.deepEqual(await readdir(f.root), ['service.sock']); + assert.deepEqual(await readdir(f.root), []); } assert.equal(f.events.includes('run'), false); - assert.equal((await send(f.socketPath)).status, 200); + assert.equal((await send(f.port)).status, 200); } finally { await f.close(); } @@ -188,11 +191,11 @@ test('two active jobs bound concurrency without a queue', async () => { return Buffer.alloc(0); }; try { - const a = send(f.socketPath), - b = send(f.socketPath); + const a = send(f.port), + b = send(f.port); while (active !== 2) await new Promise(resolve => setTimeout(resolve, 5)); - assert.equal((await send(f.socketPath)).status, 429); + assert.equal((await send(f.port)).status, 429); assert.equal((await a).status, 504); assert.equal((await b).status, 504); } finally { @@ -205,7 +208,8 @@ test('deadline closes stalled output readers while keeping their slots admitted' const clients = [0, 1].map(() => { const req = request( { - socketPath: f.socketPath, + host: '127.0.0.1', + port: f.port, path: '/v1/extract-text', method: 'POST', headers, @@ -222,10 +226,10 @@ test('deadline closes stalled output readers while keeping their slots admitted' try { while (f.events.filter(e => e === 'cleanup').length !== 2) await new Promise(resolve => setTimeout(resolve, 5)); - assert.equal((await send(f.socketPath)).status, 429); + assert.equal((await send(f.port)).status, 429); await new Promise(resolve => setTimeout(resolve, 300)); f.backend.run = async () => Buffer.from('{}'); - assert.equal((await send(f.socketPath)).status, 200); + assert.equal((await send(f.port)).status, 200); } finally { clients.forEach(client => client.destroy()); await f.close(); @@ -236,7 +240,7 @@ test('duplicate extraction control headers are rejected before worker creation', try { assert.equal( ( - await send(f.socketPath, { + await send(f.port, { 'X-Extraction-Format': ['pdf', 'pdf'], }) ).status, @@ -254,13 +258,95 @@ test('unsupported document content returns a safe failure after cleanup', async throw new ExtractionError('UNSUPPORTED_CONTENT'); }; try { - const response = await send(f.socketPath); + const response = await send(f.port); assert.equal(response.status, 422); assert.deepEqual(JSON.parse(response.body), { error: { code: 'UNSUPPORTED_CONTENT' }, }); assert.deepEqual(f.events, ['create', 'cleanup']); - assert.deepEqual(await readdir(f.root), ['service.sock']); + assert.deepEqual(await readdir(f.root), []); + } finally { + await f.close(); + } +}); + +test('authentication fails before validation, health checks or admission', async () => { + const f = await fixture(); + f.backend.healthy = false; + try { + for (const authorization of [ + undefined, + '', + `Bearer ${'b'.repeat(64)}`, + `Bearer ${'A'.repeat(64)}`, + [`Bearer ${'a'.repeat(64)}`, `Bearer ${'a'.repeat(64)}`], + ]) { + for (const path of ['/v1/capabilities', '/v1/extract-text']) { + const response = await send( + f.port, + { + Authorization: authorization, + 'X-Extraction-Format': 'invalid', + }, + path + ); + assert.equal(response.status, 401); + assert.deepEqual(JSON.parse(response.body), { + error: { code: 'UNAUTHORIZED' }, + }); + } + } + assert.deepEqual(f.events, []); + const response = await new Promise((resolve, reject) => { + const req = request( + { + host: '127.0.0.1', + port: f.port, + path: '/v1/capabilities', + headers: { Authorization: headers.Authorization }, + }, + res => { + res.resume(); + res.on('end', () => resolve(res.statusCode!)); + } + ); + req.on('error', reject); + req.end(); + }); + assert.equal(response, 503); + } finally { + await f.close(); + } +}); + +test('unauthenticated expectations and stalled bodies close without admission', async () => { + const f = await fixture(); + try { + for (const expectation of [undefined, '100-continue', 'unsupported']) { + await new Promise((resolve, reject) => { + const req = request( + { + host: '127.0.0.1', + port: f.port, + method: 'POST', + path: '/v1/extract-text', + headers: { + 'Content-Length': '1000', + ...(expectation ? { Expect: expectation } : {}), + }, + }, + res => { + assert.equal(res.statusCode, 401); + assert.equal(res.headers.connection, 'close'); + res.resume(); + res.once('end', resolve); + } + ); + req.on('error', reject); + req.flushHeaders(); + }); + } + assert.deepEqual(f.events, []); } finally { await f.close(); } diff --git a/packages/extraction/src/server.ts b/packages/extraction/src/server.ts index b3bd37c8..54428438 100644 --- a/packages/extraction/src/server.ts +++ b/packages/extraction/src/server.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto'; +import { createHash, timingSafeEqual } from 'node:crypto'; import { createServer } from 'node:http'; import { open } from 'node:fs/promises'; import { join } from 'node:path'; @@ -95,8 +95,31 @@ function requestFormat(req: IncomingMessage): Format { } export function extractionServer( backend: Backend, + token: string, deadlineMs: number = policy.deadlineMs ) { + if (!/^[a-f0-9]{64}$/.test(token)) throw new Error('INVALID_TOKEN'); + const expected = Buffer.from(`Bearer ${token}`, 'ascii'); + function authorized(req: IncomingMessage): boolean { + let count = 0; + for (let i = 0; i < req.rawHeaders.length; i += 2) + if (req.rawHeaders[i]!.toLowerCase() === 'authorization') count++; + const value = req.headers.authorization; + if ( + count !== 1 || + typeof value !== 'string' || + value.length !== expected.length + ) + return false; + const actual = Buffer.from(value, 'utf8'); + return ( + actual.length === expected.length && + timingSafeEqual(actual, expected) + ); + } + function unauthorized(res: ServerResponse): void { + reply(res, 401, Buffer.from('{"error":{"code":"UNAUTHORIZED"}}')); + } if ( !Number.isInteger(deadlineMs) || deadlineMs <= 0 || @@ -120,12 +143,18 @@ export function extractionServer( server.requestTimeout = policy.deadlineMs; server.setTimeout(policy.deadlineMs, socket => socket.destroy()); server.on('checkContinue', (req, res) => { + if (!authorized(req)) return unauthorized(res); + reply(res, 400, Buffer.from('{"error":{"code":"INVALID_REQUEST"}}')); + }); + server.on('checkExpectation', (req, res) => { + if (!authorized(req)) return unauthorized(res); reply(res, 400, Buffer.from('{"error":{"code":"INVALID_REQUEST"}}')); }); async function handle( req: IncomingMessage, res: ServerResponse ): Promise { + if (!authorized(req)) return unauthorized(res); if (req.method === 'GET' && req.url === '/v1/capabilities') { reply( res, diff --git a/packages/extraction/tests/block-landlock.c b/packages/extraction/tests/block-landlock.c index 1185ec95..c4f5fbee 100644 --- a/packages/extraction/tests/block-landlock.c +++ b/packages/extraction/tests/block-landlock.c @@ -7,15 +7,18 @@ #include /* Negative startup probe: add a restriction to Docker's existing filter. */ -int main(void) { +int main(int argc, char **argv) { + (void)argv; + int gateway = argc == 2; struct sock_filter filter[] = { BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), - BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_landlock_create_ruleset, 0, 1), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, gateway ? SYS_prctl : SYS_landlock_create_ruleset, 0, 1), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | 38), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW) }; struct sock_fprog program = {.len = sizeof(filter) / sizeof(filter[0]), .filter = filter}; if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) || prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program)) return 125; - execl("/usr/local/bin/node", "node", "/opt/extraction/dist/main.js", (char *)NULL); + if (gateway) execl("/opt/extraction/gateway", "gateway", "node", "dist/main.js", (char *)NULL); + else execl("/usr/local/bin/node", "node", "/opt/extraction/dist/main.js", (char *)NULL); return 125; } diff --git a/packages/extraction/tests/container-client.mjs b/packages/extraction/tests/container-client.mjs index 14e70f74..d053a9e9 100644 --- a/packages/extraction/tests/container-client.mjs +++ b/packages/extraction/tests/container-client.mjs @@ -3,21 +3,27 @@ import { readFileSync } from 'node:fs'; import { request } from 'node:http'; import { createHash } from 'node:crypto'; -const socketPath = '/socket/extraction.sock'; +const host = process.env.EXTRACTION_HOST ?? 'extraction'; +const token = readFileSync('/run/extraction/token', 'ascii'); function send(format, bytes, path = '/v1/extract-text', extra = {}) { return new Promise((resolve, reject) => { const req = request( { - socketPath, + host, + port: 8000, path, - method: 'POST', - headers: { - 'Content-Type': 'application/octet-stream', - 'Content-Length': bytes.length, - 'X-Extraction-Version': '1', - 'X-Extraction-Format': format, - ...extra, - }, + method: path === '/v1/capabilities' ? 'GET' : 'POST', + family: 4, + headers: Object.fromEntries( + Object.entries({ + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/octet-stream', + 'Content-Length': bytes.length, + 'X-Extraction-Version': '1', + 'X-Extraction-Format': format, + ...extra, + }).filter(([, value]) => value !== undefined) + ), }, res => { const chunks = []; @@ -37,6 +43,15 @@ function send(format, bytes, path = '/v1/extract-text', extra = {}) { req.end(bytes); }); } +for (const Authorization of [undefined, `Bearer ${'0'.repeat(64)}`]) { + for (const path of ['/v1/capabilities', '/v1/extract-text']) { + const response = await send('pdf', Buffer.from('test'), path, { + Authorization, + }); + assert.equal(response.status, 401); + assert.deepEqual(response.value, { error: { code: 'UNAUTHORIZED' } }); + } +} const fixtures = '/tests/fixtures'; const pages = readFileSync(`${fixtures}/pages.pdf`); const result = await send('pdf', pages); diff --git a/packages/extraction/tests/container.sh b/packages/extraction/tests/container.sh index 839670c5..520b7805 100755 --- a/packages/extraction/tests/container.sh +++ b/packages/extraction/tests/container.sh @@ -13,40 +13,65 @@ cleanup() { trap cleanup EXIT "${compose[@]}" build extraction docker build --target qualification -t "$image" -f packages/extraction/Dockerfile . -"${compose[@]}" up -d --wait --wait-timeout 30 extraction -id=$("${compose[@]}" ps -q extraction) -volume="${project}_extraction-socket" -# Inspect exactly the shipped recipe, not a relaxed test variant. -docker inspect "$id" | python3 -c ' +"${compose[@]}" up -d --scale extraction=2 --wait --wait-timeout 30 extraction +mapfile -t ids < <("${compose[@]}" ps -q extraction) +[ "${#ids[@]}" -eq 2 ] +volume="${project}_extraction-token" +network="${project}_extraction" +docker network inspect "$network" | python3 -c 'import json,sys; assert json.load(sys.stdin)[0]["Internal"]' +for id in "${ids[@]}"; do + # Inspect the shipped recipe, not a relaxed test variant. + docker inspect "$id" | python3 -c ' import json,sys c=json.load(sys.stdin)[0]; h=c["HostConfig"] -assert h["NetworkMode"] == "none" and h["ReadonlyRootfs"] and not h["Privileged"] +assert h["ReadonlyRootfs"] and not h["Privileged"] assert h["CapDrop"] == ["ALL"] and h["PidsLimit"] == 32 assert h["Memory"] == 768*1024*1024 and h["NanoCpus"] == 2*10**9 assert "no-new-privileges:true" in h["SecurityOpt"] assert c["Config"]["User"] == "10001:10001" -assert len(c["Mounts"]) == 1 and c["Mounts"][0]["Destination"] == "/socket" +assert c["Config"]["Entrypoint"] == ["/opt/extraction/gateway"] +assert len(c["NetworkSettings"]["Networks"]) == 1 and not h["PortBindings"] +assert len(c["Mounts"]) == 1 and c["Mounts"][0]["Destination"] == "/run/extraction" and not c["Mounts"][0]["RW"] assert set(h["Tmpfs"]) == {"/jobs"} for flag in ("noexec", "nosuid", "nodev", "size=96m", "nr_inodes=1024", "uid=10001", "gid=10001", "mode=0700"): assert flag in h["Tmpfs"]["/jobs"] -assert not c["Config"]["ExposedPorts"] if "ExposedPorts" in c["Config"] else True -print("Shipped Compose mounts, resource limits and security options verified")' -docker run --rm --init --network none --cap-drop ALL --security-opt no-new-privileges \ - --mount "type=volume,src=$volume,dst=/socket,readonly" "$image" \ - sh -c 'python /tests/adversarial.py && node /tests/container-client.mjs' -docker exec "$id" node -e 'require("node:fs").readdir("/jobs",(e,v)=>{if(e||v.length)process.exit(1); console.log("Endpoint scratch cleanup verified")})' -# Same outer boundary, with a test-only worker that attempts forbidden operations. +assert not c["Config"].get("ExposedPorts") +print("Shipped Compose mounts, private network, resource limits and security options verified")' + docker exec "$id" /opt/extraction/gateway node dist/health.js + docker exec "$id" node -e 'require("node:fs").readdir("/jobs",(e,v)=>{if(e||v.length)process.exit(1)})' +done +# One-shot init is idempotent; arbitrary caller UIDs can read but not write. +before=$(docker exec "${ids[0]}" node -e 'console.log(require("node:fs").statSync("/run/extraction/token").ino)') +"${compose[@]}" run --rm --no-deps extraction-init +after=$(docker exec "${ids[0]}" node -e 'console.log(require("node:fs").statSync("/run/extraction/token").ino)') +[ "$before" = "$after" ] +docker run --rm --user 12345:12345 --network none --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=volume,src=$volume,dst=/run/extraction,readonly" "$image" \ + node --input-type=module -e 'import {readToken} from "/opt/extraction/dist/credential.js"; await readToken(); console.log("Cross-UID credential read verified")' +docker run --rm --init --network "$network" --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=volume,src=$volume,dst=/run/extraction,readonly" "$image" \ + sh -c 'python /tests/gateway.py && node --input-type=module -e '\''import {lookup, resolve4} from "node:dns/promises"; const a=await lookup("extraction", {all:true, family:4}); const b=await resolve4("extraction"); if(a.length !== 2 || b.length !== 2) process.exit(1); console.log("Node IPv4 libc and c-ares DNS qualified for two replicas")'\'' && python /tests/adversarial.py && node /tests/container-client.mjs' +for id in "${ids[@]}"; do + docker exec "$id" node -e 'require("node:fs").readdir("/jobs",(e,v)=>{if(e||v.length)process.exit(1); console.log("Endpoint scratch cleanup verified")})' +done +# Same outer boundary; the worker independently exercises its stronger filter. docker run --rm --init --network none --cap-drop ALL --security-opt no-new-privileges \ --read-only --memory 768m --cpus 2 --pids-limit 32 \ --tmpfs /jobs:rw,noexec,nosuid,nodev,size=96m,nr_inodes=1024,uid=10001,gid=10001,mode=0700 \ - --tmpfs /socket:rw,noexec,nosuid,nodev,size=1m,uid=10001,gid=10001,mode=0700 \ + --mount "type=volume,src=$volume,dst=/run/extraction,readonly" \ "$image" node /tests/isolation.mjs -# Denying the mandatory isolation syscall must fail closed, never start unsandboxed. +# A test controller without the gateway supplies socket stdio deliberately. +# Both native launchers must reject/remove it before running their target. +docker run --rm --init --network none --cap-drop ALL --security-opt no-new-privileges \ + --read-only --memory 768m --cpus 2 --pids-limit 32 \ + --tmpfs /jobs:rw,noexec,nosuid,nodev,size=96m,uid=10001,gid=10001,mode=0700 \ + --entrypoint node "$image" /tests/stdio.mjs +# Mandatory isolation unavailable: fail closed, never start unsandboxed. set +e docker run --rm --init --network none --cap-drop ALL --security-opt no-new-privileges \ --read-only --memory 768m --cpus 2 --pids-limit 32 \ --tmpfs /jobs:rw,noexec,nosuid,nodev,size=96m,uid=10001,gid=10001,mode=0700 \ - --tmpfs /socket:rw,noexec,nosuid,nodev,size=1m,uid=10001,gid=10001,mode=0700 \ + --mount "type=volume,src=$volume,dst=/run/extraction,readonly" \ "$image" /tests/block-landlock > /dev/null 2>&1 blocked=$? set -e @@ -55,7 +80,17 @@ if [ "$blocked" -ne 1 ]; then exit 1 fi printf 'Unavailable isolation fails closed\n' -# A stale socket left by SIGKILL must not prevent a safe restart. -docker kill -s KILL "$id" >/dev/null -"${compose[@]}" up -d --wait --wait-timeout 30 extraction -printf 'Hardened container restart passed\n' +set +e +docker run --rm --init --network none --cap-drop ALL --security-opt no-new-privileges \ + --read-only --memory 128m --pids-limit 16 \ + "$image" /tests/block-landlock gateway > /dev/null 2>&1 +blocked=$? +set -e +[ "$blocked" -eq 125 ] +printf 'Unavailable pre-runtime gateway fails closed\n' +# Restart uses the same credential and fresh per-replica scratch. +for id in "${ids[@]}"; do docker kill -s KILL "$id" >/dev/null; done +"${compose[@]}" up -d --scale extraction=2 --wait --wait-timeout 30 extraction +after=$(docker exec "${ids[0]}" node -e 'console.log(require("node:fs").statSync("/run/extraction/token").ino)') +[ "$before" = "$after" ] +printf 'Hardened two-replica restart and credential persistence passed\n' diff --git a/packages/extraction/tests/gateway.py b/packages/extraction/tests/gateway.py new file mode 100644 index 00000000..0d947345 --- /dev/null +++ b/packages/extraction/tests/gateway.py @@ -0,0 +1,21 @@ +"""Probe the pre-runtime guard independently of the stronger worker filter.""" +import ctypes +import errno +import socket +import os +import stat + +libc = ctypes.CDLL(None, use_errno=True) +for fd in (0, 1, 2): + assert not stat.S_ISSOCK(os.fstat(fd).st_mode) +for name, args in [ + ('socket', (socket.AF_UNIX, socket.SOCK_STREAM, 0)), + ('socketpair', (socket.AF_UNIX, socket.SOCK_STREAM, 0, 0)), + ('sendmsg', (-1, 0, 0)), ('recvmsg', (-1, 0, 0)), + ('sendmmsg', (-1, 0, 0, 0)), ('recvmmsg', (-1, 0, 0, 0, 0)), +]: + assert getattr(libc, name)(*args) == -1, name + assert ctypes.get_errno() == errno.EPERM, (name, ctypes.get_errno()) +with socket.socket(socket.AF_INET, socket.SOCK_STREAM): + pass +print('Pre-runtime guard: AF_UNIX, socketpair and descriptor-message syscalls denied; IP available') diff --git a/packages/extraction/tests/isolation.mjs b/packages/extraction/tests/isolation.mjs index d44ef800..fb93cef9 100644 --- a/packages/extraction/tests/isolation.mjs +++ b/packages/extraction/tests/isolation.mjs @@ -1,31 +1,42 @@ import assert from 'node:assert/strict'; +import { + mkdir, + mkdtemp, + writeFile, + rm, + readdir, + open, + readFile, +} from 'node:fs/promises'; +import { constants } from 'node:fs'; import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, writeFile, rm, readdir } from 'node:fs/promises'; import { once } from 'node:events'; import { RestrictedBackend } from '/opt/extraction/dist/backend.js'; await mkdir('/jobs/other'); await writeFile('/jobs/other/secret', 'cross-job-canary'); -await writeFile('/socket/secret', 'transport-canary'); + const job = await mkdtemp('/jobs/job-'); +const status = await open( + `${job}/status`, + constants.O_RDWR | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + 0o600 +); const child = spawn( '/opt/extraction/guard', [job, '/usr/local/bin/python3.13', '/tests/isolation.py', 'probe'], { env: { EXTRACTION_SECRET_CANARY: 'private-canary' }, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', status.fd, 'ignore'], } ); -let output = '', - errors = ''; -child.stdout.on('data', bytes => { - output += bytes; -}); -child.stderr.on('data', bytes => { - errors += bytes; -}); const [code] = await once(child, 'close'); -assert.equal(code, 0, errors); +await status.close(); +const output = await readFile(`${job}/status`, 'ascii'); +assert.equal(code, 0, output); assert.equal(output.trim(), 'ISOLATED'); await rm(job, { recursive: true }); const runtime = { @@ -45,13 +56,19 @@ for (const abort of ['immediate', 'running']) { await assert.rejects(pending); await backend.cleanup(job); } +const flood = await backend.create(); +await assert.rejects( + backend.run(flood, 'flood', 'a'.repeat(64), AbortSignal.timeout(1000)), + /UNAVAILABLE/ +); +await backend.cleanup(flood); const job2 = await backend.create(); await assert.rejects( backend.run(job2, 'hang', 'a'.repeat(64), AbortSignal.timeout(100)) ); await backend.cleanup(job2); await rm('/jobs/other', { recursive: true }); -await rm('/socket/secret'); + assert.deepEqual(await readdir('/jobs'), []); const missing = new RestrictedBackend({ ...runtime, guard: '/nonexistent' }); const job3 = await missing.create(); diff --git a/packages/extraction/tests/isolation.py b/packages/extraction/tests/isolation.py index a50d0084..e46119b4 100644 --- a/packages/extraction/tests/isolation.py +++ b/packages/extraction/tests/isolation.py @@ -6,15 +6,44 @@ import signal import socket import struct +import stat import sys +for fd in (0, 2): + assert stat.S_ISCHR(os.fstat(fd).st_mode) + assert os.fstat(fd).st_rdev == os.stat('/dev/null').st_rdev +assert stat.S_ISREG(os.fstat(1).st_mode) +for fd in range(3, 64): + try: + os.fstat(fd) + except OSError as error: + assert error.errno == errno.EBADF + else: + raise RuntimeError('Inherited descriptor') + +libc = ctypes.CDLL(None, use_errno=True) +# Invalid descriptors would return EBADF without filtering. No messages are sent. +for name, args in [ + ('socketpair', (socket.AF_UNIX, socket.SOCK_STREAM, 0, 0)), + ('sendmsg', (-1, 0, 0)), ('recvmsg', (-1, 0, 0)), + ('sendmmsg', (-1, 0, 0, 0)), ('recvmmsg', (-1, 0, 0, 0, 0)), + ('sendto', (-1, 0, 0, 0, 0, 0)), ('recvfrom', (-1, 0, 0, 0, 0, 0)), + ('setsockopt', (-1, 0, 0, 0, 0)), ('getsockopt', (-1, 0, 0, 0, 0)), + ('sendmsg', (1, 0, 0)), ('recvmsg', (1, 0, 0)), +]: + assert getattr(libc, name)(*args) == -1, name + assert ctypes.get_errno() == errno.EPERM, (name, ctypes.get_errno()) + +if sys.argv[1] == 'flood': + os.write(1, b'x' * 2048) + sys.exit(0) if sys.argv[1] == 'hang': # Exercise supervisor cancellation/deadline termination, not a cooperative parser. while True: pass for path, mode in [('/jobs/other/secret', 'rb'), ('/jobs/other/secret', 'wb'), - ('/socket/secret', 'rb'), ('/proc/self/environ', 'rb'), ('/etc/passwd', 'rb')]: + ('/run/extraction/token', 'rb'), ('/proc/self/environ', 'rb'), ('/etc/passwd', 'rb')]: try: with open(path, mode) as file: if mode == 'rb': diff --git a/packages/extraction/tests/stdio.mjs b/packages/extraction/tests/stdio.mjs new file mode 100644 index 00000000..d6260d98 --- /dev/null +++ b/packages/extraction/tests/stdio.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; + +const job = await mkdtemp('/jobs/job-'); +try { + const worker = spawn( + '/opt/extraction/guard', + [job, '/usr/local/bin/python3.13', '/tests/isolation.py', 'probe'], + { stdio: ['pipe', 'pipe', 'pipe'] } + ); + worker.stdin.end(); + worker.stdout.resume(); + worker.stderr.resume(); + assert.equal((await once(worker, 'close'))[0], 125); + const gateway = spawn( + '/opt/extraction/gateway', + ['python', '/tests/gateway.py'], + { stdio: ['pipe', 'pipe', 'pipe'] } + ); + gateway.stdin.end(); + gateway.stdout.resume(); + gateway.stderr.resume(); + assert.equal((await once(gateway, 'close'))[0], 0); + console.log( + 'Socket standard IO rejected by worker and removed by pre-runtime gateway' + ); +} finally { + await rm(job, { recursive: true }); +} diff --git a/packages/extraction/worker/extract.py b/packages/extraction/worker/extract.py index 20f0360f..65022025 100644 --- a/packages/extraction/worker/extract.py +++ b/packages/extraction/worker/extract.py @@ -434,9 +434,9 @@ def probe(): import docx import lxml.etree - for path in ("/socket", "/jobs", "/proc/self/environ", "/etc/passwd"): + for path in ("/run/extraction", "/jobs", "/proc/self/environ", "/etc/passwd"): try: - if path in ("/socket", "/jobs"): + if path in ("/run/extraction", "/jobs"): os.listdir(path) else: open(path, "rb").read(1) diff --git a/service/Dockerfile b/service/Dockerfile index d7bf9036..805d6c5c 100644 --- a/service/Dockerfile +++ b/service/Dockerfile @@ -1,5 +1,5 @@ # File Server Dockerfile -FROM oven/bun:1.4.2 AS base +FROM oven/bun:1.4.4 AS base WORKDIR /app # Install dependencies @@ -27,7 +27,7 @@ RUN bun build ./src/worker-server.ts --minify --outdir .build-worker --target bu RUN bun build ./src/egress-gateway.ts --minify --outdir .build-egress-gateway --target bun --external '@opentelemetry/*' # File server production -FROM oven/bun:1.4.2 AS production +FROM oven/bun:1.4.4 AS production ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules @@ -35,7 +35,7 @@ COPY --from=builder /app/.build ./.build CMD ["bun", "run", ".build/file-server.js"] # API server (HTTP on port 3112) -FROM oven/bun:1.4.2 AS api +FROM oven/bun:1.4.4 AS api ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules @@ -45,7 +45,7 @@ COPY --from=builder /app/src/*.py ./src/ CMD ["bun", "run", ".build-api/api-server.js"] # Worker server (job processor, health on port 3113) -FROM oven/bun:1.4.2 AS worker +FROM oven/bun:1.4.4 AS worker ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules @@ -54,7 +54,7 @@ COPY --from=builder /app/src/*.py ./src/ CMD ["bun", "run", ".build-worker/worker-server.js"] # Egress gateway (sandbox outbound delegation) -FROM oven/bun:1.4.2 AS egress-gateway +FROM oven/bun:1.4.4 AS egress-gateway ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules diff --git a/service/Dockerfile.api b/service/Dockerfile.api index 1da68c46..6a571db8 100644 --- a/service/Dockerfile.api +++ b/service/Dockerfile.api @@ -1,7 +1,7 @@ # API-Only Server Dockerfile # This builds the HTTP API server without workers # Scale this based on HTTP traffic -FROM oven/bun:1.4.2 AS base +FROM oven/bun:1.4.4 AS base WORKDIR /app # Install dependencies @@ -27,7 +27,7 @@ RUN bun build ./src/api-server.ts --minify --outdir .build --target bun --extern RUN bun build ./scripts/rehydrate-session-cache.ts --minify --outdir .build-migrations --target bun --external '@opentelemetry/*' # Production stage -FROM oven/bun:1.4.2 AS production +FROM oven/bun:1.4.4 AS production ENV NODE_ENV=production WORKDIR /app # Install curl for healthcheck (not included in bun base image) diff --git a/service/Dockerfile.bun b/service/Dockerfile.bun index d545f32c..941c7183 100644 --- a/service/Dockerfile.bun +++ b/service/Dockerfile.bun @@ -1,5 +1,5 @@ # Base stage -FROM oven/bun:1.4.2 AS base +FROM oven/bun:1.4.4 AS base WORKDIR /usr/src/app # Install dependencies diff --git a/service/Dockerfile.egress-gateway b/service/Dockerfile.egress-gateway index bd1d2b64..cde97938 100644 --- a/service/Dockerfile.egress-gateway +++ b/service/Dockerfile.egress-gateway @@ -1,5 +1,5 @@ # Egress Gateway Dockerfile -FROM oven/bun:1.4.2 AS base +FROM oven/bun:1.4.4 AS base WORKDIR /app FROM base AS install @@ -11,7 +11,7 @@ RUN mkdir -p /temp/prod COPY service/package.json service/bun.lock /temp/prod/ RUN cd /temp/prod && bun install --frozen-lockfile --production -FROM oven/bun:1.4.2 AS production +FROM oven/bun:1.4.4 AS production ENV NODE_ENV=production WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* diff --git a/service/Dockerfile.local b/service/Dockerfile.local index 4ed93ee4..216e9c31 100644 --- a/service/Dockerfile.local +++ b/service/Dockerfile.local @@ -1,5 +1,5 @@ # Local development Dockerfile - no authentication required -FROM oven/bun:1.4.2 AS base +FROM oven/bun:1.4.4 AS base WORKDIR /app # Install dependencies @@ -22,7 +22,7 @@ COPY service/tsconfig.json ./ RUN bun build ./src/local-api.ts --minify --outdir .build --target bun --external '@opentelemetry/*' # Production stage -FROM oven/bun:1.4.2 AS production +FROM oven/bun:1.4.4 AS production ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules diff --git a/service/Dockerfile.service b/service/Dockerfile.service index d5d4e6d9..dea1cea3 100644 --- a/service/Dockerfile.service +++ b/service/Dockerfile.service @@ -1,5 +1,5 @@ # Service API Dockerfile -FROM oven/bun:1.4.2 AS base +FROM oven/bun:1.4.4 AS base WORKDIR /usr/src/app # Install dependencies diff --git a/service/Dockerfile.tool-call-server b/service/Dockerfile.tool-call-server index 76afb837..95543ee7 100644 --- a/service/Dockerfile.tool-call-server +++ b/service/Dockerfile.tool-call-server @@ -1,5 +1,5 @@ # Tool Call Server Dockerfile -FROM oven/bun:1.4.2 AS base +FROM oven/bun:1.4.4 AS base WORKDIR /app # Install dependencies @@ -13,7 +13,7 @@ COPY service/package.json service/bun.lock /temp/prod/ RUN cd /temp/prod && bun install --frozen-lockfile --production # Production stage -FROM oven/bun:1.4.2 AS production +FROM oven/bun:1.4.4 AS production ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules diff --git a/service/Dockerfile.worker b/service/Dockerfile.worker index bb7b6945..059ce2aa 100644 --- a/service/Dockerfile.worker +++ b/service/Dockerfile.worker @@ -2,7 +2,7 @@ # This builds the job processing worker without HTTP server # Deploy alongside a sandbox sidecar for execution # Scale this based on queue depth -FROM oven/bun:1.4.2 AS base +FROM oven/bun:1.4.4 AS base WORKDIR /app # Install dependencies @@ -25,7 +25,7 @@ COPY service/tsconfig.json ./ RUN bun build ./src/worker-server.ts --minify --outdir .build --target bun --external '@opentelemetry/*' # Production stage -FROM oven/bun:1.4.2 AS production +FROM oven/bun:1.4.4 AS production ENV NODE_ENV=production WORKDIR /app # Install curl for healthcheck diff --git a/service/src/config.spec.ts b/service/src/config.spec.ts index 45ae5700..e5af81cf 100644 --- a/service/src/config.spec.ts +++ b/service/src/config.spec.ts @@ -80,15 +80,15 @@ describe('runtime version configuration', () => { }); }); - it('maps Bun JavaScript and TypeScript requests to Bun 1.4.2', () => { + it('maps Bun JavaScript and TypeScript requests to Bun 1.4.4', () => { expect(languageConfig[Languages.js]).toMatchObject({ language: 'bun-js', - version: '1.4.2', + version: '1.4.4', fileName: 'index.js', }); expect(languageConfig[Languages.ts]).toMatchObject({ language: 'bun-ts', - version: '1.4.2', + version: '1.4.4', fileName: 'main.ts', }); }); diff --git a/service/src/config.ts b/service/src/config.ts index 6a49ea36..8abfa54f 100644 --- a/service/src/config.ts +++ b/service/src/config.ts @@ -11,10 +11,10 @@ import { BRIDGE_WORKSPACE_IMPORT_FILE_DEFAULT_MAX_BYTES } from '../../packages/c export const languageConfig: Record = { [Languages.bash]: { language: 'bash', version: '5.2.0', fileName: 'script.sh' }, - [Languages.js]: { language: 'bun-js', version: '1.4.2', fileName: 'index.js' }, + [Languages.js]: { language: 'bun-js', version: '1.4.4', fileName: 'index.js' }, [Languages.node]: { language: 'node', version: '24.22.1', fileName: 'index.js' }, [Languages.py]: { language: 'python', version: '3.14.4', fileName: 'main.py' }, - [Languages.ts]: { language: 'bun-ts', version: '1.4.2', fileName: 'main.ts' }, + [Languages.ts]: { language: 'bun-ts', version: '1.4.4', fileName: 'main.ts' }, }; const languageAliases: Record = { diff --git a/service/src/workspace-tools/outcome.ts b/service/src/workspace-tools/outcome.ts index e8dc100f..03b54c5e 100644 --- a/service/src/workspace-tools/outcome.ts +++ b/service/src/workspace-tools/outcome.ts @@ -30,6 +30,7 @@ export function getWorkspaceToolOutcome(res: Response, route = '/workspace-tools if (existing != null) return existing; const startedAt = performance.now(); let responseEndedAt: number | undefined; + let responseFinished = false; let logged = false; const outcome: WorkspaceToolOutcome = { dispatchPending: false, @@ -37,7 +38,7 @@ export function getWorkspaceToolOutcome(res: Response, route = '/workspace-tools if (logged || responseEndedAt == null || outcome.dispatchPending) return; logged = true; outcomes.delete(res); - const finished = res.writableFinished; + const finished = responseFinished; logger.log(finished && res.statusCode < 400 ? 'info' : 'warn', 'Workspace tool request completed', { route, operation: outcome.operation, @@ -55,6 +56,8 @@ export function getWorkspaceToolOutcome(res: Response, route = '/workspace-tools res.removeListener('finish', onResponseEnd); res.removeListener('close', onResponseEnd); responseEndedAt = performance.now(); + // Late dispatch settlement can write to a closed response. Preserve its state at close. + responseFinished = res.writableFinished; outcome.flush(); }; outcomes.set(res, outcome); diff --git a/service/src/workspace-tools/router.test.ts b/service/src/workspace-tools/router.test.ts index 8a753c08..e3ed54bd 100644 --- a/service/src/workspace-tools/router.test.ts +++ b/service/src/workspace-tools/router.test.ts @@ -534,11 +534,9 @@ test('logs a disconnected dispatch once without inventing HTTP 200', async () => const settlementGate = Promise.withResolvers(); let dispatchAborted = false; let queueRemaining: number | undefined; - let closeConnection = (): void => { throw new Error('connection not ready'); }; app.use(json()); app.use((req, res, next) => { applyPrincipal(req, { userId: 'user-1', tenantId: 'tenant-1', principalSource: 'librechat_jwt', codeWorkerId: 'user-worker' }); - closeConnection = (): void => { res.destroy(); }; res.once('close', () => closed.resolve()); next(); }); @@ -578,7 +576,7 @@ test('logs a disconnected dispatch once without inventing HTTP 200', async () => body: JSON.stringify({ protocolVersion: 1, operation: 'list_files', workspaceId: 'primary' }), }); await started.promise; - closeConnection(); + controller.abort(); await expect(response).rejects.toThrow(); await closed.promise; expect(queueRemaining).toBeGreaterThan(29_000); diff --git a/tests/package_init_runtime_rollout.sh b/tests/package_init_runtime_rollout.sh index 9c09d542..839d9a7c 100755 --- a/tests/package_init_runtime_rollout.sh +++ b/tests/package_init_runtime_rollout.sh @@ -17,6 +17,7 @@ render() { } # The hook installs the runtime selected by service requests. render | grep -A 1 'name: NODE_VERSION' | grep -q 'value: "24.22.1"' +render | grep -A 1 'name: BUN_VERSION' | grep -q 'value: "1.4.4"' # Explicit runtime selections still override the chart default. render --set workerSandbox.packages.initJob.nodeVersion=24.22.0 \ | grep -A 1 'name: NODE_VERSION' | grep -q 'value: "24.22.0"' diff --git a/tests/tool_call_runner.cjs b/tests/tool_call_runner.cjs index 61ddcb9e..7028d9cc 100644 --- a/tests/tool_call_runner.cjs +++ b/tests/tool_call_runner.cjs @@ -118,8 +118,8 @@ if __name__ == '__main__': const plain = await execute("import os, asyncio; assert not os.path.exists('/proc/self/fd/3'); asyncio.run(asyncio.to_thread(lambda: 1)); print('PASS: ordinary asyncio execution without pipe capability')", false); console.log(plain.run.stdout.trim()); for (const language of ['bun-js', 'bun-ts']) { - const js = await execute("let denied = false; try { Bun.spawn([process.execPath, '-e', '0'], {stdout:'pipe', stderr:'pipe'}); } catch (error) { if (error.code !== 'EPERM' || error.syscall !== 'socketpair') throw error; denied = true; } if (!denied) throw new Error('socketpair spawn allowed'); console.log('PASS: Bun ' + Bun.version + ', socketpair-based spawn denied')", false, language, '1.4.2'); - assert(js.run.stdout.includes('PASS: Bun 1.4.2'), JSON.stringify(js)); + const js = await execute("let denied = false; try { Bun.spawn([process.execPath, '-e', '0'], {stdout:'pipe', stderr:'pipe'}); } catch (error) { if (error.code !== 'EPERM' || error.syscall !== 'socketpair') throw error; denied = true; } if (!denied) throw new Error('socketpair spawn allowed'); console.log('PASS: Bun ' + Bun.version + ', socketpair-based spawn denied')", false, language, '1.4.4'); + assert(js.run.stdout.includes('PASS: Bun 1.4.4'), JSON.stringify(js)); console.log(language + ': ' + js.run.stdout.trim()); } } finally {