Merge origin/main and preserve nonblocking project entry
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-09-07 12:53:55 +08:00
66 changed files with 4068 additions and 519 deletions

View File

@@ -7,6 +7,7 @@ import { hasRendererCapability } from '../renderer-capability';
import { parseJsonBody, sendJson } from '../route-utils';
type AgentBrowserBody = {
project_id?: unknown;
project_path?: unknown;
url?: unknown;
action?: unknown;
@@ -71,7 +72,12 @@ function parseStringArray(value: unknown): string[] | undefined {
return value.map((item) => item.trim()).filter(Boolean);
}
async function resolveActiveProject(ctx: HostApiContext, requestedPath?: unknown) {
async function resolveActiveProject(
ctx: HostApiContext,
requestedPath?: unknown,
requestedId?: unknown,
rendererPresentation = false,
) {
const activeProject = await ctx.codingProjectStore.getActiveProject();
if (!activeProject) {
throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '请先打开一个项目。', 409);
@@ -84,6 +90,24 @@ async function resolveActiveProject(ctx: HostApiContext, requestedPath?: unknown
throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '当前项目目录不可用。', 409);
}
const projectId = nonEmptyString(requestedId);
if (projectId) {
if (!rendererPresentation) {
throw new AgentBrowserRouteError(
'TARGET_DENIED',
'项目 ID 只能由 Makelore 界面用于浏览器操作。',
403,
);
}
if (projectId !== activeProject.id) {
throw new AgentBrowserRouteError('PROJECT_MISMATCH', '智能体只能调试当前项目。', 403);
}
return {
...activeProject,
path: activeRealPath,
};
}
const requested = nonEmptyString(requestedPath);
if (!requested) {
throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少当前项目路径。');
@@ -188,6 +212,19 @@ async function readBody(req: IncomingMessage): Promise<AgentBrowserBody> {
return await parseJsonBody<AgentBrowserBody>(req);
}
function resolveRequestProject(
req: IncomingMessage,
ctx: HostApiContext,
body: AgentBrowserBody,
) {
return resolveActiveProject(
ctx,
body.project_path,
body.project_id,
hasRendererCapability(req),
);
}
export async function handleAgentBrowserRoutes(
req: IncomingMessage,
res: ServerResponse,
@@ -200,7 +237,12 @@ export async function handleAgentBrowserRoutes(
const service = requireService(ctx);
if (url.pathname === '/api/agent-browser/state' && req.method === 'GET') {
const project = await resolveActiveProject(ctx, url.searchParams.get('project_path'));
const project = await resolveActiveProject(
ctx,
url.searchParams.get('project_path'),
url.searchParams.get('project_id'),
hasRendererCapability(req),
);
const browser = await service.getSnapshot(project.path);
await ensureProjectStillActive(ctx, project);
sendJson(res, 200, { success: true, browser });
@@ -209,8 +251,13 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/open' && req.method === 'POST') {
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const rendererPresentation = hasRendererCapability(req);
const project = await resolveActiveProject(
ctx,
body.project_path,
body.project_id,
rendererPresentation,
);
if (body.bounds !== undefined && !rendererPresentation) {
requireRendererPresentation(req);
}
@@ -233,7 +280,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/present' && req.method === 'POST') {
requireRendererPresentation(req);
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const project = await resolveActiveProject(ctx, body.project_path, body.project_id, true);
const browser = await service.present({
projectPath: project.path,
visible: body.visible === true,
@@ -248,7 +295,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/diagnostics' && req.method === 'POST') {
requireRendererPresentation(req);
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const project = await resolveActiveProject(ctx, body.project_path, body.project_id, true);
const browser = await service.setDiagnostics({
projectPath: project.path,
enabled: body.enabled === true,
@@ -260,7 +307,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/navigate' && req.method === 'POST') {
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const project = await resolveRequestProject(req, ctx, body);
const action = nonEmptyString(body.action);
if (action !== 'url' && action !== 'back' && action !== 'forward' && action !== 'reload') {
throw new AgentBrowserRouteError('INVALID_REQUEST', '浏览器导航动作无效。');
@@ -278,7 +325,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/cdp/send' && req.method === 'POST') {
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const project = await resolveRequestProject(req, ctx, body);
const method = nonEmptyString(body.method);
if (!method) throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少 CDP method。');
const params = body.params === undefined
@@ -302,7 +349,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/cdp/events' && req.method === 'POST') {
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const project = await resolveRequestProject(req, ctx, body);
const page = await service.readEvents({
projectPath: project.path,
after: finiteInteger(body.after),
@@ -317,7 +364,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/payload/read' && req.method === 'POST') {
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const project = await resolveRequestProject(req, ctx, body);
const handle = nonEmptyString(body.handle);
if (!handle) throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少 payload handle。');
const chunk = await service.readPayload({
@@ -333,7 +380,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/close' && req.method === 'POST') {
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const project = await resolveRequestProject(req, ctx, body);
const browser = await service.close(project.path);
await ensureProjectStillActive(ctx, project);
emitState(ctx, 'agent-browser:state', {
@@ -347,7 +394,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/reset-profile' && req.method === 'POST') {
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const project = await resolveRequestProject(req, ctx, body);
const browser = await service.resetProfile(project.path);
await ensureProjectStillActive(ctx, project);
emitState(ctx, 'agent-browser:state', {

View File

@@ -60,6 +60,27 @@ const AGENT_AVATAR_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp'
const MAX_PROJECT_COVER_BYTES = 10 * 1024 * 1024;
const PROJECT_COVER_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
const SAFE_PROJECT_STATUSES = new Set(['draft', 'published']);
const TOKEN_POINT_FIELDS = [
'weekly_allowance',
'weekly_used',
'weekly_reserved',
'weekly_remaining',
'permanent_total',
'permanent_used',
'permanent_reserved',
'permanent_remaining',
'total_remaining',
] as const;
const TOKEN_POINT_METADATA_FIELDS = [
'plan_code',
'plan_name',
'cycle_start',
'cycle_end',
'next_refresh_at',
] as const;
const TOKEN_POINT_ENTITLEMENT_SOURCES = new Set(['self', 'family_owner', 'shared_group']);
const TOKEN_POINT_UPGRADE_ACTIONS = new Set(['self_service', 'contact_family_owner']);
const TOKEN_POINT_VALUE_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/u;
function readRequiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) {
@@ -267,6 +288,62 @@ function readNullableStringField(
return typeof value === 'string' ? value : undefined;
}
function readNullablePointField(
source: Record<string, unknown>,
field: string,
): string | null | undefined {
const value = source[field];
if (value === undefined || value === null) return null;
if (typeof value !== 'string') return undefined;
const normalized = value.trim();
return TOKEN_POINT_VALUE_PATTERN.test(normalized) ? normalized : undefined;
}
function projectSafeTokenPointBalance(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const entitlementSource = readOptionalString(value.entitlement_source);
const upgradeAction = readOptionalString(value.upgrade_action);
if (
!entitlementSource
|| !TOKEN_POINT_ENTITLEMENT_SOURCES.has(entitlementSource)
|| typeof value.family_shared !== 'boolean'
|| typeof value.can_manage_membership !== 'boolean'
|| !upgradeAction
|| !TOKEN_POINT_UPGRADE_ACTIONS.has(upgradeAction)
) return null;
if (
value.shared_available !== undefined
&& value.shared_available !== null
&& typeof value.shared_available !== 'boolean'
) return null;
const canManageMembership = value.can_manage_membership;
if (!canManageMembership && typeof value.shared_available !== 'boolean') return null;
const projected: Record<string, unknown> = {};
for (const field of TOKEN_POINT_METADATA_FIELDS) {
const fieldValue = canManageMembership ? readNullableStringField(value, field) : null;
if (fieldValue === undefined) return null;
projected[field] = fieldValue;
}
for (const field of TOKEN_POINT_FIELDS) {
const fieldValue = canManageMembership ? readNullablePointField(value, field) : null;
if (fieldValue === undefined) return null;
projected[field] = fieldValue;
}
return {
...projected,
entitlement_source: entitlementSource,
family_shared: value.family_shared,
can_manage_membership: canManageMembership,
upgrade_action: upgradeAction,
shared_available: canManageMembership ? null : value.shared_available,
};
}
function projectSafeProject(
value: unknown,
options: { requireStatus?: boolean } = {},
@@ -614,12 +691,12 @@ async function handleListMyProjects(
sendJson(res, response.status, { success: true, page });
}
async function handleGetBillingTokenUsage(
async function handleGetBillingTokenPoints(
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
const response = await proxyAwareFetch(createWorksUrl('/api/billing/token-usage').toString(), {
const response = await proxyAwareFetch(createWorksUrl('/api/billing/points').toString(), {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
@@ -627,11 +704,16 @@ async function handleGetBillingTokenUsage(
});
if (!response.ok) {
await sendUpstreamError(res, response, `Works Square token usage failed (${response.status})`);
await sendUpstreamError(res, response, `Works Square token points failed (${response.status})`);
return;
}
sendJson(res, response.status, { success: true, usage: await readResponsePayload(response) });
const points = projectSafeTokenPointBalance(await readResponsePayload(response));
if (!points) {
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid token point balance' });
return;
}
sendJson(res, response.status, { success: true, points });
}
async function handleAgentProfile(
@@ -1413,8 +1495,8 @@ export async function handleWorksRoutes(
return true;
}
if (url.pathname === '/api/works/billing/token-usage' && req.method === 'GET') {
await handleGetBillingTokenUsage(req, res);
if (url.pathname === '/api/works/billing/points' && req.method === 'GET') {
await handleGetBillingTokenPoints(req, res);
return true;
}