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.
- package/CHANGELOG.md +58 -0
- package/README.md +273 -220
- package/extensions/__tests__/compaction.test.ts +218 -0
- package/extensions/__tests__/integration.test.ts +1 -3
- package/extensions/__tests__/internal.test.ts +814 -16
- package/extensions/__tests__/recall-autocomplete.test.ts +137 -0
- package/extensions/internal.ts +1051 -52
- package/extensions/vision-proxy.ts +3056 -2326
- package/package.json +3 -3
- package/.pi/ghost-autocomplete/metrics.jsonl +0 -192
- package/.pi/ghost-autocomplete/profile.jsonl +0 -19
- package/PRD-Implementation-Status.md +0 -170
- package/PRD.md +0 -599
- package/bash.exe.stackdump +0 -28
|
@@ -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
|
-
|
|
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("
|
|
595
|
-
|
|
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
|
-
|
|
1962
|
-
|
|
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
|
-
|
|
2054
|
-
storeImageMeta(hash, encoded
|
|
2055
|
-
|
|
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
|
});
|