u1s1-cli 1.3.1 → 1.3.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/api.d.ts CHANGED
@@ -72,6 +72,8 @@ export interface ModelsResponse {
72
72
  announcement?: ApiAnnouncement | null;
73
73
  /** Opaque, device-bound canary echoed only by the official signing proxy. */
74
74
  clientAttestation?: string;
75
+ /** Remaining lifetime of the canary, so the proxy can refresh it before it expires. */
76
+ clientAttestationExpiresInSeconds?: number;
75
77
  }
76
78
  /** 401:凭证失效(设备被移除/换过钥匙),调用方应引导重新登录而不是继续硬跑。 */
77
79
  export declare class AuthError extends Error {
package/dist/api.js CHANGED
@@ -82,11 +82,13 @@ export async function fetchModels(cfg) {
82
82
  if (!body || !Array.isArray(body.data))
83
83
  throw new Error("服务端模型列表格式不正确");
84
84
  const token = body.client_attestation?.token;
85
+ const expiresIn = body.client_attestation?.expires_in;
85
86
  return {
86
87
  models: body.data,
87
88
  features: body.features ?? {},
88
89
  announcement: body.announcement,
89
90
  clientAttestation: typeof token === "string" && token.length <= 1024 ? token : undefined,
91
+ clientAttestationExpiresInSeconds: typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : undefined,
90
92
  };
91
93
  }
92
94
  /**
@@ -11,17 +11,44 @@ export declare function dpopHeaders(cfg: CliConfig, method: string, url: string)
11
11
  export declare function authorizedFetch(cfg: CliConfig, input: string | URL, init?: RequestInit): Promise<Response>;
12
12
  /** Buffer a loopback request only up to the same JSON limit enforced by Gateway. */
13
13
  export declare function readSigningProxyRequestBody(request: IncomingMessage, maxBytes?: number): Promise<Buffer | undefined>;
14
+ /**
15
+ * How the proxy obtains and refreshes the device attestation canary. The token
16
+ * is device-bound and expires (7 days server-side); the proxy keeps it fresh so
17
+ * a healthy Gateway always sees a valid attestation instead of falling back to
18
+ * the weaker "legacy official" verdict when a transient /v1/models fetch missed.
19
+ */
20
+ export interface ClientAttestationSource {
21
+ token?: string;
22
+ expiresInSeconds?: number;
23
+ /** Fetch a fresh token (e.g. via GET /v1/models). Single-flighted by the proxy. */
24
+ refresh?: () => Promise<{
25
+ token?: string;
26
+ expiresInSeconds?: number;
27
+ }>;
28
+ }
29
+ interface AttestationHolder {
30
+ token?: string;
31
+ expiresAtMs?: number;
32
+ refresh?: () => Promise<{
33
+ token?: string;
34
+ expiresInSeconds?: number;
35
+ }>;
36
+ refreshing?: Promise<void>;
37
+ lastFailureMs?: number;
38
+ }
14
39
  interface SigningProxy {
15
40
  baseUrl: string;
16
41
  localKey: string;
17
42
  token: string;
18
43
  client: ClientSurface;
19
- clientAttestation?: string;
44
+ attestation: AttestationHolder;
20
45
  }
46
+ /** Attach a fresh (self-healing) attestation header to an outbound proxy request. */
47
+ export declare function attachAttestationHeader(holder: AttestationHolder, headers: Headers): Promise<void>;
21
48
  export type ClientSurface = "terminal" | "web" | "desktop" | "cloud";
22
49
  /**
23
50
  * pi accepts static provider headers only. Keep it behind a loopback proxy that
24
51
  * replaces the local bearer credential with a fresh DPoP proof per request.
25
52
  */
26
- export declare function ensureSigningProxy(cfg: CliConfig, fallbackClient?: ClientSurface, clientAttestation?: string): Promise<SigningProxy>;
53
+ export declare function ensureSigningProxy(cfg: CliConfig, fallbackClient?: ClientSurface, attestationSource?: ClientAttestationSource): Promise<SigningProxy>;
27
54
  export {};
@@ -110,7 +110,81 @@ export async function readSigningProxyRequestBody(request, maxBytes = SIGNING_PR
110
110
  }
111
111
  return chunks.length ? Buffer.concat(chunks, total) : undefined;
112
112
  }
113
+ // Refresh once we are within a day of the server-side 7-day expiry; back off for
114
+ // a while after a failed refresh so a flaky Gateway is not hammered per request.
115
+ const ATTESTATION_REFRESH_MARGIN_MS = 24 * 60 * 60 * 1000;
116
+ const ATTESTATION_REFRESH_COOLDOWN_MS = 30_000;
117
+ // Only ever block a request while we have no token at all, and even then only
118
+ // briefly — a Gateway too slow to answer would fail the real request anyway.
119
+ const ATTESTATION_BLOCK_TIMEOUT_MS = 4_000;
113
120
  let signingProxy;
121
+ function unrefDelay(ms) {
122
+ return new Promise((resolve) => {
123
+ const timer = setTimeout(resolve, ms);
124
+ if (typeof timer.unref === "function")
125
+ timer.unref();
126
+ });
127
+ }
128
+ function applyAttestation(holder, result) {
129
+ if (typeof result.token !== "string" || result.token.length === 0 || result.token.length > 1024)
130
+ return;
131
+ holder.token = result.token;
132
+ holder.expiresAtMs = typeof result.expiresInSeconds === "number" && result.expiresInSeconds > 0
133
+ ? Date.now() + result.expiresInSeconds * 1000
134
+ : undefined;
135
+ }
136
+ /** Seed the mutable holder from a new ensureSigningProxy call without dropping a fresher token. */
137
+ function updateAttestationHolder(holder, source) {
138
+ if (!source)
139
+ return;
140
+ if (source.refresh)
141
+ holder.refresh = source.refresh;
142
+ if (source.token && !holder.token) {
143
+ applyAttestation(holder, { token: source.token, expiresInSeconds: source.expiresInSeconds });
144
+ }
145
+ }
146
+ /**
147
+ * Kick off a single-flight refresh when the token is missing or nearing expiry.
148
+ * Returns the in-flight refresh promise (or undefined when no refresh is due),
149
+ * so the caller can decide whether to wait for it.
150
+ */
151
+ function ensureFreshAttestation(holder) {
152
+ if (!holder.refresh)
153
+ return undefined;
154
+ const now = Date.now();
155
+ const stale = !holder.token
156
+ || (holder.expiresAtMs !== undefined && now >= holder.expiresAtMs - ATTESTATION_REFRESH_MARGIN_MS);
157
+ if (!stale)
158
+ return undefined;
159
+ if (holder.refreshing)
160
+ return holder.refreshing;
161
+ if (holder.lastFailureMs !== undefined && now - holder.lastFailureMs < ATTESTATION_REFRESH_COOLDOWN_MS) {
162
+ return undefined;
163
+ }
164
+ const run = (async () => {
165
+ try {
166
+ applyAttestation(holder, await holder.refresh());
167
+ holder.lastFailureMs = undefined;
168
+ }
169
+ catch {
170
+ holder.lastFailureMs = Date.now();
171
+ }
172
+ finally {
173
+ holder.refreshing = undefined;
174
+ }
175
+ })();
176
+ holder.refreshing = run;
177
+ return run;
178
+ }
179
+ /** Attach a fresh (self-healing) attestation header to an outbound proxy request. */
180
+ export async function attachAttestationHeader(holder, headers) {
181
+ const refreshing = ensureFreshAttestation(holder);
182
+ if (refreshing && !holder.token) {
183
+ await Promise.race([refreshing, unrefDelay(ATTESTATION_BLOCK_TIMEOUT_MS)]);
184
+ }
185
+ if (holder.token)
186
+ headers.set("x-u1s1-attestation", holder.token);
187
+ }
114
188
  function clientSurface(fallback) {
115
189
  const explicit = process.env["U1S1_CLIENT"];
116
190
  if (explicit === "terminal" || explicit === "web" || explicit === "desktop" || explicit === "cloud") {
@@ -122,14 +196,17 @@ function clientSurface(fallback) {
122
196
  * pi accepts static provider headers only. Keep it behind a loopback proxy that
123
197
  * replaces the local bearer credential with a fresh DPoP proof per request.
124
198
  */
125
- export async function ensureSigningProxy(cfg, fallbackClient = "terminal", clientAttestation) {
199
+ export async function ensureSigningProxy(cfg, fallbackClient = "terminal", attestationSource) {
126
200
  if (!hasDeviceCredential(cfg))
127
201
  throw new Error("当前安装需要重新登录,以创建设备凭证");
128
202
  const client = clientSurface(fallbackClient);
129
203
  const current = signingProxy;
130
- if (current && current.token === cfg.deviceToken && current.client === client
131
- && current.clientAttestation === clientAttestation)
204
+ if (current && current.token === cfg.deviceToken && current.client === client) {
205
+ updateAttestationHolder(current.attestation, attestationSource);
132
206
  return current;
207
+ }
208
+ const attestation = {};
209
+ updateAttestationHolder(attestation, attestationSource);
133
210
  const localKey = `local-${randomBytes(32).toString("hex")}`;
134
211
  const upstreamOrigin = new URL(cfg.baseUrl).origin;
135
212
  const server = createServer(async (req, res) => {
@@ -163,8 +240,7 @@ export async function ensureSigningProxy(cfg, fallbackClient = "terminal", clien
163
240
  outboundHeaders.set("x-u1s1-client", client);
164
241
  outboundHeaders.set("x-u1s1-version", VERSION);
165
242
  outboundHeaders.set("x-u1s1-platform", `${process.platform}-${process.arch}`);
166
- if (clientAttestation)
167
- outboundHeaders.set("x-u1s1-attestation", clientAttestation);
243
+ await attachAttestationHeader(attestation, outboundHeaders);
168
244
  const upstream = await authorizedFetch(cfg, target, {
169
245
  method: req.method ?? "GET",
170
246
  headers: outboundHeaders,
@@ -216,7 +292,7 @@ export async function ensureSigningProxy(cfg, fallbackClient = "terminal", clien
216
292
  localKey,
217
293
  token: cfg.deviceToken,
218
294
  client,
219
- clientAttestation,
295
+ attestation,
220
296
  };
221
297
  return signingProxy;
222
298
  }
package/dist/index.js CHANGED
@@ -197,7 +197,17 @@ async function runAgent(cfg, args) {
197
197
  // it after the live model list arrives; explicit user choices remain intact.
198
198
  ensureDefaultSettings(MODELS);
199
199
  // pi provider 只支持静态 header;指向本机 signing proxy,由它逐请求附 DPoP proof。
200
- const signing = await ensureSigningProxy(cfg, "terminal", modelsResp?.clientAttestation);
200
+ const signing = await ensureSigningProxy(cfg, "terminal", {
201
+ token: modelsResp?.clientAttestation,
202
+ expiresInSeconds: modelsResp?.clientAttestationExpiresInSeconds,
203
+ refresh: async () => {
204
+ const refreshed = await fetchModels(cfg);
205
+ return {
206
+ token: refreshed.clientAttestation,
207
+ expiresInSeconds: refreshed.clientAttestationExpiresInSeconds,
208
+ };
209
+ },
210
+ });
201
211
  const officialCfg = { ...cfg, baseUrl: signing.baseUrl, apiKey: signing.localKey };
202
212
  ensureBrandPrompt(await shellReady);
203
213
  ensureProviderModels(officialCfg);
package/dist/tools.d.ts CHANGED
@@ -58,6 +58,10 @@ export declare function createImageTool(cfg: Pick<CliConfig, "baseUrl" | "apiKey
58
58
  path: string;
59
59
  size: string | null;
60
60
  bytes: number;
61
+ displayImage: {
62
+ data: string;
63
+ mimeType: string;
64
+ };
61
65
  }, any> & import("@earendil-works/pi-coding-agent").ToolDefinition<any, any, any>;
62
66
  export interface FetchToolConfig {
63
67
  baseUrl: string;
package/dist/tools.js CHANGED
@@ -406,7 +406,7 @@ export function createImageTool(cfg) {
406
406
  name: "generate_image",
407
407
  label: "生成图片",
408
408
  description: "Generate an image from a text prompt, or edit/compose existing images, using the Seedream image model. " +
409
- "Saves the result as a local image file and returns its path. " +
409
+ "Saves the result as a local image file and displays it directly in supported chat UIs. " +
410
410
  "Pass local file paths or http(s) URLs in `images` to edit an image or use references (style transfer, adding elements, combining up to 10 images). " +
411
411
  "Prompts work in Chinese or English; describe content, style, composition, and any text to render.",
412
412
  promptSnippet: "AI image generation/editing (text-to-image, editing, multi-image composition)",
@@ -415,6 +415,7 @@ export function createImageTool(cfg) {
415
415
  "Each call generates one image and costs the user credits; refine the prompt first instead of regenerating repeatedly.",
416
416
  "If an error says the image may/already has been generated or says not to call generate_image again, stop immediately; never retry with a new generation.",
417
417
  "To edit an existing image, pass its path in `images` and describe only the change in `prompt`.",
418
+ "A successful result is already displayed in supported chat UIs; never open an OS image viewer or claim that you opened one when the user asks to see it.",
418
419
  ],
419
420
  parameters: Type.Object({
420
421
  prompt: Type.String({
@@ -442,9 +443,17 @@ export function createImageTool(cfg) {
442
443
  mkdirSync(dirname(path), { recursive: true });
443
444
  writeFileSync(path, bytes);
444
445
  const sizeNote = result.size ? ` (${result.size})` : "";
446
+ const mimeType = REF_IMAGE_MIME[urlExt] ?? "image/jpeg";
445
447
  return {
446
- content: [{ type: "text", text: `图片已保存: ${path}${sizeNote}` }],
447
- details: { path, size: result.size, bytes: bytes.byteLength },
448
+ content: [{ type: "text", text: `图片已保存并在对话中展示: ${path}${sizeNote}` }],
449
+ // displayImage 只给 UI 预览,不放进 content:避免每轮都把整张 2K/4K
450
+ // 图片送回模型占用 context。pi-web-ui 会从 tool details 序列化它。
451
+ details: {
452
+ path,
453
+ size: result.size,
454
+ bytes: bytes.byteLength,
455
+ displayImage: { data: Buffer.from(bytes).toString("base64"), mimeType },
456
+ },
448
457
  };
449
458
  },
450
459
  });
package/dist/web.js CHANGED
@@ -55,21 +55,33 @@ export async function prepareWebEnv(cfg) {
55
55
  // 老网关没有 /v1/image,image_gen 缺失时按关闭处理,不注册生图工具
56
56
  let imageGenEnabled = false;
57
57
  let clientAttestation;
58
+ let clientAttestationExpiresInSeconds;
58
59
  const endpointsReady = loadCustomEndpoints(cfg);
59
60
  try {
60
- const { models, features, clientAttestation: attestation } = await fetchModels(cfg);
61
+ const { models, features, clientAttestation: attestation, clientAttestationExpiresInSeconds: attestationTtl } = await fetchModels(cfg);
61
62
  setModelsFromApi(models.map(apiModelToDef));
62
63
  webSearchEnabled = features.web_search !== false;
63
64
  webFetchRenderEnabled = features.web_fetch_render === true;
64
65
  imageGenEnabled = features.image_gen === true;
65
66
  clientAttestation = attestation;
67
+ clientAttestationExpiresInSeconds = attestationTtl;
66
68
  }
67
69
  catch (e) {
68
70
  console.error(" 获取模型列表失败,使用内置列表:", e.message);
69
71
  }
70
72
  await endpointsReady;
71
73
  ensureDefaultSettings(MODELS);
72
- const signing = await ensureSigningProxy(cfg, "desktop", clientAttestation);
74
+ const signing = await ensureSigningProxy(cfg, "desktop", {
75
+ token: clientAttestation,
76
+ expiresInSeconds: clientAttestationExpiresInSeconds,
77
+ refresh: async () => {
78
+ const refreshed = await fetchModels(cfg);
79
+ return {
80
+ token: refreshed.clientAttestation,
81
+ expiresInSeconds: refreshed.clientAttestationExpiresInSeconds,
82
+ };
83
+ },
84
+ });
73
85
  const officialCfg = { ...cfg, baseUrl: signing.baseUrl, apiKey: signing.localKey };
74
86
  webOfficialCfg = officialCfg;
75
87
  const modelsPath = refreshWebModels();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {