Skip to content

Commit b364d96

Browse files
committed
fix(files): restrict temporary uploads to supported regions
1 parent 9344dbb commit b364d96

4 files changed

Lines changed: 215 additions & 61 deletions

File tree

‎docs/agents/url-change.md‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,23 @@ runtime/src/urls.ts ← 用户面控制台 URL(cn-only)
2323
MCP_WEBSEARCH_PAGE mcpMarketplaceDetailPage("WebSearch")
2424
mcpMarketplaceDetailPage BAILIAN_CONSOLE?tab=mcp#/mcp-market/detail/<serverCode>
2525
26-
core/files/upload.ts ← 文件上传 endpoint(与 model 域凭证同源)
27-
UploadOptions.baseUrl Client 传入有效凭证的 baseUrl,经 normalizeModelBaseUrl 取 origin
28-
/api/v1/uploads 直接 SDK 调用未提供 baseUrl 时保持 REGIONS.cn 默认值
26+
core/files/upload.ts ← model baseUrl → 同源临时上传 endpoint
27+
北京/新加坡公共站 保持当前公共站 origin + /api/v1/uploads
28+
北京/新加坡 Workspace 保持当前 Workspace origin + /api/v1/uploads
29+
显式自定义代理 保持代理 origin,不改写到公共站
30+
其他官方地域 / Token Plan 联网前报 usage error,引导使用 HTTPS URL
31+
UploadOptions.baseUrl 直接 SDK 调用未提供时保持北京公共站默认值
2932
```
3033

31-
临时文件上传与后续模型请求使用同一 API origin,包括国际区、Workspace 和自定义代理。
3234
Prime 视频命令通过 `Client.uploadFile` / `resolveImageInput` 的 `defaultBaseUrl` 回调,
33-
复用 `resolvePrimeEndpoint` 推导默认 Workspace origin;显式配置的 API origin 仍优先,
34-
已有媒体 URL 不触发上传或求值上传默认地址。
35-
上传服务的区域/模型支持由服务端决定:错误中附上有效上传 endpoint 并保留原始服务响应,
36-
不自动换区域重试。
37-
如果所选服务不支持临时上传,使用服务可访问的 HTTPS 媒体 URL;已有 URL 会跳过本地上传。
35+
复用 `resolvePrimeEndpoint` 推导 model Workspace origin;上传策略与模型提交均保持该
36+
Workspace origin。显式配置的 model origin 仍优先;已有媒体 URL 不触发上传或求值上传默认地址。
37+
38+
官方临时文件上传当前只支持 `cn-beijing` 与 `ap-southeast-1`;其他已知官方地域与
39+
Token Plan 在发送 API Key 前失败。显式自定义代理视为自身路由的权威来源,保持同源并由
40+
代理/服务决定能力。任何路径都不得回退公共站或跨 origin 重试;不支持时用户可改用服务
41+
可访问的 HTTPS 媒体 URL。服务端错误保留 message/code/request ID/raw response,并在
42+
message 的结构化上下文中展示本次有效上传 endpoint。
3843

3944
## 必查清单
4045

‎packages/commands/tests/prime.test.ts‎

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
4-
import { Client, REGIONS, type ApiKeyCredential, type Settings } from "bailian-cli-core";
4+
import { Client, ExitCode, REGIONS, type ApiKeyCredential, type Settings } from "bailian-cli-core";
55
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
66
import { resolvePrimeEndpoint, validatePrimeFlags } from "../src/commands/shared/prime.ts";
77
import videoGenerate from "../src/commands/video/generate.ts";
@@ -145,20 +145,25 @@ describe("Prime video upload origins", () => {
145145
} as never);
146146
}
147147

148-
function expectUploadOrigin(origin: string, uploadCount = 1) {
148+
function expectRequestOrigins(options: {
149+
uploadOrigin: string;
150+
submitOrigin: string;
151+
uploadCount?: number;
152+
}) {
153+
const { uploadOrigin, submitOrigin, uploadCount = 1 } = options;
149154
const policyCalls = fetchMock.mock.calls.filter(([input]) => {
150155
const url = input instanceof Request ? input.url : input.toString();
151156
return url.includes("/api/v1/uploads?");
152157
});
153158
expect(policyCalls).toHaveLength(uploadCount);
154159
for (const [policyUrl, policyOptions] of policyCalls) {
155-
expect(policyUrl).toBe(`${origin}/api/v1/uploads?action=getPolicy&model=wan3.0-video`);
160+
expect(policyUrl).toBe(`${uploadOrigin}/api/v1/uploads?action=getPolicy&model=wan3.0-video`);
156161
expect(new Headers(policyOptions?.headers).get("Authorization")).toBe(
157162
"Bearer synthetic-workspace-key",
158163
);
159164
}
160165
const [submitUrl, submitOptions] = fetchMock.mock.calls.at(-1)!;
161-
expect(submitUrl).toBe(`${origin}/api/v1/services/aigc/video-generation/video-synthesis`);
166+
expect(submitUrl).toBe(`${submitOrigin}/api/v1/services/aigc/video-generation/video-synthesis`);
162167
expect(new Headers(submitOptions?.headers).get("Authorization")).toBe(
163168
"Bearer synthetic-workspace-key",
164169
);
@@ -175,24 +180,49 @@ describe("Prime video upload origins", () => {
175180
settingsWorkspaceId: credentialSource === "env" ? "ws_test" : undefined,
176181
},
177182
);
178-
expectUploadOrigin(workspaceOrigin);
183+
expectRequestOrigins({ uploadOrigin: workspaceOrigin, submitOrigin: workspaceOrigin });
179184
},
180185
);
181186

182187
test("uploads both local keyframes to the derived workspace", async () => {
183188
await runVideo({ image: fixturePath, lastFrame: fixturePath, workspaceId: "ws_test" });
184-
expectUploadOrigin(workspaceOrigin, 2);
189+
expectRequestOrigins({
190+
uploadOrigin: workspaceOrigin,
191+
submitOrigin: workspaceOrigin,
192+
uploadCount: 2,
193+
});
185194
});
186195

187196
test.each([
188-
REGIONS.intl,
189-
REGIONS.us,
190-
"https://configured-workspace.ap-southeast-1.maas.aliyuncs.com",
191-
"https://proxy.example.test",
192-
])("keeps the configured origin %s without requiring a workspace", async (baseUrl) => {
193-
await runVideo({ file: fixturePath }, { baseUrl });
194-
expectUploadOrigin(baseUrl);
195-
});
197+
[REGIONS.cn, REGIONS.cn],
198+
[REGIONS.intl, REGIONS.intl],
199+
[
200+
"https://configured-workspace.cn-beijing.maas.aliyuncs.com",
201+
"https://configured-workspace.cn-beijing.maas.aliyuncs.com",
202+
],
203+
[
204+
"https://configured-workspace.ap-southeast-1.maas.aliyuncs.com",
205+
"https://configured-workspace.ap-southeast-1.maas.aliyuncs.com",
206+
],
207+
["https://proxy.example.test", "https://proxy.example.test"],
208+
])(
209+
"routes configured origin %s through upload service %s without requiring a workspace",
210+
async (baseUrl, uploadOrigin) => {
211+
await runVideo({ file: fixturePath }, { baseUrl });
212+
expectRequestOrigins({ uploadOrigin, submitOrigin: baseUrl });
213+
},
214+
);
215+
216+
test.each([REGIONS.us, "https://configured-workspace.us-east-1.maas.aliyuncs.com"])(
217+
"rejects unsupported local upload origin %s before submitting",
218+
async (baseUrl) => {
219+
await expect(runVideo({ file: fixturePath }, { baseUrl })).rejects.toMatchObject({
220+
exitCode: ExitCode.USAGE,
221+
hint: expect.stringContaining("HTTPS media URL"),
222+
});
223+
expect(fetchMock).not.toHaveBeenCalled();
224+
},
225+
);
196226

197227
test("rejects a missing default workspace before making an upload request", async () => {
198228
await expect(runVideo({ file: fixturePath })).rejects.toMatchObject({
@@ -203,6 +233,10 @@ describe("Prime video upload origins", () => {
203233

204234
test("passes an existing media URL through without uploading", async () => {
205235
await runVideo({ file: "https://media.example.test/video.mp4", workspaceId: "ws_test" });
206-
expectUploadOrigin(workspaceOrigin, 0);
236+
expectRequestOrigins({
237+
uploadOrigin: workspaceOrigin,
238+
submitOrigin: workspaceOrigin,
239+
uploadCount: 0,
240+
});
207241
});
208242
});

‎packages/core/src/files/upload.ts‎

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,49 @@ import { basename, extname } from "path";
1010
import { BailianError } from "../errors/base.ts";
1111
import { ExitCode } from "../errors/codes.ts";
1212
import { trackingHeaders, type TrackingIdentity } from "../client/headers.ts";
13+
import { getApiKeyLoginKind } from "../config/profile-presets.ts";
1314
import { REGIONS } from "../config/schema.ts";
1415
import { normalizeModelBaseUrl } from "../config/model-base-url.ts";
1516

17+
const SUPPORTED_UPLOAD_REGIONS = ["cn-beijing", "ap-southeast-1"] as const;
18+
19+
function unsupportedTemporaryUpload(target: string): BailianError {
20+
return new BailianError(
21+
`Local-file temporary upload is supported only in ${SUPPORTED_UPLOAD_REGIONS.join(", ")}; "${target}" is not supported.`,
22+
ExitCode.USAGE,
23+
"Use a reachable HTTPS media URL instead.",
24+
);
25+
}
26+
27+
/** Keep the configured origin when supported; reject known unsupported official regions. */
28+
function resolveUploadBaseUrl(baseUrl: string, apiKey: string): string {
29+
const normalizedBaseUrl = normalizeModelBaseUrl(baseUrl);
30+
const hostname = new URL(normalizedBaseUrl).hostname.toLowerCase();
31+
32+
if (
33+
getApiKeyLoginKind(apiKey) === "token-plan" ||
34+
/^token-plan\.[a-z0-9-]+\.maas\.aliyuncs\.com$/.test(hostname)
35+
) {
36+
throw unsupportedTemporaryUpload("Token Plan");
37+
}
38+
39+
if (hostname === new URL(REGIONS.cn).hostname) return normalizedBaseUrl;
40+
if (hostname === new URL(REGIONS.intl).hostname) return normalizedBaseUrl;
41+
if (hostname === new URL(REGIONS.us).hostname) {
42+
throw unsupportedTemporaryUpload(hostname);
43+
}
44+
45+
const maasMatch = /^[^.]+\.([a-z0-9-]+)\.maas\.aliyuncs\.com$/.exec(hostname);
46+
const cloudRegion = maasMatch?.[1];
47+
if (cloudRegion === "cn-beijing" || cloudRegion === "ap-southeast-1") {
48+
return normalizedBaseUrl;
49+
}
50+
if (cloudRegion) throw unsupportedTemporaryUpload(cloudRegion);
51+
52+
// Explicit custom proxies are authoritative for their own upload routing.
53+
return normalizedBaseUrl;
54+
}
55+
1656
interface UploadPolicy {
1757
upload_host: string;
1858
upload_dir: string;
@@ -28,6 +68,12 @@ interface UploadPolicyResponse {
2868
request_id?: string;
2969
}
3070

71+
interface UploadPolicyErrorResponse {
72+
code?: unknown;
73+
message?: unknown;
74+
request_id?: unknown;
75+
}
76+
3177
/**
3278
* Step 1: Fetch the upload policy (presigned credentials) from DashScope.
3379
*/
@@ -38,7 +84,7 @@ async function getUploadPolicy(
3884
baseUrl: string,
3985
signal?: AbortSignal,
4086
): Promise<UploadPolicy> {
41-
const uploadApi = `${normalizeModelBaseUrl(baseUrl)}/api/v1/uploads`;
87+
const uploadApi = `${resolveUploadBaseUrl(baseUrl, apiKey)}/api/v1/uploads`;
4288
const url = `${uploadApi}?action=getPolicy&model=${encodeURIComponent(model)}`;
4389
const policySignal = combineWithTimeout(15_000, signal);
4490
const res = await fetch(url, {
@@ -52,10 +98,35 @@ async function getUploadPolicy(
5298

5399
if (!res.ok) {
54100
const text = await res.text().catch(() => "");
55-
throw new BailianError(
56-
`Failed to get upload policy (HTTP ${res.status}, ${uploadApi}): ${text}`,
57-
ExitCode.GENERAL,
58-
);
101+
let serviceError: UploadPolicyErrorResponse | undefined;
102+
try {
103+
serviceError = JSON.parse(text) as UploadPolicyErrorResponse;
104+
} catch {
105+
// Non-JSON service responses pass through below.
106+
}
107+
const serviceMessage =
108+
typeof serviceError?.message === "string" && serviceError.message
109+
? serviceError.message
110+
: text || `${res.status} ${res.statusText}`;
111+
const apiCode = typeof serviceError?.code === "string" ? serviceError.code : undefined;
112+
const requestId =
113+
typeof serviceError?.request_id === "string" ? serviceError.request_id : undefined;
114+
const responseContext = [
115+
`HTTP ${res.status}`,
116+
apiCode ? `code=${apiCode}` : undefined,
117+
requestId ? `request_id=${requestId}` : undefined,
118+
`upload_endpoint=${uploadApi}`,
119+
]
120+
.filter((item): item is string => item !== undefined)
121+
.join("; ");
122+
throw new BailianError(`${responseContext} - ${serviceMessage}`, ExitCode.GENERAL, undefined, {
123+
api: {
124+
httpStatus: res.status,
125+
apiCode,
126+
requestId,
127+
},
128+
rawResponse: text.slice(0, 500),
129+
});
59130
}
60131

61132
const body = (await res.json()) as UploadPolicyResponse;
@@ -108,7 +179,7 @@ async function uploadToOSS(
108179

109180
export interface UploadOptions {
110181
apiKey: string;
111-
/** Defaults to CN for direct SDK callers; Client supplies its resolved credential origin. */
182+
/** Model origin used only to resolve the supported regional upload service. Defaults to CN. */
112183
baseUrl?: string;
113184
model: string;
114185
filePath: string;

0 commit comments

Comments
 (0)