@phi-code-admin/phi-code 0.76.6 → 0.76.8
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/dist/core/api-key-store.d.ts.map +1 -1
- package/dist/core/api-key-store.js +10 -6
- package/dist/core/api-key-store.js.map +1 -1
- package/dist/core/export-html/index.d.ts.map +1 -1
- package/dist/core/export-html/index.js +14 -10
- package/dist/core/export-html/index.js.map +1 -1
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +44 -3
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/tools/output-accumulator.d.ts.map +1 -1
- package/dist/core/tools/output-accumulator.js +6 -1
- package/dist/core/tools/output-accumulator.js.map +1 -1
- package/dist/utils/tools-manager.d.ts.map +1 -1
- package/dist/utils/tools-manager.js +40 -6
- package/dist/utils/tools-manager.js.map +1 -1
- package/extensions/phi/init.ts +10 -1
- package/extensions/phi/models.ts +10 -3
- package/extensions/phi/orchestrator.ts +13 -3
- package/extensions/phi/web-search.ts +76 -7
- package/package.json +3 -3
- package/scripts/postinstall.cjs +17 -1
package/extensions/phi/models.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* it appear everywhere without restarting Phi Code.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { ApiKeyStore, type ExtensionAPI, getApiKeyStore } from "phi-code";
|
|
18
|
+
import { ApiKeyStore, type ConfigWatcher, type ExtensionAPI, getApiKeyStore, getConfigWatcher } from "phi-code";
|
|
19
19
|
import { fetchLiveModels, peekCache, resetLiveModelsCache, toPersistedModel } from "./providers/live-models.js";
|
|
20
20
|
|
|
21
21
|
const PROVIDER_DISPLAY: Record<string, string> = {
|
|
@@ -44,6 +44,7 @@ interface RefreshOutcome {
|
|
|
44
44
|
|
|
45
45
|
async function refreshOne(
|
|
46
46
|
store: ApiKeyStore,
|
|
47
|
+
watcher: ConfigWatcher,
|
|
47
48
|
providerId: string,
|
|
48
49
|
): Promise<RefreshOutcome> {
|
|
49
50
|
const stored = store.getProvider(providerId);
|
|
@@ -96,6 +97,11 @@ async function refreshOne(
|
|
|
96
97
|
return { provider: providerId, source: "skipped", count: 0, error: "unknown baseUrl" };
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
// Mute the config watcher so it does not echo this programmatic write back
|
|
101
|
+
// as a models_json_changed event (which would trigger a spurious reload +
|
|
102
|
+
// "Keys reloaded" notification). Mute per-write because refresh loops can
|
|
103
|
+
// exceed the ignore window between providers (cf. keys.ts).
|
|
104
|
+
watcher.muteForWrite("models_json_changed");
|
|
99
105
|
store.setKey(providerId, stored?.apiKey ?? "local", {
|
|
100
106
|
baseUrl,
|
|
101
107
|
api: stored?.api,
|
|
@@ -107,6 +113,7 @@ async function refreshOne(
|
|
|
107
113
|
|
|
108
114
|
export default function modelsExtension(pi: ExtensionAPI) {
|
|
109
115
|
const store = getApiKeyStore();
|
|
116
|
+
const watcher = getConfigWatcher();
|
|
110
117
|
|
|
111
118
|
pi.registerCommand("models", {
|
|
112
119
|
description: "List or refresh the live model catalog (use `/models refresh` after a provider adds a new model)",
|
|
@@ -183,7 +190,7 @@ export default function modelsExtension(pi: ExtensionAPI) {
|
|
|
183
190
|
void (async () => {
|
|
184
191
|
let changed = 0;
|
|
185
192
|
for (const id of providers) {
|
|
186
|
-
const outcome = await refreshOne(store, id).catch(() => undefined);
|
|
193
|
+
const outcome = await refreshOne(store, watcher, id).catch(() => undefined);
|
|
187
194
|
if (outcome && outcome.source === "live" && outcome.count > 0) changed++;
|
|
188
195
|
}
|
|
189
196
|
if (changed > 0) {
|
|
@@ -214,7 +221,7 @@ export default function modelsExtension(pi: ExtensionAPI) {
|
|
|
214
221
|
|
|
215
222
|
const outcomes: RefreshOutcome[] = [];
|
|
216
223
|
for (const id of providers) {
|
|
217
|
-
const outcome = await refreshOne(store, id).catch((err) => ({
|
|
224
|
+
const outcome = await refreshOne(store, watcher, id).catch((err) => ({
|
|
218
225
|
provider: id,
|
|
219
226
|
source: "skipped" as const,
|
|
220
227
|
count: 0,
|
|
@@ -750,9 +750,9 @@ Tag the note with relevant keywords for vector search.
|
|
|
750
750
|
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content || '');
|
|
751
751
|
return content.includes('401') && (content.includes('invalid access token') || content.includes('token expired') || content.includes('Unauthorized'));
|
|
752
752
|
});
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
ctx.ui.notify(`\n❌ **Orchestrator aborted:**
|
|
753
|
+
// Only a genuine auth failure (401) is fatal — abort the whole workflow.
|
|
754
|
+
if (hasAuthError) {
|
|
755
|
+
ctx.ui.notify(`\n❌ **Orchestrator aborted:** API authentication error (401)\nCheck your API key and model configuration.`, "error");
|
|
756
756
|
setOrchestrationActive(false);
|
|
757
757
|
phasePending = false;
|
|
758
758
|
deactivateAgent();
|
|
@@ -760,6 +760,16 @@ Tag the note with relevant keywords for vector search.
|
|
|
760
760
|
return;
|
|
761
761
|
}
|
|
762
762
|
|
|
763
|
+
// A phase that made 0 tool calls is NOT fatal: a model may legitimately
|
|
764
|
+
// answer with text only (e.g. an inline plan). Warn and continue rather
|
|
765
|
+
// than killing phases 2-5, mirroring the graceful phase-timeout path.
|
|
766
|
+
if (toolCallCount === 0 && messages.length > 0) {
|
|
767
|
+
ctx.ui.notify(`\n⚠️ **Phase made 0 tool calls** — it may have responded with text only. Continuing to the next phase.`, "warning");
|
|
768
|
+
if (phaseQueue.length > 0) {
|
|
769
|
+
phaseQueue[0].instruction += `\n\n**Note:** The previous phase made 0 tool calls and may have responded with text only. Make sure you actually call the required tools.`;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
763
773
|
// Build the summary
|
|
764
774
|
const summaryParts: string[] = [];
|
|
765
775
|
summaryParts.push(`Tool calls: ${toolCallCount}`);
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* Optional: set BRAVE_API_KEY for Brave Search as extra fallback.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { lookup } from "node:dns/promises";
|
|
14
|
+
import { isIP } from "node:net";
|
|
13
15
|
import { Type } from "@sinclair/typebox";
|
|
14
16
|
import type { ExtensionAPI } from "phi-code";
|
|
15
17
|
|
|
@@ -342,15 +344,82 @@ export default function webSearchExtension(pi: ExtensionAPI) {
|
|
|
342
344
|
}
|
|
343
345
|
}
|
|
344
346
|
|
|
347
|
+
// ─── Garde SSRF ──────────────────────────────────────────────────────────
|
|
348
|
+
// Bloque les IP privees / loopback / link-local / metadata cloud pour que le
|
|
349
|
+
// tool fetch_url (URL choisie par le LLM, parfois issue de contenu non fiable)
|
|
350
|
+
// ne puisse pas atteindre localhost, le reseau interne ou 169.254.169.254.
|
|
351
|
+
function isBlockedIp(ip: string): boolean {
|
|
352
|
+
const family = isIP(ip);
|
|
353
|
+
if (family === 4) {
|
|
354
|
+
const o = ip.split(".").map(Number);
|
|
355
|
+
if (o[0] === 127 || o[0] === 10 || o[0] === 0) return true; // loopback / 10/8 / 0/8
|
|
356
|
+
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true; // 172.16/12
|
|
357
|
+
if (o[0] === 192 && o[1] === 168) return true; // 192.168/16
|
|
358
|
+
if (o[0] === 169 && o[1] === 254) return true; // link-local + metadata cloud
|
|
359
|
+
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return true; // CGNAT 100.64/10
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
if (family === 6) {
|
|
363
|
+
const a = ip.toLowerCase();
|
|
364
|
+
if (a === "::1" || a === "::") return true; // loopback / unspecified
|
|
365
|
+
if (a.startsWith("::ffff:")) return isBlockedIp(a.slice(7)); // IPv4-mapped
|
|
366
|
+
if (a.startsWith("fc") || a.startsWith("fd")) return true; // fc00::/7 ULA
|
|
367
|
+
if (a.startsWith("fe80") || a.startsWith("fe9") || a.startsWith("fea") || a.startsWith("feb")) return true; // link-local
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function assertPublicUrl(rawUrl: string): Promise<void> {
|
|
374
|
+
let u: URL;
|
|
375
|
+
try {
|
|
376
|
+
u = new URL(rawUrl);
|
|
377
|
+
} catch {
|
|
378
|
+
throw new Error(`URL invalide: ${rawUrl}`);
|
|
379
|
+
}
|
|
380
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
381
|
+
throw new Error(`Schema d'URL non autorise (${u.protocol}); seuls http et https sont permis`);
|
|
382
|
+
}
|
|
383
|
+
const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
384
|
+
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".internal") || host.endsWith(".local")) {
|
|
385
|
+
throw new Error(`Hote interne bloque (SSRF): ${host}`);
|
|
386
|
+
}
|
|
387
|
+
if (isIP(host)) {
|
|
388
|
+
if (isBlockedIp(host)) throw new Error(`Adresse IP interne bloquee (SSRF): ${host}`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// Resout l'hote et rejette si une IP resolue est interne. Note: ne protege
|
|
392
|
+
// pas a 100% du DNS-rebinding (le fetch resout de nouveau), mais bloque les
|
|
393
|
+
// cas usuels (metadata cloud, localhost, RFC1918) fournis par le LLM.
|
|
394
|
+
const addrs = await lookup(host, { all: true });
|
|
395
|
+
for (const a of addrs) {
|
|
396
|
+
if (isBlockedIp(a.address)) {
|
|
397
|
+
throw new Error(`Hote resolvant vers une IP interne bloquee (SSRF): ${host} -> ${a.address}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
345
402
|
async function fetchUrl(url: string, maxLength: number = 8000): Promise<string> {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
403
|
+
// Valide l'URL initiale ET chaque saut de redirection (redirect manuel),
|
|
404
|
+
// sinon une redirection 30x vers une cible interne contournerait la garde.
|
|
405
|
+
let currentUrl = url;
|
|
406
|
+
let response: Response | undefined;
|
|
407
|
+
for (let hop = 0; hop < 6; hop++) {
|
|
408
|
+
await assertPublicUrl(currentUrl);
|
|
409
|
+
response = await fetch(currentUrl, {
|
|
410
|
+
headers: {
|
|
411
|
+
"User-Agent": randomUA(),
|
|
412
|
+
"Accept": "text/html,application/xhtml+xml,text/plain,application/json",
|
|
413
|
+
},
|
|
414
|
+
redirect: "manual",
|
|
415
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT),
|
|
416
|
+
});
|
|
417
|
+
const location = response.status >= 300 && response.status < 400 ? response.headers.get("location") : null;
|
|
418
|
+
if (!location) break;
|
|
419
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
420
|
+
}
|
|
353
421
|
|
|
422
|
+
if (!response) throw new Error("Aucune reponse HTTP");
|
|
354
423
|
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
355
424
|
|
|
356
425
|
const contentType = response.headers.get("content-type") || "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phi-code-admin/phi-code",
|
|
3
|
-
"version": "0.76.
|
|
3
|
+
"version": "0.76.8",
|
|
4
4
|
"description": "Coding agent CLI with persistent memory, sub-agents, intelligent routing, and orchestration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"piConfig": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"clean": "shx rm -rf dist",
|
|
39
39
|
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
|
|
40
40
|
"build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets",
|
|
41
|
-
"build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/cli.js --outfile dist/pi && npm run copy-binary-assets",
|
|
41
|
+
"build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile --external koffi ./dist/bun/cli.js --outfile dist/pi && npm run copy-binary-assets",
|
|
42
42
|
"copy-assets": "shx mkdir -p dist/modes/interactive/theme dist/core/export-html/vendor dist/core && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx cp src/core/default-models.json dist/core/ && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/",
|
|
43
43
|
"copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp -r src/modes/interactive/assets dist/ 2>/dev/null || true && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/",
|
|
44
44
|
"test": "vitest --run",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@mariozechner/jiti": "^2.6.5",
|
|
50
|
-
"@phi-code-admin/browser": "^1.0.
|
|
50
|
+
"@phi-code-admin/browser": "^1.0.4",
|
|
51
51
|
"@silvia-odwyer/photon-node": "^0.3.4",
|
|
52
52
|
"chalk": "^5.5.0",
|
|
53
53
|
"cli-highlight": "^2.1.11",
|
package/scripts/postinstall.cjs
CHANGED
|
@@ -10,6 +10,12 @@ const { homedir } = require("os");
|
|
|
10
10
|
const agentDir = join(homedir(), ".phi", "agent");
|
|
11
11
|
const packageDir = __dirname.replace(/[\\/]scripts$/, "");
|
|
12
12
|
|
|
13
|
+
// Opt-out / CI guard: skip the home-directory scaffolding under CI or sandbox
|
|
14
|
+
// installs (and when explicitly disabled) to avoid polluting ~/.phi there.
|
|
15
|
+
if (process.env.PHI_SKIP_POSTINSTALL || process.env.CI) {
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
// 1. Copy extensions, agents, skills
|
|
14
20
|
const copies = [
|
|
15
21
|
{ src: "extensions/phi", dest: join(agentDir, "extensions"), label: "extensions" },
|
|
@@ -23,13 +29,23 @@ for (const { src, dest, label } of copies) {
|
|
|
23
29
|
mkdirSync(dest, { recursive: true });
|
|
24
30
|
const files = readdirSync(srcDir);
|
|
25
31
|
let copied = 0;
|
|
32
|
+
const failures = [];
|
|
26
33
|
for (const file of files) {
|
|
27
34
|
try {
|
|
28
35
|
cpSync(join(srcDir, file), join(dest, file), { recursive: true, force: true });
|
|
29
36
|
copied++;
|
|
30
|
-
} catch (e) {
|
|
37
|
+
} catch (e) {
|
|
38
|
+
failures.push({ file, err: e && e.message ? e.message : String(e) });
|
|
39
|
+
}
|
|
31
40
|
}
|
|
32
41
|
if (copied > 0) console.log(` Φ Installed ${copied} ${label} to ${dest}`);
|
|
42
|
+
// Surface failures without re-throwing (must not fail the npm install).
|
|
43
|
+
if (failures.length > 0) {
|
|
44
|
+
console.warn(` ⚠ ${failures.length} ${label} failed to install (run with PHI_POSTINSTALL_DEBUG=1 for details)`);
|
|
45
|
+
if (process.env.PHI_POSTINSTALL_DEBUG) {
|
|
46
|
+
for (const f of failures) console.warn(` - ${f.file}: ${f.err}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
// 2. Make sigma packages resolvable from ~/.phi/agent/extensions/
|