@xynogen/pix-runtime 0.10.2 → 0.13.0

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.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * safe-path.ts — pre-flight check before a tool writes to a model-chosen path.
3
+ *
4
+ * The rules differ per OS: system dirs, path separators, and case. Callers get
5
+ * one call and a reason they can show to the model.
6
+ */
7
+
8
+ import { constants as fsConstants } from "node:fs";
9
+ import { access, lstat } from "node:fs/promises";
10
+ import { posix, win32 } from "node:path";
11
+ import { homeDir } from "./paths.ts";
12
+ import { currentPlatform, type HostPlatform } from "./platform.ts";
13
+
14
+ export type PathValidation = { ok: true; path: string } | { ok: false; reason: string };
15
+
16
+ export interface SafePathOptions {
17
+ env?: NodeJS.ProcessEnv;
18
+ host?: HostPlatform;
19
+ }
20
+
21
+ /** Absolute prefixes that never get tool output, for this host. */
22
+ export function sensitivePrefixes(opts: SafePathOptions = {}): string[] {
23
+ const host = opts.host ?? currentPlatform();
24
+ const env = opts.env ?? process.env;
25
+ const p = host.os === "win32" ? win32 : posix;
26
+ const home = homeDir(env, host.os);
27
+ const secrets = [".ssh", ".aws", ".gnupg", p.join(".config", "gh")].map((d) => p.join(home, d));
28
+ if (host.os !== "win32") return ["/etc", "/proc", "/sys", "/boot", ...secrets];
29
+ const sys = [env.SystemRoot ?? "C:\\Windows", env.ProgramFiles, env["ProgramFiles(x86)"]];
30
+ const appData = env.APPDATA ?? p.join(home, "AppData", "Roaming");
31
+ return [...sys, p.join(appData, "GitHub CLI"), ...secrets].filter((d): d is string => !!d);
32
+ }
33
+
34
+ /** True when `path` is `prefix` or inside it. Case-insensitive on Windows. */
35
+ function isUnder(path: string, prefix: string, host: HostPlatform): boolean {
36
+ const p = host.os === "win32" ? win32 : posix;
37
+ // win32.relative already compares case-insensitively.
38
+ const rel = p.relative(p.resolve(prefix), p.resolve(path));
39
+ return rel !== ".." && !rel.startsWith(`..${p.sep}`) && !p.isAbsolute(rel);
40
+ }
41
+
42
+ /**
43
+ * Rejects null bytes, sensitive system locations, symlinks (target or any
44
+ * existing parent — Windows junctions count), existing directories, and paths
45
+ * with no writable existing ancestor.
46
+ */
47
+ export async function validateOutputPath(
48
+ absPath: string,
49
+ opts: SafePathOptions = {},
50
+ ): Promise<PathValidation> {
51
+ const host = opts.host ?? currentPlatform();
52
+ const p = host.os === "win32" ? win32 : posix;
53
+ if (absPath.includes("\0")) return { ok: false, reason: "path contains a null byte" };
54
+ // `\\?\C:\Windows\x` and `\\.\C:\...` reach the same files as `C:\Windows\x`, but
55
+ // win32.relative() sees another root, so isUnder() would miss them. Normal
56
+ // output paths never need a device or namespace prefix.
57
+ if (host.os === "win32" && /^[\\/]{2}[?.][\\/]|^\\\?\?\\/.test(absPath))
58
+ return { ok: false, reason: `refusing a Windows device or namespace path: ${absPath}` };
59
+
60
+ for (const prefix of sensitivePrefixes({ ...opts, host })) {
61
+ if (isUnder(absPath, prefix, host))
62
+ return { ok: false, reason: `refusing to write under ${prefix}` };
63
+ }
64
+
65
+ // lstat does not follow symlinks. That is the point here.
66
+ try {
67
+ const st = await lstat(absPath);
68
+ if (st.isSymbolicLink()) return { ok: false, reason: `target is a symlink: ${absPath}` };
69
+ if (st.isDirectory())
70
+ return { ok: false, reason: `target is an existing directory: ${absPath}` };
71
+ } catch (err) {
72
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT")
73
+ return { ok: false, reason: `cannot stat target: ${(err as Error).message}` };
74
+ }
75
+
76
+ // Every existing ancestor must not be a symlink (stops a safe-looking dir → /etc
77
+ // redirect). The nearest one must be writable so mkdir -p works.
78
+ const parent = p.dirname(absPath);
79
+ let nearest: string | null = null;
80
+ for (let cursor = parent; cursor !== p.dirname(cursor); cursor = p.dirname(cursor)) {
81
+ let st: Awaited<ReturnType<typeof lstat>>;
82
+ try {
83
+ st = await lstat(cursor);
84
+ } catch (err) {
85
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") continue;
86
+ return { ok: false, reason: `cannot stat parent: ${(err as Error).message}` };
87
+ }
88
+ if (st.isSymbolicLink()) return { ok: false, reason: `parent is a symlink: ${cursor}` };
89
+ if (st.isDirectory() && nearest === null) nearest = cursor;
90
+ }
91
+ if (nearest === null)
92
+ return { ok: false, reason: `no existing ancestor directory for ${parent}` };
93
+ try {
94
+ // ponytail: W_OK reads only the read-only attribute on Windows, not ACLs.
95
+ // A denied ACL still fails later at mkdir/writeFile with a clear EPERM.
96
+ await access(nearest, fsConstants.W_OK);
97
+ } catch {
98
+ return { ok: false, reason: `no writable ancestor directory: ${nearest}` };
99
+ }
100
+ return { ok: true, path: absPath };
101
+ }
@@ -30,6 +30,18 @@ import { ioSection } from "./io.ts";
30
30
  import { optimizerSection } from "./optimizer.ts";
31
31
  import { prettySection } from "./pretty.ts";
32
32
 
33
+ export {
34
+ fetchSection,
35
+ searchSection,
36
+ type ToolboxConfig,
37
+ toolboxSection,
38
+ type VoiceConfig,
39
+ voiceSection,
40
+ type WebConfig,
41
+ } from "./services.ts";
42
+
43
+ import { fetchSection, searchSection, toolboxSection, voiceSection } from "./services.ts";
44
+
33
45
  /** All built-in sections, in stable registration order. */
34
46
  export const builtinSections = [
35
47
  collapseSection,
@@ -38,4 +50,8 @@ export const builtinSections = [
38
50
  optimizerSection,
39
51
  gateSection,
40
52
  compactionSection,
53
+ fetchSection,
54
+ searchSection,
55
+ voiceSection,
56
+ toolboxSection,
41
57
  ] as const;
@@ -1,4 +1,4 @@
1
- import { defineSection, enumOr, isObj, posNumOr } from "../schema.ts";
1
+ import { boolOr, defineSection, enumOr, isObj, posNumOr } from "../schema.ts";
2
2
 
3
3
  export type IconMode = "nerd" | "unicode" | "ascii";
4
4
  export type LsStyle = "grid" | "tree";
@@ -19,6 +19,21 @@ export interface PrettyConfig {
19
19
  maxHighlightChars: number;
20
20
  cacheLimit: number;
21
21
  diff: DiffConfig;
22
+ footer: Record<
23
+ | "mode"
24
+ | "cwd"
25
+ | "git"
26
+ | "context"
27
+ | "model"
28
+ | "thinking"
29
+ | "price"
30
+ | "score"
31
+ | "statuses"
32
+ | "tokens"
33
+ | "cost"
34
+ | "tps",
35
+ boolean
36
+ >;
22
37
  }
23
38
 
24
39
  const ICON_MODES: readonly IconMode[] = ["nerd", "unicode", "ascii"];
@@ -43,15 +58,35 @@ const DEFAULTS: Readonly<PrettyConfig> = {
43
58
  maxHighlightChars: 80_000,
44
59
  cacheLimit: 128,
45
60
  diff: { splitMinWidth: 150, splitMinCodeWidth: 60 },
61
+ footer: {
62
+ mode: true,
63
+ cwd: true,
64
+ git: true,
65
+ context: true,
66
+ model: true,
67
+ thinking: true,
68
+ price: true,
69
+ score: true,
70
+ statuses: true,
71
+ tokens: true,
72
+ cost: true,
73
+ tps: true,
74
+ },
46
75
  };
47
76
 
48
77
  export const prettySection = defineSection<"pretty", PrettyConfig>({
49
78
  key: "pretty",
50
79
  defaults: DEFAULTS,
51
80
  parse(raw) {
52
- if (!isObj(raw)) return { ...DEFAULTS, diff: { ...DEFAULTS.diff } };
81
+ if (!isObj(raw)) return structuredClone(DEFAULTS);
53
82
  const rawDiff = isObj(raw.diff) ? raw.diff : {};
83
+ const rawFooter = isObj(raw.footer) ? raw.footer : {};
84
+ const footer = { ...DEFAULTS.footer };
85
+ for (const key of Object.keys(footer) as (keyof typeof footer)[]) {
86
+ footer[key] = boolOr(rawFooter[key], DEFAULTS.footer[key]);
87
+ }
54
88
  return {
89
+ footer,
55
90
  icons: enumOr(raw.icons, ICON_MODES, DEFAULTS.icons),
56
91
  lsStyle: enumOr(raw.lsStyle, LS_STYLES, DEFAULTS.lsStyle),
57
92
  maxRenderWidth: renderSizeOr(raw.maxRenderWidth, DEFAULTS.maxRenderWidth),
@@ -0,0 +1,71 @@
1
+ import { boolOr, defineSection, isObj, strArr } from "../schema.ts";
2
+
3
+ export interface WebConfig {
4
+ provider: string;
5
+ nineRouterModel: string;
6
+ }
7
+
8
+ const webDefaults: WebConfig = { provider: "auto", nineRouterModel: "exa" };
9
+ function parseWeb(raw: unknown): WebConfig {
10
+ const item = isObj(raw) ? raw : {};
11
+ return {
12
+ provider: typeof item.provider === "string" ? item.provider : webDefaults.provider,
13
+ nineRouterModel:
14
+ typeof item.nineRouterModel === "string" ? item.nineRouterModel : webDefaults.nineRouterModel,
15
+ };
16
+ }
17
+ export const fetchSection = defineSection({ key: "fetch", defaults: webDefaults, parse: parseWeb });
18
+ export const searchSection = defineSection({
19
+ key: "search",
20
+ defaults: webDefaults,
21
+ parse: parseWeb,
22
+ });
23
+
24
+ const voiceDefaults = {
25
+ sttProvider: "auto",
26
+ ttsProvider: "auto",
27
+ sttNineRouterModel: "dg/nova-3",
28
+ ttsNineRouterModel: "edge-tts/en-US-AriaNeural",
29
+ ttsPlay: true,
30
+ sttDevice: "default",
31
+ sttLanguage: "auto",
32
+ sttShortcut: "ctrl+alt+z",
33
+ sttCleanup: "off",
34
+ };
35
+ export type VoiceConfig = typeof voiceDefaults;
36
+ export const voiceSection = defineSection({
37
+ key: "voice",
38
+ defaults: voiceDefaults,
39
+ parse(raw): VoiceConfig {
40
+ const item = isObj(raw) ? raw : {};
41
+ const result = { ...voiceDefaults };
42
+ for (const key of Object.keys(result) as (keyof VoiceConfig)[]) {
43
+ if (key === "ttsPlay") result.ttsPlay = boolOr(item.ttsPlay, true);
44
+ else if (typeof item[key] === "string" && item[key].trim()) result[key] = item[key].trim();
45
+ }
46
+ result.sttLanguage = result.sttLanguage.toLowerCase();
47
+ return result;
48
+ },
49
+ });
50
+ export interface ToolboxConfig {
51
+ disabledTools?: string[];
52
+ loadedTools?: string[];
53
+ enabledTools?: string[];
54
+ }
55
+ export const toolboxSection = defineSection<"toolbox", ToolboxConfig>({
56
+ key: "toolbox",
57
+ defaults: {},
58
+ parse(raw) {
59
+ const result: ToolboxConfig = {};
60
+ if (isObj(raw)) {
61
+ for (const key of ["disabledTools", "loadedTools", "enabledTools"] as const) {
62
+ if (
63
+ Array.isArray(raw[key]) &&
64
+ raw[key].every((value: unknown) => typeof value === "string")
65
+ )
66
+ result[key] = strArr(raw[key]);
67
+ }
68
+ }
69
+ return result;
70
+ },
71
+ });
package/src/testing.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  * never touch the real agent directory or the process singleton.
4
4
  */
5
5
 
6
- import { mkdtempSync, rmSync } from "node:fs";
7
- import { tmpdir } from "node:os";
6
+ import { mkdtempSync, rmSync, symlinkSync } from "node:fs";
8
7
  import { join } from "node:path";
8
+ import { tempDir } from "./paths.ts";
9
9
  import { FileStorage } from "./persistence.ts";
10
10
  import { createRuntime, type PixRuntime } from "./runtime.ts";
11
11
 
@@ -16,9 +16,25 @@ export interface IsolatedRuntime {
16
16
  cleanup(): void;
17
17
  }
18
18
 
19
+ /**
20
+ * True when this process may create symlinks. Windows allows it only in Developer
21
+ * Mode or as admin (else EPERM). Use it to skip symlink tests: `it.skipIf(!canSymlink())`.
22
+ */
23
+ export function canSymlink(): boolean {
24
+ const dir = mkdtempSync(join(tempDir(), "pix-symlink-probe-"));
25
+ try {
26
+ symlinkSync(dir, join(dir, "link"), "dir");
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ } finally {
31
+ rmSync(dir, { recursive: true, force: true });
32
+ }
33
+ }
34
+
19
35
  /** Create a runtime backed by a fresh temp directory. */
20
36
  export function createIsolatedRuntime(): IsolatedRuntime {
21
- const agentDir = mkdtempSync(join(tmpdir(), "pix-runtime-"));
37
+ const agentDir = mkdtempSync(join(tempDir(), "pix-runtime-"));
22
38
  const runtime = createRuntime({ agentDir, storage: new FileStorage(agentDir) });
23
39
  return {
24
40
  runtime,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * user-shell.ts — pick the shell for user `!` commands, per OS.
3
+ *
4
+ * - Windows: PowerShell (Pi resolves pwsh 7, else powershell.exe 5.1). Pi's
5
+ * default is Git Bash, which is not what a Windows user types into `!`.
6
+ * - POSIX: $SHELL when it is not bash. zsh also needs rc + aliases: `zsh -c`
7
+ * is non-interactive and resolves aliases at parse time, so we source
8
+ * .zshrc and then `eval` the command to get a fresh parse boundary.
9
+ */
10
+
11
+ import { homeDir } from "./paths.ts";
12
+ import { currentPlatform, type HostPlatform } from "./platform.ts";
13
+
14
+ export type UserShell =
15
+ | { kind: "powershell"; wrap(command: string): string }
16
+ | { kind: "posix"; shellPath: string; wrap(command: string): string };
17
+
18
+ export interface UserShellOptions {
19
+ host?: HostPlatform;
20
+ shell?: string;
21
+ home?: string;
22
+ }
23
+
24
+ /** Guarded: `$PSStyle` exists only in PowerShell 7.2+. Strips ANSI color from output. */
25
+ // ponytail: duplicated from pix-powershell (Package Independence). One line, keep both in sync.
26
+ export const PLAIN_TEXT_PREAMBLE = "if ($PSStyle) { $PSStyle.OutputRendering = 'PlainText' }\n";
27
+
28
+ /** Single-quote a string for POSIX shell embedding. */
29
+ function quote(s: string): string {
30
+ return `'${s.replace(/'/g, "'\\''")}'`;
31
+ }
32
+
33
+ export function userShell(opts: UserShellOptions = {}): UserShell | undefined {
34
+ const host = opts.host ?? currentPlatform();
35
+ // ponytail: no opt-out back to Git Bash on Windows. Add a pix.json knob on request.
36
+ if (host.os === "win32") {
37
+ return { kind: "powershell", wrap: (command) => `${PLAIN_TEXT_PREAMBLE}${command}` };
38
+ }
39
+ const shell = "shell" in opts ? opts.shell : process.env.SHELL;
40
+ if (!shell) return undefined;
41
+ const name = shell.split("/").pop() ?? "";
42
+ if (name === "bash") return undefined;
43
+ if (name !== "zsh") return { kind: "posix", shellPath: shell, wrap: (command) => command };
44
+ // ponytail: only zsh gets rc wrapping. fish/nu run with shellPath alone. Add their rc logic on request.
45
+ const rc = quote(`${opts.home ?? homeDir()}/.zshrc`);
46
+ return {
47
+ kind: "posix",
48
+ shellPath: shell,
49
+ wrap: (command) => `. ${rc} 2>/dev/null; eval ${quote(command)}`,
50
+ };
51
+ }
package/src/which.ts CHANGED
@@ -16,15 +16,24 @@
16
16
  import { accessSync, constants, statSync } from "node:fs";
17
17
  import { access, stat } from "node:fs/promises";
18
18
  import { delimiter, isAbsolute, join, sep } from "node:path";
19
+ import { currentPlatform } from "./platform.ts";
19
20
 
20
- const isWindows = process.platform === "win32";
21
+ const isWindows = currentPlatform().os === "win32";
21
22
 
22
23
  /** Windows executable extensions, from `PATHEXT` with a sane fallback. */
23
24
  function pathExtensions(env: NodeJS.ProcessEnv): string[] {
24
25
  if (!isWindows) return [""];
25
26
  const raw = env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD";
26
- // Leading "" lets an already-suffixed name (foo.exe) match as-is.
27
- return ["", ...raw.split(";").filter(Boolean)];
27
+ // Leading "" lets an already-suffixed name (foo.exe) match as-is. Lowercased:
28
+ // NTFS matches either case, and the returned path should read `rtk.exe`, not
29
+ // `rtk.EXE`, since that is how the files are conventionally named.
30
+ return [
31
+ "",
32
+ ...raw
33
+ .split(";")
34
+ .filter(Boolean)
35
+ .map((ext) => ext.toLowerCase()),
36
+ ];
28
37
  }
29
38
 
30
39
  /** Directories to scan, from `PATH`. Windows also probes the current directory first. */
@@ -40,7 +49,15 @@ export interface FindExecutableOptions {
40
49
  }
41
50
 
42
51
  function candidatesFor(name: string, env: NodeJS.ProcessEnv): string[] {
43
- const exts = pathExtensions(env);
52
+ let exts = pathExtensions(env);
53
+ // Windows cannot spawn an extensionless file (npm ships a `npm` sh script
54
+ // beside `npm.cmd`), so only accept the bare name when it already carries a
55
+ // PATHEXT extension.
56
+ if (isWindows) {
57
+ const lower = name.toLowerCase();
58
+ const suffixed = exts.some((ext) => ext && lower.endsWith(ext));
59
+ exts = suffixed ? [""] : exts.filter(Boolean);
60
+ }
44
61
  const withExts = (base: string): string[] => exts.map((ext) => base + ext);
45
62
  // A name with a separator (or absolute) is a direct path — do not scan PATH.
46
63
  if (isAbsolute(name) || name.includes(sep) || (isWindows && name.includes("/"))) {