u1s1-cli 1.4.0 → 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.
- package/dist/agent-setup.d.ts +12 -2
- package/dist/agent-setup.js +56 -5
- package/dist/announcements-poll.d.ts +22 -0
- package/dist/announcements-poll.js +59 -0
- package/dist/api.d.ts +9 -17
- package/dist/api.js +25 -0
- package/dist/config.d.ts +19 -8
- package/dist/config.js +26 -11
- package/dist/device-auth.d.ts +8 -0
- package/dist/device-auth.js +17 -6
- package/dist/index.js +31 -10
- package/dist/model.js +5 -6
- package/dist/secret-env.d.ts +14 -0
- package/dist/secret-env.js +90 -0
- package/dist/subagent.d.ts +16 -0
- package/dist/subagent.js +22 -3
- package/dist/tools.js +5 -3
- package/dist/update.d.ts +16 -0
- package/dist/update.js +54 -8
- package/dist/workflow/runner.d.ts +2 -0
- package/dist/workflow/runner.js +3 -0
- package/dist/workflow/tool.d.ts +1 -1
- package/dist/workflow/tool.js +4 -1
- package/package.json +1 -1
package/dist/agent-setup.d.ts
CHANGED
|
@@ -41,6 +41,14 @@ export declare function writeWebToolsExtension(cfg: CliConfig, features: {
|
|
|
41
41
|
* 宁可回到原始错误文本,也不能让扩展报错刷英文堆栈。
|
|
42
42
|
*/
|
|
43
43
|
export declare function writeErrorHumanizeExtension(): void;
|
|
44
|
+
/**
|
|
45
|
+
* 生成「会话内公告触达」扩展到 <agentDir>/extensions/u1s1-announcements.js:
|
|
46
|
+
* 启动横幅只渲染一次公告,维护窗口里挂着会话的用户只会撞裸错误(审计 F14)。
|
|
47
|
+
* 轮询逻辑在 announcements-poll.ts(5 分钟一次、首拉只建基线不打扰);
|
|
48
|
+
* 公告变化时 appendEntry 进会话 + 尽力弹 notify。TUI 和 Desktop App 共用。
|
|
49
|
+
* print/json 一次性模式不起轮询;import 失败静默跳过(同 error-humanize)。
|
|
50
|
+
*/
|
|
51
|
+
export declare function writeAnnouncementsExtension(): void;
|
|
44
52
|
/**
|
|
45
53
|
* 生成「OpenRouter 应用归因」扩展到 <agentDir>/extensions/u1s1-attribution.js。
|
|
46
54
|
* 用户自己配 OPENROUTER_API_KEY 直连时,pi 内置的归因头归到 pi.dev 名下(且受遥测
|
|
@@ -71,7 +79,7 @@ export declare function toProviderModels(models: ModelDef[]): {
|
|
|
71
79
|
reasoning: boolean;
|
|
72
80
|
thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
|
|
73
81
|
input: ("text" | "image")[];
|
|
74
|
-
cost: ModelDef["cost"]
|
|
82
|
+
cost: NonNullable<ModelDef["cost"]>;
|
|
75
83
|
contextWindow: number;
|
|
76
84
|
maxTokens: number;
|
|
77
85
|
compat?: {
|
|
@@ -89,7 +97,9 @@ export declare function toProviderModels(models: ModelDef[]): {
|
|
|
89
97
|
* 字面量 "local" —— pi 没有凭据会直接拒启("No API key found"),
|
|
90
98
|
* 这是 pi 自带 llama provider 的同款兜底,本地服务会忽略 Authorization 头。
|
|
91
99
|
*/
|
|
92
|
-
export declare function endpointProviderEntry(ep: CustomEndpoint
|
|
100
|
+
export declare function endpointProviderEntry(ep: CustomEndpoint, options?: {
|
|
101
|
+
literalKey?: boolean;
|
|
102
|
+
}): Record<string, unknown>;
|
|
93
103
|
/**
|
|
94
104
|
* 生成 /workflow 提示词模板到 <agentDir>/prompts/workflow.md:引导主 agent
|
|
95
105
|
* 拆解任务 → 生成沙箱脚本 → 调 run_workflow 执行。每次启动重写,便于随版迭代文案。
|
package/dist/agent-setup.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import { agentDir, CUSTOM_ENDPOINTS, ENDPOINT_ID_RE, endpointKeyEnvName, MODELS, PROVIDER_ID, VERSION, } from "./config.js";
|
|
4
|
+
import { agentDir, CUSTOM_ENDPOINTS, ENDPOINT_ID_RE, endpointKeyEnvName, MODELS, PROVIDER_ID, UNKNOWN_MODEL_COST, VERSION, } from "./config.js";
|
|
5
5
|
const BRAND_APPEND = `## u1s1
|
|
6
6
|
|
|
7
7
|
You are u1s1 — a plain-spoken AI coding buddy for programming beginners. Most users are unfamiliar with jargon:
|
|
@@ -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` +
|
|
@@ -264,6 +268,49 @@ export function writeErrorHumanizeExtension() {
|
|
|
264
268
|
` });\n` +
|
|
265
269
|
`}\n`);
|
|
266
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* 生成「会话内公告触达」扩展到 <agentDir>/extensions/u1s1-announcements.js:
|
|
273
|
+
* 启动横幅只渲染一次公告,维护窗口里挂着会话的用户只会撞裸错误(审计 F14)。
|
|
274
|
+
* 轮询逻辑在 announcements-poll.ts(5 分钟一次、首拉只建基线不打扰);
|
|
275
|
+
* 公告变化时 appendEntry 进会话 + 尽力弹 notify。TUI 和 Desktop App 共用。
|
|
276
|
+
* print/json 一次性模式不起轮询;import 失败静默跳过(同 error-humanize)。
|
|
277
|
+
*/
|
|
278
|
+
export function writeAnnouncementsExtension() {
|
|
279
|
+
const dir = join(agentDir, "extensions");
|
|
280
|
+
mkdirSync(dir, { recursive: true });
|
|
281
|
+
const url = new URL("./announcements-poll.js", import.meta.url).href;
|
|
282
|
+
writeFileSync(join(dir, "u1s1-announcements.js"), `// 由 u1s1 每次启动自动生成,请勿手改\n` +
|
|
283
|
+
`export default async function (pi) {\n` +
|
|
284
|
+
` let startAnnouncementPoll;\n` +
|
|
285
|
+
` try {\n` +
|
|
286
|
+
` startAnnouncementPoll = (await import(${JSON.stringify(url)})).startAnnouncementPoll;\n` +
|
|
287
|
+
` } catch {\n` +
|
|
288
|
+
` return;\n` +
|
|
289
|
+
` }\n` +
|
|
290
|
+
` const { Text } = await import("@earendil-works/pi-tui");\n` +
|
|
291
|
+
` pi.registerEntryRenderer("u1s1-announcement", (entry, _opts, theme) => {\n` +
|
|
292
|
+
` const d = entry.data ?? {};\n` +
|
|
293
|
+
` let text = theme.fg("text", theme.bold("📢 " + String(d.text ?? "")));\n` +
|
|
294
|
+
` if (d.url) text += "\\n" + theme.fg("dim", String(d.url));\n` +
|
|
295
|
+
` return new Text(text, 1, 0);\n` +
|
|
296
|
+
` });\n` +
|
|
297
|
+
` let ui;\n` +
|
|
298
|
+
` let started = false;\n` +
|
|
299
|
+
` pi.on("session_start", (_event, ctx) => {\n` +
|
|
300
|
+
` if (ctx.hasUI) ui = ctx.ui;\n` +
|
|
301
|
+
` if (started || ctx.mode === "print" || ctx.mode === "json") return;\n` +
|
|
302
|
+
` started = true;\n` +
|
|
303
|
+
` startAnnouncementPoll({\n` +
|
|
304
|
+
` onNew: (a) => {\n` +
|
|
305
|
+
` pi.appendEntry("u1s1-announcement", { text: a.text, url: a.url });\n` +
|
|
306
|
+
` try {\n` +
|
|
307
|
+
` ui?.notify("📢 " + a.text, "info");\n` +
|
|
308
|
+
` } catch {}\n` +
|
|
309
|
+
` },\n` +
|
|
310
|
+
` });\n` +
|
|
311
|
+
` });\n` +
|
|
312
|
+
`}\n`);
|
|
313
|
+
}
|
|
267
314
|
/**
|
|
268
315
|
* 生成「OpenRouter 应用归因」扩展到 <agentDir>/extensions/u1s1-attribution.js。
|
|
269
316
|
* 用户自己配 OPENROUTER_API_KEY 直连时,pi 内置的归因头归到 pi.dev 名下(且受遥测
|
|
@@ -370,8 +417,10 @@ export default async function (pi) {
|
|
|
370
417
|
}
|
|
371
418
|
|
|
372
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)});
|
|
373
422
|
hideTool("read", createReadTool(cwd));
|
|
374
|
-
hideTool("bash", createBashTool(cwd));
|
|
423
|
+
hideTool("bash", createBashTool(cwd, { spawnHook: (spawnContext) => ({ ...spawnContext, env: stripSecretEnv(spawnContext.env) }) }));
|
|
375
424
|
hideTool("edit", createEditTool(cwd));
|
|
376
425
|
hideTool("write", createWriteTool(cwd));
|
|
377
426
|
hideTool("grep", createGrepTool(cwd));
|
|
@@ -438,7 +487,7 @@ export function toProviderModels(models) {
|
|
|
438
487
|
reasoning: m.reasoning,
|
|
439
488
|
thinkingLevelMap,
|
|
440
489
|
input: m.vision ? ["text", "image"] : ["text"],
|
|
441
|
-
cost: m.cost,
|
|
490
|
+
cost: m.cost ?? UNKNOWN_MODEL_COST,
|
|
442
491
|
contextWindow: m.contextWindow,
|
|
443
492
|
maxTokens: m.maxTokens,
|
|
444
493
|
compat,
|
|
@@ -451,12 +500,14 @@ export function toProviderModels(models) {
|
|
|
451
500
|
* 字面量 "local" —— pi 没有凭据会直接拒启("No API key found"),
|
|
452
501
|
* 这是 pi 自带 llama provider 的同款兜底,本地服务会忽略 Authorization 头。
|
|
453
502
|
*/
|
|
454
|
-
export function endpointProviderEntry(ep) {
|
|
503
|
+
export function endpointProviderEntry(ep, options = {}) {
|
|
504
|
+
// 进程内 registerProvider 可以直接给字面量(不落文件);写 models.json 时仍走 $VAR 引用
|
|
505
|
+
const envRef = `$${endpointKeyEnvName(ep.id)}`;
|
|
455
506
|
return {
|
|
456
507
|
name: ep.name,
|
|
457
508
|
baseUrl: ep.baseUrl,
|
|
458
509
|
api: ep.api,
|
|
459
|
-
apiKey: ep.apiKey ?
|
|
510
|
+
apiKey: ep.apiKey ? (options.literalKey ? ep.apiKey : envRef) : "local",
|
|
460
511
|
models: toProviderModels(ep.models),
|
|
461
512
|
};
|
|
462
513
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ApiAnnouncement } from "./api.js";
|
|
2
|
+
/**
|
|
3
|
+
* 会话内公告轮询(审计 F14):启动横幅只渲染一次公告,维护窗口里挂着
|
|
4
|
+
* 会话的用户只会撞裸错误。这里每 5 分钟带凭证拉一次模型列表(与启动同一
|
|
5
|
+
* 接口,公告随响应捎回,服务端零新增开销),公告发生变化时推进会话。
|
|
6
|
+
*
|
|
7
|
+
* 首次成功拉取只做基线、不打扰——启动横幅已经展示过当前公告;之后
|
|
8
|
+
* 「出现新公告」或「公告内容变化」才触发 onNew。公告被清空不提示。
|
|
9
|
+
*/
|
|
10
|
+
export declare const ANNOUNCEMENT_POLL_INTERVAL_MS: number;
|
|
11
|
+
export declare function announcementKey(a?: ApiAnnouncement | null): string;
|
|
12
|
+
/** 基线跟踪器:observe 返回「需要通知的新公告」,否则 undefined。 */
|
|
13
|
+
export declare function createAnnouncementTracker(): {
|
|
14
|
+
observe(a: ApiAnnouncement | null | undefined): ApiAnnouncement | undefined;
|
|
15
|
+
};
|
|
16
|
+
export declare function startAnnouncementPoll(deps: {
|
|
17
|
+
onNew: (a: ApiAnnouncement) => void;
|
|
18
|
+
fetch?: () => Promise<{
|
|
19
|
+
announcement?: ApiAnnouncement | null;
|
|
20
|
+
}>;
|
|
21
|
+
intervalMs?: number;
|
|
22
|
+
}): () => void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { fetchLatestAnnouncement } from "./api.js";
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
import { hasDeviceCredential } from "./device-auth.js";
|
|
4
|
+
/**
|
|
5
|
+
* 会话内公告轮询(审计 F14):启动横幅只渲染一次公告,维护窗口里挂着
|
|
6
|
+
* 会话的用户只会撞裸错误。这里每 5 分钟带凭证拉一次模型列表(与启动同一
|
|
7
|
+
* 接口,公告随响应捎回,服务端零新增开销),公告发生变化时推进会话。
|
|
8
|
+
*
|
|
9
|
+
* 首次成功拉取只做基线、不打扰——启动横幅已经展示过当前公告;之后
|
|
10
|
+
* 「出现新公告」或「公告内容变化」才触发 onNew。公告被清空不提示。
|
|
11
|
+
*/
|
|
12
|
+
export const ANNOUNCEMENT_POLL_INTERVAL_MS = 5 * 60_000;
|
|
13
|
+
export function announcementKey(a) {
|
|
14
|
+
return a?.text ? `${a.text}\u0000${a.url ?? ""}` : "";
|
|
15
|
+
}
|
|
16
|
+
/** 基线跟踪器:observe 返回「需要通知的新公告」,否则 undefined。 */
|
|
17
|
+
export function createAnnouncementTracker() {
|
|
18
|
+
let baseline;
|
|
19
|
+
return {
|
|
20
|
+
observe(a) {
|
|
21
|
+
const key = announcementKey(a);
|
|
22
|
+
if (baseline === undefined) {
|
|
23
|
+
baseline = key;
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
const changed = key !== "" && key !== baseline;
|
|
27
|
+
baseline = key;
|
|
28
|
+
return changed ? a : undefined;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function startAnnouncementPoll(deps) {
|
|
33
|
+
const doFetch = deps.fetch ?? (async () => {
|
|
34
|
+
const cfg = loadConfig();
|
|
35
|
+
if (!hasDeviceCredential(cfg))
|
|
36
|
+
throw new Error("not logged in");
|
|
37
|
+
// 轻量公告接口,不再每 5 分钟拉整份模型目录(鉴权 + 主库写 + 目录构建)
|
|
38
|
+
return fetchLatestAnnouncement(cfg);
|
|
39
|
+
});
|
|
40
|
+
const tracker = createAnnouncementTracker();
|
|
41
|
+
const tick = async () => {
|
|
42
|
+
let announcement;
|
|
43
|
+
try {
|
|
44
|
+
announcement = (await doFetch()).announcement;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return; // 网络抖动/未登录:静默,下一轮再试
|
|
48
|
+
}
|
|
49
|
+
const fresh = tracker.observe(announcement ?? null);
|
|
50
|
+
if (fresh)
|
|
51
|
+
deps.onNew(fresh);
|
|
52
|
+
};
|
|
53
|
+
void tick(); // 立刻建基线,避免把启动前就存在的公告当成"新公告"
|
|
54
|
+
const timer = setInterval(() => {
|
|
55
|
+
void tick();
|
|
56
|
+
}, deps.intervalMs ?? ANNOUNCEMENT_POLL_INTERVAL_MS);
|
|
57
|
+
timer.unref?.();
|
|
58
|
+
return () => clearInterval(timer);
|
|
59
|
+
}
|
package/dist/api.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ApiModel, type CliConfig } from "./config.js";
|
|
2
2
|
/** Preserve caller cancellation while always enforcing the operation deadline. */
|
|
3
3
|
export declare function signalWithTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal;
|
|
4
4
|
/** Read a complete response without allowing a custom or faulty server to exhaust CLI memory. */
|
|
@@ -37,22 +37,7 @@ export interface MePackage {
|
|
|
37
37
|
expires_at: string | null;
|
|
38
38
|
note: string | null;
|
|
39
39
|
}
|
|
40
|
-
export
|
|
41
|
-
id: string;
|
|
42
|
-
name: string;
|
|
43
|
-
reasoning: boolean;
|
|
44
|
-
/** Older gateways omit model-specific thinking metadata. */
|
|
45
|
-
thinking?: ApiThinkingCapabilities | null;
|
|
46
|
-
/** Older gateways omit this field; missing means text-only. */
|
|
47
|
-
vision?: boolean;
|
|
48
|
-
context_length: number;
|
|
49
|
-
max_tokens: number;
|
|
50
|
-
price: {
|
|
51
|
-
input: number;
|
|
52
|
-
output: number;
|
|
53
|
-
cache_read: number | null;
|
|
54
|
-
};
|
|
55
|
-
}
|
|
40
|
+
export type { ApiModel };
|
|
56
41
|
/** 启动横幅公告(后台「全局设置」下发);url 可选。老网关没有该字段,undefined = 不显示。 */
|
|
57
42
|
export interface ApiAnnouncement {
|
|
58
43
|
text: string;
|
|
@@ -79,6 +64,13 @@ export interface ModelsResponse {
|
|
|
79
64
|
export declare class AuthError extends Error {
|
|
80
65
|
}
|
|
81
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
|
+
}>;
|
|
82
74
|
export interface ApiEndpoint {
|
|
83
75
|
id: string;
|
|
84
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
|
|
1
|
+
import { type webcrypto } from "node:crypto";
|
|
2
2
|
export declare const VERSION: string;
|
|
3
3
|
/**
|
|
4
4
|
* 便携包安装(install.sh / install.ps1):包根旁边带自己的 node 运行时,
|
|
@@ -48,6 +48,14 @@ export interface ApiModel {
|
|
|
48
48
|
* Aliases are derived from the short id; note is left empty (filled by /model command).
|
|
49
49
|
*/
|
|
50
50
|
export declare function apiModelToDef(m: ApiModel): ModelDef;
|
|
51
|
+
export interface ModelCost {
|
|
52
|
+
input: number;
|
|
53
|
+
output: number;
|
|
54
|
+
cacheRead: number;
|
|
55
|
+
cacheWrite: number;
|
|
56
|
+
}
|
|
57
|
+
/** pi 的 Model 类型要求 cost 非空;价格未知时按 0 交给 pi(与自有端点同一约定)。 */
|
|
58
|
+
export declare const UNKNOWN_MODEL_COST: ModelCost;
|
|
51
59
|
export interface ModelDef {
|
|
52
60
|
id: string;
|
|
53
61
|
name: string;
|
|
@@ -59,13 +67,11 @@ export interface ModelDef {
|
|
|
59
67
|
vision: boolean;
|
|
60
68
|
contextWindow: number;
|
|
61
69
|
maxTokens: number;
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
cacheWrite: number;
|
|
68
|
-
};
|
|
70
|
+
/**
|
|
71
|
+
* USD per million tokens — display/estimation only; billing is server-side.
|
|
72
|
+
* null = 价格未知(内置兜底目录不再硬编码价格,权威在网关 D1;09-01 评审 D5 拍板删)。
|
|
73
|
+
*/
|
|
74
|
+
cost: ModelCost | null;
|
|
69
75
|
/** 网关下发的免费用量包覆盖标记;老网关缺失时为 undefined(不派生 note)。 */
|
|
70
76
|
freeEligible?: boolean;
|
|
71
77
|
note: string;
|
|
@@ -159,6 +165,11 @@ export declare function writeAgentDefaultModel(provider: string, modelId: string
|
|
|
159
165
|
*/
|
|
160
166
|
export declare function resolvePreferredModel(cfg: Pick<CliConfig, "model" | "modelProvider">): ModelRef;
|
|
161
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;
|
|
162
173
|
export declare function saveConfig(cfg: CliConfig): void;
|
|
163
174
|
/** Persist the user's preferred model to both stores. */
|
|
164
175
|
export declare function persistPreferredModel(cfg: CliConfig, provider: string, modelId: string): CliConfig;
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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";
|
|
@@ -80,6 +81,8 @@ export function apiModelToDef(m) {
|
|
|
80
81
|
note: "",
|
|
81
82
|
};
|
|
82
83
|
}
|
|
84
|
+
/** pi 的 Model 类型要求 cost 非空;价格未知时按 0 交给 pi(与自有端点同一约定)。 */
|
|
85
|
+
export const UNKNOWN_MODEL_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
83
86
|
export const MODELS = [
|
|
84
87
|
{
|
|
85
88
|
id: "deepseek-v4-flash",
|
|
@@ -96,7 +99,7 @@ export const MODELS = [
|
|
|
96
99
|
vision: false,
|
|
97
100
|
contextWindow: 1_048_576,
|
|
98
101
|
maxTokens: 384_000,
|
|
99
|
-
cost:
|
|
102
|
+
cost: null,
|
|
100
103
|
note: "默认 · 免费用量包额度内免费,日常写代码首选",
|
|
101
104
|
},
|
|
102
105
|
{
|
|
@@ -107,7 +110,7 @@ export const MODELS = [
|
|
|
107
110
|
vision: false,
|
|
108
111
|
contextWindow: 500_000,
|
|
109
112
|
maxTokens: 65_536,
|
|
110
|
-
cost:
|
|
113
|
+
cost: null,
|
|
111
114
|
note: "更强 · 但烧额度快约 20 倍,难题再用",
|
|
112
115
|
},
|
|
113
116
|
];
|
|
@@ -120,8 +123,8 @@ function deriveModelNote(m, base) {
|
|
|
120
123
|
if (m.freeEligible === true)
|
|
121
124
|
return "免费用量包可抵扣";
|
|
122
125
|
if (m.freeEligible === false) {
|
|
123
|
-
const blended = (m.cost.input + m.cost.output) / 2;
|
|
124
|
-
const baseBlended = base ? (base.cost.input + base.cost.output) / 2 : 0;
|
|
126
|
+
const blended = m.cost ? (m.cost.input + m.cost.output) / 2 : 0;
|
|
127
|
+
const baseBlended = base?.cost ? (base.cost.input + base.cost.output) / 2 : 0;
|
|
125
128
|
const mult = baseBlended > 0 ? Math.round(blended / baseBlended) : 0;
|
|
126
129
|
return mult >= 2
|
|
127
130
|
? `不走免费包 · 费用约为默认模型 ${mult} 倍`
|
|
@@ -295,10 +298,24 @@ export function loadConfig() {
|
|
|
295
298
|
modelProvider: typeof file.modelProvider === "string" ? file.modelProvider : undefined,
|
|
296
299
|
};
|
|
297
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
|
+
}
|
|
298
317
|
export function saveConfig(cfg) {
|
|
299
|
-
|
|
300
|
-
writeFileSync(configFile, JSON.stringify(cfg, null, 2) + "\n");
|
|
301
|
-
chmodSync(configFile, 0o600);
|
|
318
|
+
writePrivateFile(configFile, JSON.stringify(cfg, null, 2) + "\n");
|
|
302
319
|
}
|
|
303
320
|
/** Persist the user's preferred model to both stores. */
|
|
304
321
|
export function persistPreferredModel(cfg, provider, modelId) {
|
|
@@ -310,9 +327,7 @@ export function persistPreferredModel(cfg, provider, modelId) {
|
|
|
310
327
|
// ---- 端点本地缓存:离线/网关不可达时沿用上次拉到的配置(含密钥,0600)----
|
|
311
328
|
const endpointsCacheFile = join(u1s1Dir, "endpoints.json");
|
|
312
329
|
export function saveEndpointsCache(endpoints) {
|
|
313
|
-
|
|
314
|
-
writeFileSync(endpointsCacheFile, JSON.stringify(endpoints, null, 2) + "\n");
|
|
315
|
-
chmodSync(endpointsCacheFile, 0o600);
|
|
330
|
+
writePrivateFile(endpointsCacheFile, JSON.stringify(endpoints, null, 2) + "\n");
|
|
316
331
|
}
|
|
317
332
|
export function loadEndpointsCache() {
|
|
318
333
|
const raw = readJsonFile(endpointsCacheFile);
|
package/dist/device-auth.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/device-auth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execSync, spawnSync } from "node:child_process";
|
|
3
3
|
import { writeFileSync } from "node:fs";
|
|
4
|
-
import { cleanupBrandThemes, endpointKeyEnv, endpointProviderEntry, ensureBrandPrompt, ensureDefaultSettings, ensureProviderModels, ensureWorkflowPromptTemplate, scrubForeignProviderEnv, toProviderModels, writeAttributionExtension, writeCompactUiExtension, writeErrorHumanizeExtension, writeWebToolsExtension, } from "./agent-setup.js";
|
|
4
|
+
import { cleanupBrandThemes, endpointKeyEnv, endpointProviderEntry, ensureBrandPrompt, ensureDefaultSettings, ensureProviderModels, ensureWorkflowPromptTemplate, scrubForeignProviderEnv, toProviderModels, writeAnnouncementsExtension, writeAttributionExtension, writeCompactUiExtension, writeErrorHumanizeExtension, writeWebToolsExtension, } from "./agent-setup.js";
|
|
5
5
|
import { Text } from "@earendil-works/pi-tui";
|
|
6
6
|
import { printConsoleBanner } from "./brand.js";
|
|
7
7
|
import { agentDir, apiModelToDef, CUSTOM_ENDPOINTS, isPortableInstall, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, readSettings, refValid, resolvePreferredModel, setModelsFromApi, VERSION, } from "./config.js";
|
|
@@ -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
|
|
187
|
+
modelsResp = await modelsReady;
|
|
169
188
|
}
|
|
170
189
|
catch (e) {
|
|
171
190
|
if (e instanceof AuthError) {
|
|
@@ -223,22 +242,23 @@ async function runAgent(cfg, args) {
|
|
|
223
242
|
writeCompactUiExtension();
|
|
224
243
|
// 模型调用错误人话化(同样投影,Desktop App 共享)
|
|
225
244
|
writeErrorHumanizeExtension();
|
|
245
|
+
// 会话内公告触达:5 分钟轮询,公告变化时推进会话(同样投影,Desktop 共享)
|
|
246
|
+
writeAnnouncementsExtension();
|
|
226
247
|
// OpenRouter 应用归因:直连流量计入 u1s1 的公开排行
|
|
227
248
|
writeAttributionExtension();
|
|
228
249
|
ensureTmuxKeyboardProtocol();
|
|
229
|
-
// must be set before pi reads them (getAgentDir() reads at call time
|
|
230
|
-
|
|
250
|
+
// must be set before pi reads them (getAgentDir() reads at call time);
|
|
251
|
+
// 常量部分已在 runAgent 开头、发起 import 之前设好
|
|
231
252
|
process.env["U1S1_API_KEY"] = officialCfg.apiKey;
|
|
232
253
|
process.env["U1S1_SIGNING_PROXY_URL"] = officialCfg.baseUrl;
|
|
233
|
-
process.env["U1S1_TOOLS_VIA_EXTENSION"] = "1";
|
|
234
254
|
// 自定义端点的密钥走环境变量引用(models.json 里只有 $VAR,不落明文)
|
|
235
255
|
Object.assign(process.env, endpointKeyEnv());
|
|
236
256
|
// 剔掉环境里的其他厂商密钥,/model 只展示 u1s1 官方模型 + 自定义端点
|
|
237
257
|
scrubForeignProviderEnv();
|
|
238
|
-
//
|
|
239
|
-
|
|
258
|
+
// 密钥只留在本进程:模型跑的 shell 命令、子 agent、扩展派生的任何子进程都拿不到
|
|
259
|
+
installChildEnvGuard();
|
|
240
260
|
await searchToolsReady;
|
|
241
|
-
const { CustomEditor, main } = await
|
|
261
|
+
const { CustomEditor, main } = await agentReady;
|
|
242
262
|
// 网页终端(如 Taikula)把 Shift+Enter 发成 ESC+CR(\x1b\r)。pi 在没开 Kitty
|
|
243
263
|
// 协议时把它当成 Alt+Enter:空闲就直接发送,忙碌才排队。先改成 CSI-u 的
|
|
244
264
|
// Shift+Enter,输入框就会换行,真·Alt+Enter(\x1b[13;3u)不受影响。
|
|
@@ -345,16 +365,17 @@ async function runAgent(cfg, args) {
|
|
|
345
365
|
});
|
|
346
366
|
},
|
|
347
367
|
});
|
|
368
|
+
// 进程内注册直接给字面量密钥:不经 $VAR 环境变量引用,也不落文件
|
|
348
369
|
pi.registerProvider(PROVIDER_ID, {
|
|
349
370
|
name: "u1s1",
|
|
350
371
|
baseUrl: officialCfg.baseUrl,
|
|
351
372
|
api: "openai-completions",
|
|
352
|
-
apiKey:
|
|
373
|
+
apiKey: officialCfg.apiKey,
|
|
353
374
|
models: toProviderModels(MODELS),
|
|
354
375
|
});
|
|
355
376
|
// 用户在云端配置的自定义端点,一个端点一个 provider,/model 里即可切换
|
|
356
377
|
for (const ep of CUSTOM_ENDPOINTS) {
|
|
357
|
-
pi.registerProvider(ep.id, endpointProviderEntry(ep));
|
|
378
|
+
pi.registerProvider(ep.id, endpointProviderEntry(ep, { literalKey: true }));
|
|
358
379
|
}
|
|
359
380
|
// /model and Ctrl+P already write pi settings; also keep ~/.u1s1/config.json in sync
|
|
360
381
|
pi.on("model_select", (event) => {
|
package/dist/model.js
CHANGED
|
@@ -32,12 +32,11 @@ export async function modelCommand(nameOrAlias) {
|
|
|
32
32
|
for (const m of MODELS) {
|
|
33
33
|
const mark = current.provider === PROVIDER_ID && m.id === current.id ? "●" : " ";
|
|
34
34
|
console.log(` ${mark} ${m.aliases[0].padEnd(10)} ${m.name}`);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
console.log(`
|
|
40
|
-
}
|
|
35
|
+
// 价格来自网关目录;离线兜底目录不带价格,只列名字与提示
|
|
36
|
+
const price = m.cost ? `$${m.cost.input}/$${m.cost.output} 每百万 token` : "";
|
|
37
|
+
const detail = [m.note, price].filter(Boolean).join(" · ");
|
|
38
|
+
if (detail)
|
|
39
|
+
console.log(` ${detail}`);
|
|
41
40
|
}
|
|
42
41
|
for (const ep of CUSTOM_ENDPOINTS) {
|
|
43
42
|
console.log("");
|
|
@@ -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
|
+
}
|
package/dist/subagent.d.ts
CHANGED
|
@@ -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
|
|
126
|
-
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 不限。 */
|
package/dist/workflow/runner.js
CHANGED
|
@@ -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++;
|
package/dist/workflow/tool.d.ts
CHANGED
package/dist/workflow/tool.js
CHANGED
|
@@ -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,
|