@kaydash9999/banker-plugins 0.16.0 → 0.16.1

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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code skills + commands — QA, audit, docs, architecture, dependency setup, and wiki; the banker plugin marketplace.",
9
- "version": "0.16.0"
9
+ "version": "0.16.1"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "banker",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Claude Code plugin: QA/audit/docs/architecture skills + commands, a /banker:setup installer for OS dependencies (playwright, etc.), and wiki compaction.",
5
5
  "author": {
6
6
  "name": "smallOpenSource",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.16.1] - 2026-10-11
4
+
5
+ ### Security
6
+ - **3d-intro 의 API 키는 https 로만 간다.** 0.16.0 은 Sora 영상 생성, Azure 이미지 생성과 목록, setup 프리플라이트가 `http://` endpoint 에도 키를 평문으로 보냈다(https 확인은 Sora `resumeClip` 에만 있었다).\
7
+ 이제 키 헤더가 실린 요청은 https 로만 가고, http 는 이 기기의 localhost 만 허용한다. 그 밖의 http endpoint 는 보내기 전에 막는다. Sora 영상 단계는 `invalid_request` 로 멈추고, 그 밖의 호출은 `HTTP 0 (EINSECURE)` 오류다. WAN endpoint 도 https 만 받는다.
8
+ - **리다이렉트로 키, 프롬프트, 유료 제출이 다른 곳에 가지 않는다.** 0.16.0 의 fetch 는 리다이렉트를 따라가며 `api-key` 헤더를 다른 출처에 넘겼고, 307, 308 에서는 프롬프트와 프레임을 다시 보냈다. 이제 리다이렉트를 직접 따라간다(최대 5회).\
9
+ 다른 출처로는 키 헤더 없이 본문 없는 GET 만 보낸다. 본문이 있는 요청은 다른 출처로 따라가지 않아, 그곳의 401, 429 를 키의 답으로 읽고 다른 키로 다시 제출하는 일(이중 과금)이 없다. https 를 벗어나는 리다이렉트(예: 다운로드 URL 이 `http://169.254.169.254` 로 보내는 것)도 따라가지 않는다. Location 이 URL 이 아니면 그 제출을 "보내지 않음" 으로 보지 않는다.
10
+ - **`3d-intro-setup` 이 키를 채팅이나 명령줄로 받지 않는다.** 키는 사람이 `umask 077` 로 만든 키 파일에 직접 적거나, 콘솔 샘플을 비공개 파일로 저장하면 에이전트가 화면에 내지 않고 합친다.\
11
+ 합칠 때는 `resolveCreds` 가 이미 읽는 키 파일(홈 fallback 포함)에 쓰고, 홈 키 파일이 있으면 프로젝트 키 파일을 새로 만들지 않는다(만들면 홈의 키가 가려진다). 파일의 다른 키를 바꾸면 길이로만 알린다. 샘플의 endpoint 가 다른데 샘플에 키가 없으면 저장하지 않는다(기존 키가 새 host 로 가지 않게). SDK 샘플의 `api_key=subscription_key` 같은 변수 이름은 키로 읽지 않는다.\
12
+ 키가 대화나 명령에 드러났다면 Azure 포털의 Regenerate, Model Studio 콘솔의 API Key 관리에서 교체한다.
13
+ - 화면, 오류, 로그에 키가 실리지 않는다. `redact` 는 길이만 보인다(0.16.0 은 앞뒤 4자를 보였고, `sk-ws-` 키는 앞이 상수라 뒤 4자가 드러났다). URL 이 아닌 endpoint 값(붙여 넣은 키일 수 있음)은 원문 대신 `null` 이다. WAN 다운로드 실패 오류의 서명 URL 은 `err.videoUrl` 로만 읽히고 JSON 과 `console.error` 에는 실리지 않는다.
14
+ - WAN 풀 상태 파일은 무작위 이름의 임시 파일을 배타적으로 만들어 써서, 미리 심어 둔 링크를 따라 다른 파일을 덮지 않는다. 폴더는 0700, 파일은 0600 이고, 쓰기나 이름 바꾸기에 실패하면 임시 파일을 지운다.
15
+
16
+ ### Fixed
17
+ - 서버의 `Retry-After` 를 1초에서 10분 사이로만 받는다. 0.16.0 은 `999999999` 면 키를 31년 쉬게 했고, `0` 이면 기다리지 않고 다시 시도했다. 쉬는 키의 기록은 `resetPoolState({ creds, labels })` 로 지운다.
18
+ - setup 프리플라이트가 status 0 의 이유(`EINSECURE`, `ERR_INVALID_URL` 등)를 보인다. FLUX 가 다른 리소스면 그 키를 `AZURE_IMAGE_SERVICES_API_KEY` 로 키 파일에 적는다.
19
+
3
20
  ## [0.16.0] - 2026-10-11
4
21
 
5
22
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaydash9999/banker-plugins",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "banker — Claude Code + Codex CLI plugin (QA, audit, docs, architecture, wiki skills). Install via Claude Code marketplace, or `npm i -g @kaydash9999/banker-plugins` then `banker setup`.",
5
5
  "bin": {
6
6
  "banker": "bin/banker.js"
@@ -60,7 +60,9 @@ if (creds.FFMPEG_PATH && !process.env.FFMPEG_PATH) process.env.FFMPEG_PATH = cre
60
60
  - 둘 다 통과해야 다음 단계로 간다.
61
61
  - 영상 provider 상태를 무료로 확인해 둔다.
62
62
  `poolSummary({ creds })` 는 WAN 키별 상태(`ok`, `cooldown`, `exhausted`, `invalid`, `malformed`)를 키 원문 없이 돌려준다.
63
- `malformed` 는 endpoint 가 http(s) URL 이 아니거나 모델이 한 id 가 아닌 항목이다(`reason` 이 `endpoint` 또는 `model`). 영상 단계는 이 항목을 건너뛰고, 모두 `malformed` 면 영상 단계에서 멈춘다. 그러니 유료 스틸을 만들기 전에 사용자에게 알리고 `3d-intro-setup` 으로 고친다.
63
+ 쉬는 키를 바로 다시 쓰려면 `resetPoolState({ creds, labels: ['WAN_2'] })` 로 그 키의 기록을 지운다(`labels` 를 빼면 전체). 서버의 `Retry-After` 는 1초에서 10분 사이로만 받는다.
64
+ `malformed` 는 endpoint 가 https URL 이 아니거나(http 는 이 기기의 localhost 만 허용) 모델이 한 id 가 아닌 항목이다(`reason` 이 `endpoint` 또는 `model`). 영상 단계는 이 항목을 건너뛰고, 모두 `malformed` 면 영상 단계에서 멈춘다. 그러니 유료 스틸을 만들기 전에 사용자에게 알리고 `3d-intro-setup` 으로 고친다.
65
+ Azure 호출도 같다. 키는 https 로만 보내고, `http://` endpoint 는 보내기 전에 막는다(Sora 영상 단계는 `invalid_request`, 이미지 생성 같은 그 밖의 호출은 `HTTP 0 (EINSECURE)` 오류).
64
66
  WAN 항목(`WAN_<n>_ENDPOINT`, `WAN_<n>_API_KEY`)이 하나도 없으면 영상은 처음부터 Sora 로 만든다. 이때 정지와 비행 모드(Step 4B)는 쓸 수 없다.
65
67
  - 독립 검증(Step 6)에는 Node 용 playwright 패키지와 Chromium 이 필요하다. `npx playwright install chromium`(`setup-playwright`)은 브라우저만 받으므로, 패키지도 `npm i -g playwright` 나 프로젝트의 `npm i -D playwright` 로 설치해 둔다.
66
68
 
@@ -134,6 +136,7 @@ const png = await generateImage({ endpoint, key, deployment, prompt, size: ORIEN
134
136
  `generateImage` 는 서버의 `Retry-After` 만큼 기다렸다가 같은 경로로 다시 시도한다(기본 3회, `retry429`). 그래도 실패하면 씬 사이에 간격을 두고 다시 부른다.
135
137
  - **변형(권장)** - 씬마다 테이크를 2개 만들면 사용자가 비교해 고를 수 있다. 스틸 비용이 대략 씬당 2배이니 Step 2 추정에 미리 넣는다.
136
138
  - **FLUX 옵트인** - 씬 간 톤, 질감 일관성을 더 원하면 `generateImageFlux({ width, height })`(ORIENTATION 을 나눠 전달).
139
+ `endpoint` 는 `AZURE_IMAGE_SERVICES_ENDPOINT`, `key` 는 `AZURE_IMAGE_SERVICES_API_KEY` 이고 이 줄이 없으면 `AZURE_IMAGE_API_KEY` 다.
137
140
  FLUX 크리덴셜을 요청했는데 미수집이면 조용히 실패하지 말고 `gpt-image-2` 로 **정직하게 폴백**한다("FLUX 미수집, gpt-image-2 로 진행").
138
141
  - **N3 주의(씨앗 스틸 크기)** - `generateImage` 의 classic 폴백이 뜨면 반환 크기가 `1024x1536`(2:3)라 `720x1280` 이 아니다.
139
142
  체인 씨앗이 되는 스틸은 `probeDims` 로 확인해 ORIENTATION 과 다르면 영상 전에 리사이즈(ffmpeg `scale`)하거나 재생성한다.
@@ -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
 
@@ -93,9 +98,12 @@ export function parseConsoleSample(text) {
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;
@@ -164,18 +172,84 @@ function envLiteral(v) {
164
172
  return v.includes('"') ? `'${v}'` : `"${v}"`;
165
173
  }
166
174
 
167
- /** Redact a secret to `abcd…wxyz(len)`; short (<=8) secrets are fully masked; falsy -> "(missing)". */
175
+ /**
176
+ * Redact a secret to its length only, `…(len)`: no character of it, since keys share fixed
177
+ * prefixes (`sk-ws-`) and a shown tail narrows them down. Falsy -> "(missing)".
178
+ */
168
179
  export function redact(s) {
169
180
  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})`;
181
+ return `…(${String(s).length})`;
173
182
  }
174
183
 
175
184
  // ---------------------------------------------------------------------------
176
185
  // HTTP
177
186
  // ---------------------------------------------------------------------------
178
187
 
188
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
189
+ const SECRET_HEADERS = new Set(['authorization', 'api-key', 'proxy-authorization', 'cookie']);
190
+ const MAX_HOPS = 5;
191
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
192
+ const localHttp = (u) => u.protocol === 'http:' && LOOPBACK.has(u.hostname);
193
+ const refused = (code, message) => Object.assign(new TypeError(`fetch failed: ${message}`), { cause: { code } });
194
+ const isSecret = (name) => SECRET_HEADERS.has(name.toLowerCase());
195
+ const withoutSecrets = (headers) => Object.fromEntries(Object.entries(headers).filter(([k]) => !isSecret(k)));
196
+ // Every form fetch takes (an object, [name, value] pairs, Headers) as one plain object, so a key is
197
+ // found in any of them. Pairs would otherwise read as headers named "0", "1".
198
+ const plainHeaders = (h) => (Array.isArray(h) || h instanceof Headers ? Object.fromEntries(new Headers(h)) : { ...h });
199
+
200
+ /** Whether a key may go to this URL: over https, or over http only to this machine (a local mock or proxy). */
201
+ export const safeForKey = (u) => u.protocol === 'https:' || localHttp(u);
202
+
203
+ // Where a redirect goes. The request was sent already, so a Location that is not a URL, or one that
204
+ // leaves https, ends it with a code that does not read as "nothing sent". http stays allowed only
205
+ // while the request is on this machine over http (a local mock); from anywhere else, an https
206
+ // localhost included, it would reach a service here or on the network.
207
+ function redirectTarget(location, from) {
208
+ let next;
209
+ try { next = new URL(location, from); } catch { throw refused('EBADREDIRECT', 'the redirect location is not a URL'); }
210
+ if (next.protocol === 'https:' || (localHttp(next) && localHttp(from))) return next;
211
+ throw refused('EINSECUREREDIRECT', 'a redirect away from https is not followed');
212
+ }
213
+
214
+ // The request for the next hop of a redirect. A request with a body (a prompt, a frame, a paid submit)
215
+ // follows only a 307 or 308 within its origin, which send it again as it was. A 301, 302 or 303 would
216
+ // make it a GET, and another origin is another server: either way the next answer does not answer this
217
+ // request, and its 401 or 429 would read as this key's (a key retired, a submit sent again elsewhere).
218
+ // 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
219
+ // another origin goes without the secret headers.
220
+ function nextHop(req, status, crossOrigin) {
221
+ if (req.body != null && (crossOrigin || status < 307)) {
222
+ throw refused(crossOrigin ? 'ECROSSREDIRECT' : 'EBODYREDIRECT', 'a request with a body follows only a 307 or 308 within its origin');
223
+ }
224
+ const toGet = status === 303 || ((status === 301 || status === 302) && req.method === 'POST');
225
+ const next = toGet ? { ...req, method: 'GET', body: undefined } : req;
226
+ return crossOrigin ? { ...next, headers: withoutSecrets(next.headers) } : next;
227
+ }
228
+
229
+ /**
230
+ * fetch that follows redirects by hand (at most 5): a request without a body as fetch would, but to
231
+ * another origin without the key headers; a request with a body only by a 307 or 308 within its
232
+ * origin (fetch itself forwards a custom `api-key` header and re-sends the body on 307 and 308 to
233
+ * any origin, and turns a POST into a GET on 301 to 303). A key goes only where safeForKey allows: an http
234
+ * URL with one is refused before anything is sent (cause.code EINSECURE), and no redirect leaves
235
+ * https. Throws as fetch does on a network error.
236
+ */
237
+ export async function fetchGuarded(url, init = {}) {
238
+ let target = new URL(String(url));
239
+ let req = { ...init, headers: plainHeaders(init.headers), redirect: 'manual' };
240
+ if (!safeForKey(target) && Object.keys(req.headers).some(isSecret)) throw refused('EINSECURE', 'a key is sent only over https');
241
+ for (let hop = 0; hop <= MAX_HOPS; hop++) {
242
+ const r = await globalThis.fetch(target.href, req);
243
+ const location = REDIRECT_STATUS.has(r.status) ? r.headers.get('location') : null;
244
+ if (!location) return r;
245
+ try { await r.body?.cancel(); } catch { /* the redirect body is not needed */ }
246
+ const next = redirectTarget(location, target);
247
+ req = nextHop(req, r.status, next.origin !== target.origin);
248
+ target = next;
249
+ }
250
+ throw refused('ETOOMANYREDIRECTS', 'too many redirects');
251
+ }
252
+
179
253
  // Retry-After / retry-after-ms response header -> milliseconds (null when absent or unparsable).
180
254
  function retryAfterMsOf(headers) {
181
255
  const ms = Number(headers.get('retry-after-ms'));
@@ -202,7 +276,7 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
202
276
  if (body !== undefined) opts.body = body;
203
277
  let r;
204
278
  try {
205
- r = await globalThis.fetch(url, opts);
279
+ r = await fetchGuarded(url, opts);
206
280
  } catch (e) {
207
281
  last = { status: 0, ok: false, ct: '', body: null, authStyle: null, error: e };
208
282
  break; // network error is not an auth problem — do not retry other styles
@@ -260,7 +334,7 @@ export async function generateImage({
260
334
  });
261
335
  }
262
336
  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)}`);
337
+ if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
264
338
  return Buffer.from(b64, 'base64');
265
339
  }
266
340
 
@@ -278,7 +352,7 @@ export async function editImage({
278
352
  key, method: 'POST', body: fd,
279
353
  });
280
354
  const b64 = r.body?.data?.[0]?.b64_json;
281
- if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status} ${truncate(r.body)}`);
355
+ if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
282
356
  return Buffer.from(b64, 'base64');
283
357
  }
284
358
 
@@ -289,7 +363,7 @@ export async function generateImageFlux({ endpoint, key, prompt, width, height,
289
363
  body: JSON.stringify({ prompt, width, height }),
290
364
  });
291
365
  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)}`);
366
+ if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
293
367
  return Buffer.from(b64, 'base64');
294
368
  }
295
369
 
@@ -322,7 +396,7 @@ export async function createVideo({
322
396
  const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body: fd });
323
397
  if (!(r.ok && r.body?.id)) {
324
398
  // 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)}`);
399
+ const err = new Error(`createVideo failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
326
400
  err.status = r.status;
327
401
  err.errno = r.error?.cause?.code || r.error?.code || null;
328
402
  throw err;
@@ -368,7 +442,7 @@ export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview'
368
442
  const r = await azFetch(`${endpoint}/openai/v1/videos/${id}/content?api-version=${apiVersion}`, {
369
443
  key, method: 'GET', binary: true,
370
444
  });
371
- if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}`);
445
+ if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`);
372
446
  return r.body;
373
447
  }
374
448
 
@@ -413,7 +487,7 @@ export async function createVideoTwoImage({
413
487
  key, method: 'POST', body: fd,
414
488
  });
415
489
  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)}`);
490
+ if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
417
491
  return r.body;
418
492
  }
419
493
 
@@ -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;
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: 3d-intro-setup
3
- description: "`3d-intro-build` 에 필요한 Node 18 이상과 ffmpeg 를 갖추고 Azure 크레덴셜과 선택 항목인 WAN API 키 풀을 저장함. 크레덴셜은 직접 입력하거나 콘솔의 샘플 코드를 붙여 넣어 받고, 무과금 프리플라이트로 확인. 'setup-3d-intro'/'3d-intro-setup'/'3D 인트로 설정'/'Azure Sora 크레덴셜' 또는 `/banker:setup` 시 사용."
3
+ description: "`3d-intro-build` 에 필요한 Node 18 이상과 ffmpeg 를 갖추고 Azure 크레덴셜과 선택 항목인 WAN API 키 풀을 저장함. 엔드포인트와 배포명은 대화로 받고, 키는 사람이 비공개 키 파일에 직접 적거나 콘솔 샘플 파일에서 화면에 내지 않고 합쳐(채팅과 명령줄에 키를 싣지 않음), 무과금 프리플라이트로 확인. 'setup-3d-intro'/'3d-intro-setup'/'3D 인트로 설정'/'Azure Sora 크레덴셜' 또는 `/banker:setup` 시 사용."
4
4
  ---
5
5
 
6
6
  # 3d-intro-setup — Azure Sora-2 / gpt-image-2 3D 인트로 제작 전제조건 설치
@@ -94,64 +94,97 @@ Sora(영상)와 gpt-image(이미지)는 **보통 서로 다른 Azure 리소스**
94
94
  WAN 영상 풀(§3-WAN)은 선택이지만, 있으면 영상이 WAN 으로 먼저 만들어진다.
95
95
  FLUX.2-pro(대체 이미지 생성)는 **opt-in**: 필요 없으면 완전히 건너뛴다(§3-FLUX 참조).
96
96
 
97
- ### 모드 (a) — 직접 입력
98
- 사용자에게 리소스별로 순서대로 질문한다: endpoint(예: `https://<resource>.openai.azure.com` 또는 `https://<resource>.cognitiveservices.azure.com`), API key, 배포명(deployment).
99
- - **Sora**: endpoint + key (+ 배포명, 기본 `sora-2` — 콘솔에서 다른 이름으로 배포했으면 그 이름).
100
- - **Image(gpt-image 계열)**: endpoint + key + 배포명(예: `gpt-image-2`, `gpt-image-2.5-sunburst`. 필수 — 생성 호출의 `model` 필드로 그대로 쓰인다).
101
- AI Foundry 리소스(`https://<resource>.services.ai.azure.com`)도 그대로 쓴다. 콘솔이 `.../models` 를 붙여 보여줘도 저장은 origin 까지만 한다(이미지 경로는 `/openai/v1/images/...` 이고 `/models/images/...` 는 404).
97
+ ### 키는 사람이 파일에 적는다
98
+ **API key 는 채팅으로 받지 않고, 에이전트가 실행하는 명령에도 넣지 않는다.** `node -e "..."` 인자는 `ps` 출력과 대화 기록에 그대로 남는다.
99
+ 키는 사람이 아래 비공개 키 파일에 편집기로 직접 적거나(모드 a), 콘솔 샘플을 파일로 저장해 에이전트가 화면에 내지 않고 합친다(모드 b).
100
+ 엔드포인트, 배포명, API 버전은 비밀이 아니라 대화로 받아도 된다.
102
101
 
103
- ### 모드 (b) — 콘솔 샘플 코드 붙여넣기
104
- Azure AI Foundry/AI Hub 콘솔의 "Sample code"/"View code" 패널이 주는 curl 또는 Python 스니펫을 리소스별로 통째로 붙여넣게 한다.
105
- 붙여넣은 원문은 세션 스크래치 임시 파일에 저장 → `parseConsoleSample()` 로 파싱 → **파싱 직후 임시 파일 즉시 삭제**(원문에 raw key 가 그대로 들어있다).
102
+ 키 파일은 먼저 비공개로 만든다(이미 있으면 그대로 둔다). `echo >>` 로 새 파일을 만들면 0644 로 남는다.
103
+ 홈 키 파일(아래 fallback)이 이미 있으면 프로젝트 파일을 만들지 않는다. 빈 프로젝트 파일이 먼저 읽혀 홈의 키가 가려진다.
106
104
  ```bash
107
- node -e "
108
- (async () => {
109
- const fs = require('fs');
110
- const { parseConsoleSample, redact } = await import('$ADAPTER');
111
- const text = fs.readFileSync(process.argv[1], 'utf8');
112
- const p = parseConsoleSample(text);
113
- console.log(JSON.stringify({ endpoint: p.endpoint, deployment: p.deployment, apiVersion: p.apiVersion, apiKey: redact(p.apiKey) }));
114
- })();
115
- " "<붙여넣은 샘플을 저장한 임시 파일 절대경로로 치환>"
105
+ [ -e ~/.config/banker/3d-intro/env ] || (umask 077 && touch .env.3d-intro.local)
106
+ ls -l .env.3d-intro.local ~/.config/banker/3d-intro/env 2>/dev/null || true # 있는 파일이 -rw------- 면 된다
116
107
  ```
117
- 파싱 결과(엔드포인트·배포명·apiVersion·redact 된 key)를 사용자에게 보여주고 확인받은 뒤 저장 단계로 넘어간다.
118
- 필드가 `null` 로 나오면(정규식 미매치) 모드(a) 직접 입력으로 그 필드만 보완한다.
119
-
120
- ### 저장
121
- 아래 필드로 합쳐 `persistCreds()` 로 저장한다(0600 자동 적용, 원문 재출력 안 함):
108
+ 여러 프로젝트에서 재사용하려면 프로젝트 파일 대신 홈 폴더 fallback 을 쓴다. `resolveCreds` 가 project-local 다음으로 찾는 경로다.
109
+ ```bash
110
+ mkdir -p ~/.config/banker/3d-intro && chmod 700 ~/.config/banker/3d-intro && (umask 077 && touch ~/.config/banker/3d-intro/env)
122
111
  ```
123
- AZURE_SORA_ENDPOINT / AZURE_SORA_API_KEY / AZURE_SORA_DEPLOYMENT(기본 sora-2) / AZURE_SORA_API_VERSION(기본 preview)
124
- AZURE_IMAGE_OPENAI_ENDPOINT / AZURE_IMAGE_API_KEY / AZURE_GPT_IMAGE_DEPLOYMENT / AZURE_GPT_IMAGE_API_VERSION(기본 preview)
112
+ 그 프로젝트의 `.gitignore` 에 무시 규칙이 있는지 확인하고 없으면 추가한다(이 저장소는 `.env.*.local` 로 이미 커버된다):
113
+ ```bash
114
+ grep -qE '^\.env\.3d-intro\.local$|^\.env\.\*\.local$' .gitignore 2>/dev/null || echo '.env.3d-intro.local' >> .gitignore
125
115
  ```
126
- `persistCreds` 는 파일 전체를 새로 쓴다. 이미 저장된 값(WAN 풀 등)을 잃지 않도록 기존 값에 덮어 합친다.
116
+
117
+ ### 모드 (a) — 직접 입력
118
+ 엔드포인트와 배포명을 리소스별로 대화로 묻는다: endpoint(예: `https://<resource>.openai.azure.com` 또는 `https://<resource>.cognitiveservices.azure.com`), 배포명(deployment).
119
+ - **Sora**: endpoint (+ 배포명, 기본 `sora-2` — 콘솔에서 다른 이름으로 배포했으면 그 이름).
120
+ - **Image(gpt-image 계열)**: endpoint + 배포명(예: `gpt-image-2`, `gpt-image-2.5-sunburst`. 필수 — 생성 호출의 `model` 필드로 그대로 쓰인다).
121
+ AI Foundry 리소스(`https://<resource>.services.ai.azure.com`)도 그대로 쓴다. 콘솔이 `.../models` 를 붙여 보여줘도 저장은 origin 까지만 한다(이미지 경로는 `/openai/v1/images/...` 이고 `/models/images/...` 는 404).
122
+
123
+ 비밀이 아닌 값은 에이전트가 `persistCreds()` 로 합친다(파일 전체를 새로 쓰되 기존 값, 사람이 적은 키 포함, 은 그대로 둔다. 0600 자동 적용). 키 칸은 이 명령에 넣지 않는다.
124
+ 쓰는 곳은 `resolveCreds` 가 이미 읽는 키 파일(홈 fallback 포함)이고, 없을 때만 프로젝트 파일이다. 홈 파일을 쓰는 사람에게 프로젝트 파일을 새로 만들면 그 파일이 먼저 읽혀 홈의 키가 가려진다:
127
125
  ```bash
128
126
  node -e "
129
127
  (async () => {
130
128
  const { persistCreds, resolveCreds } = await import('$ADAPTER');
131
129
  const path = require('path');
132
- const target = path.join(process.cwd(), '.env.3d-intro.local'); // 프로젝트 전용(기본, 권장)
133
130
  const prev = resolveCreds({ projectDir: process.cwd() });
134
- const keep = prev._source === target ? prev : {}; // 같은 파일일 때만 기존 값 유지
135
- const written = persistCreds({ ...keep,
136
- AZURE_SORA_ENDPOINT: '<입력값>', AZURE_SORA_API_KEY: '<입력값>',
137
- AZURE_SORA_DEPLOYMENT: '<입력값 또는 sora-2>', AZURE_SORA_API_VERSION: '<입력값 또는 preview>',
138
- AZURE_IMAGE_OPENAI_ENDPOINT: '<입력값>', AZURE_IMAGE_API_KEY: '<입력값>',
139
- AZURE_GPT_IMAGE_DEPLOYMENT: '<입력값>', AZURE_GPT_IMAGE_API_VERSION: '<입력값 또는 preview>',
140
- // WAN 풀(선택): 키 수만큼 WAN_<n>_ENDPOINT / WAN_<n>_API_KEY, 필요하면 WAN_MODEL
131
+ const target = prev._source || path.join(process.cwd(), '.env.3d-intro.local');
132
+ const written = persistCreds({ ...prev,
133
+ AZURE_SORA_ENDPOINT: '<입력값>', AZURE_SORA_DEPLOYMENT: '<입력값 또는 sora-2>', AZURE_SORA_API_VERSION: '<입력값 또는 preview>',
134
+ AZURE_IMAGE_OPENAI_ENDPOINT: '<입력값>', AZURE_GPT_IMAGE_DEPLOYMENT: '<입력값>', AZURE_GPT_IMAGE_API_VERSION: '<입력값 또는 preview>',
141
135
  }, { target });
142
136
  console.log('저장:', written);
143
137
  })();
144
138
  "
145
139
  ```
146
- 여러 프로젝트에서 재사용하려면 대신 홈 폴더 fallback 에 저장한다 — `target` 을 `path.join(os.homedir(),'.config','banker','3d-intro','env')` 로 바꾼다(`resolveCreds` 가 project-local 다음으로 자동 탐색하는 바로 그 경로).
147
- 저장 후 대상 프로젝트의 `.gitignore` 에 무시 규칙이 있는지 확인하고 없으면 추가한다(이 저장소는 `.env.*.local` 로 이미 커버되지만, 다른 프로젝트에서 이 스킬을 쓸 때는 별도 확인이 필요하다):
140
+ 그다음 사람에게 키 파일을 편집기로 열어 키 줄을 직접 적어 달라고 한다(값은 대화에 붙이지 않는다):
141
+ ```
142
+ AZURE_SORA_API_KEY=<Sora 리소스 키>
143
+ AZURE_IMAGE_API_KEY=<Image 리소스 키>
144
+ ```
145
+ 적었다고 하면 위 "이미 있으면" 확인을 다시 돌린다. 키는 길이로만 표시된다.
146
+
147
+ ### 모드 (b) — 콘솔 샘플 파일
148
+ Azure AI Foundry/AI Hub 콘솔의 "Sample code"/"View code" 패널이 주는 curl 또는 Python 스니펫에는 원문 키가 들어 있다. 사람이 이것을 **채팅에 붙이지 않고** 비공개 파일에 저장한다:
148
149
  ```bash
149
- grep -qE '^\.env\.3d-intro\.local$|^\.env\.\*\.local$' .gitignore 2>/dev/null || echo '.env.3d-intro.local' >> .gitignore
150
+ mkdir -p ~/.config/banker/3d-intro && (umask 077 && touch ~/.config/banker/3d-intro/sample.txt) # 사람이 이 파일에 샘플을 붙여 저장
151
+ ```
152
+ 에이전트는 그 파일을 읽어 키를 화면에 내지 않고 키 파일에 바로 합친 뒤 샘플 파일을 지운다. 리소스마다 마지막 인자를 `sora`, `image`, `wan:<n>` 중 하나로 준다:
153
+ ```bash
154
+ node -e "
155
+ (async () => {
156
+ const fs = require('fs'), path = require('path');
157
+ const { parseConsoleSample, persistCreds, resolveCreds, redact } = await import('$ADAPTER');
158
+ const [file, kind] = process.argv.slice(1);
159
+ const p = parseConsoleSample(fs.readFileSync(file, 'utf8'));
160
+ const names = kind === 'sora' ? { endpoint: 'AZURE_SORA_ENDPOINT', apiKey: 'AZURE_SORA_API_KEY', deployment: 'AZURE_SORA_DEPLOYMENT', apiVersion: 'AZURE_SORA_API_VERSION' }
161
+ : kind === 'image' ? { endpoint: 'AZURE_IMAGE_OPENAI_ENDPOINT', apiKey: 'AZURE_IMAGE_API_KEY', deployment: 'AZURE_GPT_IMAGE_DEPLOYMENT', apiVersion: 'AZURE_GPT_IMAGE_API_VERSION' }
162
+ : { endpoint: 'WAN_' + kind.slice(4) + '_ENDPOINT', apiKey: 'WAN_' + kind.slice(4) + '_API_KEY' };
163
+ const prev = resolveCreds({ projectDir: process.cwd() });
164
+ const target = prev._source || path.join(process.cwd(), '.env.3d-intro.local');
165
+ const add = Object.fromEntries(Object.entries(names).filter(([f]) => p[f]).map(([f, name]) => [name, p[f]]));
166
+ const was = (() => { try { return new URL(prev[names.endpoint]).origin; } catch { return null; } })();
167
+ if (p.endpoint && was && p.endpoint !== was && add[names.apiKey] === undefined) { // 다른 리소스인데 키가 없음
168
+ console.log(JSON.stringify({ file: target, endpointChanged: { from: was, to: p.endpoint } }));
169
+ process.exit(3);
170
+ }
171
+ const old = prev[names.apiKey];
172
+ persistCreds({ ...prev, ...add }, { target });
173
+ const replaced = old && add[names.apiKey] && old !== add[names.apiKey] ? redact(old) : null;
174
+ console.log(JSON.stringify({ file: target, endpoint: p.endpoint, deployment: p.deployment, apiVersion: p.apiVersion, apiKey: redact(p.apiKey), replaced }));
175
+ })();
176
+ " "$HOME/.config/banker/3d-intro/sample.txt" sora
177
+ rm -f "$HOME/.config/banker/3d-intro/sample.txt"
150
178
  ```
179
+ 파싱 결과(저장한 파일, 엔드포인트, 배포명, apiVersion, 길이만 보이는 key)를 사람에게 보여 주고 확인받는다.
180
+ `replaced` 에 길이가 보이면 파일에 있던 다른 키를 이 샘플의 키로 바꾼 것이다. 교체(로테이션)가 아니었다면 사람이 키 파일의 키 줄을 다시 적는다.
181
+ 출력에 `endpointChanged` 가 있으면(node 는 exit 3, 블록은 다음 `rm -f` 때문에 0) 샘플의 endpoint 가 키 파일의 것과 다른데 샘플에 키가 없다는 뜻이다. 기존 키가 새 host 로 가지 않도록 아무것도 저장하지 않았다. 다른 리소스로 바꾸는 것이면 사람이 키 파일의 endpoint 와 키 줄을 함께 고친다.
182
+ 샘플의 SDK 코드에서 키는 따옴표 안의 값만 읽는다. `api_key=subscription_key` 처럼 변수 이름이면 키로 보지 않아 기존 키를 덮지 않는다.
183
+ 필드가 `null` 이면(정규식 미매치, 또는 URL 이 아닌 endpoint) 그 필드만 모드 (a) 로 보완한다. 키가 샘플에 변수(`$AZURE_API_KEY` 등)로만 있으면 키 파일에 사람이 직접 적는다.
151
184
 
152
185
  ### WAN 영상 풀 (선택, 권장)
153
186
  Alibaba Cloud Model Studio 의 WAN 영상 모델(기본 `wan3.0-video-prime`)을 여러 API 키로 돌린다. 키(workspace)마다 할당량이 따로라 키가 많을수록 오래 쓴다.
154
- 키마다 아래 두 줄을 번호를 붙여 저장한다. 번호 개수 제한은 없다.
187
+ 키마다 아래 두 줄을 번호를 붙여, 위 비공개 키 파일에 사람이 직접 적는다(콘솔 샘플 파일이 있으면 모드 (b) 를 `wan:<n>` 으로 돌린다). 번호 개수 제한은 없다.
155
188
  ```
156
189
  # endpoint 는 origin 만 (경로 제외)
157
190
  WAN_1_ENDPOINT=https://ws-<workspace-id>.<region>.maas.aliyuncs.com
@@ -161,15 +194,15 @@ WAN_2_API_KEY=...
161
194
  # 선택. 키 하나만 모델이 다르면 WAN_<n>_MODEL
162
195
  WAN_MODEL=wan3.0-video-prime
163
196
  ```
164
- - 콘솔 샘플(curl)을 붙여넣으면 `parseConsoleSample()` 로 endpoint 를 뽑을 수 있다. 키는 샘플에 실제 값(`Bearer sk-ws-...`)이 있을 때만 뽑히고, `$DASHSCOPE_API_KEY` 같은 변수면 따로 입력받는다. JSON 본문의 `"model"` 은 뽑히지 않으니 `WAN_MODEL` 로 따로 넣는다.
197
+ - 콘솔 샘플(curl)은 모드 (b) 로 파일에서 읽는다. 키는 샘플에 실제 값(`Bearer sk-ws-...`)이 있을 때만 뽑히고, `$DASHSCOPE_API_KEY` 같은 변수면 사람이 키 파일에 직접 적는다. JSON 본문의 `"model"` 은 뽑히지 않으니 `WAN_MODEL` 로 따로 넣는다.
165
198
  - 엔드포인트, 키, 모델의 리전이 같아야 한다. 다르면 `401 InvalidApiKey` 가 난다.
166
199
  - 값 뒤의 주석은 `#` 앞에 공백이 있어야 주석으로 읽힌다(`값 # 메모`). 공백 없이 붙은 `#` 는 값의 일부다. 값에 ` #` 이 들어가야 하면 따옴표로 감싼다.
167
- - endpoint 가 http(s) URL 이 아니거나 모델이 한 id 가 아닌 항목은 build 가 건너뛴다. 모든 WAN 항목이 그렇다면 build 는 Sora 로 넘어가지 않고 멈춘다.
200
+ - endpoint 가 https URL 이 아니거나(`http://` 는 키가 평문으로 가므로 막는다. 이 기기의 localhost 만 예외) 모델이 한 id 가 아닌 항목은 build 가 건너뛴다. 모든 WAN 항목이 그렇다면 build 는 Sora 로 넘어가지 않고 멈춘다.
168
201
  - 선택 설정(모두 기본값이 있음): `VIDEO_PROVIDER_ORDER`(기본 `wan,sora`), `WAN_RESOLUTION`(기본은 출력 크기에서 계산), `WAN_PROMPT_EXTEND`(기본 `false`), `WAN_MAX_CONCURRENT`(기본 5), `WAN_EXHAUSTED_TTL_HOURS`(기본 24).
169
202
  - 소진·쿨다운 기록은 `~/.config/banker/3d-intro/video-pool-state.json` 에 지문(엔드포인트, 키, 모델)으로만 남는다. 키나 모델을 바꾸면 지문이 바뀌어 기록이 새로 시작된다.
170
203
 
171
204
  ### FLUX.2-pro (opt-in)
172
- 필요할 때만: endpoint 하나 추가(`AZURE_IMAGE_SERVICES_ENDPOINT`) — 같은 리소스에 배포된 경우가 많아 키는 기본적으로 `AZURE_IMAGE_API_KEY` 를 재사용하고, 다른 리소스면 별도 key 를 물어본다.
205
+ 필요할 때만: endpoint 하나 추가(`AZURE_IMAGE_SERVICES_ENDPOINT`) — 같은 리소스에 배포된 경우가 많아 키는 기본적으로 `AZURE_IMAGE_API_KEY` 를 재사용한다. 다른 리소스면 그 키를 `AZURE_IMAGE_SERVICES_API_KEY=` 줄로 사람이 키 파일에 직접 적는다(채팅으로 받지 않는다).
173
206
  FLUX 는 이 스킬의 §4 프리플라이트 대상이 아니다(무과금으로 확인할 별도 엔드포인트가 없음).
174
207
  저장만 하고 실제 연결 확인은 생략한다(graceful skip) — 첫 실제 사용 시점(3d-intro-build)으로 미룬다.
175
208
 
@@ -182,12 +215,13 @@ node -e "
182
215
  const { resolveCreds, listVideos, azFetch, redact } = await import('$ADAPTER');
183
216
  const c = resolveCreds({ projectDir: process.cwd() });
184
217
  if (!c._source) { console.log('크레덴셜 없음 -- §3 먼저 실행'); process.exit(2); }
218
+ const how = (r) => (r.error ? r.error.cause?.code || r.error.code || r.error.name : r.authStyle); // status 0 이면 이유
185
219
  const sora = await listVideos({ endpoint: c.AZURE_SORA_ENDPOINT, key: c.AZURE_SORA_API_KEY, apiVersion: c.AZURE_SORA_API_VERSION || 'preview' });
186
- console.log('sora.listVideos ->', sora.status, '(' + sora.authStyle + ')');
220
+ console.log('sora.listVideos ->', sora.status, '(' + how(sora) + ')');
187
221
  const imgv = c.AZURE_GPT_IMAGE_API_VERSION || 'preview';
188
222
  const imgUrl = c.AZURE_IMAGE_OPENAI_ENDPOINT + '/openai/v1/images/generations?api-version=' + imgv;
189
223
  const img = await azFetch(imgUrl, { key: c.AZURE_IMAGE_API_KEY, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: c.AZURE_GPT_IMAGE_DEPLOYMENT }) });
190
- console.log('images.badreq ->', img.status, '(' + img.authStyle + ')');
224
+ console.log('images.badreq ->', img.status, '(' + how(img) + ')');
191
225
  const { probeWanPool } = await import('$POOL');
192
226
  const wan = await probeWanPool({ creds: c });
193
227
  for (const w of wan) console.log('wan.probe', w.label, w.host, w.model, '->', w.status, w.code || '', '| key', w.key);
@@ -211,6 +245,7 @@ node -e "
211
245
  | 둘 중 하나라도 401/403 | - | 인증 실패 — `azFetch` 가 `api-key`→`Bearer` 두 헤더 스타일을 이미 자동 재시도하므로, 그래도 401/403 이면 헤더 스타일이 아니라 키·엔드포인트 자체가 틀린 것(§3 재입력) |
212
246
  | `images.badreq` 가 200 | - | 있을 수 없음(프롬프트 없이 생성이 성공할 리 없다) — 어댑터·API 버전 불일치 의심, 정직히 보고 |
213
247
  | status 0 | - | 네트워크 오류(엔드포인트 오타·오프라인) |
248
+ | status 0 (`EINSECURE`) | - | endpoint 가 `http://` 다. 키를 평문으로 보내지 않으려고 보내기 전에 막았다 — `https://` 로 고침(이 기기의 localhost 만 http 허용) |
214
249
 
215
250
  ## 5. 검증 (보고 의무)
216
251
  ```bash
@@ -219,12 +254,13 @@ command -v ffmpeg >/dev/null && ffmpeg -version | head -1 || echo "시스템 ffm
219
254
  # 위 §4 프리플라이트를 재실행해 PASS 문자열 + 두 status 코드를 그대로 증거로 남긴다
220
255
  ```
221
256
  4-field 보고: 변경(Node/ffmpeg 확보 방식 · 크레덴셜 저장 경로 — 파일 내용 아님) / Evidence(§0 감지 출력 · §4 status 코드 전부) / 검증(`listVideos`=200 **AND** `images.badreq`=400 **AND** WAN 항목 전부 200 이어야 PASS) / Unknown(FLUX 미검증 · WAN 키별 남은 할당량(조회 API 없음 — 소진은 첫 생성에서 드러나고 풀이 자동으로 다음 키·Sora 로 넘어감) · 배포명이 실제 존재하는지는 이 무과금 프리플라이트로 확인 불가하며 첫 실제 생성 시점에만 드러남 · 관리자 권한 제약으로 ffmpeg 시스템 설치 불가 등, 가짜 성공 금지).
222
- 크레덴셜은 어떤 필드도 원문으로 보고하지 않고 항상 `redact()` 출력만 남긴다.
257
+ 크레덴셜은 어떤 필드도 원문으로 보고하지 않고 항상 `redact()` 출력(길이만)만 남긴다.
223
258
 
224
259
  ## 함정
225
260
  - **키·엔드포인트가 진짜 다른 이유**: Sora 와 gpt-image-2 는 같은 Azure OpenAI 리소스에 함께 배포될 수도, 완전히 별도 리소스일 수도 있다 — 하나로 퉁치지 말고 두 세트를 각각 확인한다.
226
261
  - **`azFetch` 는 이미 이중 인증을 시도한다**: `api-key` 헤더로 먼저 시도하고 401/403 이면 자동으로 `Authorization: Bearer` 로 재시도한다 — 그래도 실패하면 헤더 스타일 문제가 아니라 키·엔드포인트 자체가 틀린 것이다.
227
- - **콘솔 샘플 붙여넣기의 스크롤백 잔존**: 터미널 히스토리·세션 로그에 원문 키가 남을 수 있다(플러그인 통제 밖) — 공유 터미널이거나 로깅되는 환경이면 붙여넣기 후 Azure 콘솔에서 키 로테이션을 권한다.
262
+ - **키가 드러났으면 교체한다**: 키가 대화, 터미널 기록, 출력에 한 번이라도 드러났으면(예: 샘플을 채팅에 붙임) 그 키를 바꾼다. Azure 는 리소스의 Keys and Endpoint 에서 Regenerate 로 교체한다.
263
+ WAN 키는 Model Studio 콘솔의 API Key 관리에서 그 키를 지우고 새로 만들어 교체한다.
228
264
  - **WAN 키 검증은 GET task 로만**: 존재하지 않는 task 조회는 과금이 없고 키 유효성(200 vs 401)을 가른다. 생성 요청(POST)은 과금될 수 있으니 점검용으로 쓰지 않는다.
229
265
  - **이미지 배포 429**: 낮은 티어(S0) 이미지 배포는 연속 호출에 `429 RateLimitReached` 를 낸다. 프리플라이트를 짧은 간격으로 반복하지 않는다.
230
266
  - **RHEL8/Rocky8 ffmpeg 미제공**: BaseOS/AppStream 에 없음(라이선스) → motion-graphic-setup 과 동일하게 RPM Fusion 또는 `ffmpeg-static` 폴백.
@@ -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
 
@@ -93,9 +98,12 @@ export function parseConsoleSample(text) {
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;
@@ -164,18 +172,84 @@ function envLiteral(v) {
164
172
  return v.includes('"') ? `'${v}'` : `"${v}"`;
165
173
  }
166
174
 
167
- /** Redact a secret to `abcd…wxyz(len)`; short (<=8) secrets are fully masked; falsy -> "(missing)". */
175
+ /**
176
+ * Redact a secret to its length only, `…(len)`: no character of it, since keys share fixed
177
+ * prefixes (`sk-ws-`) and a shown tail narrows them down. Falsy -> "(missing)".
178
+ */
168
179
  export function redact(s) {
169
180
  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})`;
181
+ return `…(${String(s).length})`;
173
182
  }
174
183
 
175
184
  // ---------------------------------------------------------------------------
176
185
  // HTTP
177
186
  // ---------------------------------------------------------------------------
178
187
 
188
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
189
+ const SECRET_HEADERS = new Set(['authorization', 'api-key', 'proxy-authorization', 'cookie']);
190
+ const MAX_HOPS = 5;
191
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
192
+ const localHttp = (u) => u.protocol === 'http:' && LOOPBACK.has(u.hostname);
193
+ const refused = (code, message) => Object.assign(new TypeError(`fetch failed: ${message}`), { cause: { code } });
194
+ const isSecret = (name) => SECRET_HEADERS.has(name.toLowerCase());
195
+ const withoutSecrets = (headers) => Object.fromEntries(Object.entries(headers).filter(([k]) => !isSecret(k)));
196
+ // Every form fetch takes (an object, [name, value] pairs, Headers) as one plain object, so a key is
197
+ // found in any of them. Pairs would otherwise read as headers named "0", "1".
198
+ const plainHeaders = (h) => (Array.isArray(h) || h instanceof Headers ? Object.fromEntries(new Headers(h)) : { ...h });
199
+
200
+ /** Whether a key may go to this URL: over https, or over http only to this machine (a local mock or proxy). */
201
+ export const safeForKey = (u) => u.protocol === 'https:' || localHttp(u);
202
+
203
+ // Where a redirect goes. The request was sent already, so a Location that is not a URL, or one that
204
+ // leaves https, ends it with a code that does not read as "nothing sent". http stays allowed only
205
+ // while the request is on this machine over http (a local mock); from anywhere else, an https
206
+ // localhost included, it would reach a service here or on the network.
207
+ function redirectTarget(location, from) {
208
+ let next;
209
+ try { next = new URL(location, from); } catch { throw refused('EBADREDIRECT', 'the redirect location is not a URL'); }
210
+ if (next.protocol === 'https:' || (localHttp(next) && localHttp(from))) return next;
211
+ throw refused('EINSECUREREDIRECT', 'a redirect away from https is not followed');
212
+ }
213
+
214
+ // The request for the next hop of a redirect. A request with a body (a prompt, a frame, a paid submit)
215
+ // follows only a 307 or 308 within its origin, which send it again as it was. A 301, 302 or 303 would
216
+ // make it a GET, and another origin is another server: either way the next answer does not answer this
217
+ // request, and its 401 or 429 would read as this key's (a key retired, a submit sent again elsewhere).
218
+ // 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
219
+ // another origin goes without the secret headers.
220
+ function nextHop(req, status, crossOrigin) {
221
+ if (req.body != null && (crossOrigin || status < 307)) {
222
+ throw refused(crossOrigin ? 'ECROSSREDIRECT' : 'EBODYREDIRECT', 'a request with a body follows only a 307 or 308 within its origin');
223
+ }
224
+ const toGet = status === 303 || ((status === 301 || status === 302) && req.method === 'POST');
225
+ const next = toGet ? { ...req, method: 'GET', body: undefined } : req;
226
+ return crossOrigin ? { ...next, headers: withoutSecrets(next.headers) } : next;
227
+ }
228
+
229
+ /**
230
+ * fetch that follows redirects by hand (at most 5): a request without a body as fetch would, but to
231
+ * another origin without the key headers; a request with a body only by a 307 or 308 within its
232
+ * origin (fetch itself forwards a custom `api-key` header and re-sends the body on 307 and 308 to
233
+ * any origin, and turns a POST into a GET on 301 to 303). A key goes only where safeForKey allows: an http
234
+ * URL with one is refused before anything is sent (cause.code EINSECURE), and no redirect leaves
235
+ * https. Throws as fetch does on a network error.
236
+ */
237
+ export async function fetchGuarded(url, init = {}) {
238
+ let target = new URL(String(url));
239
+ let req = { ...init, headers: plainHeaders(init.headers), redirect: 'manual' };
240
+ if (!safeForKey(target) && Object.keys(req.headers).some(isSecret)) throw refused('EINSECURE', 'a key is sent only over https');
241
+ for (let hop = 0; hop <= MAX_HOPS; hop++) {
242
+ const r = await globalThis.fetch(target.href, req);
243
+ const location = REDIRECT_STATUS.has(r.status) ? r.headers.get('location') : null;
244
+ if (!location) return r;
245
+ try { await r.body?.cancel(); } catch { /* the redirect body is not needed */ }
246
+ const next = redirectTarget(location, target);
247
+ req = nextHop(req, r.status, next.origin !== target.origin);
248
+ target = next;
249
+ }
250
+ throw refused('ETOOMANYREDIRECTS', 'too many redirects');
251
+ }
252
+
179
253
  // Retry-After / retry-after-ms response header -> milliseconds (null when absent or unparsable).
180
254
  function retryAfterMsOf(headers) {
181
255
  const ms = Number(headers.get('retry-after-ms'));
@@ -202,7 +276,7 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
202
276
  if (body !== undefined) opts.body = body;
203
277
  let r;
204
278
  try {
205
- r = await globalThis.fetch(url, opts);
279
+ r = await fetchGuarded(url, opts);
206
280
  } catch (e) {
207
281
  last = { status: 0, ok: false, ct: '', body: null, authStyle: null, error: e };
208
282
  break; // network error is not an auth problem — do not retry other styles
@@ -260,7 +334,7 @@ export async function generateImage({
260
334
  });
261
335
  }
262
336
  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)}`);
337
+ if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
264
338
  return Buffer.from(b64, 'base64');
265
339
  }
266
340
 
@@ -278,7 +352,7 @@ export async function editImage({
278
352
  key, method: 'POST', body: fd,
279
353
  });
280
354
  const b64 = r.body?.data?.[0]?.b64_json;
281
- if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status} ${truncate(r.body)}`);
355
+ if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
282
356
  return Buffer.from(b64, 'base64');
283
357
  }
284
358
 
@@ -289,7 +363,7 @@ export async function generateImageFlux({ endpoint, key, prompt, width, height,
289
363
  body: JSON.stringify({ prompt, width, height }),
290
364
  });
291
365
  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)}`);
366
+ if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
293
367
  return Buffer.from(b64, 'base64');
294
368
  }
295
369
 
@@ -322,7 +396,7 @@ export async function createVideo({
322
396
  const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body: fd });
323
397
  if (!(r.ok && r.body?.id)) {
324
398
  // 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)}`);
399
+ const err = new Error(`createVideo failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
326
400
  err.status = r.status;
327
401
  err.errno = r.error?.cause?.code || r.error?.code || null;
328
402
  throw err;
@@ -368,7 +442,7 @@ export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview'
368
442
  const r = await azFetch(`${endpoint}/openai/v1/videos/${id}/content?api-version=${apiVersion}`, {
369
443
  key, method: 'GET', binary: true,
370
444
  });
371
- if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}`);
445
+ if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`);
372
446
  return r.body;
373
447
  }
374
448
 
@@ -413,7 +487,7 @@ export async function createVideoTwoImage({
413
487
  key, method: 'POST', body: fd,
414
488
  });
415
489
  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)}`);
490
+ if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
417
491
  return r.body;
418
492
  }
419
493
 
@@ -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;