pi-multimodal-proxy 1.7.1 → 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 +43 -0
- package/README.md +37 -6
- package/extensions/__tests__/compaction.test.ts +218 -0
- package/extensions/__tests__/integration.test.ts +1 -1
- package/extensions/__tests__/internal.test.ts +488 -4
- package/extensions/__tests__/recall-autocomplete.test.ts +137 -0
- package/extensions/internal.ts +589 -27
- package/extensions/vision-proxy.ts +617 -54
- package/package.json +4 -4
- package/PRD-Implementation-Status.md +0 -170
- package/PRD.md +0 -599
|
@@ -11,7 +11,7 @@ import { strict as assert } from "node:assert";
|
|
|
11
11
|
import { after, describe, it } from "node:test";
|
|
12
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,
|
|
@@ -152,6 +160,12 @@ describe("sanitize", () => {
|
|
|
152
160
|
assert.equal(result.videoProvider, "xai");
|
|
153
161
|
});
|
|
154
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
|
+
|
|
155
169
|
it("preserves valid values", () => {
|
|
156
170
|
const cfg: VisionConfig = {
|
|
157
171
|
mode: "always",
|
|
@@ -219,19 +233,57 @@ describe("readEnvOverrides", () => {
|
|
|
219
233
|
}
|
|
220
234
|
assert.equal(readEnvOverrides({ PI_VISION_PROXY_INCLUDE_CONTEXT: "garbage" }).includeContext, undefined);
|
|
221
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
|
+
});
|
|
222
261
|
});
|
|
223
262
|
|
|
224
263
|
describe("envFlags", () => {
|
|
225
264
|
it("reports presence per variable", () => {
|
|
226
|
-
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 });
|
|
227
266
|
assert.deepEqual(
|
|
228
267
|
envFlags({
|
|
229
268
|
PI_VISION_PROXY_MODE: "x",
|
|
230
269
|
PI_VISION_PROXY_MODEL: "y",
|
|
231
270
|
PI_VISION_PROXY_INCLUDE_CONTEXT: "",
|
|
232
271
|
}),
|
|
233
|
-
{ 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 },
|
|
234
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);
|
|
235
287
|
});
|
|
236
288
|
});
|
|
237
289
|
|
|
@@ -557,6 +609,182 @@ describe("hasConsent", () => {
|
|
|
557
609
|
});
|
|
558
610
|
});
|
|
559
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
|
+
|
|
560
788
|
describe("toPiAiImage", () => {
|
|
561
789
|
it("passes through new shape", () => {
|
|
562
790
|
const img = { type: "image", data: "AAAA", mimeType: "image/png" } as any;
|
|
@@ -821,6 +1049,94 @@ describe("isPathAllowed", () => {
|
|
|
821
1049
|
else process.env.PI_VISION_PROXY_ALLOW_DRIVES = prevDrives;
|
|
822
1050
|
}
|
|
823
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
|
+
});
|
|
824
1140
|
});
|
|
825
1141
|
|
|
826
1142
|
describe("readImageFileWithReason", () => {
|
|
@@ -938,16 +1254,82 @@ describe("readImageFileWithReason", () => {
|
|
|
938
1254
|
});
|
|
939
1255
|
});
|
|
940
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
|
+
});
|
|
1308
|
+
});
|
|
1309
|
+
|
|
941
1310
|
describe("readPersistentFile / writePersistentFile", () => {
|
|
942
1311
|
it("round-trips config through a file", async () => {
|
|
943
1312
|
const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
|
|
944
1313
|
try {
|
|
945
|
-
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" };
|
|
946
1315
|
await writePersistentFile(cfg, dir);
|
|
947
1316
|
const read = await readPersistentFile(dir);
|
|
948
1317
|
assert.equal(read.mode, "always");
|
|
949
1318
|
assert.equal(read.provider, "openai");
|
|
950
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);
|
|
951
1333
|
} finally {
|
|
952
1334
|
await rm(dir, { recursive: true, force: true });
|
|
953
1335
|
}
|
|
@@ -1504,6 +1886,29 @@ describe("sanitize (1.4.0 fields)", () => {
|
|
|
1504
1886
|
});
|
|
1505
1887
|
});
|
|
1506
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
|
+
|
|
1507
1912
|
describe("readImageFileWithReason (basename)", () => {
|
|
1508
1913
|
it("returns filename (basename)", async () => {
|
|
1509
1914
|
const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
|
|
@@ -2371,3 +2776,82 @@ describe("Review fixes: storeImageMeta filename backfill", () => {
|
|
|
2371
2776
|
assert.equal(sessionB.get("shared-hash"), undefined, "session B must not inherit it");
|
|
2372
2777
|
});
|
|
2373
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
|
+
});
|
|
2857
|
+
});
|