@phi-code-admin/phi-code 0.76.7 → 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.
@@ -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
- if (hasAuthError || (toolCallCount === 0 && messages.length > 0)) {
754
- const errorMsg = hasAuthError ? 'API authentication error (401)' : 'Phase produced 0 tool calls — possible API or model error';
755
- ctx.ui.notify(`\n❌ **Orchestrator aborted:** ${errorMsg}\nCheck your API key and model configuration.`, "error");
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
- const response = await fetch(url, {
347
- headers: {
348
- "User-Agent": randomUA(),
349
- "Accept": "text/html,application/xhtml+xml,text/plain,application/json",
350
- },
351
- signal: AbortSignal.timeout(HTTP_TIMEOUT),
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.7",
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",
@@ -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) { /* skip */ }
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/