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
2 changes: 2 additions & 0 deletions .agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ state. A local/runtime result is not cloud acceptance or package publication.
- [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)

- [Proxy application token compatibility](notes/implemented/feature/2026-10-24-proxy-application-token.md) · [verification](evidence/verify/2026-10-24-proxy-application-token.md).
37 changes: 37 additions & 0 deletions .agents/evidence/verify/2026-10-24-proxy-application-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Verification: Node SDK application tokens through server proxies

- Date: 2026-10-24
- Result: PASS
- Change: [Issue 11](https://github.com/Alibaba-Agent-Sandbox/nodejs-sdk/issues/11)
- Source: fix/application-token-proxy, package source version 0.1.0.

## Executed

- npm ci, npm run typecheck and npm run build passed.
- node --test test/server-client.test.mjs: 16 passed, including real local HTTP Bearer-only and proxy-compatible services, gateway header case variants and copied header immutability.

- Initial full npm test: 22 passed, 2 failed because the shell had no installed Python server. No test was weakened. A subsequent isolated environment installed published agent-sandbox-server==0.3.1a3 and websockets; the complete npm test rerun passed all 24 tests with no skips.

- With the same isolated runtime, node examples/agent-loop.mjs passed health, shell result 4, file roundtrip and process cleanup. npm pack --dry-run passed.
- Complete remote Node 20/22 [CI 40110191447](https://github.com/Alibaba-Agent-Sandbox/nodejs-sdk/actions/runs/40110191447) passed, including actual Python runtime loop, configuration schema parity and package checks.
- Delivery is source merge only. No first npm publication is authorized or claimed.

## Actual OpenSandbox regression

[Run 40110372103](https://github.com/Alibaba-Agent-Sandbox/awesome-providers/actions/runs/40110372103) passed using these exact sources:

| Repository | Revision |
| --- | --- |
| awesome-providers | `65d8e5a9c130f424c05a662fe430b0eb04793c0e` |
| agent-sandbox | `80c45cd2594d0fc997badfd31246d60ffa434de5` |
| python-sdk | `588d126af9310ee5536ea631856b092b84c6ce6b` |
| nodejs-sdk | `344d297df816063b6580d7f2bb34180111ad9a54` |
| awesome-templates | `295aff8f45f710eadf46d164b7ce4150b2c914ec` |

The python-hello template completed default/direct, portable/direct and default/proxy project launches and actual CLI/Python application calls. The independent Node client called the protected command through the proxy twice. Raw direct Bearer/proxy Bearer/proxy X-Access-Token probes retained the expected 200/401/200 distinction; successful requests asserted the application result. Independent Docker resource readback confirmed cleanup.

This covers one template and the stated launch paths against the pinned single-tenant OpenSandbox server. It does not certify all 46 templates, every gateway credential combination or multi-tenant server configuration. Existing gateway X-Access-Token values remain authoritative and are never replaced.

## Review and delivery boundary

Independent cross-agent review found no blocker in copied header assembly, case-insensitive gateway preservation, Bearer compatibility, anonymous requests, redirect rejection or no-retry behavior. Final evidence-only commit CI remains a merge gate. This record does not claim package publication.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Decision: Preserve application tokens through server proxies

Status: implemented

## Problem

ServerClient sends token as Bearer, while OpenSandbox server proxy strips Authorization. The existing application server also accepts X-Access-Token. A real [same-sandbox probe](https://github.com/Alibaba-Agent-Sandbox/awesome-providers/actions/runs/40109164062) isolated this difference.

## Decision

Keep Bearer and add the same explicit application token as X-Access-Token only if caller/gateway headers have not occupied that name. Match names case-insensitively and generate headers on a fresh copy per request. Preserve existing binding validation, redirect rejection and no retry.

## API Design

No new fields or methods. Existing token, headers and gatewayHeaders retain their roles. Supplied headers are not mutated, and no token means no synthesized application credential. This delivery changes source only, without starting a new npm publication process.

## Alternatives considered

Replacing Bearer would break existing services. Overwriting gateway credentials would mix audiences. A new provider option or protocol is unnecessary because the server already accepts both headers. Sending a second request after 401 would violate the no-replay behavior.

## Dependencies

Existing Python server authentication. Tracking: [Issue 11](https://github.com/Alibaba-Agent-Sandbox/nodejs-sdk/issues/11). Companion CLI fix: [Issue 140](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/issues/140).

## Test Strategy

Run owner typecheck/tests and real local server loop. Test Bearer-only and proxy-compatible HTTP authentication, gateway header case variants, caller immutability and missing tokens. Verify this exact source candidate against actual OpenSandbox proxy before merge.

## Acceptance criteria

Existing services and gateway headers remain compatible; the protected operation succeeds through the actual proxy and owned resources are cleaned up. No cloud creation API or npm publication is implied.

## Evidence

[Verification](../../../evidence/verify/2026-10-24-proxy-application-token.md).
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,5 @@ 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.

The explicit application `token` is also sent as `X-Access-Token` when no supplied header already occupies that name, case-insensitively. Bearer is retained for existing services. Caller and gateway headers are copied for each request and never overwritten; requests without a token gain no application credentials. This supports server proxies that strip Authorization using the server's existing authentication header.
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,5 @@ Configuration storage uses one `storage` descriptor. / 配置存储统一使用
不能再传入 `token`。不能覆盖 Host 等寻址及传输请求头。公开 `baseUrl` 和连接
错误省略查询凭据。不要把平台控制面 API Key 作为应用令牌传入。本客户端使用
绑定代理前缀的 HTTP URL;按操作签名的路径绑定需要使用提供方适配器的逐操作传输接口,不能传入本前缀客户端。

显式应用 `token` 还会通过 `X-Access-Token` 发送,前提是调用方提供的请求头未占用该名称(不区分大小写)。Bearer 仍然保留以兼容现有服务。每次请求复制调用方与网关请求头,不会覆盖它们;未提供 token 时不会添加应用凭据。该行为使用服务端已有认证请求头,兼容剥离 Authorization 的服务端代理。
10 changes: 8 additions & 2 deletions src/server-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const DEFAULT_SERVER_PORT = 9000;
const SYNC_TIMEOUT_SLACK = 10;

export interface ServerClientOptions {
/** Sent as `Authorization: Bearer` when set. */
/** Sent as Bearer and, unless occupied, the existing X-Access-Token header. */
token?: string | null;
/** Extra headers on every request. */
headers?: Readonly<Record<string, string>>;
Expand Down Expand Up @@ -166,7 +166,13 @@ export class ServerClient {
/** Headers sent on every request. */
authHeaders(): Record<string, string> {
const headers = { ...this.#headers };
if (this.#token) headers.Authorization = `Bearer ${this.#token}`;
if (this.#token) {
headers.Authorization = `Bearer ${this.#token}`;
// Preserve caller/gateway credentials while supporting proxies that strip Bearer.
if (!Object.keys(headers).some(name => name.toLowerCase() === "x-access-token")) {
headers["X-Access-Token"] = this.#token;
}
}
return headers;
}

Expand Down
40 changes: 40 additions & 0 deletions test/server-client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,43 @@ test("opaque signed query values containing literal percent bytes stay unchanged
assert.deepEqual(hits, ['/proxy/owned/9000/health?sig=100%25&opaque=%2525']);
} finally { await client.close(); await server.close(); }
});


for (const header of ["X-Access-Token", "x-access-token", "X-aCcEsS-ToKeN"]) {
test(`preserves gateway ${header} and leaves caller headers unchanged`, async () => {
const gatewayHeaders = {[header]: "gateway-only"};
const client = new ServerClient("http://localhost:9000", {token: "application-only", gatewayHeaders});
const first = client.authHeaders();
assert.deepEqual(first, {"x-access-token": "gateway-only", Authorization: "Bearer application-only"});
first["x-access-token"] = "changed-copy";
assert.equal(client.authHeaders()["x-access-token"], "gateway-only");
assert.deepEqual(gatewayHeaders, {[header]: "gateway-only"});
await client.close();
});
}

test("does not create application authentication without a token", async () => {
const client = new ServerClient("http://localhost:9000", {gatewayHeaders: {"X-Gateway-Token": "gateway-only"}});
assert.deepEqual(client.authHeaders(), {"x-gateway-token": "gateway-only"});
await client.close();
});

for (const mode of ["bearer-only", "authorization-stripped"]) {
test(`authenticates a real HTTP ${mode} service`, async () => {
const server = await listen(async (req, res) => {
await readBody(req);
const accepted = mode === "authorization-stripped"
? req.headers["x-access-token"] === "application-only"
: req.headers.authorization === "Bearer application-only";
res.writeHead(accepted ? 200 : 401, {"Content-Type": "application/json"});
res.end(JSON.stringify(accepted ? {stdout: "42\n", stderr: "", exit_code: 0} : {error: "unauthorized"}));
});
const headers = {"X-Custom-App": "kept"};
const client = new ServerClient(`http://127.0.0.1:${server.port}/proxy/owned/9000`, {token: "application-only", headers});
try {
const result = await client.process.shell("echo 42");
assert.equal(result.stdout, "42\n");
assert.deepEqual(headers, {"X-Custom-App": "kept"});
} finally {await client.close(); await server.close();}
});
}
Loading