From aa0d3677e68a6163d215151691444a3731d02cee Mon Sep 17 00:00:00 2001 From: Clouds Beyond <34269366+cloudsbeyond@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:41:41 +0800 Subject: [PATCH] feat: harden local runtime release boundary Change-Id: I047873919d63cbbed9c4e5807ddd99d6bebc78ee --- .github/workflows/ci.yml | 32 ++++ CHANGELOG.md | 47 ++++++ README.md | 11 ++ README.zh-CN.md | 9 ++ architecture/README.md | 10 ++ architecture/project-traceability.md | 49 ++++++ architecture/project-traceability.yaml | 164 +++++++++++++++++++++ architecture/release-readiness-contract.md | 56 +++++++ architecture/rpc-transport-contract.md | 60 ++++++++ package.json | 6 +- src/rpc/server.ts | 118 ++++++++++++--- test/cli-rpc-smoke.test.ts | 35 +++++ test/publication-identity.test.ts | 69 ++++++++- test/rpc.contract.test.ts | 137 +++++++++++++++++ 14 files changed, 779 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 architecture/project-traceability.md create mode 100644 architecture/project-traceability.yaml create mode 100644 architecture/release-readiness-contract.md create mode 100644 architecture/rpc-transport-contract.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..df2ce43 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + release-check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [20, 22] + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm security:audit + - run: pnpm release:check diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c4715a8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +All notable changes to Agent Runtime Services are documented in this file. The +project follows Semantic Versioning after its first published release; until +then, the `Unreleased` section is the release-candidate source of truth. + +## [Unreleased] + +### Added + +- `RPC-LOOPBACK-001`: fail-closed loopback validation for the local RPC server. +- `RPC-JSONRPC-002`: JSON-RPC 2.0 parse, request, method, parameter, and internal + error classification. +- `RPC-BODY-003`: a 1 MiB local RPC request-body limit enforced before dispatch. +- `RELEASE-GATE-004`: one `pnpm release:check` command shared by contributors + and CI on supported Node.js versions. +- Machine-readable and human-readable project traceability for release + hardening requirements. + +### Changed + +- The local RPC documentation now makes non-loopback exposure an explicit + human-owned architecture and security gate. + +### Validation Evidence + +- The release-candidate gate passed 116 tests across 15 test files under Node 20 + and Node 22, plus typecheck, build, and a 21-file package dry-run. +- The production dependency audit reported no known vulnerabilities at the time + of validation. +- Two clean external consumer snapshots installed the same generated tarball: + one passed 51 focused integration tests plus typecheck, and one passed 8 + focused integration tests plus typecheck. + +### Not Yet Proven + +- Real provider smoke with owner-managed credentials. +- Acceptance in concrete domain-agent and build-agent repositories. +- npm publication and production operation. + +## [0.1.0] - Unpublished baseline + +- Established Runtime Core and Agent Services with local JSON-RPC, model, + artifact, record, memory, vector, resource, secret, and provider-port + capabilities. +- Added atomic record compare-and-set with fail-closed provider capability + negotiation. diff --git a/README.md b/README.md index 42a138c..031b264 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,22 @@ product intent; that remains in the public narrative and [PRD.md](PRD.md). pnpm install pnpm test pnpm build +pnpm release:check agent-runtime-services models install-volcengine-agent-plan agent-runtime-services secrets set --id ARK_API_KEY agent-runtime-services serve --host 127.0.0.1 --port 8765 ``` +The P0 RPC transport is loopback-only. `serve` rejects wildcard, private-network, +and public bind addresses before starting; non-loopback exposure requires a +separate authenticated remote-transport contract rather than a permissive host +flag. Requests to `/rpc` are limited to 1 MiB and use JSON-RPC 2.0 error codes. + +`pnpm release:check` is the shared local and CI release-candidate gate. It runs +the complete test suite, typecheck, build, and package dry-run. A green gate is +local validation evidence; real provider smoke, consumer acceptance, npm +publication, and production operation remain separate owner-controlled gates. + The library entrypoint is `createRuntimeServices(config)`. It exposes typed capabilities across two service layers: diff --git a/README.zh-CN.md b/README.zh-CN.md index 7a25710..148d3f6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -88,11 +88,20 @@ append-only event -> extracted claim -> relationship context -> evidence-backed pnpm install pnpm test pnpm build +pnpm release:check agent-runtime-services models install-volcengine-agent-plan agent-runtime-services secrets set --id ARK_API_KEY agent-runtime-services serve --host 127.0.0.1 --port 8765 ``` +P0 RPC 传输只允许 loopback。`serve` 会在启动前拒绝通配、私网和公网绑定地址; +非 loopback 暴露必须先建立独立且带认证的远程传输契约,不能通过放宽 host 参数实现。 +`/rpc` 请求体上限为 1 MiB,并使用 JSON-RPC 2.0 错误码。 + +`pnpm release:check` 是本地与 CI 共用的发布候选门禁,会运行完整测试、类型检查、 +构建和 package dry-run。门禁全绿只是本地验证证据;真实 Provider smoke、消费者验收、 +npm 发布和生产运行仍是彼此独立、由 owner 控制的门禁。 + 库入口是 `createRuntimeServices(config)`。它跨两层服务暴露类型化能力: Runtime Core: diff --git a/architecture/README.md b/architecture/README.md index d0b6ec0..89c2086 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -264,6 +264,16 @@ not shared by default across projects. - [Memory Substrate Capability Contract](./memory-substrate-prd.md): downstream L1/L2 contract for append-only events, claims, relationship context, preserved policy metadata, and retrieval bundles. +- [Local RPC Transport Contract](./rpc-transport-contract.md): loopback-only + binding, JSON-RPC error semantics, bounded request bodies, and release-gate + evidence for the P0 local transport. +- [Release Readiness Contract](./release-readiness-contract.md): package + integrity, external-consumer compatibility, dependency security, real + provider smoke, and publication owner gates. +- [Project Traceability](./project-traceability.md): human-readable mapping from + product requirements and formal contracts to implementation, validation, and + residual owner gates. The machine-readable source is + [`project-traceability.yaml`](./project-traceability.yaml). ## Change Rules diff --git a/architecture/project-traceability.md b/architecture/project-traceability.md new file mode 100644 index 0000000..06a3ef7 --- /dev/null +++ b/architecture/project-traceability.md @@ -0,0 +1,49 @@ +# Project Traceability + +This is the human-readable companion to +[`project-traceability.yaml`](./project-traceability.yaml). It connects current +product intent to formal contracts, implementation, validation, and residual +risk without treating validation as publication approval. + +| Requirement | Product and contract refs | Implementation | Validation | Current state | +| --- | --- | --- | --- | --- | +| `RPC-LOOPBACK-001` | `PRD.md`; `rpc-transport-contract.md` | `src/rpc/server.ts`, `src/cli/index.ts` | RPC contract and CLI smoke tests | Validated local | +| `RPC-JSONRPC-002` | `PRD.md`; `rpc-transport-contract.md` | `src/rpc/server.ts` | RPC protocol matrix | Validated local | +| `RPC-BODY-003` | `PRD.md`; `rpc-transport-contract.md` | `src/rpc/server.ts` | oversized-request regression | Validated local | +| `RELEASE-GATE-004` | `PRD.md`; `rpc-transport-contract.md` | `package.json`, GitHub Actions | `pnpm release:check` | Validated local | +| `PACKAGE-INTEGRITY-001` | `PRD.md`; `release-readiness-contract.md` | package metadata and packed consumer | release check and tarball acceptance | Validated local | +| `CONSUMER-COMPATIBILITY-002` | `PRD.md`; `release-readiness-contract.md` | public library/RPC adapters | clean consumer snapshots against the tarball | Validated snapshots | +| `DEPENDENCY-SECURITY-003` | `PRD.md`; `release-readiness-contract.md` | package and CI scripts | `pnpm security:audit` | Validated current | +| `PROVIDER-SMOKE-004` | `PRD.md`; `release-readiness-contract.md` | operator model/resource commands | real provider smoke | Owner gate | +| `PUBLICATION-OWNER-005` | `PRD.md`; `release-readiness-contract.md` | version, changelog, package metadata | owner approval and registry/tag evidence | Owner gate | + +## Residual Owner Gates + +- selecting internal tarball/Git distribution or npm publication; +- real provider credentials and smoke execution; +- acceptance in concrete domain-agent and build-agent consumers; +- any remote, non-loopback, or MCP exposure. + +These gates remain open until direct evidence and owner approval exist. + +## Current Validation Baseline + +- `pnpm release:check`: 15 test files and 116 tests passed under Node 20 and + Node 22; typecheck, build, and the 21-file package dry-run passed. +- `pnpm security:audit`: no known production dependency vulnerabilities at the + time of the check. +- Clean consumer snapshot `4c200a13a5331cf5df213f8a73d3b9bee583983a` + installed the tarball and passed 51 focused integration tests plus typecheck. +- Clean consumer snapshot `0571b678563e82ab5ec6032ff7eccb13ffc6188c` + installed the same tarball and passed 8 focused integration tests plus + typecheck. + +These observations are revision-specific L4 evidence. They do not cover +uncommitted consumer work, real provider credentials, npm publication, or +production operation. + +Node 22 also exposed an environment-specific residue: enabling the experimental +`NODE_USE_ENV_PROXY` flag makes Node itself emit `EnvHttpProxyAgent` warnings on +stderr before CLI code runs. The CI-like Node 22 matrix without that +experimental flag passes; operators that enable it must account for Node's +warning stream separately from Runtime Services CLI errors. diff --git a/architecture/project-traceability.yaml b/architecture/project-traceability.yaml new file mode 100644 index 0000000..74fdcca --- /dev/null +++ b/architecture/project-traceability.yaml @@ -0,0 +1,164 @@ +schema_version: 1 +validation_baseline: + observed_at: 2026-07-22 + release_candidate: + command: pnpm release:check + result: 15 test files and 116 tests passed on Node 20 and Node 22; typecheck, build, and 21-file package dry-run passed + dependency_security: + command: pnpm security:audit + result: no known production dependency vulnerabilities + packed_consumers: + - role: domain-agent session navigation + source_revision: 4c200a13a5331cf5df213f8a73d3b9bee583983a + installation: clean git archive installed agent-runtime-services-0.1.0.tgz + result: 51 focused integration tests and typecheck passed + - role: agent intelligence workflow + source_revision: 0571b678563e82ab5ec6032ff7eccb13ffc6188c + installation: clean git archive installed agent-runtime-services-0.1.0.tgz + result: 8 focused integration tests and typecheck passed +l0_assets: + product_narrative: + primary: README.md + localized: + - README.zh-CN.md + formal_projection: PRD.md +authority: + source_order: + - README.md + - README.zh-CN.md + - PRD.md + - architecture/project-traceability.yaml + - architecture/README.md + - architecture/rpc-transport-contract.md + - architecture/release-readiness-contract.md + - src/capabilities/registry.ts + - src/runtime-services.ts + - src/rpc/server.ts + - test +requirements: + - id: RPC-LOOPBACK-001 + status: validated_local + prd_refs: + - PRD.md#p0-scope + - PRD.md#non-goals + - PRD.md#owner-boundary + yaml_refs: + - architecture/rpc-transport-contract.md#rpc-loopback-001-local-trust-boundary + code_refs: + - src/rpc/server.ts + - src/cli/index.ts + validation: + - pnpm vitest run test/rpc.contract.test.ts + - pnpm vitest run test/cli-rpc-smoke.test.ts + residual_risk: + - Remote exposure remains prohibited until a separate authenticated transport contract is accepted. + - id: RPC-JSONRPC-002 + status: validated_local + prd_refs: + - PRD.md#p0-scope + yaml_refs: + - architecture/rpc-transport-contract.md#rpc-jsonrpc-002-json-rpc-semantics + code_refs: + - src/rpc/server.ts + validation: + - pnpm vitest run test/rpc.contract.test.ts + residual_risk: + - Batch requests and notifications remain outside P0. + - id: RPC-BODY-003 + status: validated_local + prd_refs: + - PRD.md#p0-scope + yaml_refs: + - architecture/rpc-transport-contract.md#rpc-body-003-bounded-requests + code_refs: + - src/rpc/server.ts + validation: + - pnpm vitest run test/rpc.contract.test.ts + residual_risk: + - Capability-specific payload limits remain future contract work. + - id: RELEASE-GATE-004 + status: validated_local + prd_refs: + - PRD.md#downstream-chain + - PRD.md#owner-boundary + yaml_refs: + - architecture/rpc-transport-contract.md#release-gate-004-delivery-evidence + code_refs: + - package.json + - .github/workflows/ci.yml + validation: + - pnpm release:check + residual_risk: + - Real provider smoke, consumer acceptance, npm publication, and production operation require separate evidence. + - Node 22 with the experimental NODE_USE_ENV_PROXY flag emits runtime warnings on stderr; the CI-like matrix without that experimental flag passes. + - id: PACKAGE-INTEGRITY-001 + status: validated_local + prd_refs: + - PRD.md#p0-scope + - PRD.md#downstream-chain + yaml_refs: + - architecture/release-readiness-contract.md#package-integrity-001 + code_refs: + - package.json + - test/cli-rpc-smoke.test.ts + validation: + - pnpm release:check + residual_risk: + - npm publication and registry installation remain owner-controlled gates. + - id: CONSUMER-COMPATIBILITY-002 + status: validated_snapshot + prd_refs: + - PRD.md#l0-problem + - PRD.md#p0-scope + yaml_refs: + - architecture/release-readiness-contract.md#consumer-compatibility-002 + code_refs: + - examples/client-sample.ts + - examples/upstream-agent-sample.md + - test/cli-rpc-smoke.test.ts + validation: + - packed package consumer acceptance + - clean snapshot external consumer tests and typecheck + residual_risk: + - Tested consumer revisions do not prove future contract compatibility or product acceptance. + - id: DEPENDENCY-SECURITY-003 + status: validated_current + prd_refs: + - PRD.md#owner-boundary + yaml_refs: + - architecture/release-readiness-contract.md#dependency-security-003 + code_refs: + - package.json + - .github/workflows/ci.yml + validation: + - pnpm security:audit + residual_risk: + - Advisory results are time-sensitive and must be refreshed for each release candidate. + - id: PROVIDER-SMOKE-004 + status: owner_gate + prd_refs: + - PRD.md#owner-boundary + yaml_refs: + - architecture/release-readiness-contract.md#provider-smoke-004 + code_refs: + - src/cli/model-smoke.ts + - src/cli/resources.ts + validation: + - agent-runtime-services models smoke --module all + - agent-runtime-services doctor + residual_risk: + - Requires owner-managed credentials and a selected real provider configuration. + - id: PUBLICATION-OWNER-005 + status: owner_gate + prd_refs: + - PRD.md#owner-boundary + yaml_refs: + - architecture/release-readiness-contract.md#publication-owner-005 + code_refs: + - package.json + - CHANGELOG.md + validation: + - explicit owner approval + - registry and tag verification after publication + residual_risk: + - Package is not published and no release tag exists. diff --git a/architecture/release-readiness-contract.md b/architecture/release-readiness-contract.md new file mode 100644 index 0000000..c527e3a --- /dev/null +++ b/architecture/release-readiness-contract.md @@ -0,0 +1,56 @@ +# Release Readiness Contract + +This contract defines the evidence required to move Agent Runtime Services from +`validated-local` toward a publishable release. It is downstream of the product +narrative and `PRD.md`; it does not authorize publication, credentials, or +remote exposure. + +## PACKAGE-INTEGRITY-001 + +The exact package payload must be built from tracked source and consumed from a +tarball in a clean temporary project. Required evidence: + +- full tests, typecheck, and build pass; +- `npm pack --dry-run --ignore-scripts` lists only intended public assets; +- a tarball consumer imports the public entrypoint and runs a representative + library and localhost RPC flow; +- package code has no runtime dependency on repository-maintenance material. + +## CONSUMER-COMPATIBILITY-002 + +At least one concrete domain-agent consumer and one other agent workflow must +install the packed artifact rather than importing the source checkout. Their +focused integration tests and typecheck must pass against the same tarball. + +Consumer evidence proves compatibility with the tested revisions only. It does +not prove owner acceptance, production traffic, or compatibility with future +unfrozen consumer contracts. + +## DEPENDENCY-SECURITY-003 + +CI must run the production dependency audit and fail for high or critical +advisories. The result is time-sensitive and must be refreshed for every release +candidate. Development-only findings are triaged separately and cannot be +silently upgraded or ignored. + +## PROVIDER-SMOKE-004 + +Before publication or deployment, an owner must run model and configured remote +provider smoke with a dedicated runtime home and owner-managed credentials. +Required observations are resource readiness, one successful call for each +enabled model module, secret non-disclosure, and fail-closed behavior for every +unconfigured capability. + +This gate cannot be satisfied by fake providers or repository tests. + +## PUBLICATION-OWNER-005 + +Publication requires explicit owner approval after reviewing: + +- the release-candidate diff and changelog; +- package name, version, tag, registry destination, and visibility; +- `PACKAGE-INTEGRITY-001`, `CONSUMER-COMPATIBILITY-002`, + `DEPENDENCY-SECURITY-003`, and applicable `PROVIDER-SMOKE-004` evidence; +- remaining compatibility, provider, operational, and remote-exposure risks. + +No command in the ordinary CI workflow publishes to npm or creates credentials. diff --git a/architecture/rpc-transport-contract.md b/architecture/rpc-transport-contract.md new file mode 100644 index 0000000..5f27528 --- /dev/null +++ b/architecture/rpc-transport-contract.md @@ -0,0 +1,60 @@ +# Local RPC Transport Contract + +This document freezes the L1/L2 transport contract for the P0 local Runtime +Services surface. It is downstream of the product narrative and `PRD.md`; it +does not introduce a remote service, an authentication system, or a new product +capability. + +## RPC-LOOPBACK-001: Local Trust Boundary + +`/rpc` and `/health` are local-machine surfaces. The server must fail before +binding when the requested host is not a loopback address. + +Accepted host forms are: + +- `localhost`; +- IPv4 loopback addresses in `127.0.0.0/8`; +- IPv6 loopback `::1`, including the bracketed URL form `[::1]`; +- IPv4-mapped IPv6 loopback addresses. + +Wildcard addresses such as `0.0.0.0` and `::`, private-network addresses, and +public addresses are rejected. DNS resolution is not used to expand this set; +the boundary must stay deterministic and fail closed. + +Non-loopback exposure requires a new human-owned L0-L2 decision covering +authentication, Origin validation, scoped capability exposure, audit, and +remote caller identity. It must not be enabled by a permissive CLI flag. + +## RPC-JSONRPC-002: JSON-RPC Semantics + +The local endpoint implements JSON-RPC 2.0 request/response semantics for one +request object per HTTP call. Batch requests and JSON-RPC notifications are not +part of the P0 contract. + +Errors use the standard codes: + +- `-32700`: JSON parse error; +- `-32600`: structurally invalid request; +- `-32601`: unknown method; +- `-32602`: invalid method parameters; +- `-32603`: unexpected internal error. + +Runtime capability failures that already return the common Runtime Services +envelope remain successful JSON-RPC results. Error responses must not expose +stack traces, secrets, or provider credentials. + +## RPC-BODY-003: Bounded Requests + +The maximum HTTP request body is 1 MiB (`1_048_576` bytes). The server rejects +larger requests with HTTP `413` before JSON parsing or capability dispatch. +This transport limit is independent of capability-specific payload rules. + +## RELEASE-GATE-004: Delivery Evidence + +Every pull request and push to `main` must run the same public release-candidate +gate available to local contributors. The gate covers tests, typecheck, build, +and package dry-run on supported Node.js versions. + +The gate proves local implementation conformance. Real provider smoke, consumer +owner acceptance, npm publication, credentials, and remote exposure remain +separate human-owned evidence and approval gates. diff --git a/package.json b/package.json index 3117855..a8102bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "agent-runtime-services", "version": "0.1.0", + "packageManager": "pnpm@10.30.3", "description": "Local-first runtime service plane for domain agents and build agents with Runtime Core capabilities and memory substrate Agent Services", "repository": { "type": "git", @@ -28,6 +29,7 @@ "README.md", "README.zh-CN.md", "PRD.md", + "CHANGELOG.md", "LICENSE", "NOTICE" ], @@ -37,7 +39,9 @@ "typecheck": "tsc --noEmit", "test": "vitest run --dir test", "test:acceptance": "vitest run test/external-runtime-services.acceptance.test.ts test/remote-provider-config.acceptance.test.ts test/remote-runtime-services.acceptance.test.ts test/cli-rpc-smoke.test.ts", - "prepublishOnly": "pnpm test:acceptance && pnpm test && pnpm typecheck && pnpm build && npm pack --dry-run --ignore-scripts" + "security:audit": "pnpm audit --prod --audit-level high", + "release:check": "pnpm test && pnpm typecheck && pnpm build && npm pack --dry-run --ignore-scripts", + "prepublishOnly": "pnpm test:acceptance && pnpm release:check" }, "dependencies": { "@lancedb/lancedb": "^0.29.0", diff --git a/src/rpc/server.ts b/src/rpc/server.ts index 1c23190..147908f 100644 --- a/src/rpc/server.ts +++ b/src/rpc/server.ts @@ -1,4 +1,5 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { BlockList, isIP } from 'node:net'; import pkg from '../../package.json'; import { RUNTIME_SERVICE_CAPABILITIES, @@ -10,6 +11,10 @@ import { type RuntimeServices, } from '../runtime-services'; +export const MAX_RPC_REQUEST_BYTES = 1_048_576; +const LOOPBACK_ADDRESSES = new BlockList(); +LOOPBACK_ADDRESSES.addSubnet('127.0.0.0', 8, 'ipv4'); + export interface RuntimeServicesRpcServerOptions { services: RuntimeServices; host?: string; @@ -22,16 +27,29 @@ export interface RuntimeServicesRpcServer { } interface JsonRpcRequest { - jsonrpc?: string; - id?: string | number | null; - method?: string; - params?: unknown; + jsonrpc: '2.0'; + id: string | number | null; + method: string; + params: Record; +} + +class JsonRpcProtocolError extends Error { + constructor( + readonly code: -32700 | -32600 | -32601 | -32602, + message: string, + readonly id: JsonRpcRequest['id'] = null, + ) { + super(message); + } } +class RequestBodyTooLargeError extends Error {} + export async function startRuntimeServicesRpcServer( options: RuntimeServicesRpcServerOptions, ): Promise { const host = options.host ?? '127.0.0.1'; + assertLoopbackHost(host); const server = createServer((request, response) => { void handleRequest(options.services, request, response); }); @@ -50,6 +68,17 @@ export async function startRuntimeServicesRpcServer( }; } +export function assertLoopbackHost(host: string): void { + const normalized = host.trim().toLowerCase(); + const address = normalized.startsWith('[') && normalized.endsWith(']') + ? normalized.slice(1, -1) + : normalized; + if (address === 'localhost' || address === '::1') return; + const family = isIP(address); + if (family !== 0 && LOOPBACK_ADDRESSES.check(address, family === 4 ? 'ipv4' : 'ipv6')) return; + throw new Error(`local RPC requires a loopback host; rejected: ${host}`); +} + async function handleRequest( services: RuntimeServices, request: IncomingMessage, @@ -67,19 +96,36 @@ async function handleRequest( writeJson(response, 405, { error: 'method_not_allowed' }); return; } - const rpc = parseRequest(await readBody(request)); - if (!rpc.method) { - writeJson(response, 400, rpcError(rpc.id, -32600, 'invalid request')); + let rpc: JsonRpcRequest; + try { + rpc = parseRequest(await readBody(request)); + } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + writeJson(response, 413, { + error: 'request_too_large', + maxBytes: MAX_RPC_REQUEST_BYTES, + }); + return; + } + if (error instanceof JsonRpcProtocolError) { + writeJson(response, 200, rpcError(error.id, error.code, error.message)); + return; + } + writeJson(response, 200, rpcError(null, -32603, 'internal error')); return; } try { writeJson(response, 200, { jsonrpc: '2.0', - id: rpc.id ?? null, - result: await dispatch(services, rpc.method, paramsRecord(rpc.params)), + id: rpc.id, + result: await dispatch(services, rpc.method, rpc.params), }); } catch (error) { - writeJson(response, 200, rpcError(rpc.id, -32601, error instanceof Error ? error.message : String(error))); + if (error instanceof JsonRpcProtocolError) { + writeJson(response, 200, rpcError(rpc.id, error.code, error.message)); + return; + } + writeJson(response, 200, rpcError(rpc.id, -32603, 'internal error')); } } @@ -167,32 +213,66 @@ async function dispatch( case 'resources.smoke': return services.resources.smoke(params as { module?: 'language' | 'embedding' | 'vision' | 'all' }); default: - throw new Error(`unknown method: ${method}`); + throw new JsonRpcProtocolError(-32601, `unknown method: ${method}`); } } function parseRequest(body: string): JsonRpcRequest { + let value: unknown; try { - return JSON.parse(body) as JsonRpcRequest; + value = JSON.parse(body) as unknown; } catch { - return {}; + throw new JsonRpcProtocolError(-32700, 'parse error'); } -} - -function paramsRecord(params: unknown): Record { - return params && typeof params === 'object' && !Array.isArray(params) ? params as Record : {}; + if (!isRecord(value)) throw new JsonRpcProtocolError(-32600, 'invalid request'); + const id = validRequestId(value.id) ? value.id : null; + if (value.jsonrpc !== '2.0') throw new JsonRpcProtocolError(-32600, 'invalid request: jsonrpc must be 2.0', id); + if (!Object.hasOwn(value, 'id')) throw new JsonRpcProtocolError(-32600, 'invalid request: notifications are not supported'); + if (!validRequestId(value.id)) throw new JsonRpcProtocolError(-32600, 'invalid request: id must be a string, number, or null'); + if (typeof value.method !== 'string' || value.method.length === 0) { + throw new JsonRpcProtocolError(-32600, 'invalid request: method must be a non-empty string', value.id); + } + if (value.params !== undefined && !isRecord(value.params)) { + throw new JsonRpcProtocolError(-32602, 'invalid params: expected an object', value.id); + } + return { + jsonrpc: '2.0', + id: value.id, + method: value.method, + params: value.params ?? {}, + }; } function stringParam(params: Record, key: string): string { const value = params[key]; - if (typeof value !== 'string') throw new Error(`missing string param: ${key}`); + if (typeof value !== 'string') throw new JsonRpcProtocolError(-32602, `invalid params: missing string param ${key}`); return value; } +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function validRequestId(value: unknown): value is string | number | null { + return value === null || typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)); +} + async function readBody(request: IncomingMessage): Promise { + const declaredLength = Number(request.headers['content-length']); + if (Number.isFinite(declaredLength) && declaredLength > MAX_RPC_REQUEST_BYTES) { + request.resume(); + throw new RequestBodyTooLargeError(); + } const chunks: Buffer[] = []; + let bytes = 0; for await (const chunk of request) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.length; + if (bytes > MAX_RPC_REQUEST_BYTES) { + request.resume(); + throw new RequestBodyTooLargeError(); + } + chunks.push(buffer); } return Buffer.concat(chunks).toString('utf8'); } diff --git a/test/cli-rpc-smoke.test.ts b/test/cli-rpc-smoke.test.ts index e122595..fc7b660 100644 --- a/test/cli-rpc-smoke.test.ts +++ b/test/cli-rpc-smoke.test.ts @@ -73,6 +73,25 @@ describe('CLI RPC smoke', () => { })); }, 20_000); + test('serve fails closed when asked to bind outside loopback', async () => { + const runtimeHome = await mkdtemp(join(tmpdir(), 'agent-runtime-services-cli-loopback-')); + await execFileAsync('pnpm', ['build'], { cwd: repoRoot, maxBuffer: 10 * 1024 * 1024 }); + + await expect(execFileAsync(process.execPath, [ + join(repoRoot, 'bin', 'agent-runtime-services.mjs'), + 'serve', + '--host', + '0.0.0.0', + '--port', + '0', + '--runtime-home', + runtimeHome, + ], { cwd: repoRoot, timeout: 5_000 })).rejects.toMatchObject({ + stderr: expect.stringMatching(/loopback/i), + }); + await expect(readFile(join(runtimeHome, 'service.pid'), 'utf8')).rejects.toThrow(); + }, 20_000); + test('serve reads runtime-providers.json and routes stable RPC calls to remote providers', async () => { const runtimeHome = await mkdtemp(join(tmpdir(), 'agent-runtime-services-cli-remote-')); const remote = await startFakeRemoteRuntimeServer(); @@ -483,7 +502,13 @@ for (const forbiddenExport of [ const client = createRuntimeServicesRpcClient({ endpoint: process.env.RUNTIME_SERVICES_RPC_URL }); const typedRuntime = createRuntimeServicesRpcRuntime({ endpoint: process.env.RUNTIME_SERVICES_RPC_URL }); +const version = await client.call('version', {}); const describe = await client.call('capabilities.describe', {}); +if (version.capabilityRevision !== describe.capabilityRevision) { + throw new Error( + \`capability revision mismatch: version=\${version.capabilityRevision} describe=\${describe.capabilityRevision}\`, + ); +} const requiredIds = new Set(describe.capabilities.map((capability) => capability.id)); for (const id of [ 'language.complete', @@ -580,6 +605,8 @@ const resourcesSmoke = await typedRuntime.resources.smoke({ module: 'all' }); const languageSmoke = await typedRuntime.resources.smoke({ module: 'language' }); const status = await typedRuntime.resources.status(); console.log(JSON.stringify({ + version, + capabilityRevision: describe.capabilityRevision, language, typedRuntimeStatus, embedding, @@ -609,6 +636,8 @@ console.log(JSON.stringify({ maxBuffer: 10 * 1024 * 1024, }); const result = JSON.parse(stdout) as { + version: { name: string; version: string; capabilityRevision: string }; + capabilityRevision: string; language: { status: string; providerId: string; proposal?: { text?: string } }; embedding: { status: string; providerId: string; embedding?: number[] }; image: { status: string; providerId: string; artifact?: { url?: string } }; @@ -636,6 +665,12 @@ console.log(JSON.stringify({ status: { status: string; resources: Array<{ id: string; provider?: string }> }; }; + expect(result.version).toMatchObject({ + name: 'agent-runtime-services', + version: '0.1.0', + capabilityRevision: expect.stringMatching(/^[a-f0-9]{16}$/), + }); + expect(result.capabilityRevision).toBe(result.version.capabilityRevision); expect(result.language).toMatchObject({ status: 'ok', providerId: 'package-remote-model', diff --git a/test/publication-identity.test.ts b/test/publication-identity.test.ts index c49f824..75ad77f 100644 --- a/test/publication-identity.test.ts +++ b/test/publication-identity.test.ts @@ -11,6 +11,65 @@ function gitFiles(args: string[]): string[] { } describe('publication identity', () => { + test('release hardening contracts stay inside the public formal chain', async () => { + const [architecture, transport, traceability, humanTraceability] = await Promise.all([ + readFile(join(repoRoot, 'architecture', 'README.md'), 'utf8'), + readFile(join(repoRoot, 'architecture', 'rpc-transport-contract.md'), 'utf8'), + readFile(join(repoRoot, 'architecture', 'project-traceability.yaml'), 'utf8'), + readFile(join(repoRoot, 'architecture', 'project-traceability.md'), 'utf8'), + ]); + + expect(architecture).toContain('rpc-transport-contract.md'); + expect(architecture).toContain('project-traceability.yaml'); + for (const requirement of [ + 'RPC-LOOPBACK-001', + 'RPC-JSONRPC-002', + 'RPC-BODY-003', + 'RELEASE-GATE-004', + ]) { + expect(transport).toContain(requirement); + expect(traceability).toContain(requirement); + expect(humanTraceability).toContain(requirement); + } + expect(traceability).toContain('prd_refs:'); + expect(traceability).toContain('yaml_refs:'); + expect(traceability).toContain('code_refs:'); + expect(traceability).toContain('validation:'); + expect(traceability).toContain('residual_risk:'); + }); + + test('release candidate gate is reproducible locally and in CI', async () => { + const [pkgRaw, workflow, changelog, releaseContract] = await Promise.all([ + readFile(join(repoRoot, 'package.json'), 'utf8'), + readFile(join(repoRoot, '.github', 'workflows', 'ci.yml'), 'utf8').catch(() => ''), + readFile(join(repoRoot, 'CHANGELOG.md'), 'utf8').catch(() => ''), + readFile(join(repoRoot, 'architecture', 'release-readiness-contract.md'), 'utf8').catch(() => ''), + ]); + const pkg = JSON.parse(pkgRaw) as { files?: string[]; scripts?: Record }; + + expect(pkg.scripts?.['release:check']).toBe( + 'pnpm test && pnpm typecheck && pnpm build && npm pack --dry-run --ignore-scripts', + ); + expect(pkg.scripts?.prepublishOnly).toBe('pnpm test:acceptance && pnpm release:check'); + expect(pkg.files).toContain('CHANGELOG.md'); + expect(workflow).toContain('pnpm release:check'); + expect(workflow).toContain('pnpm security:audit'); + expect(workflow).toMatch(/node-version:\s*\[20, 22\]/); + expect(workflow).toContain('pnpm install --frozen-lockfile'); + expect(pkg.scripts?.['security:audit']).toBe('pnpm audit --prod --audit-level high'); + expect(changelog).toContain('## [Unreleased]'); + expect(changelog).toContain('RPC-LOOPBACK-001'); + for (const gate of [ + 'PACKAGE-INTEGRITY-001', + 'CONSUMER-COMPATIBILITY-002', + 'DEPENDENCY-SECURITY-003', + 'PROVIDER-SMOKE-004', + 'PUBLICATION-OWNER-005', + ]) { + expect(releaseContract).toContain(gate); + } + }); + test('package identity is Agent Runtime Services while retaining upstream attribution', async () => { const license = await readFile(join(repoRoot, 'LICENSE'), 'utf8'); const notice = await readFile(join(repoRoot, 'NOTICE'), 'utf8'); @@ -307,12 +366,14 @@ describe('publication identity', () => { scripts?: Record; }; const prepublish = pkg.scripts?.prepublishOnly ?? ''; + const releaseCheck = pkg.scripts?.['release:check'] ?? ''; expect(prepublish).toContain('pnpm test:acceptance'); - expect(prepublish).toContain('pnpm test'); - expect(prepublish).toContain('pnpm typecheck'); - expect(prepublish).toContain('pnpm build'); - expect(prepublish).toContain('npm pack --dry-run'); + expect(prepublish).toContain('pnpm release:check'); + expect(releaseCheck).toContain('pnpm test'); + expect(releaseCheck).toContain('pnpm typecheck'); + expect(releaseCheck).toContain('pnpm build'); + expect(releaseCheck).toContain('npm pack --dry-run'); }); test('github source boundary excludes local-only and private publication hazards', async () => { diff --git a/test/rpc.contract.test.ts b/test/rpc.contract.test.ts index b108e22..5050f28 100644 --- a/test/rpc.contract.test.ts +++ b/test/rpc.contract.test.ts @@ -8,8 +8,145 @@ import { createRuntimeServicesRpcClient, startRuntimeServicesRpcServer, } from '../src/index'; +import { assertLoopbackHost } from '../src/rpc/server'; describe('runtime services RPC contract', () => { + test('rejects non-loopback bind hosts before starting the local RPC server', async () => { + const services = createRuntimeServices(); + + for (const host of ['localhost', '127.0.0.1', '127.255.1.2', '::1', '[::1]', '::ffff:127.0.0.1', '::ffff:7f00:1']) { + expect(() => assertLoopbackHost(host), host).not.toThrow(); + } + for (const host of ['0.0.0.0', '192.168.1.10', '::', '::ffff:128.0.0.1']) { + expect(() => assertLoopbackHost(host), host).toThrow(/loopback/i); + } + + for (const host of ['0.0.0.0', '192.168.1.10', '::']) { + let started: Awaited> | undefined; + let failure: unknown; + try { + started = await startRuntimeServicesRpcServer({ services, host, port: 0 }); + } catch (error) { + failure = error; + } finally { + await started?.close(); + } + + expect(failure, host).toBeInstanceOf(Error); + expect((failure as Error | undefined)?.message, host).toMatch(/loopback/i); + } + }); + + test('uses JSON-RPC 2.0 error codes for parse, request, method, params, and internal failures', async () => { + const baseServices = createRuntimeServices(); + const services = { + ...baseServices, + resources: { + ...baseServices.resources, + status: async () => { + throw new Error('internal status failure'); + }, + }, + }; + const server = await startRuntimeServicesRpcServer({ services, host: '127.0.0.1', port: 0 }); + try { + const scenarios = [ + { label: 'parse error', body: '{', code: -32700, id: null }, + { + label: 'wrong protocol version', + body: JSON.stringify({ jsonrpc: '1.0', id: 1, method: 'health', params: {} }), + code: -32600, + id: 1, + }, + { + label: 'notification outside P0', + body: JSON.stringify({ jsonrpc: '2.0', method: 'health', params: {} }), + code: -32600, + id: null, + }, + { + label: 'unknown method', + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'unknown.method', params: {} }), + code: -32601, + id: 2, + }, + { + label: 'invalid params', + body: JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'language.complete', params: {} }), + code: -32602, + id: 3, + }, + { + label: 'array params outside P0', + body: JSON.stringify({ jsonrpc: '2.0', id: 4, method: 'health', params: [] }), + code: -32602, + id: 4, + }, + { + label: 'internal failure', + body: JSON.stringify({ jsonrpc: '2.0', id: 5, method: 'resources.status', params: {} }), + code: -32603, + id: 5, + }, + ]; + + for (const scenario of scenarios) { + const response = await fetch(`${server.url}/rpc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: scenario.body, + }); + const payload = await response.json() as { + jsonrpc?: string; + id?: string | number | null; + error?: { code?: number; message?: string }; + }; + expect(response.status, scenario.label).toBe(200); + expect(payload, scenario.label).toMatchObject({ + jsonrpc: '2.0', + id: scenario.id, + error: { code: scenario.code, message: expect.any(String) }, + }); + } + } finally { + await server.close(); + } + }); + + test('rejects request bodies above 1 MiB before capability dispatch', async () => { + let modelCalls = 0; + const services = createRuntimeServices({ + modelConfig: createDefaultModelProviderConfig(), + runtime: { env: { ARK_API_KEY: 'test-key' } }, + fetch: async () => { + modelCalls += 1; + return new Response(JSON.stringify({ output_text: 'unexpected' }), { status: 200 }); + }, + }); + const server = await startRuntimeServicesRpcServer({ services, host: '127.0.0.1', port: 0 }); + try { + const response = await fetch(`${server.url}/rpc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'language.complete', + params: { input: 'x'.repeat(1_048_577) }, + }), + }); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + error: 'request_too_large', + maxBytes: 1_048_576, + }); + expect(modelCalls).toBe(0); + } finally { + await server.close(); + } + }); + test('health, version, discovery, resources.status, and language.complete mirror lib contracts', async () => { const services = createRuntimeServices({ modelConfig: createDefaultModelProviderConfig(),