fix: allow public code agent chat proxy
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
const GET_PROXY_PREFIXES = Object.freeze(["/v1/"]);
|
||||
const GET_PROXY_ROUTES = new Set(["/v1"]);
|
||||
const POST_PROXY_ROUTES = new Set([
|
||||
"/json-rpc",
|
||||
"/v1/agent/chat",
|
||||
"/v1/agent/chat/cancel",
|
||||
"/v1/m3/io"
|
||||
]);
|
||||
const PUBLIC_PROXY_ROUTES = new Set([
|
||||
"POST /v1/agent/chat"
|
||||
]);
|
||||
|
||||
export function cloudWebProxyRoutePolicy(method, pathname) {
|
||||
const normalizedMethod = normalizeMethod(method);
|
||||
const normalizedPath = normalizePathname(pathname);
|
||||
const routeKey = `${normalizedMethod} ${normalizedPath}`;
|
||||
const proxy = isCloudApiProxyRoute(normalizedMethod, normalizedPath);
|
||||
const publicRoute = proxy && PUBLIC_PROXY_ROUTES.has(routeKey);
|
||||
return {
|
||||
proxy,
|
||||
authRequired: proxy && !publicRoute,
|
||||
publicRoute,
|
||||
routeKey
|
||||
};
|
||||
}
|
||||
|
||||
export function isCloudApiProxyRoute(method, pathname) {
|
||||
const normalizedMethod = normalizeMethod(method);
|
||||
const normalizedPath = normalizePathname(pathname);
|
||||
if (normalizedMethod === "GET") {
|
||||
return GET_PROXY_ROUTES.has(normalizedPath) ||
|
||||
GET_PROXY_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix));
|
||||
}
|
||||
if (normalizedMethod === "POST") {
|
||||
return POST_PROXY_ROUTES.has(normalizedPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeMethod(method) {
|
||||
return String(method || "GET").trim().toUpperCase() || "GET";
|
||||
}
|
||||
|
||||
function normalizePathname(pathname) {
|
||||
const value = String(pathname || "/").trim();
|
||||
if (!value || value[0] !== "/") return "/";
|
||||
return value.replace(/\/+$/u, "") || "/";
|
||||
}
|
||||
@@ -2,8 +2,48 @@ import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import test from "node:test";
|
||||
|
||||
import { cloudWebProxyRoutePolicy } from "./cloud-web-routes.mjs";
|
||||
import { proxyHttpRequest } from "./http.mjs";
|
||||
|
||||
test("cloud web route policy proxies public Code Agent chat without gating other writes", () => {
|
||||
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/agent/chat"), {
|
||||
proxy: true,
|
||||
authRequired: false,
|
||||
publicRoute: true,
|
||||
routeKey: "POST /v1/agent/chat"
|
||||
});
|
||||
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/agent/chat/cancel"), {
|
||||
proxy: true,
|
||||
authRequired: true,
|
||||
publicRoute: false,
|
||||
routeKey: "POST /v1/agent/chat/cancel"
|
||||
});
|
||||
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/m3/io"), {
|
||||
proxy: true,
|
||||
authRequired: true,
|
||||
publicRoute: false,
|
||||
routeKey: "POST /v1/m3/io"
|
||||
});
|
||||
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/json-rpc"), {
|
||||
proxy: true,
|
||||
authRequired: true,
|
||||
publicRoute: false,
|
||||
routeKey: "POST /json-rpc"
|
||||
});
|
||||
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/agent/chat/trace/trc_live"), {
|
||||
proxy: true,
|
||||
authRequired: true,
|
||||
publicRoute: false,
|
||||
routeKey: "GET /v1/agent/chat/trace/trc_live"
|
||||
});
|
||||
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/app.mjs"), {
|
||||
proxy: false,
|
||||
authRequired: false,
|
||||
publicRoute: false,
|
||||
routeKey: "GET /app.mjs"
|
||||
});
|
||||
});
|
||||
|
||||
test("dev entrypoint proxy allows slow first response beyond legacy 4500ms", async () => {
|
||||
const upstream = createServer((request, response) => {
|
||||
request.resume();
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -10,7 +10,11 @@ import {
|
||||
handleCodeAgentChat,
|
||||
validateCodeAgentChatSchema
|
||||
} from "../internal/cloud/code-agent-chat.mjs";
|
||||
import { createCodexStdioSessionManager } from "../internal/cloud/codex-stdio-session.mjs";
|
||||
import {
|
||||
codexAppServerArgs,
|
||||
codexAppServerProviderBaseUrl,
|
||||
createCodexStdioSessionManager
|
||||
} from "../internal/cloud/codex-stdio-session.mjs";
|
||||
import {
|
||||
classifyCodexRunnerCapability,
|
||||
classifyCodeAgentChatReadiness,
|
||||
@@ -281,6 +285,41 @@ async function runLocalContractSmoke() {
|
||||
}
|
||||
logOk("non-2xx completion-looking payloads are blocked");
|
||||
|
||||
const failedProvider401Payload = {
|
||||
...completedCodexPayload,
|
||||
status: "failed",
|
||||
reply: undefined,
|
||||
error: {
|
||||
code: "codex_stdio_failed",
|
||||
providerStatus: 401,
|
||||
layer: "runner",
|
||||
message: "provider returned HTTP 401"
|
||||
},
|
||||
availability: {
|
||||
status: "codex-stdio-feasible",
|
||||
ready: true
|
||||
}
|
||||
};
|
||||
const provider401Readiness = classifyCodeAgentChatReadiness(failedProvider401Payload, { realDevLive: true, httpStatus: 200 });
|
||||
assert.equal(provider401Readiness.status, "blocked");
|
||||
assert.equal(provider401Readiness.level, "BLOCKED/provider");
|
||||
assert.equal(provider401Readiness.blocker, "provider-upstream");
|
||||
assert.equal(provider401Readiness.providerStatus, 401);
|
||||
assert.equal(provider401Readiness.devLiveReplyPass, false);
|
||||
logOk("providerStatus=401 blocks even when readiness fields look feasible");
|
||||
|
||||
const providerEnv = {
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses",
|
||||
OPENAI_API_KEY: "test-openai-key-material"
|
||||
};
|
||||
assert.equal(codexAppServerProviderBaseUrl(providerEnv), "http://172.26.26.227:17680/v1");
|
||||
const providerArgs = codexAppServerArgs(providerEnv).join(" ");
|
||||
assert.match(providerArgs, /model_providers\.OpenAI\.base_url="http:\/\/172\.26\.26\.227:17680\/v1"/u);
|
||||
assert.match(providerArgs, /model_providers\.OpenAI\.wire_api="responses"/u);
|
||||
assert.match(providerArgs, /model_providers\.OpenAI\.requires_openai_auth=true/u);
|
||||
assert.equal(providerArgs.includes("test-openai-key-material"), false);
|
||||
logOk("Codex app-server provider override keeps auth material out of argv");
|
||||
|
||||
process.stdout.write("[code-agent-chat-smoke] passed\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -325,6 +325,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { cloudWebProxyRoutePolicy } from "/app/internal/dev-entrypoint/cloud-web-routes.mjs";
|
||||
|
||||
const serviceId = process.env.HWLAB_SERVICE_ID || "hwlab-unknown";
|
||||
const environment = process.env.HWLAB_ENVIRONMENT || "dev";
|
||||
@@ -740,11 +741,9 @@ async function serveCloudWeb() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(request.method === "GET" && (url.pathname === "/v1" || url.pathname === "/v1/m3/status" || url.pathname.startsWith("/v1/"))) ||
|
||||
(request.method === "POST" && (url.pathname === "/json-rpc" || url.pathname === "/v1/agent/chat" || url.pathname === "/v1/m3/io"))
|
||||
) {
|
||||
if (!activeAuthSession(request)) {
|
||||
const proxyPolicy = cloudWebProxyRoutePolicy(request.method, url.pathname);
|
||||
if (proxyPolicy.proxy) {
|
||||
if (proxyPolicy.authRequired && !activeAuthSession(request)) {
|
||||
sendJson(response, 401, {
|
||||
status: "failed",
|
||||
error: "auth_required",
|
||||
|
||||
@@ -224,13 +224,14 @@ export async function buildM3IoControlSourceReport(options = {}) {
|
||||
}
|
||||
|
||||
export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } = {}) {
|
||||
const [serverSource, jsonRpcSource, m3Source, appSource, htmlSource, artifactPublisherSource] = await Promise.all([
|
||||
const [serverSource, jsonRpcSource, m3Source, appSource, htmlSource, artifactPublisherSource, cloudWebRouteSource] = await Promise.all([
|
||||
readRepoText(root, "internal/cloud/server.mjs"),
|
||||
readRepoText(root, "internal/cloud/json-rpc.mjs"),
|
||||
readRepoText(root, "internal/cloud/m3-io-control.mjs"),
|
||||
readRepoText(root, "web/hwlab-cloud-web/app.mjs"),
|
||||
readRepoText(root, "web/hwlab-cloud-web/index.html"),
|
||||
readRepoText(root, "scripts/dev-artifact-publish.mjs")
|
||||
readRepoText(root, "scripts/dev-artifact-publish.mjs"),
|
||||
readRepoText(root, "internal/dev-entrypoint/cloud-web-routes.mjs")
|
||||
]);
|
||||
|
||||
const contract = describeM3IoControl({
|
||||
@@ -241,7 +242,8 @@ export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } =
|
||||
const frontendGuardrails = checkFrontendNoDirectRuntimeCalls({
|
||||
appSource,
|
||||
htmlSource,
|
||||
artifactPublisherSource
|
||||
artifactPublisherSource,
|
||||
cloudWebRouteSource
|
||||
});
|
||||
const requestSchema = validateExpectedRequestSchemas(expectedM3IoLiveSequence());
|
||||
const responseSchema = validateM3IoSourceResponseContract(m3Source);
|
||||
@@ -323,7 +325,7 @@ export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } =
|
||||
];
|
||||
}
|
||||
|
||||
export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "", artifactPublisherSource = "" }) {
|
||||
export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "", artifactPublisherSource = "", cloudWebRouteSource = "" }) {
|
||||
const source = `${appSource}\n${htmlSource}`;
|
||||
const fetchTargets = literalFetchTargets(appSource);
|
||||
const issues = [];
|
||||
@@ -382,10 +384,15 @@ export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "",
|
||||
issues.push(`m3ControlCanOperate must require ${label}`);
|
||||
}
|
||||
}
|
||||
if (!/url\.pathname === "\/v1\/m3\/io"/u.test(artifactPublisherSource)) {
|
||||
const artifactUsesCloudWebRoutePolicy = /cloudWebProxyRoutePolicy\(request\.method,\s*url\.pathname\)/u.test(artifactPublisherSource);
|
||||
const routePolicyProxiesM3Io = artifactUsesCloudWebRoutePolicy &&
|
||||
new RegExp(`"${escapeRegExp(M3_IO_CONTROL_ROUTE)}"`, "u").test(cloudWebRouteSource);
|
||||
const routePolicyProxiesV1Status = artifactUsesCloudWebRoutePolicy &&
|
||||
/const GET_PROXY_PREFIXES = Object\.freeze\(\["\/v1\/"\]\)/u.test(cloudWebRouteSource);
|
||||
if (!routePolicyProxiesM3Io && !/url\.pathname === "\/v1\/m3\/io"/u.test(artifactPublisherSource)) {
|
||||
issues.push("cloud-web artifact server must proxy same-origin /v1/m3/io to cloud-api");
|
||||
}
|
||||
if (!/\/v1\/m3\/status/u.test(artifactPublisherSource)) {
|
||||
if (!routePolicyProxiesV1Status && !/\/v1\/m3\/status/u.test(artifactPublisherSource)) {
|
||||
issues.push("cloud-web artifact server must proxy same-origin /v1/m3/status to cloud-api");
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ const buildScript = fs.readFileSync(path.resolve(rootDir, "scripts/build.mjs"),
|
||||
const distContractScript = fs.readFileSync(path.resolve(rootDir, "scripts/dist-contract.mjs"), "utf8");
|
||||
const markedLicense = fs.readFileSync(path.resolve(rootDir, "third_party/marked/LICENSE"), "utf8");
|
||||
const artifactPublisher = fs.readFileSync(path.resolve(repoRoot, "scripts/dev-artifact-publish.mjs"), "utf8");
|
||||
const cloudWebRoutes = fs.readFileSync(path.resolve(repoRoot, "internal/dev-entrypoint/cloud-web-routes.mjs"), "utf8");
|
||||
const frontendSource = `${html}\n${auth}\n${app}\n${artifactPublisher}`;
|
||||
const requiredTrustedRecordTerms = Object.freeze([
|
||||
"Code Agent 对话记录",
|
||||
@@ -980,9 +981,14 @@ assert.match(artifactPublisher, /readOnlyRpcMethods/);
|
||||
assert.match(artifactPublisher, /requestUpstream/);
|
||||
assert.match(artifactPublisher, /from "node:http"/);
|
||||
assert.doesNotMatch(artifactPublisher, /await fetch\(target/);
|
||||
assert.match(artifactPublisher, /url\.pathname === "\/v1\/agent\/chat"/);
|
||||
assert.match(artifactPublisher, /url\.pathname === "\/v1\/m3\/io"/);
|
||||
assert.match(artifactPublisher, /url\.pathname === "\/v1\/m3\/status"/);
|
||||
assert.match(artifactPublisher, /cloudWebProxyRoutePolicy\(request\.method,\s*url\.pathname\)/);
|
||||
assert.match(cloudWebRoutes, /"\/v1\/agent\/chat"/);
|
||||
assert.match(cloudWebRoutes, /"\/v1\/agent\/chat\/cancel"/);
|
||||
assert.match(cloudWebRoutes, /"\/v1\/m3\/io"/);
|
||||
assert.match(cloudWebRoutes, /"POST \/v1\/agent\/chat"/);
|
||||
assert.match(cloudWebRoutes, /const GET_PROXY_PREFIXES = Object\.freeze\(\["\/v1\/"\]\)/);
|
||||
assert.match(cloudWebRoutes, /const GET_PROXY_ROUTES = new Set\(\["\/v1"\]\)/);
|
||||
assert.match(cloudWebRoutes, /authRequired: proxy && !publicRoute/);
|
||||
assert.match(artifactPublisher, /"system\.health"/);
|
||||
assert.match(artifactPublisher, /"cloud\.adapter\.describe"/);
|
||||
assert.match(artifactPublisher, /"audit\.event\.query"/);
|
||||
|
||||
@@ -29,6 +29,7 @@ export function runM3ControlPanelGuard() {
|
||||
const app = readText("web/hwlab-cloud-web/app.mjs");
|
||||
const styles = readText("web/hwlab-cloud-web/styles.css");
|
||||
const artifactPublisher = readText("scripts/dev-artifact-publish.mjs");
|
||||
const cloudWebRouteSource = readText("internal/dev-entrypoint/cloud-web-routes.mjs");
|
||||
const browserSource = `${html}\n${app}\n${styles}`;
|
||||
|
||||
assert.equal(M3_IO_CONTROL_ROUTE, "/v1/m3/io", "M3 IO control route must remain exact");
|
||||
@@ -41,7 +42,7 @@ export function runM3ControlPanelGuard() {
|
||||
|
||||
assertControlPanelDom(html);
|
||||
assertControlPanelSource(app);
|
||||
assertBrowserWriteBoundaries({ app, html, artifactPublisher });
|
||||
assertBrowserWriteBoundaries({ app, html, artifactPublisher, cloudWebRouteSource });
|
||||
assertNoStaticDevLiveClaim({ html, app });
|
||||
|
||||
for (const pattern of [
|
||||
@@ -203,13 +204,14 @@ function assertControlPanelSource(app) {
|
||||
assert.match(operationRecordBody, /frontendBypass=false/u, "operation record must show no frontend bypass");
|
||||
}
|
||||
|
||||
function assertBrowserWriteBoundaries({ app, html, artifactPublisher }) {
|
||||
function assertBrowserWriteBoundaries({ app, html, artifactPublisher, cloudWebRouteSource }) {
|
||||
const browserSource = `${html}\n${app}`;
|
||||
const sanitizedBrowserSource = browserSource.replace(/data-static-source-fallback="SOURCE"/gu, "");
|
||||
const frontendGuard = checkFrontendNoDirectRuntimeCalls({
|
||||
appSource: app,
|
||||
htmlSource: html,
|
||||
artifactPublisherSource: artifactPublisher
|
||||
artifactPublisherSource: artifactPublisher,
|
||||
cloudWebRouteSource
|
||||
});
|
||||
assert.equal(frontendGuard.status, "pass", frontendGuard.issues.join("\n"));
|
||||
assert.deepEqual(
|
||||
|
||||
Reference in New Issue
Block a user