u1s1-cli 1.4.1 → 1.4.2

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.
@@ -97,7 +97,9 @@ export declare function toProviderModels(models: ModelDef[]): {
97
97
  * 字面量 "local" —— pi 没有凭据会直接拒启("No API key found"),
98
98
  * 这是 pi 自带 llama provider 的同款兜底,本地服务会忽略 Authorization 头。
99
99
  */
100
- export declare function endpointProviderEntry(ep: CustomEndpoint): Record<string, unknown>;
100
+ export declare function endpointProviderEntry(ep: CustomEndpoint, options?: {
101
+ literalKey?: boolean;
102
+ }): Record<string, unknown>;
101
103
  /**
102
104
  * 生成 /workflow 提示词模板到 <agentDir>/prompts/workflow.md:引导主 agent
103
105
  * 拆解任务 → 生成沙箱脚本 → 调 run_workflow 执行。每次启动重写,便于随版迭代文案。
@@ -224,8 +224,12 @@ export function writeWebToolsExtension(cfg, features) {
224
224
  ` const workflow = await import(${JSON.stringify(new URL("./workflow/tool.js", import.meta.url).href)});\n` +
225
225
  ` pi.registerTool(workflow.createRunWorkflowTool(getParentModel));\n` +
226
226
  ` }\n`;
227
+ // 密钥剥离守卫也从这里装:Desktop App 的 pi 跑在 pi-web-ui 子进程里,没有
228
+ // 启动器代码,只有扩展能替它把 U1S1_API_KEY / U1S1_EP_KEY_* 挡在子进程之外
229
+ const secretEnvUrl = new URL("./secret-env.js", import.meta.url).href;
227
230
  writeFileSync(join(dir, "u1s1-tools.js"), `// 由 u1s1 每次启动自动生成,请勿手改\n` +
228
231
  `export default async function (pi) {\n` +
232
+ ` (await import(${JSON.stringify(secretEnvUrl)})).installChildEnvGuard();\n` +
229
233
  ` if (process.env.U1S1_TOOLS_VIA_EXTENSION !== "1") return;\n` +
230
234
  ` const baseUrl = process.env.U1S1_SIGNING_PROXY_URL || ${JSON.stringify(cfg.baseUrl)};\n` +
231
235
  ` const tools = await import(${JSON.stringify(toolsUrl)});\n` +
@@ -413,8 +417,10 @@ export default async function (pi) {
413
417
  }
414
418
 
415
419
  const cwd = process.cwd();
420
+ // 模型跑的 shell 命令拿不到 U1S1_API_KEY / U1S1_EP_KEY_* 等密钥(进程级守卫之外的显式一层)
421
+ const { stripSecretEnv } = await import(${JSON.stringify(new URL("./secret-env.js", import.meta.url).href)});
416
422
  hideTool("read", createReadTool(cwd));
417
- hideTool("bash", createBashTool(cwd));
423
+ hideTool("bash", createBashTool(cwd, { spawnHook: (spawnContext) => ({ ...spawnContext, env: stripSecretEnv(spawnContext.env) }) }));
418
424
  hideTool("edit", createEditTool(cwd));
419
425
  hideTool("write", createWriteTool(cwd));
420
426
  hideTool("grep", createGrepTool(cwd));
@@ -494,12 +500,14 @@ export function toProviderModels(models) {
494
500
  * 字面量 "local" —— pi 没有凭据会直接拒启("No API key found"),
495
501
  * 这是 pi 自带 llama provider 的同款兜底,本地服务会忽略 Authorization 头。
496
502
  */
497
- export function endpointProviderEntry(ep) {
503
+ export function endpointProviderEntry(ep, options = {}) {
504
+ // 进程内 registerProvider 可以直接给字面量(不落文件);写 models.json 时仍走 $VAR 引用
505
+ const envRef = `$${endpointKeyEnvName(ep.id)}`;
498
506
  return {
499
507
  name: ep.name,
500
508
  baseUrl: ep.baseUrl,
501
509
  api: ep.api,
502
- apiKey: ep.apiKey ? `$${endpointKeyEnvName(ep.id)}` : "local",
510
+ apiKey: ep.apiKey ? (options.literalKey ? ep.apiKey : envRef) : "local",
503
511
  models: toProviderModels(ep.models),
504
512
  };
505
513
  }
@@ -1,4 +1,4 @@
1
- import { fetchModels } from "./api.js";
1
+ import { fetchLatestAnnouncement } from "./api.js";
2
2
  import { loadConfig } from "./config.js";
3
3
  import { hasDeviceCredential } from "./device-auth.js";
4
4
  /**
@@ -34,7 +34,8 @@ export function startAnnouncementPoll(deps) {
34
34
  const cfg = loadConfig();
35
35
  if (!hasDeviceCredential(cfg))
36
36
  throw new Error("not logged in");
37
- return fetchModels(cfg);
37
+ // 轻量公告接口,不再每 5 分钟拉整份模型目录(鉴权 + 主库写 + 目录构建)
38
+ return fetchLatestAnnouncement(cfg);
38
39
  });
39
40
  const tracker = createAnnouncementTracker();
40
41
  const tick = async () => {
package/dist/api.d.ts CHANGED
@@ -64,6 +64,13 @@ export interface ModelsResponse {
64
64
  export declare class AuthError extends Error {
65
65
  }
66
66
  export declare function fetchModels(cfg: CliConfig): Promise<ModelsResponse>;
67
+ /**
68
+ * 会话内公告轮询用的轻量接口:免鉴权、服务端有缓存,回的就是 /v1/models 里的 announcement
69
+ * 字段。老网关没有这个路由(404)时回退整份 /v1/models。
70
+ */
71
+ export declare function fetchLatestAnnouncement(cfg: CliConfig): Promise<{
72
+ announcement?: ApiAnnouncement | null;
73
+ }>;
67
74
  export interface ApiEndpoint {
68
75
  id: string;
69
76
  name: string;
package/dist/api.js CHANGED
@@ -91,6 +91,31 @@ export async function fetchModels(cfg) {
91
91
  clientAttestationExpiresInSeconds: typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : undefined,
92
92
  };
93
93
  }
94
+ /**
95
+ * 会话内公告轮询用的轻量接口:免鉴权、服务端有缓存,回的就是 /v1/models 里的 announcement
96
+ * 字段。老网关没有这个路由(404)时回退整份 /v1/models。
97
+ */
98
+ export async function fetchLatestAnnouncement(cfg) {
99
+ let resp;
100
+ try {
101
+ resp = await fetch(new URL("/public/announcements/latest", cfg.baseUrl), {
102
+ headers: { "x-u1s1-version": VERSION, Accept: "application/json" },
103
+ signal: AbortSignal.timeout(15_000),
104
+ });
105
+ }
106
+ catch {
107
+ throw new Error(`连不上 ${cfg.baseUrl}`);
108
+ }
109
+ if (resp.status === 404)
110
+ return fetchModels(cfg);
111
+ if (!resp.ok)
112
+ throw new Error(`服务端返回 ${resp.status}`);
113
+ const body = jsonRecord(await readJsonResponseCapped(resp));
114
+ const announcement = body?.announcement;
115
+ return {
116
+ announcement: announcement && typeof announcement === "object" ? announcement : null,
117
+ };
118
+ }
94
119
  /**
95
120
  * 拉取用户在云端配置的自定义模型端点(dashboard「自定义模型端点」卡片)。
96
121
  * 老网关没有这个路由(GET 落到静态资产 404),调用方失败时回退本地缓存。
package/dist/config.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { webcrypto } from "node:crypto";
1
+ import { type webcrypto } from "node:crypto";
2
2
  export declare const VERSION: string;
3
3
  /**
4
4
  * 便携包安装(install.sh / install.ps1):包根旁边带自己的 node 运行时,
@@ -165,6 +165,11 @@ export declare function writeAgentDefaultModel(provider: string, modelId: string
165
165
  */
166
166
  export declare function resolvePreferredModel(cfg: Pick<CliConfig, "model" | "modelProvider">): ModelRef;
167
167
  export declare function loadConfig(): CliConfig;
168
+ /**
169
+ * 凭据文件落盘:先以 0600 写临时文件再 rename 覆盖,文件从不以默认权限
170
+ * (umask 下常是 0644)存在过哪怕一瞬;rename 在 POSIX 上原子替换。
171
+ */
172
+ export declare function writePrivateFile(path: string, content: string): void;
168
173
  export declare function saveConfig(cfg: CliConfig): void;
169
174
  /** Persist the user's preferred model to both stores. */
170
175
  export declare function persistPreferredModel(cfg: CliConfig, provider: string, modelId: string): CliConfig;
package/dist/config.js CHANGED
@@ -1,4 +1,5 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { createRequire } from "node:module";
3
4
  import { homedir } from "node:os";
4
5
  import { basename, dirname, join } from "node:path";
@@ -297,10 +298,24 @@ export function loadConfig() {
297
298
  modelProvider: typeof file.modelProvider === "string" ? file.modelProvider : undefined,
298
299
  };
299
300
  }
301
+ /**
302
+ * 凭据文件落盘:先以 0600 写临时文件再 rename 覆盖,文件从不以默认权限
303
+ * (umask 下常是 0644)存在过哪怕一瞬;rename 在 POSIX 上原子替换。
304
+ */
305
+ export function writePrivateFile(path, content) {
306
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
307
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
308
+ writeFileSync(tmp, content, { mode: 0o600 });
309
+ try {
310
+ renameSync(tmp, path);
311
+ }
312
+ catch (e) {
313
+ rmSync(tmp, { force: true });
314
+ throw e;
315
+ }
316
+ }
300
317
  export function saveConfig(cfg) {
301
- mkdirSync(u1s1Dir, { recursive: true, mode: 0o700 });
302
- writeFileSync(configFile, JSON.stringify(cfg, null, 2) + "\n");
303
- chmodSync(configFile, 0o600);
318
+ writePrivateFile(configFile, JSON.stringify(cfg, null, 2) + "\n");
304
319
  }
305
320
  /** Persist the user's preferred model to both stores. */
306
321
  export function persistPreferredModel(cfg, provider, modelId) {
@@ -312,9 +327,7 @@ export function persistPreferredModel(cfg, provider, modelId) {
312
327
  // ---- 端点本地缓存:离线/网关不可达时沿用上次拉到的配置(含密钥,0600)----
313
328
  const endpointsCacheFile = join(u1s1Dir, "endpoints.json");
314
329
  export function saveEndpointsCache(endpoints) {
315
- mkdirSync(u1s1Dir, { recursive: true, mode: 0o700 });
316
- writeFileSync(endpointsCacheFile, JSON.stringify(endpoints, null, 2) + "\n");
317
- chmodSync(endpointsCacheFile, 0o600);
330
+ writePrivateFile(endpointsCacheFile, JSON.stringify(endpoints, null, 2) + "\n");
318
331
  }
319
332
  export function loadEndpointsCache() {
320
333
  const raw = readJsonFile(endpointsCacheFile);
@@ -9,6 +9,14 @@ export declare function generateDeviceKeyPair(): Promise<{
9
9
  export declare function dpopHeaders(cfg: CliConfig, method: string, url: string): Promise<Record<string, string>>;
10
10
  /** Fetch a gateway route with a fresh proof; generic keys remain a read-only compatibility fallback. */
11
11
  export declare function authorizedFetch(cfg: CliConfig, input: string | URL, init?: RequestInit): Promise<Response>;
12
+ /**
13
+ * Headers to replay on the loopback response. fetch() has already decoded the
14
+ * upstream body (gzip/br), so the upstream framing and content-encoding must
15
+ * not be forwarded: replaying `content-encoding` made the local SDK inflate
16
+ * plain JSON and surface every non-streaming error as `<status> terminated`
17
+ * (zlib "incorrect header check"), hiding the real message.
18
+ */
19
+ export declare function forwardedResponseHeaders(upstream: Headers): Record<string, string>;
12
20
  /** Buffer a loopback request only up to the same JSON limit enforced by Gateway. */
13
21
  export declare function readSigningProxyRequestBody(request: IncomingMessage, maxBytes?: number): Promise<Buffer | undefined>;
14
22
  /**
@@ -80,6 +80,22 @@ export async function authorizedFetch(cfg, input, init = {}) {
80
80
  }
81
81
  return fetch(url, { ...init, headers });
82
82
  }
83
+ /**
84
+ * Headers to replay on the loopback response. fetch() has already decoded the
85
+ * upstream body (gzip/br), so the upstream framing and content-encoding must
86
+ * not be forwarded: replaying `content-encoding` made the local SDK inflate
87
+ * plain JSON and surface every non-streaming error as `<status> terminated`
88
+ * (zlib "incorrect header check"), hiding the real message.
89
+ */
90
+ export function forwardedResponseHeaders(upstream) {
91
+ const headers = {};
92
+ upstream.forEach((value, name) => {
93
+ if (!["content-length", "transfer-encoding", "connection", "content-encoding"].includes(name)) {
94
+ headers[name] = value;
95
+ }
96
+ });
97
+ return headers;
98
+ }
83
99
  function requestHeaders(input) {
84
100
  const headers = new Headers();
85
101
  for (const [name, value] of Object.entries(input)) {
@@ -247,12 +263,7 @@ export async function ensureSigningProxy(cfg, fallbackClient = "terminal", attes
247
263
  body,
248
264
  signal: upstreamAbort.signal,
249
265
  });
250
- const headers = {};
251
- upstream.headers.forEach((value, name) => {
252
- if (!["content-length", "transfer-encoding", "connection"].includes(name))
253
- headers[name] = value;
254
- });
255
- res.writeHead(upstream.status, headers);
266
+ res.writeHead(upstream.status, forwardedResponseHeaders(upstream.headers));
256
267
  if (upstream.body) {
257
268
  for await (const chunk of upstream.body)
258
269
  res.write(chunk);
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { ensureUsableShell } from "./shell-doctor.js";
11
11
  import { applyBrandUi, setAnnouncement, setUpdateNotice } from "./style.js";
12
12
  import { AuthError, fetchModels, loadCustomEndpoints, readJsonResponseCapped } from "./api.js";
13
13
  import { ensureSigningProxy } from "./device-auth.js";
14
+ import { installChildEnvGuard } from "./secret-env.js";
14
15
  const PACKAGE_NAME = "u1s1-cli";
15
16
  /** 启动时检测到的可自动安装的新版;TUI 退出后才装(见 installPendingUpdate)。 */
16
17
  let pendingUpdate;
@@ -145,9 +146,18 @@ function ensureTmuxKeyboardProtocol() {
145
146
  runTmux(["bind-key", "-n", "S-Enter", "send-keys", "-l", "\x1b[13;2u"]);
146
147
  runTmux(["bind-key", "-n", "C-Enter", "send-keys", "-l", "\x1b[13;5u"]);
147
148
  }
149
+ /** 取模型列表的请求发出后、发起 pi import 前的让路时间(见 runAgent 内注释)。 */
150
+ const AGENT_IMPORT_DEFER_MS = 200;
148
151
  async function runAgent(cfg, args) {
149
152
  cleanupBrandThemes();
150
153
  ensureDefaultSettings();
154
+ // pi 在 import/初始化阶段可能读到的常量环境变量先设好(import 在下面网络请求
155
+ // 发出之后才发起);依赖网络结果的变量(签名代理地址/密钥、端点密钥)仍在进 pi
156
+ // 之前才设置——pi 只在调用时读它们。
157
+ process.env["PI_CODING_AGENT_DIR"] = agentDir;
158
+ process.env["U1S1_TOOLS_VIA_EXTENSION"] = "1";
159
+ // hide pi's own "Run pi update" banner; users should run `u1s1 update` instead
160
+ process.env["PI_SKIP_VERSION_CHECK"] = "1";
151
161
  // Windows shell 体检异步先行,与取模型列表/搜索工具下载并行;首启验活
152
162
  // (Defender 首扫 bash.exe 可达秒级)藏进网络等待,进 pi 前再 await
153
163
  const shellReady = ensureUsableShell();
@@ -163,9 +173,18 @@ async function runAgent(cfg, args) {
163
173
  // 老网关没有 /v1/image,image_gen 缺失时按关闭处理,不注册生图工具
164
174
  let imageGenEnabled = false;
165
175
  const endpointsReady = loadCustomEndpoints(cfg);
176
+ const modelsReady = fetchModels(cfg);
177
+ modelsReady.catch(() => { });
178
+ // pi 的模块图 import 要 300~800 ms,且 ESM 求值是同步的、会把事件循环卡住;
179
+ // 若在请求发出前就 import,请求要等它求值完才真正上线,重叠白做(实测总耗时不变)。
180
+ // 先让 DNS/TLS/写出在事件循环里推进一段,再发起 import,让它落在等服务端应答的空档里。
181
+ await new Promise((resolve) => setTimeout(resolve, AGENT_IMPORT_DEFER_MS));
182
+ const agentReady = import("@earendil-works/pi-coding-agent");
183
+ // 失败留到下面 await 时再抛;这里只是避免在等网络期间被当成未处理的 rejection。
184
+ agentReady.catch(() => { });
166
185
  let modelsResp;
167
186
  try {
168
- modelsResp = await fetchModels(cfg);
187
+ modelsResp = await modelsReady;
169
188
  }
170
189
  catch (e) {
171
190
  if (e instanceof AuthError) {
@@ -228,19 +247,18 @@ async function runAgent(cfg, args) {
228
247
  // OpenRouter 应用归因:直连流量计入 u1s1 的公开排行
229
248
  writeAttributionExtension();
230
249
  ensureTmuxKeyboardProtocol();
231
- // must be set before pi reads them (getAgentDir() reads at call time, env at import is fine too)
232
- process.env["PI_CODING_AGENT_DIR"] = agentDir;
250
+ // must be set before pi reads them (getAgentDir() reads at call time);
251
+ // 常量部分已在 runAgent 开头、发起 import 之前设好
233
252
  process.env["U1S1_API_KEY"] = officialCfg.apiKey;
234
253
  process.env["U1S1_SIGNING_PROXY_URL"] = officialCfg.baseUrl;
235
- process.env["U1S1_TOOLS_VIA_EXTENSION"] = "1";
236
254
  // 自定义端点的密钥走环境变量引用(models.json 里只有 $VAR,不落明文)
237
255
  Object.assign(process.env, endpointKeyEnv());
238
256
  // 剔掉环境里的其他厂商密钥,/model 只展示 u1s1 官方模型 + 自定义端点
239
257
  scrubForeignProviderEnv();
240
- // hide pi's own "Run pi update" banner; users should run `u1s1 update` instead
241
- process.env["PI_SKIP_VERSION_CHECK"] = "1";
258
+ // 密钥只留在本进程:模型跑的 shell 命令、子 agent、扩展派生的任何子进程都拿不到
259
+ installChildEnvGuard();
242
260
  await searchToolsReady;
243
- const { CustomEditor, main } = await import("@earendil-works/pi-coding-agent");
261
+ const { CustomEditor, main } = await agentReady;
244
262
  // 网页终端(如 Taikula)把 Shift+Enter 发成 ESC+CR(\x1b\r)。pi 在没开 Kitty
245
263
  // 协议时把它当成 Alt+Enter:空闲就直接发送,忙碌才排队。先改成 CSI-u 的
246
264
  // Shift+Enter,输入框就会换行,真·Alt+Enter(\x1b[13;3u)不受影响。
@@ -347,16 +365,17 @@ async function runAgent(cfg, args) {
347
365
  });
348
366
  },
349
367
  });
368
+ // 进程内注册直接给字面量密钥:不经 $VAR 环境变量引用,也不落文件
350
369
  pi.registerProvider(PROVIDER_ID, {
351
370
  name: "u1s1",
352
371
  baseUrl: officialCfg.baseUrl,
353
372
  api: "openai-completions",
354
- apiKey: "$U1S1_API_KEY",
373
+ apiKey: officialCfg.apiKey,
355
374
  models: toProviderModels(MODELS),
356
375
  });
357
376
  // 用户在云端配置的自定义端点,一个端点一个 provider,/model 里即可切换
358
377
  for (const ep of CUSTOM_ENDPOINTS) {
359
- pi.registerProvider(ep.id, endpointProviderEntry(ep));
378
+ pi.registerProvider(ep.id, endpointProviderEntry(ep, { literalKey: true }));
360
379
  }
361
380
  // /model and Ctrl+P already write pi settings; also keep ~/.u1s1/config.json in sync
362
381
  pi.on("model_select", (event) => {
@@ -0,0 +1,14 @@
1
+ export declare function isSecretEnvName(name: string): boolean;
2
+ /** 返回去掉密钥变量的环境副本;传入对象本身不动。 */
3
+ export declare function stripSecretEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
4
+ /**
5
+ * 把 child_process 各函数的实参改写成「env 已剥离密钥」的版本:已有 options
6
+ * 就替换其 env(缺省 env 视为 process.env);没有 options 就在 args 数组之后、
7
+ * 回调之前插一个。签名族:fn(cmd[, args][, options][, callback])。
8
+ */
9
+ export declare function withStrippedEnvArgs(args: unknown[]): unknown[];
10
+ /**
11
+ * 给 node:child_process 装上密钥剥离守卫(幂等)。ESM 的具名导入是活绑定,
12
+ * syncBuiltinESMExports 之后 `import { spawn } from "child_process"` 也拿到守卫版。
13
+ */
14
+ export declare function installChildEnvGuard(): void;
@@ -0,0 +1,90 @@
1
+ import childProcess from "node:child_process";
2
+ import { syncBuiltinESMExports } from "node:module";
3
+ import { promisify } from "node:util";
4
+ /**
5
+ * 只该留在 u1s1 进程内的凭据类环境变量:官方 key(TUI 里是本地签名代理的
6
+ * 一次性 bearer)、云端自定义端点的第三方长期 key、设备令牌与设备私钥。
7
+ * pi 通过 models.json / auth.json 里的 `$VAR` 引用在每次请求时从 process.env
8
+ * 解析它们,所以进程内必须保留;但模型执行的任何 shell 命令都不该看见 ——
9
+ * prompt injection 跑一句 `env` 或项目 `postinstall` 就能把它们外传。
10
+ *
11
+ * 非密钥的 U1S1_* 配置(签名代理地址、工具开关、子 agent 标记、BASE_URL、
12
+ * CLIENT、MODELS_PATH、设备公钥)照常继承:脚本可能正当依赖它们,且单独
13
+ * 拿到也换不来任何权限(代理地址没有 bearer 打不通)。
14
+ */
15
+ const SECRET_ENV_NAMES = new Set(["U1S1_API_KEY", "U1S1_DEVICE_TOKEN", "U1S1_DEVICE_PRIVATE_JWK"]);
16
+ const SECRET_ENV_PREFIXES = ["U1S1_EP_KEY_"];
17
+ export function isSecretEnvName(name) {
18
+ return SECRET_ENV_NAMES.has(name) || SECRET_ENV_PREFIXES.some((prefix) => name.startsWith(prefix));
19
+ }
20
+ /** 返回去掉密钥变量的环境副本;传入对象本身不动。 */
21
+ export function stripSecretEnv(env = process.env) {
22
+ const out = {};
23
+ for (const [key, value] of Object.entries(env)) {
24
+ if (!isSecretEnvName(key))
25
+ out[key] = value;
26
+ }
27
+ return out;
28
+ }
29
+ const GUARDED_METHODS = ["spawn", "spawnSync", "exec", "execSync", "execFile", "execFileSync", "fork"];
30
+ const GUARD_MARK = Symbol.for("u1s1.childEnvGuard");
31
+ function isOptionsObject(value) {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !Buffer.isBuffer(value);
33
+ }
34
+ /**
35
+ * 把 child_process 各函数的实参改写成「env 已剥离密钥」的版本:已有 options
36
+ * 就替换其 env(缺省 env 视为 process.env);没有 options 就在 args 数组之后、
37
+ * 回调之前插一个。签名族:fn(cmd[, args][, options][, callback])。
38
+ */
39
+ export function withStrippedEnvArgs(args) {
40
+ const next = [...args];
41
+ for (let i = 1; i < next.length; i++) {
42
+ const candidate = next[i];
43
+ if (!isOptionsObject(candidate))
44
+ continue;
45
+ const env = candidate["env"];
46
+ next[i] = { ...candidate, env: stripSecretEnv(isOptionsObject(env) ? env : process.env) };
47
+ return next;
48
+ }
49
+ const at = Array.isArray(next[1]) ? 2 : 1;
50
+ next.splice(at, 0, { env: stripSecretEnv() });
51
+ return next;
52
+ }
53
+ function isGuarded(fn) {
54
+ return fn[GUARD_MARK] === true;
55
+ }
56
+ /** exec/execFile 的 util.promisify 定制:必须经过守卫版本,否则 promisify 会绕回原函数。 */
57
+ function promisifiedThrough(fn) {
58
+ return (...args) => new Promise((resolve, reject) => {
59
+ fn(...args, (error, stdout, stderr) => {
60
+ if (error) {
61
+ error.stdout = stdout;
62
+ error.stderr = stderr;
63
+ reject(error);
64
+ return;
65
+ }
66
+ resolve({ stdout, stderr });
67
+ });
68
+ });
69
+ }
70
+ /**
71
+ * 给 node:child_process 装上密钥剥离守卫(幂等)。ESM 的具名导入是活绑定,
72
+ * syncBuiltinESMExports 之后 `import { spawn } from "child_process"` 也拿到守卫版。
73
+ */
74
+ export function installChildEnvGuard() {
75
+ const cp = childProcess;
76
+ for (const name of GUARDED_METHODS) {
77
+ const original = cp[name];
78
+ if (!original || isGuarded(original))
79
+ continue;
80
+ const guarded = function (...args) {
81
+ return original.apply(this, withStrippedEnvArgs(args));
82
+ };
83
+ Object.defineProperty(guarded, GUARD_MARK, { value: true });
84
+ if (name === "exec" || name === "execFile") {
85
+ Object.defineProperty(guarded, promisify.custom, { value: promisifiedThrough(guarded) });
86
+ }
87
+ cp[name] = guarded;
88
+ }
89
+ syncBuiltinESMExports();
90
+ }
@@ -19,7 +19,23 @@ export interface SubagentOptions {
19
19
  noTools?: boolean;
20
20
  /** 子 agent 的工作目录(git worktree 隔离等场景);缺省继承主进程 cwd。 */
21
21
  cwd?: string;
22
+ /**
23
+ * 主会话对项目的信任裁决(ctx.isProjectTrusted());缺省按 trust.json 非交互
24
+ * 裁决。子 agent 绝不能默认可信:主会话拒绝过的 .pi/extensions 不能被绕过。
25
+ */
26
+ projectTrusted?: boolean;
27
+ /** 信任裁决依据的目录(worktree 隔离时是源仓库,不是临时工作树);缺省 cwd。 */
28
+ trustCwd?: string;
22
29
  }
30
+ /** 工具 execute 的 ctx 里的信任裁决;老版 pi 没这个方法时返回 undefined 走存档兜底。 */
31
+ export declare function contextProjectTrust(ctx: {
32
+ isProjectTrusted?: () => boolean;
33
+ } | undefined): boolean | undefined;
34
+ /**
35
+ * 与 pi 主会话同一口径的非交互裁决:没有需要信任门的项目资源(.pi/extensions、
36
+ * .agents/skills 等)即视为可信;有则只认 trust.json 里的明确「信任」,从不弹窗。
37
+ */
38
+ export declare function resolveStoredProjectTrust(cwd: string, agentDir?: string): boolean;
23
39
  /** 单个子 agent 的 token/费用用量(汇总自会话内全部 assistant 消息)。 */
24
40
  export interface SubagentUsage {
25
41
  totalTokens: number;
package/dist/subagent.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createAgentSession, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
1
+ import { createAgentSession, DefaultResourceLoader, getAgentDir, hasTrustRequiringProjectResources, ModelRuntime, ProjectTrustStore, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
2
2
  // ---- 子 agent spawn 基础设施:spawn_subagent 工具与 workflow runner 共用 ----
3
3
  /** 同时在跑的子 agent 上限;超额排队,防止打爆网关。 */
4
4
  export const SUBAGENT_CONCURRENCY = 4;
@@ -6,6 +6,19 @@ 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
+ /** 工具 execute 的 ctx 里的信任裁决;老版 pi 没这个方法时返回 undefined 走存档兜底。 */
10
+ export function contextProjectTrust(ctx) {
11
+ return typeof ctx?.isProjectTrusted === "function" ? ctx.isProjectTrusted() : undefined;
12
+ }
13
+ /**
14
+ * 与 pi 主会话同一口径的非交互裁决:没有需要信任门的项目资源(.pi/extensions、
15
+ * .agents/skills 等)即视为可信;有则只认 trust.json 里的明确「信任」,从不弹窗。
16
+ */
17
+ export function resolveStoredProjectTrust(cwd, agentDir = getAgentDir()) {
18
+ if (!hasTrustRequiringProjectResources(cwd))
19
+ return true;
20
+ return new ProjectTrustStore(agentDir).get(cwd) === true;
21
+ }
9
22
  function cancelledError(signal) {
10
23
  return new Error("已取消", { cause: signal.reason });
11
24
  }
@@ -121,9 +134,15 @@ export async function runSubagent(opts) {
121
134
  }
122
135
  // 子会话不加载精简 UI 扩展:它用 process.cwd() 重建内置工具,会破坏自定义
123
136
  // cwd(worktree 隔离)下的路径解析;且 UI 美化对无界面的子 agent 毫无意义
137
+ const cwd = opts.cwd ?? process.cwd();
138
+ const agentDir = getAgentDir();
139
+ // 项目信任跟随主会话:SettingsManager 缺省 projectTrusted=true,会把主会话
140
+ // 拒绝过的 .pi/extensions 在子 agent 里悄悄加载执行
141
+ const projectTrusted = opts.projectTrusted ?? resolveStoredProjectTrust(opts.trustCwd ?? cwd, agentDir);
124
142
  const loader = new DefaultResourceLoader({
125
- agentDir: getAgentDir(),
126
- cwd: opts.cwd ?? process.cwd(),
143
+ agentDir,
144
+ cwd,
145
+ settingsManager: SettingsManager.create(cwd, agentDir, { projectTrusted }),
127
146
  extensionsOverride: (base) => ({
128
147
  ...base,
129
148
  extensions: base.extensions.filter((e) => !JSON.stringify(e).includes("u1s1-compact-ui")),
package/dist/tools.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname, extname, resolve } from "node:path";
3
3
  import { defineTool } from "@earendil-works/pi-coding-agent";
4
- import { runPool, runSubagent, SUBAGENT_CONCURRENCY, SUBAGENT_TIMEOUT_MS } from "./subagent.js";
4
+ import { contextProjectTrust, runPool, runSubagent, SUBAGENT_CONCURRENCY, SUBAGENT_TIMEOUT_MS, } from "./subagent.js";
5
5
  import { Text } from "@earendil-works/pi-tui";
6
6
  import { Type } from "typebox";
7
7
  import { generateImage, renderPage, searchWeb } from "./api.js";
@@ -204,7 +204,9 @@ export function createSubagentTool(getParentModel) {
204
204
  : "🤖 子任务";
205
205
  return compactResultRender({ result, options, theme, context, summaryLine: summary });
206
206
  },
207
- async execute(_toolCallId, params, signal) {
207
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
208
+ // 子 agent 沿用主会话的项目信任裁决,不能默认可信
209
+ const projectTrusted = contextProjectTrust(ctx);
208
210
  const list = (params.tasks?.length ? params.tasks : params.task ? [params.task] : [])
209
211
  .map((t) => t.trim())
210
212
  .filter(Boolean)
@@ -224,7 +226,7 @@ export function createSubagentTool(getParentModel) {
224
226
  items: list,
225
227
  limit: SUBAGENT_CONCURRENCY,
226
228
  run: async (task, i) => {
227
- outcomes[i] = await runSubagent({ task, parentModel, model: params.model, timeoutMs, signal }).catch((e) => ({ ok: false, text: e.message, usage: { totalTokens: 0, costUsd: 0 } }));
229
+ outcomes[i] = await runSubagent({ task, parentModel, model: params.model, timeoutMs, signal, projectTrusted }).catch((e) => ({ ok: false, text: e.message, usage: { totalTokens: 0, costUsd: 0 } }));
228
230
  },
229
231
  signal,
230
232
  });
package/dist/update.d.ts CHANGED
@@ -5,4 +5,20 @@ export declare function detectPackageManager(): string;
5
5
  /** Fetch the latest published version from npm registry. */
6
6
  export declare function getLatestVersion(): Promise<string | undefined>;
7
7
  export declare function compareVersions(a: string, b: string): number;
8
+ /** 从固定发布源拉一个文本文件:强制 https、拒绝重定向、限长。 */
9
+ export declare function fetchReleaseText(name: string, maxBytes: number, timeoutMs?: number): Promise<string>;
10
+ /** 在 sha256sum 格式的清单里找某个文件的哈希("<hex> <name>" 或 "<hex> *<name>")。 */
11
+ export declare function findPublishedSha256(sums: string, name: string): string | undefined;
12
+ export type InstallScriptCheck = "verified" | "unlisted" | "mismatch";
13
+ /** 拿发布清单核对脚本正文;清单没列脚本(旧版发布)返回 unlisted,由调用方决定是否放行。 */
14
+ export declare function verifyInstallScript(script: string, sums: string, name?: string): InstallScriptCheck;
15
+ /**
16
+ * 下载安装脚本并对照随发布上传的 SHA256SUMS。清单拉不到或哈希不符都拒绝执行;
17
+ * 清单里没有脚本条目只可能是旧版发布,放行但告知调用方(verified=false)。
18
+ * 这只是同源完整性校验(防边缘缓存残缺/单文件被换),不是代码签名。
19
+ */
20
+ export declare function downloadInstallScript(): Promise<{
21
+ script: string;
22
+ verified: boolean;
23
+ }>;
8
24
  export declare function update(): Promise<void>;
package/dist/update.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
4
  import { createRequire } from "node:module";
4
5
  import { tmpdir } from "node:os";
@@ -64,6 +65,52 @@ export function compareVersions(a, b) {
64
65
  }
65
66
  return 0;
66
67
  }
68
+ /** 发布件只认这一个 https 源;任何跳转(哪怕同域)都视为异常,不跟。 */
69
+ const RELEASE_ORIGIN = "https://u1s1.io";
70
+ const INSTALL_SCRIPT_NAME = "install.sh";
71
+ const MAX_SUMS_BYTES = 64 * 1024;
72
+ /** 从固定发布源拉一个文本文件:强制 https、拒绝重定向、限长。 */
73
+ export async function fetchReleaseText(name, maxBytes, timeoutMs = 30_000) {
74
+ const url = new URL(`/releases/${name}`, RELEASE_ORIGIN);
75
+ if (url.protocol !== "https:")
76
+ throw new Error(`拒绝非 https 发布源: ${url.href}`);
77
+ const res = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(timeoutMs) });
78
+ if (!res.ok)
79
+ throw new Error(`${name}: HTTP ${res.status}`);
80
+ return readResponseTextCapped(res, maxBytes);
81
+ }
82
+ /** 在 sha256sum 格式的清单里找某个文件的哈希("<hex> <name>" 或 "<hex> *<name>")。 */
83
+ export function findPublishedSha256(sums, name) {
84
+ for (const line of sums.split("\n")) {
85
+ const m = /^([0-9a-fA-F]{64})\s+\*?(.+?)\s*$/.exec(line.trim());
86
+ if (m && m[2] === name)
87
+ return m[1].toLowerCase();
88
+ }
89
+ return undefined;
90
+ }
91
+ /** 拿发布清单核对脚本正文;清单没列脚本(旧版发布)返回 unlisted,由调用方决定是否放行。 */
92
+ export function verifyInstallScript(script, sums, name = INSTALL_SCRIPT_NAME) {
93
+ const expected = findPublishedSha256(sums, name);
94
+ if (!expected)
95
+ return "unlisted";
96
+ const actual = createHash("sha256").update(script, "utf8").digest("hex");
97
+ return actual === expected ? "verified" : "mismatch";
98
+ }
99
+ /**
100
+ * 下载安装脚本并对照随发布上传的 SHA256SUMS。清单拉不到或哈希不符都拒绝执行;
101
+ * 清单里没有脚本条目只可能是旧版发布,放行但告知调用方(verified=false)。
102
+ * 这只是同源完整性校验(防边缘缓存残缺/单文件被换),不是代码签名。
103
+ */
104
+ export async function downloadInstallScript() {
105
+ const [script, sums] = await Promise.all([
106
+ fetchReleaseText(INSTALL_SCRIPT_NAME, MAX_INSTALL_SCRIPT_BYTES),
107
+ fetchReleaseText("SHA256SUMS", MAX_SUMS_BYTES, 15_000),
108
+ ]);
109
+ const check = verifyInstallScript(script, sums);
110
+ if (check === "mismatch")
111
+ throw new Error("安装脚本与发布清单的 SHA256 不符,已拒绝执行");
112
+ return { script, verified: check === "verified" };
113
+ }
67
114
  /**
68
115
  * 便携版原地升级:下载官网安装脚本并替用户执行,整包替换安装目录。
69
116
  *
@@ -102,15 +149,14 @@ async function portableSelfUpdate(latest) {
102
149
  console.log(`正在下载安装脚本并升级到 v${latest}…`);
103
150
  let script;
104
151
  try {
105
- const res = await fetch("https://u1s1.io/releases/install.sh", {
106
- signal: AbortSignal.timeout(30_000),
107
- });
108
- if (!res.ok)
109
- throw new Error(`HTTP ${res.status}`);
110
- script = await readResponseTextCapped(res, MAX_INSTALL_SCRIPT_BYTES);
152
+ const download = await downloadInstallScript();
153
+ script = download.script;
154
+ if (!download.verified) {
155
+ console.log("⚠ 发布清单里没有安装脚本的校验和(旧版发布),跳过脚本校验;安装包本身仍会校验。");
156
+ }
111
157
  }
112
- catch {
113
- console.error("安装脚本下载失败。请手动运行:");
158
+ catch (e) {
159
+ console.error(`安装脚本下载失败:${e.message}。请手动运行:`);
114
160
  console.error(" curl -fsSL https://u1s1.io/releases/install.sh | bash");
115
161
  process.exit(1);
116
162
  }
@@ -49,6 +49,8 @@ export interface WorkflowRunOptions {
49
49
  progressPath: string;
50
50
  resume?: boolean;
51
51
  parentModel: ParentModelRef;
52
+ /** 主会话的项目信任裁决;子 agent 与临时 worktree 都沿用它,缺省按 trust.json 非交互裁决。 */
53
+ projectTrusted?: boolean;
52
54
  signal?: AbortSignal;
53
55
  timeoutMs?: number;
54
56
  /** 整个 run 的 token 上限;缺省 WORKFLOW_DEFAULT_BUDGET_TOKENS,<=0 不限。 */
@@ -180,6 +180,9 @@ export async function runWorkflow(opts) {
180
180
  timeoutMs: PER_TASK_TIMEOUT_MS,
181
181
  signal: internal.signal,
182
182
  cwd: worktreeDir,
183
+ // 临时 worktree 永远不在 trust.json 里:信任裁决按源仓库(主进程 cwd)算
184
+ projectTrusted: opts.projectTrusted,
185
+ trustCwd: process.cwd(),
183
186
  noTools: o.noTools === true,
184
187
  });
185
188
  stats.ok++;
@@ -1,5 +1,5 @@
1
1
  import { Type } from "typebox";
2
- import type { ParentModelRef } from "../subagent.js";
2
+ import { type ParentModelRef } from "../subagent.js";
3
3
  /**
4
4
  * run_workflow 工具:主 agent 把模型生成的编排脚本交给 WorkflowRunner 执行。
5
5
  * 脚本在 vm 沙箱里跑,只能用注入的 subagent/parallel/pipeline 原语。
@@ -3,6 +3,7 @@ import { dirname, resolve } from "node:path";
3
3
  import { defineTool } from "@earendil-works/pi-coding-agent";
4
4
  import { Text } from "@earendil-works/pi-tui";
5
5
  import { Type } from "typebox";
6
+ import { contextProjectTrust } from "../subagent.js";
6
7
  import { compactResultRender, truncate } from "../tools.js";
7
8
  import { runWorkflow, saveWorkflowScript, validateScript, workflowsDir, WORKFLOW_DEFAULT_BUDGET_TOKENS, WORKFLOW_TIMEOUT_MS, } from "./runner.js";
8
9
  import { buildFromTemplate, TEMPLATES } from "./templates.js";
@@ -131,7 +132,7 @@ export function createRunWorkflowTool(getParentModel) {
131
132
  return compactResultRender({ result, options, theme, context, summaryLine: summary });
132
133
  },
133
134
  async execute(...args) {
134
- const [, params, signal, onUpdate] = args;
135
+ const [, params, signal, onUpdate, ctx] = args;
135
136
  const source = resolveWorkflowSource(params);
136
137
  const scriptPath = persistWorkflowSource(source);
137
138
  const progressPath = scriptPath.replace(/\.mjs$/, ".progress.jsonl");
@@ -159,6 +160,8 @@ export function createRunWorkflowTool(getParentModel) {
159
160
  progressPath,
160
161
  resume: params.resume === true,
161
162
  parentModel: getParentModel(),
163
+ // 子 agent(含临时 worktree)沿用主会话的项目信任裁决
164
+ projectTrusted: contextProjectTrust(ctx),
162
165
  signal,
163
166
  timeoutMs: Math.min(180, params.timeout_minutes ?? WORKFLOW_TIMEOUT_MS / 60_000) * 60_000,
164
167
  budgetTokens: params.budget_tokens,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {