@kaydash9999/banker-plugins 0.16.0 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,8 +36,9 @@ function originOf(url) {
36
36
  const u = new URL(raw);
37
37
  return `${u.protocol}//${u.host}`;
38
38
  } catch {
39
- const m = raw.match(/^(https?:\/\/[^/]+)/i);
40
- return m ? m[1] : raw.replace(/\/+$/, '');
39
+ // Not a URL: null, never the raw text, which may be a key pasted into the endpoint field.
40
+ const m = raw.match(/^(https?:\/\/[^/\s]+)/i);
41
+ return m ? m[1] : null;
41
42
  }
42
43
  }
43
44
 
@@ -50,6 +51,10 @@ function truncate(x, n = 300) {
50
51
  return s.length > n ? `${s.slice(0, n)}…(+${s.length - n})` : s;
51
52
  }
52
53
 
54
+ // Why an azFetch result has no HTTP answer, for error messages: " (EINSECURE)" and the like. The
55
+ // error's code or name only, never its text: fetch can quote a header value, a key included.
56
+ const why = (r) => (r.error ? ` (${r.error.cause?.code || r.error.code || r.error.name})` : '');
57
+
53
58
  // Last n chars of ffmpeg stderr (the useful part) for error messages.
54
59
  const tail = (s, n = 400) => { const str = String(s || ''); return str.length > n ? str.slice(-n) : str; };
55
60
 
@@ -77,7 +82,7 @@ export function parseConsoleSample(text) {
77
82
  const out = { endpoint: null, deployment: null, apiKey: null, apiVersion: null };
78
83
 
79
84
  // endpoint: an explicit kwarg (python/JS SDK) wins; else the origin of the first URL (curl).
80
- let m = s.match(/(?:azure_endpoint|azure_openai_endpoint|base_url|endpoint)\s*[=:]\s*["']([^"']+)["']/i);
85
+ let m = s.match(/(?:azure_endpoint|azure_openai_endpoint|base_url|endpoint)\s*[=:]\s*["']([^"'\r\n]+)["']/i);
81
86
  if (m) out.endpoint = originOf(m[1]);
82
87
  else { m = s.match(/https?:\/\/[^\s"'`)]+/); if (m) out.endpoint = originOf(m[0]); }
83
88
 
@@ -89,13 +94,16 @@ export function parseConsoleSample(text) {
89
94
  m = s.match(/\/deployments\/([^/?"'\s]+)/i);
90
95
  if (m) out.deployment = decodeURIComponent(m[1]);
91
96
  else {
92
- m = s.match(/(?:azure_deployment|deployment_name|deployment|model)\s*[=:]\s*["']([^"']+)["']/i);
97
+ m = s.match(/(?:azure_deployment|deployment_name|deployment|model)\s*[=:]\s*["']([^"'\r\n]+)["']/i);
93
98
  if (m) out.deployment = m[1];
94
99
  }
95
100
 
96
- // key: Authorization: Bearer <k> | api-key: <k> (curl header) | api_key="<k>" (python/JS SDK).
97
- m = s.match(/Authorization["']?\s*[:=]\s*["']?\s*Bearer\s+([A-Za-z0-9._-]+)/i);
98
- if (!m) m = s.match(/api[-_]?key["']?\s*[:=]\s*["']?\s*([A-Za-z0-9._-]+)/i);
101
+ // key: Authorization: Bearer <k> | api-key: <k> (curl header) | api_key="<k>", "api-key": "<k>",
102
+ // apiKey: '<k>' (python/JS SDK). An SDK value counts only in quotes: api_key=subscription_key or
103
+ // apiKey: process.env.X names a variable, and taking that name for the key would replace a real one.
104
+ m = s.match(/Authorization["']?\s*[:=]\s*["']?\s*Bearer\s+([A-Za-z0-9._-]+)/i)
105
+ || s.match(/api-key\s*:\s*([A-Za-z0-9._-]+)/i)
106
+ || s.match(/api[-_]?key["']?\s*[:=]\s*["']([A-Za-z0-9._-]+)["']/i);
99
107
  if (m) out.apiKey = m[1];
100
108
 
101
109
  return out;
@@ -126,56 +134,164 @@ function envValue(raw) {
126
134
  return raw.trimEnd().replace(/\s+#.*$/, '').trim().replace(/^["']|["']$/g, '');
127
135
  }
128
136
 
137
+ /** The home key file, `~/.config/banker/3d-intro/env`: the keys when no project key file is used. */
138
+ export function homeCredsPath() {
139
+ return path.join(os.homedir(), '.config', 'banker', '3d-intro', 'env');
140
+ }
141
+
142
+ /**
143
+ * This machine's settings file, `~/.config/banker/3d-intro/settings.env` (FFMPEG_PATH). It holds no
144
+ * key, so it can exist without making the home key file look set up.
145
+ */
146
+ export function homeSettingsPath() {
147
+ return path.join(os.homedir(), '.config', 'banker', '3d-intro', 'settings.env');
148
+ }
149
+
150
+ /** The settings file as a KEY=VALUE map; {} when it is missing or cannot be read. */
151
+ export function readSettings() {
152
+ return readEnvQuietly(homeSettingsPath);
153
+ }
154
+
155
+ // A KEY=VALUE file named by fileOf(); {} when there is none or it cannot be read (no home folder, no
156
+ // permission, a folder in its place).
157
+ function readEnvQuietly(fileOf) {
158
+ try { return parseEnvFile(fs.readFileSync(fileOf(), 'utf8')); } catch { return {}; }
159
+ }
160
+
161
+ // Settings that name a program to run. Only the shell or this machine's own files set them (the
162
+ // settings file, or the home key file from an earlier setup): a project's .env.3d-intro.local can come
163
+ // with a repository, and must not choose what runs on this machine.
164
+ const MACHINE_ONLY = ['FFMPEG_PATH'];
165
+
129
166
  /**
130
167
  * Load creds from `.env.3d-intro.local` in projectDir, else `~/.config/banker/3d-intro/env`.
131
- * Returns the parsed KEY=VALUE map plus `_source` (the file used, or null if none found).
168
+ * Returns the parsed KEY=VALUE map plus `_source` (the file used, or null if none found). From a
169
+ * project file, FFMPEG_PATH is left out and returned under `_ignored` so the caller can say why.
132
170
  */
133
171
  export function resolveCreds({ projectDir } = {}) {
172
+ const home = homeCredsPath();
134
173
  const candidates = [];
135
174
  if (projectDir) candidates.push(path.join(projectDir, '.env.3d-intro.local'));
136
- candidates.push(path.join(os.homedir(), '.config', 'banker', '3d-intro', 'env'));
137
- for (const p of candidates) {
138
- if (fs.existsSync(p)) return { ...parseEnvFile(fs.readFileSync(p, 'utf8')), _source: p };
139
- }
140
- return { _source: null };
175
+ candidates.push(home);
176
+ const p = candidates.find((c) => fs.existsSync(c));
177
+ if (!p) return { _source: null };
178
+ const creds = { ...parseEnvFile(fs.readFileSync(p, 'utf8')), _source: p };
179
+ return p === home ? creds : withoutMachineOnly(creds);
180
+ }
181
+
182
+ function withoutMachineOnly(creds) {
183
+ const ignored = Object.fromEntries(MACHINE_ONLY.filter((k) => creds[k] != null).map((k) => [k, creds[k]]));
184
+ for (const k of MACHINE_ONLY) delete creds[k];
185
+ return Object.keys(ignored).length ? { ...creds, _ignored: ignored } : creds;
141
186
  }
142
187
 
143
188
  /**
144
189
  * Persist creds to a KEY=VALUE env file, chmod 0600 where supported. Keys starting with `_`
145
- * and null/undefined values are skipped. Returns the written path.
190
+ * and null/undefined values are skipped. Returns the written path. A name that is not a variable
191
+ * name, or a value with a line break, throws before anything is written: either would add lines of
192
+ * its own (a FFMPEG_PATH, a second endpoint) to the file.
146
193
  */
147
194
  export function persistCreds(creds, { target } = {}) {
148
195
  if (!target) throw new Error('persistCreds: target path required');
149
- const lines = [];
150
- for (const [k, v] of Object.entries(creds || {})) {
151
- if (k.startsWith('_') || v == null) continue;
152
- lines.push(`${k}=${envLiteral(String(v))}`);
153
- }
196
+ const lines = Object.entries(creds || {}).filter(([k, v]) => !k.startsWith('_') && v != null).map(([k, v]) => envLine(k, String(v)));
154
197
  const body = lines.length ? `${lines.join('\n')}\n` : '';
155
- fs.mkdirSync(path.dirname(target), { recursive: true });
198
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
156
199
  fs.writeFileSync(target, body, { mode: 0o600 });
157
200
  try { fs.chmodSync(target, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
158
201
  return target;
159
202
  }
160
203
 
204
+ // One KEY=VALUE line for persistCreds, refusing a name or value that would make lines of its own.
205
+ function envLine(k, v) {
206
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) throw new Error(`persistCreds: ${JSON.stringify(k)} is not a variable name; nothing was written`);
207
+ if (/[\r\n]/.test(v)) throw new Error(`persistCreds: ${k} contains a line break; nothing was written`);
208
+ return `${k}=${envLiteral(v)}`;
209
+ }
210
+
161
211
  // Quote a value that parseEnvFile would otherwise cut at " #", trim, or strip of its quotes.
162
212
  function envLiteral(v) {
163
213
  if (!/\s#|^["'\s]|["'\s]$/.test(v)) return v;
164
214
  return v.includes('"') ? `'${v}'` : `"${v}"`;
165
215
  }
166
216
 
167
- /** Redact a secret to `abcd…wxyz(len)`; short (<=8) secrets are fully masked; falsy -> "(missing)". */
217
+ /**
218
+ * Redact a secret to its length only, `…(len)`: no character of it, since keys share fixed
219
+ * prefixes (`sk-ws-`) and a shown tail narrows them down. Falsy -> "(missing)".
220
+ */
168
221
  export function redact(s) {
169
222
  if (!s) return '(missing)';
170
- const str = String(s);
171
- if (str.length <= 8) return `…(${str.length})`;
172
- return `${str.slice(0, 4)}…${str.slice(-4)}(${str.length})`;
223
+ return `…(${String(s).length})`;
173
224
  }
174
225
 
175
226
  // ---------------------------------------------------------------------------
176
227
  // HTTP
177
228
  // ---------------------------------------------------------------------------
178
229
 
230
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
231
+ const SECRET_HEADERS = new Set(['authorization', 'api-key', 'proxy-authorization', 'cookie']);
232
+ const MAX_HOPS = 5;
233
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
234
+ const localHttp = (u) => u.protocol === 'http:' && LOOPBACK.has(u.hostname);
235
+ const refused = (code, message) => Object.assign(new TypeError(`fetch failed: ${message}`), { cause: { code } });
236
+ const isSecret = (name) => SECRET_HEADERS.has(name.toLowerCase());
237
+ const withoutSecrets = (headers) => Object.fromEntries(Object.entries(headers).filter(([k]) => !isSecret(k)));
238
+ // Every form fetch takes (an object, [name, value] pairs, Headers) as one plain object, so a key is
239
+ // found in any of them. Pairs would otherwise read as headers named "0", "1".
240
+ const plainHeaders = (h) => (Array.isArray(h) || h instanceof Headers ? Object.fromEntries(new Headers(h)) : { ...h });
241
+
242
+ /** Whether a key may go to this URL: over https, or over http only to this machine (a local mock or proxy). */
243
+ export const safeForKey = (u) => u.protocol === 'https:' || localHttp(u);
244
+
245
+ // Where a redirect goes. The request was sent already, so a Location that is not a URL, or one that
246
+ // leaves https, ends it with a code that does not read as "nothing sent". http stays allowed only
247
+ // while the request is on this machine over http (a local mock); from anywhere else, an https
248
+ // localhost included, it would reach a service here or on the network.
249
+ function redirectTarget(location, from) {
250
+ let next;
251
+ try { next = new URL(location, from); } catch { throw refused('EBADREDIRECT', 'the redirect location is not a URL'); }
252
+ if (next.protocol === 'https:' || (localHttp(next) && localHttp(from))) return next;
253
+ throw refused('EINSECUREREDIRECT', 'a redirect away from https is not followed');
254
+ }
255
+
256
+ // The request for the next hop of a redirect. A request with a body (a prompt, a frame, a paid submit)
257
+ // follows only a 307 or 308 within its origin, which send it again as it was. A 301, 302 or 303 would
258
+ // make it a GET, and another origin is another server: either way the next answer does not answer this
259
+ // request, and its 401 or 429 would read as this key's (a key retired, a submit sent again elsewhere).
260
+ // Without a body, a 303, and a 301 or 302 after a POST, go on as a GET, as browsers do, and a hop to
261
+ // another origin goes without the secret headers.
262
+ function nextHop(req, status, crossOrigin) {
263
+ if (req.body != null && (crossOrigin || status < 307)) {
264
+ throw refused(crossOrigin ? 'ECROSSREDIRECT' : 'EBODYREDIRECT', 'a request with a body follows only a 307 or 308 within its origin');
265
+ }
266
+ const toGet = status === 303 || ((status === 301 || status === 302) && req.method === 'POST');
267
+ const next = toGet ? { ...req, method: 'GET', body: undefined } : req;
268
+ return crossOrigin ? { ...next, headers: withoutSecrets(next.headers) } : next;
269
+ }
270
+
271
+ /**
272
+ * fetch that follows redirects by hand (at most 5): a request without a body as fetch would, but to
273
+ * another origin without the key headers; a request with a body only by a 307 or 308 within its
274
+ * origin (fetch itself forwards a custom `api-key` header and re-sends the body on 307 and 308 to
275
+ * any origin, and turns a POST into a GET on 301 to 303). A key goes only where safeForKey allows: an http
276
+ * URL with one is refused before anything is sent (cause.code EINSECURE), and no redirect leaves
277
+ * https. Throws as fetch does on a network error.
278
+ */
279
+ export async function fetchGuarded(url, init = {}) {
280
+ let target = new URL(String(url));
281
+ let req = { ...init, headers: plainHeaders(init.headers), redirect: 'manual' };
282
+ if (!safeForKey(target) && Object.keys(req.headers).some(isSecret)) throw refused('EINSECURE', 'a key is sent only over https');
283
+ for (let hop = 0; hop <= MAX_HOPS; hop++) {
284
+ const r = await globalThis.fetch(target.href, req);
285
+ const location = REDIRECT_STATUS.has(r.status) ? r.headers.get('location') : null;
286
+ if (!location) return r;
287
+ try { await r.body?.cancel(); } catch { /* the redirect body is not needed */ }
288
+ const next = redirectTarget(location, target);
289
+ req = nextHop(req, r.status, next.origin !== target.origin);
290
+ target = next;
291
+ }
292
+ throw refused('ETOOMANYREDIRECTS', 'too many redirects');
293
+ }
294
+
179
295
  // Retry-After / retry-after-ms response header -> milliseconds (null when absent or unparsable).
180
296
  function retryAfterMsOf(headers) {
181
297
  const ms = Number(headers.get('retry-after-ms'));
@@ -202,7 +318,7 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
202
318
  if (body !== undefined) opts.body = body;
203
319
  let r;
204
320
  try {
205
- r = await globalThis.fetch(url, opts);
321
+ r = await fetchGuarded(url, opts);
206
322
  } catch (e) {
207
323
  last = { status: 0, ok: false, ct: '', body: null, authStyle: null, error: e };
208
324
  break; // network error is not an auth problem — do not retry other styles
@@ -260,7 +376,7 @@ export async function generateImage({
260
376
  });
261
377
  }
262
378
  const b64 = r.body?.data?.[0]?.b64_json;
263
- if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status} ${truncate(r.body)}`);
379
+ if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
264
380
  return Buffer.from(b64, 'base64');
265
381
  }
266
382
 
@@ -278,7 +394,7 @@ export async function editImage({
278
394
  key, method: 'POST', body: fd,
279
395
  });
280
396
  const b64 = r.body?.data?.[0]?.b64_json;
281
- if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status} ${truncate(r.body)}`);
397
+ if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
282
398
  return Buffer.from(b64, 'base64');
283
399
  }
284
400
 
@@ -289,7 +405,7 @@ export async function generateImageFlux({ endpoint, key, prompt, width, height,
289
405
  body: JSON.stringify({ prompt, width, height }),
290
406
  });
291
407
  const b64 = r.body?.data?.[0]?.b64_json || r.body?.b64_json || r.body?.image;
292
- if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status} ${truncate(r.body)}`);
408
+ if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
293
409
  return Buffer.from(b64, 'base64');
294
410
  }
295
411
 
@@ -322,7 +438,7 @@ export async function createVideo({
322
438
  const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body: fd });
323
439
  if (!(r.ok && r.body?.id)) {
324
440
  // status 0 = network error; errno tells a refused connection (nothing sent) from a lost one
325
- const err = new Error(`createVideo failed: HTTP ${r.status} ${truncate(r.body)}`);
441
+ const err = new Error(`createVideo failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
326
442
  err.status = r.status;
327
443
  err.errno = r.error?.cause?.code || r.error?.code || null;
328
444
  throw err;
@@ -368,7 +484,7 @@ export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview'
368
484
  const r = await azFetch(`${endpoint}/openai/v1/videos/${id}/content?api-version=${apiVersion}`, {
369
485
  key, method: 'GET', binary: true,
370
486
  });
371
- if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}`);
487
+ if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`);
372
488
  return r.body;
373
489
  }
374
490
 
@@ -413,7 +529,7 @@ export async function createVideoTwoImage({
413
529
  key, method: 'POST', body: fd,
414
530
  });
415
531
  const id = r.body?.id || r.body?.job_id;
416
- if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status} ${truncate(r.body)}`);
532
+ if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
417
533
  return r.body;
418
534
  }
419
535
 
@@ -423,30 +539,43 @@ export async function createVideoTwoImage({
423
539
 
424
540
  /**
425
541
  * Locate an ffmpeg binary without ever invoking a shell:
426
- * 1) $FFMPEG_PATH (if it exists)
427
- * 2) scan $PATH entries (split on path.delimiter) for ffmpeg / ffmpeg.exe
542
+ * 1) $FFMPEG_PATH from the shell, else FFMPEG_PATH in the settings file, else one an earlier setup
543
+ * left in the home key file; absolute paths only
544
+ * 2) the absolute $PATH entries (split on path.delimiter), for ffmpeg / ffmpeg.exe
428
545
  * 3) the optional ffmpeg-static package (dynamic import — may be absent)
546
+ * A project's .env.3d-intro.local never names the program, and "." or another relative PATH entry is
547
+ * skipped: either would let the files of a project, a cloned repository say, choose what runs.
429
548
  * Throws an actionable error if none is found.
430
549
  */
431
550
  export async function resolveFfmpeg() {
432
- const fromEnv = process.env.FFMPEG_PATH;
433
- if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
551
+ const found = namedFfmpeg() || ffmpegOnPath() || await staticFfmpeg();
552
+ if (!found) throw new Error('ffmpeg not found; run motion-graphic-setup or 3d-intro-setup');
553
+ return found;
554
+ }
555
+
556
+ // The ffmpeg this machine names. Absolute only: a relative value would be looked up in the current
557
+ // folder, the project. The files are read only when the shell names nothing usable.
558
+ function namedFfmpeg() {
559
+ const usable = (p) => !!p && path.isAbsolute(p) && fs.existsSync(p);
560
+ if (usable(process.env.FFMPEG_PATH)) return process.env.FFMPEG_PATH;
561
+ return [readSettings(), readEnvQuietly(homeCredsPath)].map((s) => s.FFMPEG_PATH).find(usable) || null;
562
+ }
434
563
 
564
+ function ffmpegOnPath() {
435
565
  const names = process.platform === 'win32' ? ['ffmpeg.exe', 'ffmpeg'] : ['ffmpeg'];
436
- for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
437
- for (const name of names) {
438
- const candidate = path.join(dir, name);
439
- if (fs.existsSync(candidate)) return candidate;
440
- }
566
+ for (const dir of (process.env.PATH || '').split(path.delimiter).filter((d) => path.isAbsolute(d))) {
567
+ const hit = names.map((name) => path.join(dir, name)).find((c) => fs.existsSync(c));
568
+ if (hit) return hit;
441
569
  }
570
+ return null;
571
+ }
442
572
 
573
+ async function staticFfmpeg() {
443
574
  try {
444
575
  const mod = await import('ffmpeg-static');
445
576
  const p = mod?.default || mod;
446
- if (typeof p === 'string' && fs.existsSync(p)) return p;
447
- } catch { /* ffmpeg-static not installed — fall through to the error */ }
448
-
449
- throw new Error('ffmpeg not found; run motion-graphic-setup or 3d-intro-setup');
577
+ return typeof p === 'string' && fs.existsSync(p) ? p : null;
578
+ } catch { return null; /* ffmpeg-static not installed */ }
450
579
  }
451
580
 
452
581
  // Run ffmpeg with an ARGUMENT ARRAY (never a shell string). Returns {code, stdout, stderr, args}.
@@ -37,7 +37,7 @@ import path from 'node:path';
37
37
  import os from 'node:os';
38
38
  import crypto from 'node:crypto';
39
39
  import cp from 'node:child_process';
40
- import { createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact } from './azure-adapter.mjs';
40
+ import { createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact, fetchGuarded, safeForKey } from './azure-adapter.mjs';
41
41
 
42
42
  export const WAN_DEFAULT_MODEL = 'wan3.0-video-prime';
43
43
  const WAN_CREATE_PATH = '/api/v1/services/aigc/video-generation/video-synthesis';
@@ -50,6 +50,8 @@ const PROBE_TASK_ID = '00000000-0000-0000-0000-000000000000';
50
50
 
51
51
  const RATE_BASE_MS = 30_000;
52
52
  const RATE_MAX_MS = 10 * 60_000;
53
+ // A server Retry-After is held to this range: 0 would retry at once, a huge value rest a key for years.
54
+ const RETRY_MIN_MS = 1000;
53
55
  const QUOTA_STRIKES_TO_EXHAUST = 3;
54
56
  const QUOTA_STRIKE_TTL_MS = 3_600_000;
55
57
  const TRANSIENT_BASE_MS = 15_000;
@@ -72,7 +74,9 @@ const TRANSIENT_CODES = new Set([
72
74
  'InternalError', 'InternalError.Timeout', 'ServiceUnavailable', 'ModelUnavailable', 'Throttling.ServiceOverloaded',
73
75
  ]);
74
76
  // Errors that mean the request never left this machine, so resubmitting elsewhere cannot double-bill.
75
- const PRE_SEND_ERRORS = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT', 'ERR_INVALID_URL']);
77
+ // EINSECURE is fetchGuarded refusing a key over http before sending. Its redirect codes are not here:
78
+ // a redirect answers a request that was sent.
79
+ const PRE_SEND_ERRORS = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT', 'ERR_INVALID_URL', 'EINSECURE']);
76
80
 
77
81
  /**
78
82
  * Map a WAN failure to a pool action.
@@ -115,11 +119,13 @@ function codeKind(code) {
115
119
 
116
120
  // Endpoints are origins: a pasted console URL with a path would otherwise 404 every call. A value
117
121
  // new URL() rejects (a bare host, a value with spaces, a key pasted into the wrong field) gives
118
- // null. The raw value is never echoed, because it may be a key.
122
+ // null. The raw value is never echoed, because it may be a key. The key goes only over https: an
123
+ // http:// endpoint (one typo) would send it in the clear, so it counts as malformed too, except on
124
+ // this machine (a local mock or proxy). safeForKey is the same rule fetchGuarded enforces.
119
125
  function originOf(url) {
120
126
  try {
121
127
  const u = new URL(String(url).trim());
122
- return /^https?:$/.test(u.protocol) && u.host ? `${u.protocol}//${u.host}` : null;
128
+ return safeForKey(u) && u.host ? `${u.protocol}//${u.host}` : null;
123
129
  } catch { return null; }
124
130
  }
125
131
  const fingerprint = (endpoint, key, model) => crypto.createHash('sha256').update(`${endpoint}\n${key}\n${model}`).digest('hex').slice(0, 16);
@@ -136,8 +142,8 @@ const malformedField = (e) => (e.badEndpoint ? 'endpoint' : e.badModel ? 'model'
136
142
 
137
143
  /**
138
144
  * Build the WAN pool from creds: every WAN_<n>_ENDPOINT with a matching WAN_<n>_API_KEY, by n.
139
- * An entry whose endpoint is not an http(s) URL (`badEndpoint`) or whose model is not one model id
140
- * (`badModel`) is kept so the preflight can report it; generateClip skips it.
145
+ * An entry whose endpoint is not an https URL (`badEndpoint`; http only to this machine) or whose
146
+ * model is not one model id (`badModel`) is kept so the preflight can report it; generateClip skips it.
141
147
  */
142
148
  export function loadWanPool(creds = {}) {
143
149
  const pool = [];
@@ -171,11 +177,20 @@ export function loadPoolState(file = defaultStatePath()) {
171
177
  return { version: 1, entries: {} };
172
178
  }
173
179
 
180
+ // Written through a fresh random name opened exclusively ('wx'), so a link someone planted at a
181
+ // guessable name is never followed; the folder is private when this creates it. A failed write or
182
+ // rename removes the temp file.
174
183
  export function savePoolState(state, file = defaultStatePath()) {
175
- fs.mkdirSync(path.dirname(file), { recursive: true });
176
- const tmp = `${file}.${process.pid}.tmp`;
177
- fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
178
- fs.renameSync(tmp, file);
184
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
185
+ const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
186
+ const fd = fs.openSync(tmp, 'wx', 0o600);
187
+ try {
188
+ try { fs.writeFileSync(fd, `${JSON.stringify(state, null, 2)}\n`); } finally { fs.closeSync(fd); }
189
+ fs.renameSync(tmp, file);
190
+ } catch (e) {
191
+ fs.rmSync(tmp, { force: true });
192
+ throw e;
193
+ }
179
194
  try { fs.chmodSync(file, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
180
195
  return file;
181
196
  }
@@ -258,9 +273,9 @@ function pickEntry(pool, state, t, limit) {
258
273
  function parseRetryAfter(h) {
259
274
  if (!h) return null;
260
275
  const sec = Number(h);
261
- if (Number.isFinite(sec)) return Math.max(0, sec * 1000);
262
276
  const at = Date.parse(h);
263
- return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
277
+ const ms = Number.isFinite(sec) ? sec * 1000 : Number.isFinite(at) ? at - Date.now() : null;
278
+ return ms === null ? null : Math.min(RATE_MAX_MS, Math.max(RETRY_MIN_MS, ms));
264
279
  }
265
280
 
266
281
  const WAN_FETCH_DEFAULTS = { method: 'GET', binary: false, timeoutMs: API_TIMEOUT_MS };
@@ -269,7 +284,7 @@ async function wanFetch(url, opts = {}) {
269
284
  const o = { ...WAN_FETCH_DEFAULTS, ...defined(opts) };
270
285
  let r;
271
286
  try {
272
- r = await globalThis.fetch(url, {
287
+ r = await fetchGuarded(url, {
273
288
  method: o.method, headers: wanHeaders(o), body: o.body === undefined ? undefined : JSON.stringify(o.body), signal: AbortSignal.timeout(o.timeoutMs),
274
289
  });
275
290
  } catch (e) {
@@ -391,7 +406,13 @@ export async function wanPollTask({ entry, taskId, intervalMs = 15_000, timeoutM
391
406
  }
392
407
 
393
408
  /** Download a finished WAN video (signed URL, no auth header). Returns an mp4 Buffer. */
409
+ // The video URL comes from the provider's answer: only https is fetched.
410
+ function httpsOnly(url) {
411
+ if (!/^https:\/\//i.test(String(url))) throw new Error('WAN video download refused: the video_url is not https');
412
+ }
413
+
394
414
  export async function downloadWanVideo(url, { sleep = realSleep, tries = 3 } = {}) {
415
+ httpsOnly(url);
395
416
  let last;
396
417
  for (let i = 0; i < tries; i++) {
397
418
  last = await wanFetch(url, { binary: true, timeoutMs: DOWNLOAD_TIMEOUT_MS });
@@ -436,9 +457,12 @@ export class ContentRejectedError extends Error {
436
457
 
437
458
  // An error that must NOT be retried by resubmitting the leg: the task may exist and bill.
438
459
  // kind: timeout | submit_unknown | download | no_video | task_lost. Carries taskId/label when known.
439
- function stopError(kind, message, props = {}) {
460
+ // A stop the caller can act on. `props` are plain properties (they show in logs); `hidden` ones (a
461
+ // signed download URL) read as err.<name> but stay out of JSON.stringify and console.error.
462
+ function stopError(kind, message, props = {}, hidden = {}) {
440
463
  const err = new Error(message);
441
464
  Object.assign(err, { kind, ...props });
465
+ for (const [k, v] of Object.entries(hidden)) Object.defineProperty(err, k, { value: v, enumerable: false });
442
466
  return err;
443
467
  }
444
468
 
@@ -530,8 +554,8 @@ async function succeededClip(ctx, e, taskId, p, seconds) {
530
554
  try {
531
555
  mp4 = await downloadWanVideo(videoUrl, { sleep: ctx.sleep });
532
556
  } catch (err) {
533
- // videoUrl is a signed URL: kept as a property for recovery (valid 24 h), never in the message
534
- throw stopError('download', `${err.message} for WAN task ${taskId} (${e.label}); resume with resumeClip within 24 h`, { provider: 'wan', taskId, label: e.label, videoUrl, seconds });
557
+ // videoUrl is a signed URL (valid 24 h): readable as err.videoUrl for recovery, never in the message or a log
558
+ throw stopError('download', `${err.message} for WAN task ${taskId} (${e.label}); resume with resumeClip within 24 h`, { provider: 'wan', taskId, label: e.label, seconds }, { videoUrl });
535
559
  }
536
560
  ctx.emit({ type: 'done', provider: 'wan', label: e.label, taskId });
537
561
  return { mp4, provider: 'wan', label: e.label, model: shownModel(e), seconds: seconds ?? p.usage?.duration ?? null, taskId, usage: p.usage };
@@ -563,7 +587,7 @@ async function runWan(ctx) {
563
587
  function usablePool(ctx) {
564
588
  const all = loadWanPool(ctx.creds);
565
589
  const bad = all.filter(malformedField);
566
- for (const b of bad) ctx.emit({ type: 'skip', provider: 'wan', label: b.label, reason: `${malformedField(b)} is malformed (not an http(s) URL / not one model id)` });
590
+ for (const b of bad) ctx.emit({ type: 'skip', provider: 'wan', label: b.label, reason: `${malformedField(b)} is malformed (not an https URL / not one model id)` });
567
591
  if (all.length && bad.length === all.length) {
568
592
  throw stopError('invalid_request', `every WAN entry is malformed (${bad.map((b) => `${b.label} ${malformedField(b)}`).join(', ')}); fix the env file — nothing was sent, and Sora was not used`, { provider: 'wan' });
569
593
  }
@@ -722,7 +746,8 @@ const DEFAULTS = { size: '720x1280', seconds: 5, pollIntervalMs: 15_000, pollTim
722
746
  *
723
747
  * Throws (never resubmitting the leg) when retrying could pay twice or cannot help:
724
748
  * ContentRejectedError moderation — change the prompt/frame
725
- * kind 'invalid_request' malformed request, ModelNotFound or every WAN entry malformed (config)
749
+ * kind 'invalid_request' malformed request, ModelNotFound, every WAN entry malformed or a
750
+ * Sora endpoint that is not https (config)
726
751
  * kind 'timeout' | 'download' the task exists — call resumeClip({ ..., provider, label, taskId })
727
752
  * kind 'submit_unknown' | 'task_lost' | 'no_video' | 'failed' outcome unknown — check the console
728
753
  * ('failed': the task ended for a reason the pool does not know)
@@ -741,7 +766,7 @@ export async function generateClip(opts = {}) {
741
766
  const r = await runWan(ctx);
742
767
  if (r) return r;
743
768
  } else if (p === 'sora') {
744
- if (!o.creds.AZURE_SORA_ENDPOINT || !o.creds.AZURE_SORA_API_KEY) { ctx.emit({ type: 'skip', provider: 'sora', reason: 'AZURE_SORA_ENDPOINT/API_KEY missing' }); continue; }
769
+ if (!soraReady(ctx)) continue;
745
770
  tried.push('sora');
746
771
  ctx.emit({ type: 'fallback', provider: 'sora', from: tried.slice(0, -1) });
747
772
  return runSora(ctx);
@@ -750,6 +775,18 @@ export async function generateClip(opts = {}) {
750
775
  throw new Error(`generateClip: no video provider available (tried: ${tried.join(', ') || 'none'})`);
751
776
  }
752
777
 
778
+ // Whether Sora can take the leg. Not set up: skipped with an event. An endpoint that is set but not
779
+ // https (http only to this machine) stops the leg before the key is sent in the clear.
780
+ function soraReady(ctx) {
781
+ const { AZURE_SORA_ENDPOINT: endpoint, AZURE_SORA_API_KEY: key } = ctx.creds;
782
+ if (!endpoint || !key) {
783
+ ctx.emit({ type: 'skip', provider: 'sora', reason: 'AZURE_SORA_ENDPOINT/API_KEY missing' });
784
+ return false;
785
+ }
786
+ if (!originOf(endpoint)) throw stopError('invalid_request', 'AZURE_SORA_ENDPOINT is not an https URL; fix the env file — nothing was sent', { provider: 'sora' });
787
+ return true;
788
+ }
789
+
753
790
  /**
754
791
  * Finish a clip whose task already exists (after a 'timeout' or 'download' error) without
755
792
  * submitting anything new. FREE except for the work already billed. Same return shape as
@@ -763,7 +800,7 @@ export async function resumeClip(opts = {}) {
763
800
  const ctx = makeCtx(o);
764
801
  if (o.provider !== 'sora') return resumeWan(ctx, o);
765
802
  if (!originOf(o.creds.AZURE_SORA_ENDPOINT) || !o.creds.AZURE_SORA_API_KEY) {
766
- throw stopError('invalid_request', 'resumeClip: resuming a Sora job needs AZURE_SORA_ENDPOINT as an http(s) URL and AZURE_SORA_API_KEY (run 3d-intro-setup); nothing was sent', { provider: 'sora', taskId: o.taskId });
803
+ throw stopError('invalid_request', 'resumeClip: resuming a Sora job needs AZURE_SORA_ENDPOINT as an https URL and AZURE_SORA_API_KEY (run 3d-intro-setup); nothing was sent', { provider: 'sora', taskId: o.taskId });
767
804
  }
768
805
  return finishSoraJob(ctx, o.taskId, o.seconds == null ? null : snapSoraSeconds(o.seconds));
769
806
  }
@@ -772,7 +809,7 @@ async function resumeWan(ctx, o) {
772
809
  const e = loadWanPool(o.creds).find((x) => x.label === o.label);
773
810
  if (!e) throw new Error(`resumeClip: ${o.label} is not in the WAN pool (was the key removed?)`);
774
811
  if (e.badEndpoint) {
775
- throw stopError('invalid_request', `resumeClip: ${o.label} endpoint is not an http(s) URL; fix the env file — nothing was sent`, { provider: 'wan', label: o.label, taskId: o.taskId });
812
+ throw stopError('invalid_request', `resumeClip: ${o.label} endpoint is not an https URL; fix the env file — nothing was sent`, { provider: 'wan', label: o.label, taskId: o.taskId });
776
813
  }
777
814
  const out = await finishWanTask(ctx, e, o.taskId, { duration: Math.round(Number(o.seconds) || 0) || null });
778
815
  if (out.clip) return out.clip;