@vibedev-si/dsh-vibedev 0.2.6 → 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
  /**
@@ -297,11 +302,15 @@ function parseGatewayFailure(body) {
297
302
  availableCny: amount$1(source.available_cny),
298
303
  pendingCny: amount$1(source.pending_cny)
299
304
  };
305
+ const submissionState = text$7(source.submission_state) ?? text$7(root.submission_state);
306
+ const retryAction = text$7(source.retry_action) ?? text$7(root.retry_action);
300
307
  return {
301
308
  ...code === void 0 ? {} : { code },
302
309
  ...message === void 0 ? {} : { message },
303
310
  ...field === void 0 ? {} : { field },
304
311
  ...typeof source.retryable === "boolean" ? { retryable: source.retryable } : {},
312
+ ...submissionState === void 0 ? {} : { submissionState: submissionState.toLowerCase() },
313
+ ...retryAction === void 0 ? {} : { retryAction: retryAction.toLowerCase() },
305
314
  ...rechargeUrl === void 0 ? {} : { rechargeUrl },
306
315
  ...Object.fromEntries(Object.entries({
307
316
  ...numbers,
@@ -507,6 +516,32 @@ async function downloadTo(response, temporary, maxBytes, signal) {
507
516
  }
508
517
  }
509
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
510
545
  //#region src/auth/credentials.ts
511
546
  /**
512
547
  * Which VibeDev credential a gateway request carries, in order: the host's
@@ -551,7 +586,10 @@ var CredentialChain = class {
551
586
  else if (credential.kind === "plugin") await this.options.plugin?.reject(credential.token);
552
587
  }
553
588
  };
554
- 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
+ }
555
593
  function isGrant(value) {
556
594
  const grant = value;
557
595
  return typeof grant === "object" && grant !== null && typeof grant.accessToken === "string" && typeof grant.refreshToken === "string" && typeof grant.expiresAt === "number" && typeof grant.refreshExpiresAt === "number";
@@ -560,11 +598,23 @@ function isGrant(value) {
560
598
  * Grant storage: the host's credential store when one is loaded (a grant
561
599
  * record the store keeps verbatim), else a private file in the state
562
600
  * directory. The device id is not a secret and lives in the state directory.
563
- * @param stateDir - the plugin state directory.
601
+ * @param root - the plugin state root; the storage selects an origin-specific directory itself.
564
602
  * @param credentials - the host's credential store, when loaded.
603
+ * @param selectedOrigin - the issuing gateway; unmarked historical state is never adopted.
565
604
  * @returns the storage.
566
605
  */
567
- 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
+ };
568
618
  const sessionFile = join(stateDir, "session.json");
569
619
  const deviceFile = join(stateDir, "device.json");
570
620
  let device;
@@ -577,9 +627,9 @@ function grantStorage(stateDir, credentials) {
577
627
  throw error;
578
628
  }
579
629
  const parsed = JSON.parse(text);
580
- return isGrant(parsed) ? parsed : void 0;
630
+ return readGrant(parsed);
581
631
  };
582
- 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 });
583
633
  const clearFileGrant = async () => {
584
634
  try {
585
635
  await stat(sessionFile);
@@ -595,9 +645,9 @@ function grantStorage(stateDir, credentials) {
595
645
  } catch {}
596
646
  };
597
647
  const readHostGrant = async (store) => {
598
- const stored = await store.readRecord(GRANT_KEY);
648
+ const stored = await store.readRecord(key);
599
649
  if (stored !== void 0) {
600
- const grant = stored.kind === "grant" && isGrant(stored.payload) ? stored.payload : void 0;
650
+ const grant = stored.kind === "grant" ? readGrant(stored.payload) : void 0;
601
651
  if (grant !== void 0) await clearFileGrantQuietly();
602
652
  return grant;
603
653
  }
@@ -608,15 +658,15 @@ function grantStorage(stateDir, credentials) {
608
658
  throw error;
609
659
  }
610
660
  let adopted;
611
- await store.modifyRecord(GRANT_KEY, async (current) => {
661
+ await store.modifyRecord(key, async (current) => {
612
662
  if (current !== void 0) {
613
- adopted = current.kind === "grant" && isGrant(current.payload) ? current.payload : void 0;
663
+ adopted = current.kind === "grant" ? readGrant(current.payload) : void 0;
614
664
  return;
615
665
  }
616
666
  adopted = await readFileGrant();
617
667
  return adopted === void 0 ? void 0 : {
618
668
  kind: "grant",
619
- payload: adopted
669
+ payload: bind(adopted)
620
670
  };
621
671
  });
622
672
  if (adopted !== void 0) await clearFileGrantQuietly();
@@ -632,11 +682,11 @@ function grantStorage(stateDir, credentials) {
632
682
  if (store !== void 0) {
633
683
  if (grant === void 0) {
634
684
  await clearFileGrant();
635
- await store.deleteRecord(GRANT_KEY);
685
+ await store.deleteRecord(key);
636
686
  } else {
637
- await store.modifyRecord(GRANT_KEY, () => Promise.resolve({
687
+ await store.modifyRecord(key, () => Promise.resolve({
638
688
  kind: "grant",
639
- payload: grant
689
+ payload: bind(grant)
640
690
  }));
641
691
  await clearFileGrantQuietly();
642
692
  }
@@ -649,17 +699,17 @@ function grantStorage(stateDir, credentials) {
649
699
  if (store !== void 0) {
650
700
  await readHostGrant(store);
651
701
  let remove = false;
652
- await store.modifyRecord(GRANT_KEY, async (current) => {
653
- 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);
654
704
  if (result.kind === "set") return {
655
705
  kind: "grant",
656
- payload: result.grant
706
+ payload: bind(result.grant)
657
707
  };
658
708
  remove = result.kind === "delete";
659
709
  });
660
710
  if (remove) {
661
711
  await clearFileGrant();
662
- await store.deleteRecord(GRANT_KEY);
712
+ await store.deleteRecord(key);
663
713
  }
664
714
  return;
665
715
  }
@@ -682,6 +732,100 @@ function grantStorage(stateDir, credentials) {
682
732
  };
683
733
  }
684
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
685
829
  //#region src/auth/login.ts
686
830
  /**
687
831
  * The plugin's own VibeDev sign-in, for hosts whose account service does not
@@ -768,6 +912,7 @@ var PluginLogin = class {
768
912
  readFailureReported = false;
769
913
  refreshing;
770
914
  pending;
915
+ attempt;
771
916
  listeners = /* @__PURE__ */ new Set();
772
917
  now;
773
918
  origin;
@@ -777,7 +922,7 @@ var PluginLogin = class {
777
922
  constructor(options) {
778
923
  this.options = options;
779
924
  this.now = options.now ?? Date.now;
780
- this.origin = options.origin.trim().replace(/\/+$/, "");
925
+ this.origin = normalizeGatewayOrigin(options.origin);
781
926
  }
782
927
  readStored(notify) {
783
928
  if (this.reading !== void 0) return this.reading;
@@ -812,10 +957,32 @@ var PluginLogin = class {
812
957
  const changed = this.grant?.accessToken !== grant?.accessToken;
813
958
  this.grant = grant;
814
959
  if (!changed) return;
960
+ this.notify();
961
+ }
962
+ notify() {
815
963
  for (const listener of this.listeners) try {
816
964
  listener();
817
965
  } catch {}
818
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
+ }
819
986
  async save(grant) {
820
987
  await this.options.storage.write(grant);
821
988
  this.adopt(grant);
@@ -838,9 +1005,10 @@ var PluginLogin = class {
838
1005
  }
839
1006
  /** Whether a sign-in is waiting for the browser, and its link. */
840
1007
  pendingSignIn() {
841
- return this.pending === void 0 ? void 0 : {
842
- url: this.pending.url,
843
- 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
844
1012
  };
845
1013
  }
846
1014
  /**
@@ -896,7 +1064,7 @@ var PluginLogin = class {
896
1064
  });
897
1065
  const body = await response.json().catch(() => void 0);
898
1066
  if (response.status === 401 || response.status === 403) {
899
- 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`);
900
1068
  outcome = void 0;
901
1069
  return { kind: "delete" };
902
1070
  }
@@ -923,7 +1091,7 @@ var PluginLogin = class {
923
1091
  })();
924
1092
  return this.refreshing;
925
1093
  }
926
- post(path, json, token) {
1094
+ post(path, json, token, signal = AbortSignal.timeout(3e4)) {
927
1095
  return (this.options.fetch ?? globalThis.fetch)(`${this.origin}${path}`, {
928
1096
  method: "POST",
929
1097
  headers: {
@@ -933,7 +1101,8 @@ var PluginLogin = class {
933
1101
  ...token === void 0 ? {} : { authorization: `Bearer ${token}` }
934
1102
  },
935
1103
  body: JSON.stringify(json),
936
- signal: AbortSignal.timeout(3e4)
1104
+ redirect: "error",
1105
+ signal
937
1106
  });
938
1107
  }
939
1108
  /**
@@ -944,19 +1113,22 @@ var PluginLogin = class {
944
1113
  * @returns the authorization link, whether a browser was opened, and the completion.
945
1114
  */
946
1115
  async startSignIn(request = {}) {
947
- const open = (url) => request.open === false ? Promise.resolve(false) : (this.options.openBrowser ?? openInBrowser)(url);
948
- if (this.pending !== void 0 && this.pending.expiresAt > this.now()) {
949
- const opened = await open(this.pending.url);
950
- return {
951
- url: this.pending.url,
952
- expiresAt: this.pending.expiresAt,
953
- done: this.pending.done,
954
- opened
955
- };
956
- }
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() {
957
1126
  const verifier = base64url(randomBytes(32));
958
1127
  const challenge = base64url(createHash("sha256").update(verifier).digest());
959
1128
  const state = base64url(randomBytes(18));
1129
+ const id = randomUUID();
1130
+ const expiresAt = this.now() + (this.options.signInTimeoutMs ?? 3e5);
1131
+ const controller = new AbortController();
960
1132
  let resolveDone;
961
1133
  let rejectDone;
962
1134
  const done = new Promise((resolve, reject) => {
@@ -964,8 +1136,73 @@ var PluginLogin = class {
964
1136
  rejectDone = reject;
965
1137
  });
966
1138
  done.catch(() => void 0);
967
- let settled = false;
968
- 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) => {
969
1206
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
970
1207
  if (url.pathname !== CALLBACK_PATH) {
971
1208
  response.writeHead(404).end();
@@ -977,107 +1214,170 @@ var PluginLogin = class {
977
1214
  "cache-control": "no-store"
978
1215
  }).end(PAGE(title, body));
979
1216
  };
980
- if (settled) {
981
- 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>");
1220
+ return;
1221
+ }
1222
+ if (claimed || !live()) {
1223
+ answer(410, "VibeDev", "<h1>This sign-in link has already been used.</h1><p>此登录链接已使用过或已失效。</p>");
982
1224
  return;
983
1225
  }
984
- if (url.searchParams.get("state") !== state || url.searchParams.get("code") === null) {
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() === "") {
985
1230
  answer(400, "VibeDev", "<h1>Sign-in could not be completed.</h1><p>登录未完成:链接无效,请回到对话重新登录。</p>");
986
1231
  return;
987
1232
  }
988
- settled = true;
989
- const code = url.searchParams.get("code");
990
- this.redeem(code, verifier).then((grant) => {
991
- const who = grant.user?.email ?? grant.user?.nickname;
992
- 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>");
993
1319
  finish();
1320
+ this.transition(id, { phase: "succeeded" }, "terminal");
994
1321
  resolveDone(grant);
995
- }, (error) => {
996
- answer(502, "VibeDev", "<h1>Sign-in failed.</h1><p>登录失败,请回到对话重新登录。</p>");
997
- finish();
998
- 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" });
999
1330
  });
1000
- });
1001
- await new Promise((resolve, reject) => {
1002
- server.once("error", reject);
1003
1331
  server.listen(0, "127.0.0.1", () => {
1004
- server.off("error", reject);
1005
- resolve();
1006
- });
1007
- });
1008
- const callback = `http://127.0.0.1:${server.address().port}${CALLBACK_PATH}`;
1009
- const url = `${this.origin}/vibedev-link?${new URLSearchParams({
1010
- callback,
1011
- state,
1012
- response_type: "code",
1013
- code_challenge: challenge,
1014
- code_challenge_method: "S256",
1015
- client: this.options.client ?? "vibedev-plugin"
1016
- }).toString()}`;
1017
- const timeoutMs = this.options.signInTimeoutMs ?? 3e5;
1018
- const timer = setTimeout(() => {
1019
- if (settled) return;
1020
- settled = true;
1021
- finish();
1022
- rejectDone(new MediaError("The VibeDev sign-in was not completed in time.", "SIGN_IN_TIMEOUT"));
1023
- }, timeoutMs);
1024
- timer.unref();
1025
- const finish = () => {
1026
- clearTimeout(timer);
1027
- server.close();
1028
- server.closeAllConnections?.();
1029
- if (this.pending?.server === server) this.pending = void 0;
1030
- };
1031
- this.pending = {
1032
- url,
1033
- expiresAt: this.now() + timeoutMs,
1034
- done,
1035
- server,
1036
- cancel: (reason) => {
1037
- if (!settled) {
1038
- settled = true;
1039
- finish();
1040
- rejectDone(reason);
1332
+ if (!live()) {
1333
+ server?.close();
1334
+ server?.closeAllConnections?.();
1335
+ return;
1041
1336
  }
1042
- }
1043
- };
1044
- const opened = await open(url);
1045
- return {
1046
- url,
1047
- expiresAt: this.now() + timeoutMs,
1048
- done,
1049
- opened
1050
- };
1051
- }
1052
- async redeem(code, verifier) {
1053
- const response = await this.post("/api/v1/vibedev/link/redeem", {
1054
- code,
1055
- code_verifier: verifier,
1056
- token_type: "app",
1057
- client: this.options.client ?? "vibedev-plugin",
1058
- device_id: await this.options.storage.deviceId(),
1059
- device_model: `${platform()}-${arch()}`.slice(0, 128),
1060
- os_version: release().slice(0, 128)
1061
- });
1062
- const body = await response.json().catch(() => void 0);
1063
- 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 });
1064
- const grant = parseGrant(body, this.now());
1065
- if (grant === void 0) throw new MediaError("The VibeDev gateway returned no tokens for the sign-in.", "SIGN_IN_REFUSED");
1066
- await this.save(grant);
1067
- 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;
1068
1361
  }
1069
1362
  /** Sign out: revoke the session at the gateway (best effort) and forget it. */
1070
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");
1071
1368
  await this.load();
1072
1369
  const grant = this.grant;
1073
- 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
+ }
1074
1375
  if (grant === void 0) return;
1075
- await this.save(void 0);
1076
1376
  await this.post("/api/v1/vibedev/app-token/revoke", { refresh_token: grant.refreshToken }, grant.accessToken).then((response) => response.body?.cancel(), () => void 0);
1077
1377
  }
1078
1378
  /** Stop a waiting sign-in. */
1079
1379
  dispose() {
1080
- this.pending?.cancel(new MediaError("The plugin stopped before the sign-in finished.", "SIGN_IN_CANCELLED"));
1380
+ this.pending?.cancel("cancelled");
1081
1381
  }
1082
1382
  };
1083
1383
  //#endregion
@@ -2491,7 +2791,12 @@ var GatewayAdapter = class extends LlmAdapter {
2491
2791
  const token = credential.token;
2492
2792
  let response;
2493
2793
  const observingFetch = async (input, init) => {
2494
- 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
+ });
2495
2800
  response = {
2496
2801
  status: received.status,
2497
2802
  headers: Object.fromEntries(received.headers.entries())
@@ -3137,7 +3442,7 @@ var GatewayHttp = class {
3137
3442
  method: request.method ?? (body === void 0 ? "GET" : "POST"),
3138
3443
  headers,
3139
3444
  ...body === void 0 ? {} : { body },
3140
- redirect: "follow",
3445
+ redirect: "error",
3141
3446
  signal
3142
3447
  });
3143
3448
  } catch (error) {
@@ -3345,6 +3650,8 @@ function parseVideoCapabilities(raw) {
3345
3650
  ["maxAssetBytes", "max_asset_bytes"],
3346
3651
  ["minReferenceVideoSeconds", "min_reference_video_duration_seconds"],
3347
3652
  ["maxReferenceVideoSeconds", "max_reference_video_duration_seconds"],
3653
+ ["minReferenceAudioSeconds", "min_reference_audio_duration_seconds"],
3654
+ ["maxReferenceAudioSeconds", "max_reference_audio_duration_seconds"],
3348
3655
  ["maxTotalReferenceVideoSeconds", "max_total_reference_video_duration_seconds"]
3349
3656
  ]) {
3350
3657
  const value = positive(field(raw, snake));
@@ -4524,7 +4831,7 @@ function startTaskJob(runtime, exec, record) {
4524
4831
  /**
4525
4832
  * Submit a task and start its report. An unconfirmed submission is not an
4526
4833
  * error here: the agent must not resubmit it, so it is reported like any
4527
- * other task while the tracker resends it under the same key.
4834
+ * other task while the tracker settles that one submission under the same key.
4528
4835
  * @param runtime - the media runtime.
4529
4836
  * @param exec - the tool call.
4530
4837
  * @param draft - the task to submit.
@@ -4548,7 +4855,7 @@ async function submitTask(runtime, exec, draft) {
4548
4855
  };
4549
4856
  }
4550
4857
  /** The sentence about an unconfirmed submission. */
4551
- const UNCONFIRMED_NOTE = "The gateway did not confirm the submission; dsh-vibedev keeps resending it for a few minutes under the same request key, so it cannot be created twice. Do not submit it again.";
4858
+ const UNCONFIRMED_NOTE = "The gateway did not confirm the submission; dsh-vibedev keeps settling that same submission under the same request key for a few minutes, so no second task can be created from it. Do not submit it again.";
4552
4859
  /**
4553
4860
  * Wait for a task in the tool call itself, for hosts without background jobs.
4554
4861
  * @param runtime - the media runtime.
@@ -4585,7 +4892,7 @@ const text$2 = (value) => typeof value === "string" && value.trim() !== "" ? val
4585
4892
  const noPaths = { readPath: () => Promise.reject(new MediaError("The gateway returned an unexpected image reference.", "GATEWAY_BAD_RESPONSE")) };
4586
4893
  /**
4587
4894
  * Read the images of an `/v1/images/*` answer: `b64_json`, a `data:` URL, or a link.
4588
- * @param runtime - for authenticated downloads of gateway links.
4895
+ * @param runtime - for downloading the gateway's full signed result URLs.
4589
4896
  * @param body - the parsed answer.
4590
4897
  * @param signal - cancels downloads.
4591
4898
  * @returns the images and the revised prompt, when the model returned one.
@@ -4605,7 +4912,7 @@ async function decodeImages(runtime, body, signal) {
4605
4912
  else if (url?.startsWith("data:") === true) data = (await loadMedia(url, noPaths, MAX_OUTPUT_BYTES)).data;
4606
4913
  else if (url !== void 0 && /^https?:\/\//i.test(url)) {
4607
4914
  const response = await runtime.http.send(url, {
4608
- anonymous: !runtime.http.isGatewayUrl(url),
4915
+ anonymous: true,
4609
4916
  headers: { accept: "*/*" },
4610
4917
  timeoutMs: 12e4,
4611
4918
  signal
@@ -4934,6 +5241,7 @@ function describeVideoModel(model) {
4934
5241
  const limits = [];
4935
5242
  if (video.minReferenceVideoSeconds !== void 0 || video.maxReferenceVideoSeconds !== void 0) limits.push(`each reference video ${video.minReferenceVideoSeconds ?? 0}-${video.maxReferenceVideoSeconds ?? "∞"} s`);
4936
5243
  if (video.maxTotalReferenceVideoSeconds !== void 0) limits.push(`reference videos at most ${video.maxTotalReferenceVideoSeconds} s in total`);
5244
+ if (video.minReferenceAudioSeconds !== void 0 || video.maxReferenceAudioSeconds !== void 0) limits.push(`each reference audio ${video.minReferenceAudioSeconds ?? 0}-${video.maxReferenceAudioSeconds ?? "∞"} s`);
4937
5245
  if (video.combinations !== void 0) limits.push(`allowed combinations: ${video.combinations.map((c) => [
4938
5246
  c.duration === void 0 ? "" : `${c.duration}s`,
4939
5247
  c.ratio ?? "",
@@ -5091,12 +5399,15 @@ function referenceByteLimit(model, input) {
5091
5399
  return Math.min(kindMax ?? Infinity, video.maxAssetBytes ?? Infinity, MEDIA_LIBRARY_MAX_BYTES);
5092
5400
  }
5093
5401
  /**
5094
- * Check reference video durations once the gateway has measured them.
5402
+ * Check the measured lengths of the reference media once the gateway has
5403
+ * measured them. A reference's OWN length is what is checked — never the
5404
+ * requested output duration — and only against the limits that model declares.
5095
5405
  * @param model - the video model.
5096
5406
  * @param videos - the uploaded reference videos, in order.
5407
+ * @param audios - the uploaded reference audios, in order.
5097
5408
  * @throws {@link MediaError} when a clip or the total is outside the model's limits.
5098
5409
  */
5099
- function checkReferenceDurations(model, videos) {
5410
+ function checkReferenceDurations(model, videos, audios = []) {
5100
5411
  const video = model.video ?? {};
5101
5412
  let totalMs = 0;
5102
5413
  for (const [index, clip] of videos.entries()) {
@@ -5110,6 +5421,16 @@ function checkReferenceDurations(model, videos) {
5110
5421
  totalMs += clip.durationMs;
5111
5422
  }
5112
5423
  if (video.maxTotalReferenceVideoSeconds !== void 0 && totalMs / 1e3 > video.maxTotalReferenceVideoSeconds) throw new MediaError(`The reference videos add up to ${(totalMs / 1e3).toFixed(2)} s; ${model.id} takes at most ${video.maxTotalReferenceVideoSeconds} s in total.`, "REFERENCE_TOTAL_TOO_LONG", { field: "reference_videos" });
5424
+ const audioLimits = video.minReferenceAudioSeconds !== void 0 || video.maxReferenceAudioSeconds !== void 0;
5425
+ for (const [index, clip] of audios.entries()) {
5426
+ if (clip.durationMs === void 0) {
5427
+ if (audioLimits) throw new MediaError(`The gateway could not measure the length of reference audio ${index + 1} ("${clip.source}"), so ${model.id}'s length limits cannot be checked. Export it as a standard MP3 or WAV and try again.`, "REFERENCE_DURATION_UNKNOWN", { field: "reference_audios" });
5428
+ continue;
5429
+ }
5430
+ const seconds = clip.durationMs / 1e3;
5431
+ if (video.minReferenceAudioSeconds !== void 0 && seconds < video.minReferenceAudioSeconds) throw new MediaError(`Reference audio ${index + 1} ("${clip.source}") is ${seconds.toFixed(2)} s; ${model.id} needs at least ${video.minReferenceAudioSeconds} s of reference audio.`, "REFERENCE_AUDIO_TOO_SHORT", { field: "reference_audios" });
5432
+ if (video.maxReferenceAudioSeconds !== void 0 && seconds > video.maxReferenceAudioSeconds) throw new MediaError(`Reference audio ${index + 1} ("${clip.source}") is ${seconds.toFixed(2)} s; ${model.id} takes at most ${video.maxReferenceAudioSeconds} s of reference audio per clip. Trim it first, or use a shorter excerpt.`, "REFERENCE_AUDIO_TOO_LONG", { field: "reference_audios" });
5433
+ }
5113
5434
  }
5114
5435
  /**
5115
5436
  * The `/v1/videos` body for a plan whose inputs are uploaded. The gateway reads
@@ -5260,7 +5581,12 @@ async function prepareVideoTask(runtime, call, request, save) {
5260
5581
  bytes: item.media.data.byteLength,
5261
5582
  ...assets[index]?.durationMs === void 0 ? {} : { durationMs: assets[index]?.durationMs }
5262
5583
  }] : []);
5263
- checkReferenceDurations(model, videos);
5584
+ checkReferenceDurations(model, videos, loaded.flatMap((item, index) => item.input === "referenceAudios" ? [{
5585
+ source: item.media.source,
5586
+ mime: item.media.mime,
5587
+ bytes: item.media.data.byteLength,
5588
+ ...assets[index]?.durationMs === void 0 ? {} : { durationMs: assets[index]?.durationMs }
5589
+ }] : []));
5264
5590
  const urls = {};
5265
5591
  for (const [index, item] of loaded.entries()) {
5266
5592
  const asset = assets[index];
@@ -5898,13 +6224,21 @@ function parseAudioTask(body) {
5898
6224
  * background job, so a task keeps being followed after its job is stopped and
5899
6225
  * after the host restarts.
5900
6226
  *
5901
- * Money rules: a task record is removed only when the first submission was
5902
- * refused outright, so nothing can exist under its key. Every answer that
5903
- * leaves the outcome open (no answer, a gateway error, a 409) keeps the record,
5904
- * and the same request is resent under the same key, which returns the
5905
- * original task when there is one. A resend that is refused outright is
5906
- * retried only briefly, so a refused request is not created later behind the
5907
- * user's back.
6227
+ * Money rules, and the rule that one operation creates at most one task:
6228
+ *
6229
+ * - A submission is settled by the gateway's structured verdict
6230
+ * (`submission_state` and `retry_action`) first, and only by the HTTP status
6231
+ * when it sends none. `rejected`/`retry_action: none` is final: the reason is
6232
+ * saved and nothing is sent again — not by the tool, not by the background
6233
+ * loop, not after a restart. `retryable` on its own never authorizes creating
6234
+ * another task.
6235
+ * - An unknown outcome keeps the record and settles it under the SAME key and
6236
+ * the SAME body, which the gateway answers with the original task when there
6237
+ * is one. A new key is minted only for a new request the user asks for.
6238
+ * - Once a gateway task id is known the task is only polled; a failed poll
6239
+ * never submits anything.
6240
+ * - A refusal that left nothing under the key ends the record as failed with
6241
+ * the gateway's own reason, so the person sees why.
5908
6242
  * @module dsh-vibedev/tasks/tracker
5909
6243
  */
5910
6244
  const SPECS = {
@@ -5945,34 +6279,74 @@ const SUBMIT_TIMEOUT_MS = 3e5;
5945
6279
  const SUBMISSION_AMBIGUOUS = "VIDEO_SUBMISSION_AMBIGUOUS";
5946
6280
  /** The same key was sent with a different body: a plugin bug, never retried. */
5947
6281
  const KEY_CONFLICT = /_IDEMPOTENCY_CONFLICT$/;
6282
+ /** Structured verdicts the gateway sends; an unknown word falls back to the older protocol. */
6283
+ const REJECTED_STATES = /* @__PURE__ */ new Set([
6284
+ "rejected",
6285
+ "refused",
6286
+ "denied"
6287
+ ]);
6288
+ const RECONCILE_STATES = /* @__PURE__ */ new Set(["unknown", "accepted"]);
6289
+ /** `retry_action` values that keep the same key: a lookup of this submission, never a new one. */
6290
+ const SAME_KEY_ACTIONS = /* @__PURE__ */ new Set([
6291
+ "reconcile",
6292
+ "retry_same_key",
6293
+ "retry_same"
6294
+ ]);
5948
6295
  /**
5949
- * Whether a failed submission may still have created the task: no answer, an
5950
- * unreadable answer, a gateway error that is not "busy", or a 409 such as
5951
- * `VIDEO_SUBMISSION_IN_PROGRESS` (an earlier attempt is still being submitted).
6296
+ * What a failed submission means.
6297
+ *
6298
+ * The two STOP rules are read before anything that would keep the operation
6299
+ * alive, so no verdict can talk the plugin into sending a refused request
6300
+ * again: `retry_action: none` (the provider refused it) and
6301
+ * `VIDEO_SUBMISSION_AMBIGUOUS` (the gateway's key answers this forever) end the
6302
+ * operation even when the same answer also says the outcome is unknown.
6303
+ *
6304
+ * After those, the gateway's structured verdict decides, and only when it
6305
+ * sends none do the HTTP status and the older codes. `retryable` says recovery
6306
+ * is still needed; it never authorizes creating another task.
5952
6307
  * @param error - the failure.
5953
- * @returns true when the same key should be sent again.
6308
+ * @returns how it may be taken further.
5954
6309
  */
5955
- function outcomeUnknown(error) {
5956
- if (!(error instanceof MediaError)) return false;
5957
- if (NO_ANSWER_CODES.has(error.code)) return true;
5958
- if (error.code === SUBMISSION_AMBIGUOUS || KEY_CONFLICT.test(error.code)) return false;
5959
- const status = error.details.status;
5960
- return status === 409 || status !== void 0 && status >= 500 && error.details.busy !== true;
6310
+ function submissionRecovery(error) {
6311
+ if (!(error instanceof MediaError)) return "rejected";
6312
+ const { submissionState, retryAction, status, busy, retryable } = error.details;
6313
+ if (retryAction === "none") return "rejected";
6314
+ if (error.code === SUBMISSION_AMBIGUOUS || KEY_CONFLICT.test(error.code)) return "rejected";
6315
+ if (submissionState !== void 0 && REJECTED_STATES.has(submissionState)) return "rejected";
6316
+ if (retryAction !== void 0 && SAME_KEY_ACTIONS.has(retryAction)) return "reconcile";
6317
+ if (retryAction === "retry") return "wait";
6318
+ if (submissionState !== void 0 && RECONCILE_STATES.has(submissionState)) return "reconcile";
6319
+ if (NO_ANSWER_CODES.has(error.code)) return "reconcile";
6320
+ if (busy === true) return "wait";
6321
+ if (status !== void 0 && status >= 400 && status < 500) return status === 409 ? "reconcile" : "rejected";
6322
+ if (status !== void 0 && status >= 500) return retryable === false ? "rejected" : "reconcile";
6323
+ return "rejected";
5961
6324
  }
5962
6325
  /**
5963
- * The record of a submission the gateway settled for good without a task.
6326
+ * The failure to record when a submission is refused for good.
6327
+ *
6328
+ * None of these tells the person to generate again: in the two cases where the
6329
+ * provider may already hold the request, a new generation would be a second
6330
+ * paid task, so the wording asks them to check the records first.
5964
6331
  * @param error - the failure.
5965
- * @returns the failure to record, or undefined when the error is not one of these.
6332
+ * @returns the code and message to keep with the task.
5966
6333
  */
5967
- function settledWithoutTask(error) {
5968
- if (!(error instanceof MediaError)) return void 0;
6334
+ function terminalFailure(error) {
6335
+ if (!(error instanceof MediaError)) return {
6336
+ code: "SUBMISSION_REJECTED",
6337
+ message: messageOf(error)
6338
+ };
5969
6339
  if (error.code === SUBMISSION_AMBIGUOUS) return {
5970
6340
  code: error.code,
5971
- message: "The gateway could not confirm the submission with the provider, so it created no task and charged nothing. Submit the request again if it is still wanted."
6341
+ message: "The gateway could not confirm this submission with the provider, so it created no task and charged nothing, and this request key will answer the same way. If the provider did receive it, that task is not visible here. Check the task list and the account usage before generating the same thing again."
5972
6342
  };
5973
6343
  if (KEY_CONFLICT.test(error.code)) return {
5974
6344
  code: error.code,
5975
- message: "The gateway refused the request: its key was already used for a different request. Nothing was charged; submit it again."
6345
+ message: "The gateway refused the request: its key was already used for a different request. Nothing was charged. Check the task list for the earlier request before generating again."
6346
+ };
6347
+ return {
6348
+ code: error.code,
6349
+ message: error.message
5976
6350
  };
5977
6351
  }
5978
6352
  function sleepFor(ms, signal) {
@@ -6085,8 +6459,14 @@ var TaskTracker = class {
6085
6459
  await this.releaseClaim(draft.id);
6086
6460
  throw error;
6087
6461
  }
6088
- const settled = settledWithoutTask(error);
6089
- if (settled !== void 0) {
6462
+ const recovery = submissionRecovery(error);
6463
+ if (recovery === "rejected") {
6464
+ const settled = terminalFailure(error);
6465
+ if (!unknown) {
6466
+ this.dropClaim(draft.id);
6467
+ await this.options.store.remove(draft.id);
6468
+ throw error;
6469
+ }
6090
6470
  await this.finish(draft.id, {
6091
6471
  status: "failed",
6092
6472
  error: settled
@@ -6094,14 +6474,10 @@ var TaskTracker = class {
6094
6474
  await this.releaseClaim(draft.id);
6095
6475
  throw new MediaError(settled.message, settled.code, { retryable: false }, { cause: error });
6096
6476
  }
6097
- if (!outcomeUnknown(error)) {
6098
- if (!unknown) {
6099
- this.dropClaim(draft.id);
6100
- await this.options.store.remove(draft.id);
6101
- throw error;
6102
- }
6103
- lastError = error;
6104
- break;
6477
+ if (!unknown && recovery === "wait") {
6478
+ this.dropClaim(draft.id);
6479
+ await this.options.store.remove(draft.id);
6480
+ throw error;
6105
6481
  }
6106
6482
  unknown = true;
6107
6483
  lastError = error;
@@ -6129,7 +6505,7 @@ var TaskTracker = class {
6129
6505
  }
6130
6506
  }
6131
6507
  this.followQuietly(draft.id);
6132
- throw new MediaError(`The VibeDev gateway did not confirm the request (${messageOf(lastError)}). dsh-vibedev keeps resending it for a few minutes under the same request key, so it cannot be created twice, and saves the result if it runs. Check media_tasks for task ${draft.id}; do not submit the same request again.`, "SUBMISSION_UNCONFIRMED", { retryable: false });
6508
+ throw new MediaError(`The VibeDev gateway did not confirm the request (${messageOf(lastError)}). dsh-vibedev keeps settling it under the same request key for a few minutes — that key can only ever answer with the one task it may have created — and saves the result if it runs. Check media_tasks for task ${draft.id}; do not submit the same request again.`, "SUBMISSION_UNCONFIRMED", { retryable: false });
6133
6509
  }
6134
6510
  /**
6135
6511
  * Follow a recorded task until it ends or the tracker is disposed. A task
@@ -6295,24 +6671,25 @@ var TaskTracker = class {
6295
6671
  await this.update(record.id, { progress: "waiting for a VibeDev sign-in" });
6296
6672
  return 6e4;
6297
6673
  }
6298
- const settled = submitting ? settledWithoutTask(error) : void 0;
6299
- if (settled !== void 0) {
6300
- await this.finish(record.id, {
6301
- status: "failed",
6302
- error: settled
6303
- });
6304
- return;
6305
- }
6306
- if (submitting && !outcomeUnknown(error) && status !== void 0 && status >= 400 && status < 500) {
6307
- if (this.now() - record.createdAt < (this.options.refusalGraceMs ?? 18e4)) return 3e4;
6308
- await this.finish(record.id, {
6309
- status: "failed",
6310
- error: {
6311
- code: code ?? `HTTP_${status}`,
6312
- message: messageOf(error)
6313
- }
6314
- });
6315
- return;
6674
+ if (submitting) {
6675
+ if (submissionRecovery(error) === "rejected") {
6676
+ await this.finish(record.id, {
6677
+ status: "failed",
6678
+ error: terminalFailure(error)
6679
+ });
6680
+ return;
6681
+ }
6682
+ if (failures >= CONSECUTIVE_FAILURE_LIMIT) {
6683
+ await this.finish(record.id, {
6684
+ status: "failed",
6685
+ error: {
6686
+ code: code ?? "SUBMISSION_FAILED",
6687
+ message: `The submission could not be settled ${failures} times in a row: ${messageOf(error)}`
6688
+ }
6689
+ });
6690
+ return;
6691
+ }
6692
+ return Math.min(6e4, 5e3 * 2 ** Math.min(failures - 1, 4));
6316
6693
  }
6317
6694
  if (status === 402) {
6318
6695
  await this.update(record.id, { progress: "paused: the VibeDev balance is used up; top up to receive the result" });
@@ -6440,19 +6817,15 @@ var TaskTracker = class {
6440
6817
  missing
6441
6818
  };
6442
6819
  }
6443
- /** 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`. */
6444
6821
  async fetchOutput(record, task, output, temporary) {
6445
6822
  const spec = SPECS[record.kind];
6446
6823
  const sources = [];
6447
- if (record.kind === "video") sources.push({
6448
- url: spec.content(task.id, output.index),
6449
- anonymous: false
6450
- });
6451
6824
  if (output.url !== void 0) sources.push({
6452
6825
  url: output.url,
6453
6826
  anonymous: true
6454
6827
  });
6455
- if (record.kind === "audio") sources.push({
6828
+ else sources.push({
6456
6829
  url: spec.content(task.id, output.index),
6457
6830
  anonymous: false
6458
6831
  });
@@ -6485,8 +6858,6 @@ var TaskTracker = class {
6485
6858
  * Settings → VibeDev 账号; this is the agent's way to the same sign-in.
6486
6859
  * @module dsh-vibedev/tools/account
6487
6860
  */
6488
- /** Where new users register. */
6489
- const REGISTER_URL = "https://vibedev.jzsaas.com";
6490
6861
  /**
6491
6862
  * Build the tool.
6492
6863
  * @param runtime - the media runtime.
@@ -6567,7 +6938,7 @@ function mediaAccountTool(runtime, chain, login) {
6567
6938
  return {
6568
6939
  source,
6569
6940
  signInUrl: started.url,
6570
- 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.`
6571
6942
  };
6572
6943
  }
6573
6944
  });
@@ -7187,10 +7558,10 @@ const Config = Schema.object({
7187
7558
  musicModel: Schema.string().default("").volatile(),
7188
7559
  podcastModel: Schema.string().default("").volatile(),
7189
7560
  transcriptionModel: Schema.string().default("").volatile(),
7190
- gatewayOrigin: Schema.string().default("https://vibedev.jzsaas.com"),
7561
+ gatewayOrigin: Schema.string().default(DEFAULT_GATEWAY_ORIGIN),
7191
7562
  client: Schema.string().default("vibedev-plugin"),
7192
7563
  primary: Schema.boolean().default(false),
7193
- apiKeyEnv: Schema.string().default("VIBEDEV_GATEWAY_API_KEY"),
7564
+ apiKeyEnv: Schema.string().default(""),
7194
7565
  stateDir: Schema.string().default("")
7195
7566
  }).i18n({
7196
7567
  "zh-CN": {
@@ -7232,13 +7603,13 @@ const Config = Schema.object({
7232
7603
  });
7233
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.";
7234
7605
  function apply(ctx, config) {
7235
- const origin = config.gatewayOrigin.trim().replace(/\/+$/, "");
7606
+ const origin = normalizeGatewayOrigin(config.gatewayOrigin);
7236
7607
  const stateDir = stateDirectory(config.stateDir);
7237
7608
  const userAgent = `vibedev-plugin/${version}`;
7238
7609
  const warn = (message) => {
7239
7610
  ctx.logger.warn(message);
7240
7611
  };
7241
- const storage = grantStorage(stateDir, () => ctx.get("credentials"));
7612
+ const storage = grantStorage(stateDir, () => ctx.get("credentials"), origin);
7242
7613
  const login = new PluginLogin({
7243
7614
  origin,
7244
7615
  userAgent,
@@ -7249,7 +7620,7 @@ function apply(ctx, config) {
7249
7620
  });
7250
7621
  ctx.inject(["credentials"], (scoped) => {
7251
7622
  scoped.on("credentials/record-updated", (key) => {
7252
- if (key === GRANT_KEY) login.restore();
7623
+ if (key === gatewayGrantKey(origin)) login.restore();
7253
7624
  });
7254
7625
  scoped.effect(async () => {
7255
7626
  await login.restore();
@@ -7260,7 +7631,7 @@ function apply(ctx, config) {
7260
7631
  origin,
7261
7632
  plugin: login,
7262
7633
  account: () => ctx.get("deepseekAccount"),
7263
- apiKey: () => config.apiKeyEnv.trim() === "" ? void 0 : process.env[config.apiKeyEnv.trim()]?.trim()
7634
+ apiKey: () => developmentKey(origin, config.apiKeyEnv)
7264
7635
  });
7265
7636
  const http = new GatewayHttp({
7266
7637
  origin,
@@ -7268,7 +7639,7 @@ function apply(ctx, config) {
7268
7639
  resolveCredential: () => chain.resolve(),
7269
7640
  rejectToken: (credential) => chain.reject(credential)
7270
7641
  });
7271
- const store = new TaskStore(stateDir);
7642
+ const store = new TaskStore(gatewayStateDirectory(stateDir, origin));
7272
7643
  const tracker = new TaskTracker({
7273
7644
  http,
7274
7645
  store,