fix(design): bound requests and prevent mutation replay

This commit is contained in:
2026-08-19 12:51:46 +08:00
parent 1ba68a9e41
commit 87e4140f8a
7 changed files with 447 additions and 55 deletions

View File

@@ -27,7 +27,12 @@ import type {
import WebSocket from 'ws';
import { designAssetContentPath } from '../../shared/image-workspace';
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
import { proxyAwareFetch } from '../utils/proxy-fetch';
import {
fetchWithDeadline,
proxyAwareFetch,
RequestDeadlineExceededError,
runWithDeadline,
} from '../utils/proxy-fetch';
import { getValidWorksSquareAccessToken } from '../services/works-square-session';
import {
DesignWorkspaceModuleError,
@@ -169,6 +174,7 @@ type WorksSquareDesignWorkspaceOptions = {
apiBaseUrl?: string;
fetchImpl?: typeof fetch;
webSocketFactory?: AgentWebSocketFactory;
requestTimeoutMs?: number;
};
type ServerAgentStreamTicket = {
@@ -269,6 +275,7 @@ type AgentCommandChannel = {
};
const AGENT_WEBSOCKET_OPEN = 1;
const DESIGN_WORKSPACE_REQUEST_TIMEOUT_MS = 30_000;
const AGENT_WEBSOCKET_OPEN_TIMEOUT_MS = 10_000;
const AGENT_WEBSOCKET_PING_INTERVAL_MS = 20_000;
const AGENT_COMMAND_ACK_TIMEOUT_MS = 5_000;
@@ -850,10 +857,19 @@ function isStaleAgentSessionError(error: unknown): error is DesignWorkspaceModul
|| error.code === 'agent_session_closed');
}
function designRequestTimeoutError(): DesignWorkspaceModuleError {
return new DesignWorkspaceModuleError(
504,
'DESIGN_WORKSPACE_REQUEST_TIMEOUT',
'AI 设计服务响应超时,请重试',
);
}
export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
private readonly apiBaseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly webSocketFactory: AgentWebSocketFactory;
private readonly requestTimeoutMs: number;
private readonly conversationSessionIds = new Map<string, string>();
private readonly eventSubscriptionClosers = new Map<string, Set<() => void>>();
private readonly activeRunEventStreams = new Map<string, number>();
@@ -868,6 +884,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? proxyAwareFetch;
this.webSocketFactory = options.webSocketFactory ?? defaultAgentWebSocketFactory;
this.requestTimeoutMs = options.requestTimeoutMs ?? DESIGN_WORKSPACE_REQUEST_TIMEOUT_MS;
}
async bootstrap(): Promise<DesignWorkspaceBootstrap> {
@@ -1505,59 +1522,89 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
}
private async requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await this.authorizedFetch(path, {
...init,
headers: {
Accept: 'application/json',
...(init.body && !(init.body instanceof FormData)
? { 'Content-Type': 'application/json' }
: {}),
...(init.headers ?? {}),
},
});
const payload = await readPayload(response);
if (!response.ok) {
const detail = asErrorDetail(payload);
const code = typeof detail.code === 'string'
? detail.code
: 'DESIGN_WORKSPACE_REQUEST_FAILED';
throw new DesignWorkspaceModuleError(
response.status,
code,
userFacingErrorMessage(code),
);
try {
return await runWithDeadline(async (signal) => {
const response = await this.authorizedFetch(path, {
...init,
signal,
headers: {
Accept: 'application/json',
...(init.body && !(init.body instanceof FormData)
? { 'Content-Type': 'application/json' }
: {}),
...(init.headers ?? {}),
},
});
const payload = await readPayload(response);
if (!response.ok) {
const detail = asErrorDetail(payload);
const code = typeof detail.code === 'string'
? detail.code
: 'DESIGN_WORKSPACE_REQUEST_FAILED';
throw new DesignWorkspaceModuleError(
response.status,
code,
userFacingErrorMessage(code),
);
}
return payload as T;
}, this.requestTimeoutMs, init.signal);
} catch (error) {
if (error instanceof RequestDeadlineExceededError) {
throw designRequestTimeoutError();
}
throw error;
}
return payload as T;
}
private async authorizedFetch(path: string, init: RequestInit = {}): Promise<Response> {
let token = await getValidWorksSquareAccessToken({ fetchImpl: this.fetchImpl });
if (!token) {
throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计');
}
try {
let token = await getValidWorksSquareAccessToken({
fetchImpl: this.fetchImpl,
requestTimeoutMs: this.requestTimeoutMs,
});
if (!token) {
throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计');
}
let response = await this.fetchWithToken(path, token, init);
if (response.status !== 401) return response;
let response = await this.fetchWithToken(path, token, init);
if (response.status !== 401) return response;
token = await getValidWorksSquareAccessToken({
fetchImpl: this.fetchImpl,
forceRefresh: true,
});
if (!token) {
throw new DesignWorkspaceModuleError(401, 'AUTH_EXPIRED', '登录状态已失效,请重新登录');
token = await getValidWorksSquareAccessToken({
fetchImpl: this.fetchImpl,
forceRefresh: true,
requestTimeoutMs: this.requestTimeoutMs,
});
if (!token) {
throw new DesignWorkspaceModuleError(401, 'AUTH_EXPIRED', '登录状态已失效,请重新登录');
}
response = await this.fetchWithToken(path, token, init);
return response;
} catch (error) {
if (error instanceof RequestDeadlineExceededError) {
throw designRequestTimeoutError();
}
throw error;
}
response = await this.fetchWithToken(path, token, init);
return response;
}
private fetchWithToken(path: string, token: string, init: RequestInit): Promise<Response> {
return this.fetchImpl(`${this.apiBaseUrl}${path}`, {
const requestInit: RequestInit = {
...init,
headers: {
...init.headers,
Authorization: `Bearer ${token}`,
},
});
};
if (init.signal) {
return this.fetchImpl(`${this.apiBaseUrl}${path}`, requestInit);
}
return fetchWithDeadline(
this.fetchImpl,
`${this.apiBaseUrl}${path}`,
requestInit,
this.requestTimeoutMs,
);
}
private conversationKey(workspaceId: string, conversationId: string): string {

View File

@@ -1,10 +1,11 @@
import { createHash } from 'node:crypto';
import { NIANCODE_AUTH_CONFIG } from '../api/auth-config';
import { proxyAwareFetch } from '../utils/proxy-fetch';
import { proxyAwareFetch, runWithDeadline } from '../utils/proxy-fetch';
import { logger } from '../utils/logger';
import { WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS } from '../../shared/auth-session';
const TOKEN_REFRESH_SKEW_MS = 30_000;
const WORKS_SQUARE_AUTH_REQUEST_TIMEOUT_MS = 30_000;
const SESSION_STORE_SCHEMA_VERSION = 1;
export { WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS } from '../../shared/auth-session';
@@ -716,7 +717,7 @@ async function waitForCredentialPersistence(): Promise<boolean> {
async function refreshWorksSquareSession(
session: StoredWorksSquareSession,
generation: number,
options: { fetchImpl?: typeof fetch; nowMs?: number } = {},
options: { fetchImpl?: typeof fetch; nowMs?: number; requestTimeoutMs?: number } = {},
): Promise<string | null> {
if (!session.refreshToken) return null;
@@ -726,17 +727,25 @@ async function refreshWorksSquareSession(
grant_type: 'refresh_token',
refresh_token: session.refreshToken,
});
const response = await fetchImpl(`${NIANCODE_AUTH_CONFIG.gatewayAuthUrl.replace(/\/+$/, '')}/oauth2/token`, {
method: 'POST',
headers: {
Authorization: createBasicAuthHeader(
NIANCODE_AUTH_CONFIG.clientId,
NIANCODE_AUTH_CONFIG.clientSecret,
),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
});
const { response, payload } = await runWithDeadline(async (signal) => {
const response = await fetchImpl(
`${NIANCODE_AUTH_CONFIG.gatewayAuthUrl.replace(/\/+$/, '')}/oauth2/token`,
{
method: 'POST',
headers: {
Authorization: createBasicAuthHeader(
NIANCODE_AUTH_CONFIG.clientId,
NIANCODE_AUTH_CONFIG.clientSecret,
),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
signal,
},
);
const payload = response.ok ? await readResponsePayload(response) : null;
return { response, payload };
}, options.requestTimeoutMs ?? WORKS_SQUARE_AUTH_REQUEST_TIMEOUT_MS);
if (!response.ok) {
logger.warn('[works-square-session] Refresh failed', { status: response.status });
@@ -746,7 +755,6 @@ async function refreshWorksSquareSession(
return null;
}
const payload = await readResponsePayload(response);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
logger.warn('[works-square-session] Refresh returned an invalid payload');
return null;
@@ -780,6 +788,7 @@ export async function getValidWorksSquareAccessToken(
fetchImpl?: typeof fetch;
nowMs?: number;
forceRefresh?: boolean;
requestTimeoutMs?: number;
} = {},
): Promise<string | null> {
if (restoreStatus === 'unavailable') {

View File

@@ -6,6 +6,77 @@
import { net } from 'electron';
const SAFE_FALLBACK_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export class RequestDeadlineExceededError extends Error {
readonly timeoutMs: number;
constructor(timeoutMs: number) {
super(`Request did not complete within ${timeoutMs}ms`);
this.name = 'RequestDeadlineExceededError';
this.timeoutMs = timeoutMs;
}
}
function abortReason(signal: AbortSignal): unknown {
if (signal.reason !== undefined) return signal.reason;
const error = new Error('The operation was aborted');
error.name = 'AbortError';
return error;
}
export async function runWithDeadline<T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
sourceSignal?: AbortSignal | null,
): Promise<T> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new RangeError('Request timeout must be a positive finite number');
}
if (sourceSignal?.aborted) throw abortReason(sourceSignal);
const controller = new AbortController();
let rejectInterruption!: (reason: unknown) => void;
const interruption = new Promise<never>((_resolve, reject) => {
rejectInterruption = reject;
});
const abortFromSource = () => {
if (!sourceSignal) return;
const reason = abortReason(sourceSignal);
controller.abort(reason);
rejectInterruption(reason);
};
sourceSignal?.addEventListener('abort', abortFromSource, { once: true });
const timeout = setTimeout(() => {
const error = new RequestDeadlineExceededError(timeoutMs);
controller.abort(error);
rejectInterruption(error);
}, timeoutMs);
timeout.unref?.();
try {
return await Promise.race([operation(controller.signal), interruption]);
} finally {
clearTimeout(timeout);
sourceSignal?.removeEventListener('abort', abortFromSource);
}
}
export function fetchWithDeadline(
fetchImpl: typeof fetch,
input: Parameters<typeof fetch>[0],
init: RequestInit | undefined,
timeoutMs: number,
): Promise<Response> {
return runWithDeadline(
(signal) => fetchImpl(input, { ...init, signal }),
timeoutMs,
init?.signal,
);
}
export async function proxyAwareFetch(
input: string | URL,
init?: RequestInit
@@ -13,8 +84,12 @@ export async function proxyAwareFetch(
if (process.versions.electron) {
try {
return await net.fetch(input, init);
} catch {
// Fall through to the global fetch.
} catch (error) {
const method = (init?.method ?? 'GET').toUpperCase();
if (!SAFE_FALLBACK_METHODS.has(method) || init?.signal?.aborted) {
throw error;
}
// Safe reads retain the Node fallback for proxy compatibility.
}
}