pi-multimodal-proxy 1.6.0 → 1.10.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.
@@ -8,10 +8,10 @@
8
8
  */
9
9
 
10
10
  import { strict as assert } from "node:assert";
11
- import { describe, it } from "node:test";
12
- import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
11
+ import { after, describe, it } from "node:test";
12
+ import { lstat, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
13
13
  import os from "node:os";
14
- import { join, parse } from "node:path";
14
+ import { delimiter, join, parse } from "node:path";
15
15
  import {
16
16
  buildConversationContext,
17
17
  buildDescriptionFence,
@@ -24,6 +24,7 @@ import {
24
24
  formatXaiSttTranscript,
25
25
  isTranscriptionRequest,
26
26
  isXaiProvider,
27
+ consentState,
27
28
  CUSTOM_TYPE_CONFIG,
28
29
  CUSTOM_TYPE_CONSENT,
29
30
  CUSTOM_TYPE_DESCRIPTION,
@@ -45,12 +46,19 @@ import {
45
46
  VIDEO_PATH_PLACEHOLDER,
46
47
  isPathAllowed,
47
48
  isValidNamedRegion,
49
+ expandLeadingTilde,
50
+ sanitizeAllowedFolders,
51
+ pathAccessFromConfig,
52
+ MAX_ALLOWED_FOLDERS,
48
53
  LRUCache,
54
+ normalizeAllowedProviders,
49
55
  normalizedToPixels,
50
56
  parseModelString,
57
+ parseProviderList,
51
58
  pluralImages,
52
59
  readEnvOverrides,
53
60
  readImageFileWithReason,
61
+ readMediaFileWithReason,
54
62
  readPersistentFile,
55
63
  resolveConfig,
56
64
  resolveCropEntry,
@@ -59,6 +67,7 @@ import {
59
67
  hammingDistance,
60
68
  computePHash,
61
69
  cropImage,
70
+ shutdownCropWorkers,
62
71
  piAiImageToBuffer,
63
72
  bufferToPiAiImage,
64
73
  shouldStripImages,
@@ -70,7 +79,15 @@ import {
70
79
  writePersistentFile,
71
80
  sanitizeForLog,
72
81
  storeImageMeta,
73
- _imageMeta,
82
+ createImageMetaStore,
83
+ storeImageData,
84
+ getImageData,
85
+ createImageDataStore,
86
+ parseRecallRef,
87
+ spinnerFrame,
88
+ formatProgressStatus,
89
+ SPINNER_FRAMES,
90
+ RECALL_HINT,
74
91
  } from "../internal.ts";
75
92
 
76
93
  // SessionEntry minimal shape — typed loose because peer dep types are not loaded in test
@@ -143,6 +160,12 @@ describe("sanitize", () => {
143
160
  assert.equal(result.videoProvider, "xai");
144
161
  });
145
162
 
163
+ it("defaults statusLine to on and preserves off", () => {
164
+ assert.equal(sanitize({ ...DEFAULT_CONFIG, statusLine: undefined as any }).statusLine, "on");
165
+ assert.equal(sanitize({ ...DEFAULT_CONFIG, statusLine: "bogus" as any }).statusLine, "on");
166
+ assert.equal(sanitize({ ...DEFAULT_CONFIG, statusLine: "off" }).statusLine, "off");
167
+ });
168
+
146
169
  it("preserves valid values", () => {
147
170
  const cfg: VisionConfig = {
148
171
  mode: "always",
@@ -210,19 +233,57 @@ describe("readEnvOverrides", () => {
210
233
  }
211
234
  assert.equal(readEnvOverrides({ PI_VISION_PROXY_INCLUDE_CONTEXT: "garbage" }).includeContext, undefined);
212
235
  });
236
+
237
+ it("parses allowHome truthy/falsy values", () => {
238
+ for (const v of ["1", "true", "yes", "on", "TRUE", " 1 "]) {
239
+ assert.equal(readEnvOverrides({ PI_VISION_PROXY_ALLOW_HOME: v }).allowHome, true, `truthy ${v}`);
240
+ }
241
+ for (const v of ["0", "false", "no", "off", " off "]) {
242
+ assert.equal(readEnvOverrides({ PI_VISION_PROXY_ALLOW_HOME: v }).allowHome, false, `falsy ${v}`);
243
+ }
244
+ assert.equal(readEnvOverrides({}).allowHome, undefined);
245
+ assert.equal(readEnvOverrides({ PI_VISION_PROXY_ALLOW_HOME: "garbage" }).allowHome, undefined);
246
+ });
247
+
248
+ it("parses allowedFolders as a delimiter-separated list of absolute paths", () => {
249
+ const out = readEnvOverrides({ PI_VISION_PROXY_ALLOWED_FOLDERS: ["/a/b", "relative", "/c"].join(delimiter) });
250
+ assert.deepEqual(out.allowedFolders, ["/a/b", "/c"]);
251
+ assert.equal(readEnvOverrides({}).allowedFolders, undefined);
252
+ // Set-but-useless value still overrides (locks) the persisted list with an empty one
253
+ assert.deepEqual(readEnvOverrides({ PI_VISION_PROXY_ALLOWED_FOLDERS: "relative" }).allowedFolders, []);
254
+ });
255
+
256
+ it("reads PI_VISION_PROXY_STATUS_LINE", () => {
257
+ assert.equal(readEnvOverrides({ PI_VISION_PROXY_STATUS_LINE: "on" }).statusLine, "on");
258
+ assert.equal(readEnvOverrides({ PI_VISION_PROXY_STATUS_LINE: "off" }).statusLine, "off");
259
+ assert.equal(readEnvOverrides({ PI_VISION_PROXY_STATUS_LINE: "bogus" }).statusLine, undefined);
260
+ });
213
261
  });
214
262
 
215
263
  describe("envFlags", () => {
216
264
  it("reports presence per variable", () => {
217
- assert.deepEqual(envFlags({}), { mode: false, model: false, context: false, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false });
265
+ assert.deepEqual(envFlags({}), { mode: false, model: false, context: false, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: false, allowedFolders: false, statusLine: false });
218
266
  assert.deepEqual(
219
267
  envFlags({
220
268
  PI_VISION_PROXY_MODE: "x",
221
269
  PI_VISION_PROXY_MODEL: "y",
222
270
  PI_VISION_PROXY_INCLUDE_CONTEXT: "",
223
271
  }),
224
- { mode: true, model: true, context: true, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false },
272
+ { mode: true, model: true, context: true, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: false, allowedFolders: false, statusLine: false },
225
273
  );
274
+ assert.deepEqual(
275
+ envFlags({ PI_VISION_PROXY_ALLOW_HOME: "1", PI_VISION_PROXY_ALLOWED_FOLDERS: "/a" }),
276
+ { mode: false, model: false, context: false, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: true, allowedFolders: true, statusLine: false },
277
+ );
278
+ assert.equal(envFlags({ PI_VISION_PROXY_ALLOWED_PROVIDERS: "" }).allowedProviders, true);
279
+ // An unrecognized ALLOW_HOME value is not an override and must not lock the command
280
+ assert.equal(envFlags({ PI_VISION_PROXY_ALLOW_HOME: "garbage" }).allowHome, false);
281
+ assert.equal(envFlags({ PI_VISION_PROXY_ALLOW_HOME: " off " }).allowHome, true);
282
+ assert.equal(envFlags({ PI_VISION_PROXY_STATUS_LINE: "off" }).statusLine, true);
283
+ assert.equal(envFlags({ PI_VISION_PROXY_STATUS_LINE: "on" }).statusLine, true);
284
+ // An invalid value is not applied by readEnvOverrides, so it must not
285
+ // report (and thereby lock) the setting as env-overridden either.
286
+ assert.equal(envFlags({ PI_VISION_PROXY_STATUS_LINE: "bogus" }).statusLine, false);
226
287
  });
227
288
  });
228
289
 
@@ -285,6 +346,152 @@ describe("pluralImages", () => {
285
346
  });
286
347
  });
287
348
 
349
+ describe("parseRecallRef", () => {
350
+ const hash = "a".repeat(32);
351
+
352
+ it("accepts a bare 32-hex hash", () => {
353
+ assert.equal(parseRecallRef(hash), hash);
354
+ });
355
+
356
+ it("accepts a sha256:-prefixed hash", () => {
357
+ assert.equal(parseRecallRef(`sha256:${hash}`), hash);
358
+ });
359
+
360
+ it("strips a #crop suffix and recalls the base image", () => {
361
+ assert.equal(parseRecallRef(`${hash}#crop:1840,120,840,360`), hash);
362
+ });
363
+
364
+ it("normalizes uppercase to lowercase", () => {
365
+ assert.equal(parseRecallRef("A".repeat(32)), hash);
366
+ });
367
+
368
+ it("rejects file paths and non-hash refs", () => {
369
+ assert.equal(parseRecallRef("/tmp/shot.png"), null);
370
+ assert.equal(parseRecallRef("./a.png"), null);
371
+ assert.equal(parseRecallRef("screenshot.png"), null);
372
+ // Wrong length / non-hex
373
+ assert.equal(parseRecallRef("a".repeat(31)), null);
374
+ assert.equal(parseRecallRef("a".repeat(33)), null);
375
+ assert.equal(parseRecallRef("z".repeat(32)), null);
376
+ });
377
+ });
378
+
379
+ describe("session image recall store", () => {
380
+ it("round-trips retained image bytes by hash", () => {
381
+ const store = createImageDataStore();
382
+ storeImageData(store, "hash1", "AAAA", "image/png");
383
+ const got = getImageData(store, "hash1");
384
+ assert.deepEqual(got, { data: "AAAA", mimeType: "image/png" });
385
+ assert.equal(getImageData(store, "missing"), undefined);
386
+ });
387
+
388
+ it("ignores empty hash or data", () => {
389
+ const store = createImageDataStore();
390
+ storeImageData(store, "", "AAAA", "image/png");
391
+ storeImageData(store, "hash", "", "image/png");
392
+ assert.equal(store.map.size, 0);
393
+ });
394
+
395
+ it("does not duplicate on re-store of the same hash", () => {
396
+ const store = createImageDataStore();
397
+ storeImageData(store, "hash1", "AAAA", "image/png");
398
+ storeImageData(store, "hash1", "AAAA", "image/png");
399
+ assert.equal(store.map.size, 1);
400
+ });
401
+
402
+ it("keeps stores isolated — one session's bytes do not leak into another (issue #12)", () => {
403
+ const sessionA = createImageDataStore();
404
+ const sessionB = createImageDataStore();
405
+ storeImageData(sessionA, "hash1", "AAAA", "image/png");
406
+ assert.deepEqual(getImageData(sessionA, "hash1"), { data: "AAAA", mimeType: "image/png" });
407
+ assert.equal(getImageData(sessionB, "hash1"), undefined);
408
+ });
409
+
410
+ it("evicts least-recently-used entries past the byte budget", () => {
411
+ const store = createImageDataStore();
412
+ const prev = process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES;
413
+ // Budget of 10 decoded bytes; each 8-char base64 entry decodes to 6 bytes.
414
+ process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES = "10";
415
+ try {
416
+ storeImageData(store, "h1", "AAAAAAAA", "image/png"); // 6 decoded bytes, total 6
417
+ storeImageData(store, "h2", "BBBBBBBB", "image/png"); // 6 decoded bytes, total 12 > 10 → evict h1
418
+ assert.equal(getImageData(store, "h1"), undefined);
419
+ assert.deepEqual(getImageData(store, "h2"), { data: "BBBBBBBB", mimeType: "image/png" });
420
+ } finally {
421
+ if (prev === undefined) delete process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES;
422
+ else process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES = prev;
423
+ }
424
+ });
425
+
426
+ it("keeps a single oversized image rather than evicting everything", () => {
427
+ const store = createImageDataStore();
428
+ const prev = process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES;
429
+ process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES = "4";
430
+ try {
431
+ storeImageData(store, "big", "AAAAAAAAAAAA", "image/png"); // 9 decoded bytes > 4 budget
432
+ assert.deepEqual(getImageData(store, "big"), { data: "AAAAAAAAAAAA", mimeType: "image/png" });
433
+ assert.equal(store.map.size, 1);
434
+ } finally {
435
+ if (prev === undefined) delete process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES;
436
+ else process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES = prev;
437
+ }
438
+ });
439
+
440
+ it("bumps recency on access so the touched entry survives eviction", () => {
441
+ const store = createImageDataStore();
442
+ const prev = process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES;
443
+ // Budget of 14 decoded bytes; each 8-char base64 entry decodes to 6 bytes.
444
+ process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES = "14";
445
+ try {
446
+ storeImageData(store, "h1", "AAAAAAAA", "image/png"); // 6 decoded bytes, total 6
447
+ storeImageData(store, "h2", "BBBBBBBB", "image/png"); // 6 decoded bytes, total 12
448
+ getImageData(store, "h1"); // bump h1 to most-recent
449
+ storeImageData(store, "h3", "CCCCCCCC", "image/png"); // 6 decoded bytes, total 18 > 14 → evict LRU (h2)
450
+ assert.deepEqual(getImageData(store, "h1"), { data: "AAAAAAAA", mimeType: "image/png" });
451
+ assert.equal(getImageData(store, "h2"), undefined);
452
+ assert.deepEqual(getImageData(store, "h3"), { data: "CCCCCCCC", mimeType: "image/png" });
453
+ } finally {
454
+ if (prev === undefined) delete process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES;
455
+ else process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES = prev;
456
+ }
457
+ });
458
+ });
459
+
460
+ describe("spinnerFrame", () => {
461
+ it("returns a frame from the set and wraps around", () => {
462
+ assert.equal(spinnerFrame(0), SPINNER_FRAMES[0]);
463
+ assert.equal(spinnerFrame(SPINNER_FRAMES.length), SPINNER_FRAMES[0]);
464
+ assert.equal(spinnerFrame(SPINNER_FRAMES.length + 1), SPINNER_FRAMES[1]);
465
+ assert.ok(SPINNER_FRAMES.includes(spinnerFrame(7)));
466
+ });
467
+
468
+ it("handles negative ticks without throwing", () => {
469
+ assert.ok(SPINNER_FRAMES.includes(spinnerFrame(-1)));
470
+ assert.ok(SPINNER_FRAMES.includes(spinnerFrame(-13)));
471
+ });
472
+ });
473
+
474
+ describe("formatProgressStatus", () => {
475
+ it("includes frame, label, and elapsed seconds", () => {
476
+ assert.equal(
477
+ formatProgressStatus("Analyzing image 2/4…", "⠙", 3),
478
+ "multimodal-proxy ⠙ Analyzing image 2/4… (3s)",
479
+ );
480
+ });
481
+
482
+ it("floors and clamps elapsed seconds", () => {
483
+ assert.match(formatProgressStatus("x", "⠋", 4.9), /\(4s\)$/);
484
+ assert.match(formatProgressStatus("x", "⠋", -2), /\(0s\)$/);
485
+ });
486
+ });
487
+
488
+ describe("RECALL_HINT", () => {
489
+ it("mentions analyze_image and the image id", () => {
490
+ assert.match(RECALL_HINT, /analyze_image/);
491
+ assert.match(RECALL_HINT, /image id/i);
492
+ });
493
+ });
494
+
288
495
  describe("splitSubcommand", () => {
289
496
  it("splits sub and value with arbitrary whitespace", () => {
290
497
  assert.deepEqual(splitSubcommand("model anthropic/claude"), { sub: "model", value: "anthropic/claude" });
@@ -402,6 +609,182 @@ describe("hasConsent", () => {
402
609
  });
403
610
  });
404
611
 
612
+ describe("consentState", () => {
613
+ it("distinguishes none from revoked", () => {
614
+ assert.equal(consentState([], "anthropic"), "none");
615
+ const revoked: Entry[] = [customEntry(CUSTOM_TYPE_CONSENT, { granted: false, provider: "anthropic" })];
616
+ assert.equal(consentState(revoked, "anthropic"), "revoked");
617
+ // Revoke for another provider does not apply
618
+ assert.equal(consentState(revoked, "openai"), "none");
619
+ });
620
+
621
+ it("reports granted for a matching per-provider grant", () => {
622
+ const entries: Entry[] = [customEntry(CUSTOM_TYPE_CONSENT, { granted: true, provider: "anthropic" })];
623
+ assert.equal(consentState(entries, "anthropic"), "granted");
624
+ assert.equal(consentState(entries, "openai"), "none");
625
+ });
626
+ });
627
+
628
+ describe("hasConsent with allowedProviders (pre-consent)", () => {
629
+ it("grants without any session entry when the provider is listed", () => {
630
+ assert.equal(hasConsent([], "anthropic", ["anthropic"]), true);
631
+ assert.equal(hasConsent([], "openai", ["anthropic"]), false);
632
+ assert.equal(hasConsent([], "anthropic", []), false);
633
+ assert.equal(hasConsent([], "anthropic", undefined), false);
634
+ });
635
+
636
+ it("never applies as a blanket grant without a specific provider", () => {
637
+ assert.equal(hasConsent([], undefined, ["anthropic", "openai"]), false);
638
+ });
639
+
640
+ it("an explicit in-session revoke beats the pre-consent list", () => {
641
+ const entries: Entry[] = [customEntry(CUSTOM_TYPE_CONSENT, { granted: false, provider: "anthropic" })];
642
+ assert.equal(hasConsent(entries, "anthropic", ["anthropic"]), false);
643
+ // Revoking one provider leaves other listed providers pre-consented
644
+ assert.equal(hasConsent(entries, "openai", ["anthropic", "openai"]), true);
645
+ });
646
+
647
+ it("a provider-less in-session revoke blocks all pre-consented providers", () => {
648
+ const entries: Entry[] = [customEntry(CUSTOM_TYPE_CONSENT, { granted: false })];
649
+ assert.equal(hasConsent(entries, "anthropic", ["anthropic"]), false);
650
+ assert.equal(hasConsent(entries, "openai", ["openai"]), false);
651
+ });
652
+
653
+ it("a grant after a revoke restores consent", () => {
654
+ const entries: Entry[] = [
655
+ customEntry(CUSTOM_TYPE_CONSENT, { granted: false, provider: "anthropic" }),
656
+ customEntry(CUSTOM_TYPE_CONSENT, { granted: true, provider: "anthropic" }),
657
+ ];
658
+ assert.equal(hasConsent(entries, "anthropic", []), true);
659
+ });
660
+
661
+ it("canonicalizes the provider before matching the list", () => {
662
+ assert.equal(hasConsent([], "x-ai", ["xai"]), true);
663
+ });
664
+ });
665
+
666
+ describe("consent provider alias canonicalization (PR #18 review)", () => {
667
+ it("a revoke stored under an alias still blocks the canonical provider", () => {
668
+ const entries: Entry[] = [customEntry(CUSTOM_TYPE_CONSENT, { granted: false, provider: "x-ai" })];
669
+ assert.equal(consentState(entries, "xai"), "revoked");
670
+ // pre-consent list must NOT override the aliased revoke
671
+ assert.equal(hasConsent(entries, "xai", ["xai"]), false);
672
+ });
673
+
674
+ it("a revoke stored canonically blocks an alias-form check", () => {
675
+ const entries: Entry[] = [customEntry(CUSTOM_TYPE_CONSENT, { granted: false, provider: "xai" })];
676
+ assert.equal(consentState(entries, "x-ai"), "revoked");
677
+ assert.equal(hasConsent(entries, "x-ai", ["xai"]), false);
678
+ });
679
+
680
+ it("a grant stored under an alias satisfies the canonical check and vice versa", () => {
681
+ const aliasGrant: Entry[] = [customEntry(CUSTOM_TYPE_CONSENT, { granted: true, provider: "x-ai" })];
682
+ assert.equal(hasConsent(aliasGrant, "xai"), true);
683
+ const canonicalGrant: Entry[] = [customEntry(CUSTOM_TYPE_CONSENT, { granted: true, provider: "xai" })];
684
+ assert.equal(hasConsent(canonicalGrant, "x-ai"), true);
685
+ });
686
+
687
+ it("aliased entries for a different provider still don't match", () => {
688
+ const entries: Entry[] = [customEntry(CUSTOM_TYPE_CONSENT, { granted: false, provider: "x-ai" })];
689
+ assert.equal(consentState(entries, "anthropic"), "none");
690
+ });
691
+ });
692
+
693
+ describe("normalizeAllowedProviders", () => {
694
+ it("canonicalizes, dedupes, and drops invalid or non-string entries", () => {
695
+ assert.deepEqual(normalizeAllowedProviders(["x-ai", "xai", "anthropic", "bad/id", 42]), ["xai", "anthropic"]);
696
+ });
697
+
698
+ it("keeps empty arrays and rejects non-arrays", () => {
699
+ assert.deepEqual(normalizeAllowedProviders([]), []);
700
+ assert.equal(normalizeAllowedProviders("anthropic"), undefined);
701
+ assert.equal(normalizeAllowedProviders(undefined), undefined);
702
+ });
703
+ });
704
+
705
+ describe("parseProviderList", () => {
706
+ it("splits on commas and whitespace, trims, dedupes", () => {
707
+ assert.deepEqual(parseProviderList("anthropic, openai anthropic"), ["anthropic", "openai"]);
708
+ });
709
+
710
+ it("canonicalizes x-ai to xai", () => {
711
+ assert.deepEqual(parseProviderList("x-ai,xai"), ["xai"]);
712
+ });
713
+
714
+ it("drops invalid provider ids", () => {
715
+ assert.deepEqual(parseProviderList("good, bad/slash, also:bad"), ["good"]);
716
+ });
717
+
718
+ it("returns [] for empty or all-invalid input", () => {
719
+ assert.deepEqual(parseProviderList(""), []);
720
+ assert.deepEqual(parseProviderList("a/b, c:d"), []);
721
+ });
722
+ });
723
+
724
+ describe("allowedProviders config plumbing", () => {
725
+ it("sanitize normalizes, canonicalizes, and dedupes the list", () => {
726
+ const cfg = sanitize({ ...DEFAULT_CONFIG, allowedProviders: ["x-ai", "anthropic", "anthropic", "bad/id", 42 as any] });
727
+ assert.deepEqual(cfg.allowedProviders, ["xai", "anthropic"]);
728
+ });
729
+
730
+ it("sanitize keeps an explicit empty list and drops non-arrays", () => {
731
+ assert.deepEqual(sanitize({ ...DEFAULT_CONFIG, allowedProviders: [] }).allowedProviders, []);
732
+ assert.equal("allowedProviders" in sanitize({ ...DEFAULT_CONFIG, allowedProviders: "anthropic" as any }), false);
733
+ assert.equal("allowedProviders" in sanitize({ ...DEFAULT_CONFIG }), false);
734
+ });
735
+
736
+ it("reads PI_VISION_PROXY_ALLOWED_PROVIDERS", () => {
737
+ assert.deepEqual(
738
+ readEnvOverrides({ PI_VISION_PROXY_ALLOWED_PROVIDERS: "anthropic,openai" }).allowedProviders,
739
+ ["anthropic", "openai"],
740
+ );
741
+ // defined-but-empty must override a persisted list with "none"
742
+ assert.deepEqual(readEnvOverrides({ PI_VISION_PROXY_ALLOWED_PROVIDERS: "" }).allowedProviders, []);
743
+ assert.equal(readEnvOverrides({}).allowedProviders, undefined);
744
+ });
745
+
746
+ it("file config supplies the list; env override wins", () => {
747
+ const fileConfig = { allowedProviders: ["anthropic"] };
748
+ assert.deepEqual(resolveConfig([], {}, fileConfig).allowedProviders, ["anthropic"]);
749
+ // session-entry configs never carry the key, so they don't shadow the file
750
+ const entries: Entry[] = [customEntry(CUSTOM_TYPE_CONFIG, { mode: "always" })];
751
+ assert.deepEqual(resolveConfig(entries, {}, fileConfig).allowedProviders, ["anthropic"]);
752
+ assert.deepEqual(
753
+ resolveConfig(entries, { PI_VISION_PROXY_ALLOWED_PROVIDERS: "openai" }, fileConfig).allowedProviders,
754
+ ["openai"],
755
+ );
756
+ assert.deepEqual(
757
+ resolveConfig(entries, { PI_VISION_PROXY_ALLOWED_PROVIDERS: "" }, fileConfig).allowedProviders,
758
+ [],
759
+ );
760
+ });
761
+
762
+ it("round-trips through the persistent file", async () => {
763
+ const dir = await mkdtemp(join(os.tmpdir(), "vision-proxy-allowed-"));
764
+ try {
765
+ await writePersistentFile({ allowedProviders: ["anthropic", "openai"] }, dir);
766
+ const read = await readPersistentFile(dir);
767
+ assert.deepEqual(read.allowedProviders, ["anthropic", "openai"]);
768
+ } finally {
769
+ await rm(dir, { recursive: true, force: true });
770
+ }
771
+ });
772
+
773
+ it("canonicalizes a hand-edited list at the file-read boundary", async () => {
774
+ const dir = await mkdtemp(join(os.tmpdir(), "vision-proxy-allowed-"));
775
+ try {
776
+ await writePersistentFile({ allowedProviders: ["x-ai", "anthropic", "bad/id", 7 as any] }, dir);
777
+ const read = await readPersistentFile(dir);
778
+ assert.deepEqual(read.allowedProviders, ["xai", "anthropic"]);
779
+ // non-array value is dropped entirely
780
+ await writePersistentFile({ allowedProviders: "anthropic" as any }, dir);
781
+ assert.equal("allowedProviders" in (await readPersistentFile(dir)), false);
782
+ } finally {
783
+ await rm(dir, { recursive: true, force: true });
784
+ }
785
+ });
786
+ });
787
+
405
788
  describe("toPiAiImage", () => {
406
789
  it("passes through new shape", () => {
407
790
  const img = { type: "image", data: "AAAA", mimeType: "image/png" } as any;
@@ -591,8 +974,30 @@ describe("isPathAllowed", () => {
591
974
  }
592
975
  });
593
976
 
594
- it("denies non-existent files", async () => {
595
- assert.equal(await isPathAllowed(join(os.tmpdir(), "does-not-exist-xyz.png")), false);
977
+ it("allows non-existent files whose parent directory is in the allow-list", async () => {
978
+ // Non-existent files in tmpdir pass the check so callers can return "unreadable"
979
+ // instead of the misleading "denied" / "path outside allowed directories" message.
980
+ assert.equal(await isPathAllowed(join(os.tmpdir(), "does-not-exist-xyz.png")), true);
981
+ });
982
+
983
+ it("denies non-existent files outside the allow-list", async () => {
984
+ assert.equal(await isPathAllowed("/etc/does-not-exist-vp-xyz.png"), false);
985
+ });
986
+
987
+ it("allows files inside /tmp (system-wide Unix temp dir)", async () => {
988
+ if (os.platform() === "win32") return;
989
+ const file = join("/tmp", `vp-test-direct-${Date.now()}.png`);
990
+ await writeFile(file, TINY_PNG);
991
+ try {
992
+ assert.equal(await isPathAllowed(file), true);
993
+ } finally {
994
+ try { await rm(file); } catch { /* ignore */ }
995
+ }
996
+ });
997
+
998
+ it("allows non-existent files inside /tmp", async () => {
999
+ if (os.platform() === "win32") return;
1000
+ assert.equal(await isPathAllowed("/tmp/does-not-exist-vp-xyz.png"), true);
596
1001
  });
597
1002
 
598
1003
  it("allows local Windows drive paths by default", async () => {
@@ -644,6 +1049,94 @@ describe("isPathAllowed", () => {
644
1049
  else process.env.PI_VISION_PROXY_ALLOW_DRIVES = prevDrives;
645
1050
  }
646
1051
  });
1052
+
1053
+ it("allows homedir via the allowHome config option (no env var)", async () => {
1054
+ const home = os.homedir();
1055
+ const prevHome = process.env.PI_VISION_PROXY_ALLOW_HOME;
1056
+ const prevDrives = process.env.PI_VISION_PROXY_ALLOW_DRIVES;
1057
+ try {
1058
+ process.env.PI_VISION_PROXY_ALLOW_DRIVES = "0";
1059
+ delete process.env.PI_VISION_PROXY_ALLOW_HOME;
1060
+ if (!home.toLowerCase().startsWith(process.cwd().toLowerCase())) {
1061
+ assert.equal(await isPathAllowed(home, { allowHome: false }), false);
1062
+ }
1063
+ assert.equal(await isPathAllowed(home, { allowHome: true }), true);
1064
+ } finally {
1065
+ if (prevHome === undefined) delete process.env.PI_VISION_PROXY_ALLOW_HOME;
1066
+ else process.env.PI_VISION_PROXY_ALLOW_HOME = prevHome;
1067
+ if (prevDrives === undefined) delete process.env.PI_VISION_PROXY_ALLOW_DRIVES;
1068
+ else process.env.PI_VISION_PROXY_ALLOW_DRIVES = prevDrives;
1069
+ }
1070
+ });
1071
+
1072
+ it("allows paths under a configured allowed folder", async () => {
1073
+ const home = os.homedir();
1074
+ const prevDrives = process.env.PI_VISION_PROXY_ALLOW_DRIVES;
1075
+ try {
1076
+ process.env.PI_VISION_PROXY_ALLOW_DRIVES = "0";
1077
+ if (!home.toLowerCase().startsWith(process.cwd().toLowerCase())) {
1078
+ assert.equal(await isPathAllowed(home, { allowedFolders: [] }), false);
1079
+ assert.equal(await isPathAllowed(home, { allowedFolders: ["/nonexistent-root-vp-xyz"] }), false);
1080
+ }
1081
+ assert.equal(await isPathAllowed(home, { allowedFolders: [home] }), true);
1082
+ // Non-existent file directly under an allowed folder passes via parent resolution
1083
+ assert.equal(
1084
+ await isPathAllowed(join(home, "does-not-exist-vp-xyz.png"), { allowedFolders: [home] }),
1085
+ true,
1086
+ );
1087
+ } finally {
1088
+ if (prevDrives === undefined) delete process.env.PI_VISION_PROXY_ALLOW_DRIVES;
1089
+ else process.env.PI_VISION_PROXY_ALLOW_DRIVES = prevDrives;
1090
+ }
1091
+ });
1092
+ });
1093
+
1094
+ describe("expandLeadingTilde", () => {
1095
+ it("expands ~ and ~/sub to the home directory", () => {
1096
+ assert.equal(expandLeadingTilde("~"), os.homedir());
1097
+ assert.equal(expandLeadingTilde("~/pics"), join(os.homedir(), "pics"));
1098
+ });
1099
+
1100
+ it("leaves other paths untouched", () => {
1101
+ assert.equal(expandLeadingTilde("/a/~/b"), "/a/~/b");
1102
+ assert.equal(expandLeadingTilde("~user/x"), "~user/x");
1103
+ assert.equal(expandLeadingTilde("/plain"), "/plain");
1104
+ });
1105
+
1106
+ it("keeps double-separator inputs under home", () => {
1107
+ assert.equal(expandLeadingTilde("~//etc"), join(os.homedir(), "etc"));
1108
+ assert.equal(expandLeadingTilde("~/\\\\pics"), join(os.homedir(), "pics"));
1109
+ });
1110
+ });
1111
+
1112
+ describe("sanitizeAllowedFolders", () => {
1113
+ it("keeps absolute paths, expands ~, drops relative and non-string entries", () => {
1114
+ const home = os.homedir();
1115
+ const out = sanitizeAllowedFolders(["/a/b", "relative/x", 42, "~/pics", " /c ", ""]);
1116
+ assert.deepEqual(out, ["/a/b", join(home, "pics"), "/c"]);
1117
+ });
1118
+
1119
+ it("dedupes case-insensitively and caps at MAX_ALLOWED_FOLDERS", () => {
1120
+ assert.deepEqual(sanitizeAllowedFolders(["/A/B", "/a/b"]), ["/A/B"]);
1121
+ const many = Array.from({ length: MAX_ALLOWED_FOLDERS + 10 }, (_, i) => `/folder-${i}`);
1122
+ assert.equal(sanitizeAllowedFolders(many).length, MAX_ALLOWED_FOLDERS);
1123
+ });
1124
+
1125
+ it("returns empty array for non-arrays", () => {
1126
+ assert.deepEqual(sanitizeAllowedFolders("/a"), []);
1127
+ assert.deepEqual(sanitizeAllowedFolders(undefined), []);
1128
+ });
1129
+
1130
+ it("rejects UNC/network roots", () => {
1131
+ assert.deepEqual(sanitizeAllowedFolders(["\\\\server\\share", "//server/share", "/ok"]), ["/ok"]);
1132
+ });
1133
+ });
1134
+
1135
+ describe("pathAccessFromConfig", () => {
1136
+ it("extracts allowedFolders and allowHome", () => {
1137
+ const cfg = sanitize({ ...DEFAULT_CONFIG, allowedFolders: ["/media"], allowHome: true });
1138
+ assert.deepEqual(pathAccessFromConfig(cfg), { allowedFolders: ["/media"], allowHome: true });
1139
+ });
647
1140
  });
648
1141
 
649
1142
  describe("readImageFileWithReason", () => {
@@ -669,13 +1162,20 @@ describe("readImageFileWithReason", () => {
669
1162
  });
670
1163
 
671
1164
  it("returns reason=denied for path outside allow-list", async () => {
672
- // /etc/passwd.png does not exist but extension is image-like.
673
- // realpath fails → denied. Either reason is acceptable in that order; assert non-null reason.
674
1165
  const r = await readImageFileWithReason("/etc/never-exists-vp.png");
675
1166
  assert.equal(r.image, null);
676
1167
  assert.equal(r.reason, "denied");
677
1168
  });
678
1169
 
1170
+ it("returns reason=unreadable for non-existent file inside /tmp", async () => {
1171
+ if (os.platform() === "win32") return;
1172
+ // Previously returned "denied" (misleading); now returns "unreadable" so the user
1173
+ // knows the file is simply missing, not that /tmp itself is forbidden.
1174
+ const r = await readImageFileWithReason("/tmp/does-not-exist-vp-xyz.png");
1175
+ assert.equal(r.image, null);
1176
+ assert.equal(r.reason, "unreadable");
1177
+ });
1178
+
679
1179
  it("returns reason=empty for zero-byte image", async () => {
680
1180
  const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
681
1181
  const file = join(dir, "empty.png");
@@ -724,18 +1224,112 @@ describe("readImageFileWithReason", () => {
724
1224
  await rm(dir, { recursive: true, force: true });
725
1225
  }
726
1226
  });
1227
+
1228
+ it("denies symlink to existing file outside allow-list (TOCTOU post-read check)", async () => {
1229
+ // Simulate the TOCTOU race: the symlink target exists and is readable, so
1230
+ // readFile() succeeds, but the post-read realpath re-verification must catch that
1231
+ // the resolved path is outside the allow-list and return "denied".
1232
+ if (os.platform() === "win32") return;
1233
+ // Find a readable file outside the allow-list to use as a target.
1234
+ // /etc/hostname is present on most Unix systems; use it if it exists.
1235
+ const target = "/etc/hostname";
1236
+ let targetExists = false;
1237
+ try { await lstat(target); targetExists = true; } catch { /* skip if absent */ }
1238
+ if (!targetExists) return;
1239
+
1240
+ const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
1241
+ const link = join(dir, "link.png");
1242
+ try {
1243
+ try {
1244
+ await symlink(target, link);
1245
+ } catch {
1246
+ return; // no symlink support → skip
1247
+ }
1248
+ const r = await readImageFileWithReason(link);
1249
+ assert.equal(r.image, null);
1250
+ assert.equal(r.reason, "denied");
1251
+ } finally {
1252
+ await rm(dir, { recursive: true, force: true });
1253
+ }
1254
+ });
1255
+ });
1256
+
1257
+ describe("readMediaFileWithReason", () => {
1258
+ it("returns reason=not-a-media for unsupported extension", async () => {
1259
+ const r = await readMediaFileWithReason("/tmp/foo.xyzunknown");
1260
+ assert.equal(r.media, null);
1261
+ assert.equal(r.reason, "not-a-media");
1262
+ });
1263
+
1264
+ it("rejects a TypeScript .ts file as not-a-media (extension collision with MPEG-TS)", async () => {
1265
+ const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
1266
+ const file = join(dir, "store.ts");
1267
+ // Larger than one MPEG-TS packet (188 bytes) so the sniff check runs.
1268
+ await writeFile(file, "// TypeScript source file, not video\n".repeat(20));
1269
+ try {
1270
+ const r = await readMediaFileWithReason(file);
1271
+ assert.equal(r.media, null);
1272
+ assert.equal(r.reason, "not-a-media");
1273
+ } finally {
1274
+ await rm(dir, { recursive: true, force: true });
1275
+ }
1276
+ });
1277
+
1278
+ it("accepts a real MPEG-TS .ts file with valid sync bytes", async () => {
1279
+ const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
1280
+ const file = join(dir, "clip.ts");
1281
+ // Build a buffer of 8 packets, each starting with the 0x47 sync byte.
1282
+ const packet = Buffer.alloc(188);
1283
+ packet[0] = 0x47;
1284
+ const content = Buffer.concat(Array.from({ length: 8 }, () => packet));
1285
+ await writeFile(file, content);
1286
+ try {
1287
+ const r = await readMediaFileWithReason(file);
1288
+ assert.ok(r.media, "media should be returned for valid MPEG-TS");
1289
+ assert.equal(r.media?.mimeType, "video/mp2t");
1290
+ assert.equal(r.media?.type, "image");
1291
+ } finally {
1292
+ await rm(dir, { recursive: true, force: true });
1293
+ }
1294
+ });
1295
+
1296
+ it("returns reason=empty for zero-byte .ts file", async () => {
1297
+ const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
1298
+ const file = join(dir, "empty.ts");
1299
+ await writeFile(file, "");
1300
+ try {
1301
+ const r = await readMediaFileWithReason(file);
1302
+ assert.equal(r.media, null);
1303
+ assert.equal(r.reason, "empty");
1304
+ } finally {
1305
+ await rm(dir, { recursive: true, force: true });
1306
+ }
1307
+ });
727
1308
  });
728
1309
 
729
1310
  describe("readPersistentFile / writePersistentFile", () => {
730
1311
  it("round-trips config through a file", async () => {
731
1312
  const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
732
1313
  try {
733
- const cfg: Partial<VisionConfig> = { mode: "always", provider: "openai", modelId: "gpt-4o" };
1314
+ const cfg: Partial<VisionConfig> = { mode: "always", provider: "openai", modelId: "gpt-4o", statusLine: "off" };
734
1315
  await writePersistentFile(cfg, dir);
735
1316
  const read = await readPersistentFile(dir);
736
1317
  assert.equal(read.mode, "always");
737
1318
  assert.equal(read.provider, "openai");
738
1319
  assert.equal(read.modelId, "gpt-4o");
1320
+ assert.equal(read.statusLine, "off");
1321
+ } finally {
1322
+ await rm(dir, { recursive: true, force: true });
1323
+ }
1324
+ });
1325
+
1326
+ it("round-trips allowedFolders and allowHome", async () => {
1327
+ const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
1328
+ try {
1329
+ await writePersistentFile({ allowedFolders: ["/media/photos"], allowHome: true }, dir);
1330
+ const read = await readPersistentFile(dir);
1331
+ assert.deepEqual(read.allowedFolders, ["/media/photos"]);
1332
+ assert.equal(read.allowHome, true);
739
1333
  } finally {
740
1334
  await rm(dir, { recursive: true, force: true });
741
1335
  }
@@ -1292,6 +1886,29 @@ describe("sanitize (1.4.0 fields)", () => {
1292
1886
  });
1293
1887
  });
1294
1888
 
1889
+ describe("sanitize (1.10.0 file-access fields)", () => {
1890
+ it("defaults allowedFolders and allowHome when missing or invalid", () => {
1891
+ const result = sanitize({
1892
+ mode: "fallback",
1893
+ provider: "anthropic",
1894
+ modelId: "claude-sonnet-5",
1895
+ systemPrompt: "test",
1896
+ includeContext: true,
1897
+ } as VisionConfig);
1898
+ assert.deepEqual(result.allowedFolders, []);
1899
+ assert.equal(result.allowHome, false);
1900
+ const bad = sanitize({ ...DEFAULT_CONFIG, allowedFolders: "nope" as unknown as string[], allowHome: "yes" as unknown as boolean });
1901
+ assert.deepEqual(bad.allowedFolders, []);
1902
+ assert.equal(bad.allowHome, false);
1903
+ });
1904
+
1905
+ it("filters allowedFolders to absolute paths", () => {
1906
+ const result = sanitize({ ...DEFAULT_CONFIG, allowedFolders: ["/media", "relative/x"], allowHome: true });
1907
+ assert.deepEqual(result.allowedFolders, ["/media"]);
1908
+ assert.equal(result.allowHome, true);
1909
+ });
1910
+ });
1911
+
1295
1912
  describe("readImageFileWithReason (basename)", () => {
1296
1913
  it("returns filename (basename)", async () => {
1297
1914
  const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
@@ -1321,6 +1938,10 @@ async function create10x10Png(): Promise<Buffer> {
1321
1938
  }
1322
1939
 
1323
1940
  describe("cropImage (ImageScript)", () => {
1941
+ after(async () => {
1942
+ await shutdownCropWorkers();
1943
+ });
1944
+
1324
1945
  it("crops a 10×10 PNG to a 5×5 region", async () => {
1325
1946
  const png = await create10x10Png();
1326
1947
  const crop = { x: 2, y: 3, width: 5, height: 5 };
@@ -1355,6 +1976,92 @@ describe("cropImage (ImageScript)", () => {
1355
1976
  assert.equal(result[0], 0xff);
1356
1977
  assert.equal(result[1], 0xd8);
1357
1978
  });
1979
+
1980
+ it("succeeds within a generous decode timeout (env override is honoured)", async () => {
1981
+ const prev = process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS;
1982
+ process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS = "10000";
1983
+ try {
1984
+ const png = await create10x10Png();
1985
+ const result = await cropImage(png, { x: 0, y: 0, width: 10, height: 10 }, "image/png");
1986
+ assert.ok(result, "crop should succeed with a generous timeout");
1987
+ } finally {
1988
+ if (prev === undefined) delete process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS;
1989
+ else process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS = prev;
1990
+ }
1991
+ });
1992
+
1993
+ it("returns null (no throw) for undecodable garbage bytes", async () => {
1994
+ const garbage = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05]);
1995
+ const result = await cropImage(garbage, { x: 0, y: 0, width: 5, height: 5 }, "image/png");
1996
+ assert.equal(result, null);
1997
+ });
1998
+
1999
+ it("falls back to the in-thread path when the worker is disabled", async () => {
2000
+ const prev = process.env.PI_VISION_PROXY_DECODE_WORKER;
2001
+ process.env.PI_VISION_PROXY_DECODE_WORKER = "0";
2002
+ try {
2003
+ const png = await create10x10Png();
2004
+ const result = await cropImage(png, { x: 2, y: 3, width: 5, height: 5 }, "image/png");
2005
+ assert.ok(result, "in-thread fallback should still crop");
2006
+ const dims = extractDimensions(result);
2007
+ assert.ok(dims && dims.width === 5 && dims.height === 5, "fallback crop should have correct dims");
2008
+ } finally {
2009
+ if (prev === undefined) delete process.env.PI_VISION_PROXY_DECODE_WORKER;
2010
+ else process.env.PI_VISION_PROXY_DECODE_WORKER = prev;
2011
+ }
2012
+ });
2013
+
2014
+ it("handles several sequential crops (worker pool reuse)", async () => {
2015
+ const png = await create10x10Png();
2016
+ for (let i = 0; i < 5; i++) {
2017
+ const result = await cropImage(png, { x: 0, y: 0, width: 5, height: 5 }, "image/png");
2018
+ assert.ok(result, `crop ${i} should succeed`);
2019
+ const dims = extractDimensions(result);
2020
+ assert.ok(dims && dims.width === 5 && dims.height === 5, `crop ${i} dims`);
2021
+ }
2022
+ });
2023
+
2024
+ it("works with pooling disabled (spawn-per-call)", async () => {
2025
+ const prev = process.env.PI_VISION_PROXY_DECODE_WORKER_POOL;
2026
+ process.env.PI_VISION_PROXY_DECODE_WORKER_POOL = "0";
2027
+ try {
2028
+ const png = await create10x10Png();
2029
+ const a = await cropImage(png, { x: 0, y: 0, width: 5, height: 5 }, "image/png");
2030
+ const b = await cropImage(png, { x: 1, y: 1, width: 4, height: 4 }, "image/png");
2031
+ assert.ok(a && b, "both crops should succeed without pooling");
2032
+ } finally {
2033
+ if (prev === undefined) delete process.env.PI_VISION_PROXY_DECODE_WORKER_POOL;
2034
+ else process.env.PI_VISION_PROXY_DECODE_WORKER_POOL = prev;
2035
+ }
2036
+ });
2037
+
2038
+ it("hard-terminates the worker on timeout (returns null, does not hang)", async () => {
2039
+ // Force pool=0 so a *fresh* worker is spawned (never a warmed pooled one).
2040
+ // Spinning up a thread + loading ImageScript + instantiating the WASM codec
2041
+ // is reliably far slower than the 1ms timeout regardless of machine speed,
2042
+ // so the main-thread timer fires and terminate()s the worker — proving the
2043
+ // timeout is a hard limit, without depending on wall-clock scheduling luck.
2044
+ const prevWorker = process.env.PI_VISION_PROXY_DECODE_WORKER;
2045
+ const prevPool = process.env.PI_VISION_PROXY_DECODE_WORKER_POOL;
2046
+ const prevTimeout = process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS;
2047
+ process.env.PI_VISION_PROXY_DECODE_WORKER = "1";
2048
+ process.env.PI_VISION_PROXY_DECODE_WORKER_POOL = "0";
2049
+ process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS = "1";
2050
+ try {
2051
+ const png = await create10x10Png();
2052
+ const started = Date.now();
2053
+ const result = await cropImage(png, { x: 0, y: 0, width: 10, height: 10 }, "image/png");
2054
+ assert.equal(result, null, "timed-out crop should return null");
2055
+ assert.ok(Date.now() - started < 5000, "should return promptly, not hang");
2056
+ } finally {
2057
+ if (prevWorker === undefined) delete process.env.PI_VISION_PROXY_DECODE_WORKER;
2058
+ else process.env.PI_VISION_PROXY_DECODE_WORKER = prevWorker;
2059
+ if (prevPool === undefined) delete process.env.PI_VISION_PROXY_DECODE_WORKER_POOL;
2060
+ else process.env.PI_VISION_PROXY_DECODE_WORKER_POOL = prevPool;
2061
+ if (prevTimeout === undefined) delete process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS;
2062
+ else process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS = prevTimeout;
2063
+ }
2064
+ });
1358
2065
  });
1359
2066
 
1360
2067
  describe("piAiImageToBuffer / bufferToPiAiImage", () => {
@@ -1958,8 +2665,9 @@ describe("Security: image decode bomb protection", () => {
1958
2665
  const img = new Image(100, 100);
1959
2666
  const encoded = Buffer.from(await img.encode(1));
1960
2667
  const hash = "test-decode-bomb-normal";
1961
- storeImageMeta(hash, encoded);
1962
- const meta = _imageMeta.get(hash);
2668
+ const store = createImageMetaStore();
2669
+ storeImageMeta(store, hash, encoded);
2670
+ const meta = store.get(hash);
1963
2671
  // Normal image should be accepted
1964
2672
  assert.ok(meta, "normal image should be stored");
1965
2673
  });
@@ -2050,10 +2758,100 @@ describe("Review fixes: storeImageMeta filename backfill", () => {
2050
2758
  const img = new Image(50, 60);
2051
2759
  const encoded = Buffer.from(await img.encode(1));
2052
2760
  const hash = "test-backfill-filename";
2053
- storeImageMeta(hash, encoded); // first call, no filename
2054
- storeImageMeta(hash, encoded, "photo.png"); // second call, with filename
2055
- const meta = _imageMeta.get(hash);
2761
+ const store = createImageMetaStore();
2762
+ storeImageMeta(store, hash, encoded); // first call, no filename
2763
+ storeImageMeta(store, hash, encoded, "photo.png"); // second call, with filename
2764
+ const meta = store.get(hash);
2056
2765
  assert.ok(meta, "meta should exist");
2057
2766
  assert.equal(meta!.filename, "photo.png", "filename should be backfilled");
2058
2767
  });
2768
+
2769
+ it("keeps stores isolated — one session's metadata does not leak into another (issue #12)", async () => {
2770
+ const { Image } = await import("imagescript");
2771
+ const encoded = Buffer.from(await new Image(40, 30).encode(1));
2772
+ const sessionA = createImageMetaStore();
2773
+ const sessionB = createImageMetaStore();
2774
+ storeImageMeta(sessionA, "shared-hash", encoded, "a.png");
2775
+ assert.ok(sessionA.get("shared-hash"), "session A should have the metadata");
2776
+ assert.equal(sessionB.get("shared-hash"), undefined, "session B must not inherit it");
2777
+ });
2778
+ });
2779
+
2780
+ // ── Default model fallback (1.8.0) ──────────────────────────────────────────
2781
+
2782
+ import { applyDefaultModelFallback, DEFAULT_MODEL_FALLBACKS } from "../internal.ts";
2783
+
2784
+ describe("applyDefaultModelFallback", () => {
2785
+ const registryWith = (...models: string[]) => (p: string, m: string) => models.includes(`${p}/${m}`);
2786
+ const builtin = { ...DEFAULT_CONFIG };
2787
+
2788
+ it("keeps the built-in default when the registry knows it", () => {
2789
+ const out = applyDefaultModelFallback(builtin, registryWith(`${builtin.provider}/${builtin.modelId}`));
2790
+ assert.equal(out.modelId, DEFAULT_CONFIG.modelId);
2791
+ });
2792
+
2793
+ it("falls back to the first available fallback on older catalogs", () => {
2794
+ const fb = DEFAULT_MODEL_FALLBACKS[0]!;
2795
+ const out = applyDefaultModelFallback(builtin, registryWith(`${fb.provider}/${fb.modelId}`));
2796
+ assert.equal(out.provider, fb.provider);
2797
+ assert.equal(out.modelId, fb.modelId);
2798
+ });
2799
+
2800
+ it("returns the config unchanged when no fallback is available either", () => {
2801
+ const out = applyDefaultModelFallback(builtin, () => false);
2802
+ assert.equal(out.modelId, DEFAULT_CONFIG.modelId);
2803
+ });
2804
+
2805
+ it("never rewrites a user-configured model, even when it is missing", () => {
2806
+ const custom = { ...DEFAULT_CONFIG, provider: "openai", modelId: "gpt-5.5" };
2807
+ const out = applyDefaultModelFallback(custom, () => false);
2808
+ assert.equal(out.provider, "openai");
2809
+ assert.equal(out.modelId, "gpt-5.5");
2810
+ });
2811
+
2812
+ it("never rewrites an explicit user choice that equals the built-in default (userConfigured)", () => {
2813
+ const out = applyDefaultModelFallback(builtin, () => false, true);
2814
+ assert.equal(out.provider, DEFAULT_CONFIG.provider);
2815
+ assert.equal(out.modelId, DEFAULT_CONFIG.modelId);
2816
+ });
2817
+
2818
+ it("never rewrites a model persisted with modelExplicit, even the default pair", () => {
2819
+ const explicit = { ...DEFAULT_CONFIG, modelExplicit: true };
2820
+ const out = applyDefaultModelFallback(explicit, () => false);
2821
+ assert.equal(out.modelId, DEFAULT_CONFIG.modelId);
2822
+ });
2823
+
2824
+ it("upgrades an implicit legacy baked-in default to the current default when available", () => {
2825
+ const legacy = { ...DEFAULT_CONFIG, provider: "anthropic", modelId: "claude-sonnet-4-5" };
2826
+ const out = applyDefaultModelFallback(
2827
+ legacy,
2828
+ registryWith(`${DEFAULT_CONFIG.provider}/${DEFAULT_CONFIG.modelId}`, "anthropic/claude-sonnet-4-5"),
2829
+ );
2830
+ assert.equal(out.modelId, DEFAULT_CONFIG.modelId);
2831
+ });
2832
+
2833
+ it("keeps a legacy default as-is when the current default is not in the registry", () => {
2834
+ const legacy = { ...DEFAULT_CONFIG, provider: "anthropic", modelId: "claude-sonnet-4-5" };
2835
+ const out = applyDefaultModelFallback(legacy, registryWith("anthropic/claude-sonnet-4-5"));
2836
+ assert.equal(out.modelId, "claude-sonnet-4-5");
2837
+ });
2838
+
2839
+ it("does not upgrade a legacy-default model marked modelExplicit", () => {
2840
+ const legacy = {
2841
+ ...DEFAULT_CONFIG,
2842
+ provider: "anthropic",
2843
+ modelId: "claude-sonnet-4-5",
2844
+ modelExplicit: true,
2845
+ };
2846
+ const out = applyDefaultModelFallback(legacy, () => true);
2847
+ assert.equal(out.modelId, "claude-sonnet-4-5");
2848
+ });
2849
+
2850
+ it("does not mutate the other config fields when substituting", () => {
2851
+ const fb = DEFAULT_MODEL_FALLBACKS[0]!;
2852
+ const cfg = { ...DEFAULT_CONFIG, mode: "always" as const, cacheSize: 7 };
2853
+ const out = applyDefaultModelFallback(cfg, registryWith(`${fb.provider}/${fb.modelId}`));
2854
+ assert.equal(out.mode, "always");
2855
+ assert.equal(out.cacheSize, 7);
2856
+ });
2059
2857
  });