mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(shell): launcher arguments for a pinned shell (wsl.exe -d <distro> -- /bin/bash)
This commit is contained in:
1 parent
465882cad6
commit
369fbf83b4
34 files changed
+794
-57
No files matched your search
@@ -486,6 +486,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "versions.notes-lang", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "shell.custom-args", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "hooks.recipe", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "hooks.remove", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "hooks.run-once", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
|
||||
@@ -717,12 +717,16 @@ export const EN: Record<string, string> = {
|
||||
"指定一个可执行文件": "Point at an executable",
|
||||
"取消指定": "Clear it",
|
||||
"可执行文件路径": "Path to the executable",
|
||||
"启动命令": "Launch command",
|
||||
"保存的启动参数现在无法运行,已回退到自动检测。": "The saved launcher arguments no longer run, so auto-detection is in use.",
|
||||
"启动参数(可选,每行一项)": "Launcher arguments (optional, one per line)",
|
||||
"例如 wsl.exe 需要 -d <发行版> -- /bin/bash 才会进入 shell。参数按原样传给程序,不经过任何 shell;保存前会带着它们运行一次来验证。": "For example wsl.exe needs -d <distro> -- /bin/bash to reach a shell. Arguments are passed to the program as written, through no shell; they are run once to verify before saving.",
|
||||
"自行编译的 bash、MSYS2,或安装在其他位置的 pwsh 均可填写于此。保存前会实际执行一条命令进行验证,无法运行则不会写入配置。":
|
||||
"A bash you built yourself, MSYS2, a pwsh installed elsewhere — all go here. Before saving, one command is really run through it; if that fails, nothing is written to the config.",
|
||||
"切换失败": "Not changed",
|
||||
"够不着这个工作目录。": "cannot reach this working directory.",
|
||||
"本机没有 bash,因此命令只能按 PowerShell 语法编写。安装 Git for Windows 后会增加 Git Bash 选项 —— WSL 中的 bash 不适用,它看到的是 /mnt 下的另一套路径,无法访问当前工作目录。":
|
||||
"There is no bash on this machine, so commands can only be written for PowerShell. Installing Git for Windows adds Git Bash here — the one inside WSL does not count: it sees a different set of paths under /mnt and cannot reach this working directory.",
|
||||
"本机没有 bash,因此命令只能按 PowerShell 语法编写。安装 Git for Windows 后会增加 Git Bash 选项。要用 WSL,请在下方指定 wsl.exe 并填写启动参数;其中的路径是 Linux 写法。":
|
||||
"There is no bash on this machine, so commands can only be written for PowerShell. Installing Git for Windows adds Git Bash here. To use WSL, point at wsl.exe below and add its launcher arguments; paths inside it are Linux paths.",
|
||||
"无法读取 shell 配置。": "Could not read the shell config.",
|
||||
|
||||
// ── 卡片:批准、还原、收工 ───────────────────────────────────────
|
||||
|
||||
@@ -84,10 +84,16 @@ export class MockShell extends MockExtensions {
|
||||
|
||||
// A path nobody has is the failure the pane has to render, so it is refused
|
||||
// here the way the kernel refuses it rather than quietly accepted.
|
||||
async saveShell(prefer: string, path: string): Promise<ShellSettings> {
|
||||
async saveShell(prefer: string, path: string, args: string[] = []): Promise<ShellSettings> {
|
||||
if (args.length > 0) {
|
||||
if (!/wsl\.exe$/i.test(path)) throw new Error(`这个 shell 用不了:${path} ${args.join(" ")}: did not run a command`);
|
||||
const argv = [path, ...args];
|
||||
this.sh = { ...this.sh, prefer, path, args, argv, effective: { ...GIT_BASH, name: "bash", path } };
|
||||
return { ...this.sh };
|
||||
}
|
||||
const found = (this.sh.options ?? []).find((o) => (path ? o.path === path : o.prefer === prefer));
|
||||
if (path && !found) throw new Error(`这个 shell 用不了:${path}: no such executable`);
|
||||
this.sh = { ...this.sh, prefer, path, effective: prefer === "auto" ? this.sh.auto : (found ?? this.sh.auto) };
|
||||
this.sh = { ...this.sh, prefer, path, args: undefined, argv: undefined, effective: prefer === "auto" ? this.sh.auto : (found ?? this.sh.auto) };
|
||||
return { ...this.sh };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ export interface AgentPort {
|
||||
// assembling and hands it to the shell tool, so a choice cannot reach a
|
||||
// runtime that is already up. Refused mid-turn, like every other rebuild.
|
||||
// An empty path leaves the executable to detection.
|
||||
saveShell(prefer: string, path: string): Promise<ShellSettings>;
|
||||
saveShell(prefer: string, path: string, args?: string[]): Promise<ShellSettings>;
|
||||
permissions(): Promise<PermissionRules>;
|
||||
// Replaces all three lists at once, then rebuilds — the gate is assembled
|
||||
// with the runtime, so a rule cannot reach one that is already up. Every rule
|
||||
|
||||
@@ -51,6 +51,10 @@ export interface ShellOption {
|
||||
export interface ShellSettings {
|
||||
prefer: string;
|
||||
path?: string;
|
||||
// Launcher arguments saved for path, and the exact command line that was run
|
||||
// to verify them when they are in force (absent when they fell back to auto).
|
||||
args?: string[];
|
||||
argv?: string[];
|
||||
effective: ShellOption;
|
||||
auto: ShellOption;
|
||||
options?: ShellOption[];
|
||||
|
||||
@@ -18,7 +18,7 @@ export class SseShell extends SseExtensions {
|
||||
// post0 for the same reason setContextWindow uses it: a refusal here carries
|
||||
// a code, and hand-rolling the fetch threw it away — "this server does not
|
||||
// let you set the shell" reached the panel in the kernel's English.
|
||||
saveShell(prefer: string, path: string) {
|
||||
return this.post0<ShellSettings>("/shell", { prefer, path });
|
||||
saveShell(prefer: string, path: string, args?: string[]) {
|
||||
return this.post0<ShellSettings>("/shell", args && args.length > 0 ? { prefer, path, args } : { prefer, path });
|
||||
}
|
||||
}
|
||||
@@ -1412,9 +1412,6 @@ body { background: var(--page) !important; }
|
||||
.workbench-filebar [role="group"] { display: flex; padding: 2px; background: var(--page); border: 1px solid var(--hair); border-radius: 5px; }
|
||||
.workbench-filebar button { min-height: 25px; padding: 0 8px; border: 0; border-radius: 3px; background: transparent; color: var(--muted); font: 600 10px/1 var(--ui); }
|
||||
.workbench-filebar button[aria-pressed="true"] { background: var(--raised); color: var(--text); }
|
||||
.workbench-filebar .workbench-reveal { display: grid; place-items: center; width: 25px; padding: 0; }
|
||||
.workbench-filebar .workbench-reveal:hover { color: var(--text); background: var(--overlay); }
|
||||
.workbench-filebar .workbench-reveal .studio-icon { width: 14px; height: 14px; }
|
||||
.workbench-filebar .workbench-save { display: flex; align-items: center; gap: 5px; }
|
||||
.workbench-filebar .workbench-save { background: var(--accent); color: white; }
|
||||
.workbench-filebar .workbench-save:disabled { opacity: .35; }
|
||||
@@ -4223,3 +4220,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.openwith-menu button { text-align: start; padding: 7px 10px; border: 0; border-radius: 5px; background: transparent; color: var(--text); font: 500 12px/1.3 var(--ui); }
|
||||
.openwith-menu button:hover, .openwith-menu button:focus-visible { background: var(--overlay); }
|
||||
.openwith-menu small { padding: 7px 10px; color: var(--muted); }
|
||||
.shell .pinpath .fields { display: grid; gap: 10px; margin-top: 6px; }
|
||||
.shell .pinpath .fields label { display: grid; gap: 4px; }
|
||||
.shell .pinpath .fields input, .shell .pinpath .fields textarea { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid var(--hair); border-radius: 6px; background: var(--page); color: var(--text); font-family: var(--mono); }
|
||||
.shell .pinpath .fields small { color: var(--muted); line-height: 1.5; }
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Shell } from "./Shell";
|
||||
import { MockPort } from "../port/mock";
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
|
||||
const WSL = "C:\\Windows\\System32\\wsl.exe";
|
||||
const openPinned = async () => {
|
||||
const port = new MockPort();
|
||||
const save = vi.spyOn(port, "saveShell");
|
||||
render(<Shell port={port} />);
|
||||
await userEvent.click(await screen.findByText("指定一个可执行文件"));
|
||||
return { port, save };
|
||||
};
|
||||
const args = () => screen.getByRole<HTMLTextAreaElement>("textbox", { name: /启动参数/ });
|
||||
const path = () => screen.getByRole<HTMLInputElement>("textbox", { name: "可执行文件路径" });
|
||||
|
||||
it("sends launcher arguments one per line, untouched, and shows the command that was verified", async () => {
|
||||
const { save } = await openPinned();
|
||||
fireEvent.change(path(), { target: { value: WSL } });
|
||||
fireEvent.change(args(), { target: { value: "-d\nmy distro\n--\n/bin/bash\n" } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存该路径" }));
|
||||
await waitFor(() => expect(save).toHaveBeenCalledWith("bash", WSL, ["-d", "my distro", "--", "/bin/bash"]));
|
||||
expect((await screen.findByText(/启动命令/)).nextElementSibling?.textContent).toBe(`${WSL} -d my distro -- /bin/bash -c …`);
|
||||
expect(args().value).toBe("-d\nmy distro\n--\n/bin/bash");
|
||||
});
|
||||
|
||||
it("saves a plain path exactly as before when no arguments are typed", async () => {
|
||||
const { save } = await openPinned();
|
||||
fireEvent.change(path(), { target: { value: "C:\\Program Files\\Git\\usr\\bin\\bash.exe" } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存该路径" }));
|
||||
await waitFor(() => expect(save).toHaveBeenCalled());
|
||||
expect(save.mock.calls[0]).toEqual(["bash", "C:\\Program Files\\Git\\usr\\bin\\bash.exe"]);
|
||||
});
|
||||
|
||||
it("keeps what was typed and says why when the launcher does not run", async () => {
|
||||
const { port } = await openPinned();
|
||||
vi.spyOn(port, "saveShell").mockRejectedValueOnce(new Error("这个 shell 用不了:wsl.exe: did not run a command"));
|
||||
fireEvent.change(path(), { target: { value: WSL } });
|
||||
fireEvent.change(args(), { target: { value: "-x" } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存该路径" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("did not run a command");
|
||||
expect(args().value).toBe("-x");
|
||||
expect(screen.queryByText("启动命令")).toBeNull();
|
||||
});
|
||||
|
||||
it("clearing the pinned program clears its arguments with it", async () => {
|
||||
const { port, save } = await openPinned();
|
||||
fireEvent.change(path(), { target: { value: WSL } });
|
||||
fireEvent.change(args(), { target: { value: "-d\nx\n--\n/bin/sh" } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存该路径" }));
|
||||
await screen.findByText(/启动命令/);
|
||||
await userEvent.click(screen.getByRole("button", { name: "取消指定" }));
|
||||
await waitFor(() => expect(save).toHaveBeenLastCalledWith("bash", ""));
|
||||
await waitFor(() => expect(screen.queryByText("启动命令")).toBeNull());
|
||||
expect((await port.shell()).args).toBeUndefined();
|
||||
expect(args().value).toBe("");
|
||||
});
|
||||
@@ -32,6 +32,7 @@ export function Shell({ port, onChanged }: { port: AgentPort; onChanged?: () =>
|
||||
const [busy, setBusy] = useState("");
|
||||
const [note, setNote] = useState<{ text: string; unapplied: boolean } | null>(null);
|
||||
const [custom, setCustom] = useState("");
|
||||
const [launcher, setLauncher] = useState("");
|
||||
|
||||
const load = useCallback(() => {
|
||||
port
|
||||
@@ -39,6 +40,7 @@ export function Shell({ port, onChanged }: { port: AgentPort; onChanged?: () =>
|
||||
.then((v) => {
|
||||
setS(v);
|
||||
setCustom(v.path ?? "");
|
||||
setLauncher((v.args ?? []).join("\n"));
|
||||
})
|
||||
.catch(() => setS(null));
|
||||
}, [port]);
|
||||
@@ -51,13 +53,14 @@ export function Shell({ port, onChanged }: { port: AgentPort; onChanged?: () =>
|
||||
// Two bashes on one machine are two different programs, so picking one has to
|
||||
// pin its path. With only one, the name alone survives a PATH that moves.
|
||||
const pin = (o: ShellOption) => options.filter((x) => x.prefer === o.prefer).length > 1;
|
||||
const save = async (what: string, prefer: string, path: string) => {
|
||||
const save = async (what: string, prefer: string, path: string, args?: string[]) => {
|
||||
setBusy(what);
|
||||
setNote(null);
|
||||
try {
|
||||
const next = await port.saveShell(prefer, path);
|
||||
const next = await (args && args.length > 0 ? port.saveShell(prefer, path, args) : port.saveShell(prefer, path));
|
||||
setS(next);
|
||||
setCustom(next.path ?? "");
|
||||
setLauncher((next.args ?? []).join("\n"));
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
setNote({ text: reason(e), unapplied: savedNotApplied(e) });
|
||||
@@ -71,6 +74,10 @@ export function Shell({ port, onChanged }: { port: AgentPort; onChanged?: () =>
|
||||
// A Windows host without bash is the case worth naming: the model's POSIX
|
||||
// habits are wrong there, and one install is what changes that.
|
||||
const noBash = s.platform === "windows" && !options.some((o) => o.prefer === "bash");
|
||||
// One argument per line: an argument may hold spaces (a distro name), and the
|
||||
// kernel runs the list as written, with no shell in between.
|
||||
const launcherArgs = launcher.split("\n").map((a) => a.trim()).filter((a) => a !== "");
|
||||
const fellBack = (s.args?.length ?? 0) > 0 && !s.argv;
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
@@ -82,6 +89,14 @@ export function Shell({ port, onChanged }: { port: AgentPort; onChanged?: () =>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{s.argv && (
|
||||
<div className="kv">
|
||||
<span className="k">{t("启动命令")}</span>
|
||||
<span className="v"><code>{s.argv.join(" ")} -c …</code></span>
|
||||
</div>
|
||||
)}
|
||||
{fellBack && <p className="note" data-tone="warn">{t("保存的启动参数现在无法运行,已回退到自动检测。")}</p>}
|
||||
|
||||
{/* One shape for "pick one of a few", the same as every other such choice
|
||||
in this pane. The executable's path is too long for a segment, and it
|
||||
is not what you pick by anyway — it rides the 当前生效 row above and
|
||||
@@ -113,7 +128,7 @@ export function Shell({ port, onChanged }: { port: AgentPort; onChanged?: () =>
|
||||
|
||||
{noBash && (
|
||||
<p className="note">
|
||||
{t("本机没有 bash,因此命令只能按 PowerShell 语法编写。安装 Git for Windows 后会增加 Git Bash 选项 —— WSL 中的 bash 不适用,它看到的是 /mnt 下的另一套路径,无法访问当前工作目录。")}
|
||||
{t("本机没有 bash,因此命令只能按 PowerShell 语法编写。安装 Git for Windows 后会增加 Git Bash 选项。要用 WSL,请在下方指定 wsl.exe 并填写启动参数;其中的路径是 Linux 写法。")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -134,6 +149,21 @@ export function Shell({ port, onChanged }: { port: AgentPort; onChanged?: () =>
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{s.platform === "windows" && (
|
||||
<div className="fields">
|
||||
<label className="grow full">
|
||||
<span>{t("启动参数(可选,每行一项)")}</span>
|
||||
<textarea
|
||||
data-action="shell.custom-args"
|
||||
rows={4}
|
||||
value={launcher}
|
||||
placeholder={"-d\nUbuntu\n--\n/bin/bash"}
|
||||
onChange={(e) => setLauncher(e.target.value)}
|
||||
/>
|
||||
<small className="note">{t("例如 wsl.exe 需要 -d <发行版> -- /bin/bash 才会进入 shell。参数按原样传给程序,不经过任何 shell;保存前会带着它们运行一次来验证。")}</small>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="acts">
|
||||
{s.path && (
|
||||
<button className="act" data-action="shell.custom-path" disabled={!!busy} onClick={() => void save("clear", s.prefer, "")}>
|
||||
@@ -141,7 +171,7 @@ export function Shell({ port, onChanged }: { port: AgentPort; onChanged?: () =>
|
||||
</button>
|
||||
)}
|
||||
<button className="act" data-action="shell.custom-path" data-primary disabled={!!busy || !custom.trim()}
|
||||
onClick={() => void save("custom", kindOf(custom), custom.trim())}>
|
||||
onClick={() => void save("custom", launcherArgs.length > 0 ? "bash" : kindOf(custom), custom.trim(), launcherArgs)}>
|
||||
{t(busy === "custom" ? "验证中…" : "保存该路径")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -239,7 +239,7 @@ func (b *builder) load() error {
|
||||
if opts.resolvedShell != nil {
|
||||
b.shell = *opts.resolvedShell
|
||||
} else {
|
||||
b.shell = resolveShellWithNotice(opts, cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, b.stderr, b.sink)
|
||||
b.shell = resolveShellWithNotice(opts, cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, cfg.Tools.Shell.Args, b.stderr, b.sink)
|
||||
}
|
||||
// Record the resolved interpreter for diagnostics, staying at Debug because
|
||||
// headless `run` must leave stderr empty unless --debug is passed. A launch
|
||||
@@ -253,9 +253,9 @@ func (b *builder) load() error {
|
||||
// resolveShellWithNotice keeps shell-discovery warnings on stderr for CLI
|
||||
// diagnostics and also reports them through the boot sink, where the settings
|
||||
// surface can show which interpreter actually runs.
|
||||
func resolveShellWithNotice(opts Options, prefer, path string, stderr io.Writer, sink event.Sink) sandbox.Shell {
|
||||
func resolveShellWithNotice(opts Options, prefer, path string, args []string, stderr io.Writer, sink event.Sink) sandbox.Shell {
|
||||
var warnings strings.Builder
|
||||
d := sandbox.ShellDiscovery{Prefer: prefer, Path: path, Warn: io.MultiWriter(stderr, &warnings), ProofDir: opts.roots().CacheDir()}
|
||||
d := sandbox.ShellDiscovery{Prefer: prefer, Path: path, Args: args, Warn: io.MultiWriter(stderr, &warnings), ProofDir: opts.roots().CacheDir()}
|
||||
if opts.tuneShell != nil {
|
||||
opts.tuneShell(&d)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ func buildPromptAssembly(ctx context.Context, opts Options, cfg *config.Config,
|
||||
if strings.TrimSpace(cfg.Tools.Shell.Path) != "" {
|
||||
shellLabel = shell.Path
|
||||
}
|
||||
if label := shell.Description(); label != "" {
|
||||
shellLabel = label
|
||||
}
|
||||
envSection := environment.FormatSection(
|
||||
environment.RunProbesWithOptions(ctx, environment.DefaultProbes(), environment.ProbeOptions{
|
||||
Overrides: cfg.Environment.Tools,
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func TestResolveShellWithNoticeReportsFallback(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
var notices []event.Event
|
||||
resolveShellWithNotice(Options{}, "not-a-shell", "", &stderr, event.FuncSink(func(e event.Event) {
|
||||
resolveShellWithNotice(Options{}, "not-a-shell", "", nil, &stderr, event.FuncSink(func(e event.Event) {
|
||||
notices = append(notices, e)
|
||||
}))
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SetShell records which interpreter the shell tool runs commands under. An
|
||||
// empty path keeps auto-detection of the executable, which is what every host
|
||||
// without a custom install wants; pinning one survives a PATH that changes.
|
||||
func (c *Config) SetShell(prefer, path string) error {
|
||||
func (c *Config) SetShell(prefer, path string, args ...string) error {
|
||||
switch strings.ToLower(strings.TrimSpace(prefer)) {
|
||||
case "", "auto":
|
||||
c.Tools.Shell.Prefer = "auto"
|
||||
@@ -23,5 +24,6 @@ func (c *Config) SetShell(prefer, path string) error {
|
||||
return fmt.Errorf("shell prefer %q: must be auto|bash|powershell|pwsh", prefer)
|
||||
}
|
||||
c.Tools.Shell.Path = strings.TrimSpace(path)
|
||||
c.Tools.Shell.Args = slices.Clone(args)
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -114,3 +115,76 @@ func TestLoadWarnsWhenProjectSetsShellEnv(t *testing.T) {
|
||||
t.Fatalf("no warning that the project shell env was ignored: %v", cfg.LoadWarnings())
|
||||
}
|
||||
}
|
||||
|
||||
// Launcher arguments decide what program the agent's commands are handed to, so
|
||||
// a repository can neither set them nor change the ones the user wrote.
|
||||
func TestProjectConfigCannotSetOrChangeShellArgs(t *testing.T) {
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
writeProjectDefaultTestConfig(t, home, "config.toml", "[tools.shell]\nprefer = \"bash\"\npath = \"/usr/bin/user-wsl\"\nargs = [\"-d\", \"mine\", \"--\", \"/bin/bash\"]\n")
|
||||
|
||||
project := testenv.TempDir(t)
|
||||
writeProjectDefaultTestConfig(t, project, "reasonix.toml", "[tools.shell]\nargs = [\"-d\", \"evil\", \"--\", \"/bin/sh\"]\n")
|
||||
t.Chdir(project)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"-d", "mine", "--", "/bin/bash"}; !slices.Equal(cfg.Tools.Shell.Args, want) {
|
||||
t.Fatalf("args = %q, want the user's %q", cfg.Tools.Shell.Args, want)
|
||||
}
|
||||
if !strings.Contains(strings.Join(cfg.LoadWarnings(), "\n"), "[tools.shell] args") {
|
||||
t.Fatalf("no warning that the project args were ignored: %v", cfg.LoadWarnings())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectConfigWithoutShellArgsLeavesTheUsersAlone(t *testing.T) {
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
writeProjectDefaultTestConfig(t, home, "config.toml", "[tools.shell]\nargs = [\"-d\", \"mine\"]\n")
|
||||
project := testenv.TempDir(t)
|
||||
writeProjectDefaultTestConfig(t, project, "reasonix.toml", "[agent]\nsystem_prompt = \"x\"\n")
|
||||
t.Chdir(project)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(cfg.Tools.Shell.Args, []string{"-d", "mine"}) {
|
||||
t.Fatalf("args = %q", cfg.Tools.Shell.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellArgsRoundTripThroughTheUserConfig(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
unlock := LockUserConfigEdits()
|
||||
defer unlock()
|
||||
orig := Default()
|
||||
if err := orig.SetShell("bash", "/usr/bin/user-wsl", "-d", `we"ird name`, "--", "/bin/bash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := orig.SaveTo(UserConfigPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := LoadForEdit(UserConfigPath())
|
||||
if want := []string{"-d", `we"ird name`, "--", "/bin/bash"}; !slices.Equal(got.Tools.Shell.Args, want) {
|
||||
t.Fatalf("args = %q, want %q", got.Tools.Shell.Args, want)
|
||||
}
|
||||
if err := got.SetShell("auto", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Tools.Shell.Args) != 0 {
|
||||
t.Fatalf("choosing auto kept the arguments: %q", got.Tools.Shell.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProjectScopedRenderNeverWritesShellArgs(t *testing.T) {
|
||||
c := Default()
|
||||
if err := c.SetShell("bash", "/usr/bin/user-wsl", "-d", "x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out := RenderTOMLForScope(c, RenderScopeProject); strings.Contains(out, "args") && strings.Contains(out, `"-d"`) {
|
||||
t.Fatalf("a project file would carry launcher arguments:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ func holdUserScope(c *Config) heldScope {
|
||||
p.Allow, p.Ask, p.Deny = slices.Clone(p.Allow), slices.Clone(p.Ask), slices.Clone(p.Deny)
|
||||
shell := c.Tools.Shell
|
||||
shell.Env = maps.Clone(shell.Env)
|
||||
shell.Args = slices.Clone(shell.Args)
|
||||
return heldScope{
|
||||
sandbox: s,
|
||||
permissions: p,
|
||||
@@ -145,6 +146,7 @@ func (c *Config) ignoreProject(key, value string, reason IgnoredProjectReason) {
|
||||
func (h heldScope) narrow(c *Config, r Roots, root string, projectMeta toml.MetaData) {
|
||||
ws := workspaceDir(root)
|
||||
c.Tools.Shell.Env = h.shell.Env
|
||||
c.Tools.Shell.Args = h.shell.Args
|
||||
warnShellEnvProblems(c, projectMeta)
|
||||
// Auto-submit commits a user's ask answers without a confirmation, so it is
|
||||
// the user's alone: a cloned repo must not turn it on.
|
||||
@@ -224,6 +226,10 @@ func (h heldScope) narrow(c *Config, r Roots, root string, projectMeta toml.Meta
|
||||
}
|
||||
store := NewProjectProgramStore(r.Home())
|
||||
h.gatePrograms(c, store, ws)
|
||||
// Launcher arguments belong to the program the user chose them for.
|
||||
if c.Tools.Shell.Path != h.shell.Path {
|
||||
c.Tools.Shell.Args = nil
|
||||
}
|
||||
h.endpoints.gateEndpoints(c, store, ws)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -16,15 +17,33 @@ func renderToolsShell(b *strings.Builder, s ShellConfig, scope RenderScope) {
|
||||
b.WriteString("# prefer = \"auto\" # auto|bash|powershell|pwsh; empty/default = auto-detect\n")
|
||||
}
|
||||
if s.Path != "" {
|
||||
fmt.Fprintf(b, "path = %q # absolute path to the shell executable; empty = PATH lookup\n\n", s.Path)
|
||||
fmt.Fprintf(b, "path = %q # absolute path to the shell executable; empty = PATH lookup\n", s.Path)
|
||||
} else {
|
||||
b.WriteString("# path = \"/opt/homebrew/bin/bash\" # absolute path to the shell executable; empty = PATH lookup\n\n")
|
||||
b.WriteString("# path = \"/opt/homebrew/bin/bash\" # absolute path to the shell executable; empty = PATH lookup\n")
|
||||
}
|
||||
if scope != RenderScopeProject {
|
||||
renderShellArgs(b, s.Args)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
if scope != RenderScopeProject {
|
||||
renderShellEnv(b, s.Env)
|
||||
}
|
||||
}
|
||||
|
||||
// renderShellArgs writes launcher arguments for [tools.shell] path. They are
|
||||
// the user's alone, so a project file never carries them.
|
||||
func renderShellArgs(b *strings.Builder, args []string) {
|
||||
if len(args) == 0 {
|
||||
b.WriteString("# args = [\"-d\", \"Ubuntu\", \"--\", \"/bin/bash\"] # Windows: launcher arguments for path (wsl.exe); verified by running them\n")
|
||||
return
|
||||
}
|
||||
quoted := make([]string, len(args))
|
||||
for i, a := range args {
|
||||
quoted[i] = strconv.Quote(a)
|
||||
}
|
||||
fmt.Fprintf(b, "args = [%s] # launcher arguments for path, run as written (no shell)\n", strings.Join(quoted, ", "))
|
||||
}
|
||||
|
||||
// renderShellEnv writes the [tools.shell.env] sub-table. The full template shows
|
||||
// a commented example when nothing is set, so the option is discoverable.
|
||||
func renderShellEnv(b *strings.Builder, env map[string]string) {
|
||||
|
||||
@@ -15,15 +15,21 @@ import (
|
||||
// auto if absent). Path optionally points at a specific shell executable. Env
|
||||
// adds KEY=value variables to every bash command's environment.
|
||||
type ShellConfig struct {
|
||||
Prefer string `toml:"prefer"`
|
||||
Path string `toml:"path"`
|
||||
Env map[string]string `toml:"env"`
|
||||
Prefer string `toml:"prefer"`
|
||||
Path string `toml:"path"`
|
||||
// Args are launcher arguments for Path (wsl.exe -d <distro> -- /bin/bash).
|
||||
// They are the user's alone: a project cannot set or inherit them.
|
||||
Args []string `toml:"args"`
|
||||
Env map[string]string `toml:"env"`
|
||||
}
|
||||
|
||||
// warnShellEnvProblems reports [tools.shell] env the load could not honour: a
|
||||
// project table it discarded, and entries that cannot become environment entries
|
||||
// (the bash tool drops those, so they would silently never take effect).
|
||||
func warnShellEnvProblems(cfg *Config, projectMeta toml.MetaData) {
|
||||
if projectMeta.IsDefined("tools", "shell", "args") {
|
||||
cfg.addLoadWarning("project reasonix.toml sets [tools.shell] args, which is user-global; it was ignored")
|
||||
}
|
||||
if projectMeta.IsDefined("tools", "shell", "env") {
|
||||
cfg.addLoadWarning("project reasonix.toml sets [tools.shell] env, which is user-global; it was ignored")
|
||||
}
|
||||
|
||||
@@ -1016,21 +1016,6 @@ type SpawnInput struct {
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// RuntimeOptions carries resolved host dependencies into Hook execution.
|
||||
// It is runtime-only and never changes persisted Hook configuration.
|
||||
type RuntimeOptions struct {
|
||||
BashPath string
|
||||
}
|
||||
|
||||
// RuntimeOptionsForShell carries an explicitly configured Bash path into Hook
|
||||
// execution while leaving other interpreter preferences independent.
|
||||
func RuntimeOptionsForShell(prefer, path string) RuntimeOptions {
|
||||
if !strings.EqualFold(strings.TrimSpace(prefer), "bash") {
|
||||
return RuntimeOptions{}
|
||||
}
|
||||
return RuntimeOptions{BashPath: strings.TrimSpace(path)}
|
||||
}
|
||||
|
||||
// RuntimeIssue identifies one plugin Hook whose host dependency is unavailable.
|
||||
type RuntimeIssue struct {
|
||||
Event Event
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package hook
|
||||
|
||||
import "strings"
|
||||
|
||||
// RuntimeOptions carries resolved host dependencies into Hook execution.
|
||||
// It is runtime-only and never changes persisted Hook configuration.
|
||||
type RuntimeOptions struct {
|
||||
BashPath string
|
||||
}
|
||||
|
||||
// RuntimeOptionsForShell carries an explicitly configured Bash path into Hook
|
||||
// execution while leaving other interpreter preferences independent. A path
|
||||
// that needs launcher arguments (wsl.exe) is the agent's shell tool alone:
|
||||
// hooks are the user's own scripts, written for the host's shell, so they keep
|
||||
// their own discovery.
|
||||
func RuntimeOptionsForShell(prefer, path string, launcherArgs ...string) RuntimeOptions {
|
||||
if !strings.EqualFold(strings.TrimSpace(prefer), "bash") || len(launcherArgs) > 0 {
|
||||
return RuntimeOptions{}
|
||||
}
|
||||
return RuntimeOptions{BashPath: strings.TrimSpace(path)}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package hook
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRuntimeOptionsKeepHooksOffTheAgentsLauncherShell(t *testing.T) {
|
||||
if got := RuntimeOptionsForShell("bash", `C:\Program Files\Git\bin\bash.exe`); got.BashPath == "" {
|
||||
t.Fatal("a plain pinned bash must still reach hooks")
|
||||
}
|
||||
if got := RuntimeOptionsForShell("bash", `C:\Windows\System32\wsl.exe`, "-d", "x", "--", "/bin/bash"); got != (RuntimeOptions{}) {
|
||||
t.Fatalf("a launcher with arguments reached hooks as %+v; they keep their own shell", got)
|
||||
}
|
||||
}
|
||||
@@ -277,7 +277,7 @@ func collectHooks(root, home, reasonixHome string, cfg *config.Config, disp func
|
||||
})
|
||||
runtimeOptions := hook.RuntimeOptions{}
|
||||
if cfg != nil {
|
||||
runtimeOptions = hook.RuntimeOptionsForShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path)
|
||||
runtimeOptions = hook.RuntimeOptionsForShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, cfg.Tools.Shell.Args...)
|
||||
}
|
||||
rep := HookReport{
|
||||
// Retained in schema v1 for compatibility; project hooks are enabled by
|
||||
|
||||
@@ -487,7 +487,7 @@ func pluginDoctorCommand(args []string) int {
|
||||
cfg, _ := config.LoadForRootReadOnly(workspaceRoot)
|
||||
runtimeOptions := hook.RuntimeOptions{}
|
||||
if cfg != nil {
|
||||
runtimeOptions = hook.RuntimeOptionsForShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path)
|
||||
runtimeOptions = hook.RuntimeOptionsForShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, cfg.Tools.Shell.Args...)
|
||||
}
|
||||
runtimeIssues := hook.CheckPackageRuntime(pkg, runtimeOptions)
|
||||
for _, issue := range runtimeIssues {
|
||||
|
||||
@@ -508,10 +508,13 @@ func redactHomeAll(paths []string) []string {
|
||||
// resolvedShellSummary reports which interpreter the bash tool would run
|
||||
// commands under, e.g. "bash (~/bin/bash)" or "powershell (C:\...\pwsh.exe)".
|
||||
func resolvedShellSummary(cfg *config.Config) string {
|
||||
sh := sandbox.ResolveShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, io.Discard)
|
||||
sh := sandbox.ShellDiscovery{Prefer: cfg.Tools.Shell.Prefer, Path: cfg.Tools.Shell.Path, Args: cfg.Tools.Shell.Args, Warn: io.Discard}.Resolve()
|
||||
if sh.Path == "" {
|
||||
return sh.Kind.String() + " (not found)"
|
||||
}
|
||||
if args := sh.Args(); len(args) > 0 {
|
||||
return sh.Kind.String() + " (" + redactHome(sh.Path) + " " + strings.Join(args, " ") + ")"
|
||||
}
|
||||
return sh.Kind.String() + " (" + redactHome(sh.Path) + ")"
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ func hookRuntimeWarnings(pkg pluginpkg.Package, workspaceRoot string) []string {
|
||||
}
|
||||
options := hook.RuntimeOptions{}
|
||||
if cfg, _ := config.LoadForRootReadOnly(workspaceRoot); cfg != nil {
|
||||
options = hook.RuntimeOptionsForShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path)
|
||||
options = hook.RuntimeOptionsForShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, cfg.Tools.Shell.Args...)
|
||||
}
|
||||
var out []string
|
||||
for _, issue := range hook.CheckPackageRuntime(pkg, options) {
|
||||
|
||||
@@ -26,14 +26,15 @@ func (s *Server) saveShellSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Prefer string `json:"prefer"`
|
||||
Path string `json:"path"`
|
||||
Prefer string `json:"prefer"`
|
||||
Path string `json:"path"`
|
||||
Args []string `json:"args"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&body); err != nil {
|
||||
badBody(w)
|
||||
return
|
||||
}
|
||||
if err := s.ctl().SaveShellSettings(body.Prefer, body.Path); err != nil {
|
||||
if err := s.ctl().SaveShellSettings(body.Prefer, body.Path, body.Args...); err != nil {
|
||||
saveFailed(w, http.StatusBadRequest, "shell.rejected", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,8 +70,15 @@ const (
|
||||
// (so callers can adapt prompts) and the executable to invoke. Fallback is set
|
||||
// only when bash was wanted and could not be used.
|
||||
type Shell struct {
|
||||
Kind ShellKind
|
||||
Path string
|
||||
Kind ShellKind
|
||||
Path string
|
||||
// launcherArgs, NUL-joined so Shell stays comparable, come between Path and
|
||||
// -c for a launcher such as wsl.exe, which needs them to reach a shell at
|
||||
// all. They are the user's own and run by argv.
|
||||
launcherArgs string
|
||||
// Drives says where the Windows drives appear inside a WSL launcher's Linux
|
||||
// side, as the distro itself reports it ("C:\ is /mnt/host/c/").
|
||||
Drives string
|
||||
Fallback FallbackReason
|
||||
}
|
||||
|
||||
@@ -712,7 +719,7 @@ func (s Shell) argv(command string) []string {
|
||||
if s.Kind == ShellPowerShell {
|
||||
return []string{path, "-NoProfile", "-NonInteractive", "-Command", powerShellToolScript(normalizeNullRedirects(command, "$null"))}
|
||||
}
|
||||
return []string{path, "-c", normalizeNullRedirects(command, "/dev/null")}
|
||||
return append(append([]string{path}, s.Args()...), "-c", normalizeNullRedirects(command, "/dev/null"))
|
||||
}
|
||||
|
||||
// SupportsChaining reports whether the shell parses '&&' / '||'. bash does;
|
||||
@@ -730,3 +737,17 @@ func (s Shell) SupportsChaining() bool {
|
||||
func quotedPath(p string) string {
|
||||
return strings.ReplaceAll(strconv.Quote(p), `\\`, `\`)
|
||||
}
|
||||
|
||||
// Args are the launcher arguments this shell is started with, if any.
|
||||
func (s Shell) Args() []string {
|
||||
if s.launcherArgs == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(s.launcherArgs, "\x00")
|
||||
}
|
||||
|
||||
// WithArgs is s started through the given launcher arguments.
|
||||
func (s Shell) WithArgs(args []string) Shell {
|
||||
s.launcherArgs = strings.Join(args, "\x00")
|
||||
return s
|
||||
}
|
||||
@@ -16,7 +16,11 @@ import (
|
||||
// drive discovery through a slow or hung interpreter on any OS.
|
||||
type ShellDiscovery struct {
|
||||
Prefer, Path string
|
||||
Warn io.Writer
|
||||
// Args are launcher arguments for Path (wsl.exe -d <distro> -- /bin/bash).
|
||||
// With them the pinned program is proven by running it with them, and an
|
||||
// unusable pair falls back to auto-detection.
|
||||
Args []string
|
||||
Warn io.Writer
|
||||
// ProofDir, when set, keeps launch proofs between runs. It must sit in the
|
||||
// user's own data tree; a proof never changes a sandbox decision.
|
||||
ProofDir string
|
||||
@@ -30,6 +34,9 @@ type ShellDiscovery struct {
|
||||
|
||||
// Resolve is ResolveShell with the discovery settings above.
|
||||
func (d ShellDiscovery) Resolve() Shell {
|
||||
if len(d.Args) > 0 {
|
||||
return d.resolveLauncher()
|
||||
}
|
||||
memo := &provenBash
|
||||
if d.Probe != nil {
|
||||
memo = new(sync.Map)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/base/proc"
|
||||
"reasonix/internal/base/secrets"
|
||||
)
|
||||
|
||||
// LauncherProof is what verifying a launcher with its arguments established:
|
||||
// the exact argv that was run, and where the Windows drives are on the Linux
|
||||
// side when the launcher is wsl.exe.
|
||||
type LauncherProof struct {
|
||||
Argv []string
|
||||
Drives string
|
||||
}
|
||||
|
||||
// launchersSupported is the host gate: wsl.exe-style launchers are a Windows
|
||||
// fact. A test on another OS may open it to drive the same code with a script.
|
||||
var launchersSupported = runtime.GOOS == "windows"
|
||||
|
||||
// launcherProbeTimeout bounds every launch a launcher setting makes.
|
||||
var launcherProbeTimeout = bashProbeTimeout
|
||||
|
||||
// launcherProofs remembers, per program file and argument list, a launch that
|
||||
// worked, so one process does not start the same distro for every resolution.
|
||||
var launcherProofs sync.Map
|
||||
|
||||
type launcherIdentity struct {
|
||||
path, args string
|
||||
size int64
|
||||
modTime int64
|
||||
}
|
||||
|
||||
// launcherProbeArgv is the whole probe: the launcher, its arguments, and a
|
||||
// command that does nothing and touches nothing.
|
||||
func launcherProbeArgv(path string, args []string) []string {
|
||||
return append(append([]string{path}, args...), "-c", "true")
|
||||
}
|
||||
|
||||
// proveLauncher runs the probe once, bounded by the same deadline as a plain
|
||||
// bash probe. A launcher that does not answer is not retried: a distro cold
|
||||
// start that exceeds the bound is a setting to look at, not a flake to hide.
|
||||
func proveLauncher(path string, args []string) (LauncherProof, error) {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return LauncherProof{}, fmt.Errorf("%s: no such executable", path)
|
||||
}
|
||||
id := launcherIdentity{path: path, args: strings.Join(args, "\x00"), size: fi.Size(), modTime: fi.ModTime().UnixNano()}
|
||||
if v, ok := launcherProofs.Load(id); ok {
|
||||
return v.(LauncherProof), nil
|
||||
}
|
||||
argv := launcherProbeArgv(path, args)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), launcherProbeTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
cmd.WaitDelay = probeWaitDelay
|
||||
cmd.Env = secrets.ProcessEnv()
|
||||
proc.HideWindow(cmd)
|
||||
if err := cmd.Run(); err != nil {
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return LauncherProof{}, fmt.Errorf("%s: did not answer within %s", strings.Join(argv, " "), launcherProbeTimeout)
|
||||
}
|
||||
return LauncherProof{}, fmt.Errorf("%s: did not run a command", strings.Join(argv, " "))
|
||||
}
|
||||
proof := LauncherProof{Argv: argv[:len(argv)-2], Drives: wslDrives(path, args)}
|
||||
launcherProofs.Store(id, proof)
|
||||
return proof, nil
|
||||
}
|
||||
|
||||
// wslDrives asks the distro itself where C:\ is mounted. The automount root is
|
||||
// the distro's own setting (docker-desktop uses /mnt/host), so it is read from
|
||||
// the running system and never assumed. Failing to learn it loses a sentence in
|
||||
// the prompt and nothing else.
|
||||
func wslDrives(path string, args []string) string {
|
||||
if !isWSLExe(path) {
|
||||
return ""
|
||||
}
|
||||
argv := append(append([]string{path}, args...), "-c", `wslpath -u 'C:\'`)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), launcherProbeTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
cmd.WaitDelay = probeWaitDelay
|
||||
cmd.Env = secrets.ProcessEnv()
|
||||
proc.HideWindow(cmd)
|
||||
out, err := cmd.Output()
|
||||
root := strings.TrimSpace(string(out))
|
||||
if err != nil || !strings.HasPrefix(root, "/") || strings.ContainsAny(root, "\r\n\x00") {
|
||||
return ""
|
||||
}
|
||||
return `C:\ is ` + root
|
||||
}
|
||||
|
||||
// isWSLExe reports the wsl.exe Windows ships, by file identity rather than by
|
||||
// spelling, as isWSLLauncherPath does for the bash.exe stub.
|
||||
func isWSLExe(path string) bool {
|
||||
if runtime.GOOS != "windows" || !strings.EqualFold(filepath.Base(path), "wsl.exe") {
|
||||
return false
|
||||
}
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ref, err := os.Stat(filepath.Join(systemWindowsDir(), "System32", "wsl.exe"))
|
||||
return err == nil && os.SameFile(fi, ref)
|
||||
}
|
||||
|
||||
// checkLauncherSettings is the part of a launcher setting that needs no launch.
|
||||
func checkLauncherSettings(prefer, path string, args []string) error {
|
||||
switch strings.ToLower(strings.TrimSpace(prefer)) {
|
||||
case "bash":
|
||||
default:
|
||||
return fmt.Errorf(`launcher arguments need prefer = "bash"`)
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return errors.New("launcher arguments need a path to the program they are for")
|
||||
}
|
||||
if !filepath.IsAbs(path) || !shellLaunchPlace(runtime.GOOS == "windows", path) {
|
||||
return fmt.Errorf("%s: not an absolute path on a local volume", path)
|
||||
}
|
||||
if slices.ContainsFunc(args, func(a string) bool { return a == "" || strings.ContainsAny(a, "\x00\r\n") }) {
|
||||
return errors.New("launcher arguments cannot be empty or contain line breaks")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyLauncher proves a launcher with its arguments the way it will run, so
|
||||
// the settings screen can show the argv that worked.
|
||||
func VerifyLauncher(prefer, path string, args []string) (LauncherProof, error) {
|
||||
if !launchersSupported {
|
||||
return LauncherProof{}, errors.New("launcher arguments are only supported on Windows")
|
||||
}
|
||||
if err := checkLauncherSettings(prefer, path, args); err != nil {
|
||||
return LauncherProof{}, err
|
||||
}
|
||||
return proveLauncher(path, args)
|
||||
}
|
||||
|
||||
func (d ShellDiscovery) resolveLauncher() Shell {
|
||||
proof, err := VerifyLauncher(d.Prefer, d.Path, d.Args)
|
||||
if err == nil {
|
||||
return Shell{Kind: ShellBash, Path: d.Path, Drives: proof.Drives}.WithArgs(d.Args)
|
||||
}
|
||||
if d.Warn != nil {
|
||||
fmt.Fprintf(d.Warn, "warning: [tools.shell] launcher arguments are not usable (%v); using auto-detection instead\n", err)
|
||||
}
|
||||
plain := d
|
||||
plain.Prefer, plain.Path, plain.Args = "", "", nil
|
||||
return plain.Resolve()
|
||||
}
|
||||
|
||||
// Description says what a launcher shell is in the words the model needs: the
|
||||
// argv that starts it and, for WSL, where the Windows drives are, as the distro
|
||||
// reported them. A plain shell has nothing to add and answers "".
|
||||
func (s Shell) Description() string {
|
||||
args := s.Args()
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := strings.Join(append([]string{s.Path}, args...), " ")
|
||||
if s.Drives != "" {
|
||||
out += " (Linux under WSL; " + s.Drives + ")"
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// fakeLauncher behaves like `wsl.exe -d <distro> -- <shell>`: it only answers
|
||||
// when given exactly that prefix, then runs what follows. Run with no
|
||||
// arguments it fails, the way the bare launcher does.
|
||||
func fakeLauncher(t *testing.T) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("script launcher")
|
||||
}
|
||||
launchersSupported = true
|
||||
t.Cleanup(func() { launchersSupported = runtime.GOOS == "windows" })
|
||||
launcherProofs.Clear()
|
||||
path := filepath.Join(testenv.TempDir(t), "fake-wsl")
|
||||
script := "#!/bin/sh\n[ \"$1\" = -d ] || exit 3\nshift 2\n[ \"$1\" = -- ] || exit 4\nshift\nexec \"$@\"\n"
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLauncherWithItsArgumentsIsProvenByRunningThem(t *testing.T) {
|
||||
path := fakeLauncher(t)
|
||||
if _, err := VerifyLauncher("bash", path, nil); err == nil {
|
||||
t.Fatal("the bare launcher does not run a command and must not verify")
|
||||
}
|
||||
args := []string{"-d", "kali", "--", "/bin/sh"}
|
||||
proof, err := VerifyLauncher("bash", path, args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := append([]string{path}, args...); !slices.Equal(proof.Argv, want) {
|
||||
t.Fatalf("argv shown = %q, want %q", proof.Argv, want)
|
||||
}
|
||||
var warn bytes.Buffer
|
||||
sh := ShellDiscovery{Prefer: "bash", Path: path, Args: args, Warn: &warn}.Resolve()
|
||||
if sh.Path != path || !slices.Equal(sh.Args(), args) || sh.Kind != ShellBash || warn.Len() != 0 {
|
||||
t.Fatalf("resolved %+v with warnings %q", sh, warn.String())
|
||||
}
|
||||
got := sh.argv("echo hi")
|
||||
want := []string{path, "-d", "kali", "--", "/bin/sh", "-c", "echo hi"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("command argv = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherRunsArgumentsByArgvNotThroughAShell(t *testing.T) {
|
||||
path := fakeLauncher(t)
|
||||
marker := filepath.Join(testenv.TempDir(t), "injected")
|
||||
args := []string{"-d", "x; touch " + marker, "--", "/bin/sh"}
|
||||
if _, err := VerifyLauncher("bash", path, args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatal("an argument was interpreted by a shell")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnusableLauncherSettingsFallBackToAutoWithAReason(t *testing.T) {
|
||||
path := fakeLauncher(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
prefer, path string
|
||||
args []string
|
||||
why string
|
||||
}{
|
||||
{"wrong arguments", "bash", path, []string{"-x"}, "did not run a command"},
|
||||
{"not bash", "powershell", path, []string{"-d", "x", "--", "/bin/sh"}, `prefer = "bash"`},
|
||||
{"no path", "bash", "", []string{"-d", "x"}, "need a path"},
|
||||
{"relative path", "bash", "wsl.exe", []string{"-d", "x"}, "absolute path"},
|
||||
{"empty argument", "bash", path, []string{"-d", "", "--"}, "empty"},
|
||||
{"line break", "bash", path, []string{"-d", "a\nb"}, "line breaks"},
|
||||
{"missing program", "bash", filepath.Join(testenv.TempDir(t), "nope"), []string{"-d", "x"}, "no such executable"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
var warn bytes.Buffer
|
||||
sh := ShellDiscovery{Prefer: c.prefer, Path: c.path, Args: c.args, Warn: &warn}.Resolve()
|
||||
if len(sh.Args()) != 0 || sh.Path == path && c.name != "not bash" {
|
||||
t.Errorf("%s: kept the launcher: %+v", c.name, sh)
|
||||
}
|
||||
if !strings.Contains(warn.String(), c.why) || !strings.Contains(warn.String(), "auto-detection") {
|
||||
t.Errorf("%s: warning = %q, want it to say %q and the fallback", c.name, warn.String(), c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherArgumentsAreWindowsOnly(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("windows supports them")
|
||||
}
|
||||
launcherProofs.Clear()
|
||||
var warn bytes.Buffer
|
||||
sh := ShellDiscovery{Prefer: "bash", Path: "/bin/sh", Args: []string{"-d", "x"}, Warn: &warn}.Resolve()
|
||||
if len(sh.Args()) != 0 || !strings.Contains(warn.String(), "only supported on Windows") {
|
||||
t.Fatalf("resolved %+v with %q", sh, warn.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellWithoutArgsKeepsItsArgv(t *testing.T) {
|
||||
sh := Shell{Kind: ShellBash, Path: "/bin/bash"}
|
||||
if got, want := sh.argv("ls"), []string{"/bin/bash", "-c", "ls"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("argv = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A launcher that hangs (a distro that will not start) costs one bounded wait and
|
||||
// is then refused; it never wedges settings or startup.
|
||||
func TestHungLauncherIsRefusedWithinTheBound(t *testing.T) {
|
||||
path := fakeLauncher(t)
|
||||
hang := filepath.Join(filepath.Dir(path), "hang")
|
||||
if err := os.WriteFile(hang, []byte("#!/bin/sh\nexec sleep 30\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcherProbeTimeout = 300 * time.Millisecond
|
||||
t.Cleanup(func() { launcherProbeTimeout = bashProbeTimeout })
|
||||
start := time.Now()
|
||||
_, err := VerifyLauncher("bash", hang, []string{"-d", "x"})
|
||||
if err == nil || !strings.Contains(err.Error(), "did not answer") {
|
||||
t.Fatalf("err = %v, want a timeout refusal", err)
|
||||
}
|
||||
if took := time.Since(start); took > 5*time.Second {
|
||||
t.Fatalf("the refusal took %v; the bound is not holding", took)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherShellIsDescribedForTheModelWithItsRealMounts(t *testing.T) {
|
||||
plain := Shell{Kind: ShellBash, Path: "/bin/bash"}
|
||||
if plain.Description() != "" {
|
||||
t.Fatalf("a plain shell needs no description, got %q", plain.Description())
|
||||
}
|
||||
wsl := Shell{Kind: ShellBash, Path: `C:\Windows\System32\wsl.exe`, Drives: `C:\ is /mnt/host/c/`}.WithArgs([]string{"-d", "Ubuntu", "--", "/bin/bash"})
|
||||
want := `C:\Windows\System32\wsl.exe -d Ubuntu -- /bin/bash (Linux under WSL; C:\ is /mnt/host/c/)`
|
||||
if got := wsl.Description(); got != want {
|
||||
t.Fatalf("description = %q, want %q", got, want)
|
||||
}
|
||||
other := Shell{Kind: ShellBash, Path: "/opt/launcher"}.WithArgs([]string{"run"})
|
||||
if got := other.Description(); got != "/opt/launcher run" {
|
||||
t.Fatalf("description = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -271,7 +271,7 @@ type RuntimeSettings interface {
|
||||
SaveNetworkSettings(in NetworkSettings, password string, clearPassword bool) error
|
||||
DiagnoseNetwork(ctx context.Context) []NetworkProbe
|
||||
ShellSettings() ShellSettings
|
||||
SaveShellSettings(prefer, path string) error
|
||||
SaveShellSettings(prefer, path string, args ...string) error
|
||||
PermissionRules() PermissionRules
|
||||
SavePermissionRules(in PermissionLists) error
|
||||
RevokeSessionGrant(rule string) int
|
||||
|
||||
@@ -31,8 +31,12 @@ type ShellOption struct {
|
||||
// probed instead of listed from a fixed table — offering a shell the host does
|
||||
// not have is a switch that breaks every command it accepts.
|
||||
type ShellSettings struct {
|
||||
Prefer string `json:"prefer"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Prefer string `json:"prefer"`
|
||||
Path string `json:"path,omitempty"`
|
||||
// Args are the saved launcher arguments for Path, and Argv the exact command
|
||||
// line that was run to verify them when they are in force.
|
||||
Args []string `json:"args,omitempty"`
|
||||
Argv []string `json:"argv,omitempty"`
|
||||
Effective ShellOption `json:"effective"`
|
||||
// Auto is what detection picks here, so "自动" can name its own outcome.
|
||||
Auto ShellOption `json:"auto"`
|
||||
@@ -44,24 +48,30 @@ type ShellSettings struct {
|
||||
// beside it.
|
||||
func (c *Controller) ShellSettings() ShellSettings {
|
||||
prefer, path := "auto", ""
|
||||
var args []string
|
||||
if cfg, err := config.Load(); err == nil {
|
||||
if p := strings.TrimSpace(cfg.Tools.Shell.Prefer); p != "" {
|
||||
prefer = strings.ToLower(p)
|
||||
}
|
||||
path = strings.TrimSpace(cfg.Tools.Shell.Path)
|
||||
args = cfg.Tools.Shell.Args
|
||||
}
|
||||
auto := sandbox.ResolveShell("", "", nil)
|
||||
effective := auto
|
||||
if prefer != "auto" || path != "" {
|
||||
effective = sandbox.ResolveShell(prefer, path, nil)
|
||||
effective = sandbox.ShellDiscovery{Prefer: prefer, Path: path, Args: args}.Resolve()
|
||||
}
|
||||
out := ShellSettings{
|
||||
Prefer: prefer,
|
||||
Path: path,
|
||||
Args: args,
|
||||
Effective: shellOption(effective),
|
||||
Auto: shellOption(auto),
|
||||
Platform: shellrun.DescriptorFromShell(auto).Platform,
|
||||
}
|
||||
if got := effective.Args(); len(got) > 0 {
|
||||
out.Argv = append([]string{effective.Path}, got...)
|
||||
}
|
||||
for _, sh := range sandbox.DetectShells() {
|
||||
out.Options = append(out.Options, shellOption(sh))
|
||||
}
|
||||
@@ -109,14 +119,18 @@ func gitInstallRoot(path string) string {
|
||||
// path that cannot execute is refused on the screen that typed it rather than
|
||||
// on every command afterwards. The caller rebuilds the runtime, because boot
|
||||
// binds the interpreter into the shell tool while assembling it.
|
||||
func (c *Controller) SaveShellSettings(prefer, path string) error {
|
||||
if err := sandbox.VerifyShell(prefer, path); err != nil {
|
||||
func (c *Controller) SaveShellSettings(prefer, path string, args ...string) error {
|
||||
if len(args) > 0 {
|
||||
if _, err := sandbox.VerifyLauncher(prefer, path, args); err != nil {
|
||||
return fmt.Errorf("这个 shell 用不了:%w", err)
|
||||
}
|
||||
} else if err := sandbox.VerifyShell(prefer, path); err != nil {
|
||||
return fmt.Errorf("这个 shell 用不了:%w", err)
|
||||
}
|
||||
unlock := config.LockUserConfigEdits()
|
||||
defer unlock()
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if err := cfg.SetShell(prefer, path); err != nil {
|
||||
if err := cfg.SetShell(prefer, path, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
return cfg.SaveTo(config.UserConfigPath())
|
||||
|
||||
@@ -137,3 +137,32 @@ func TestOneGitBashPerInstall(t *testing.T) {
|
||||
t.Fatal("input slice was rewritten")
|
||||
}
|
||||
}
|
||||
|
||||
// A launcher that does not run is refused where it was typed, and the refusal
|
||||
// leaves the saved settings alone.
|
||||
func TestSaveShellSettingsRefusesUnusableLauncherArguments(t *testing.T) {
|
||||
path := seedShellConfig(t)
|
||||
missing := filepath.Join(testenv.TempDir(t), "no-such-wsl")
|
||||
if err := (&Controller{}).SaveShellSettings("bash", missing, "-d", "x", "--", "/bin/bash"); err == nil {
|
||||
t.Fatal("saving arguments for a program that does not exist succeeded")
|
||||
}
|
||||
saved := config.LoadForEdit(path)
|
||||
if saved.Tools.Shell.Path != "" || len(saved.Tools.Shell.Args) != 0 {
|
||||
t.Fatalf("a refused launcher was written anyway: %+v", saved.Tools.Shell)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveShellSettingsWithoutArgumentsClearsSavedOnes(t *testing.T) {
|
||||
path := seedShellConfig(t)
|
||||
cfg := config.LoadForEdit(path)
|
||||
cfg.Tools.Shell.Args = []string{"-d", "x"}
|
||||
if err := cfg.SaveTo(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (&Controller{}).SaveShellSettings("auto", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved := config.LoadForEdit(path); len(saved.Tools.Shell.Args) != 0 {
|
||||
t.Fatalf("arguments survived choosing auto: %q", saved.Tools.Shell.Args)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//go:build windows
|
||||
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/safety/sandbox"
|
||||
)
|
||||
|
||||
// wslLauncher resolves the shell this machine's WSL gives, or skips: a runner
|
||||
// without WSL has nothing to prove. REASONIX_TEST_WSL_DISTRO names the distro;
|
||||
// its shell is whatever /bin/sh is there.
|
||||
func wslLauncher(t *testing.T) (sandbox.Shell, func(command string) string) {
|
||||
t.Helper()
|
||||
distro := os.Getenv("REASONIX_TEST_WSL_DISTRO")
|
||||
wsl := filepath.Join(os.Getenv("SystemRoot"), "System32", "wsl.exe")
|
||||
if distro == "" {
|
||||
t.Skip("REASONIX_TEST_WSL_DISTRO is not set")
|
||||
}
|
||||
if _, err := os.Stat(wsl); err != nil {
|
||||
t.Skip("wsl.exe is not installed")
|
||||
}
|
||||
args := []string{"-d", distro, "--", "/bin/sh"}
|
||||
sh := sandbox.ShellDiscovery{Prefer: "bash", Path: wsl, Args: args}.Resolve()
|
||||
if len(sh.Args()) == 0 {
|
||||
t.Skipf("distro %q did not answer", distro)
|
||||
}
|
||||
inDistro := func(command string) string {
|
||||
out, _ := exec.Command(wsl, "-d", distro, "--", "/bin/sh", "-c", command).CombinedOutput()
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
return sh, inDistro
|
||||
}
|
||||
|
||||
func TestBashRunsThroughAWSLLauncherByArgv(t *testing.T) {
|
||||
sh, _ := wslLauncher(t)
|
||||
if sh.Drives == "" || !strings.Contains(sh.Drives, `C:\ is /`) {
|
||||
t.Fatalf("the distro did not report where the drives are: %q", sh.Drives)
|
||||
}
|
||||
args, _ := json.Marshal(map[string]any{"command": "echo from-linux; uname -s; pwd"})
|
||||
out, err := (bash{shell: sh}).Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v (%q)", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "from-linux") || !strings.Contains(out, "Linux") {
|
||||
t.Fatalf("output = %q, want a Linux shell answering", out)
|
||||
}
|
||||
t.Logf("drives: %q; output: %q", sh.Drives, out)
|
||||
}
|
||||
|
||||
// A cancelled command must end on the Linux side too: killing wsl.exe alone
|
||||
// could leave the process running in the distro.
|
||||
func TestBashCancelEndsTheLinuxProcessBehindWSL(t *testing.T) {
|
||||
sh, inDistro := wslLauncher(t)
|
||||
const marker = "31337.123"
|
||||
t.Cleanup(func() { inDistro("pkill -f '[s]leep " + marker + "' || true") })
|
||||
args, _ := json.Marshal(map[string]any{"command": "sleep " + marker})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := (bash{shell: sh}).Execute(ctx, args)
|
||||
done <- err
|
||||
}()
|
||||
started := false
|
||||
for range 50 {
|
||||
if inDistro("pgrep -f '[s]leep "+marker+"' | head -1") != "" {
|
||||
started = true
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if !started {
|
||||
t.Fatal("the command never started in the distro")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(40 * time.Second):
|
||||
t.Fatal("cancel did not return within 40s")
|
||||
}
|
||||
for range 50 {
|
||||
if inDistro("pgrep -f '[s]leep "+marker+"' | head -1") == "" {
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("the Linux process survived the cancel")
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
- 新增:Windows 上可以给「指定一个可执行文件」填写启动参数(例如 wsl.exe -d 发行版 -- /bin/bash),保存前带着参数实际运行验证,无法运行则不保存。关联 #13247。
|
||||
- Added: on Windows, "point at an executable" takes launcher arguments (for example wsl.exe -d <distro> -- /bin/bash), verified by running them before saving. Refs #13247.
|
||||
Reference in new issue
Block a user