Files
AgentHub/server/__tests__/server-mode-proxy-gateway.test.js
nntrivi2001 e94038481e feat: v2.7.7 — File Explorer on workspaces and the user's home, clone with per-user git identity
- File Explorer (Files in the sidebar, /files; also from a workspace's Files
  tab, portalled so no panel clips it), laid out like GNOME Files: places
  (Home, workspaces, Trash), Back/Forward/Up, path bar, search, icon grid with
  image thumbnails or list, labelled New/Upload, selection action bar, hidden
  files behind Ctrl+H, right-click menus and the usual keys.
  Make, rename, move, copy (within a workspace, between workspaces, home ↔
  workspace), delete into a per-account trash restorable for 7 days, streamed
  upload of files and whole folders (≤ 500 MB a batch, Replace / Keep both /
  Skip on a taken name), download a file or a zip of anything.
  server/lib/file-explorer.js, /api/lanes/:id/fs/*, /api/homes/:who/fs/*.
- The user's home: every dot entry at its top stays hidden and refused
  (also via a symlink, download, zip or as a copy source); an admin may view
  other users' homes read-only, audited.
- write-as learns move/copy (re-run `sudo agenthub server install-system`).
- Hardening: a write to a workspace re-checks its owner by the live role;
  S3 keeps its server.conf if the file stops reading and a tmux call never
  falls back to the host's server; the AI Proxy gateway marks what it relays
  and the dashboard refuses it; a NUL in a pane's argv/env is refused.
- OpenAPI for the new routes (server/openapi-extra/files.js); docs (API,
  SERVER-MODE, CLAUDE.md) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:17:39 +07:00

267 lines
9.8 KiB
JavaScript

/**
* @file server-mode-proxy-gateway.test.js
* @description The AI Proxy key gateway (S3 plan, P1b). In server mode a pane
* never gets a proxy's key: its CLI talks to `/api/proxy-gw/<name>/v1/…` on
* loopback with its own pane token, and the gateway forwards to the profile's
* endpoint with the real key, streaming both ways. Refused: local mode, a
* non-loopback request, an unknown/dead pane token, a profile the pane was not
* started on, a path outside `/v1/`. Also what a spawn writes in server mode:
* no key in any file, the gateway as the endpoint, the pane token as the key.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const http = require("node:http");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const { execFileSync } = require("node:child_process");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-proxy-gw-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.LANES_ROOT = path.join(TMP, "lanes");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
process.env.CODEX_HOME = path.join(TMP, ".codex");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
const { createApp, startServer } = require("../index");
const auth = require("../lib/auth");
const proxies = require("../lib/ai-proxies");
const { proxyForSpawn, resolveProxy } = require("../lib/proxy-apply");
const { db } = require("../db");
const KEY = "sk-upstream-real-key";
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
let server;
let BASE;
let upstream;
let seen = []; // what the fake upstream received
const LIVE_TOKEN = "ahpane_live-token-for-tests-0001";
const DEAD_TOKEN = "ahpane_dead-token-for-tests-0002";
const LIVE_RUN = "agenthub-lane-900";
const DEAD_RUN = "agenthub-lane-901";
let profileName;
let otherName;
function gw(p, { token = LIVE_TOKEN, headers = {}, body, method = "POST" } = {}) {
return fetch(`${BASE}/api/proxy-gw/${p}`, {
method,
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
...headers,
},
body,
});
}
before(async () => {
upstream = http.createServer((req, res) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
seen.push({ url: req.url, headers: req.headers, body: raw });
if (req.url.endsWith("/stream")) {
res.writeHead(200, { "content-type": "text/event-stream" });
res.write("event: a\ndata: 1\n\n");
setTimeout(() => res.end("event: b\ndata: 2\n\n"), 30);
return;
}
res.writeHead(201, { "content-type": "application/json", "x-upstream": "yes" });
res.end(JSON.stringify({ ok: true }));
});
});
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
const upBase = `http://127.0.0.1:${upstream.address().port}/root`;
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "developer");
profileName = proxies.createProxy({
label: "Team GW",
baseUrl: upBase,
apiKey: KEY,
model: "m",
}).name;
otherName = proxies.createProxy({
label: "Other GW",
baseUrl: upBase,
apiKey: KEY,
model: "m",
}).name;
const laneId = db
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/gw', 'claude', ?)")
.run(mia.id).lastInsertRowid;
const run = db.prepare(
`INSERT INTO dashboard_runs (id, lane_id, cwd, mode, status, proxy, pane_token_hash)
VALUES (?, ?, '/w/gw', 'interactive', 'running', ?, ?)`
);
run.run(LIVE_RUN, laneId, profileName, sha256(LIVE_TOKEN));
run.run(DEAD_RUN, laneId, profileName, sha256(DEAD_TOKEN));
// A live pane for LIVE_RUN only, on the suite's private tmux socket.
execFileSync("tmux", ["new-session", "-d", "-s", LIVE_RUN, "sleep 300"]);
process.env.AGENTHUB_MODE = "server";
});
after(() => {
delete process.env.AGENTHUB_MODE;
try {
execFileSync("tmux", ["kill-session", "-t", LIVE_RUN]);
} catch {
/* already gone */
}
server?.close();
upstream?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("the proxy gateway", () => {
it("forwards with the real key, never the pane token, body and status intact", async () => {
seen = [];
const res = await gw(`${profileName}/v1/messages?beta=true`, {
headers: { "anthropic-version": "2023-06-01", "x-api-key": LIVE_TOKEN },
body: JSON.stringify({ hello: "world" }),
});
assert.equal(res.status, 201);
assert.equal(res.headers.get("x-upstream"), "yes");
assert.deepEqual(await res.json(), { ok: true });
assert.equal(seen.length, 1);
const got = seen[0];
assert.equal(got.url, "/root/v1/messages?beta=true");
assert.equal(got.headers.authorization, `Bearer ${KEY}`);
assert.equal(got.headers["x-api-key"], KEY);
assert.equal(got.headers["anthropic-version"], "2023-06-01");
assert.ok(!JSON.stringify(got.headers).includes("ahpane_"));
assert.equal(got.body, JSON.stringify({ hello: "world" }));
});
it("streams an SSE answer through", async () => {
const res = await gw(`${profileName}/v1/stream`, { body: "{}" });
assert.equal(res.status, 200);
assert.equal(await res.text(), "event: a\ndata: 1\n\nevent: b\ndata: 2\n\n");
});
it("refuses a missing, unknown or dead pane token", async () => {
for (const token of [null, "ahpane_nope", DEAD_TOKEN]) {
seen = [];
const res = await gw(`${profileName}/v1/messages`, { token, body: "{}" });
assert.equal(res.status, 401, String(token));
assert.equal(seen.length, 0);
}
});
it("refuses a profile the pane was not started on", async () => {
seen = [];
const res = await gw(`${otherName}/v1/messages`, { body: "{}" });
assert.equal(res.status, 403);
assert.equal(seen.length, 0);
});
it("refuses a path that leaves /v1/", async () => {
seen = [];
for (const p of [`${profileName}/v1/../../admin`, `${profileName}/v2/x`, `${profileName}`]) {
const res = await gw(p, { body: "{}" });
assert.equal(res.status, 400, p);
}
assert.equal(seen.length, 0);
});
it("refuses a raw `..` path the client did not normalise", async () => {
seen = [];
for (const raw of [`${profileName}/v1/../../admin`, `${profileName}/v1/%2e%2e/%2e%2e/admin`]) {
const status = await new Promise((resolve, reject) => {
const req = http.request(
`${BASE}/api/proxy-gw/x`,
{
method: "POST",
path: `/api/proxy-gw/${raw}`,
headers: { authorization: `Bearer ${LIVE_TOKEN}`, "content-type": "application/json" },
},
(res) => {
res.resume();
resolve(res.statusCode);
}
);
req.on("error", reject);
req.end("{}");
});
assert.equal(status, 400, raw);
}
assert.equal(seen.length, 0);
});
it("refuses a request that came through a reverse proxy", async () => {
const res = await gw(`${profileName}/v1/messages`, {
headers: { "x-forwarded-for": "10.0.0.9" },
body: "{}",
});
assert.equal(res.status, 403);
});
it("marks what it relays, and the dashboard refuses anything so marked", async () => {
seen = [];
await gw(`${profileName}/v1/messages`, { headers: { "x-api-key": LIVE_TOKEN }, body: "{}" });
assert.equal(seen[0].headers["x-agenthub-via-gateway"], "1");
// A profile pointed back at the dashboard itself: what arrives carries
// the mark, so no route answers it — the hooks least of all.
const self = `http://127.0.0.1:${server.address().port}`;
for (const p of ["/api/hooks/event", "/api/lanes", "/api/health"]) {
const r = await fetch(`${self}${p}`, {
method: "POST",
headers: { "content-type": "application/json", "x-agenthub-via-gateway": "1" },
body: "{}",
});
assert.equal(r.status, 403, p);
assert.equal((await r.json()).error.code, "EGATEWAY");
}
});
it("is not there in local mode", async () => {
delete process.env.AGENTHUB_MODE;
try {
const res = await gw(`${profileName}/v1/messages`, { body: "{}" });
assert.equal(res.status, 404);
} finally {
process.env.AGENTHUB_MODE = "server";
}
});
});
describe("a spawn on a proxy in server mode", () => {
it("puts no key in Claude's settings file — the pane's own, with its pane token", () => {
const spawn = proxyForSpawn(
"claude",
resolveProxy("claude", profileName),
LIVE_TOKEN,
null,
"agenthub-lane-3"
);
const file = spawn.argv[0].replace(/^--settings=/, "");
assert.equal(path.basename(file), `${profileName}--agenthub-lane-3.json`);
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
const text = fs.readFileSync(file, "utf8");
assert.ok(!text.includes(KEY));
const env = JSON.parse(text).env;
assert.match(env.ANTHROPIC_BASE_URL, new RegExp(`/api/proxy-gw/${profileName}$`));
// The command-line layer: a user's or project's env.ANTHROPIC_* cannot override it.
assert.equal(env.ANTHROPIC_AUTH_TOKEN, LIVE_TOKEN);
assert.deepEqual(spawn.env, { ANTHROPIC_AUTH_TOKEN: LIVE_TOKEN });
});
it("points Codex's layer at the gateway, keyed by the pane token", () => {
const spawn = proxyForSpawn("codex", resolveProxy("codex", profileName), LIVE_TOKEN);
const layer = fs.readFileSync(
path.join(process.env.CODEX_HOME, `${profileName}.config.toml`),
"utf8"
);
assert.ok(!layer.includes(KEY));
assert.match(layer, new RegExp(`base_url = ".*/api/proxy-gw/${profileName}/v1"`));
assert.match(layer, /env_key = "AGENTHUB_PROXY_KEY"/);
assert.deepEqual(spawn.env, { AGENTHUB_PROXY_KEY: LIVE_TOKEN });
});
});