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/login.js CHANGED
@@ -1,11 +1,25 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { hostname, platform } from "node:os";
3
- import { fetchMe } from "./api.js";
3
+ import { fetchMe, readJsonResponseCapped } from "./api.js";
4
4
  import { printConsoleBanner } from "./brand.js";
5
5
  import { loadConfig, saveConfig } from "./config.js";
6
6
  import { generateDeviceKeyPair, hasDeviceCredential } from "./device-auth.js";
7
7
  const require = createRequire(import.meta.url);
8
8
  const VERSION = require("../package.json").version;
9
+ const MAX_DEVICE_AUTH_RESPONSE_BYTES = 64 * 1024;
10
+ function boundedInteger(value, fallback, min, max) {
11
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max
12
+ ? value
13
+ : fallback;
14
+ }
15
+ function boundedCredential(value, prefix) {
16
+ return typeof value === "string"
17
+ && value.startsWith(prefix)
18
+ && value.length <= 4_096
19
+ && !/[\u0000-\u001f\u007f]/.test(value)
20
+ ? value
21
+ : null;
22
+ }
9
23
  function tryOpenBrowser(url) {
10
24
  import("node:child_process")
11
25
  .then(({ spawn }) => {
@@ -36,16 +50,26 @@ export async function startDeviceLogin(origin) {
36
50
  }),
37
51
  signal: AbortSignal.timeout(15_000),
38
52
  });
39
- if (!resp.ok)
53
+ if (!resp.ok) {
54
+ void resp.body?.cancel("device login start rejected").catch(() => { });
40
55
  return null;
41
- const data = (await resp.json());
42
- if (typeof data.verify_url !== "string" || typeof data.poll_secret !== "string")
56
+ }
57
+ const data = await readJsonResponseCapped(resp, MAX_DEVICE_AUTH_RESPONSE_BYTES);
58
+ if (typeof data.verify_url !== "string"
59
+ || data.verify_url.length > 2_048
60
+ || typeof data.poll_secret !== "string"
61
+ || !data.poll_secret
62
+ || data.poll_secret.length > 4_096
63
+ || /[\u0000-\u001f\u007f]/.test(data.poll_secret))
64
+ return null;
65
+ const verifyUrl = new URL(data.verify_url);
66
+ if (!["http:", "https:"].includes(verifyUrl.protocol) || verifyUrl.username || verifyUrl.password)
43
67
  return null;
44
68
  return {
45
- verify_url: data.verify_url,
69
+ verify_url: verifyUrl.toString(),
46
70
  poll_secret: data.poll_secret,
47
- interval: data.interval || 2,
48
- expires_in: data.expires_in || 900,
71
+ interval: boundedInteger(data.interval, 2, 1, 30),
72
+ expires_in: boundedInteger(data.expires_in, 900, 1, 1_800),
49
73
  private_jwk: pair.privateJwk,
50
74
  public_jwk: pair.publicJwk,
51
75
  };
@@ -56,9 +80,11 @@ export async function startDeviceLogin(origin) {
56
80
  }
57
81
  /** 轮询等浏览器批准;只接受带设备凭证的新网关响应。outcome 可带出失败原因。 */
58
82
  export async function pollDeviceLogin(origin, start, outcome) {
59
- const deadline = Date.now() + start.expires_in * 1000;
83
+ const interval = boundedInteger(start.interval, 2, 1, 30);
84
+ const expiresIn = boundedInteger(start.expires_in, 900, 1, 1_800);
85
+ const deadline = Date.now() + expiresIn * 1000;
60
86
  while (Date.now() < deadline) {
61
- await sleep(start.interval * 1000);
87
+ await sleep(interval * 1000);
62
88
  try {
63
89
  const resp = await fetch(`${origin}/auth/device/poll`, {
64
90
  method: "POST",
@@ -66,14 +92,22 @@ export async function pollDeviceLogin(origin, start, outcome) {
66
92
  body: JSON.stringify({ poll_secret: start.poll_secret }),
67
93
  signal: AbortSignal.timeout(10_000),
68
94
  });
69
- if (!resp.ok)
95
+ if (!resp.ok) {
96
+ void resp.body?.cancel("device login poll rejected").catch(() => { });
70
97
  continue;
71
- const data = (await resp.json());
72
- if (data.status === "ok" && data.api_key && data.device_token) {
98
+ }
99
+ const data = await readJsonResponseCapped(resp, MAX_DEVICE_AUTH_RESPONSE_BYTES);
100
+ const apiKey = boundedCredential(data.api_key, "u1s1-");
101
+ const deviceToken = boundedCredential(data.device_token, "u1s1d-");
102
+ if (data.status === "ok" && apiKey && deviceToken) {
73
103
  return {
74
- apiKey: data.api_key,
75
- deviceToken: data.device_token,
76
- deviceId: data.device_id,
104
+ apiKey,
105
+ deviceToken,
106
+ deviceId: typeof data.device_id === "number"
107
+ && Number.isSafeInteger(data.device_id)
108
+ && data.device_id > 0
109
+ ? data.device_id
110
+ : undefined,
77
111
  devicePrivateJwk: start.private_jwk,
78
112
  devicePublicJwk: start.public_jwk,
79
113
  };
package/dist/model.d.ts CHANGED
@@ -1 +1,7 @@
1
+ import { type ModelsResponse } from "./api.js";
2
+ import { type CliConfig } from "./config.js";
3
+ type ModelFetcher = (cfg: CliConfig) => Promise<ModelsResponse>;
4
+ /** Refresh official models while preserving the built-in list as an explicit offline fallback. */
5
+ export declare function refreshOfficialModels(cfg: CliConfig, fetcher?: ModelFetcher): Promise<string | null>;
1
6
  export declare function modelCommand(nameOrAlias?: string): Promise<void>;
7
+ export {};
package/dist/model.js CHANGED
@@ -1,9 +1,28 @@
1
- import { loadCustomEndpoints } from "./api.js";
2
- import { CUSTOM_ENDPOINTS, findModel, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, resolvePreferredModel, } from "./config.js";
1
+ import { fetchModels, loadCustomEndpoints } from "./api.js";
2
+ import { apiModelToDef, CUSTOM_ENDPOINTS, findModel, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, resolvePreferredModel, setModelsFromApi, } from "./config.js";
3
+ /** Refresh official models while preserving the built-in list as an explicit offline fallback. */
4
+ export async function refreshOfficialModels(cfg, fetcher = fetchModels) {
5
+ try {
6
+ const { models } = await fetcher(cfg);
7
+ if (models.length === 0)
8
+ return "线上模型列表为空";
9
+ setModelsFromApi(models.map(apiModelToDef));
10
+ return null;
11
+ }
12
+ catch (error) {
13
+ return error instanceof Error ? error.message : String(error);
14
+ }
15
+ }
3
16
  export async function modelCommand(nameOrAlias) {
4
17
  const cfg = loadConfig();
5
- // 云端可能刚改过端点配置;拉一次(失败回退本地缓存),再解析当前默认
6
- await loadCustomEndpoints(cfg);
18
+ // Fetch both live catalogues before resolving the persisted default.
19
+ const [officialModelsError] = await Promise.all([
20
+ refreshOfficialModels(cfg),
21
+ loadCustomEndpoints(cfg),
22
+ ]);
23
+ if (officialModelsError) {
24
+ console.error(` 获取线上模型列表失败,使用内置列表:${officialModelsError}`);
25
+ }
7
26
  const current = resolvePreferredModel(cfg);
8
27
  if (!nameOrAlias) {
9
28
  console.log("");
@@ -26,7 +45,7 @@ export async function modelCommand(nameOrAlias) {
26
45
  }
27
46
  }
28
47
  console.log("");
29
- console.log(" 切换:u1s1 model grok / u1s1 model deepseek(对话里 /model 同样会记住)");
48
+ console.log(" 切换:u1s1 model <model-id>(对话里 /model 同样会记住)");
30
49
  if (CUSTOM_ENDPOINTS.length) {
31
50
  console.log(" 端点模型重名时可加限定:u1s1 model 端点名:模型id");
32
51
  }
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
3
3
  import { arch, platform } from "node:os";
4
4
  import { join } from "node:path";
5
- import { Readable } from "node:stream";
5
+ import { Readable, Transform } from "node:stream";
6
6
  import { pipeline } from "node:stream/promises";
7
7
  import { agentDir } from "./config.js";
8
8
  /**
@@ -24,6 +24,7 @@ const TOOLS = [
24
24
  ];
25
25
  const binDir = join(agentDir, "bin");
26
26
  const DOWNLOAD_TIMEOUT_MS = 60_000;
27
+ const MAX_TOOL_ARCHIVE_BYTES = 64 * 1024 * 1024;
27
28
  function archStr() {
28
29
  const a = arch();
29
30
  return a === "arm64" ? "aarch64" : a === "x64" ? "x86_64" : null;
@@ -110,11 +111,23 @@ async function install(tool, baseUrl, binName) {
110
111
  });
111
112
  if (!res.ok || !res.body)
112
113
  throw new Error(`HTTP ${res.status}`);
114
+ const declaredLength = Number(res.headers.get("content-length"));
115
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_TOOL_ARCHIVE_BYTES) {
116
+ void res.body.cancel("tool archive exceeded limit").catch(() => { });
117
+ throw new Error(`下载文件超过 ${MAX_TOOL_ARCHIVE_BYTES} bytes`);
118
+ }
113
119
  const archive = join(binDir, asset);
114
120
  // fd/rg 并行安装,解压目录按工具名+pid 隔离,避免互踩
115
121
  const extractDir = join(binDir, `preseed_${tool.bin}_${process.pid}`);
116
122
  try {
117
- await pipeline(Readable.fromWeb(res.body), createWriteStream(archive));
123
+ let received = 0;
124
+ const limiter = new Transform({
125
+ transform(chunk, _encoding, callback) {
126
+ received += chunk.byteLength;
127
+ callback(received > MAX_TOOL_ARCHIVE_BYTES ? new Error("tool archive exceeded limit") : null, received > MAX_TOOL_ARCHIVE_BYTES ? undefined : chunk);
128
+ },
129
+ });
130
+ await pipeline(Readable.fromWeb(res.body), limiter, createWriteStream(archive));
118
131
  mkdirSync(extractDir, { recursive: true });
119
132
  if (!extract(archive, extractDir))
120
133
  throw new Error(`解压失败 ${asset}`);
@@ -30,6 +30,19 @@ export interface SubagentOutcome {
30
30
  text: string;
31
31
  usage: SubagentUsage;
32
32
  }
33
+ type PromptSession = {
34
+ prompt(task: string): Promise<void>;
35
+ abort(): Promise<void>;
36
+ };
37
+ /**
38
+ * Run one prompt while preserving the reason that stopped it. AgentSession.abort()
39
+ * resolves the active prompt normally, so the caller must not infer success merely
40
+ * because prompt() settled after a timeout or parent cancellation.
41
+ */
42
+ export declare function promptSubagentSession(session: PromptSession, task: string, options: {
43
+ timeoutMs: number;
44
+ signal?: AbortSignal;
45
+ }): Promise<void>;
33
46
  /**
34
47
  * Spawn 一个独立上下文的子 agent 执行任务,返回其最终文本输出。
35
48
  * 模型解析:"provider/id" 精确匹配;裸 id(如 deepseek-v4-flash)先按原样找,
@@ -47,3 +60,4 @@ export declare function runPool<T>(input: {
47
60
  run: (item: T, index: number) => Promise<void>;
48
61
  signal?: AbortSignal;
49
62
  }): Promise<void>;
63
+ export {};
package/dist/subagent.js CHANGED
@@ -6,6 +6,45 @@ export const SUBAGENT_CONCURRENCY = 4;
6
6
  export const SUBAGENT_TIMEOUT_MS = 15 * 60_000;
7
7
  /** ModelRuntime 进程内只建一次(auth/models 解析有启动开销)。 */
8
8
  let sharedModelRuntime;
9
+ function cancelledError(signal) {
10
+ return new Error("已取消", { cause: signal.reason });
11
+ }
12
+ /**
13
+ * Run one prompt while preserving the reason that stopped it. AgentSession.abort()
14
+ * resolves the active prompt normally, so the caller must not infer success merely
15
+ * because prompt() settled after a timeout or parent cancellation.
16
+ */
17
+ export async function promptSubagentSession(session, task, options) {
18
+ if (options.signal?.aborted)
19
+ throw cancelledError(options.signal);
20
+ let timedOut = false;
21
+ let promptError;
22
+ const abortSession = () => {
23
+ void session.abort().catch(() => { });
24
+ };
25
+ const timer = setTimeout(() => {
26
+ timedOut = true;
27
+ abortSession();
28
+ }, options.timeoutMs);
29
+ const onParentAbort = abortSession;
30
+ options.signal?.addEventListener("abort", onParentAbort, { once: true });
31
+ try {
32
+ await session.prompt(task);
33
+ }
34
+ catch (error) {
35
+ promptError = error;
36
+ }
37
+ finally {
38
+ clearTimeout(timer);
39
+ options.signal?.removeEventListener("abort", onParentAbort);
40
+ }
41
+ if (options.signal?.aborted)
42
+ throw cancelledError(options.signal);
43
+ if (timedOut)
44
+ throw new Error(`子任务超时(${options.timeoutMs}ms)`, { cause: promptError });
45
+ if (promptError)
46
+ throw promptError;
47
+ }
9
48
  /** 从会话消息里取最后一条非空 assistant 文本作为子 agent 的最终输出。 */
10
49
  function extractFinalText(messages) {
11
50
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -59,6 +98,8 @@ function extractUsage(messages, model) {
59
98
  * 失败(模型错误/超时/取消)抛错,由调用方决定容错语义。
60
99
  */
61
100
  export async function runSubagent(opts) {
101
+ if (opts.signal?.aborted)
102
+ throw cancelledError(opts.signal);
62
103
  sharedModelRuntime ??= await ModelRuntime.create();
63
104
  let model;
64
105
  if (opts.model) {
@@ -98,22 +139,17 @@ export async function runSubagent(opts) {
98
139
  ...(model ? { model } : {}),
99
140
  ...(opts.noTools ? { noTools: "all" } : {}),
100
141
  });
101
- const timeoutMs = opts.timeoutMs ?? SUBAGENT_TIMEOUT_MS;
102
- const timer = setTimeout(() => void session.abort(), timeoutMs);
103
- const onParentAbort = () => void session.abort();
104
- opts.signal?.addEventListener("abort", onParentAbort, { once: true });
105
142
  try {
106
- await session.prompt(opts.task);
143
+ await promptSubagentSession(session, opts.task, {
144
+ timeoutMs: opts.timeoutMs ?? SUBAGENT_TIMEOUT_MS,
145
+ signal: opts.signal,
146
+ });
147
+ const messages = session.messages;
148
+ return { ok: true, text: extractFinalText(messages), usage: extractUsage(messages, model) };
107
149
  }
108
150
  finally {
109
- clearTimeout(timer);
110
- opts.signal?.removeEventListener("abort", onParentAbort);
111
151
  session.dispose();
112
152
  }
113
- if (opts.signal?.aborted)
114
- throw new Error("已取消");
115
- const messages = session.messages;
116
- return { ok: true, text: extractFinalText(messages), usage: extractUsage(messages, model) };
117
153
  }
118
154
  /**
119
155
  * 固定并发的工作池:limit 个工人依次领任务跑完一个补位一个。
package/dist/tools.d.ts CHANGED
@@ -3,6 +3,8 @@ import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import type { CliConfig } from "./config.js";
5
5
  export declare function truncate(text: string): string;
6
+ /** 只下载前 maxBytes 就断流,超大页面不用整个拉完再丢。 */
7
+ export declare function readBodyCapped(resp: Response, maxBytes: number): Promise<Uint8Array>;
6
8
  /** 联网工具通用渲染:调用行不占位,收起时只显一行摘要,ctrl+o 展开全文,出错显一行 ✗ */
7
9
  export declare function compactResultRender(input: {
8
10
  result: {
@@ -43,6 +45,8 @@ export declare function downloadGeneratedImage(url: string, signal: AbortSignal,
43
45
  attempts?: number;
44
46
  retryDelayMs?: number;
45
47
  fetchImpl?: typeof fetch;
48
+ maxBytes?: number;
49
+ attemptTimeoutMs?: number;
46
50
  }): Promise<Uint8Array>;
47
51
  /** 生图工具:走 u1s1 网关代理火山方舟 Seedream,上游 key 不落到用户机器上。 */
48
52
  export declare function createImageTool(cfg: Pick<CliConfig, "baseUrl" | "apiKey">): import("@earendil-works/pi-coding-agent").ToolDefinition<Type.TObject<{
package/dist/tools.js CHANGED
@@ -8,6 +8,8 @@ import { generateImage, renderPage, searchWeb } from "./api.js";
8
8
  const FETCH_TIMEOUT_MS = 20_000;
9
9
  const MAX_FETCH_BYTES = 2_000_000;
10
10
  const MAX_TEXT_CHARS = 30_000;
11
+ const MAX_GENERATED_IMAGE_BYTES = 64 * 1024 * 1024;
12
+ const IMAGE_DOWNLOAD_TIMEOUT_MS = 60_000;
11
13
  /** 云端渲染是真开浏览器,比直连慢得多,给宽裕些。 */
12
14
  const RENDER_TIMEOUT_MS = 45_000;
13
15
  /** 200 但榨出的正文比这还短,多半是 JS 渲染的空壳页,值得上浏览器再试。 */
@@ -39,31 +41,37 @@ export function truncate(text) {
39
41
  return `${text.slice(0, MAX_TEXT_CHARS)}\n\n…(内容过长已截断,共 ${text.length} 字符)`;
40
42
  }
41
43
  /** 只下载前 maxBytes 就断流,超大页面不用整个拉完再丢。 */
42
- async function readBodyCapped(resp, maxBytes) {
44
+ export async function readBodyCapped(resp, maxBytes) {
43
45
  if (!resp.body)
44
46
  return new Uint8Array(await resp.arrayBuffer()).subarray(0, maxBytes);
45
47
  const reader = resp.body.getReader();
46
48
  const chunks = [];
47
49
  let total = 0;
48
- for (;;) {
49
- const { done, value } = await reader.read();
50
- if (done)
51
- break;
52
- chunks.push(value);
53
- total += value.byteLength;
54
- if (total >= maxBytes) {
55
- void reader.cancel().catch(() => { });
56
- break;
50
+ try {
51
+ for (;;) {
52
+ const { done, value } = await reader.read();
53
+ if (done)
54
+ break;
55
+ chunks.push(value);
56
+ total += value.byteLength;
57
+ if (total >= maxBytes) {
58
+ void reader.cancel("web fetch body limit reached").catch(() => { });
59
+ break;
60
+ }
57
61
  }
58
62
  }
63
+ finally {
64
+ reader.releaseLock();
65
+ }
59
66
  const out = new Uint8Array(Math.min(total, maxBytes));
60
67
  let offset = 0;
61
68
  for (const chunk of chunks) {
62
69
  const room = out.byteLength - offset;
63
70
  if (room <= 0)
64
71
  break;
65
- out.set(room < chunk.byteLength ? chunk.subarray(0, room) : chunk, offset);
66
- offset += chunk.byteLength;
72
+ const copied = Math.min(room, chunk.byteLength);
73
+ out.set(copied < chunk.byteLength ? chunk.subarray(0, copied) : chunk, offset);
74
+ offset += copied;
67
75
  }
68
76
  return out;
69
77
  }
@@ -316,6 +324,44 @@ async function abortableDelay(ms, signal) {
316
324
  signal.addEventListener("abort", onAbort, { once: true });
317
325
  });
318
326
  }
327
+ class GeneratedImageTooLargeError extends Error {
328
+ }
329
+ async function readGeneratedImageBody(resp, maxBytes) {
330
+ const declaredLength = Number(resp.headers.get("content-length"));
331
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
332
+ if (resp.body)
333
+ void resp.body.cancel("generated image body limit reached").catch(() => { });
334
+ throw new GeneratedImageTooLargeError(`图片文件超过下载上限 ${Math.floor(maxBytes / 1024 / 1024)} MiB`);
335
+ }
336
+ if (!resp.body)
337
+ return new Uint8Array();
338
+ const reader = resp.body.getReader();
339
+ const chunks = [];
340
+ let total = 0;
341
+ try {
342
+ for (;;) {
343
+ const { done, value } = await reader.read();
344
+ if (done)
345
+ break;
346
+ if (total + value.byteLength > maxBytes) {
347
+ void reader.cancel("generated image body limit reached").catch(() => { });
348
+ throw new GeneratedImageTooLargeError(`图片文件超过下载上限 ${Math.floor(maxBytes / 1024 / 1024)} MiB`);
349
+ }
350
+ chunks.push(value);
351
+ total += value.byteLength;
352
+ }
353
+ }
354
+ finally {
355
+ reader.releaseLock();
356
+ }
357
+ const bytes = new Uint8Array(total);
358
+ let offset = 0;
359
+ for (const chunk of chunks) {
360
+ bytes.set(chunk, offset);
361
+ offset += chunk.byteLength;
362
+ }
363
+ return bytes;
364
+ }
319
365
  /**
320
366
  * 下载方舟返回的同一个临时 URL。fetch() 拿到响应头后,body 仍可能在
321
367
  * arrayBuffer() 阶段以 Undici `terminated` 断流,所以两步必须放在同一个
@@ -325,17 +371,27 @@ export async function downloadGeneratedImage(url, signal, options = {}) {
325
371
  const attempts = Math.max(1, Math.floor(options.attempts ?? IMAGE_DOWNLOAD_ATTEMPTS));
326
372
  const retryDelayMs = Math.max(0, options.retryDelayMs ?? IMAGE_DOWNLOAD_RETRY_DELAY_MS);
327
373
  const fetchImpl = options.fetchImpl ?? fetch;
374
+ const maxBytes = Math.max(1, Math.floor(options.maxBytes ?? MAX_GENERATED_IMAGE_BYTES));
375
+ const attemptTimeoutMs = Math.max(1, Math.floor(options.attemptTimeoutMs ?? IMAGE_DOWNLOAD_TIMEOUT_MS));
328
376
  let lastError;
329
377
  for (let attempt = 1; attempt <= attempts; attempt++) {
330
378
  try {
331
- const resp = await fetchImpl(url, { signal });
332
- if (!resp.ok)
379
+ const resp = await fetchImpl(url, {
380
+ signal: AbortSignal.any([signal, AbortSignal.timeout(attemptTimeoutMs)]),
381
+ });
382
+ if (!resp.ok) {
383
+ if (resp.body)
384
+ void resp.body.cancel("generated image HTTP error").catch(() => { });
333
385
  throw new Error(`HTTP ${resp.status}`);
334
- return new Uint8Array(await resp.arrayBuffer());
386
+ }
387
+ return await readGeneratedImageBody(resp, maxBytes);
335
388
  }
336
389
  catch (error) {
337
390
  if (signal.aborted)
338
391
  throw error;
392
+ if (error instanceof GeneratedImageTooLargeError) {
393
+ throw new Error(`图片已经生成,但${error.message}。不要重新调用 generate_image,可在 24 小时内手动下载: ${url}`, { cause: error });
394
+ }
339
395
  lastError = error;
340
396
  if (attempt < attempts)
341
397
  await abortableDelay(retryDelayMs * attempt, signal);
@@ -425,13 +481,25 @@ async function fetchDirect(url, signal) {
425
481
  throw new DirectFetchError(`打不开 ${url.href}: ${e.message}`, true);
426
482
  }
427
483
  if (!resp.ok) {
484
+ void resp.body?.cancel("web fetch rejected HTTP response").catch(() => undefined);
428
485
  throw new DirectFetchError(`${url.href} 返回 ${resp.status} ${resp.statusText}`, RENDER_WORTHY_STATUS.has(resp.status));
429
486
  }
430
487
  const type = resp.headers.get("content-type") ?? "";
431
488
  if (!/text\/|json|xml|javascript/i.test(type)) {
489
+ void resp.body?.cancel("web fetch rejected non-text response").catch(() => undefined);
432
490
  throw new DirectFetchError(`${url.href} 不是文本内容 (${type || "unknown"}),读不了`, false);
433
491
  }
434
- const raw = decodeBody(await readBodyCapped(resp, MAX_FETCH_BYTES), type);
492
+ let bytes;
493
+ try {
494
+ bytes = await readBodyCapped(resp, MAX_FETCH_BYTES);
495
+ }
496
+ catch (error) {
497
+ if (signal?.aborted)
498
+ throw error;
499
+ const detail = error instanceof Error ? error.message : String(error);
500
+ throw new DirectFetchError(`${url.href} 正文读取失败: ${detail}`, true);
501
+ }
502
+ const raw = decodeBody(bytes, type);
435
503
  const isHtml = /html|xml/i.test(type);
436
504
  return { text: isHtml ? htmlToText(raw) : raw.trim(), contentType: type, isHtml };
437
505
  }
package/dist/update.js CHANGED
@@ -1,14 +1,24 @@
1
- import { execSync, spawnSync } from "node:child_process";
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { isPortableInstall } from "./config.js";
8
+ import { readJsonResponseCapped, readResponseTextCapped } from "./api.js";
8
9
  const require = createRequire(import.meta.url);
9
10
  const pkg = require("../package.json");
10
11
  export const VERSION = pkg.version;
11
12
  export const PACKAGE_NAME = "u1s1-cli";
13
+ const MAX_NPM_METADATA_BYTES = 64 * 1024;
14
+ const MAX_INSTALL_SCRIPT_BYTES = 1024 * 1024;
15
+ function publishedVersion(value) {
16
+ return typeof value === "string"
17
+ && value.length <= 100
18
+ && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value)
19
+ ? value
20
+ : undefined;
21
+ }
12
22
  /** engines.node 里要求的最低版本(如 ">=22.19.0" → "22.19.0");解析不出返回 undefined。 */
13
23
  function requiredNodeVersion() {
14
24
  const m = /(\d+\.\d+\.\d+)/.exec(pkg.engines?.node ?? "");
@@ -36,8 +46,8 @@ export async function getLatestVersion() {
36
46
  });
37
47
  if (!res.ok)
38
48
  return undefined;
39
- const body = (await res.json());
40
- return body.version;
49
+ const body = await readJsonResponseCapped(res, MAX_NPM_METADATA_BYTES);
50
+ return publishedVersion(body.version);
41
51
  }
42
52
  catch {
43
53
  return undefined;
@@ -97,7 +107,7 @@ async function portableSelfUpdate(latest) {
97
107
  });
98
108
  if (!res.ok)
99
109
  throw new Error(`HTTP ${res.status}`);
100
- script = await res.text();
110
+ script = await readResponseTextCapped(res, MAX_INSTALL_SCRIPT_BYTES);
101
111
  }
102
112
  catch {
103
113
  console.error("安装脚本下载失败。请手动运行:");
@@ -107,7 +117,7 @@ async function portableSelfUpdate(latest) {
107
117
  const tmp = join(mkdtempSync(join(tmpdir(), "u1s1-update-")), "install.sh");
108
118
  writeFileSync(tmp, script, { mode: 0o755 });
109
119
  try {
110
- execSync(`bash ${JSON.stringify(tmp)}`, { stdio: "inherit" });
120
+ execFileSync("bash", [tmp], { stdio: "inherit" });
111
121
  }
112
122
  catch {
113
123
  console.error("\n自动升级失败。请手动运行:");
@@ -171,8 +181,10 @@ export async function update() {
171
181
  console.log(`正在用 ${pm} 更新 ${PACKAGE_NAME}…`);
172
182
  try {
173
183
  // npm 压掉 EBADENGINE 等警告墙,对新手只有噪音;出错时 error 仍会显示
174
- const installCmd = pm === "npm" ? `npm install -g --loglevel=error ${PACKAGE_NAME}@latest` : `${pm} add -g ${PACKAGE_NAME}@latest`;
175
- execSync(installCmd, { stdio: "inherit" });
184
+ const args = pm === "npm"
185
+ ? ["install", "-g", "--loglevel=error", `${PACKAGE_NAME}@latest`]
186
+ : ["add", "-g", `${PACKAGE_NAME}@latest`];
187
+ execFileSync(pm, args, { stdio: "inherit" });
176
188
  console.log(`\n✅ 已更新到 v${latest},重启 u1s1 后生效。`);
177
189
  }
178
190
  catch {
package/dist/web.js CHANGED
@@ -54,20 +54,22 @@ export async function prepareWebEnv(cfg) {
54
54
  let webFetchRenderEnabled = false;
55
55
  // 老网关没有 /v1/image,image_gen 缺失时按关闭处理,不注册生图工具
56
56
  let imageGenEnabled = false;
57
+ let clientAttestation;
57
58
  const endpointsReady = loadCustomEndpoints(cfg);
58
59
  try {
59
- const { models, features } = await fetchModels(cfg);
60
+ const { models, features, clientAttestation: attestation } = await fetchModels(cfg);
60
61
  setModelsFromApi(models.map(apiModelToDef));
61
62
  webSearchEnabled = features.web_search !== false;
62
63
  webFetchRenderEnabled = features.web_fetch_render === true;
63
64
  imageGenEnabled = features.image_gen === true;
65
+ clientAttestation = attestation;
64
66
  }
65
67
  catch (e) {
66
68
  console.error(" 获取模型列表失败,使用内置列表:", e.message);
67
69
  }
68
70
  await endpointsReady;
69
71
  ensureDefaultSettings(MODELS);
70
- const signing = await ensureSigningProxy(cfg, "desktop");
72
+ const signing = await ensureSigningProxy(cfg, "desktop", clientAttestation);
71
73
  const officialCfg = { ...cfg, baseUrl: signing.baseUrl, apiKey: signing.localKey };
72
74
  webOfficialCfg = officialCfg;
73
75
  const modelsPath = refreshWebModels();
@@ -3,6 +3,22 @@ import { type ParentModelRef } from "../subagent.js";
3
3
  export declare const WORKFLOW_TIMEOUT_MS: number;
4
4
  /** 默认整个 run 的 token 预算;0 或负数表示不设限。 */
5
5
  export declare const WORKFLOW_DEFAULT_BUDGET_TOKENS = 20000000;
6
+ interface ProgressEntry {
7
+ key: string;
8
+ /** 任务摘要(前 120 字),调试/排查用,key 才是身份。 */
9
+ task: string;
10
+ ok: boolean;
11
+ text: string;
12
+ ms: number;
13
+ }
14
+ /** JSONL 进度存档:一行一个已完成的子任务,断点续跑时按 key 跳过。 */
15
+ export declare class ProgressStore {
16
+ #private;
17
+ readonly path: string;
18
+ constructor(path: string);
19
+ getSuccessful(key: string): ProgressEntry | undefined;
20
+ append(entry: ProgressEntry): void;
21
+ }
6
22
  /** 静态白名单校验:语法能编译 + 不碰沙箱之外的任何能力。 */
7
23
  export declare function validateScript(code: string): string[];
8
24
  export interface WorkflowRunResult {
@@ -46,3 +62,4 @@ export declare function runWorkflow(opts: WorkflowRunOptions): Promise<WorkflowR
46
62
  export declare function workflowsDir(): string;
47
63
  /** 内联脚本落盘,返回脚本路径(进度存档按同名约定派生)。 */
48
64
  export declare function saveWorkflowScript(code: string): string;
65
+ export {};
@@ -15,7 +15,7 @@ const MAX_LOG_LINES = 60;
15
15
  /** 默认整个 run 的 token 预算;0 或负数表示不设限。 */
16
16
  export const WORKFLOW_DEFAULT_BUDGET_TOKENS = 20_000_000;
17
17
  /** JSONL 进度存档:一行一个已完成的子任务,断点续跑时按 key 跳过。 */
18
- class ProgressStore {
18
+ export class ProgressStore {
19
19
  path;
20
20
  #cache = new Map();
21
21
  constructor(path) {
@@ -36,8 +36,9 @@ class ProgressStore {
36
36
  }
37
37
  }
38
38
  }
39
- get(key) {
40
- return this.#cache.get(key);
39
+ getSuccessful(key) {
40
+ const entry = this.#cache.get(key);
41
+ return entry?.ok === true && typeof entry.text === "string" ? entry : undefined;
41
42
  }
42
43
  append(entry) {
43
44
  this.#cache.set(entry.key, entry);
@@ -154,14 +155,12 @@ export async function runWorkflow(opts) {
154
155
  }
155
156
  const key = taskKey(task, model);
156
157
  if (opts.resume) {
157
- const prev = store.get(key);
158
+ const prev = store.getSuccessful(key);
158
159
  if (prev) {
159
160
  stats.cached++;
160
161
  progress.cached = stats.cached;
161
162
  reportProgress();
162
- if (prev.ok)
163
- return prev.text;
164
- throw new Error(prev.text);
163
+ return prev.text;
165
164
  }
166
165
  }
167
166
  stats.spawns++;