u1s1-cli 0.19.4 → 0.20.0

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.
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, join } from "node:path";
3
4
  import { agentDir, CUSTOM_ENDPOINTS, ENDPOINT_ID_RE, endpointKeyEnvName, MODELS, PROVIDER_ID, VERSION, } from "./config.js";
4
5
  const BRAND_APPEND = `## u1s1
5
6
 
@@ -50,6 +51,21 @@ export function ensureBrandPrompt(shell) {
50
51
  writeFileSync(p, content);
51
52
  }
52
53
  }
54
+ /**
55
+ * 当前捆绑的 pi 引擎版本号。pi 的包 exports 只有 "import" 条目,
56
+ * require.resolve 会报 ERR_PACKAGE_PATH_NOT_EXPORTED,必须走
57
+ * import.meta.resolve;拿到入口文件(dist/index.js)后向上一级找包根。
58
+ */
59
+ function bundledPiVersion() {
60
+ try {
61
+ const entry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
62
+ const pkg = JSON.parse(readFileSync(join(dirname(entry), "..", "package.json"), "utf8"));
63
+ return typeof pkg.version === "string" ? pkg.version : undefined;
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
53
69
  /** Defaults that don't overwrite values the user already set. */
54
70
  export function ensureDefaultSettings() {
55
71
  mkdirSync(agentDir, { recursive: true });
@@ -105,6 +121,16 @@ export function ensureDefaultSettings() {
105
121
  delete settings.theme;
106
122
  changed = true;
107
123
  }
124
+ // 屏蔽 pi 引擎的启动更新日志(What's New):那是上游英文 changelog,且 pi 的
125
+ // 版本号体系(0.84.x)和 u1s1(0.19.x)完全对不上——settings 里「上次看到的
126
+ // 版本」一旦缺失或偏旧,升级后启动会一次性灌出几十条英文更新说明。
127
+ // u1s1 的版本动态走公告横幅和 u1s1 update,这里每次启动都把该记录钉死在
128
+ // 当前捆绑的引擎版本上,保证启动时永不弹 changelog。
129
+ const piVersion = bundledPiVersion();
130
+ if (piVersion && settings["lastChangelogVersion"] !== piVersion) {
131
+ settings["lastChangelogVersion"] = piVersion;
132
+ changed = true;
133
+ }
108
134
  if (changed)
109
135
  writeFileSync(p, JSON.stringify(settings, null, 2) + "\n");
110
136
  }
package/dist/api.js CHANGED
@@ -1,13 +1,10 @@
1
1
  import { apiEndpointToCustom, loadEndpointsCache, saveEndpointsCache, setCustomEndpoints, VERSION, } from "./config.js";
2
- /** 网关按 x-u1s1-version 识别客户端版本(旧版 CLI 不带,提示升级)。 */
3
- function authHeaders(apiKey) {
4
- return { authorization: `Bearer ${apiKey}`, "x-u1s1-version": VERSION };
5
- }
2
+ import { authorizedFetch } from "./device-auth.js";
6
3
  export async function fetchModels(cfg) {
7
4
  let resp;
8
5
  try {
9
- resp = await fetch(`${cfg.baseUrl}/models`, {
10
- headers: authHeaders(cfg.apiKey),
6
+ resp = await authorizedFetch(cfg, `${cfg.baseUrl}/models`, {
7
+ headers: { "x-u1s1-version": VERSION },
11
8
  });
12
9
  }
13
10
  catch {
@@ -27,8 +24,8 @@ export async function fetchModels(cfg) {
27
24
  export async function fetchUserEndpoints(cfg) {
28
25
  let resp;
29
26
  try {
30
- resp = await fetch(`${cfg.baseUrl}/endpoints`, {
31
- headers: authHeaders(cfg.apiKey),
27
+ resp = await authorizedFetch(cfg, `${cfg.baseUrl}/endpoints`, {
28
+ headers: { "x-u1s1-version": VERSION },
32
29
  });
33
30
  }
34
31
  catch {
@@ -59,10 +56,10 @@ export async function searchWeb(cfg, query, maxResults, signal) {
59
56
  throw new Error("没有配置 API Key");
60
57
  let resp;
61
58
  try {
62
- resp = await fetch(`${cfg.baseUrl}/search`, {
59
+ resp = await authorizedFetch(cfg, `${cfg.baseUrl}/search`, {
63
60
  method: "POST",
64
61
  headers: {
65
- ...authHeaders(cfg.apiKey),
62
+ "x-u1s1-version": VERSION,
66
63
  "content-type": "application/json",
67
64
  },
68
65
  body: JSON.stringify({ query, max_results: maxResults }),
@@ -86,10 +83,10 @@ export async function renderPage(cfg, url, signal) {
86
83
  throw new Error("没有配置 API Key");
87
84
  let resp;
88
85
  try {
89
- resp = await fetch(`${cfg.baseUrl}/fetch`, {
86
+ resp = await authorizedFetch(cfg, `${cfg.baseUrl}/fetch`, {
90
87
  method: "POST",
91
88
  headers: {
92
- ...authHeaders(cfg.apiKey),
89
+ "x-u1s1-version": VERSION,
93
90
  "content-type": "application/json",
94
91
  },
95
92
  body: JSON.stringify({ url }),
@@ -113,10 +110,10 @@ export async function generateImage(cfg, req, signal) {
113
110
  throw new Error("没有配置 API Key");
114
111
  let resp;
115
112
  try {
116
- resp = await fetch(`${cfg.baseUrl}/image`, {
113
+ resp = await authorizedFetch(cfg, `${cfg.baseUrl}/image`, {
117
114
  method: "POST",
118
115
  headers: {
119
- ...authHeaders(cfg.apiKey),
116
+ "x-u1s1-version": VERSION,
120
117
  "content-type": "application/json",
121
118
  },
122
119
  body: JSON.stringify(req),
@@ -141,8 +138,8 @@ export async function fetchMe(cfg) {
141
138
  throw new Error("没有配置 API Key");
142
139
  let resp;
143
140
  try {
144
- resp = await fetch(`${cfg.baseUrl}/me`, {
145
- headers: authHeaders(cfg.apiKey),
141
+ resp = await authorizedFetch(cfg, `${cfg.baseUrl}/me`, {
142
+ headers: { "x-u1s1-version": VERSION },
146
143
  });
147
144
  }
148
145
  catch {
package/dist/bench.js CHANGED
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { homedir } from "node:os";
5
5
  import { CUSTOM_ENDPOINTS, loadConfig, MODELS, PROVIDER_ID, } from "./config.js";
6
6
  import { loadCustomEndpoints } from "./api.js";
7
+ import { authorizedFetch, hasDeviceCredential } from "./device-auth.js";
7
8
  // ─── 工具 ───
8
9
  const BENCH_DIR = join(homedir(), ".u1s1", "bench");
9
10
  /** 题目未声明 maxScore 时的默认满分 */
@@ -231,7 +232,7 @@ function runCheck(response, check) {
231
232
  }
232
233
  }
233
234
  // ─── 模型调用 ───
234
- async function callModel(baseUrl, apiKey, modelId, prompt) {
235
+ async function callModel(baseUrl, apiKey, modelId, prompt, officialCfg) {
235
236
  const start = performance.now();
236
237
  const isReasoning = /reasoner|grok/i.test(modelId);
237
238
  const body = {
@@ -245,12 +246,15 @@ async function callModel(baseUrl, apiKey, modelId, prompt) {
245
246
  if (apiKey)
246
247
  headers["authorization"] = `Bearer ${apiKey}`;
247
248
  try {
248
- const res = await fetch(`${baseUrl}/chat/completions`, {
249
+ const request = {
249
250
  method: "POST",
250
251
  headers,
251
252
  body: JSON.stringify(body),
252
253
  signal: AbortSignal.timeout(120_000),
253
- });
254
+ };
255
+ const res = officialCfg
256
+ ? await authorizedFetch(officialCfg, `${baseUrl}/chat/completions`, request)
257
+ : await fetch(`${baseUrl}/chat/completions`, request);
254
258
  const latencyMs = Math.round(performance.now() - start);
255
259
  if (!res.ok) {
256
260
  const errBody = await res.text().catch(() => "未知错误");
@@ -491,7 +495,7 @@ function listSuitesCommand() {
491
495
  }
492
496
  /** 把模型名解析成走 u1s1 默认端点的调用目标 */
493
497
  function u1s1Target(id, cfg) {
494
- return { id, label: id, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, source: "u1s1" };
498
+ return { id, label: id, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, source: "u1s1", officialCfg: cfg };
495
499
  }
496
500
  /** 把用户输入的模型名解析成一个可调用的目标 */
497
501
  function resolveModelTarget(query, cfg) {
@@ -553,7 +557,7 @@ async function runBench(args) {
553
557
  if (delayMs > 0)
554
558
  console.log(` ⏳ 题目间隔:${(delayMs / 1000).toFixed(0)}s(限流渠道用)`);
555
559
  const cfg = loadConfig();
556
- if (!cfg.apiKey) {
560
+ if (!hasDeviceCredential(cfg)) {
557
561
  console.error(" 还没有登录,先 u1s1 login");
558
562
  process.exit(1);
559
563
  }
@@ -581,7 +585,7 @@ async function runBench(args) {
581
585
  for (const q of suite.questions) {
582
586
  const label = `${target.label} / ${q.id}`;
583
587
  process.stdout.write(` [${++done}/${total}] ${label.padEnd(40)} `);
584
- const resp = await callModel(target.baseUrl, target.apiKey, target.id, q.prompt);
588
+ const resp = await callModel(target.baseUrl, target.apiKey, target.id, q.prompt, target.officialCfg);
585
589
  const costUsd = estimateCost(target.id, resp.tokensIn, resp.tokensOut);
586
590
  // 有评分规则且调用没出错时才打分
587
591
  const maxScore = q.maxScore ?? DEFAULT_MAX_SCORE;
package/dist/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { webcrypto } from "node:crypto";
1
2
  export declare const VERSION: string;
2
3
  /**
3
4
  * 便携包安装(install.sh / install.ps1):包根旁边带自己的 node 运行时,
@@ -95,6 +96,11 @@ export declare function refValid(ref: ModelRef): boolean;
95
96
  export declare function findModel(query: string): ResolvedModel | undefined;
96
97
  export interface CliConfig {
97
98
  apiKey?: string;
99
+ /** Browser-approved sender-constrained credential used by official clients. */
100
+ deviceToken?: string;
101
+ deviceId?: number;
102
+ devicePrivateJwk?: webcrypto.JsonWebKey;
103
+ devicePublicJwk?: webcrypto.JsonWebKey;
98
104
  baseUrl: string;
99
105
  /** preferred model id (within modelProvider) */
100
106
  model?: string;
package/dist/config.js CHANGED
@@ -207,8 +207,23 @@ export function resolvePreferredModel(cfg) {
207
207
  }
208
208
  export function loadConfig() {
209
209
  const file = (readJsonFile(configFile) ?? {});
210
+ const envJwk = (name) => {
211
+ const raw = process.env[name];
212
+ if (!raw)
213
+ return undefined;
214
+ try {
215
+ return JSON.parse(raw);
216
+ }
217
+ catch {
218
+ return undefined;
219
+ }
220
+ };
210
221
  return {
211
222
  apiKey: process.env["U1S1_API_KEY"] || file.apiKey,
223
+ deviceToken: process.env["U1S1_DEVICE_TOKEN"] || file.deviceToken,
224
+ deviceId: file.deviceId,
225
+ devicePrivateJwk: envJwk("U1S1_DEVICE_PRIVATE_JWK") || file.devicePrivateJwk,
226
+ devicePublicJwk: envJwk("U1S1_DEVICE_PUBLIC_JWK") || file.devicePublicJwk,
212
227
  baseUrl: process.env["U1S1_BASE_URL"] || file.baseUrl || DEFAULT_BASE_URL,
213
228
  // 有效性不在这里裁决:端点列表可能还没拉回来,交给 resolvePreferredModel
214
229
  model: file.model,
package/dist/deploy.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "
2
2
  import { basename, join, relative, resolve, sep } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { VERSION, u1s1Dir } from "./config.js";
5
+ import { authorizedFetch } from "./device-auth.js";
5
6
  /**
6
7
  * u1s1 deploy:把静态网页一键发布到 <name>.u1s1.app。
7
8
  * 检测项目里的静态站点根目录 → 首次询问子域名(记在 ~/.u1s1/deploys.json)
@@ -11,9 +12,6 @@ const deploysFile = join(u1s1Dir, "deploys.json");
11
12
  /** 构建产物目录优先:Vite/Next 等项目根的 index.html 是源码,不是能直接上线的产物。 */
12
13
  const BUILD_DIRS = ["dist", "build", "out", "_site", "public"];
13
14
  const SKIP_DIRS = new Set(["node_modules", "__pycache__"]);
14
- function authHeaders(apiKey) {
15
- return { authorization: `Bearer ${apiKey}`, "x-u1s1-version": VERSION };
16
- }
17
15
  function readDeploys() {
18
16
  try {
19
17
  return JSON.parse(readFileSync(deploysFile, "utf8"));
@@ -89,9 +87,9 @@ function fmtBytes(n) {
89
87
  async function api(cfg, method, path, body) {
90
88
  let resp;
91
89
  try {
92
- resp = await fetch(`${cfg.baseUrl}${path}`, {
90
+ resp = await authorizedFetch(cfg, `${cfg.baseUrl}${path}`, {
93
91
  method,
94
- headers: { ...authHeaders(cfg.apiKey), "content-type": "application/json" },
92
+ headers: { "x-u1s1-version": VERSION, "content-type": "application/json" },
95
93
  body: body === undefined ? undefined : JSON.stringify(body),
96
94
  });
97
95
  }
@@ -112,9 +110,9 @@ async function uploadAll(cfg, start, files) {
112
110
  const queue = [...files];
113
111
  const uploadOne = async (f) => {
114
112
  const qs = new URLSearchParams({ site: start.site, deploy_id: start.deploy_id, path: f.path });
115
- const put = async () => fetch(`${cfg.baseUrl}/deploy/file?${qs}`, {
113
+ const put = async () => authorizedFetch(cfg, `${cfg.baseUrl}/deploy/file?${qs}`, {
116
114
  method: "PUT",
117
- headers: { ...authHeaders(cfg.apiKey), "content-type": "application/octet-stream" },
115
+ headers: { "x-u1s1-version": VERSION, "content-type": "application/octet-stream" },
118
116
  body: readFileSync(f.abs),
119
117
  });
120
118
  let resp = await put().catch(() => null);
@@ -0,0 +1,21 @@
1
+ import { webcrypto } from "node:crypto";
2
+ import type { CliConfig } from "./config.js";
3
+ export declare function hasDeviceCredential(cfg: CliConfig): boolean;
4
+ export declare function generateDeviceKeyPair(): Promise<{
5
+ privateJwk: webcrypto.JsonWebKey;
6
+ publicJwk: webcrypto.JsonWebKey;
7
+ }>;
8
+ export declare function dpopHeaders(cfg: CliConfig, method: string, url: string): Promise<Record<string, string>>;
9
+ /** Fetch a gateway route with a fresh proof; generic keys remain a read-only compatibility fallback. */
10
+ export declare function authorizedFetch(cfg: CliConfig, input: string | URL, init?: RequestInit): Promise<Response>;
11
+ interface SigningProxy {
12
+ baseUrl: string;
13
+ localKey: string;
14
+ token: string;
15
+ }
16
+ /**
17
+ * pi accepts static provider headers only. Keep it behind a loopback proxy that
18
+ * replaces the local bearer credential with a fresh DPoP proof per request.
19
+ */
20
+ export declare function ensureSigningProxy(cfg: CliConfig): Promise<SigningProxy>;
21
+ export {};
@@ -0,0 +1,153 @@
1
+ import { createHash, randomBytes, randomUUID, webcrypto } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ const enc = new TextEncoder();
4
+ let cachedPrivate;
5
+ function b64url(value) {
6
+ const bytes = typeof value === "string" ? enc.encode(value) : value;
7
+ return Buffer.from(bytes).toString("base64url");
8
+ }
9
+ function dpopHtu(rawUrl) {
10
+ const url = new URL(rawUrl);
11
+ url.search = "";
12
+ url.hash = "";
13
+ return url.toString();
14
+ }
15
+ export function hasDeviceCredential(cfg) {
16
+ return !!(cfg.deviceToken?.startsWith("u1s1d-") &&
17
+ cfg.devicePrivateJwk?.kty === "EC" && cfg.devicePrivateJwk.crv === "P-256" &&
18
+ cfg.devicePublicJwk?.kty === "EC" && cfg.devicePublicJwk.crv === "P-256");
19
+ }
20
+ export async function generateDeviceKeyPair() {
21
+ const pair = await webcrypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
22
+ return {
23
+ privateJwk: await webcrypto.subtle.exportKey("jwk", pair.privateKey),
24
+ publicJwk: await webcrypto.subtle.exportKey("jwk", pair.publicKey),
25
+ };
26
+ }
27
+ async function privateKey(jwk) {
28
+ const serialized = JSON.stringify(jwk);
29
+ if (cachedPrivate?.serialized === serialized)
30
+ return cachedPrivate.key;
31
+ const key = await webcrypto.subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
32
+ cachedPrivate = { serialized, key };
33
+ return key;
34
+ }
35
+ export async function dpopHeaders(cfg, method, url) {
36
+ if (!hasDeviceCredential(cfg))
37
+ throw new Error("当前安装还没有设备凭证,请重新运行 u1s1 login");
38
+ const token = cfg.deviceToken;
39
+ const header = b64url(JSON.stringify({
40
+ typ: "dpop+jwt",
41
+ alg: "ES256",
42
+ jwk: cfg.devicePublicJwk,
43
+ }));
44
+ const ath = createHash("sha256").update(token).digest("base64url");
45
+ const payload = b64url(JSON.stringify({
46
+ jti: randomUUID().replace(/-/g, ""),
47
+ htm: method.toUpperCase(),
48
+ htu: dpopHtu(url),
49
+ iat: Math.floor(Date.now() / 1000),
50
+ ath,
51
+ }));
52
+ const signature = new Uint8Array(await webcrypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, await privateKey(cfg.devicePrivateJwk), enc.encode(`${header}.${payload}`)));
53
+ return {
54
+ authorization: `DPoP ${token}`,
55
+ dpop: `${header}.${payload}.${b64url(signature)}`,
56
+ };
57
+ }
58
+ /** Fetch a gateway route with a fresh proof; generic keys remain a read-only compatibility fallback. */
59
+ export async function authorizedFetch(cfg, input, init = {}) {
60
+ const url = String(input);
61
+ const headers = new Headers(init.headers);
62
+ if (hasDeviceCredential(cfg)) {
63
+ const signed = await dpopHeaders(cfg, init.method ?? "GET", url);
64
+ for (const [name, value] of Object.entries(signed))
65
+ headers.set(name, value);
66
+ }
67
+ else if (cfg.apiKey) {
68
+ headers.set("authorization", `Bearer ${cfg.apiKey}`);
69
+ }
70
+ else {
71
+ throw new Error("还没有登录,请运行 u1s1 login");
72
+ }
73
+ return fetch(url, { ...init, headers });
74
+ }
75
+ function requestHeaders(input) {
76
+ const headers = new Headers();
77
+ for (const [name, value] of Object.entries(input)) {
78
+ if (value === undefined || ["host", "connection", "content-length", "authorization", "dpop"].includes(name))
79
+ continue;
80
+ if (Array.isArray(value))
81
+ for (const item of value)
82
+ headers.append(name, item);
83
+ else
84
+ headers.set(name, value);
85
+ }
86
+ return headers;
87
+ }
88
+ let signingProxy;
89
+ /**
90
+ * pi accepts static provider headers only. Keep it behind a loopback proxy that
91
+ * replaces the local bearer credential with a fresh DPoP proof per request.
92
+ */
93
+ export async function ensureSigningProxy(cfg) {
94
+ if (!hasDeviceCredential(cfg))
95
+ throw new Error("当前安装需要重新登录,以创建设备凭证");
96
+ const current = signingProxy;
97
+ if (current && current.token === cfg.deviceToken)
98
+ return current;
99
+ const localKey = `local-${randomBytes(32).toString("hex")}`;
100
+ const upstreamOrigin = new URL(cfg.baseUrl).origin;
101
+ const server = createServer(async (req, res) => {
102
+ try {
103
+ if (req.headers.authorization !== `Bearer ${localKey}` || !req.url) {
104
+ res.writeHead(401, { "content-type": "application/json" });
105
+ res.end(JSON.stringify({ error: { message: "local signing proxy authentication failed" } }));
106
+ return;
107
+ }
108
+ // Only proxy the gateway namespace. Absolute-form request targets must not
109
+ // be allowed to override the configured upstream origin and steal a proof.
110
+ const localUrl = new URL(req.url, "http://127.0.0.1");
111
+ if (!localUrl.pathname.startsWith("/v1/")) {
112
+ res.writeHead(403, { "content-type": "application/json" });
113
+ res.end(JSON.stringify({ error: { message: "signing proxy only serves /v1/*" } }));
114
+ return;
115
+ }
116
+ const target = new URL(`${localUrl.pathname}${localUrl.search}`, upstreamOrigin).toString();
117
+ const chunks = [];
118
+ for await (const chunk of req)
119
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
120
+ const body = chunks.length ? Buffer.concat(chunks) : undefined;
121
+ const upstream = await authorizedFetch(cfg, target, {
122
+ method: req.method ?? "GET",
123
+ headers: requestHeaders(req.headers),
124
+ body,
125
+ });
126
+ const headers = {};
127
+ upstream.headers.forEach((value, name) => {
128
+ if (!["content-length", "transfer-encoding", "connection"].includes(name))
129
+ headers[name] = value;
130
+ });
131
+ res.writeHead(upstream.status, headers);
132
+ if (upstream.body) {
133
+ for await (const chunk of upstream.body)
134
+ res.write(chunk);
135
+ }
136
+ res.end();
137
+ }
138
+ catch (error) {
139
+ if (res.headersSent)
140
+ return void res.destroy(error);
141
+ res.writeHead(502, { "content-type": "application/json" });
142
+ res.end(JSON.stringify({ error: { message: error instanceof Error ? error.message : "signing proxy failed" } }));
143
+ }
144
+ });
145
+ await new Promise((resolve, reject) => {
146
+ server.once("error", reject);
147
+ server.listen(0, "127.0.0.1", resolve);
148
+ });
149
+ server.unref();
150
+ const port = server.address().port;
151
+ signingProxy = { baseUrl: `http://127.0.0.1:${port}/v1`, localKey, token: cfg.deviceToken };
152
+ return signingProxy;
153
+ }
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { ensureSearchTools } from "./search-tools.js";
9
9
  import { ensureUsableShell } from "./shell-doctor.js";
10
10
  import { applyBrandUi, setAnnouncement, setUpdateNotice } from "./style.js";
11
11
  import { fetchModels, loadCustomEndpoints } from "./api.js";
12
+ import { ensureSigningProxy } from "./device-auth.js";
12
13
  const PACKAGE_NAME = "u1s1-cli";
13
14
  /** 启动时检测到的可自动安装的新版;TUI 退出后才装(见 installPendingUpdate)。 */
14
15
  let pendingUpdate;
@@ -164,11 +165,14 @@ async function runAgent(cfg, args) {
164
165
  console.error(" 获取模型列表失败,使用内置列表:", e.message);
165
166
  }
166
167
  await endpointsReady;
168
+ // pi provider 只支持静态 header;指向本机 signing proxy,由它逐请求附 DPoP proof。
169
+ const signing = await ensureSigningProxy(cfg);
170
+ const officialCfg = { ...cfg, baseUrl: signing.baseUrl, apiKey: signing.localKey };
167
171
  ensureBrandPrompt(await shellReady);
168
- ensureProviderModels(cfg);
172
+ ensureProviderModels(officialCfg);
169
173
  ensureWorkflowPromptTemplate();
170
174
  // 联网工具经 agentDir/extensions 投影,TUI 和 u1s1 web 共用一份注册
171
- writeWebToolsExtension(cfg, {
175
+ writeWebToolsExtension(officialCfg, {
172
176
  webSearch: webSearchEnabled,
173
177
  webFetchRender: webFetchRenderEnabled,
174
178
  imageGen: imageGenEnabled,
@@ -180,7 +184,7 @@ async function runAgent(cfg, args) {
180
184
  ensureTmuxKeyboardProtocol();
181
185
  // must be set before pi reads them (getAgentDir() reads at call time, env at import is fine too)
182
186
  process.env["PI_CODING_AGENT_DIR"] = agentDir;
183
- process.env["U1S1_API_KEY"] = cfg.apiKey;
187
+ process.env["U1S1_API_KEY"] = officialCfg.apiKey;
184
188
  process.env["U1S1_TOOLS_VIA_EXTENSION"] = "1";
185
189
  // 自定义端点的密钥走环境变量引用(models.json 里只有 $VAR,不落明文)
186
190
  Object.assign(process.env, endpointKeyEnv());
@@ -260,7 +264,7 @@ async function runAgent(cfg, args) {
260
264
  });
261
265
  pi.registerProvider(PROVIDER_ID, {
262
266
  name: "u1s1",
263
- baseUrl: cfg.baseUrl,
267
+ baseUrl: officialCfg.baseUrl,
264
268
  api: "openai-completions",
265
269
  apiKey: "$U1S1_API_KEY",
266
270
  models: toProviderModels(MODELS),
@@ -350,7 +354,19 @@ async function run() {
350
354
  }
351
355
  if (cmd === "logout") {
352
356
  const { saveConfig } = await import("./config.js");
353
- saveConfig({ ...loadConfig(), apiKey: undefined });
357
+ const current = loadConfig();
358
+ if (current.deviceToken) {
359
+ const { authorizedFetch } = await import("./device-auth.js");
360
+ await authorizedFetch(current, `${current.baseUrl}/device`, { method: "DELETE" }).catch(() => null);
361
+ }
362
+ saveConfig({
363
+ ...current,
364
+ apiKey: undefined,
365
+ deviceToken: undefined,
366
+ deviceId: undefined,
367
+ devicePrivateJwk: undefined,
368
+ devicePublicJwk: undefined,
369
+ });
354
370
  console.log("已退出登录。");
355
371
  return;
356
372
  }
package/dist/login.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { webcrypto } from "node:crypto";
1
2
  import { type CliConfig } from "./config.js";
2
3
  /** baseUrl 形如 https://api.u1s1.io/v1;auth 路由挂在同一域名的根路径。 */
3
4
  export declare function apiOrigin(cfg: CliConfig): string;
@@ -6,11 +7,20 @@ export interface DeviceStart {
6
7
  poll_secret: string;
7
8
  interval: number;
8
9
  expires_in: number;
10
+ private_jwk: webcrypto.JsonWebKey;
11
+ public_jwk: webcrypto.JsonWebKey;
9
12
  }
10
- /** 发起浏览器登录;网关太老或连不上时返回 null,退回手动粘贴。 */
13
+ /** 发起浏览器设备登录;网关不支持或网络不可用时返回 null */
11
14
  export declare function startDeviceLogin(origin: string): Promise<DeviceStart | null>;
12
- /** 轮询等浏览器那边批准;拿到 key 返回,过期返回 null。 */
13
- export declare function pollDeviceLogin(origin: string, start: DeviceStart): Promise<string | null>;
15
+ export interface DeviceLoginResult {
16
+ apiKey: string;
17
+ deviceToken: string;
18
+ deviceId?: number;
19
+ devicePrivateJwk: webcrypto.JsonWebKey;
20
+ devicePublicJwk: webcrypto.JsonWebKey;
21
+ }
22
+ /** 轮询等浏览器批准;只接受带设备凭证的新网关响应。 */
23
+ export declare function pollDeviceLogin(origin: string, start: DeviceStart): Promise<DeviceLoginResult | null>;
14
24
  export declare function login(keyArg?: string): Promise<CliConfig>;
15
- /** Returns a config that definitely has an apiKey, prompting the user if needed. */
25
+ /** Returns a config with a browser-approved, sender-constrained device credential. */
16
26
  export declare function ensureAuth(): Promise<CliConfig>;
package/dist/login.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { createRequire } from "node:module";
2
- import { createInterface } from "node:readline/promises";
2
+ import { hostname, platform } from "node:os";
3
3
  import { fetchMe } from "./api.js";
4
- import { DASHBOARD_URL, printConsoleBanner } from "./brand.js";
4
+ import { printConsoleBanner } from "./brand.js";
5
5
  import { loadConfig, saveConfig } from "./config.js";
6
+ import { generateDeviceKeyPair, hasDeviceCredential } from "./device-auth.js";
6
7
  const require = createRequire(import.meta.url);
7
8
  const VERSION = require("../package.json").version;
8
9
  function tryOpenBrowser(url) {
@@ -21,10 +22,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
22
  export function apiOrigin(cfg) {
22
23
  return cfg.baseUrl.replace(/\/v1\/?$/, "");
23
24
  }
24
- /** 发起浏览器登录;网关太老或连不上时返回 null,退回手动粘贴。 */
25
+ /** 发起浏览器设备登录;网关不支持或网络不可用时返回 null */
25
26
  export async function startDeviceLogin(origin) {
26
27
  try {
27
- const resp = await fetch(`${origin}/auth/device/start`, { method: "POST" });
28
+ const pair = await generateDeviceKeyPair();
29
+ const resp = await fetch(`${origin}/auth/device/start`, {
30
+ method: "POST",
31
+ headers: { "content-type": "application/json" },
32
+ body: JSON.stringify({
33
+ public_jwk: pair.publicJwk,
34
+ device_name: `${hostname()} (${platform()})`,
35
+ client_version: VERSION,
36
+ }),
37
+ });
28
38
  if (!resp.ok)
29
39
  return null;
30
40
  const data = (await resp.json());
@@ -35,13 +45,15 @@ export async function startDeviceLogin(origin) {
35
45
  poll_secret: data.poll_secret,
36
46
  interval: data.interval || 2,
37
47
  expires_in: data.expires_in || 900,
48
+ private_jwk: pair.privateJwk,
49
+ public_jwk: pair.publicJwk,
38
50
  };
39
51
  }
40
52
  catch {
41
53
  return null;
42
54
  }
43
55
  }
44
- /** 轮询等浏览器那边批准;拿到 key 返回,过期返回 null。 */
56
+ /** 轮询等浏览器批准;只接受带设备凭证的新网关响应。 */
45
57
  export async function pollDeviceLogin(origin, start) {
46
58
  const deadline = Date.now() + start.expires_in * 1000;
47
59
  while (Date.now() < deadline) {
@@ -55,8 +67,15 @@ export async function pollDeviceLogin(origin, start) {
55
67
  if (!resp.ok)
56
68
  continue;
57
69
  const data = (await resp.json());
58
- if (data.status === "ok" && data.api_key)
59
- return data.api_key;
70
+ if (data.status === "ok" && data.api_key && data.device_token) {
71
+ return {
72
+ apiKey: data.api_key,
73
+ deviceToken: data.device_token,
74
+ deviceId: data.device_id,
75
+ devicePrivateJwk: start.private_jwk,
76
+ devicePublicJwk: start.public_jwk,
77
+ };
78
+ }
60
79
  if (data.status === "expired")
61
80
  return null;
62
81
  }
@@ -66,54 +85,30 @@ export async function pollDeviceLogin(origin, start) {
66
85
  }
67
86
  return null;
68
87
  }
69
- async function promptForKey() {
70
- // 桌面图标双击启动时没有可交互的终端,rl.question 会无声挂死;
71
- // 走到这个兜底说明 device login 不可用(网关太老/断网),只能明确报错。
72
- if (!process.stdin.isTTY) {
73
- console.error(" 无法完成浏览器登录(网络问题或网关不可用),请稍后重试。");
74
- process.exit(1);
75
- }
76
- console.log(" 需要一把 API Key(免费):");
77
- console.log(` 1. 打开 ${DASHBOARD_URL} 注册/登录(30 秒,注册领免费用量包:首月每天 1 亿 Token)`);
78
- console.log(" 2. 复制你的 API Key,粘贴到下面");
79
- console.log("");
80
- tryOpenBrowser(DASHBOARD_URL);
81
- const rl = createInterface({ input: process.stdin, output: process.stdout });
82
- const key = (await rl.question(" 粘贴 API Key: ")).trim();
83
- rl.close();
84
- return key;
85
- }
86
88
  export async function login(keyArg) {
87
89
  const cfg = loadConfig();
88
- let key = keyArg?.trim();
89
- if (!key) {
90
- printConsoleBanner(VERSION);
91
- const origin = apiOrigin(cfg);
92
- const start = await startDeviceLogin(origin);
93
- if (start) {
94
- console.log(" 用浏览器登录(注册领免费用量包,首月每天 1 亿 Token):");
95
- console.log("");
96
- console.log(` ${start.verify_url}`);
97
- console.log("");
98
- console.log(" 已经帮你打开浏览器了;如果没打开,把上面这行网址复制到浏览器打开。");
99
- console.log(" 在浏览器里登录并点「批准」后,这里会自动继续,等着就行……");
100
- tryOpenBrowser(start.verify_url);
101
- key = (await pollDeviceLogin(origin, start)) ?? undefined;
102
- if (!key) {
103
- console.error(" 等了太久没等到浏览器登录。重新运行一次 u1s1,会给你一个新链接。");
104
- process.exit(1);
105
- }
106
- console.log("");
107
- }
108
- else {
109
- key = await promptForKey();
110
- }
90
+ let credential;
91
+ if (keyArg?.trim()) {
92
+ console.log(" API Key 不能证明请求来自 u1s1 客户端,将改用浏览器批准本机设备。");
93
+ }
94
+ printConsoleBanner(VERSION);
95
+ const origin = apiOrigin(cfg);
96
+ const start = await startDeviceLogin(origin);
97
+ if (start) {
98
+ console.log(" 用浏览器登录并批准这台设备:");
99
+ console.log("");
100
+ console.log(` ${start.verify_url}`);
101
+ console.log("");
102
+ console.log(" 已经帮你打开浏览器了;如果没打开,把上面这行网址复制到浏览器打开。");
103
+ console.log(" 批准后,本机会用设备私钥为每次 API 请求签名。");
104
+ tryOpenBrowser(start.verify_url);
105
+ credential = (await pollDeviceLogin(origin, start)) ?? undefined;
111
106
  }
112
- if (!key.startsWith("u1s1-")) {
113
- console.error(" 这不像一把 u1s1 的 Key(应该以 u1s1- 开头),再看看?");
107
+ if (!credential) {
108
+ console.error(" 没能取得设备凭证。请确认网络正常并升级到最新版 u1s1 后重试。");
114
109
  process.exit(1);
115
110
  }
116
- const next = { ...cfg, apiKey: key };
111
+ const next = { ...cfg, ...credential };
117
112
  const me = await fetchMe(next).catch((e) => {
118
113
  console.error(` 验证失败:${e.message}`);
119
114
  process.exit(1);
@@ -144,10 +139,10 @@ export async function login(keyArg) {
144
139
  console.log(` ✓ 登录成功${me.email ? `(${me.email})` : ""},${quotaNote}。`);
145
140
  return next;
146
141
  }
147
- /** Returns a config that definitely has an apiKey, prompting the user if needed. */
142
+ /** Returns a config with a browser-approved, sender-constrained device credential. */
148
143
  export async function ensureAuth() {
149
144
  const cfg = loadConfig();
150
- if (cfg.apiKey)
145
+ if (hasDeviceCredential(cfg))
151
146
  return cfg;
152
147
  return login();
153
148
  }
package/dist/usage.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { fetchMe } from "./api.js";
2
2
  import { loadConfig } from "./config.js";
3
+ import { hasDeviceCredential } from "./device-auth.js";
3
4
  function bar(ratio, width = 24) {
4
5
  const filled = Math.round(Math.max(0, Math.min(1, ratio)) * width);
5
6
  return "█".repeat(filled) + "░".repeat(width - filled);
@@ -21,7 +22,7 @@ function fmtTokensCn(tokens) {
21
22
  }
22
23
  export async function usage() {
23
24
  const cfg = loadConfig();
24
- if (!cfg.apiKey) {
25
+ if (!hasDeviceCredential(cfg)) {
25
26
  console.error("还没登录,先跑 u1s1 login");
26
27
  process.exit(1);
27
28
  }
@@ -70,7 +71,9 @@ export async function usage() {
70
71
  const label = ((PKG_LABEL[p.kind] ?? p.kind) + (p.count > 1 ? ` ×${p.count}` : "")).padEnd(5, " ");
71
72
  const per = daily ? "/天" : "";
72
73
  console.log(` ${label} 还剩 ${fmtTokensCn(p.remaining)} / ${fmtTokensCn(total)}${per} ${bar(ratio)}`);
73
- const scopeNote = p.scope === "free" ? "仅默认模型和搜索 · 0 点恢复" : "全模型可用";
74
+ const scopeNote = p.kind === "invite"
75
+ ? "仅限 u1s1 客户端使用 · 全模型可用"
76
+ : p.scope === "free" ? "仅默认模型和搜索 · 0 点恢复" : "全模型可用";
74
77
  console.log(` ${scopeNote} · ${p.expires_at ? `${p.expires_at.slice(0, 10)} 到期` : "永不过期"}`);
75
78
  }
76
79
  if (me.bonus_balance_usd > 0) {
@@ -86,7 +89,7 @@ export async function usage() {
86
89
  console.log(" → 免费用量包到期了,去 https://u1s1.io/dashboard 续领:每天 3000 万 Token,一年有效");
87
90
  }
88
91
  else {
89
- console.log(" 免费包每天 0 点恢复;邀请朋友双方各得一次性 1 亿 Token 包 → https://u1s1.io/dashboard");
92
+ console.log(" 免费包每天 0 点恢复;邀请礼包需受邀人手动领取,仅限 u1s1 客户端 → https://u1s1.io/dashboard");
90
93
  }
91
94
  console.log(" Token 数按默认模型单价折算,为约数;用更贵的模型时消耗更快。");
92
95
  console.log("");
package/dist/web.js CHANGED
@@ -8,6 +8,7 @@ import { ensureSearchTools } from "./search-tools.js";
8
8
  import { ensureUsableShell } from "./shell-doctor.js";
9
9
  import { applyWebUiBranding, applyWebUiFrontend } from "./webui-brand.js";
10
10
  import { fetchModels, loadCustomEndpoints } from "./api.js";
11
+ import { ensureSigningProxy } from "./device-auth.js";
11
12
  const require = createRequire(import.meta.url);
12
13
  /**
13
14
  * `u1s1 web` — 浏览器网页版。薄包装 pi-web-ui 的服务器:注入我们的 agentDir
@@ -46,14 +47,16 @@ export async function prepareWebEnv(cfg) {
46
47
  console.error(" 获取模型列表失败,使用内置列表:", e.message);
47
48
  }
48
49
  await endpointsReady;
50
+ const signing = await ensureSigningProxy(cfg);
51
+ const officialCfg = { ...cfg, baseUrl: signing.baseUrl, apiKey: signing.localKey };
49
52
  ensureBrandPrompt(await shellReady);
50
53
  // /workflow 提示词模板与 TUI 同源,web/桌面版也要有
51
54
  ensureWorkflowPromptTemplate();
52
- ensureProviderModels(cfg);
55
+ ensureProviderModels(officialCfg);
53
56
  // pi-web-ui 靠 auth.json 判断「已配置」,否则网页会弹 pi 安装引导
54
57
  ensureAuthCredential();
55
58
  // 联网工具经 agentDir/extensions 投影,和 TUI 共用一份注册
56
- writeWebToolsExtension(cfg, {
59
+ writeWebToolsExtension(officialCfg, {
57
60
  webSearch: webSearchEnabled,
58
61
  webFetchRender: webFetchRenderEnabled,
59
62
  imageGen: imageGenEnabled,
@@ -71,7 +74,7 @@ export async function prepareWebEnv(cfg) {
71
74
  await searchToolsReady;
72
75
  return {
73
76
  PI_CODING_AGENT_DIR: agentDir,
74
- U1S1_API_KEY: cfg.apiKey,
77
+ U1S1_API_KEY: officialCfg.apiKey,
75
78
  U1S1_TOOLS_VIA_EXTENSION: "1",
76
79
  PI_WEB_DATA_DIR: dataDir,
77
80
  // 自定义端点的密钥经环境变量传给 web 子进程(models.json 里只有 $VAR 引用)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "0.19.4",
3
+ "version": "0.20.0",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {