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 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.json().catch(() => null));
9
- return body?.error?.message ?? fallback;
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 = (await resp.json());
27
- return { models: body.data, features: body.features ?? {}, announcement: body.announcement };
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.json());
47
- return Array.isArray(body.endpoints) ? body.endpoints : [];
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 ?? AbortSignal.timeout(60_000),
139
+ signal: signalWithTimeout(input.signal, 60_000),
77
140
  });
78
141
  }
79
- catch {
80
- throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
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
- return (await resp.json());
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 ?? AbortSignal.timeout(90_000),
184
+ signal: signalWithTimeout(signal, 90_000),
102
185
  });
103
186
  }
104
- catch {
105
- throw new Error(`连不上 ${cfg.baseUrl}`);
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
- return (await resp.json());
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
- return { url, size: encodedSize ? decodeURIComponent(encodedSize) : null };
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
- await resp.body?.cancel().catch(() => undefined);
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.json());
149
- if (typeof body.url !== "string" || !/^https?:\/\//.test(body.url)) {
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 { url: body.url, size: typeof body.size === "string" ? body.size : null };
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 ?? AbortSignal.timeout(180_000),
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
- return (await resp.json());
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.text().catch(() => "未知错误");
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 data = (await res.json());
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: data.choices?.[0]?.message?.content ?? "",
129
+ response: typeof message?.content === "string" ? message.content : "",
117
130
  latencyMs,
118
- tokensIn: data.usage?.prompt_tokens ?? 0,
119
- tokensOut: data.usage?.completion_tokens ?? 0,
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 = statSync(abs);
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 data = (await resp.json().catch(() => null));
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 e = new Error(data?.error?.message ?? `服务端返回 ${resp.status},稍后再试`);
142
- e.code = data?.error?.code;
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?.ok)
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 body = resp ? (await resp.json().catch(() => null)) : null;
164
- throw new Error(`上传 ${f.path} 失败:${body?.error?.message ?? "网络错误"}`);
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
  };
@@ -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 {};
@@ -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
- const chunks = [];
127
- for await (const chunk of req)
128
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
129
- const body = chunks.length ? Buffer.concat(chunks) : undefined;
130
- const outboundHeaders = requestHeaders(req.headers);
131
- // Set these at the final local hop so model calls and extension tools share
132
- // exactly the same attribution, regardless of their upstream SDK defaults.
133
- outboundHeaders.set("x-u1s1-client", client);
134
- outboundHeaders.set("x-u1s1-version", VERSION);
135
- outboundHeaders.set("x-u1s1-platform", `${process.platform}-${process.arch}`);
136
- const upstream = await authorizedFetch(cfg, target, {
137
- method: req.method ?? "GET",
138
- headers: outboundHeaders,
139
- body,
140
- });
141
- const headers = {};
142
- upstream.headers.forEach((value, name) => {
143
- if (!["content-length", "transfer-encoding", "connection"].includes(name))
144
- headers[name] = value;
145
- });
146
- res.writeHead(upstream.status, headers);
147
- if (upstream.body) {
148
- for await (const chunk of upstream.body)
149
- res.write(chunk);
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 = { baseUrl: `http://127.0.0.1:${port}/v1`, localKey, token: cfg.deviceToken, client };
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 = (await res.json());
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);