Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ state. A local/runtime result is not cloud acceptance or package publication.
- [Current configuration contract](notes/implemented/architecture/2026-10-22-remove-legacy-compatibility.md) · [verification](evidence/verify/2026-10-22-remove-legacy-compatibility.md)

- [Compatibility cleanup remote acceptance](evidence/verify/2026-10-22-remove-legacy-acceptance.md)

- [Bound HTTP client](notes/proposed/architecture/2026-10-23-bound-http-client.md) · [verification](evidence/verify/2026-10-23-bound-http-client.md)

- [HTTP binding control-character review](evidence/verify/2026-10-23-bound-http-control-review.md)
18 changes: 18 additions & 0 deletions .agents/evidence/verify/2026-10-23-bound-http-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Bound HTTP client verification

Date: 2026-10-23
Result: PASS (local scope)

Scope: src/server-client.ts, test/server-client.test.mjs, bilingual README and changelog on feat/template-workflows-20261023, based on 513a711fb839ae4df179a03e0cd74b4cf4836669.
Environment: Node.js 22.22.2, npm lockfile installation, Python 3.11 with the current task's editable in-sandbox server installed.

- PASS: npm ci.
- PASS: npm run typecheck.
- PASS: npm test, all 15 configured tests, including the real Python local server loop and four new HTTP binding regressions.
- PASS: npm pack --dry-run; built ESM, TypeScript declarations, configuration contract, both README files and license/notice included.
- PASS: node examples/agent-loop.mjs; real local health, shell stdout 4, file round trip answer=4 and server SIGTERM cleanup.
- The first suite invocation lacked the installed Python interpreter on PATH and failed its two real-runtime tests. Re-running with that installed interpreter passed both. The HTTP regressions passed in both runs.
- No cloud sandbox, external resource or deployment was created. Loopback servers stopped and example process exited. These checks do not establish cloud provider acceptance or package publication.
- Remote Node20/22 CI and required PR review remain separate delivery gates.

Issue: https://github.com/Alibaba-Agent-Sandbox/nodejs-sdk/issues/9.
17 changes: 17 additions & 0 deletions .agents/evidence/verify/2026-10-23-bound-http-control-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# HTTP binding control-character adversarial review

Date: 2026-10-23
Result: PASS (local scope)

Scope: raw/recursively encoded URL and operation validation in src/server-client.ts, three new regressions and associated documentation on the task branch after f67462b.
Environment: Node.js 22.22.2, existing locked installation, Python 3.11 with the current task server installed for the configured real-runtime tests.

The independent review found that WHATWG pathname assignment removes TAB/CR/LF before normalizing dot segments: a path containing TAB followed by two dots could escape a sandbox proxy prefix and carry its token/signature to another sandbox. Validation now runs before URL parsing/assignment, recursively rejects C0/DEL, dot traversal and encoded separators, and refuses recursively equivalent or duplicated gateway query keys.

- PASS: npm test, all 18 tests, including the actual Python runtime loop and real loopback gateway regression. Attack paths cause zero HTTP requests; the positive health request reaches only the owned sandbox with unchanged signature and expected application token.
- PASS: an independent review confirmed the original control-character bypass is blocked and identified an opaque-query overvalidation regression. The follow-up preserves query values such as `sig=100%25&opaque=%2525` byte-for-byte; only path/key semantics are recursively decoded. A real HTTP positive regression passed.
- PASS: npm run typecheck.
- PASS: git diff --check.
- No cloud sandbox or external resource was created. Loopback servers and clients closed. This is local transport validation, not cloud acceptance or remote CI/review.

Refs https://github.com/Alibaba-Agent-Sandbox/nodejs-sdk/pull/10 and https://github.com/Alibaba-Agent-Sandbox/nodejs-sdk/issues/9. The earlier PR CI result does not cover this new head; rerun Node20/22 CI and complete independent review before merge.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Decision: Bind HTTP requests to one application endpoint

Status: proposed

## Problem
Resolving relative operations against a signed gateway URL discarded its query and could misplace the proxy prefix. Default fetch redirects could send application credentials to a different sandbox. Diagnostics and object inspection exposed URL query credentials.

## Decision
Preserve the complete prefix and exact original query bytes, append only new parameters and reject parameter replacement. Refuse every redirect, URL userinfo, fragments, non-HTTP URLs, prefix escapes and authority/transport header overrides. Keep the private endpoint separate from the query-free public baseUrl. Separate gatewayHeaders from the application's token and other headers; reject name collisions instead of silently replacing credentials.

Validation runs on raw base URLs and operation paths before WHATWG URL parsing or pathname assignment. Bounded recursive decoding rejects C0/DEL, encoded separators and traversal at every layer; query keys cannot collide after recursive decoding. This prevents URL normalization from silently moving a credential-bearing request into another sandbox.

## API Design
ServerClientOptions adds gatewayHeaders. token remains the application's bearer token. baseUrl reports the query-free origin and path. Failed connections omit underlying error text that may contain signed URLs. The HTTP-only public SDK scope remains unchanged. Per-operation signed paths require the provider's operation resolver; this prefix client does not infer such signatures.

## Alternatives considered
Origin-only redirect restrictions still allow credentials to reach another sandbox on a shared gateway. Reconstructing all query parameters changes signature bytes. Silent authentication retries or token precedence obscure credential ownership.

## Dependencies
The in-sandbox HTTP server is owned by https://github.com/Alibaba-Agent-Sandbox/python-sdk. The project workflow design is tracked in https://github.com/Alibaba-Agent-Sandbox/nodejs-sdk/issues/9.

## Test Strategy
Use real loopback HTTP servers to check wire paths/query bytes, credential separation and single-request redirect refusal. Reject malformed endpoints, path traversal, overlapping headers and query replacement before I/O. Run the full configured suite, typecheck, package validation and actual local Python server example.

## Acceptance criteria
All configured Node tests and type checks pass. Proxy paths and signature bytes survive each operation. Redirects and authority overrides cannot move credentials. No cloud or interpreter API is introduced; verification distinguishes local runtime behavior from cloud acceptance.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

### Fixed

- Reject control characters before URL normalization and recursively encoded namespace escapes/query-key collisions before an authenticated request can reach another sandbox.

- Preserve sandbox proxy prefixes and signed query bytes; reject redirects, URL userinfo, escaping paths and credential/header collisions. Separate gateway headers from the application token and omit signed query credentials from diagnostics.

- Preserve caller Python source configuration in the local runtime example; document usable source-package installation and validate real runtime behavior on Node20/22 CI. Clarify configuration-name exports and retry compatibility.
- Match the Python configuration schema by leaving the installable `e2b` catalog
provider id unreserved while preserving all built-in/core reservations.
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,21 @@ readEnv("sandbox_api_key", process.env);
Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).

Configuration storage uses one `storage` descriptor. / 配置存储统一使用 `storage` 字段。

For a provider port gateway, pass the exact sandbox/port URL including its proxy
prefix and query. The client preserves existing query bytes and appends operation
parameters without replacing gateway parameters. All redirects are rejected,
including redirects to another sandbox on the same origin. URL userinfo and
request paths that escape the bound prefix are rejected. Raw or recursively
encoded control characters, path separators and dot traversal are validated
before URL normalization. Duplicate or recursively equivalent gateway query
keys cannot be introduced by the base URL or an operation.

Use `token` for the application service token and `gatewayHeaders` for the
provider gateway's headers. `headers` contains other application headers. Header
names must not overlap between groups; an existing `Authorization` header cannot
be combined with `token`. Authority and transport headers cannot be overridden.
The public `baseUrl` and connection errors omit query credentials. Never pass a
provider control-plane API key as the application token. This client accepts
prefix-bound HTTP URLs; operation-specific signed path bindings must use the provider
adapter's operation transport. This prefix client does not accept those bindings.
12 changes: 12 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,15 @@ readEnv("sandbox_api_key", process.env);
Apache-2.0。见 [LICENSE](LICENSE) 和 [NOTICE](NOTICE)。

Configuration storage uses one `storage` descriptor. / 配置存储统一使用 `storage` 字段。

通过提供方端口网关访问时,应传入包含完整代理前缀、沙箱和端口标识及查询参数
的 URL。客户端保留原查询参数的字节,仅追加操作参数,禁止覆盖网关参数。
所有重定向均会拒绝,包括同一域名下跳转到另一沙箱;URL 用户信息及越出绑定
前缀的操作路径也会拒绝。URL 归一化之前即检查原始或多层编码的控制字符、
路径分隔符与点路径;基址和操作都不能引入重复或多层编码后等价的网关查询键。

`token` 用于应用服务令牌,`gatewayHeaders` 用于平台网关请求头,`headers`
用于其他应用请求头。各组请求头名称不能重叠;已有 `Authorization` 请求头时
不能再传入 `token`。不能覆盖 Host 等寻址及传输请求头。公开 `baseUrl` 和连接
错误省略查询凭据。不要把平台控制面 API Key 作为应用令牌传入。本客户端使用
绑定代理前缀的 HTTP URL;按操作签名的路径绑定需要使用提供方适配器的逐操作传输接口,不能传入本前缀客户端。
98 changes: 88 additions & 10 deletions src/server-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface ServerClientOptions {
token?: string | null;
/** Extra headers on every request. */
headers?: Readonly<Record<string, string>>;
/** Gateway authentication is separate from the application's bearer token. */
gatewayHeaders?: Readonly<Record<string, string>>;
/** Default per-request timeout in seconds. */
timeout?: number;
}
Expand Down Expand Up @@ -48,6 +50,44 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function decodedBindingValue(value: string): string {
for (let depth = 0; depth < 8; depth += 1) {
if (/[\u0000-\u001f\u007f]/.test(value)) throw new TypeError("Control characters are not allowed in sandbox bindings");
let decoded: string;
try { decoded = decodeURIComponent(value); }
catch { throw new TypeError("Invalid sandbox binding encoding"); }
if (decoded === value) return value;
value = decoded;
}
throw new TypeError("Sandbox binding is excessively encoded");
}

function validateBindingPath(path: string, operation: boolean): void {
// Validate before WHATWG URL parsing/setters can remove TAB/CR/LF or dots.
decodedBindingValue(path);
const segments = path.split("/");
if ((operation && (!path.startsWith("/") || path.startsWith("//"))) || /[?#\\]/.test(path)) {
throw new TypeError("Sandbox operation path must stay within the bound server prefix");
}
for (let index = 0; index < segments.length; index += 1) {
const segment = decodedBindingValue(segments[index]);
if (segment === "." || segment === ".." || /[\\/]/.test(segment) ||
(operation && index > 0 && !segment)) {
throw new TypeError("Sandbox operation path must stay within the bound server prefix");
}
}
}

function queryBindingKeys(url: URL): Set<string> {
const keys = new Set<string>();
for (const key of url.searchParams.keys()) {
const decoded = decodedBindingValue(key);
if (keys.has(decoded)) throw new TypeError("Duplicate sandbox gateway query parameters are not allowed");
keys.add(decoded);
}
return keys;
}

function errorFromResponse(status: number, body: unknown, endpoint: string): ServerAPIError {
const data = isRecord(body) ? body : {};
const fromBody = data.error;
Expand Down Expand Up @@ -83,16 +123,42 @@ export class ServerClient {
readonly files: ServerFilesApi;
readonly process: ServerProcessApi;

#endpoint: URL;
#token: string | undefined;
#headers: Record<string, string>;
#closed = false;
#controllers = new Set<AbortController>();

constructor(baseUrl: string, options: ServerClientOptions = {}) {
this.baseUrl = baseUrl.replace(/\/+$/, "");
// Query values are opaque signatures: validate raw controls here, and
// recursively inspect only the path and query keys below.
if (/[\u0000-\u001f\u007f]/.test(baseUrl)) throw new TypeError("Control characters are not allowed in sandbox bindings");
const rawPath = /^https?:\/\/[^/?#]+([^?#]*)/i.exec(baseUrl)?.[1];
if (rawPath === undefined || /\\/.test(baseUrl)) throw new TypeError("Invalid sandbox server URL");
validateBindingPath(rawPath, false);
let endpoint: URL;
try { endpoint = new URL(baseUrl); } catch { throw new TypeError("Invalid sandbox server URL"); }
if (!["http:", "https:"].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.hash) {
throw new TypeError("Sandbox server URL requires HTTP(S), without userinfo or fragment");
}
queryBindingKeys(endpoint);
this.#endpoint = endpoint;
// Keep signed query credentials out of object inspection and connection errors.
this.baseUrl = endpoint.origin + endpoint.pathname.replace(/\/+$/, "");
this.timeout = options.timeout ?? 30;
this.#token = options.token ?? undefined;
this.#headers = { ...(options.headers ?? {}) };
const headers = new Headers();
for (const group of [options.headers ?? {}, options.gatewayHeaders ?? {}]) {
for (const [name, value] of Object.entries(group)) {
if (["host", ":authority", "forwarded", "x-forwarded-host", "x-forwarded-proto", "x-forwarded-port", "connection", "transfer-encoding", "content-length", "proxy-authorization"].includes(name.toLowerCase())) {
throw new TypeError("Authority and transport headers cannot be overridden");
}
if (headers.has(name)) throw new TypeError("Application and gateway headers must not overlap");
headers.set(name, value);
}
}
if (this.#token && headers.has("authorization")) throw new TypeError("Application token conflicts with an existing authentication header");
this.#headers = Object.fromEntries(headers);
this.files = new ServerFilesApi(this);
this.process = new ServerProcessApi(this);
}
Expand Down Expand Up @@ -134,16 +200,25 @@ export class ServerClient {
if (this.#closed) {
throw new ConnectionError(`ServerClient is closed (${this.baseUrl})`);
}
const url = new URL(path.replace(/^\//, ""), `${this.baseUrl}/`);
validateBindingPath(path, true);
const url = new URL(this.#endpoint);
url.pathname = url.pathname.replace(/\/+$/, "") + path;
if (options.params) {
const appended = new URLSearchParams();
const keys = queryBindingKeys(url);
for (const [key, value] of Object.entries(options.params)) {
url.searchParams.set(key, String(value));
const decoded = decodedBindingValue(key);
if (keys.has(decoded)) throw new TypeError("Operation parameters must not replace gateway query parameters");
keys.add(decoded);
appended.append(key, String(value));
}
const suffix = appended.toString();
if (suffix) url.search += (url.search ? "&" : "?") + suffix;
}
const headers = this.authHeaders();
let body: string | undefined;
if (options.jsonBody !== undefined) {
headers["Content-Type"] = headers["Content-Type"] ?? "application/json";
headers["content-type"] = headers["content-type"] ?? "application/json";
body = JSON.stringify(options.jsonBody);
}
const timeoutSeconds = options.timeout ?? this.timeout;
Expand All @@ -155,7 +230,7 @@ export class ServerClient {
]);
let response: Response;
try {
response = await fetch(url, { method, headers, body, signal });
response = await fetch(url, { method, headers, body, signal, redirect: "manual" });
} catch (err) {
if (this.#closed || controller.signal.aborted) {
throw new ConnectionError(`ServerClient is closed (${this.baseUrl})`);
Expand All @@ -166,14 +241,17 @@ export class ServerClient {
`Timed out talking to the sandbox server at ${this.baseUrl}`,
);
}
const detail = err instanceof Error ? err.message : String(err);
throw new ConnectionError(
`Could not reach the sandbox server at ${this.baseUrl}: ${detail}`,
);
throw new ConnectionError(`Could not reach the sandbox server at ${this.baseUrl}`);
} finally {
this.#controllers.delete(controller);
}

if (response.status >= 300 && response.status < 400) {
await response.body?.cancel();
throw new ServerAPIError("Sandbox server redirects are not allowed", {
statusCode: response.status, errorType: "BindingError", endpoint,
});
}
const text = await response.text();
let parsed: unknown = null;
if (text) {
Expand Down
Loading
Loading