u1s1-cli 1.2.9 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +8 -0
- package/dist/api.js +130 -22
- package/dist/bench.js +23 -6
- package/dist/deploy.d.ts +6 -0
- package/dist/deploy.js +41 -9
- package/dist/device-auth.d.ts +5 -1
- package/dist/device-auth.js +79 -28
- package/dist/index.js +8 -4
- package/dist/login.js +49 -15
- package/dist/model.d.ts +6 -0
- package/dist/model.js +24 -5
- package/dist/search-tools.js +15 -2
- package/dist/subagent.d.ts +14 -0
- package/dist/subagent.js +47 -11
- package/dist/tools.d.ts +4 -0
- package/dist/tools.js +84 -16
- package/dist/update.js +19 -7
- package/dist/web.js +4 -2
- package/dist/workflow/runner.d.ts +17 -0
- package/dist/workflow/runner.js +6 -7
- package/dist/workflow/templates.js +4 -4
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { type ApiThinkingCapabilities, type CliConfig } from "./config.js";
|
|
2
|
+
/** Preserve caller cancellation while always enforcing the operation deadline. */
|
|
3
|
+
export declare function signalWithTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal;
|
|
4
|
+
/** Read a complete response without allowing a custom or faulty server to exhaust CLI memory. */
|
|
5
|
+
export declare function readResponseTextCapped(resp: Response, maxBytes?: number): Promise<string>;
|
|
6
|
+
/** Parse JSON only after the complete response has passed the shared byte cap. */
|
|
7
|
+
export declare function readJsonResponseCapped<T = unknown>(resp: Response, maxBytes?: number): Promise<T>;
|
|
2
8
|
export interface MeResponse {
|
|
3
9
|
email: string | null;
|
|
4
10
|
signup_credit_usd?: number;
|
|
@@ -64,6 +70,8 @@ export interface ModelsResponse {
|
|
|
64
70
|
models: ApiModel[];
|
|
65
71
|
features: ApiFeatures;
|
|
66
72
|
announcement?: ApiAnnouncement | null;
|
|
73
|
+
/** Opaque, device-bound canary echoed only by the official signing proxy. */
|
|
74
|
+
clientAttestation?: string;
|
|
67
75
|
}
|
|
68
76
|
/** 401:凭证失效(设备被移除/换过钥匙),调用方应引导重新登录而不是继续硬跑。 */
|
|
69
77
|
export declare class AuthError extends Error {
|
package/dist/api.js
CHANGED
|
@@ -1,12 +1,67 @@
|
|
|
1
1
|
import { apiEndpointToCustom, loadEndpointsCache, saveEndpointsCache, setCustomEndpoints, VERSION, } from "./config.js";
|
|
2
2
|
import { authorizedFetch } from "./device-auth.js";
|
|
3
|
+
const MAX_API_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
const MAX_API_ERROR_BYTES = 64 * 1024;
|
|
5
|
+
const MAX_IMAGE_RESULT_BYTES = 64 * 1024;
|
|
6
|
+
function jsonRecord(value) {
|
|
7
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
8
|
+
? value
|
|
9
|
+
: null;
|
|
10
|
+
}
|
|
11
|
+
/** Preserve caller cancellation while always enforcing the operation deadline. */
|
|
12
|
+
export function signalWithTimeout(signal, timeoutMs) {
|
|
13
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
14
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
15
|
+
}
|
|
16
|
+
/** Read a complete response without allowing a custom or faulty server to exhaust CLI memory. */
|
|
17
|
+
export async function readResponseTextCapped(resp, maxBytes = MAX_API_RESPONSE_BYTES) {
|
|
18
|
+
const limit = Math.max(1, Math.floor(maxBytes));
|
|
19
|
+
const declaredLength = Number(resp.headers.get("content-length"));
|
|
20
|
+
if (Number.isFinite(declaredLength) && declaredLength > limit) {
|
|
21
|
+
void resp.body?.cancel("API response body limit reached").catch(() => { });
|
|
22
|
+
throw new Error(`API response body exceeds ${limit} bytes`);
|
|
23
|
+
}
|
|
24
|
+
if (!resp.body)
|
|
25
|
+
throw new Error("API response body is missing");
|
|
26
|
+
const reader = resp.body.getReader();
|
|
27
|
+
const decoder = new TextDecoder();
|
|
28
|
+
let bytes = 0;
|
|
29
|
+
let text = "";
|
|
30
|
+
try {
|
|
31
|
+
for (;;) {
|
|
32
|
+
const { done, value } = await reader.read();
|
|
33
|
+
if (done) {
|
|
34
|
+
text += decoder.decode();
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
if (bytes + value.byteLength > limit) {
|
|
38
|
+
void reader.cancel("API response body limit reached").catch(() => { });
|
|
39
|
+
throw new Error(`API response body exceeds ${limit} bytes`);
|
|
40
|
+
}
|
|
41
|
+
bytes += value.byteLength;
|
|
42
|
+
text += decoder.decode(value, { stream: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
try {
|
|
47
|
+
reader.releaseLock();
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
}
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
/** Parse JSON only after the complete response has passed the shared byte cap. */
|
|
54
|
+
export async function readJsonResponseCapped(resp, maxBytes = MAX_API_RESPONSE_BYTES) {
|
|
55
|
+
return JSON.parse(await readResponseTextCapped(resp, maxBytes));
|
|
56
|
+
}
|
|
3
57
|
/** 401:凭证失效(设备被移除/换过钥匙),调用方应引导重新登录而不是继续硬跑。 */
|
|
4
58
|
export class AuthError extends Error {
|
|
5
59
|
}
|
|
6
60
|
/** 网关错误壳统一是 { error: { message } };解析不出来时回退到状态码提示。 */
|
|
7
61
|
async function errorMessageFromResponse(resp, fallback) {
|
|
8
|
-
const body = (await resp
|
|
9
|
-
|
|
62
|
+
const body = jsonRecord(await readJsonResponseCapped(resp, MAX_API_ERROR_BYTES).catch(() => null));
|
|
63
|
+
const error = jsonRecord(body?.error);
|
|
64
|
+
return typeof error?.message === "string" ? error.message.slice(0, 2_000) : fallback;
|
|
10
65
|
}
|
|
11
66
|
export async function fetchModels(cfg) {
|
|
12
67
|
let resp;
|
|
@@ -23,8 +78,16 @@ export async function fetchModels(cfg) {
|
|
|
23
78
|
throw new AuthError("登录已失效,请重新运行 u1s1 login");
|
|
24
79
|
if (!resp.ok)
|
|
25
80
|
throw new Error(await errorMessageFromResponse(resp, `服务端返回 ${resp.status},稍后再试`));
|
|
26
|
-
const body =
|
|
27
|
-
|
|
81
|
+
const body = await readJsonResponseCapped(resp);
|
|
82
|
+
if (!body || !Array.isArray(body.data))
|
|
83
|
+
throw new Error("服务端模型列表格式不正确");
|
|
84
|
+
const token = body.client_attestation?.token;
|
|
85
|
+
return {
|
|
86
|
+
models: body.data,
|
|
87
|
+
features: body.features ?? {},
|
|
88
|
+
announcement: body.announcement,
|
|
89
|
+
clientAttestation: typeof token === "string" && token.length <= 1024 ? token : undefined,
|
|
90
|
+
};
|
|
28
91
|
}
|
|
29
92
|
/**
|
|
30
93
|
* 拉取用户在云端配置的自定义模型端点(dashboard「自定义模型端点」卡片)。
|
|
@@ -43,8 +106,8 @@ export async function fetchUserEndpoints(cfg) {
|
|
|
43
106
|
}
|
|
44
107
|
if (!resp.ok)
|
|
45
108
|
throw new Error(await errorMessageFromResponse(resp, `服务端返回 ${resp.status}`));
|
|
46
|
-
const body = (await resp
|
|
47
|
-
return Array.isArray(body
|
|
109
|
+
const body = jsonRecord(await readJsonResponseCapped(resp));
|
|
110
|
+
return Array.isArray(body?.endpoints) ? body.endpoints : [];
|
|
48
111
|
}
|
|
49
112
|
/**
|
|
50
113
|
* 拉取云端配置的自定义端点并装载进 CUSTOM_ENDPOINTS;失败(离线/老网关)回退
|
|
@@ -73,17 +136,37 @@ export async function searchWeb(cfg, input) {
|
|
|
73
136
|
"content-type": "application/json",
|
|
74
137
|
},
|
|
75
138
|
body: JSON.stringify({ query: input.query, max_results: input.maxResults }),
|
|
76
|
-
signal: input.signal
|
|
139
|
+
signal: signalWithTimeout(input.signal, 60_000),
|
|
77
140
|
});
|
|
78
141
|
}
|
|
79
|
-
catch {
|
|
80
|
-
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (input.signal?.aborted)
|
|
144
|
+
throw error;
|
|
145
|
+
throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`, { cause: error });
|
|
81
146
|
}
|
|
82
147
|
if (resp.status === 401)
|
|
83
148
|
throw new Error("登录已失效,请重新运行 u1s1 login");
|
|
84
149
|
if (!resp.ok)
|
|
85
150
|
throw new Error(await errorMessageFromResponse(resp, `搜索服务返回 ${resp.status},稍后再试`));
|
|
86
|
-
|
|
151
|
+
const body = jsonRecord(await readJsonResponseCapped(resp));
|
|
152
|
+
if (!body || !Array.isArray(body.results))
|
|
153
|
+
throw new Error("搜索服务返回格式不正确");
|
|
154
|
+
const results = [];
|
|
155
|
+
for (const value of body.results.slice(0, 10)) {
|
|
156
|
+
const result = jsonRecord(value);
|
|
157
|
+
if (!result
|
|
158
|
+
|| typeof result.title !== "string"
|
|
159
|
+
|| typeof result.url !== "string"
|
|
160
|
+
|| typeof result.snippet !== "string") {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
results.push({ title: result.title, url: result.url, snippet: result.snippet });
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
query: typeof body.query === "string" ? body.query : input.query,
|
|
167
|
+
answer: typeof body.answer === "string" ? body.answer : null,
|
|
168
|
+
results,
|
|
169
|
+
};
|
|
87
170
|
}
|
|
88
171
|
/** web_fetch 直连失败时的回退:网关用 Cloudflare Browser Rendering 渲染后转 markdown。 */
|
|
89
172
|
export async function renderPage(cfg, url, signal) {
|
|
@@ -98,31 +181,40 @@ export async function renderPage(cfg, url, signal) {
|
|
|
98
181
|
"content-type": "application/json",
|
|
99
182
|
},
|
|
100
183
|
body: JSON.stringify({ url }),
|
|
101
|
-
signal: signal
|
|
184
|
+
signal: signalWithTimeout(signal, 90_000),
|
|
102
185
|
});
|
|
103
186
|
}
|
|
104
|
-
catch {
|
|
105
|
-
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (signal?.aborted)
|
|
189
|
+
throw error;
|
|
190
|
+
throw new Error(`连不上 ${cfg.baseUrl}`, { cause: error });
|
|
106
191
|
}
|
|
107
192
|
if (resp.status === 401)
|
|
108
193
|
throw new Error("登录已失效,请重新运行 u1s1 login");
|
|
109
194
|
if (!resp.ok)
|
|
110
195
|
throw new Error(await errorMessageFromResponse(resp, `渲染服务返回 ${resp.status}`));
|
|
111
|
-
|
|
196
|
+
const body = jsonRecord(await readJsonResponseCapped(resp));
|
|
197
|
+
if (!body || typeof body.url !== "string" || typeof body.markdown !== "string") {
|
|
198
|
+
throw new Error("渲染服务返回格式不正确");
|
|
199
|
+
}
|
|
200
|
+
return { url: body.url, markdown: body.markdown };
|
|
112
201
|
}
|
|
113
202
|
const IMAGE_URL_HEADER = "x-u1s1-image-url";
|
|
114
203
|
const IMAGE_SIZE_HEADER = "x-u1s1-image-size";
|
|
115
204
|
function imageResultFromHeaders(resp) {
|
|
116
205
|
const encodedUrl = resp.headers.get(IMAGE_URL_HEADER);
|
|
117
|
-
if (!encodedUrl)
|
|
206
|
+
if (!encodedUrl || encodedUrl.length > 8_000)
|
|
118
207
|
return null;
|
|
119
208
|
try {
|
|
120
209
|
const url = decodeURIComponent(encodedUrl);
|
|
210
|
+
if (/[\u0000-\u001f\u007f]/.test(url))
|
|
211
|
+
return null;
|
|
121
212
|
const parsed = new URL(url);
|
|
122
213
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
123
214
|
return null;
|
|
124
215
|
const encodedSize = resp.headers.get(IMAGE_SIZE_HEADER);
|
|
125
|
-
|
|
216
|
+
const size = encodedSize ? decodeURIComponent(encodedSize) : null;
|
|
217
|
+
return { url, size: size && size.length <= 100 ? size : null };
|
|
126
218
|
}
|
|
127
219
|
catch {
|
|
128
220
|
return null;
|
|
@@ -141,15 +233,28 @@ function responseErrorDetail(error) {
|
|
|
141
233
|
export async function readGeneratedImageResponse(resp) {
|
|
142
234
|
const headerResult = imageResultFromHeaders(resp);
|
|
143
235
|
if (headerResult) {
|
|
144
|
-
|
|
236
|
+
void resp.body?.cancel("image result recovered from response headers").catch(() => undefined);
|
|
145
237
|
return headerResult;
|
|
146
238
|
}
|
|
147
239
|
try {
|
|
148
|
-
const body = (await resp
|
|
149
|
-
if (typeof body
|
|
240
|
+
const body = jsonRecord(await readJsonResponseCapped(resp, MAX_IMAGE_RESULT_BYTES));
|
|
241
|
+
if (typeof body?.url !== "string") {
|
|
242
|
+
throw new Error("服务端没有返回图片下载地址");
|
|
243
|
+
}
|
|
244
|
+
let parsed;
|
|
245
|
+
try {
|
|
246
|
+
parsed = new URL(body.url);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
throw new Error("服务端没有返回图片下载地址");
|
|
250
|
+
}
|
|
251
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
150
252
|
throw new Error("服务端没有返回图片下载地址");
|
|
151
253
|
}
|
|
152
|
-
return {
|
|
254
|
+
return {
|
|
255
|
+
url: body.url,
|
|
256
|
+
size: typeof body.size === "string" && body.size.length <= 100 ? body.size : null,
|
|
257
|
+
};
|
|
153
258
|
}
|
|
154
259
|
catch (error) {
|
|
155
260
|
throw new Error(`图片可能已经生成,但客户端下载结果响应失败: ${responseErrorDetail(error)}。` +
|
|
@@ -169,7 +274,7 @@ export async function generateImage(cfg, req, signal) {
|
|
|
169
274
|
"content-type": "application/json",
|
|
170
275
|
},
|
|
171
276
|
body: JSON.stringify(req),
|
|
172
|
-
signal: signal
|
|
277
|
+
signal: signalWithTimeout(signal, 180_000),
|
|
173
278
|
});
|
|
174
279
|
}
|
|
175
280
|
catch (error) {
|
|
@@ -200,5 +305,8 @@ export async function fetchMe(cfg) {
|
|
|
200
305
|
throw new Error("登录已失效,请重新运行 u1s1 login");
|
|
201
306
|
if (!resp.ok)
|
|
202
307
|
throw new Error(await errorMessageFromResponse(resp, `服务端返回 ${resp.status},稍后再试`));
|
|
203
|
-
|
|
308
|
+
const body = await readJsonResponseCapped(resp);
|
|
309
|
+
if (!body || typeof body !== "object")
|
|
310
|
+
throw new Error("服务端账户响应格式不正确");
|
|
311
|
+
return body;
|
|
204
312
|
}
|
package/dist/bench.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join, dirname } from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { CUSTOM_ENDPOINTS, loadConfig, MODELS, PROVIDER_ID, } from "./config.js";
|
|
6
|
-
import { loadCustomEndpoints } from "./api.js";
|
|
6
|
+
import { loadCustomEndpoints, readJsonResponseCapped, readResponseTextCapped } from "./api.js";
|
|
7
7
|
import { authorizedFetch, hasDeviceCredential } from "./device-auth.js";
|
|
8
8
|
import { costStr, errorRate, formatDuration, generateReport, renderTable, scoreBar, scoreRate, sortByScore, summarizeByModel, tokPerSec, } from "./bench-report.js";
|
|
9
9
|
import { scoreResponse } from "./bench-scoring.js";
|
|
@@ -108,15 +108,32 @@ async function callModel(input) {
|
|
|
108
108
|
: await fetch(`${baseUrl}/chat/completions`, request);
|
|
109
109
|
const latencyMs = Math.round(performance.now() - start);
|
|
110
110
|
if (!res.ok) {
|
|
111
|
-
const errBody = await res
|
|
111
|
+
const errBody = await readResponseTextCapped(res, 64 * 1024).catch(() => "未知错误");
|
|
112
112
|
return { response: "", latencyMs, tokensIn: 0, tokensOut: 0, error: `HTTP ${res.status}: ${errBody.slice(0, 200)}` };
|
|
113
113
|
}
|
|
114
|
-
const
|
|
114
|
+
const value = await readJsonResponseCapped(res);
|
|
115
|
+
const data = value && typeof value === "object" && !Array.isArray(value)
|
|
116
|
+
? value
|
|
117
|
+
: null;
|
|
118
|
+
const choices = Array.isArray(data?.choices) ? data.choices : [];
|
|
119
|
+
const firstChoice = choices[0] && typeof choices[0] === "object" && !Array.isArray(choices[0])
|
|
120
|
+
? choices[0]
|
|
121
|
+
: null;
|
|
122
|
+
const message = firstChoice?.message && typeof firstChoice.message === "object" && !Array.isArray(firstChoice.message)
|
|
123
|
+
? firstChoice.message
|
|
124
|
+
: null;
|
|
125
|
+
const usage = data?.usage && typeof data.usage === "object" && !Array.isArray(data.usage)
|
|
126
|
+
? data.usage
|
|
127
|
+
: null;
|
|
115
128
|
return {
|
|
116
|
-
response:
|
|
129
|
+
response: typeof message?.content === "string" ? message.content : "",
|
|
117
130
|
latencyMs,
|
|
118
|
-
tokensIn:
|
|
119
|
-
|
|
131
|
+
tokensIn: typeof usage?.prompt_tokens === "number" && Number.isFinite(usage.prompt_tokens)
|
|
132
|
+
? Math.max(0, usage.prompt_tokens)
|
|
133
|
+
: 0,
|
|
134
|
+
tokensOut: typeof usage?.completion_tokens === "number" && Number.isFinite(usage.completion_tokens)
|
|
135
|
+
? Math.max(0, usage.completion_tokens)
|
|
136
|
+
: 0,
|
|
120
137
|
};
|
|
121
138
|
}
|
|
122
139
|
catch (e) {
|
package/dist/deploy.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { type CliConfig } from "./config.js";
|
|
2
|
+
interface SiteFile {
|
|
3
|
+
path: string;
|
|
4
|
+
abs: string;
|
|
5
|
+
bytes: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function collectFiles(root: string): SiteFile[];
|
|
2
8
|
type SiteVisibility = "public" | "private";
|
|
3
9
|
interface DeployArgs {
|
|
4
10
|
name?: string;
|
package/dist/deploy.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { VERSION, u1s1Dir } from "./config.js";
|
|
5
5
|
import { authorizedFetch } from "./device-auth.js";
|
|
6
|
+
import { readJsonResponseCapped } from "./api.js";
|
|
6
7
|
/**
|
|
7
8
|
* u1s1 deploy publishes a static site to <project>.<account-code>.u1s1.app.
|
|
8
9
|
* Detect the site root, ask for a project slug, and remember it in ~/.u1s1/deploys.json.
|
|
@@ -12,6 +13,8 @@ const deploysFile = join(u1s1Dir, "deploys.json");
|
|
|
12
13
|
/** 构建产物目录优先:Vite/Next 等项目根的 index.html 是源码,不是能直接上线的产物。 */
|
|
13
14
|
const BUILD_DIRS = ["dist", "build", "out", "_site", "public"];
|
|
14
15
|
const SKIP_DIRS = new Set(["node_modules", "__pycache__"]);
|
|
16
|
+
const MAX_DEPLOY_API_RESPONSE_BYTES = 256 * 1024;
|
|
17
|
+
const MAX_DEPLOY_ERROR_RESPONSE_BYTES = 64 * 1024;
|
|
15
18
|
function readDeploys() {
|
|
16
19
|
try {
|
|
17
20
|
return JSON.parse(readFileSync(deploysFile, "utf8"));
|
|
@@ -56,14 +59,18 @@ function resolveSiteDir(explicit) {
|
|
|
56
59
|
" 在网站目录里运行 u1s1 deploy,或指定目录:u1s1 deploy <目录>\n" +
|
|
57
60
|
" 如果项目需要构建(如 Vite/Next),先跑构建再部署 dist/ 等产物目录");
|
|
58
61
|
}
|
|
59
|
-
function collectFiles(root) {
|
|
62
|
+
export function collectFiles(root) {
|
|
60
63
|
const files = [];
|
|
61
64
|
const walk = (dir) => {
|
|
62
65
|
for (const name of readdirSync(dir)) {
|
|
63
66
|
if (name.startsWith(".") || SKIP_DIRS.has(name))
|
|
64
67
|
continue;
|
|
65
68
|
const abs = join(dir, name);
|
|
66
|
-
const st =
|
|
69
|
+
const st = lstatSync(abs);
|
|
70
|
+
// Never upload or recurse through links: a project-controlled link could
|
|
71
|
+
// escape the selected root, expose credentials, or create a directory cycle.
|
|
72
|
+
if (st.isSymbolicLink())
|
|
73
|
+
continue;
|
|
67
74
|
if (st.isDirectory())
|
|
68
75
|
walk(abs);
|
|
69
76
|
else if (st.isFile()) {
|
|
@@ -136,12 +143,25 @@ async function api(cfg, request) {
|
|
|
136
143
|
catch {
|
|
137
144
|
throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
|
|
138
145
|
}
|
|
139
|
-
const
|
|
146
|
+
const value = await readJsonResponseCapped(resp, MAX_DEPLOY_API_RESPONSE_BYTES).catch(() => null);
|
|
147
|
+
const data = value && typeof value === "object" && !Array.isArray(value)
|
|
148
|
+
? value
|
|
149
|
+
: null;
|
|
140
150
|
if (!resp.ok) {
|
|
141
|
-
const
|
|
142
|
-
|
|
151
|
+
const detail = data?.error && typeof data.error === "object"
|
|
152
|
+
? data.error
|
|
153
|
+
: undefined;
|
|
154
|
+
const message = typeof detail?.message === "string"
|
|
155
|
+
? detail.message.slice(0, 2_000)
|
|
156
|
+
: `服务端返回 ${resp.status},稍后再试`;
|
|
157
|
+
const e = new Error(message);
|
|
158
|
+
if (typeof detail?.code === "string" && detail.code.length <= 100) {
|
|
159
|
+
e.code = detail.code;
|
|
160
|
+
}
|
|
143
161
|
throw e;
|
|
144
162
|
}
|
|
163
|
+
if (!data)
|
|
164
|
+
throw new Error("服务端部署响应格式不正确");
|
|
145
165
|
return data;
|
|
146
166
|
}
|
|
147
167
|
/** 逐个上传,失败重试一次;并发数保守取 6。 */
|
|
@@ -157,12 +177,24 @@ async function uploadAll(cfg, start, files) {
|
|
|
157
177
|
signal: AbortSignal.timeout(120_000),
|
|
158
178
|
});
|
|
159
179
|
let resp = await put().catch(() => null);
|
|
160
|
-
if (!resp
|
|
180
|
+
if (resp && !resp.ok) {
|
|
181
|
+
void resp.body?.cancel("retrying rejected deploy upload").catch(() => { });
|
|
161
182
|
resp = await put().catch(() => null);
|
|
183
|
+
}
|
|
184
|
+
else if (!resp) {
|
|
185
|
+
resp = await put().catch(() => null);
|
|
186
|
+
}
|
|
162
187
|
if (!resp?.ok) {
|
|
163
|
-
const
|
|
164
|
-
|
|
188
|
+
const value = resp
|
|
189
|
+
? await readJsonResponseCapped(resp, MAX_DEPLOY_ERROR_RESPONSE_BYTES).catch(() => null)
|
|
190
|
+
: null;
|
|
191
|
+
const body = value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
192
|
+
const message = body?.error && typeof body.error === "object" && typeof body.error.message === "string"
|
|
193
|
+
? body.error.message.slice(0, 2_000)
|
|
194
|
+
: "网络错误";
|
|
195
|
+
throw new Error(`上传 ${f.path} 失败:${message}`);
|
|
165
196
|
}
|
|
197
|
+
void resp.body?.cancel("deploy upload completed").catch(() => { });
|
|
166
198
|
done++;
|
|
167
199
|
process.stdout.write(`\r 上传中 ${done}/${files.length} ${f.path.slice(0, 48).padEnd(48)}`);
|
|
168
200
|
};
|
package/dist/device-auth.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { webcrypto } from "node:crypto";
|
|
2
|
+
import { type IncomingMessage } from "node:http";
|
|
2
3
|
import { type CliConfig } from "./config.js";
|
|
3
4
|
export declare function hasDeviceCredential(cfg: CliConfig): boolean;
|
|
4
5
|
export declare function generateDeviceKeyPair(): Promise<{
|
|
@@ -8,16 +9,19 @@ export declare function generateDeviceKeyPair(): Promise<{
|
|
|
8
9
|
export declare function dpopHeaders(cfg: CliConfig, method: string, url: string): Promise<Record<string, string>>;
|
|
9
10
|
/** Fetch a gateway route with a fresh proof; generic keys remain a read-only compatibility fallback. */
|
|
10
11
|
export declare function authorizedFetch(cfg: CliConfig, input: string | URL, init?: RequestInit): Promise<Response>;
|
|
12
|
+
/** Buffer a loopback request only up to the same JSON limit enforced by Gateway. */
|
|
13
|
+
export declare function readSigningProxyRequestBody(request: IncomingMessage, maxBytes?: number): Promise<Buffer | undefined>;
|
|
11
14
|
interface SigningProxy {
|
|
12
15
|
baseUrl: string;
|
|
13
16
|
localKey: string;
|
|
14
17
|
token: string;
|
|
15
18
|
client: ClientSurface;
|
|
19
|
+
clientAttestation?: string;
|
|
16
20
|
}
|
|
17
21
|
export type ClientSurface = "terminal" | "web" | "desktop" | "cloud";
|
|
18
22
|
/**
|
|
19
23
|
* pi accepts static provider headers only. Keep it behind a loopback proxy that
|
|
20
24
|
* replaces the local bearer credential with a fresh DPoP proof per request.
|
|
21
25
|
*/
|
|
22
|
-
export declare function ensureSigningProxy(cfg: CliConfig, fallbackClient?: ClientSurface): Promise<SigningProxy>;
|
|
26
|
+
export declare function ensureSigningProxy(cfg: CliConfig, fallbackClient?: ClientSurface, clientAttestation?: string): Promise<SigningProxy>;
|
|
23
27
|
export {};
|
package/dist/device-auth.js
CHANGED
|
@@ -2,7 +2,14 @@ import { createHash, randomBytes, randomUUID, webcrypto } from "node:crypto";
|
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { VERSION } from "./config.js";
|
|
4
4
|
const enc = new TextEncoder();
|
|
5
|
+
const SIGNING_PROXY_REQUEST_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
5
6
|
let cachedPrivate;
|
|
7
|
+
class SigningProxyRequestTooLargeError extends Error {
|
|
8
|
+
constructor(maxBytes) {
|
|
9
|
+
super(`local signing proxy request exceeds ${maxBytes} bytes`);
|
|
10
|
+
this.name = "SigningProxyRequestTooLargeError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
6
13
|
function b64url(value) {
|
|
7
14
|
const bytes = typeof value === "string" ? enc.encode(value) : value;
|
|
8
15
|
return Buffer.from(bytes).toString("base64url");
|
|
@@ -86,6 +93,23 @@ function requestHeaders(input) {
|
|
|
86
93
|
}
|
|
87
94
|
return headers;
|
|
88
95
|
}
|
|
96
|
+
/** Buffer a loopback request only up to the same JSON limit enforced by Gateway. */
|
|
97
|
+
export async function readSigningProxyRequestBody(request, maxBytes = SIGNING_PROXY_REQUEST_LIMIT_BYTES) {
|
|
98
|
+
const declaredLength = Number(request.headers["content-length"]);
|
|
99
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
100
|
+
throw new SigningProxyRequestTooLargeError(maxBytes);
|
|
101
|
+
}
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let total = 0;
|
|
104
|
+
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
|
|
105
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
106
|
+
total += bytes.byteLength;
|
|
107
|
+
if (total > maxBytes)
|
|
108
|
+
throw new SigningProxyRequestTooLargeError(maxBytes);
|
|
109
|
+
chunks.push(bytes);
|
|
110
|
+
}
|
|
111
|
+
return chunks.length ? Buffer.concat(chunks, total) : undefined;
|
|
112
|
+
}
|
|
89
113
|
let signingProxy;
|
|
90
114
|
function clientSurface(fallback) {
|
|
91
115
|
const explicit = process.env["U1S1_CLIENT"];
|
|
@@ -98,12 +122,13 @@ function clientSurface(fallback) {
|
|
|
98
122
|
* pi accepts static provider headers only. Keep it behind a loopback proxy that
|
|
99
123
|
* replaces the local bearer credential with a fresh DPoP proof per request.
|
|
100
124
|
*/
|
|
101
|
-
export async function ensureSigningProxy(cfg, fallbackClient = "terminal") {
|
|
125
|
+
export async function ensureSigningProxy(cfg, fallbackClient = "terminal", clientAttestation) {
|
|
102
126
|
if (!hasDeviceCredential(cfg))
|
|
103
127
|
throw new Error("当前安装需要重新登录,以创建设备凭证");
|
|
104
128
|
const client = clientSurface(fallbackClient);
|
|
105
129
|
const current = signingProxy;
|
|
106
|
-
if (current && current.token === cfg.deviceToken && current.client === client
|
|
130
|
+
if (current && current.token === cfg.deviceToken && current.client === client
|
|
131
|
+
&& current.clientAttestation === clientAttestation)
|
|
107
132
|
return current;
|
|
108
133
|
const localKey = `local-${randomBytes(32).toString("hex")}`;
|
|
109
134
|
const upstreamOrigin = new URL(cfg.baseUrl).origin;
|
|
@@ -122,35 +147,55 @@ export async function ensureSigningProxy(cfg, fallbackClient = "terminal") {
|
|
|
122
147
|
res.end(JSON.stringify({ error: { message: "signing proxy only serves /v1/*" } }));
|
|
123
148
|
return;
|
|
124
149
|
}
|
|
150
|
+
const upstreamAbort = new AbortController();
|
|
151
|
+
const abortUpstream = () => {
|
|
152
|
+
if (!res.writableEnded) {
|
|
153
|
+
upstreamAbort.abort(new Error("local signing proxy client disconnected"));
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
res.once("close", abortUpstream);
|
|
125
157
|
const target = new URL(`${localUrl.pathname}${localUrl.search}`, upstreamOrigin).toString();
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
try {
|
|
159
|
+
const body = await readSigningProxyRequestBody(req);
|
|
160
|
+
const outboundHeaders = requestHeaders(req.headers);
|
|
161
|
+
// Set these at the final local hop so model calls and extension tools share
|
|
162
|
+
// exactly the same attribution, regardless of their upstream SDK defaults.
|
|
163
|
+
outboundHeaders.set("x-u1s1-client", client);
|
|
164
|
+
outboundHeaders.set("x-u1s1-version", VERSION);
|
|
165
|
+
outboundHeaders.set("x-u1s1-platform", `${process.platform}-${process.arch}`);
|
|
166
|
+
if (clientAttestation)
|
|
167
|
+
outboundHeaders.set("x-u1s1-attestation", clientAttestation);
|
|
168
|
+
const upstream = await authorizedFetch(cfg, target, {
|
|
169
|
+
method: req.method ?? "GET",
|
|
170
|
+
headers: outboundHeaders,
|
|
171
|
+
body,
|
|
172
|
+
signal: upstreamAbort.signal,
|
|
173
|
+
});
|
|
174
|
+
const headers = {};
|
|
175
|
+
upstream.headers.forEach((value, name) => {
|
|
176
|
+
if (!["content-length", "transfer-encoding", "connection"].includes(name))
|
|
177
|
+
headers[name] = value;
|
|
178
|
+
});
|
|
179
|
+
res.writeHead(upstream.status, headers);
|
|
180
|
+
if (upstream.body) {
|
|
181
|
+
for await (const chunk of upstream.body)
|
|
182
|
+
res.write(chunk);
|
|
183
|
+
}
|
|
184
|
+
res.end();
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
res.off("close", abortUpstream);
|
|
150
188
|
}
|
|
151
|
-
res.end();
|
|
152
189
|
}
|
|
153
190
|
catch (error) {
|
|
191
|
+
if (error instanceof SigningProxyRequestTooLargeError && !res.destroyed) {
|
|
192
|
+
req.resume();
|
|
193
|
+
res.writeHead(413, { "content-type": "application/json" });
|
|
194
|
+
res.end(JSON.stringify({ error: { message: "local signing proxy request body is too large" } }));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (res.destroyed)
|
|
198
|
+
return;
|
|
154
199
|
if (res.headersSent)
|
|
155
200
|
return void res.destroy(error);
|
|
156
201
|
res.writeHead(502, { "content-type": "application/json" });
|
|
@@ -166,6 +211,12 @@ export async function ensureSigningProxy(cfg, fallbackClient = "terminal") {
|
|
|
166
211
|
});
|
|
167
212
|
server.unref();
|
|
168
213
|
const port = server.address().port;
|
|
169
|
-
signingProxy = {
|
|
214
|
+
signingProxy = {
|
|
215
|
+
baseUrl: `http://127.0.0.1:${port}/v1`,
|
|
216
|
+
localKey,
|
|
217
|
+
token: cfg.deviceToken,
|
|
218
|
+
client,
|
|
219
|
+
clientAttestation,
|
|
220
|
+
};
|
|
170
221
|
return signingProxy;
|
|
171
222
|
}
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { registerLoopCommand } from "./loop.js";
|
|
|
8
8
|
import { ensureSearchTools } from "./search-tools.js";
|
|
9
9
|
import { ensureUsableShell } from "./shell-doctor.js";
|
|
10
10
|
import { applyBrandUi, setAnnouncement, setUpdateNotice } from "./style.js";
|
|
11
|
-
import { AuthError, fetchModels, loadCustomEndpoints } from "./api.js";
|
|
11
|
+
import { AuthError, fetchModels, loadCustomEndpoints, readJsonResponseCapped } from "./api.js";
|
|
12
12
|
import { ensureSigningProxy } from "./device-auth.js";
|
|
13
13
|
const PACKAGE_NAME = "u1s1-cli";
|
|
14
14
|
/** 启动时检测到的可自动安装的新版;TUI 退出后才装(见 installPendingUpdate)。 */
|
|
@@ -32,8 +32,12 @@ async function checkForUpdate() {
|
|
|
32
32
|
signal: AbortSignal.timeout(5_000),
|
|
33
33
|
});
|
|
34
34
|
if (res.ok) {
|
|
35
|
-
const body =
|
|
36
|
-
latest = body.version
|
|
35
|
+
const body = await readJsonResponseCapped(res, 64 * 1024);
|
|
36
|
+
latest = typeof body.version === "string"
|
|
37
|
+
&& body.version.length <= 100
|
|
38
|
+
&& /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(body.version)
|
|
39
|
+
? body.version
|
|
40
|
+
: undefined;
|
|
37
41
|
}
|
|
38
42
|
}
|
|
39
43
|
catch {
|
|
@@ -193,7 +197,7 @@ async function runAgent(cfg, args) {
|
|
|
193
197
|
// it after the live model list arrives; explicit user choices remain intact.
|
|
194
198
|
ensureDefaultSettings(MODELS);
|
|
195
199
|
// pi provider 只支持静态 header;指向本机 signing proxy,由它逐请求附 DPoP proof。
|
|
196
|
-
const signing = await ensureSigningProxy(cfg);
|
|
200
|
+
const signing = await ensureSigningProxy(cfg, "terminal", modelsResp?.clientAttestation);
|
|
197
201
|
const officialCfg = { ...cfg, baseUrl: signing.baseUrl, apiKey: signing.localKey };
|
|
198
202
|
ensureBrandPrompt(await shellReady);
|
|
199
203
|
ensureProviderModels(officialCfg);
|