@vibedev-si/dsh-vibedev 0.2.7 → 0.3.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.
package/lib/index.js CHANGED
@@ -53,12 +53,14 @@ var AccountService = class {
53
53
  const credential = await this.options.chain.resolve().catch(() => void 0);
54
54
  const source = credential === void 0 ? "none" : credential.kind === "account" ? "host" : credential.kind;
55
55
  const pending = this.options.login.pendingSignIn();
56
+ const attempt = this.options.login.signInAttempt();
56
57
  const base = {
57
58
  source,
58
59
  models: this.options.models(),
59
60
  links: this.links(),
60
61
  primary: this.options.primary === true,
61
- ...pending === void 0 ? {} : { pending }
62
+ ...pending === void 0 ? {} : { pending },
63
+ ...attempt === void 0 ? {} : { attempt }
62
64
  };
63
65
  if (credential === void 0) return base;
64
66
  const grantUser = credential.kind === "plugin" ? await this.options.login.user().catch(() => void 0) : void 0;
@@ -82,7 +84,8 @@ var AccountService = class {
82
84
  authorization: `Bearer ${token}`,
83
85
  "user-agent": this.options.userAgent
84
86
  },
85
- signal: AbortSignal.timeout(15e3)
87
+ signal: AbortSignal.timeout(15e3),
88
+ redirect: "error"
86
89
  });
87
90
  if (!response.ok) return {};
88
91
  const body = record$8(await response.json().catch(() => void 0));
@@ -166,9 +169,11 @@ function json(status, body) {
166
169
  });
167
170
  }
168
171
  function failure(error) {
172
+ const candidate = record$8(error)?.code;
173
+ const code = candidate === "SIGN_IN_REFUSED" || candidate === "SIGN_IN_TIMEOUT" || candidate === "SIGN_IN_CANCELLED" ? candidate : "INTERNAL";
169
174
  return json(500, { error: {
170
- code: text$8(record$8(error)?.code) ?? "INTERNAL",
171
- message: error instanceof Error ? error.message : String(error)
175
+ code,
176
+ message: code === "SIGN_IN_CANCELLED" ? "Sign-in was cancelled. 登录已取消。" : code === "SIGN_IN_TIMEOUT" ? "Sign-in expired. 登录已超时,请重新登录。" : "The account request could not be completed. 账号请求未完成,请查看登录诊断后重试。"
172
177
  } });
173
178
  }
174
179
  /**
@@ -511,6 +516,32 @@ async function downloadTo(response, temporary, maxBytes, signal) {
511
516
  }
512
517
  }
513
518
  //#endregion
519
+ //#region src/gateway/origin.ts
520
+ /** Origin boundaries for accounts and durable media state. No endpoint fallback is defined here. */
521
+ /** Production API origin. Storage URLs are returned by the server, not derived from this address. */
522
+ const DEFAULT_GATEWAY_ORIGIN = "https://api.vibedev.studio";
523
+ /** Validate a bare HTTP(S) origin and canonicalize default ports, casing and trailing slash. */
524
+ function normalizeGatewayOrigin(value) {
525
+ const url = new URL(value.trim());
526
+ if (!["https:", "http:"].includes(url.protocol) || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") throw new Error("gatewayOrigin must be a bare HTTP(S) origin, without credentials, path, query or fragment.");
527
+ return url.origin;
528
+ }
529
+ /** Stable, filesystem-safe identity; ports and schemes remain separate boundaries. */
530
+ function gatewayId(origin) {
531
+ return createHash("sha256").update(normalizeGatewayOrigin(origin)).digest("hex");
532
+ }
533
+ /** Every gateway uses its own namespace; never read the unmarked historical root. */
534
+ function gatewayStateDirectory(root, origin) {
535
+ return join(root, "gateways", gatewayId(origin));
536
+ }
537
+ /** Ignore the old shared key variable; development requires an explicitly named, gateway-specific variable. */
538
+ function developmentKey(origin, name, env = process.env) {
539
+ normalizeGatewayOrigin(origin);
540
+ const key = name.trim();
541
+ if (key === "" || key === "VIBEDEV_GATEWAY_API_KEY") return void 0;
542
+ return env[key]?.trim() || void 0;
543
+ }
544
+ //#endregion
514
545
  //#region src/auth/credentials.ts
515
546
  /**
516
547
  * Which VibeDev credential a gateway request carries, in order: the host's
@@ -555,7 +586,10 @@ var CredentialChain = class {
555
586
  else if (credential.kind === "plugin") await this.options.plugin?.reject(credential.token);
556
587
  }
557
588
  };
558
- const GRANT_KEY = credentialKey("dsh-media", "vibedev-session");
589
+ /** Credential identity for one gateway; never consult another gateway's record. */
590
+ function gatewayGrantKey(origin) {
591
+ return credentialKey("dsh-vibedev", `gateway-${gatewayId(origin)}`);
592
+ }
559
593
  function isGrant(value) {
560
594
  const grant = value;
561
595
  return typeof grant === "object" && grant !== null && typeof grant.accessToken === "string" && typeof grant.refreshToken === "string" && typeof grant.expiresAt === "number" && typeof grant.refreshExpiresAt === "number";
@@ -564,11 +598,23 @@ function isGrant(value) {
564
598
  * Grant storage: the host's credential store when one is loaded (a grant
565
599
  * record the store keeps verbatim), else a private file in the state
566
600
  * directory. The device id is not a secret and lives in the state directory.
567
- * @param stateDir - the plugin state directory.
601
+ * @param root - the plugin state root; the storage selects an origin-specific directory itself.
568
602
  * @param credentials - the host's credential store, when loaded.
603
+ * @param selectedOrigin - the issuing gateway; unmarked historical state is never adopted.
569
604
  * @returns the storage.
570
605
  */
571
- function grantStorage(stateDir, credentials) {
606
+ function grantStorage(root, credentials, selectedOrigin = DEFAULT_GATEWAY_ORIGIN) {
607
+ const origin = normalizeGatewayOrigin(selectedOrigin);
608
+ const key = gatewayGrantKey(origin);
609
+ const stateDir = gatewayStateDirectory(root, origin);
610
+ const readGrant = (value) => isGrant(value) && value.gatewayOrigin === origin ? value : void 0;
611
+ const bind = (grant) => {
612
+ if (grant.gatewayOrigin !== void 0 && grant.gatewayOrigin !== origin) throw new Error("Cannot store a grant issued by another gateway.");
613
+ return {
614
+ ...grant,
615
+ gatewayOrigin: origin
616
+ };
617
+ };
572
618
  const sessionFile = join(stateDir, "session.json");
573
619
  const deviceFile = join(stateDir, "device.json");
574
620
  let device;
@@ -581,9 +627,9 @@ function grantStorage(stateDir, credentials) {
581
627
  throw error;
582
628
  }
583
629
  const parsed = JSON.parse(text);
584
- return isGrant(parsed) ? parsed : void 0;
630
+ return readGrant(parsed);
585
631
  };
586
- const writeFileGrant = (grant) => writeFileAtomic(sessionFile, new TextEncoder().encode(grant === void 0 ? "null" : JSON.stringify(grant)), { mode: 384 });
632
+ const writeFileGrant = (grant) => writeFileAtomic(sessionFile, new TextEncoder().encode(grant === void 0 ? "null" : JSON.stringify(bind(grant))), { mode: 384 });
587
633
  const clearFileGrant = async () => {
588
634
  try {
589
635
  await stat(sessionFile);
@@ -599,9 +645,9 @@ function grantStorage(stateDir, credentials) {
599
645
  } catch {}
600
646
  };
601
647
  const readHostGrant = async (store) => {
602
- const stored = await store.readRecord(GRANT_KEY);
648
+ const stored = await store.readRecord(key);
603
649
  if (stored !== void 0) {
604
- const grant = stored.kind === "grant" && isGrant(stored.payload) ? stored.payload : void 0;
650
+ const grant = stored.kind === "grant" ? readGrant(stored.payload) : void 0;
605
651
  if (grant !== void 0) await clearFileGrantQuietly();
606
652
  return grant;
607
653
  }
@@ -612,15 +658,15 @@ function grantStorage(stateDir, credentials) {
612
658
  throw error;
613
659
  }
614
660
  let adopted;
615
- await store.modifyRecord(GRANT_KEY, async (current) => {
661
+ await store.modifyRecord(key, async (current) => {
616
662
  if (current !== void 0) {
617
- adopted = current.kind === "grant" && isGrant(current.payload) ? current.payload : void 0;
663
+ adopted = current.kind === "grant" ? readGrant(current.payload) : void 0;
618
664
  return;
619
665
  }
620
666
  adopted = await readFileGrant();
621
667
  return adopted === void 0 ? void 0 : {
622
668
  kind: "grant",
623
- payload: adopted
669
+ payload: bind(adopted)
624
670
  };
625
671
  });
626
672
  if (adopted !== void 0) await clearFileGrantQuietly();
@@ -636,11 +682,11 @@ function grantStorage(stateDir, credentials) {
636
682
  if (store !== void 0) {
637
683
  if (grant === void 0) {
638
684
  await clearFileGrant();
639
- await store.deleteRecord(GRANT_KEY);
685
+ await store.deleteRecord(key);
640
686
  } else {
641
- await store.modifyRecord(GRANT_KEY, () => Promise.resolve({
687
+ await store.modifyRecord(key, () => Promise.resolve({
642
688
  kind: "grant",
643
- payload: grant
689
+ payload: bind(grant)
644
690
  }));
645
691
  await clearFileGrantQuietly();
646
692
  }
@@ -653,17 +699,17 @@ function grantStorage(stateDir, credentials) {
653
699
  if (store !== void 0) {
654
700
  await readHostGrant(store);
655
701
  let remove = false;
656
- await store.modifyRecord(GRANT_KEY, async (current) => {
657
- const result = await change(current?.kind === "grant" && isGrant(current.payload) ? current.payload : void 0);
702
+ await store.modifyRecord(key, async (current) => {
703
+ const result = await change(current?.kind === "grant" ? readGrant(current.payload) : void 0);
658
704
  if (result.kind === "set") return {
659
705
  kind: "grant",
660
- payload: result.grant
706
+ payload: bind(result.grant)
661
707
  };
662
708
  remove = result.kind === "delete";
663
709
  });
664
710
  if (remove) {
665
711
  await clearFileGrant();
666
- await store.deleteRecord(GRANT_KEY);
712
+ await store.deleteRecord(key);
667
713
  }
668
714
  return;
669
715
  }
@@ -686,6 +732,100 @@ function grantStorage(stateDir, credentials) {
686
732
  };
687
733
  }
688
734
  //#endregion
735
+ //#region src/auth/sign-in-state.ts
736
+ const REDEEM_PATH = "/api/v1/vibedev/link/redeem";
737
+ const NETWORK_CODES = /* @__PURE__ */ new Set([
738
+ "ECONNREFUSED",
739
+ "ECONNRESET",
740
+ "ECONNABORTED",
741
+ "ENOTFOUND",
742
+ "EAI_AGAIN",
743
+ "ENETUNREACH",
744
+ "EHOSTUNREACH",
745
+ "EPIPE",
746
+ "ETIMEDOUT",
747
+ "UND_ERR_CONNECT_TIMEOUT",
748
+ "UND_ERR_HEADERS_TIMEOUT",
749
+ "UND_ERR_BODY_TIMEOUT",
750
+ "UND_ERR_SOCKET",
751
+ "CERT_HAS_EXPIRED",
752
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
753
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
754
+ "ERR_TLS_CERT_ALTNAME_INVALID"
755
+ ]);
756
+ const TIMEOUT_CODES = /* @__PURE__ */ new Set([
757
+ "ETIMEDOUT",
758
+ "UND_ERR_CONNECT_TIMEOUT",
759
+ "UND_ERR_HEADERS_TIMEOUT",
760
+ "UND_ERR_BODY_TIMEOUT"
761
+ ]);
762
+ /** Read only fixed transport codes, never error text, stack, body or arbitrary fields. */
763
+ function transportFailure(error) {
764
+ let cause = error;
765
+ let timeout = false;
766
+ let networkCode;
767
+ for (let depth = 0; depth < 5 && typeof cause === "object" && cause !== null; depth++) {
768
+ const value = cause;
769
+ if (value.name === "TimeoutError") timeout = true;
770
+ if (typeof value.code === "string" && NETWORK_CODES.has(value.code)) {
771
+ networkCode ??= value.code;
772
+ if (TIMEOUT_CODES.has(value.code)) timeout = true;
773
+ }
774
+ cause = value.cause;
775
+ }
776
+ return {
777
+ errorCode: timeout ? "timeout" : "network",
778
+ ...networkCode === void 0 ? {} : { networkCode }
779
+ };
780
+ }
781
+ const EXPLANATIONS = {
782
+ network: ["无法连接登录网关,请检查网络后重新登录。", "The sign-in gateway could not be reached. Check the connection and start sign-in again."],
783
+ timeout: ["登录等待或网关请求已超时,请重新登录。", "Sign-in or the gateway request timed out. Start sign-in again."],
784
+ "gateway-refused": ["登录网关拒绝了请求,请重新登录。", "The gateway refused the sign-in request. Start sign-in again."],
785
+ "gateway-unavailable": ["登录网关暂时不可用,请稍后重新登录。", "The sign-in gateway is temporarily unavailable. Start sign-in again later."],
786
+ protocol: ["登录网关返回了无效的凭据格式,请稍后重新登录。", "The gateway returned an invalid credential response. Start sign-in again later."],
787
+ storage: ["无法准备或保存本地登录凭据,请检查存储权限后重新登录。", "Local sign-in credentials could not be prepared or saved. Check storage permissions and start sign-in again."],
788
+ callback: ["无法启动或使用本地登录回调,请重新登录。", "The local sign-in callback could not be started or used. Start sign-in again."]
789
+ };
790
+ const STAGES = {
791
+ listen: ["本地回调监听", "Local callback listener"],
792
+ device: ["准备本地设备标识", "Preparing the local device identity"],
793
+ redeem: ["交换登录凭据", "Exchanging sign-in credentials"],
794
+ persist: ["保存本地凭据", "Saving local credentials"]
795
+ };
796
+ /** A fixed bilingual explanation suitable for exposed rejection messages. */
797
+ function failureMessage(attempt) {
798
+ const [chinese, english] = EXPLANATIONS[attempt.errorCode ?? "callback"];
799
+ const stage = attempt.stage === void 0 ? "" : ` ${STAGES[attempt.stage].join(" / ")}; stage=${attempt.stage}.`;
800
+ const status = attempt.httpStatus === void 0 ? "" : ` HTTP ${attempt.httpStatus}.`;
801
+ return `${english} ${chinese} errorCode=${attempt.errorCode ?? "callback"}.${stage}${status}`;
802
+ }
803
+ const escapeHtml = (text) => text.replace(/[&<>"']/g, (character) => ({
804
+ "&": "&amp;",
805
+ "<": "&lt;",
806
+ ">": "&gt;",
807
+ "\"": "&quot;",
808
+ "'": "&#39;"
809
+ })[character]);
810
+ /** Only our fixed explanation and escaped safe enum/status fields reach the browser. */
811
+ function failurePage(attempt) {
812
+ return `<h1>Sign-in failed.</h1><p>${escapeHtml(failureMessage(attempt))}</p><p>请回到对话重新登录。</p>`;
813
+ }
814
+ /** Explicit log allowlist; adding fields to the public attempt never expands log output. */
815
+ function attemptLog(event, attempt) {
816
+ return `dsh-vibedev sign-in ${JSON.stringify({
817
+ event,
818
+ id: attempt.id,
819
+ phase: attempt.phase,
820
+ gatewayOrigin: attempt.gatewayOrigin,
821
+ path: REDEEM_PATH,
822
+ ...attempt.stage === void 0 ? {} : { stage: attempt.stage },
823
+ ...attempt.errorCode === void 0 ? {} : { errorCode: attempt.errorCode },
824
+ ...attempt.httpStatus === void 0 ? {} : { httpStatus: attempt.httpStatus },
825
+ ...attempt.networkCode === void 0 ? {} : { networkCode: attempt.networkCode }
826
+ })}`;
827
+ }
828
+ //#endregion
689
829
  //#region src/auth/login.ts
690
830
  /**
691
831
  * The plugin's own VibeDev sign-in, for hosts whose account service does not
@@ -772,6 +912,7 @@ var PluginLogin = class {
772
912
  readFailureReported = false;
773
913
  refreshing;
774
914
  pending;
915
+ attempt;
775
916
  listeners = /* @__PURE__ */ new Set();
776
917
  now;
777
918
  origin;
@@ -781,7 +922,7 @@ var PluginLogin = class {
781
922
  constructor(options) {
782
923
  this.options = options;
783
924
  this.now = options.now ?? Date.now;
784
- this.origin = options.origin.trim().replace(/\/+$/, "");
925
+ this.origin = normalizeGatewayOrigin(options.origin);
785
926
  }
786
927
  readStored(notify) {
787
928
  if (this.reading !== void 0) return this.reading;
@@ -816,10 +957,32 @@ var PluginLogin = class {
816
957
  const changed = this.grant?.accessToken !== grant?.accessToken;
817
958
  this.grant = grant;
818
959
  if (!changed) return;
960
+ this.notify();
961
+ }
962
+ notify() {
819
963
  for (const listener of this.listeners) try {
820
964
  listener();
821
965
  } catch {}
822
966
  }
967
+ /** A copied safe snapshot, retained after completion until the next sign-in. */
968
+ signInAttempt() {
969
+ return this.attempt === void 0 ? void 0 : Object.freeze({ ...this.attempt });
970
+ }
971
+ logAttempt(event) {
972
+ if (this.attempt === void 0) return;
973
+ try {
974
+ this.options.log?.(attemptLog(event, this.attempt));
975
+ } catch {}
976
+ }
977
+ transition(id, change, event = "phase") {
978
+ if (this.attempt?.id !== id) return;
979
+ this.attempt = {
980
+ ...this.attempt,
981
+ ...change
982
+ };
983
+ this.logAttempt(event);
984
+ this.notify();
985
+ }
823
986
  async save(grant) {
824
987
  await this.options.storage.write(grant);
825
988
  this.adopt(grant);
@@ -842,9 +1005,10 @@ var PluginLogin = class {
842
1005
  }
843
1006
  /** Whether a sign-in is waiting for the browser, and its link. */
844
1007
  pendingSignIn() {
845
- return this.pending === void 0 ? void 0 : {
846
- url: this.pending.url,
847
- expiresAt: this.pending.expiresAt
1008
+ const pending = this.pending;
1009
+ return pending?.url === void 0 ? void 0 : {
1010
+ url: pending.url,
1011
+ expiresAt: pending.expiresAt
848
1012
  };
849
1013
  }
850
1014
  /**
@@ -900,7 +1064,7 @@ var PluginLogin = class {
900
1064
  });
901
1065
  const body = await response.json().catch(() => void 0);
902
1066
  if (response.status === 401 || response.status === 403) {
903
- this.options.log?.(`dsh-vibedev: the VibeDev session ended (${text$6(record$6(body)?.reason) ?? response.status}); signed out`);
1067
+ this.options.log?.(`dsh-vibedev: the VibeDev session ended (HTTP ${response.status}); signed out`);
904
1068
  outcome = void 0;
905
1069
  return { kind: "delete" };
906
1070
  }
@@ -927,7 +1091,7 @@ var PluginLogin = class {
927
1091
  })();
928
1092
  return this.refreshing;
929
1093
  }
930
- post(path, json, token) {
1094
+ post(path, json, token, signal = AbortSignal.timeout(3e4)) {
931
1095
  return (this.options.fetch ?? globalThis.fetch)(`${this.origin}${path}`, {
932
1096
  method: "POST",
933
1097
  headers: {
@@ -937,7 +1101,8 @@ var PluginLogin = class {
937
1101
  ...token === void 0 ? {} : { authorization: `Bearer ${token}` }
938
1102
  },
939
1103
  body: JSON.stringify(json),
940
- signal: AbortSignal.timeout(3e4)
1104
+ redirect: "error",
1105
+ signal
941
1106
  });
942
1107
  }
943
1108
  /**
@@ -948,19 +1113,22 @@ var PluginLogin = class {
948
1113
  * @returns the authorization link, whether a browser was opened, and the completion.
949
1114
  */
950
1115
  async startSignIn(request = {}) {
951
- const open = (url) => request.open === false ? Promise.resolve(false) : (this.options.openBrowser ?? openInBrowser)(url);
952
- if (this.pending !== void 0 && this.pending.expiresAt > this.now()) {
953
- const opened = await open(this.pending.url);
954
- return {
955
- url: this.pending.url,
956
- expiresAt: this.pending.expiresAt,
957
- done: this.pending.done,
958
- opened
959
- };
960
- }
1116
+ if (this.pending !== void 0 && !this.pending.committing && this.pending.expiresAt <= this.now()) this.pending.cancel("expired");
1117
+ const active = this.pending ?? this.beginSignIn();
1118
+ const started = await active.ready;
1119
+ const opened = request.open === false || this.pending !== active ? false : await (this.options.openBrowser ?? openInBrowser)(started.url).catch(() => false);
1120
+ return {
1121
+ ...started,
1122
+ opened
1123
+ };
1124
+ }
1125
+ beginSignIn() {
961
1126
  const verifier = base64url(randomBytes(32));
962
1127
  const challenge = base64url(createHash("sha256").update(verifier).digest());
963
1128
  const state = base64url(randomBytes(18));
1129
+ const id = randomUUID();
1130
+ const expiresAt = this.now() + (this.options.signInTimeoutMs ?? 3e5);
1131
+ const controller = new AbortController();
964
1132
  let resolveDone;
965
1133
  let rejectDone;
966
1134
  const done = new Promise((resolve, reject) => {
@@ -968,8 +1136,73 @@ var PluginLogin = class {
968
1136
  rejectDone = reject;
969
1137
  });
970
1138
  done.catch(() => void 0);
971
- let settled = false;
972
- const server = createServer((request, response) => {
1139
+ let resolveReady;
1140
+ let rejectReady;
1141
+ const ready = new Promise((resolve, reject) => {
1142
+ resolveReady = resolve;
1143
+ rejectReady = reject;
1144
+ });
1145
+ ready.catch(() => void 0);
1146
+ let finished = false;
1147
+ let claimed = false;
1148
+ let server;
1149
+ let timer;
1150
+ const finish = () => {
1151
+ finished = true;
1152
+ clearTimeout(timer);
1153
+ server?.close();
1154
+ server?.closeAllConnections?.();
1155
+ if (this.pending === active) this.pending = void 0;
1156
+ };
1157
+ const fail = (failure, phase = "failed") => {
1158
+ const attempt = {
1159
+ ...this.attempt,
1160
+ ...failure,
1161
+ phase
1162
+ };
1163
+ const error = new MediaError(phase === "cancelled" ? "The sign-in was cancelled." : failureMessage(attempt), phase === "expired" ? "SIGN_IN_TIMEOUT" : phase === "cancelled" ? "SIGN_IN_CANCELLED" : "SIGN_IN_REFUSED", attempt.httpStatus === void 0 ? {} : { status: attempt.httpStatus });
1164
+ finish();
1165
+ controller.abort();
1166
+ this.transition(id, {
1167
+ ...failure,
1168
+ phase
1169
+ }, "terminal");
1170
+ rejectReady(error);
1171
+ rejectDone(error);
1172
+ return error;
1173
+ };
1174
+ const active = {
1175
+ id,
1176
+ expiresAt,
1177
+ ready,
1178
+ done,
1179
+ committing: false,
1180
+ cancel: (phase) => {
1181
+ if (!finished && !active.committing) fail(phase === "expired" ? { errorCode: "timeout" } : {}, phase);
1182
+ }
1183
+ };
1184
+ this.pending = active;
1185
+ this.attempt = {
1186
+ id,
1187
+ expiresAt,
1188
+ gatewayOrigin: this.origin,
1189
+ phase: "preparing",
1190
+ stage: "listen"
1191
+ };
1192
+ this.logAttempt("request");
1193
+ this.notify();
1194
+ const live = () => {
1195
+ if (finished || this.pending !== active) return false;
1196
+ if (!active.committing && this.now() >= expiresAt) {
1197
+ active.cancel("expired");
1198
+ return false;
1199
+ }
1200
+ return true;
1201
+ };
1202
+ if (!live()) return active;
1203
+ timer = setTimeout(() => active.cancel("expired"), Math.max(0, expiresAt - this.now()));
1204
+ timer.unref();
1205
+ const callback = (request, response) => {
973
1206
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
974
1207
  if (url.pathname !== CALLBACK_PATH) {
975
1208
  response.writeHead(404).end();
@@ -981,107 +1214,170 @@ var PluginLogin = class {
981
1214
  "cache-control": "no-store"
982
1215
  }).end(PAGE(title, body));
983
1216
  };
984
- if (settled) {
985
- answer(410, "VibeDev", "<h1>This sign-in link has already been used.</h1><p>此登录链接已使用过。</p>");
1217
+ if (request.method !== "GET") {
1218
+ response.setHeader("allow", "GET");
1219
+ answer(405, "VibeDev", "<h1>Use GET for sign-in.</h1><p>请使用浏览器登录回调。</p>");
986
1220
  return;
987
1221
  }
988
- if (url.searchParams.get("state") !== state || url.searchParams.get("code") === null) {
1222
+ if (claimed || !live()) {
1223
+ answer(410, "VibeDev", "<h1>This sign-in link has already been used.</h1><p>此登录链接已使用过或已失效。</p>");
1224
+ return;
1225
+ }
1226
+ const states = url.searchParams.getAll("state");
1227
+ const codes = url.searchParams.getAll("code");
1228
+ const code = codes[0];
1229
+ if (states.length !== 1 || states[0] !== state || codes.length !== 1 || code === void 0 || code.trim() === "") {
989
1230
  answer(400, "VibeDev", "<h1>Sign-in could not be completed.</h1><p>登录未完成:链接无效,请回到对话重新登录。</p>");
990
1231
  return;
991
1232
  }
992
- settled = true;
993
- const code = url.searchParams.get("code");
994
- this.redeem(code, verifier).then((grant) => {
995
- const who = grant.user?.email ?? grant.user?.nickname;
996
- answer(200, "VibeDev", `<h1>Signed in to VibeDev${who === void 0 ? "" : ` as ${who.replace(/[<>&"]/g, "")}`}.</h1><p>已登录 VibeDev,可以关闭此页面,回到对话继续。</p><p>You can close this tab and return to the conversation.</p>`);
1233
+ claimed = true;
1234
+ this.transition(id, {
1235
+ phase: "exchanging",
1236
+ stage: "device"
1237
+ }, "request");
1238
+ (async () => {
1239
+ let grant;
1240
+ try {
1241
+ if (!live()) return;
1242
+ const deviceId = await this.options.storage.deviceId();
1243
+ if (!live()) return;
1244
+ this.transition(id, { stage: "redeem" }, "request");
1245
+ if (!live()) return;
1246
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(3e4)]);
1247
+ const response = await this.post(REDEEM_PATH, {
1248
+ code,
1249
+ code_verifier: verifier,
1250
+ token_type: "app",
1251
+ client: this.options.client ?? "vibedev-plugin",
1252
+ device_id: deviceId,
1253
+ device_model: `${platform()}-${arch()}`.slice(0, 128),
1254
+ os_version: release().slice(0, 128)
1255
+ }, void 0, signal);
1256
+ if (!live()) {
1257
+ response.body?.cancel().catch(() => void 0);
1258
+ return;
1259
+ }
1260
+ this.transition(id, { httpStatus: response.status }, "response");
1261
+ if (!live()) {
1262
+ response.body?.cancel().catch(() => void 0);
1263
+ return;
1264
+ }
1265
+ if (!response.ok) {
1266
+ response.body?.cancel().catch(() => void 0);
1267
+ const errorCode = response.status === 429 || response.status >= 500 ? "gateway-unavailable" : response.status >= 400 && response.status < 500 ? "gateway-refused" : "protocol";
1268
+ answer(502, "VibeDev", failurePage({
1269
+ ...this.attempt,
1270
+ errorCode
1271
+ }));
1272
+ fail({ errorCode });
1273
+ return;
1274
+ }
1275
+ let body;
1276
+ try {
1277
+ body = await response.json();
1278
+ } catch (error) {
1279
+ if (!live()) return;
1280
+ const transport = transportFailure(error);
1281
+ const failure = transport.networkCode !== void 0 || transport.errorCode === "timeout" ? transport : { errorCode: "protocol" };
1282
+ answer(502, "VibeDev", failurePage({
1283
+ ...this.attempt,
1284
+ ...failure
1285
+ }));
1286
+ fail(failure);
1287
+ return;
1288
+ }
1289
+ if (!live()) return;
1290
+ const envelope = record$6(body);
1291
+ const parsed = envelope !== void 0 && (envelope.code === void 0 || envelope.code === 0) && (envelope.data === void 0 || record$6(envelope.data) !== void 0) ? parseGrant(body, this.now()) : void 0;
1292
+ if (parsed === void 0) {
1293
+ answer(502, "VibeDev", failurePage({
1294
+ ...this.attempt,
1295
+ errorCode: "protocol"
1296
+ }));
1297
+ fail({ errorCode: "protocol" });
1298
+ return;
1299
+ }
1300
+ grant = parsed;
1301
+ active.committing = true;
1302
+ clearTimeout(timer);
1303
+ this.transition(id, {
1304
+ phase: "committing",
1305
+ stage: "persist"
1306
+ });
1307
+ await this.save(grant);
1308
+ } catch (error) {
1309
+ if (finished || this.pending !== active) return;
1310
+ const failure = this.attempt?.stage === "device" || this.attempt?.stage === "persist" ? { errorCode: "storage" } : transportFailure(error);
1311
+ answer(502, "VibeDev", failurePage({
1312
+ ...this.attempt,
1313
+ ...failure
1314
+ }));
1315
+ fail(failure);
1316
+ return;
1317
+ }
1318
+ answer(200, "VibeDev", "<h1>Signed in to VibeDev.</h1><p>已登录 VibeDev,可以关闭此页面,回到对话继续。</p><p>You can close this tab and return to the conversation.</p>");
997
1319
  finish();
1320
+ this.transition(id, { phase: "succeeded" }, "terminal");
998
1321
  resolveDone(grant);
999
- }, (error) => {
1000
- answer(502, "VibeDev", "<h1>Sign-in failed.</h1><p>登录失败,请回到对话重新登录。</p>");
1001
- finish();
1002
- rejectDone(error instanceof Error ? error : new Error(String(error)));
1322
+ })();
1323
+ };
1324
+ try {
1325
+ server = createServer(callback);
1326
+ server.on("error", () => {
1327
+ if (finished || active.committing) return;
1328
+ this.transition(id, { stage: "listen" });
1329
+ if (!finished) fail({ errorCode: "callback" });
1003
1330
  });
1004
- });
1005
- await new Promise((resolve, reject) => {
1006
- server.once("error", reject);
1007
1331
  server.listen(0, "127.0.0.1", () => {
1008
- server.off("error", reject);
1009
- resolve();
1010
- });
1011
- });
1012
- const callback = `http://127.0.0.1:${server.address().port}${CALLBACK_PATH}`;
1013
- const url = `${this.origin}/vibedev-link?${new URLSearchParams({
1014
- callback,
1015
- state,
1016
- response_type: "code",
1017
- code_challenge: challenge,
1018
- code_challenge_method: "S256",
1019
- client: this.options.client ?? "vibedev-plugin"
1020
- }).toString()}`;
1021
- const timeoutMs = this.options.signInTimeoutMs ?? 3e5;
1022
- const timer = setTimeout(() => {
1023
- if (settled) return;
1024
- settled = true;
1025
- finish();
1026
- rejectDone(new MediaError("The VibeDev sign-in was not completed in time.", "SIGN_IN_TIMEOUT"));
1027
- }, timeoutMs);
1028
- timer.unref();
1029
- const finish = () => {
1030
- clearTimeout(timer);
1031
- server.close();
1032
- server.closeAllConnections?.();
1033
- if (this.pending?.server === server) this.pending = void 0;
1034
- };
1035
- this.pending = {
1036
- url,
1037
- expiresAt: this.now() + timeoutMs,
1038
- done,
1039
- server,
1040
- cancel: (reason) => {
1041
- if (!settled) {
1042
- settled = true;
1043
- finish();
1044
- rejectDone(reason);
1332
+ if (!live()) {
1333
+ server?.close();
1334
+ server?.closeAllConnections?.();
1335
+ return;
1045
1336
  }
1046
- }
1047
- };
1048
- const opened = await open(url);
1049
- return {
1050
- url,
1051
- expiresAt: this.now() + timeoutMs,
1052
- done,
1053
- opened
1054
- };
1055
- }
1056
- async redeem(code, verifier) {
1057
- const response = await this.post("/api/v1/vibedev/link/redeem", {
1058
- code,
1059
- code_verifier: verifier,
1060
- token_type: "app",
1061
- client: this.options.client ?? "vibedev-plugin",
1062
- device_id: await this.options.storage.deviceId(),
1063
- device_model: `${platform()}-${arch()}`.slice(0, 128),
1064
- os_version: release().slice(0, 128)
1065
- });
1066
- const body = await response.json().catch(() => void 0);
1067
- if (!response.ok) throw new MediaError(`The VibeDev gateway refused the sign-in (HTTP ${response.status}${text$6(record$6(body)?.reason) === void 0 ? "" : `, ${text$6(record$6(body)?.reason)}`}).`, "SIGN_IN_REFUSED", { status: response.status });
1068
- const grant = parseGrant(body, this.now());
1069
- if (grant === void 0) throw new MediaError("The VibeDev gateway returned no tokens for the sign-in.", "SIGN_IN_REFUSED");
1070
- await this.save(grant);
1071
- return grant;
1337
+ const port = (server?.address()).port;
1338
+ const url = `${this.origin}/vibedev-link?${new URLSearchParams({
1339
+ callback: `http://127.0.0.1:${port}${CALLBACK_PATH}`,
1340
+ state,
1341
+ response_type: "code",
1342
+ code_challenge: challenge,
1343
+ code_challenge_method: "S256",
1344
+ client: this.options.client ?? "vibedev-plugin"
1345
+ }).toString()}`;
1346
+ active.url = url;
1347
+ this.transition(id, {
1348
+ phase: "waiting-browser",
1349
+ stage: void 0
1350
+ });
1351
+ if (live()) resolveReady({
1352
+ url,
1353
+ expiresAt,
1354
+ done
1355
+ });
1356
+ });
1357
+ } catch {
1358
+ if (!finished) fail({ errorCode: "callback" });
1359
+ }
1360
+ return active;
1072
1361
  }
1073
1362
  /** Sign out: revoke the session at the gateway (best effort) and forget it. */
1074
1363
  async signOut() {
1364
+ const attemptId = this.attempt?.id;
1365
+ const pending = this.pending;
1366
+ if (pending?.committing) await pending.done.catch(() => void 0);
1367
+ else pending?.cancel("cancelled");
1075
1368
  await this.load();
1076
1369
  const grant = this.grant;
1077
- this.pending?.cancel(new MediaError("The sign-in was cancelled.", "SIGN_IN_CANCELLED"));
1370
+ if (grant !== void 0) await this.save(void 0);
1371
+ if (this.attempt?.id === attemptId && this.pending === void 0) {
1372
+ this.attempt = void 0;
1373
+ this.notify();
1374
+ }
1078
1375
  if (grant === void 0) return;
1079
- await this.save(void 0);
1080
1376
  await this.post("/api/v1/vibedev/app-token/revoke", { refresh_token: grant.refreshToken }, grant.accessToken).then((response) => response.body?.cancel(), () => void 0);
1081
1377
  }
1082
1378
  /** Stop a waiting sign-in. */
1083
1379
  dispose() {
1084
- this.pending?.cancel(new MediaError("The plugin stopped before the sign-in finished.", "SIGN_IN_CANCELLED"));
1380
+ this.pending?.cancel("cancelled");
1085
1381
  }
1086
1382
  };
1087
1383
  //#endregion
@@ -2495,7 +2791,12 @@ var GatewayAdapter = class extends LlmAdapter {
2495
2791
  const token = credential.token;
2496
2792
  let response;
2497
2793
  const observingFetch = async (input, init) => {
2498
- const received = await fetch(input, init);
2794
+ const url = input instanceof Request ? input.url : String(input);
2795
+ if (new URL(url).origin !== snapshot.origin) throw new Error("Refusing a model request to another gateway origin.");
2796
+ const received = await fetch(input, {
2797
+ ...init,
2798
+ redirect: "error"
2799
+ });
2499
2800
  response = {
2500
2801
  status: received.status,
2501
2802
  headers: Object.fromEntries(received.headers.entries())
@@ -3141,7 +3442,7 @@ var GatewayHttp = class {
3141
3442
  method: request.method ?? (body === void 0 ? "GET" : "POST"),
3142
3443
  headers,
3143
3444
  ...body === void 0 ? {} : { body },
3144
- redirect: "follow",
3445
+ redirect: "error",
3145
3446
  signal
3146
3447
  });
3147
3448
  } catch (error) {
@@ -4591,7 +4892,7 @@ const text$2 = (value) => typeof value === "string" && value.trim() !== "" ? val
4591
4892
  const noPaths = { readPath: () => Promise.reject(new MediaError("The gateway returned an unexpected image reference.", "GATEWAY_BAD_RESPONSE")) };
4592
4893
  /**
4593
4894
  * Read the images of an `/v1/images/*` answer: `b64_json`, a `data:` URL, or a link.
4594
- * @param runtime - for authenticated downloads of gateway links.
4895
+ * @param runtime - for downloading the gateway's full signed result URLs.
4595
4896
  * @param body - the parsed answer.
4596
4897
  * @param signal - cancels downloads.
4597
4898
  * @returns the images and the revised prompt, when the model returned one.
@@ -4611,7 +4912,7 @@ async function decodeImages(runtime, body, signal) {
4611
4912
  else if (url?.startsWith("data:") === true) data = (await loadMedia(url, noPaths, MAX_OUTPUT_BYTES)).data;
4612
4913
  else if (url !== void 0 && /^https?:\/\//i.test(url)) {
4613
4914
  const response = await runtime.http.send(url, {
4614
- anonymous: !runtime.http.isGatewayUrl(url),
4915
+ anonymous: true,
4615
4916
  headers: { accept: "*/*" },
4616
4917
  timeoutMs: 12e4,
4617
4918
  signal
@@ -6516,19 +6817,15 @@ var TaskTracker = class {
6516
6817
  missing
6517
6818
  };
6518
6819
  }
6519
- /** Download one output: the authenticated `/content` route, then the gateway's no-login link. */
6820
+ /** Use a returned signed URL verbatim and without the app token; old gateways without a URL use `/content`. */
6520
6821
  async fetchOutput(record, task, output, temporary) {
6521
6822
  const spec = SPECS[record.kind];
6522
6823
  const sources = [];
6523
- if (record.kind === "video") sources.push({
6524
- url: spec.content(task.id, output.index),
6525
- anonymous: false
6526
- });
6527
6824
  if (output.url !== void 0) sources.push({
6528
6825
  url: output.url,
6529
6826
  anonymous: true
6530
6827
  });
6531
- if (record.kind === "audio") sources.push({
6828
+ else sources.push({
6532
6829
  url: spec.content(task.id, output.index),
6533
6830
  anonymous: false
6534
6831
  });
@@ -6561,8 +6858,6 @@ var TaskTracker = class {
6561
6858
  * Settings → VibeDev 账号; this is the agent's way to the same sign-in.
6562
6859
  * @module dsh-vibedev/tools/account
6563
6860
  */
6564
- /** Where new users register. */
6565
- const REGISTER_URL = "https://vibedev.jzsaas.com";
6566
6861
  /**
6567
6862
  * Build the tool.
6568
6863
  * @param runtime - the media runtime.
@@ -6643,7 +6938,7 @@ function mediaAccountTool(runtime, chain, login) {
6643
6938
  return {
6644
6939
  source,
6645
6940
  signInUrl: started.url,
6646
- message: `${started.opened ? "Opened the VibeDev sign-in page in the user's browser." : "Could not open a browser."} If it did not appear, give the user this link: ${started.url} — new users can register on that page (or at ${REGISTER_URL}). The sign-in completes in the background within ${minutes} minutes; once the user says they are done, retry the request.`
6941
+ message: `${started.opened ? "Opened the VibeDev sign-in page in the user's browser." : "Could not open a browser."} If it did not appear, give the user this link: ${started.url} — new users can register on that page (or at ${runtime.http.origin}). The sign-in completes in the background within ${minutes} minutes; once the user says they are done, retry the request.`
6647
6942
  };
6648
6943
  }
6649
6944
  });
@@ -7263,10 +7558,10 @@ const Config = Schema.object({
7263
7558
  musicModel: Schema.string().default("").volatile(),
7264
7559
  podcastModel: Schema.string().default("").volatile(),
7265
7560
  transcriptionModel: Schema.string().default("").volatile(),
7266
- gatewayOrigin: Schema.string().default("https://vibedev.jzsaas.com"),
7561
+ gatewayOrigin: Schema.string().default(DEFAULT_GATEWAY_ORIGIN),
7267
7562
  client: Schema.string().default("vibedev-plugin"),
7268
7563
  primary: Schema.boolean().default(false),
7269
- apiKeyEnv: Schema.string().default("VIBEDEV_GATEWAY_API_KEY"),
7564
+ apiKeyEnv: Schema.string().default(""),
7270
7565
  stateDir: Schema.string().default("")
7271
7566
  }).i18n({
7272
7567
  "zh-CN": {
@@ -7308,13 +7603,13 @@ const Config = Schema.object({
7308
7603
  });
7309
7604
  const GUIDANCE = "VibeDev media tools: image_generate, video_generate, audio_generate (music or a podcast) and audio_transcribe create and transcribe media with the user's VibeDev account; media_models lists models and prices. Use them on your own judgment whenever generated media serves the request (illustrations, product shots, storyboard frames, short clips, background music, a podcast episode), not only when a tool is named. They spend the user's balance, so make what the request needs rather than speculative variations. Results are saved in the workspace under media/. Pass files the user attached in this chat as chat:1 (the most recent), chat:2, and so on. Video and audio run as background jobs: carry on and let the job notification report the result.";
7310
7605
  function apply(ctx, config) {
7311
- const origin = config.gatewayOrigin.trim().replace(/\/+$/, "");
7606
+ const origin = normalizeGatewayOrigin(config.gatewayOrigin);
7312
7607
  const stateDir = stateDirectory(config.stateDir);
7313
7608
  const userAgent = `vibedev-plugin/${version}`;
7314
7609
  const warn = (message) => {
7315
7610
  ctx.logger.warn(message);
7316
7611
  };
7317
- const storage = grantStorage(stateDir, () => ctx.get("credentials"));
7612
+ const storage = grantStorage(stateDir, () => ctx.get("credentials"), origin);
7318
7613
  const login = new PluginLogin({
7319
7614
  origin,
7320
7615
  userAgent,
@@ -7325,7 +7620,7 @@ function apply(ctx, config) {
7325
7620
  });
7326
7621
  ctx.inject(["credentials"], (scoped) => {
7327
7622
  scoped.on("credentials/record-updated", (key) => {
7328
- if (key === GRANT_KEY) login.restore();
7623
+ if (key === gatewayGrantKey(origin)) login.restore();
7329
7624
  });
7330
7625
  scoped.effect(async () => {
7331
7626
  await login.restore();
@@ -7336,7 +7631,7 @@ function apply(ctx, config) {
7336
7631
  origin,
7337
7632
  plugin: login,
7338
7633
  account: () => ctx.get("deepseekAccount"),
7339
- apiKey: () => config.apiKeyEnv.trim() === "" ? void 0 : process.env[config.apiKeyEnv.trim()]?.trim()
7634
+ apiKey: () => developmentKey(origin, config.apiKeyEnv)
7340
7635
  });
7341
7636
  const http = new GatewayHttp({
7342
7637
  origin,
@@ -7344,7 +7639,7 @@ function apply(ctx, config) {
7344
7639
  resolveCredential: () => chain.resolve(),
7345
7640
  rejectToken: (credential) => chain.reject(credential)
7346
7641
  });
7347
- const store = new TaskStore(stateDir);
7642
+ const store = new TaskStore(gatewayStateDirectory(stateDir, origin));
7348
7643
  const tracker = new TaskTracker({
7349
7644
  http,
7350
7645
  store,