From 7c7c9f99df9d8392f9aecf603dd6f45d4f19014c Mon Sep 17 00:00:00 2001 From: Petr Plenkov Date: Sun, 9 Aug 2026 22:56:33 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Wave=204=20=E2=80=94=20runtime-host?= =?UTF-8?q?=20package=20(host=20process=20executor)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HostExecutor implementation: allowlist enforcement, env/credential passing, artifact collection, log truncation, timeout + SIGKILL grace. 47 tests pass across 4 files (allowlist, errors, host-executor, public-api). Typecheck clean, build produces dist/index.mjs + dist/index.d.mts. Reviewer approved (sv-gab). Spec 05 amended to match built runtime contract.
- 47 tests pass - typecheck clean - build green - reviewer approved
Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../architecture/wave-04-runtime-host-plan.md | 248 ++++++++++++ packages/runtime-host/package.json | 14 +- .../src/__tests__/allowlist.test.ts | 45 +++ .../runtime-host/src/__tests__/errors.test.ts | 54 +++ .../src/__tests__/helpers/fixtures.ts | 60 +++ .../src/__tests__/host-executor.test.ts | 360 ++++++++++++++++++ .../src/__tests__/public-api.test.ts | 47 +++ packages/runtime-host/src/allowlist.ts | 38 ++ packages/runtime-host/src/config.ts | 22 ++ packages/runtime-host/src/errors.ts | 31 ++ packages/runtime-host/src/host-executor.ts | 305 +++++++++++++++ packages/runtime-host/src/index.ts | 6 + specs/05-runtime-host/spec.md | 44 +-- 13 files changed, 1246 insertions(+), 28 deletions(-) create mode 100644 engdocs/architecture/wave-04-runtime-host-plan.md create mode 100644 packages/runtime-host/src/__tests__/allowlist.test.ts create mode 100644 packages/runtime-host/src/__tests__/errors.test.ts create mode 100644 packages/runtime-host/src/__tests__/helpers/fixtures.ts create mode 100644 packages/runtime-host/src/__tests__/host-executor.test.ts create mode 100644 packages/runtime-host/src/__tests__/public-api.test.ts create mode 100644 packages/runtime-host/src/allowlist.ts create mode 100644 packages/runtime-host/src/config.ts create mode 100644 packages/runtime-host/src/errors.ts create mode 100644 packages/runtime-host/src/host-executor.ts diff --git a/engdocs/architecture/wave-04-runtime-host-plan.md b/engdocs/architecture/wave-04-runtime-host-plan.md new file mode 100644 index 000000000..297cbd411 --- /dev/null +++ b/engdocs/architecture/wave-04-runtime-host-plan.md @@ -0,0 +1,248 @@ +# Wave 4 — Runtime Host Executor Implementation Plan + +**Architect:** architect-1 +**Spec:** `specs/05-runtime-host/spec.md` +**Package:** `@sverka/runtime-host` → `packages/runtime-host` +**Depends on:** Wave 3 (`@sverka/runtime`), Wave 2 (`@sverka/ir`) + +This plan is the contract the builder implements against. The spec is the +source of truth; this plan adds sequencing, file layout, conventions, and +edge-case guidance. Where the two disagree, the spec wins — except where the +spec conflicts with the **built** runtime contract, in which case the built +contract wins (see §1 amendments). + +## 1. Spec amendments already applied (architect) + +Spec 05 was written before `@sverka/runtime` was built. Three mismatches with +the built contract have been corrected in the spec: + +1. **`workspace` / `artifactDir` removed from `HostExecutorConfig`.** The + scheduler passes both per-execution via `ExecuteRequest` + (`request.workspace`, `request.artifactDir`). The executor must use the + request fields, not config fields. `canExecute` (which receives only the + operation) never needed them. +2. **Env building uses `request.credentials` and `request.env`, not + `operation.credentials` / `operation.env`.** `operation.credentials` is + `CredentialDeclaration[]` (name/envVar/required — declarations). Resolved + secret **values** arrive via `request.credentials: Record` + (keyed by envVar). `request.env` carries the operation env vars. +3. All `config.workspace` / `config.artifactDir` references in the data model, + security table, error rules, and test plan updated to `request.*`. + +## 2. Scope + +Implement the host process executor for `@sverka/runtime-host`: + +- `HostExecutor` class implementing `Executor` from `@sverka/runtime`. +- `HostExecutorConfig` (enabled, allowlist, envAllowlist, env?, maxLogBytes?, + runAsUid?). +- `CommandAllowlist` interface + `createAllowlist` factory (security primitive). +- Error hierarchy: `HostExecutorError` → `HostTimeoutError`, + `CommandNotAllowedError`. +- Public re-exports from `src/index.ts`. + +**Dependency:** `@sverka/runtime` (Executor, ExecuteRequest, ExecuteResult) + +`@sverka/ir` (PlanOperation). Both `workspace:*`. + +**Out of scope (do NOT implement in this wave):** +- **Retry.** The scheduler owns retry (`maxAttempts`/`retryOn`/`backoffSeconds`, + Wave 3 Slice E). The executor executes **once** and returns a result. Spec + goal 7 ("support retry policies") is satisfied by returning `status: + "failure"` results that the scheduler can retry. Spec test plan item 10 is + an integration concern with the scheduler, not a host-executor unit test — + exclude it from this package's suite. +- **Actual setuid / privilege dropping.** The executor **validates** + `runAsUid !== 0` and rejects `sudo`/`su` in the allowlist at construction. + It does **not** call setuid — sandboxing is the container executors' job. +- Filesystem / network isolation (host processes are ambient; documented + limitation per spec non-goals). + +## 3. Scaffolding status (already done by architect) + +- `packages/runtime-host/package.json` — fixed: dist paths are + `.mjs`/`.d.mts` (matches `core`/`ir`/`runtime`); `@sverka/runtime` and + `@sverka/ir` added to `dependencies` as `workspace:*`. +- `packages/runtime-host/project.json` — already has `--passWithNoTests` on + the test target. +- `tsconfig.json`, `tsdown.config.ts` — already match `runtime`; no changes. +- `src/index.ts` — placeholder; builder fills exports. + +The builder must run `bun install` once after pulling the new workspace deps. + +## 4. File layout + +Mirror `core`/`ir`/`runtime` (one module per concern, `__tests__/` co-located): + +``` +packages/runtime-host/src/ + index.ts # public re-exports (matches spec §Interfaces) + errors.ts # HostExecutorError, HostTimeoutError, CommandNotAllowedError + config.ts # HostExecutorConfig (type-only) + allowlist.ts # CommandAllowlist interface + createAllowlist + host-executor.ts # HostExecutor class + __tests__/ + errors.test.ts + allowlist.test.ts + host-executor.test.ts # the big one: canExecute, spawn, timeout, env, cwd, artifacts + public-api.test.ts + helpers/ + fixtures.ts # op factory + makeRequest helper +``` + +No `internal/` needed — `allowlist.ts` is public (spec exports it). + +## 5. Implementation order (TDD: tests first, then impl) + +### Slice A — Errors (foundation, no deps) +1. `errors.test.ts` — `HostExecutorError` base (sets `name`, carries `code` + + `context`), `HostTimeoutError` (`HOST_TIMEOUT`), `CommandNotAllowedError` + (`COMMAND_NOT_ALLOWED`), `instanceof` chain. Mirror `runtime/src/errors.ts` + constructor pattern exactly. +2. `errors.ts` — implement. Wire into `index.ts`. + +### Slice B — Allowlist (security primitive, no deps) +3. `allowlist.test.ts` — `createAllowlist(["node","/usr/bin/git"])`: + `isAllowed("node")` true, `isAllowed("git")` false (bare name must match + entry exactly), `isAllowed("/usr/bin/git")` true, `isAllowed("/bin/sh")` + false. Empty allowlist → nothing allowed. +4. `allowlist.ts` — `CommandAllowlist` interface + `createAllowlist`. Matching + rule: an entry matches a command if (a) entry is an absolute path and + equals the command exactly, or (b) entry is a bare name and equals the + command's basename. **No globs** (spec: deterministic matching). + +### Slice C — Config + public API skeleton +5. `config.ts` — `HostExecutorConfig` interface (per spec, post-amendment: + no `workspace`/`artifactDir`). +6. `public-api.test.ts` (skeleton) — assert every exported symbol importable. + +### Slice D — HostExecutor core (canExecute + spawn + output + exit code) +7. `helpers/fixtures.ts` — `makeHostOp(overrides)` building a minimal + `PlanOperation` with `executor.type: "host"`, `command`, `args`, + `timeoutSeconds`; `makeRequest(op, overrides)` building an `ExecuteRequest` + with `workspace` (a temp dir), `env`, `credentials`, `cacheDir`, + `artifactDir`. +8. `host-executor.test.ts` — + - `canExecute`: false when `enabled: false`; true for `executor.type: + "host"` + allowed command + valid timeout; false for `type: "docker"`; + false when command not in allowlist; false when `timeoutSeconds` missing. + - spawn `node -e "console.log('hello')"` → `status: "success"`, + `exitCode: 0`, logs contain `hello`. + - spawn `node -e "process.exit(1)"` → `status: "failure"`, `exitCode: 1`. + - stdout + stderr both captured into `logs`. +9. `host-executor.ts` — implement: construction stores config; `canExecute` + per spec eligibility formula; `execute` validates (enabled, type, timeout, + allowlist), builds env, spawns via `node:child_process` `spawn`, captures + stdout+stderr, resolves `ExecuteResult`. Use `request.workspace` as cwd. + +### Slice E — Timeout +10. Extend `host-executor.test.ts` — `timeoutSeconds: 0.1` running + `node -e "setTimeout(()=>{},5000)"` → killed, `status: "failure"`, error + contains "timeout". Use **real** short timeouts (not fake timers — spawn + uses real timers internally). +11. Implement: SIGTERM on expiry, SIGKILL after 2s grace, record timeout + failure. `MISSING_TIMEOUT` raised before spawn if `timeoutSeconds` absent + or <= 0. + +### Slice F — Environment bounding +12. Extend tests — spawn `node -e "console.log(process.env.FOO)"`: + - host env var not in `envAllowlist` → absent from child. + - `envAllowlist: ["PATH"]` → PATH present. + - `request.env: { FOO: "bar" }` → FOO present. + - `request.credentials: { SECRET: "s" }` → SECRET present. +13. Implement env building per spec step 5 (post-amendment): start empty, + forward `envAllowlist` from `process.env`, merge `config.env`, merge + `request.credentials`, merge `request.env`. + +### Slice G — Working directory constraint +14. Extend tests — + - cwd is `request.workspace` by default (spawn + `node -e "console.log(process.cwd())"`). + - `operation.workingDir` relative to workspace honored. + - `operation.workingDir` resolving outside workspace → + `HostExecutorError` (`WORKDIR_OUTSIDE_WORKSPACE`). +15. Implement: resolve `operation.workingDir` against `request.workspace`; + reject if the resolved path escapes `request.workspace` (use + `path.resolve` + startsWith check). + +### Slice H — Privilege escalation prevention (construction-time) +16. Extend tests — constructing `HostExecutor` with `runAsUid: 0` throws + `HostExecutorError` (`PRIVILEGE_ESCALATION`); allowlist containing + `sudo` or `su` throws at construction. +17. Implement: validate in constructor; do not actually setuid. + +### Slice I — Artifacts + log truncation +18. Extend tests — + - declared artifact (a file written under workspace) copied into + `request.artifactDir`. + - missing artifact path → reported in result error, status unchanged. + - output exceeding `maxLogBytes` → truncated + notice appended. +19. Implement: copy artifacts after process exit; truncate logs with notice. + +### Slice J — Public API + gates +20. Complete `index.ts` exports to match spec §Interfaces exactly. +21. `public-api.test.ts` — every symbol importable + exercised. +22. Run gates: `bun run test`, `bun run typecheck`, `bun run lint`, + `bun run build`. All green. + +## 6. Convention checklist (enforced by reviewer) + +- **No `any`.** Use `unknown` + narrow. No `@ts-ignore`/`@ts-expect-error`. +- **`verbatimModuleSyntax: true`** → type-only imports use `import type`. +- **`exactOptionalPropertyTypes: true`** → never assign `undefined` to an + optional field; use conditional spread. +- **`noUncheckedIndexedAccess: true`** → narrow array/object access. +- **readonly everywhere** — all interface fields are `readonly`. +- **Error `name`** — each error subclass sets `this.name` in the constructor. +- **ESM only** — `.js` specifiers in imports. No `.cjs`/`.mjs` source. +- **Public surface** — only spec §Interfaces symbols exported from + `src/index.ts`. +- **Test command** — `bun run test` (vitest via nx). NEVER `bun test`. + +## 7. Edge cases the builder must handle + +- **Executor disabled** — `canExecute` returns false for all; direct + `execute` raises `HostExecutorError` (`EXECUTOR_DISABLED`). +- **Wrong type** — `canExecute` false; direct `execute` raises + `HostExecutorError` (`WRONG_EXECUTOR_TYPE`). +- **Spawn failure** — binary not found → `status: "failure"`, `error` + describing the spawn error (not an exception). +- **Non-zero exit** — normal result (`status: "failure"`), not an exception. +- **Timeout grace** — SIGTERM, then SIGKILL after 2s. Do not leak the process. +- **Log truncation** — append a notice when truncated; never exceed + `maxLogBytes`. +- **Working directory escape** — reject `..` traversal outside workspace. +- **Empty allowlist** — nothing allowed; `canExecute` false for any command. +- **`dispose()`** — no-op (no persistent resources); implement to satisfy the + optional `Executor.dispose` contract. + +## 8. Error code map + +| Condition | code | error class | +|--------------------------|-------------------------|--------------------------| +| Executor disabled | `EXECUTOR_DISABLED` | `HostExecutorError` | +| Wrong executor type | `WRONG_EXECUTOR_TYPE` | `HostExecutorError` | +| Missing timeout | `MISSING_TIMEOUT` | `HostExecutorError` | +| Command not allowed | `COMMAND_NOT_ALLOWED` | `CommandNotAllowedError` | +| Timeout exceeded | `HOST_TIMEOUT` | `HostTimeoutError` | +| Privilege escalation | `PRIVILEGE_ESCALATION` | `HostExecutorError` | +| Workdir outside workspace| `WORKDIR_OUTSIDE_WORKSPACE` | `HostExecutorError` | + +## 9. Gates (reviewer runs these) + +```bash +bun install # resolve new workspace deps +bun run test # vitest via nx (NOT `bun test`) +bun run typecheck # strict, no any +bun run lint # eslint clean +bun run build # tsdown produces dist/index.mjs + .d.mts +``` + +Acceptance criteria: all gates green; spec §Test plan items 1–9, 11 pass +(item 10 retry is scheduler-owned — excluded, see §2). + +## 10. ADR + +Optional: `engdocs/adr/ADR-008-host-executor-no-retry-no-setuid.md` — one +paragraph recording that the host executor does not retry (scheduler-owned) +and does not setuid (validates only; sandboxing is container executors' job). +File only if the reviewer/mayor wants the decision durable beyond this plan. diff --git a/packages/runtime-host/package.json b/packages/runtime-host/package.json index 495360dbc..6bd74c429 100644 --- a/packages/runtime-host/package.json +++ b/packages/runtime-host/package.json @@ -2,13 +2,13 @@ "name": "@sverka/runtime-host", "version": "0.0.0", "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" } }, "files": ["dist"], @@ -18,6 +18,10 @@ "lint": "eslint src --ext .ts", "typecheck": "tsc --noEmit" }, + "dependencies": { + "@sverka/runtime": "workspace:*", + "@sverka/ir": "workspace:*" + }, "devDependencies": { "tsdown": "^0.22.0", "typescript": "^5.8.0", diff --git a/packages/runtime-host/src/__tests__/allowlist.test.ts b/packages/runtime-host/src/__tests__/allowlist.test.ts new file mode 100644 index 000000000..5d78ae1f4 --- /dev/null +++ b/packages/runtime-host/src/__tests__/allowlist.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { createAllowlist } from "../allowlist.js"; + +describe("createAllowlist", () => { + it("matches a bare name entry by basename", () => { + const al = createAllowlist(["node"]); + expect(al.isAllowed("node")).toBe(true); + }); + + it("does not match a bare name to a different command", () => { + const al = createAllowlist(["node"]); + expect(al.isAllowed("git")).toBe(false); + }); + + it("matches an absolute path entry exactly", () => { + const al = createAllowlist(["/usr/bin/git"]); + expect(al.isAllowed("/usr/bin/git")).toBe(true); + }); + + it("does not match an absolute path to a bare name", () => { + const al = createAllowlist(["/usr/bin/git"]); + expect(al.isAllowed("git")).toBe(false); + }); + + it("matches a bare entry against the basename of an absolute-path command", () => { + const al = createAllowlist(["node"]); + expect(al.isAllowed("/usr/local/bin/node")).toBe(true); + }); + + it("empty allowlist allows nothing", () => { + const al = createAllowlist([]); + expect(al.isAllowed("node")).toBe(false); + expect(al.isAllowed("/usr/bin/node")).toBe(false); + }); + + it("empty command is not allowed", () => { + const al = createAllowlist(["node"]); + expect(al.isAllowed("")).toBe(false); + }); + + it("exposes the entries", () => { + const al = createAllowlist(["node", "/usr/bin/git"]); + expect(al.entries).toEqual(["node", "/usr/bin/git"]); + }); +}); diff --git a/packages/runtime-host/src/__tests__/errors.test.ts b/packages/runtime-host/src/__tests__/errors.test.ts new file mode 100644 index 000000000..77242ff01 --- /dev/null +++ b/packages/runtime-host/src/__tests__/errors.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { + HostExecutorError, + HostTimeoutError, + CommandNotAllowedError, +} from "../errors.js"; + +describe("HostExecutorError", () => { + it("sets name, code, and context", () => { + const err = new HostExecutorError("boom", "BOOM", { key: "value" }); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("HostExecutorError"); + expect(err.code).toBe("BOOM"); + expect(err.message).toBe("boom"); + expect(err.context).toEqual({ key: "value" }); + }); + + it("context is optional", () => { + const err = new HostExecutorError("boom", "BOOM"); + expect(err.context).toBeUndefined(); + }); +}); + +describe("HostTimeoutError", () => { + it("extends HostExecutorError with code HOST_TIMEOUT", () => { + const err = new HostTimeoutError("timed out", { seconds: 30 }); + expect(err).toBeInstanceOf(HostExecutorError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("HostTimeoutError"); + expect(err.code).toBe("HOST_TIMEOUT"); + expect(err.context).toEqual({ seconds: 30 }); + }); + + it("context is optional", () => { + const err = new HostTimeoutError("timed out"); + expect(err.context).toBeUndefined(); + }); +}); + +describe("CommandNotAllowedError", () => { + it("extends HostExecutorError with code COMMAND_NOT_ALLOWED", () => { + const err = new CommandNotAllowedError("not allowed", { command: "rm" }); + expect(err).toBeInstanceOf(HostExecutorError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("CommandNotAllowedError"); + expect(err.code).toBe("COMMAND_NOT_ALLOWED"); + expect(err.context).toEqual({ command: "rm" }); + }); + + it("context is optional", () => { + const err = new CommandNotAllowedError("not allowed"); + expect(err.context).toBeUndefined(); + }); +}); diff --git a/packages/runtime-host/src/__tests__/helpers/fixtures.ts b/packages/runtime-host/src/__tests__/helpers/fixtures.ts new file mode 100644 index 000000000..662abd29b --- /dev/null +++ b/packages/runtime-host/src/__tests__/helpers/fixtures.ts @@ -0,0 +1,60 @@ +import type { PlanOperation } from "@sverka/ir"; +import type { ExecuteRequest } from "@sverka/runtime"; +import { createAllowlist } from "../../allowlist.js"; +import type { HostExecutorConfig } from "../../config.js"; + +/** + * Build a minimal PlanOperation with executor.type: "host". + * Override any field via `overrides`. + */ +export function makeHostOp( + overrides: Partial = {}, +): PlanOperation { + const base: PlanOperation = { + id: "op-host-1", + kind: "run", + name: "host-check", + dependsOn: [], + executor: { type: "host" }, + resources: { cpu: "1", memory: "512Mi" }, + network: "deny", + credentials: [], + artifacts: [], + retry: { maxAttempts: 1, backoffSeconds: 0, retryOn: ["failure"] }, + timeoutSeconds: 30, + continueOnError: false, + }; + return { ...base, ...overrides }; +} + +/** + * Build an ExecuteRequest with a temp workspace, env, credentials, etc. + */ +export function makeRequest( + operation: PlanOperation, + overrides: Partial = {}, +): ExecuteRequest { + const base: ExecuteRequest = { + operation, + workspace: "/tmp/sverka-test-workspace", + env: {}, + credentials: {}, + cacheDir: "/tmp/sverka-test-cache", + artifactDir: "/tmp/sverka-test-artifacts", + }; + return { ...base, ...overrides }; +} + +/** + * A default config with the host executor enabled and a permissive allowlist. + */ +export function defaultConfig( + overrides: Partial = {}, +): HostExecutorConfig { + const base: HostExecutorConfig = { + enabled: true, + allowlist: createAllowlist(["node", "echo", "sh", "cat", "ls"]), + envAllowlist: ["PATH"], + }; + return { ...base, ...overrides }; +} diff --git a/packages/runtime-host/src/__tests__/host-executor.test.ts b/packages/runtime-host/src/__tests__/host-executor.test.ts new file mode 100644 index 000000000..5fab3ea6b --- /dev/null +++ b/packages/runtime-host/src/__tests__/host-executor.test.ts @@ -0,0 +1,360 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { HostExecutor } from "../host-executor.js"; +import { createAllowlist } from "../allowlist.js"; +import { HostExecutorError, CommandNotAllowedError } from "../errors.js"; +import { + makeHostOp, + makeRequest, + defaultConfig, +} from "./helpers/fixtures.js"; + +let workspace: string; +let artifactDir: string; + +beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), "sverka-ws-")); + artifactDir = await mkdtemp(join(tmpdir(), "sverka-art-")); +}); + +afterEach(async () => { + await rm(workspace, { recursive: true, force: true }); + await rm(artifactDir, { recursive: true, force: true }); +}); + +// --- Slice D: canExecute + spawn + output + exit code --- + +describe("HostExecutor.canExecute", () => { + it("returns false for all operations when disabled", () => { + const exec = new HostExecutor(defaultConfig({ enabled: false })); + expect(exec.canExecute(makeHostOp())).toBe(false); + }); + + it("returns true for host type with allowed command and valid timeout", () => { + const exec = new HostExecutor(defaultConfig()); + expect(exec.canExecute(makeHostOp({ command: "node" }))).toBe(true); + }); + + it("returns false for docker type", () => { + const exec = new HostExecutor(defaultConfig()); + expect( + exec.canExecute( + makeHostOp({ executor: { type: "docker", image: "node:24" } }), + ), + ).toBe(false); + }); + + it("returns false when command is not in allowlist", () => { + const exec = new HostExecutor(defaultConfig()); + expect(exec.canExecute(makeHostOp({ command: "rm" }))).toBe(false); + }); + + it("returns false when timeoutSeconds is missing", () => { + const exec = new HostExecutor(defaultConfig()); + // timeoutSeconds is required in PlanOperation; simulate by setting to 0 + expect( + exec.canExecute(makeHostOp({ command: "node", timeoutSeconds: 0 as unknown as number })), + ).toBe(false); + }); + + it("returns false when timeoutSeconds is <= 0", () => { + const exec = new HostExecutor(defaultConfig()); + expect( + exec.canExecute(makeHostOp({ command: "node", timeoutSeconds: -1 })), + ).toBe(false); + }); +}); + +describe("HostExecutor.execute — spawn and output", () => { + it("captures stdout and returns success for exit 0", async () => { + const exec = new HostExecutor(defaultConfig()); + const op = makeHostOp({ + command: "node", + args: ["-e", "console.log('hello')"], + }); + const result = await exec.execute( + makeRequest(op, { workspace, artifactDir }), + ); + expect(result.status).toBe("success"); + expect(result.exitCode).toBe(0); + expect(result.logs).toContain("hello"); + }); + + it("returns failure for non-zero exit", async () => { + const exec = new HostExecutor(defaultConfig()); + const op = makeHostOp({ + command: "node", + args: ["-e", "process.exit(1)"], + }); + const result = await exec.execute( + makeRequest(op, { workspace, artifactDir }), + ); + expect(result.status).toBe("failure"); + expect(result.exitCode).toBe(1); + }); + + it("captures both stdout and stderr in logs", async () => { + const exec = new HostExecutor(defaultConfig()); + const op = makeHostOp({ + command: "node", + args: ["-e", "console.log('out'); console.error('err')"], + }); + const result = await exec.execute( + makeRequest(op, { workspace, artifactDir }), + ); + expect(result.logs).toContain("out"); + expect(result.logs).toContain("err"); + }); +}); + +// --- Slice E: Timeout --- + +describe("HostExecutor.execute — timeout", () => { + it("kills the process and returns failure on timeout", async () => { + const exec = new HostExecutor(defaultConfig()); + const op = makeHostOp({ + command: "node", + args: ["-e", "setTimeout(()=>{}, 50000)"], + timeoutSeconds: 0.1, + }); + const result = await exec.execute( + makeRequest(op, { workspace, artifactDir }), + ); + expect(result.status).toBe("failure"); + expect(result.error).toContain("timeout"); + }); + + it("raises MISSING_TIMEOUT when timeoutSeconds is 0", async () => { + const exec = new HostExecutor(defaultConfig()); + const op = makeHostOp({ command: "node", timeoutSeconds: 0 }); + await expect( + exec.execute(makeRequest(op, { workspace, artifactDir })), + ).rejects.toThrow(HostExecutorError); + }); +}); + +// --- Slice F: Environment bounding --- + +describe("HostExecutor.execute — environment bounding", () => { + it("does not forward host env vars not in envAllowlist", async () => { + process.env.SVERKA_TEST_SECRET = "leaked"; + const exec = new HostExecutor(defaultConfig({ envAllowlist: ["PATH"] })); + const op = makeHostOp({ + command: "node", + args: ["-e", "console.log(process.env.SVERKA_TEST_SECRET ?? 'absent')"], + }); + const result = await exec.execute( + makeRequest(op, { workspace, artifactDir }), + ); + expect(result.logs).toContain("absent"); + delete process.env.SVERKA_TEST_SECRET; + }); + + it("forwards envAllowlist entries from host", async () => { + const exec = new HostExecutor(defaultConfig({ envAllowlist: ["PATH"] })); + const op = makeHostOp({ + command: "node", + args: ["-e", "console.log(process.env.PATH ? 'has-path' : 'no-path')"], + }); + const result = await exec.execute( + makeRequest(op, { workspace, artifactDir }), + ); + expect(result.logs).toContain("has-path"); + }); + + it("forwards request.env values", async () => { + const exec = new HostExecutor(defaultConfig()); + const op = makeHostOp({ + command: "node", + args: ["-e", "console.log(process.env.FOO)"], + }); + const result = await exec.execute( + makeRequest(op, { workspace, artifactDir, env: { FOO: "bar" } }), + ); + expect(result.logs).toContain("bar"); + }); + + it("forwards request.credentials values", async () => { + const exec = new HostExecutor(defaultConfig()); + const op = makeHostOp({ + command: "node", + args: ["-e", "console.log(process.env.SECRET)"], + }); + const result = await exec.execute( + makeRequest(op, { workspace, artifactDir, credentials: { SECRET: "s" } }), + ); + expect(result.logs).toContain("s"); + }); +}); + +// --- Slice G: Working directory --- + +describe("HostExecutor.execute — working directory", () => { + it("uses request.workspace as cwd by default", async () => { + const exec = new HostExecutor(defaultConfig()); + const op = makeHostOp({ + command: "node", + args: ["-e", "console.log(process.cwd())"], + }); + const result = await exec.execute( + makeRequest(op, { workspace, artifactDir }), + ); + expect(result.logs).toContain(workspace); + }); + + it("honors operation.workingDir relative to workspace", async () => { + await mkdir(join(workspace, "subdir"), { recursive: true }); + const exec = new HostExecutor(defaultConfig()); + const op = makeHostOp({ + command: "node", + args: ["-e", "console.log(process.cwd())"], + workingDir: "subdir", + }); + const result = await exec.execute( + makeRequest(op, { workspace, artifactDir }), + ); + expect(result.logs).toContain(join(workspace, "subdir")); + }); + + it("rejects workingDir resolving outside workspace", async () => { + const exec = new HostExecutor(defaultConfig()); + const op = makeHostOp({ + command: "node", + args: ["-e", "console.log('hi')"], + workingDir: "../../etc", + }); + await expect( + exec.execute(makeRequest(op, { workspace, artifactDir })), + ).rejects.toMatchObject({ code: "WORKDIR_OUTSIDE_WORKSPACE" }); + }); +}); + +// --- Slice H: Privilege escalation prevention --- + +describe("HostExecutor — privilege escalation prevention", () => { + it("rejects runAsUid: 0 at construction", () => { + expect( + () => new HostExecutor(defaultConfig({ runAsUid: 0 })), + ).toThrow(HostExecutorError); + }); + + it("rejects sudo in allowlist at construction", () => { + expect( + () => + new HostExecutor( + defaultConfig({ + allowlist: createAllowlist(["node", "sudo"]), + }), + ), + ).toThrow(HostExecutorError); + }); + + it("rejects su in allowlist at construction", () => { + expect( + () => + new HostExecutor( + defaultConfig({ + allowlist: createAllowlist(["su"]), + }), + ), + ).toThrow(HostExecutorError); + }); +}); + +// --- Slice I: Artifacts + log truncation --- + +describe("HostExecutor.execute — artifacts", () => { + it("copies declared artifacts into artifactDir", async () => { + await writeFile(join(workspace, "report.txt"), "test report"); + const exec = new HostExecutor(defaultConfig()); + const op = makeHostOp({ + command: "node", + args: ["-e", "console.log('done')"], + artifacts: [{ path: "report.txt", retain: true }], + }); + const result = await exec.execute( + makeRequest(op, { workspace, artifactDir }), + ); + expect(result.artifacts).toHaveLength(1); + expect(result.artifacts[0]).toContain("report.txt"); + }); + + it("reports missing artifacts in error without changing status", async () => { + const exec = new HostExecutor(defaultConfig()); + const op = makeHostOp({ + command: "node", + args: ["-e", "console.log('done')"], + artifacts: [{ path: "nonexistent.txt", retain: true }], + }); + const result = await exec.execute( + makeRequest(op, { workspace, artifactDir }), + ); + expect(result.status).toBe("success"); + expect(result.error).toContain("missing artifact"); + }); +}); + +describe("HostExecutor.execute — log truncation", () => { + it("truncates logs exceeding maxLogBytes with a notice", async () => { + const exec = new HostExecutor( + defaultConfig({ maxLogBytes: 20 }), + ); + const op = makeHostOp({ + command: "node", + args: ["-e", "console.log('A'.repeat(100))"], + }); + const result = await exec.execute( + makeRequest(op, { workspace, artifactDir }), + ); + expect(result.logs.length).toBeLessThanOrEqual(20 + "\n[log truncated]".length); + expect(result.logs).toContain("[log truncated]"); + }); +}); + +// --- Edge cases --- + +describe("HostExecutor.execute — edge cases", () => { + it("raises EXECUTOR_DISABLED when disabled", async () => { + const exec = new HostExecutor(defaultConfig({ enabled: false })); + await expect( + exec.execute(makeRequest(makeHostOp(), { workspace, artifactDir })), + ).rejects.toMatchObject({ code: "EXECUTOR_DISABLED" }); + }); + + it("raises WRONG_EXECUTOR_TYPE for non-host operation", async () => { + const exec = new HostExecutor(defaultConfig()); + const op = makeHostOp({ executor: { type: "docker", image: "node:24" } }); + await expect( + exec.execute(makeRequest(op, { workspace, artifactDir })), + ).rejects.toMatchObject({ code: "WRONG_EXECUTOR_TYPE" }); + }); + + it("raises COMMAND_NOT_ALLOWED for disallowed command", async () => { + const exec = new HostExecutor(defaultConfig()); + const op = makeHostOp({ command: "rm" }); + await expect( + exec.execute(makeRequest(op, { workspace, artifactDir })), + ).rejects.toThrow(CommandNotAllowedError); + }); + + it("returns failure for spawn error (binary not found)", async () => { + const exec = new HostExecutor( + defaultConfig({ + allowlist: createAllowlist(["nonexistent-binary-xyz"]), + }), + ); + const op = makeHostOp({ command: "nonexistent-binary-xyz" }); + const result = await exec.execute( + makeRequest(op, { workspace, artifactDir }), + ); + expect(result.status).toBe("failure"); + expect(result.error).toContain("spawn error"); + }); + + it("dispose is a no-op", async () => { + const exec = new HostExecutor(defaultConfig()); + await expect(exec.dispose()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/runtime-host/src/__tests__/public-api.test.ts b/packages/runtime-host/src/__tests__/public-api.test.ts new file mode 100644 index 000000000..6ce11bd90 --- /dev/null +++ b/packages/runtime-host/src/__tests__/public-api.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { + HostExecutor, + createAllowlist, + HostExecutorError, + HostTimeoutError, + CommandNotAllowedError, +} from "../index.js"; +import type { HostExecutorConfig, CommandAllowlist } from "../index.js"; + +describe("public API", () => { + it("exports HostExecutor class", () => { + expect(typeof HostExecutor).toBe("function"); + const exec = new HostExecutor({ + enabled: true, + allowlist: createAllowlist(["node"]), + envAllowlist: ["PATH"], + }); + expect(exec.name).toBe("host"); + expect(typeof exec.canExecute).toBe("function"); + expect(typeof exec.execute).toBe("function"); + expect(typeof exec.dispose).toBe("function"); + }); + + it("exports createAllowlist function", () => { + expect(typeof createAllowlist).toBe("function"); + const al = createAllowlist(["node"]); + expect(al.isAllowed("node")).toBe(true); + }); + + it("exports error classes", () => { + expect(new HostExecutorError("x", "X")).toBeInstanceOf(Error); + expect(new HostTimeoutError("x")).toBeInstanceOf(HostExecutorError); + expect(new CommandNotAllowedError("x")).toBeInstanceOf(HostExecutorError); + }); + + it("exports types (compile-time check)", () => { + const config: HostExecutorConfig = { + enabled: true, + allowlist: createAllowlist(["node"]), + envAllowlist: ["PATH"], + }; + const al: CommandAllowlist = createAllowlist(["node"]); + expect(config.enabled).toBe(true); + expect(al.isAllowed("node")).toBe(true); + }); +}); diff --git a/packages/runtime-host/src/allowlist.ts b/packages/runtime-host/src/allowlist.ts new file mode 100644 index 000000000..151d6ab82 --- /dev/null +++ b/packages/runtime-host/src/allowlist.ts @@ -0,0 +1,38 @@ +import { basename, isAbsolute } from "node:path"; + +/** + * An allowlist of commands the host executor may run. Entries are binary + * names (resolved via PATH) or absolute paths. Glob patterns are not + * supported to keep matching deterministic. + */ +export interface CommandAllowlist { + readonly entries: readonly string[]; + /** Returns true if the given command is allowed. */ + isAllowed(command: string): boolean; +} + +/** + * Create a command allowlist from a list of entries. + * + * Matching rule: an entry matches a command if: + * (a) the entry is an absolute path and equals the command exactly, or + * (b) the entry is a bare name and equals the command's basename. + * + * No globs. No partial matches. Empty list → nothing allowed. + */ +export function createAllowlist(entries: readonly string[]): CommandAllowlist { + const normalized = [...entries]; + return { + entries: normalized, + isAllowed(command: string): boolean { + if (!command) return false; + const cmdBasename = basename(command); + return normalized.some((entry) => { + if (isAbsolute(entry)) { + return entry === command; + } + return entry === cmdBasename; + }); + }, + }; +} diff --git a/packages/runtime-host/src/config.ts b/packages/runtime-host/src/config.ts new file mode 100644 index 000000000..629f7ea61 --- /dev/null +++ b/packages/runtime-host/src/config.ts @@ -0,0 +1,22 @@ +import type { CommandAllowlist } from "./allowlist.js"; + +/** + * Configuration for the host process executor. + * + * `workspace` and `artifactDir` are NOT here — they arrive per-execution via + * `ExecuteRequest`. This config is executor-wide. + */ +export interface HostExecutorConfig { + /** Must be true to enable the host executor. Defaults to false. */ + readonly enabled: boolean; + /** Allowlist of binary names or absolute paths that may be executed. */ + readonly allowlist: CommandAllowlist; + /** Env vars from the host that are forwarded to child processes. */ + readonly envAllowlist: readonly string[]; + /** Extra env vars to inject. */ + readonly env?: Readonly>; + /** Maximum log size in bytes before truncation. Defaults to 10 MiB. */ + readonly maxLogBytes?: number; + /** Default uid to run as. Defaults to current user. Not elevated. */ + readonly runAsUid?: number; +} diff --git a/packages/runtime-host/src/errors.ts b/packages/runtime-host/src/errors.ts new file mode 100644 index 000000000..ad038945c --- /dev/null +++ b/packages/runtime-host/src/errors.ts @@ -0,0 +1,31 @@ +/** + * Base error class for the runtime-host package. All host executor errors + * extend this so callers can catch the full family with a single + * `instanceof HostExecutorError`. + */ +export class HostExecutorError extends Error { + constructor( + message: string, + readonly code: string, + readonly context?: Record, + ) { + super(message); + this.name = "HostExecutorError"; + } +} + +/** Raised when a process exceeds its timeout. */ +export class HostTimeoutError extends HostExecutorError { + constructor(message: string, context?: Record) { + super(message, "HOST_TIMEOUT", context); + this.name = "HostTimeoutError"; + } +} + +/** Raised when a command is not in the allowlist. */ +export class CommandNotAllowedError extends HostExecutorError { + constructor(message: string, context?: Record) { + super(message, "COMMAND_NOT_ALLOWED", context); + this.name = "CommandNotAllowedError"; + } +} diff --git a/packages/runtime-host/src/host-executor.ts b/packages/runtime-host/src/host-executor.ts new file mode 100644 index 000000000..b0c7fdb78 --- /dev/null +++ b/packages/runtime-host/src/host-executor.ts @@ -0,0 +1,305 @@ +import { spawn } from "node:child_process"; +import { copyFile, mkdir } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import type { Executor, ExecuteRequest, ExecuteResult } from "@sverka/runtime"; +import type { PlanOperation } from "@sverka/ir"; +import type { HostExecutorConfig } from "./config.js"; +import { + HostExecutorError, + HostTimeoutError, + CommandNotAllowedError, +} from "./errors.js"; + +const DEFAULT_MAX_LOG_BYTES = 10 * 1024 * 1024; // 10 MiB +const GRACE_PERIOD_MS = 2000; +const TRUNCATION_NOTICE = "\n[log truncated]"; + +/** + * Host process implementation of the Executor interface. + * + * Restricted: only operations with executor.type === "host" are eligible, + * and only when config.enabled is true. + */ +export class HostExecutor implements Executor { + readonly name = "host"; + private readonly config: HostExecutorConfig; + private readonly maxLogBytes: number; + + constructor(config: HostExecutorConfig) { + // Validate at construction: no privilege escalation. + if (config.runAsUid === 0) { + throw new HostExecutorError( + "runAsUid must not be 0 (root)", + "PRIVILEGE_ESCALATION", + { runAsUid: config.runAsUid }, + ); + } + for (const entry of config.allowlist.entries) { + const base = entry.split("/").pop() ?? entry; + if (base === "sudo" || base === "su") { + throw new HostExecutorError( + `allowlist must not contain "${base}"`, + "PRIVILEGE_ESCALATION", + { entry }, + ); + } + } + this.config = config; + this.maxLogBytes = config.maxLogBytes ?? DEFAULT_MAX_LOG_BYTES; + } + + canExecute(operation: PlanOperation): boolean { + if (!this.config.enabled) return false; + if (operation.executor.type !== "host") return false; + const command = operation.command ?? ""; + if (!this.config.allowlist.isAllowed(command)) return false; + if (operation.timeoutSeconds === undefined || operation.timeoutSeconds <= 0) { + return false; + } + return true; + } + + async execute(request: ExecuteRequest): Promise { + const op = request.operation; + const start = Date.now(); + + // 1. Validate enabled. + if (!this.config.enabled) { + throw new HostExecutorError( + "host executor is disabled", + "EXECUTOR_DISABLED", + ); + } + // 2. Validate executor type. + if (op.executor.type !== "host") { + throw new HostExecutorError( + `expected executor.type "host", got "${op.executor.type}"`, + "WRONG_EXECUTOR_TYPE", + { type: op.executor.type }, + ); + } + // 3. Validate timeout. + if (op.timeoutSeconds === undefined || op.timeoutSeconds <= 0) { + throw new HostExecutorError( + "timeoutSeconds must be present and > 0", + "MISSING_TIMEOUT", + { timeoutSeconds: op.timeoutSeconds }, + ); + } + // 4. Validate allowlist. + const command = op.command ?? ""; + if (!this.config.allowlist.isAllowed(command)) { + throw new CommandNotAllowedError( + `command "${command}" is not in the allowlist`, + { command }, + ); + } + + // 5. Resolve working directory. + const cwd = this.resolveCwd(op, request.workspace); + + // 6. Build env. + const env = this.buildEnv(request); + + // 7. Spawn. + const args = op.args ?? []; + const result = await this.spawnProcess( + command, + [...args], + cwd, + env, + op.timeoutSeconds, + start, + op.id, + ); + + // 8. Collect artifacts. + const artifacts = await this.collectArtifacts( + op, + request.workspace, + request.artifactDir, + ); + + const durationMs = Date.now() - start; + + // Merge artifact errors into the result. + if (artifacts.errors.length > 0) { + const existingError = result.error ?? ""; + const artifactError = `artifact errors: ${artifacts.errors.join("; ")}`; + return { + ...result, + durationMs, + artifacts: artifacts.collected, + error: existingError + ? `${existingError}; ${artifactError}` + : artifactError, + }; + } + + return { ...result, durationMs, artifacts: artifacts.collected }; + } + + async dispose(): Promise { + // No persistent resources to clean up. + } + + // --- internals --- + + private resolveCwd(op: PlanOperation, workspace: string): string { + if (op.workingDir === undefined) { + return workspace; + } + const resolved = isAbsolute(op.workingDir) + ? op.workingDir + : resolve(workspace, op.workingDir); + const rel = relative(workspace, resolved); + if (rel.startsWith("..")) { + throw new HostExecutorError( + `workingDir "${op.workingDir}" resolves outside workspace`, + "WORKDIR_OUTSIDE_WORKSPACE", + { workingDir: op.workingDir, workspace, resolved }, + ); + } + return resolved; + } + + private buildEnv(request: ExecuteRequest): Record { + const env: Record = {}; + // Forward only envAllowlist entries from the host environment. + for (const key of this.config.envAllowlist) { + const val = process.env[key]; + if (val !== undefined) { + env[key] = val; + } + } + // Merge config.env overrides. + if (this.config.env) { + for (const [k, v] of Object.entries(this.config.env)) { + env[k] = v; + } + } + // Merge request.credentials (resolved secret values, keyed by envVar). + for (const [k, v] of Object.entries(request.credentials)) { + env[k] = v; + } + // Merge request.env (operation env vars). + for (const [k, v] of Object.entries(request.env)) { + env[k] = v; + } + return env; + } + + private spawnProcess( + command: string, + args: string[], + cwd: string, + env: Record, + timeoutSeconds: number, + start: number, + operationId: string, + ): Promise { + return new Promise((resolvePromise) => { + let stdout = ""; + let stderr = ""; + let timedOut = false; + + const child = spawn(command, args, { + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => { + if (!child.killed) { + child.kill("SIGKILL"); + } + }, GRACE_PERIOD_MS); + }, timeoutSeconds * 1000); + + child.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + child.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + child.on("error", (err) => { + clearTimeout(timer); + const durationMs = Date.now() - start; + const logs = this.truncateLogs(`stderr: ${err.message}`); + resolvePromise({ + operationId, + status: "failure", + durationMs, + logs, + artifacts: [], + error: `spawn error: ${err.message}`, + }); + }); + + child.on("close", (code) => { + clearTimeout(timer); + const durationMs = Date.now() - start; + const rawLogs = stdout + (stderr ? "\n" + stderr : ""); + const logs = this.truncateLogs(rawLogs); + + if (timedOut) { + resolvePromise({ + operationId, + status: "failure", + durationMs, + ...(code !== null ? { exitCode: code } : {}), + logs, + artifacts: [], + error: `timeout after ${timeoutSeconds}s`, + }); + return; + } + + const status = code === 0 ? "success" : "failure"; + resolvePromise({ + operationId, + status, + ...(code !== null ? { exitCode: code } : {}), + durationMs, + logs, + artifacts: [], + ...(status === "failure" ? { error: `exit code ${code}` } : {}), + }); + }); + }); + } + + private truncateLogs(logs: string): string { + if (logs.length <= this.maxLogBytes) return logs; + return logs.slice(0, this.maxLogBytes) + TRUNCATION_NOTICE; + } + + private async collectArtifacts( + op: PlanOperation, + workspace: string, + artifactDir: string, + ): Promise<{ collected: string[]; errors: string[] }> { + const collected: string[] = []; + const errors: string[] = []; + + for (const artifact of op.artifacts) { + const src = isAbsolute(artifact.path) + ? artifact.path + : join(workspace, artifact.path); + const dest = join(artifactDir, artifact.name ?? artifact.path); + try { + await mkdir(dirname(dest), { recursive: true }); + await copyFile(src, dest); + collected.push(dest); + } catch { + errors.push(`missing artifact: ${artifact.path}`); + } + } + + return { collected, errors }; + } +} diff --git a/packages/runtime-host/src/index.ts b/packages/runtime-host/src/index.ts index 5cc142f00..23dec8492 100644 --- a/packages/runtime-host/src/index.ts +++ b/packages/runtime-host/src/index.ts @@ -1 +1,7 @@ // @sverka/runtime-host — public API + +export { HostExecutor } from "./host-executor.js"; +export { type HostExecutorConfig } from "./config.js"; +export { type CommandAllowlist, createAllowlist } from "./allowlist.js"; +export { HostExecutorError, HostTimeoutError, CommandNotAllowedError } + from "./errors.js"; diff --git a/specs/05-runtime-host/spec.md b/specs/05-runtime-host/spec.md index cd620f422..14a2617d8 100644 --- a/specs/05-runtime-host/spec.md +++ b/specs/05-runtime-host/spec.md @@ -30,7 +30,8 @@ timeouts, capture output, and prevent ambient privilege escalation. - Working directory constrained to the workspace. 5. Apply a command allowlist to restrict which binaries may be invoked. 6. Collect exit code, logs, and artifacts. -7. Support retry and timeout policies from the Plan. +7. Return failures (not exceptions) so the scheduler can apply retry + policies from the Plan. The host executor itself does not retry. 8. Be fully testable without a container runtime. ## Non-goals @@ -61,10 +62,6 @@ export { HostExecutorError, HostTimeoutError, CommandNotAllowedError } export interface HostExecutorConfig { /** Must be true to enable the host executor. Defaults to false. */ readonly enabled: boolean; - /** Workspace root; child processes run with this as cwd. */ - readonly workspace: string; - /** Directory for collected artifacts. */ - readonly artifactDir: string; /** Allowlist of binary names or absolute paths that may be executed. */ readonly allowlist: CommandAllowlist; /** Env vars from the host that are forwarded to child processes. */ @@ -134,12 +131,12 @@ HostExecutor.execute(request) - Start empty. - Forward only envAllowlist entries from the host environment. - Merge config.env overrides. - - Merge operation.credentials env vars. - - Merge operation.env vars. + - Merge request.credentials (resolved secret values, keyed by envVar). + - Merge request.env (operation env vars). 6. Spawn child process: - command: operation.command - args: operation.args - - cwd: config.workspace (or operation.workingDir if relative to workspace) + - cwd: request.workspace (or operation.workingDir if relative to it) - env: built env - stdio: pipe (capture stdout + stderr) 7. Apply timeout: @@ -150,7 +147,7 @@ HostExecutor.execute(request) - Concatenate stdout + stderr. - Truncate at maxLogBytes with a notice. 9. Collect artifacts: - - Copy declared artifact paths into config.artifactDir. + - Copy declared artifact paths into request.artifactDir. 10. Return ExecuteResult: - status: success (exit 0) | failure (exit != 0) | cancelled - exitCode, durationMs, logs, artifacts @@ -164,9 +161,9 @@ HostExecutor.execute(request) | Type guard | `operation.executor.type` must be `"host"` | | Command allowlist | `command` must match an allowlist entry | | Mandatory timeout | `timeoutSeconds` must be present and > 0 | -| Bounded env | Only `envAllowlist` + `credentials` + `operation.env` | +| Bounded env | Only `envAllowlist` + `request.credentials` + `request.env` | | No ambient env leakage | Host env not forwarded unless in `envAllowlist` | -| Workspace-constrained cwd | `cwd` is within `config.workspace` | +| Workspace-constrained cwd | `cwd` is within `request.workspace` | | No privilege escalation | `runAsUid` must not be 0; no `sudo` in allowlist | | No network isolation | Host network is ambient; document this limitation | @@ -237,7 +234,7 @@ Rules: `sudo` or `su`, `HostExecutorError` with code `PRIVILEGE_ESCALATION` is raised at construction time. 7. **Working directory.** If `operation.workingDir` resolves outside - `config.workspace`, `HostExecutorError` with code `WORKDIR_OUTSIDE_WORKSPACE` + `request.workspace`, `HostExecutorError` with code `WORKDIR_OUTSIDE_WORKSPACE` is raised. 8. **Process failure.** A non-zero exit code produces `ExecuteResult.status: "failure"` with the exit code and logs. This is not @@ -249,8 +246,8 @@ Rules: ## Test plan -Tests live in `packages/runtime-host/src/__tests__/` and run via `bun test`. -No Docker daemon is required. +Tests live in `packages/runtime-host/src/__tests__/` and run via +`bun run test` (vitest via nx). No Docker daemon is required. 1. **canExecute** - Returns `false` for all operations when `config.enabled` is `false`. @@ -280,12 +277,12 @@ No Docker daemon is required. 5. **Environment bounding** - Only `envAllowlist` entries from the host are forwarded. - - `operation.env` values are present in the child environment. - - `operation.credentials` env vars are present. + - `request.env` values are present in the child environment. + - `request.credentials` env vars are present. - A host env var not in `envAllowlist` is not present in the child. 6. **Working directory** - - The child process `cwd` is `config.workspace` by default. + - The child process `cwd` is `request.workspace` by default. - `operation.workingDir` relative to the workspace is honored. - `operation.workingDir` resolving outside the workspace raises `HostExecutorError` (`WORKDIR_OUTSIDE_WORKSPACE`). @@ -297,24 +294,25 @@ No Docker daemon is required. `HostExecutorError` (`PRIVILEGE_ESCALATION`) at construction. 8. **Artifacts** - - Declared artifact paths are copied into `config.artifactDir`. + - Declared artifact paths are copied into `request.artifactDir`. - Missing artifact paths are reported in the result error but do not change the operation status. 9. **Log truncation** - Output exceeding `maxLogBytes` is truncated and a notice is appended. -10. **Retry policy** - - An operation with `maxAttempts: 3` that fails twice then succeeds - returns `status: "success"`. - - An operation that fails all attempts returns `status: "failure"`. +10. **Retry policy** (scheduler-owned — not a host-executor unit test) + - The host executor returns `status: "failure"` for failed runs; the + scheduler (`@sverka/runtime`) applies `maxAttempts`/`retryOn`/ + `backoffSeconds`. Verified at the runtime integration level, not in + this package's suite. 11. **Type safety** - `bun run typecheck` passes with `strict: true` and no `any` types. 12. **Commands** ```bash - bun test packages/runtime-host + bun run test bun run typecheck bun run lint ``` From d0393a30e77ff2f75a96803c0c9bcccc542ceacd Mon Sep 17 00:00:00 2001 From: Petr Plenkov Date: Sun, 9 Aug 2026 23:43:00 +0200 Subject: [PATCH 2/3] fix: Add ESLint 9 flat config + fix per-package lint scripts (sv-ei2) - Created eslint.config.mjs (TypeScript-aware, strict, ESM, ESLint 9 flat config) - Added typescript-eslint dependency - Fixed all per-package lint scripts: removed legacy --ext .ts flag (removed in ESLint 9) - Fixed dead imports: core/plan.ts (OperationOutcome), ir/validate.ts (PlanOperation), runtime-host/host-executor.ts (HostTimeoutError) - Relaxed no-unused-vars to warn for test files (standard practice) - Lint now passes repo-wide Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/checks/package.json | 2 +- packages/cli/package.json | 2 +- packages/compiler-earthly/package.json | 2 +- packages/compiler-github/package.json | 2 +- packages/compiler-gitlab/package.json | 2 +- packages/core/package.json | 2 +- packages/core/src/internal/plan.ts | 2 +- packages/findings/package.json | 2 +- packages/ir/package.json | 2 +- packages/planner/package.json | 2 +- packages/policy/package.json | 2 +- packages/runtime-docker/package.json | 16 ++++++++++------ packages/runtime-host/package.json | 2 +- packages/runtime-host/src/host-executor.ts | 1 - packages/runtime-podman/package.json | 2 +- packages/runtime-remote/package.json | 2 +- packages/runtime/package.json | 2 +- packages/sdk/package.json | 2 +- 18 files changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/checks/package.json b/packages/checks/package.json index b6b535dc4..d5e1731b0 100644 --- a/packages/checks/package.json +++ b/packages/checks/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/cli/package.json b/packages/cli/package.json index dbdb7416d..7f6597f2c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/compiler-earthly/package.json b/packages/compiler-earthly/package.json index 5b1084551..1ba995f07 100644 --- a/packages/compiler-earthly/package.json +++ b/packages/compiler-earthly/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/compiler-github/package.json b/packages/compiler-github/package.json index 12507ffc3..fe7000220 100644 --- a/packages/compiler-github/package.json +++ b/packages/compiler-github/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/compiler-gitlab/package.json b/packages/compiler-gitlab/package.json index 5377a655a..7ee7585e1 100644 --- a/packages/compiler-gitlab/package.json +++ b/packages/compiler-gitlab/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/core/package.json b/packages/core/package.json index bb9a296e6..98264a092 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/core/src/internal/plan.ts b/packages/core/src/internal/plan.ts index b338dd28f..c37e314f8 100644 --- a/packages/core/src/internal/plan.ts +++ b/packages/core/src/internal/plan.ts @@ -1,5 +1,5 @@ import type { Operation, OperationSpec } from "../operation.js"; -import type { Runtime, RuntimeResult, OperationOutcome } from "../runtime.js"; +import type { Runtime, RuntimeResult } from "../runtime.js"; import { asNode, createNode, withSpec, type OperationNode } from "./node.js"; import { assignId, matrixChildId, isKnownKind } from "./ids.js"; import { canonicalStringify } from "./canonical.js"; diff --git a/packages/findings/package.json b/packages/findings/package.json index fce1ec78a..30a5e9d94 100644 --- a/packages/findings/package.json +++ b/packages/findings/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/ir/package.json b/packages/ir/package.json index 1e48902b4..96bf93f32 100644 --- a/packages/ir/package.json +++ b/packages/ir/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/planner/package.json b/packages/planner/package.json index c125ce17a..d7bced8eb 100644 --- a/packages/planner/package.json +++ b/packages/planner/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/policy/package.json b/packages/policy/package.json index f87254146..706421d66 100644 --- a/packages/policy/package.json +++ b/packages/policy/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/runtime-docker/package.json b/packages/runtime-docker/package.json index daa1466be..d7615dc65 100644 --- a/packages/runtime-docker/package.json +++ b/packages/runtime-docker/package.json @@ -2,22 +2,26 @@ "name": "@sverka/runtime-docker", "version": "0.0.0", "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" } }, "files": ["dist"], "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, + "dependencies": { + "@sverka/runtime": "workspace:*", + "@sverka/ir": "workspace:*" + }, "devDependencies": { "tsdown": "^0.22.0", "typescript": "^5.8.0", diff --git a/packages/runtime-host/package.json b/packages/runtime-host/package.json index 6bd74c429..cf335dec3 100644 --- a/packages/runtime-host/package.json +++ b/packages/runtime-host/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/runtime-host/src/host-executor.ts b/packages/runtime-host/src/host-executor.ts index b0c7fdb78..031e1ef65 100644 --- a/packages/runtime-host/src/host-executor.ts +++ b/packages/runtime-host/src/host-executor.ts @@ -6,7 +6,6 @@ import type { PlanOperation } from "@sverka/ir"; import type { HostExecutorConfig } from "./config.js"; import { HostExecutorError, - HostTimeoutError, CommandNotAllowedError, } from "./errors.js"; diff --git a/packages/runtime-podman/package.json b/packages/runtime-podman/package.json index 0e184d958..3890fda8d 100644 --- a/packages/runtime-podman/package.json +++ b/packages/runtime-podman/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/runtime-remote/package.json b/packages/runtime-remote/package.json index daa5b4add..ceef69718 100644 --- a/packages/runtime-remote/package.json +++ b/packages/runtime-remote/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index c2766f1c3..5aad201b2 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 5349588fb..5628b66b1 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "devDependencies": { From 5908184f5b4d302bfe5d123cd1a05dbbcf7b0cdf Mon Sep 17 00:00:00 2001 From: Petr Plenkov Date: Tue, 11 Aug 2026 01:54:22 +0200 Subject: [PATCH 3/3] fix(runtime-host): extract validateRequest/finalizeResult to reduce complexity; pin devDeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor HostExecutor.execute (62 lines → 17, complexity 14 → 3) by extracting validateRequest and finalizeResult helpers. - Pin devDependency versions to resolve Codacy dependency-hijack warning. - Fix missing OperationOutcome import in core/plan.ts from rebase. --- bun.lock | 22 +++++--- package.json | 14 ++--- packages/core/src/internal/plan.ts | 2 +- packages/runtime-host/src/host-executor.ts | 61 ++++++++++------------ 4 files changed, 50 insertions(+), 49 deletions(-) diff --git a/bun.lock b/bun.lock index bb07dd16a..aa7f20095 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,14 @@ "": { "name": "sverka", "devDependencies": { - "@types/node": "^24.0.0", - "eslint": "^9.0.0", - "nx": "^21.0.0", - "prettier": "^3.0.0", - "tsdown": "^0.22.14", - "typescript": "^5.8.0", + "@types/node": "24.13.3", + "eslint": "9.39.5", + "nx": "21.6.11", + "prettier": "3.9.6", + "tsdown": "0.22.14", + "typescript": "5.9.3", "typescript-eslint": "8.67.0", - "vitest": "^3.0.0", + "vitest": "3.2.7", }, }, "packages/checks": { @@ -123,6 +123,10 @@ "packages/runtime-docker": { "name": "@sverka/runtime-docker", "version": "0.0.0", + "dependencies": { + "@sverka/ir": "workspace:*", + "@sverka/runtime": "workspace:*", + }, "devDependencies": { "tsdown": "^0.22.0", "typescript": "^5.8.0", @@ -132,6 +136,10 @@ "packages/runtime-host": { "name": "@sverka/runtime-host", "version": "0.0.0", + "dependencies": { + "@sverka/ir": "workspace:*", + "@sverka/runtime": "workspace:*", + }, "devDependencies": { "tsdown": "^0.22.0", "typescript": "^5.8.0", diff --git a/package.json b/package.json index 78c7d6768..b0f3c6df8 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,14 @@ "clean": "nx reset" }, "devDependencies": { - "@types/node": "^24.0.0", - "eslint": "^9.0.0", - "nx": "^21.0.0", - "prettier": "^3.0.0", - "tsdown": "^0.22.14", - "typescript": "^5.8.0", + "@types/node": "24.13.3", + "eslint": "9.39.5", + "nx": "21.6.11", + "prettier": "3.9.6", + "tsdown": "0.22.14", + "typescript": "5.9.3", "typescript-eslint": "8.67.0", - "vitest": "^3.0.0" + "vitest": "3.2.7" }, "workspaces": [ "packages/*" diff --git a/packages/core/src/internal/plan.ts b/packages/core/src/internal/plan.ts index c37e314f8..8cd9fb426 100644 --- a/packages/core/src/internal/plan.ts +++ b/packages/core/src/internal/plan.ts @@ -1,5 +1,5 @@ import type { Operation, OperationSpec } from "../operation.js"; -import type { Runtime, RuntimeResult } from "../runtime.js"; +import type { OperationOutcome, Runtime, RuntimeResult } from "../runtime.js"; import { asNode, createNode, withSpec, type OperationNode } from "./node.js"; import { assignId, matrixChildId, isKnownKind } from "./ids.js"; import { canonicalStringify } from "./canonical.js"; diff --git a/packages/runtime-host/src/host-executor.ts b/packages/runtime-host/src/host-executor.ts index 031e1ef65..6a2f8563f 100644 --- a/packages/runtime-host/src/host-executor.ts +++ b/packages/runtime-host/src/host-executor.ts @@ -61,15 +61,26 @@ export class HostExecutor implements Executor { async execute(request: ExecuteRequest): Promise { const op = request.operation; const start = Date.now(); + const command = this.validateRequest(op); + const cwd = this.resolveCwd(op, request.workspace); + const env = this.buildEnv(request); + const result = await this.spawnProcess( + command, + [...(op.args ?? [])], + cwd, + env, + op.timeoutSeconds, + start, + op.id, + ); + return this.finalizeResult(result, op, request, start); + } - // 1. Validate enabled. + /** Validate enabled, executor type, timeout, and allowlist. Returns command. */ + private validateRequest(op: PlanOperation): string { if (!this.config.enabled) { - throw new HostExecutorError( - "host executor is disabled", - "EXECUTOR_DISABLED", - ); + throw new HostExecutorError("host executor is disabled", "EXECUTOR_DISABLED"); } - // 2. Validate executor type. if (op.executor.type !== "host") { throw new HostExecutorError( `expected executor.type "host", got "${op.executor.type}"`, @@ -77,7 +88,6 @@ export class HostExecutor implements Executor { { type: op.executor.type }, ); } - // 3. Validate timeout. if (op.timeoutSeconds === undefined || op.timeoutSeconds <= 0) { throw new HostExecutorError( "timeoutSeconds must be present and > 0", @@ -85,7 +95,6 @@ export class HostExecutor implements Executor { { timeoutSeconds: op.timeoutSeconds }, ); } - // 4. Validate allowlist. const command = op.command ?? ""; if (!this.config.allowlist.isAllowed(command)) { throw new CommandNotAllowedError( @@ -93,35 +102,22 @@ export class HostExecutor implements Executor { { command }, ); } + return command; + } - // 5. Resolve working directory. - const cwd = this.resolveCwd(op, request.workspace); - - // 6. Build env. - const env = this.buildEnv(request); - - // 7. Spawn. - const args = op.args ?? []; - const result = await this.spawnProcess( - command, - [...args], - cwd, - env, - op.timeoutSeconds, - start, - op.id, - ); - - // 8. Collect artifacts. + /** Collect artifacts and merge into the spawn result. */ + private async finalizeResult( + result: ExecuteResult, + op: PlanOperation, + request: ExecuteRequest, + start: number, + ): Promise { const artifacts = await this.collectArtifacts( op, request.workspace, request.artifactDir, ); - const durationMs = Date.now() - start; - - // Merge artifact errors into the result. if (artifacts.errors.length > 0) { const existingError = result.error ?? ""; const artifactError = `artifact errors: ${artifacts.errors.join("; ")}`; @@ -129,12 +125,9 @@ export class HostExecutor implements Executor { ...result, durationMs, artifacts: artifacts.collected, - error: existingError - ? `${existingError}; ${artifactError}` - : artifactError, + error: existingError ? `${existingError}; ${artifactError}` : artifactError, }; } - return { ...result, durationMs, artifacts: artifacts.collected }; }