@inventec/dsh-copilot-auth 1.2.8 → 1.2.10

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/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  ## 特性
18
18
 
19
19
  - 🔐 **设备码登录**:网页 + user code,免 API Token;SSO 组织授权友好
20
- - 🛑 **诚实的授权语义(v2)**:登录进行中可「**请求撤回**」——请求发出后状态如实显示「结果待核实」,不再伪装成已取消;「退出登录/重新授权」在当前宿主无法证明安全结束前**默认禁用并说明**(绝不静默删除凭据);同一实例只有一个授权尝试,409 时恢复展示共同进度
20
+ - 🛑 **诚实的授权语义(v2)**:登录进行中可「**请求撤回**」——请求发出后状态如实显示「结果待核实」,不再伪装成已取消;**「退出登录」在静止状态可用**(确认弹窗 → 清除凭据 → 删后核实;尝试进行中/风险锁存期拒绝),「重新授权」禁用(等价退出+登录,请分步执行);同一实例只有一个授权尝试,409 时恢复展示共同进度
21
21
  - 🧩 **预置提供方路由**:安装即出现在 Models 页,无需手动配置
22
22
  - 📦 **模型目录兜底填充**:登录成功时,若 GitHub Copilot 路由还没有模型目录,自动填入账号全部可用模型;你已精简/定制过的目录**永不覆盖**(重启、重新登录均不重置);已请求撤回后晚到的授权成功**不会**再写你的配置
23
23
  - ➕➖ **补充模型 / 重建模型列表(v2 双入口)**:「补充」默认非破坏——候选模型默认全不勾选,勾选后才加入,现有对象/顺序/modelOverrides 全部保留;「重建」显式确认后按「账号可用 ∩ 目录可解析」重建纯 ID 列表并清空本路由 modelOverrides(二次确认 + 空目标单独确认)。预览快照 10 分钟有效、绑定选择;同一操作幂等可查;配置并发编辑优先受保护
@@ -33,7 +33,9 @@
33
33
 
34
34
  | 本插件 | DSH 实测版本 | authorization 服务 | 说明 |
35
35
  |---|---|---|---|
36
- | v1.2.8+ | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **协议 v2:诚实的授权语义+补充/重建双入口**。授权侧:等待中可「请求撤回」(结果如实显示待核实)、退出/重新授权在宿主可证明安全前禁用并说明、单实例 attempt、15 分钟等待上限、晚到授权成功不再写配置;模型侧:旧「刷新」拆为「补充模型」(候选默认不勾选、非破坏)与「重建模型列表」(二次确认+清 modelOverrides),预览 10 分钟绑定选择、operationId 幂等、配置并发编辑优先、崩溃可恢复(「结束旧配置意图」显式闭环),状态按 profile 隔离(旧全局状态只读提示);模型路由要求 `protocolVersion:2`,GET `/status` 零写探针;v1 函数族已从 state.mjs 移除 |
36
+ | v1.2.10+ | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **退出登录正式可用**:无进行中授权尝试且无未核实风险(风险锁存)时,经确认弹窗清除凭据并**删除后立即复核**(凭据未消失则如实报「退出未完成」);授权尝试进行中(begin 晚写窗口)与撤回/超时未核实期一律拒绝退出。`/status` 的 `authorization.capabilities.logout` 按可用性动态返回;**重新授权仍禁用**(等价退出+登录,请分步执行)。经主人 2026-10-05 裁决替代 v1.2.8 D-01「退出全禁」策略 |
37
+ | v1.2.9 | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **修复 v1.2.8 授权页显示回归**:授权状态机漏接 `fetch`,页面恒显「未登录」、点「授权登录」恒「失败」且请求根本不发出(2026-10-04 实机实证,nginx 全量零 `POST /copilot-auth/start`)。另加:/status 加载失败退避自愈重试(60 秒转手动+「查询状态」)、失败原因分层展示(网络/网关/响应,不再裸「失败」)、已登录时服务端拒绝替换性授权(409 `already-configured`)且客户端自动恢复已登录视图 |
38
+ | v1.2.8 | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **协议 v2:诚实的授权语义+补充/重建双入口**。授权侧:等待中可「请求撤回」(结果如实显示待核实)、退出/重新授权在宿主可证明安全前禁用并说明、单实例 attempt、15 分钟等待上限、晚到授权成功不再写配置;模型侧:旧「刷新」拆为「补充模型」(候选默认不勾选、非破坏)与「重建模型列表」(二次确认+清 modelOverrides),预览 10 分钟绑定选择、operationId 幂等、配置并发编辑优先、崩溃可恢复(「结束旧配置意图」显式闭环),状态按 profile 隔离(旧全局状态只读提示);模型路由要求 `protocolVersion:2`,GET `/status` 零写探针;v1 函数族已从 state.mjs 移除。**已知缺陷**:授权状态机漏接 fetchImpl → 授权页恒「未登录/失败」(v1.2.9 修复) |
37
39
  | v1.2.7+ | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **重启后自动恢复**:注入后以宿主 `listModels` 复核,未端出即**净零切换**(`displayName` 改后复原;两次真内容变化 ⇒ 宿主必然重建快照,用户配置逐字节不变)触发重建;另加双路取实例 + `registryInstanceMismatch`/`registryVia` 诊断。修复 v1.2.6 的"同值写不改变配置身份 ⇒ 快照不重建" |
38
40
  | v1.2.6 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | 首次引入宿主侧复核与 `settingsNotServed` 诊断;重启自愈用的"同值触碰"**实测不足**(同值写不改变配置对象身份),重启后模型可能仍不出现——请用 v1.2.7 |
39
41
  | v1.2.5 | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **desktop 只读形态可刷新**:进程内目录注册表注入(ADR 0003),无需重启、不碰官方产物;`0.2.0-rc.2` desktop **实机端到端已验**(注入条目完成真实推理请求);同批修复 pi-ai ≥0.99.0 的 `chat:` 目录键格式。**已知缺陷**:重启后若宿主快照先于注入定稿,注入条目会被丢弃(v1.2.6 修复) |
@@ -75,7 +77,7 @@ dsh plugin --profile web add @inventec/dsh-copilot-auth
75
77
  ## 撤回、退出与卸载
76
78
 
77
79
  - **请求撤回**:授权等待期间可点「请求撤回」——插件先使本地旧意图失效,再尝试调用宿主撤销接口;无论请求是否送达,状态都如实显示「结果仍待核实」,在结果核实前不能发起新的授权尝试。
78
- - **退出登录 / 重新授权**:当前宿主桥接无法证明授权流程已安全结束(晚写不可排除),两项操作**默认禁用并说明原因**,插件也绝不调用凭据删除;待宿主提供可信安全路径后再启用。
80
+ - **退出登录(v1.2.10)**:无进行中授权尝试且无未核实风险时可退出——确认弹窗(将清除同一凭据库中所有实例共享的 Copilot 授权,不注销 GitHub 账号)→ 删除凭据 → **删除后立即复核**,凭据仍在则如实显示「退出未完成,授权凭据仍可能存在」;授权尝试进行中(设备码等待期,begin 晚写窗口)与撤回/超时未核实的风险锁存期一律拒绝退出。**重新授权**仍禁用:等价于退出+登录,请先「退出登录」再「授权登录」。
79
81
  - 卸载:`dsh plugin --profile web remove @inventec/dsh-copilot-auth` 后重启 dsh web
80
82
 
81
83
  ## 补充模型 / 重建模型列表
package/lib/client.js CHANGED
@@ -374,12 +374,12 @@ function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = () => {
374
374
  timer = null;
375
375
  }
376
376
  };
377
- const schedule = (gen, delayMs) => {
377
+ const schedule = (gen, delayMs, fn = () => pollTick(gen)) => {
378
378
  if (stale(gen)) return;
379
379
  clearTimer();
380
380
  timer = clock.setTimeout(() => {
381
381
  timer = null;
382
- void pollTick(gen);
382
+ void fn();
383
383
  }, delayMs);
384
384
  };
385
385
  function applySnapshot(gen, s) {
@@ -433,12 +433,32 @@ function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = () => {
433
433
  async function init() {
434
434
  const gen = ++generation;
435
435
  clearTimer();
436
+ failures = 0;
437
+ backoffIdx = 0;
438
+ failureWindowStart = null;
439
+ await initAttempt(gen);
440
+ }
441
+ async function initAttempt(gen) {
436
442
  const status = await requestJson(fetchImpl, "/copilot-auth/status", { method: "GET" });
437
443
  if (stale(gen)) return;
438
444
  if (!status.ok) {
439
- emit({ phase: "idle", error: status.error?.messageKey });
445
+ failures += 1;
446
+ if (failureWindowStart === null) failureWindowStart = clock.now();
447
+ const cumulative = clock.now() - failureWindowStart;
448
+ if (cumulative >= AUTO_RETRY_LIMIT_MS) {
449
+ emit({ phase: "idle", error: status.error?.messageKey, connectivity: "manual" });
450
+ return;
451
+ }
452
+ emit({ phase: "loading", connectivity: "retrying", error: status.error?.messageKey });
453
+ const delayS = BACKOFF_STEPS_S[Math.min(backoffIdx, BACKOFF_STEPS_S.length - 1)];
454
+ backoffIdx += 1;
455
+ schedule(gen, delayS * 1e3, () => initAttempt(gen));
440
456
  return;
441
457
  }
458
+ failures = 0;
459
+ backoffIdx = 0;
460
+ failureWindowStart = null;
461
+ emit({ connectivity: "online" });
442
462
  const body = status.body ?? {};
443
463
  if (body.refresh?.scopeAvailable === false) {
444
464
  emit({ phase: "unavailable" });
@@ -449,13 +469,13 @@ function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = () => {
449
469
  emit({ riskLatch: body.authorization.riskLatch });
450
470
  }
451
471
  if (body.configured === true) {
452
- emit({ phase: "authorized" });
472
+ emit({ phase: "authorized", error: void 0 });
453
473
  return;
454
474
  }
455
475
  const r = await requestJson(fetchImpl, "/copilot-auth/state", { method: "GET" });
456
476
  if (stale(gen)) return;
457
477
  if (!r.ok) {
458
- emit({ phase: "idle" });
478
+ emit({ phase: "idle", error: r.error?.messageKey, connectivity: "manual" });
459
479
  return;
460
480
  }
461
481
  applySnapshot(gen, r.body);
@@ -474,6 +494,10 @@ function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = () => {
474
494
  return;
475
495
  }
476
496
  const errorCode = r.error?.details?.errorCode;
497
+ if (errorCode === "already-configured") {
498
+ emit({ phase: "authorized", error: void 0, shared: false });
499
+ return;
500
+ }
477
501
  if (r.httpStatus === 409) {
478
502
  emit({ shared: true });
479
503
  beginPolling(gen);
@@ -497,20 +521,31 @@ function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = () => {
497
521
  }
498
522
  emit({ withdrawalDelivery: "failed", error: r.error?.details?.errorCode ?? r.error?.messageKey });
499
523
  }
500
- function refresh() {
524
+ async function logout() {
501
525
  const gen = ++generation;
502
526
  clearTimer();
503
527
  failures = 0;
504
528
  backoffIdx = 0;
505
529
  failureWindowStart = null;
506
- emit({ connectivity: "online" });
507
- void pollTick(gen);
530
+ emit({ phase: "loading", logoutError: void 0 });
531
+ const r = await requestJson(fetchImpl, "/copilot-auth/logout", { method: "POST" });
532
+ if (stale(gen)) return;
533
+ if (!r.ok) {
534
+ const code = r.error?.details?.errorCode;
535
+ emit({ phase: "authorized", logoutError: code ?? r.error?.messageKey ?? "logout-failed" });
536
+ return;
537
+ }
538
+ emit({ logoutError: void 0 });
539
+ await initAttempt(gen);
540
+ }
541
+ function refresh() {
542
+ void init();
508
543
  }
509
544
  function dispose() {
510
545
  generation += 1;
511
546
  clearTimer();
512
547
  }
513
- return { init, start, cancel, refresh, dispose };
548
+ return { init, start, cancel, logout, refresh, dispose };
514
549
  }
515
550
 
516
551
  // src/client.jsx
@@ -545,6 +580,14 @@ var DICTS = {
545
580
  copy: "Copy",
546
581
  copied: "Copied \u2713",
547
582
  unknown: "Unknown error",
583
+ statusUnknown: "Status unknown",
584
+ errNetwork: "Network error: cannot reach the authorization service. Retrying automatically.",
585
+ errGateway: "Gateway error: the DSH service may be restarting. Retrying automatically.",
586
+ errBadResponse: "Unexpected response from the authorization service.",
587
+ errHttp: "The authorization request failed. Check status and retry.",
588
+ logoutScope: "This removes Copilot authorization from this credential store and affects other instances using it. It does not delete your GitHub account.",
589
+ logoutFailed: "Sign-out did not complete; authorization credentials may remain. Please retry.",
590
+ reauthHint: "Reauthorize is unavailable. Sign out first, then sign in again.",
548
591
  // ---- Model management (protocol v2, design §5) ----
549
592
  supplement: "Add models",
550
593
  rebuild: "Rebuild model list",
@@ -622,6 +665,14 @@ var DICTS = {
622
665
  copy: "\u590D\u5236",
623
666
  copied: "\u5DF2\u590D\u5236 \u2713",
624
667
  unknown: "\u672A\u77E5\u9519\u8BEF",
668
+ statusUnknown: "\u72B6\u6001\u672A\u77E5",
669
+ errNetwork: "\u7F51\u7EDC\u5F02\u5E38\uFF1A\u65E0\u6CD5\u8FDE\u63A5\u6388\u6743\u670D\u52A1\uFF0C\u6B63\u5728\u81EA\u52A8\u91CD\u8BD5\u3002",
670
+ errGateway: "\u7F51\u5173\u9519\u8BEF\uFF1ADSH \u670D\u52A1\u53EF\u80FD\u6B63\u5728\u91CD\u542F\uFF0C\u6B63\u5728\u81EA\u52A8\u91CD\u8BD5\u3002",
671
+ errBadResponse: "\u6388\u6743\u670D\u52A1\u8FD4\u56DE\u5F02\u5E38\u54CD\u5E94\u3002",
672
+ errHttp: "\u6388\u6743\u8BF7\u6C42\u5931\u8D25\u3002\u8BF7\u67E5\u8BE2\u72B6\u6001\u540E\u91CD\u8BD5\u3002",
673
+ logoutScope: "\u5C06\u6E05\u9664\u6B64\u51ED\u636E\u5E93\u4E2D\u7684 Copilot \u6388\u6743\uFF0C\u4F7F\u7528\u540C\u4E00\u51ED\u636E\u5E93\u7684\u5176\u4ED6\u5B9E\u4F8B\u4E5F\u4F1A\u53D7\u5230\u5F71\u54CD\u3002\u6B64\u64CD\u4F5C\u4E0D\u6CE8\u9500 GitHub \u8D26\u53F7\u3002",
674
+ logoutFailed: "\u9000\u51FA\u767B\u5F55\u672A\u5B8C\u6210\uFF0C\u6388\u6743\u51ED\u636E\u4ECD\u53EF\u80FD\u5B58\u5728\u3002\u8BF7\u91CD\u8BD5\u3002",
675
+ reauthHint: "\u91CD\u65B0\u6388\u6743\u6682\u4E0D\u53EF\u7528\uFF1A\u8BF7\u5148\u9000\u51FA\u767B\u5F55\uFF0C\u518D\u91CD\u65B0\u6388\u6743\u3002",
625
676
  // ---- 模型管理(协议 v2,设计§5) ----
626
677
  supplement: "\u8865\u5145\u6A21\u578B",
627
678
  rebuild: "\u91CD\u5EFA\u6A21\u578B\u5217\u8868",
@@ -681,6 +732,14 @@ function blockedReasonText(t, flags) {
681
732
  if (reason && t("blockedReasons")?.[reason]) return t("blockedReasons")[reason];
682
733
  return t("unavailable");
683
734
  }
735
+ function httpErrorText(t, key) {
736
+ return {
737
+ "network-error": t("errNetwork"),
738
+ "bad-gateway": t("errGateway"),
739
+ "bad-response": t("errBadResponse"),
740
+ "http-error": t("errHttp")
741
+ }[key];
742
+ }
684
743
  var COPILOT_ICON_SVG = '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style="flex:none"><path d="M7.998 15.035c-4.562 0-7.873-2.914-7.998-3.749V9.338c.085-.628.677-1.686 1.588-2.065.013-.07.024-.143.036-.218.029-.183.06-.384.126-.612-.201-.508-.254-1.084-.254-1.656 0-.87.128-1.769.693-2.484.579-.733 1.494-1.124 2.724-1.261 1.206-.134 2.262.034 2.944.765.05.053.096.108.139.165.044-.057.094-.112.143-.165.682-.731 1.738-.899 2.944-.765 1.23.137 2.145.528 2.724 1.261.566.715.693 1.614.693 2.484 0 .572-.053 1.148-.254 1.656.066.228.098.429.126.612.924.385 1.522 1.471 1.591 2.095v1.872c0 .766-3.351 3.795-8.002 3.795Zm0-1.485c2.28 0 4.584-1.11 5.002-1.433V7.862l-.023-.116c-.49.21-1.075.291-1.727.291-1.146 0-2.059-.327-2.71-.991A3.222 3.222 0 0 1 8 6.303a3.24 3.24 0 0 1-.544.743c-.65.664-1.563.991-2.71.991-.652 0-1.236-.081-1.727-.291l-.023.116v4.255c.419.323 2.722 1.433 5.002 1.433ZM6.762 2.83c-.193-.206-.637-.413-1.682-.297-1.019.113-1.479.404-1.713.7-.247.312-.369.789-.369 1.554 0 .793.129 1.171.308 1.371.162.181.519.379 1.442.379.853 0 1.339-.235 1.638-.54.315-.322.527-.827.617-1.553.117-.935-.037-1.395-.241-1.614Zm4.155-.297c-1.044-.116-1.488.091-1.681.297-.204.219-.359.679-.242 1.614.091.726.303 1.231.618 1.553.299.305.784.54 1.638.54.922 0 1.28-.198 1.442-.379.179-.2.308-.578.308-1.371 0-.765-.123-1.242-.37-1.554-.233-.296-.693-.587-1.713-.7Z"/><path d="M6.25 9.037a.75.75 0 0 1 .75.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 .75-.75Zm4.25.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 1.5 0Z"/></svg>';
685
744
  function enforceNavIcon(labelText) {
686
745
  if (!labelText) return;
@@ -962,15 +1021,34 @@ function RetireDialog({ t, onConfirm, onCancel }) {
962
1021
  ] })
963
1022
  ] }) });
964
1023
  }
1024
+ function ConfirmDialog({ title, body, confirmText, cancelText, onConfirm, onCancel }) {
1025
+ const cancelRef = (0, import_react.useRef)(null);
1026
+ const surface = (0, import_react.useRef)(null);
1027
+ if (!surface.current) surface.current = sampleThemeSurface();
1028
+ (0, import_react.useEffect)(() => {
1029
+ cancelRef.current?.focus();
1030
+ }, []);
1031
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: styles.modalMask, role: "dialog", "aria-modal": "true", onKeyDown: (e) => {
1032
+ if (e.key === "Escape") onCancel();
1033
+ }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { ...styles.modal, background: surface.current.bg, color: surface.current.fg, maxWidth: 460 }, children: [
1034
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h4", { style: styles.modalTitle, children: title }),
1035
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.modalText, children: body }),
1036
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.modalActions, children: [
1037
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { ref: cancelRef, type: "button", style: { ...styles.button, ...styles.secondary }, onClick: onCancel, children: cancelText }),
1038
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.primary }, onClick: onConfirm, children: confirmText })
1039
+ ] })
1040
+ ] }) });
1041
+ }
965
1042
  function CopilotSection({ t = (key) => DICTS.en[key] ?? key }) {
966
1043
  const [auth, setAuth] = (0, import_react.useState)({ phase: "loading", connectivity: "online", shared: false });
967
1044
  const [copied, setCopied] = (0, import_react.useState)(false);
968
1045
  const [copyFail, setCopyFail] = (0, import_react.useState)(false);
1046
+ const [logoutConfirm, setLogoutConfirm] = (0, import_react.useState)(false);
969
1047
  const [flow, setFlow] = (0, import_react.useState)(initial);
970
1048
  const flowRef = (0, import_react.useRef)(flow);
971
1049
  const authFlowRef = (0, import_react.useRef)(null);
972
1050
  if (!authFlowRef.current && typeof fetch === "function") {
973
- authFlowRef.current = createAuthFlow({ onState: (s) => setAuth(s) });
1051
+ authFlowRef.current = createAuthFlow({ fetchImpl: fetch, onState: (s) => setAuth(s) });
974
1052
  }
975
1053
  const drive = (event) => advance(flowRef.current, event, fetch, (next) => {
976
1054
  flowRef.current = next;
@@ -995,6 +1073,10 @@ function CopilotSection({ t = (key) => DICTS.en[key] ?? key }) {
995
1073
  const checkStatus = () => {
996
1074
  void authFlowRef.current?.refresh();
997
1075
  };
1076
+ const doLogout = () => {
1077
+ setLogoutConfirm(false);
1078
+ void authFlowRef.current?.logout();
1079
+ };
998
1080
  const copyCode = async (code) => {
999
1081
  try {
1000
1082
  await navigator.clipboard.writeText(code);
@@ -1012,7 +1094,7 @@ function CopilotSection({ t = (key) => DICTS.en[key] ?? key }) {
1012
1094
  const n = notices.find((x) => x && typeof x.code === "string" && x.code !== "");
1013
1095
  return n ? { code: n.code, url: n.url } : null;
1014
1096
  })();
1015
- const badgeText = t(phase === "risk" ? "riskBadge" : phase === "loading" ? "loading" : phase);
1097
+ const badgeText = phase === "risk" ? t("riskBadge") : phase === "loading" ? t("loading") : phase === "idle" && auth.error ? t("statusUnknown") : t(phase);
1016
1098
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.section, children: [
1017
1099
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h3", { style: styles.title, children: t("title") }),
1018
1100
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.intro, children: t("intro") }),
@@ -1055,10 +1137,34 @@ function CopilotSection({ t = (key) => DICTS.en[key] ?? key }) {
1055
1137
  t("unavailable")
1056
1138
  ] }),
1057
1139
  phase === "failed" && attempt?.error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.error, children: attempt.error }),
1140
+ phase === "failed" && !attempt?.error && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
1141
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
1142
+ "\u26A0 ",
1143
+ httpErrorText(t, auth.error) ?? t("unknown")
1144
+ ] }),
1145
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.secondary }, onClick: checkStatus, children: t("retryNow") }) })
1146
+ ] }),
1147
+ phase === "idle" && auth.error && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
1148
+ "\u26A0 ",
1149
+ httpErrorText(t, auth.error) ?? t("unknown")
1150
+ ] }),
1058
1151
  phase === "authorized" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.card, children: [
1059
1152
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }, children: [
1060
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.secondary, opacity: 0.5, cursor: "not-allowed" }, disabled: true, title: t("authUnsafe"), children: t("logout") }),
1061
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.secondary, opacity: 0.5, cursor: "not-allowed" }, disabled: true, title: t("authUnsafe"), children: t("reauthorize") }),
1153
+ (() => {
1154
+ const logoutAvailable = auth.status?.authorization?.capabilities?.logout === true;
1155
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1156
+ "button",
1157
+ {
1158
+ type: "button",
1159
+ style: { ...styles.button, ...styles.secondary, ...logoutAvailable ? null : { opacity: 0.5, cursor: "not-allowed" } },
1160
+ disabled: !logoutAvailable,
1161
+ title: logoutAvailable ? t("logoutScope") : t("authUnsafe"),
1162
+ onClick: () => setLogoutConfirm(true),
1163
+ children: t("logout")
1164
+ }
1165
+ );
1166
+ })(),
1167
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.secondary, opacity: 0.5, cursor: "not-allowed" }, disabled: true, title: t("reauthHint"), children: t("reauthorize") }),
1062
1168
  ["previewing", "applying", "checking", "retiring"].includes(flow.name) ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.primary, opacity: 0.5 }, disabled: true, children: flow.name === "applying" ? t("applying") : t("refreshing") }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
1063
1169
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1064
1170
  "button",
@@ -1084,7 +1190,11 @@ function CopilotSection({ t = (key) => DICTS.en[key] ?? key }) {
1084
1190
  ] }),
1085
1191
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
1086
1192
  "\u24D8 ",
1087
- t("authUnsafe")
1193
+ t("reauthHint")
1194
+ ] }),
1195
+ auth.logoutError && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.error, children: [
1196
+ "\u26A0 ",
1197
+ auth.logoutError === "logout-unsafe" ? t("authUnsafe") : t("logoutFailed")
1088
1198
  ] }),
1089
1199
  refreshBlocked(flow.flags) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
1090
1200
  "\u26A0 ",
@@ -1157,6 +1267,17 @@ function CopilotSection({ t = (key) => DICTS.en[key] ?? key }) {
1157
1267
  onCancel: () => drive({ type: "cancel" })
1158
1268
  }
1159
1269
  ),
1270
+ logoutConfirm && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1271
+ ConfirmDialog,
1272
+ {
1273
+ title: t("logout"),
1274
+ body: t("logoutScope"),
1275
+ confirmText: t("logout"),
1276
+ cancelText: t("cancel"),
1277
+ onCancel: () => setLogoutConfirm(false),
1278
+ onConfirm: doLogout
1279
+ }
1280
+ ),
1160
1281
  (phase === "idle" || phase === "failed") && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.primary }, onClick: login, children: t("login") }) })
1161
1282
  ] });
1162
1283
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@inventec/dsh-copilot-auth",
3
- "version": "1.2.8",
4
- "description": "为内置 Copilot 提供方补设备码登录设置页,预置开箱路由;凭据入内置凭据库,模型目录兜底不覆盖定制。v1.2.8:请求撤回/禁用不安全退出、补充模型/重建模型列表双入口(协议 v2)。适配 DSH 0.1.7+(≤0.1.5 请用 1.1.x)。",
3
+ "version": "1.2.10",
4
+ "description": "为内置 Copilot 提供方补设备码登录设置页,预置开箱路由;凭据入内置凭据库,模型目录兜底不覆盖定制。v1.2.10:退出登录正式可用(静止态删凭据+删后核实,尝试期/风险锁存期拒绝),失败原因分层展示(协议 v2)。适配 DSH 0.1.7+(≤0.1.5 请用 1.1.x)。",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "iasi",
package/src/auth-flow.mjs CHANGED
@@ -36,12 +36,12 @@ export function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = ()
36
36
  timer = null;
37
37
  }
38
38
  };
39
- const schedule = (gen, delayMs) => {
39
+ const schedule = (gen, delayMs, fn = () => pollTick(gen)) => {
40
40
  if (stale(gen)) return;
41
41
  clearTimer();
42
42
  timer = clock.setTimeout(() => {
43
43
  timer = null;
44
- void pollTick(gen);
44
+ void fn();
45
45
  }, delayMs);
46
46
  };
47
47
 
@@ -100,12 +100,35 @@ export function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = ()
100
100
  async function init() {
101
101
  const gen = ++generation;
102
102
  clearTimer();
103
+ failures = 0;
104
+ backoffIdx = 0;
105
+ failureWindowStart = null;
106
+ await initAttempt(gen);
107
+ }
108
+
109
+ // init 内核:/status 失败时按 1/2/4/8/15 秒退避自动重试,累计 60 秒转手动;
110
+ // 不把加载失败伪装成「未登录」(历史缺陷:502 窗口期打开页面会显示未登录+授权按钮)。
111
+ async function initAttempt(gen) {
103
112
  const status = await requestJson(fetchImpl, "/copilot-auth/status", { method: "GET" });
104
113
  if (stale(gen)) return;
105
114
  if (!status.ok) {
106
- emit({ phase: "idle", error: status.error?.messageKey });
115
+ failures += 1;
116
+ if (failureWindowStart === null) failureWindowStart = clock.now();
117
+ const cumulative = clock.now() - failureWindowStart;
118
+ if (cumulative >= AUTO_RETRY_LIMIT_MS) {
119
+ emit({ phase: "idle", error: status.error?.messageKey, connectivity: "manual" });
120
+ return;
121
+ }
122
+ emit({ phase: "loading", connectivity: "retrying", error: status.error?.messageKey });
123
+ const delayS = BACKOFF_STEPS_S[Math.min(backoffIdx, BACKOFF_STEPS_S.length - 1)];
124
+ backoffIdx += 1;
125
+ schedule(gen, delayS * 1000, () => initAttempt(gen));
107
126
  return;
108
127
  }
128
+ failures = 0;
129
+ backoffIdx = 0;
130
+ failureWindowStart = null;
131
+ emit({ connectivity: "online" });
109
132
  const body = status.body ?? {};
110
133
  if (body.refresh?.scopeAvailable === false) {
111
134
  emit({ phase: "unavailable" });
@@ -116,14 +139,14 @@ export function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = ()
116
139
  emit({ riskLatch: body.authorization.riskLatch });
117
140
  }
118
141
  if (body.configured === true) {
119
- emit({ phase: "authorized" });
142
+ emit({ phase: "authorized", error: undefined });
120
143
  return;
121
144
  }
122
145
  // 未配置时回查最近一次 attempt:恢复轮询或如实呈现终态
123
146
  const r = await requestJson(fetchImpl, "/copilot-auth/state", { method: "GET" });
124
147
  if (stale(gen)) return;
125
148
  if (!r.ok) {
126
- emit({ phase: "idle" });
149
+ emit({ phase: "idle", error: r.error?.messageKey, connectivity: "manual" });
127
150
  return;
128
151
  }
129
152
  applySnapshot(gen, r.body);
@@ -143,6 +166,12 @@ export function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = ()
143
166
  return;
144
167
  }
145
168
  const errorCode = r.error?.details?.errorCode;
169
+ if (errorCode === "already-configured") {
170
+ // 后端确认已有凭据:本页是过期的未登录视图(如短暂 read-error 窗口),
171
+ // 直接恢复已登录视图,不发起注定失败的替换性授权
172
+ emit({ phase: "authorized", error: undefined, shared: false });
173
+ return;
174
+ }
146
175
  if (r.httpStatus === 409) {
147
176
  // 共同尝试:不是失败——展示 shared 并转入对现有 attempt 的轮询
148
177
  emit({ shared: true });
@@ -170,15 +199,30 @@ export function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = ()
170
199
  emit({ withdrawalDelivery: "failed", error: r.error?.details?.errorCode ?? r.error?.messageKey });
171
200
  }
172
201
 
173
- function refresh() {
174
- // 手动重试(60 秒上限后):恢复自动轮询并重置失败窗口
202
+ async function logout() {
203
+ // 退出登录(写操作,单次调用):pending 态 → 成功后完整重跑 init 恢复真实
204
+ // 状态(若晚到写把凭据写了回来,init 会如实显示 authorized,绝不伪报已退出);
205
+ // 失败回到 authorized 并保留 logoutError 供 UI 分层展示。
175
206
  const gen = ++generation;
176
207
  clearTimer();
177
208
  failures = 0;
178
209
  backoffIdx = 0;
179
210
  failureWindowStart = null;
180
- emit({ connectivity: "online" });
181
- void pollTick(gen);
211
+ emit({ phase: "loading", logoutError: undefined });
212
+ const r = await requestJson(fetchImpl, "/copilot-auth/logout", { method: "POST" });
213
+ if (stale(gen)) return;
214
+ if (!r.ok) {
215
+ const code = r.error?.details?.errorCode;
216
+ emit({ phase: "authorized", logoutError: code ?? r.error?.messageKey ?? "logout-failed" });
217
+ return;
218
+ }
219
+ emit({ logoutError: undefined });
220
+ await initAttempt(gen);
221
+ }
222
+
223
+ function refresh() {
224
+ // 手动「查询状态」:完整重跑 init(status→state),可从过期的未登录/失败视图恢复
225
+ void init();
182
226
  }
183
227
 
184
228
  function dispose() {
@@ -186,5 +230,5 @@ export function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = ()
186
230
  clearTimer();
187
231
  }
188
232
 
189
- return { init, start, cancel, refresh, dispose };
233
+ return { init, start, cancel, logout, refresh, dispose };
190
234
  }
package/src/auth-host.mjs CHANGED
@@ -10,7 +10,8 @@ import { randomUUID } from "node:crypto";
10
10
  import { CREDENTIAL_KEY } from "./shared.mjs";
11
11
 
12
12
  export const WAIT_LIMIT_MS = 15 * 60 * 1000;
13
- const LIVE_STATUSES = ["starting", "waiting", "finishing"];
13
+ // 进行中的授权尝试状态:begin 的晚写窗口,期间退出/撤回类操作一律拒绝
14
+ export const LIVE_STATUSES = ["starting", "waiting", "finishing"];
14
15
 
15
16
  // 授权错误脱敏:仅保留安全字符集(字母/数字/空格/点/冒号/逗号/连字符),
16
17
  // 阻断上游异常文本或疑似 token(含 = ; / + _ 等的串)进入状态面。
@@ -40,12 +41,22 @@ export function createAuthorizationController(ctx, { scope, intentIO, clock = {
40
41
  if (!scope?.known) throw authError("SCOPE_UNAVAILABLE", "scope-unavailable");
41
42
  };
42
43
 
43
- function start() {
44
+ async function start() {
44
45
  requireScope();
45
46
  if (riskLatch) throw authError("AUTH_UNSAFE", "auth-unsafe", { riskLatch });
46
47
  if (attempt && LIVE_STATUSES.includes(attempt.status)) {
47
48
  throw authError("ATTEMPT_RUNNING", "already running", { attemptId: attempt.attemptId });
48
49
  }
50
+ // 初始授权条件(设计 Q21):已有凭据时禁止新的授权尝试——那是对既有授权的
51
+ // 隐式替换(历史实证:已登录状态下点「授权登录」,begin 带着已存在的凭据发起
52
+ // 设备流,attempt 永远停在 starting,最终以「失败」收场)。「重新授权」在宿主
53
+ // 可证明安全前默认禁用。describeRecord 自身失败不阻断(读不到≠存在)。
54
+ try {
55
+ const info = await ctx.credentials.describeRecord(CREDENTIAL_KEY);
56
+ if (info?.configured === true) throw authError("ALREADY_CONFIGURED", "already-configured");
57
+ } catch (err) {
58
+ if (err?.code === "ALREADY_CONFIGURED") throw err;
59
+ }
49
60
  // 先持久意图版本,失败不发 begin(安全门拒绝动作不产生副作用)
50
61
  const originIntentVersion = intentIO.bump();
51
62
  const a = {
@@ -137,9 +148,33 @@ export function createAuthorizationController(ctx, { scope, intentIO, clock = {
137
148
  return { withdrawalDelivery };
138
149
  }
139
150
 
140
- function logout() {
141
- // 当前宿主无法证明安全退出(D-01):统一拒绝,不调用 deleteRecord
142
- throw authError("LOGOUT_UNAVAILABLE", "logout-safety-unavailable");
151
+ async function logout() {
152
+ // 退出安全条件(v1.2.10 起,经主人裁决替代 D-01 全禁策略):
153
+ // ① 无进行中的授权尝试(begin 晚写窗口)② 无风险锁存(撤回/超时未核实)。
154
+ // 满足后凭据库视为静止:deleteRecord+删除后 describe 复核;晚到写无法绝对
155
+ // 排除,以复核与状态页如实展示兜底——退出未核实成功绝不伪报成功。
156
+ requireScope();
157
+ if (attempt && LIVE_STATUSES.includes(attempt.status)) {
158
+ throw authError("ATTEMPT_RUNNING", "already running", { attemptId: attempt.attemptId });
159
+ }
160
+ if (riskLatch) throw authError("LOGOUT_UNSAFE", "logout-unsafe");
161
+ // 本 profile 显式操作:旧预览/模型配置意图先行失效(失败则不动凭据)
162
+ intentIO.bump();
163
+ try {
164
+ const deleted = await ctx.credentials.deleteRecord(CREDENTIAL_KEY);
165
+ if (deleted === false) throw authError("LOGOUT_FAILED", "logout-failed");
166
+ } catch (err) {
167
+ if (err?.code === "LOGOUT_FAILED") throw err;
168
+ throw authError("LOGOUT_FAILED", "logout-failed");
169
+ }
170
+ try {
171
+ const info = await ctx.credentials.describeRecord(CREDENTIAL_KEY);
172
+ if (info?.configured === true) throw authError("LOGOUT_FAILED", "logout-failed");
173
+ } catch (err) {
174
+ if (err?.code === "LOGOUT_FAILED") throw err;
175
+ throw authError("LOGOUT_FAILED", "logout-failed");
176
+ }
177
+ return { ok: true };
143
178
  }
144
179
 
145
180
  function snapshot() {
package/src/host.mjs CHANGED
@@ -9,7 +9,7 @@ import { homedir } from "node:os";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { CREDENTIAL_KEY, PROTOCOL_VERSION, routes } from "./shared.mjs";
11
11
  import { readJson, writeJsonAtomic, createMutex } from "./atomic-json.mjs";
12
- import { createAuthorizationController, sanitizeAuthError } from "./auth-host.mjs";
12
+ import { createAuthorizationController, sanitizeAuthError, LIVE_STATUSES } from "./auth-host.mjs";
13
13
  import { resolveRuntimeScope } from "./runtime-scope.mjs";
14
14
  import { mergeCatalog, digest, findPiAiInstallation, classifyCatalogTarget, normalizeRemoteCatalog, catalogShapeKeys } from "./catalog.mjs";
15
15
  import { fetchLatestCatalog } from "./catalog-fetch.mjs";
@@ -501,14 +501,14 @@ export function apply(ctx, opts = {}) {
501
501
  ctx.webServer.register({
502
502
  kind: "exact",
503
503
  path: r.start,
504
- handler: (req, res) => {
504
+ handler: async (req, res) => {
505
505
  if (!guard(req, res, "POST")) return;
506
506
  if (!scope.known) {
507
507
  json(res, 503, { ok: false, error: "scope-unavailable" });
508
508
  return;
509
509
  }
510
510
  try {
511
- const { attemptId } = controller.start();
511
+ const { attemptId } = await controller.start();
512
512
  json(res, 202, { ok: true, attemptId });
513
513
  } catch (err) {
514
514
  if (err?.code === "ATTEMPT_RUNNING") {
@@ -519,6 +519,10 @@ export function apply(ctx, opts = {}) {
519
519
  json(res, 409, { ok: false, error: "auth-unsafe" });
520
520
  return;
521
521
  }
522
+ if (err?.code === "ALREADY_CONFIGURED") {
523
+ json(res, 409, { ok: false, error: "already-configured" });
524
+ return;
525
+ }
522
526
  json(res, 500, { ok: false, error: sanitizeAuthError(err) });
523
527
  }
524
528
  },
@@ -565,6 +569,12 @@ export function apply(ctx, opts = {}) {
565
569
  credential = "read-error";
566
570
  }
567
571
  const authSnapshot = controller.snapshot();
572
+ // 退出可用性(v1.2.10):无进行中尝试且无风险锁存才可安全删除凭据;
573
+ // 重新授权恒不可用(等价于退出+登录,请分步执行)
574
+ const logoutAvailable = !authSnapshot.riskLatch
575
+ && !LIVE_STATUSES.includes(authSnapshot.status)
576
+ && authSnapshot.status !== "withdrawal-pending-unverified"
577
+ && authSnapshot.status !== "timed-out-unverified";
568
578
  // refresh 块:只读聚合(通道分类来自启动期缓存;GET 不执行写探针,Q23)
569
579
  const loaded = loadRefreshState(scope, { legacyPath });
570
580
  const st = loaded.flags.unknownVersion ? null : loaded.state;
@@ -619,7 +629,7 @@ export function apply(ctx, opts = {}) {
619
629
  credential,
620
630
  attempt: authSnapshot,
621
631
  riskLatch: authSnapshot.riskLatch,
622
- capabilities: { logout: false, reauthorize: false },
632
+ capabilities: { logout: logoutAvailable, reauthorize: false },
623
633
  },
624
634
  refresh,
625
635
  ...(operation ? { operation } : {}),
@@ -627,13 +637,35 @@ export function apply(ctx, opts = {}) {
627
637
  },
628
638
  });
629
639
 
630
- // 退出登录:当前宿主桥接无法证明安全退出(D-01),统一拒绝且绝不 deleteRecord。
640
+ // 退出登录(v1.2.10):无进行中尝试且无风险锁存时执行凭据删除+删后核实;
641
+ // 进行中尝试(begin 晚写窗口)与风险锁存期(撤回/超时未核实)一律拒绝。
631
642
  ctx.webServer.register({
632
643
  kind: "exact",
633
644
  path: r.logout,
634
- handler: (req, res) => {
645
+ handler: async (req, res) => {
635
646
  if (!guard(req, res, "POST")) return;
636
- json(res, 403, { ok: false, error: "logout-safety-unavailable" });
647
+ if (!scope.known) {
648
+ json(res, 503, { ok: false, error: "scope-unavailable" });
649
+ return;
650
+ }
651
+ try {
652
+ await controller.logout();
653
+ json(res, 200, { ok: true });
654
+ } catch (err) {
655
+ if (err?.code === "ATTEMPT_RUNNING") {
656
+ json(res, 409, { ok: false, error: "already running", attemptId: err.attemptId });
657
+ return;
658
+ }
659
+ if (err?.code === "LOGOUT_UNSAFE") {
660
+ json(res, 409, { ok: false, error: "logout-unsafe" });
661
+ return;
662
+ }
663
+ if (err?.code === "LOGOUT_FAILED") {
664
+ json(res, 409, { ok: false, error: "logout-failed" });
665
+ return;
666
+ }
667
+ json(res, 500, { ok: false, error: sanitizeAuthError(err) });
668
+ }
637
669
  },
638
670
  });
639
671