717 lines
21 KiB
Go
717 lines
21 KiB
Go
// Package hook runs user-configured shell-command hooks around the agent loop:
|
|
// PreToolUse / PostToolUse fire around each tool call, PermissionRequest fires
|
|
// before a tool approval prompt is shown, UserPromptSubmit before a turn, Stop
|
|
// after it. Hooks come from settings.json — a project
|
|
// (.reasonix/settings.json, only when the project is trusted) and a global
|
|
// (<Reasonix home>/settings.json) file. A hook's exit
|
|
// code is its verdict: 0 = pass, 2 = block (only on the gating events), other =
|
|
// warn. The payload is delivered as JSON on stdin; output is captured (capped)
|
|
// and surfaced to the user. This package only loads, matches, and runs hooks;
|
|
// the agent and controller decide what a block means (see internal/agent,
|
|
// internal/control).
|
|
package hook
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"reasonix/internal/config"
|
|
fileencoding "reasonix/internal/fileutil/encoding"
|
|
"reasonix/internal/pluginpkg"
|
|
"reasonix/internal/proc"
|
|
"reasonix/internal/secrets"
|
|
)
|
|
|
|
// Event is a point in the agent loop a hook can fire at.
|
|
type Event string
|
|
|
|
const (
|
|
PreToolUse Event = "PreToolUse"
|
|
PostToolUse Event = "PostToolUse"
|
|
PermissionRequest Event = "PermissionRequest"
|
|
UserPromptSubmit Event = "UserPromptSubmit"
|
|
Stop Event = "Stop"
|
|
// PostLLMCall fires after every model turn completes (streaming finishes) but
|
|
// before the reasoning_content is stored in the session. The hook receives the
|
|
// raw reasoning text in the payload; its stdout, if non-empty on exit 0,
|
|
// replaces the reasoning stored and displayed to the user. It can't block — a
|
|
// non-zero exit or empty stdout leaves the reasoning unchanged.
|
|
PostLLMCall Event = "PostLLMCall"
|
|
// SessionStart fires once when a session becomes active (fresh, resumed, or
|
|
// after /new). SessionEnd fires when it is closed or rotated. SubagentStop
|
|
// fires when a `task` sub-agent finishes. Notification fires when the agent
|
|
// needs the user's attention (e.g. a pending approval). PreCompact fires just
|
|
// before a compaction pass; its stdout is injected as extra summary guidance.
|
|
SessionStart Event = "SessionStart"
|
|
SessionEnd Event = "SessionEnd"
|
|
SubagentStop Event = "SubagentStop"
|
|
Notification Event = "Notification"
|
|
PreCompact Event = "PreCompact"
|
|
)
|
|
|
|
// Events is every event, in a stable order — drives loading and `/hooks`.
|
|
var Events = []Event{
|
|
PreToolUse, PostToolUse, PermissionRequest, UserPromptSubmit, Stop,
|
|
PostLLMCall,
|
|
SessionStart, SessionEnd, SubagentStop, Notification, PreCompact,
|
|
}
|
|
|
|
// IsBlocking reports whether a non-zero/exit-2 (or timed-out) hook on this event
|
|
// can block the loop. Only the gating events qualify. (PreCompact does not block;
|
|
// it only contributes guidance via stdout.)
|
|
func IsBlocking(e Event) bool { return e == PreToolUse || e == UserPromptSubmit }
|
|
|
|
// defaultTimeout is the per-event timeout when a hook sets none. Tool/prompt
|
|
// hooks gate progress, so they're tight; post/stop hooks get more room.
|
|
func defaultTimeout(e Event) time.Duration {
|
|
switch e {
|
|
case PreToolUse, PermissionRequest, UserPromptSubmit:
|
|
return 5 * time.Second
|
|
default:
|
|
return 30 * time.Second
|
|
}
|
|
}
|
|
|
|
// Scope records which settings.json a hook came from. Project hooks fire before
|
|
// global ones.
|
|
type Scope string
|
|
|
|
const (
|
|
ScopeProject Scope = "project"
|
|
ScopePlugin Scope = "plugin"
|
|
ScopeGlobal Scope = "global"
|
|
)
|
|
|
|
// HookConfig is one hook as written in settings.json.
|
|
type HookConfig struct {
|
|
// Match is an anchored regex selecting tools (Pre/PostToolUse and
|
|
// PermissionRequest only); "" or "*" = every tool. Anchored: "file" won't
|
|
// match "read_file" — use ".*file".
|
|
Match string `json:"match,omitempty"`
|
|
// Command is the shell command to run (spawned through the platform shell).
|
|
Command string `json:"command"`
|
|
// ContextFile is an internal plugin-package helper: when set, the hook reads
|
|
// this file and treats it as stdout instead of spawning a shell command.
|
|
ContextFile string `json:"contextFile,omitempty"`
|
|
// Description is an optional human label surfaced in `/hooks`.
|
|
Description string `json:"description,omitempty"`
|
|
// Timeout overrides the per-event default, in milliseconds.
|
|
Timeout int `json:"timeout,omitempty"`
|
|
// Cwd overrides the working directory (defaults to the payload's cwd).
|
|
Cwd string `json:"cwd,omitempty"`
|
|
// Env adds environment variables for this hook invocation.
|
|
Env map[string]string `json:"env,omitempty"`
|
|
}
|
|
|
|
// Settings is the shape of a settings.json (only hooks for now).
|
|
type Settings struct {
|
|
Hooks map[Event][]HookConfig `json:"hooks"`
|
|
}
|
|
|
|
// ResolvedHook is a loaded hook with its origin baked in.
|
|
type ResolvedHook struct {
|
|
HookConfig
|
|
Event Event
|
|
Scope Scope
|
|
Source string // absolute path to the settings.json it came from
|
|
}
|
|
|
|
func (h ResolvedHook) timeout() time.Duration {
|
|
if h.Timeout > 0 {
|
|
return time.Duration(h.Timeout) * time.Millisecond
|
|
}
|
|
return defaultTimeout(h.Event)
|
|
}
|
|
|
|
// SettingsDirname / SettingsFilename locate a scope's settings.json.
|
|
const (
|
|
SettingsDirname = ".reasonix"
|
|
SettingsFilename = "settings.json"
|
|
)
|
|
|
|
// GlobalSettingsPath is <Reasonix home>/settings.json (homeDir overrides ~ for
|
|
// tests and legacy callers).
|
|
func GlobalSettingsPath(homeDir string) string {
|
|
return filepath.Join(reasonixHome(homeDir), SettingsFilename)
|
|
}
|
|
|
|
// ProjectSettingsPath is <root>/.reasonix/settings.json.
|
|
func ProjectSettingsPath(projectRoot string) string {
|
|
return filepath.Join(projectRoot, SettingsDirname, SettingsFilename)
|
|
}
|
|
|
|
// LoadOptions configure Load. Project hooks load only when Trusted; global hooks
|
|
// always load.
|
|
type LoadOptions struct {
|
|
ProjectRoot string
|
|
HomeDir string
|
|
Trusted bool
|
|
}
|
|
|
|
// Load resolves hooks: project first (only when trusted), then global; within a
|
|
// scope, settings.json array order. A malformed file yields no hooks (never an
|
|
// error — a typo shouldn't take down the CLI).
|
|
func Load(opts LoadOptions) []ResolvedHook {
|
|
var out []ResolvedHook
|
|
if opts.ProjectRoot != "" && opts.Trusted {
|
|
p := ProjectSettingsPath(opts.ProjectRoot)
|
|
if s := readSettings(p); s != nil {
|
|
appendResolved(&out, s, ScopeProject, p)
|
|
}
|
|
}
|
|
appendPluginHooks(&out, reasonixHome(opts.HomeDir), opts.ProjectRoot)
|
|
g := GlobalSettingsPath(opts.HomeDir)
|
|
if s := readSettings(g); s != nil {
|
|
appendResolved(&out, s, ScopeGlobal, g)
|
|
} else if !pathExists(g) {
|
|
if legacy := legacyGlobalSettingsPath(opts.HomeDir); legacy != "" {
|
|
if s := readSettings(legacy); s != nil {
|
|
appendResolved(&out, s, ScopeGlobal, legacy)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ProjectDefinesHooks reports whether a project's settings.json exists and
|
|
// declares at least one hook — regardless of trust. Frontends use this to decide
|
|
// whether to prompt the user to trust the project.
|
|
func ProjectDefinesHooks(projectRoot string) bool {
|
|
s := readSettings(ProjectSettingsPath(projectRoot))
|
|
if s == nil {
|
|
return false
|
|
}
|
|
for _, e := range Events {
|
|
for _, cfg := range s.Hooks[e] {
|
|
if strings.TrimSpace(cfg.Command) != "" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func readSettings(path string) *Settings {
|
|
b, err := fileencoding.ReadFileUTF8(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var s Settings
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
return nil // malformed → treat as no hooks, don't crash
|
|
}
|
|
return &s
|
|
}
|
|
|
|
func pathExists(path string) bool {
|
|
if strings.TrimSpace(path) == "" {
|
|
return false
|
|
}
|
|
_, err := os.Stat(path)
|
|
return err == nil || !os.IsNotExist(err)
|
|
}
|
|
|
|
func appendResolved(out *[]ResolvedHook, s *Settings, scope Scope, source string) {
|
|
if s.Hooks == nil {
|
|
return
|
|
}
|
|
for _, event := range Events {
|
|
for _, cfg := range s.Hooks[event] {
|
|
if strings.TrimSpace(cfg.Command) == "" {
|
|
continue
|
|
}
|
|
cfg.Command = NormalizeCommand(cfg.Command)
|
|
*out = append(*out, ResolvedHook{HookConfig: cfg, Event: event, Scope: scope, Source: source})
|
|
}
|
|
}
|
|
}
|
|
|
|
func appendPluginHooks(out *[]ResolvedHook, reasonixHomeDir, projectRoot string) {
|
|
if strings.TrimSpace(reasonixHomeDir) == "" {
|
|
return
|
|
}
|
|
installed, _ := pluginpkg.LoadInstalled(reasonixHomeDir)
|
|
for _, item := range installed {
|
|
pkg := item.Package
|
|
events := make([]string, 0, len(pkg.Manifest.Hooks))
|
|
for event := range pkg.Manifest.Hooks {
|
|
events = append(events, event)
|
|
}
|
|
sort.Strings(events)
|
|
for _, eventName := range events {
|
|
event := Event(eventName)
|
|
if !validEvent(event) {
|
|
continue
|
|
}
|
|
for _, h := range pkg.Manifest.Hooks[eventName] {
|
|
command := h.Command
|
|
if command != "" && !h.ShellCommand && !filepath.IsAbs(command) {
|
|
command = filepath.Join(pkg.Root, filepath.FromSlash(command))
|
|
}
|
|
command = NormalizeCommand(command)
|
|
contextFile := h.ContextFile
|
|
if contextFile != "" && !filepath.IsAbs(contextFile) {
|
|
contextFile = filepath.Join(pkg.Root, filepath.FromSlash(contextFile))
|
|
}
|
|
cwd := h.Cwd
|
|
if cwd == "" {
|
|
cwd = pkg.Root
|
|
} else if !filepath.IsAbs(cwd) {
|
|
cwd = filepath.Join(pkg.Root, filepath.FromSlash(cwd))
|
|
}
|
|
env := cloneEnv(h.Env)
|
|
env["REASONIX_PLUGIN_ROOT"] = pkg.Root
|
|
env["REASONIX_PLUGIN_NAME"] = item.Installed.Name
|
|
env["REASONIX_HOME"] = reasonixHomeDir
|
|
env["REASONIX_WORKSPACE_ROOT"] = projectRoot
|
|
env["CLAUDE_PROJECT_DIR"] = projectRoot
|
|
if item.Installed.Version != "" {
|
|
env["REASONIX_PLUGIN_VERSION"] = item.Installed.Version
|
|
}
|
|
*out = append(*out, ResolvedHook{
|
|
HookConfig: HookConfig{
|
|
Match: h.Match,
|
|
Command: command,
|
|
ContextFile: contextFile,
|
|
Description: h.Description,
|
|
Timeout: h.Timeout,
|
|
Cwd: cwd,
|
|
Env: env,
|
|
},
|
|
Event: event,
|
|
Scope: ScopePlugin,
|
|
Source: filepath.Join(pkg.Root, pluginpkg.ManifestPath(pkg.ManifestKind)),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func validEvent(event Event) bool {
|
|
for _, e := range Events {
|
|
if e == event {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func cloneEnv(in map[string]string) map[string]string {
|
|
out := map[string]string{}
|
|
for k, v := range in {
|
|
if strings.TrimSpace(k) != "" {
|
|
out[k] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// MatchesTool reports whether a hook applies to toolName. The match field is an
|
|
// anchored regex; non-tool events always match. A malformed regex never fires
|
|
// (safer than firing on everything).
|
|
func MatchesTool(h ResolvedHook, toolName string) bool {
|
|
if h.Event != PreToolUse && h.Event != PostToolUse && h.Event != PermissionRequest {
|
|
return true
|
|
}
|
|
m := h.Match
|
|
if m == "" || m == "*" {
|
|
return true
|
|
}
|
|
re, err := regexp.Compile("^(?:" + m + ")$")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return re.MatchString(toolName)
|
|
}
|
|
|
|
// Payload is the JSON envelope written to a hook's stdin.
|
|
type Payload struct {
|
|
Event Event `json:"event"`
|
|
Cwd string `json:"cwd"`
|
|
ToolName string `json:"toolName,omitempty"`
|
|
ToolArgs json.RawMessage `json:"toolArgs,omitempty"`
|
|
Subject string `json:"subject,omitempty"`
|
|
ToolResult string `json:"toolResult,omitempty"`
|
|
Prompt string `json:"prompt,omitempty"`
|
|
LastAssistant string `json:"lastAssistantText,omitempty"`
|
|
Turn int `json:"turn,omitempty"`
|
|
Message string `json:"message,omitempty"` // Notification: what needs attention
|
|
Trigger string `json:"trigger,omitempty"` // PreCompact: "auto" | "manual"
|
|
Reasoning string `json:"reasoning,omitempty"` // PostLLMCall: the model's raw reasoning text
|
|
}
|
|
|
|
// Decision is a single hook invocation's verdict.
|
|
type Decision string
|
|
|
|
const (
|
|
DecisionPass Decision = "pass"
|
|
DecisionBlock Decision = "block"
|
|
DecisionWarn Decision = "warn"
|
|
DecisionError Decision = "error" // spawn failed (ENOENT, EACCES, …)
|
|
)
|
|
|
|
// Outcome records one hook invocation.
|
|
type Outcome struct {
|
|
Hook ResolvedHook
|
|
Decision Decision
|
|
ExitCode int // -1 when unknown (killed / spawn error)
|
|
Stdout string
|
|
Stderr string
|
|
TimedOut bool
|
|
Truncated bool
|
|
Duration time.Duration
|
|
}
|
|
|
|
// Report aggregates the outcomes of running an event's hooks.
|
|
type Report struct {
|
|
Event Event
|
|
Outcomes []Outcome
|
|
Blocked bool // at least one outcome blocked (only meaningful on gating events)
|
|
}
|
|
|
|
// HookOutput is the parsed, model-facing part of a successful hook stdout.
|
|
type HookOutput struct {
|
|
AdditionalContext string
|
|
}
|
|
|
|
type hookJSONOutput struct {
|
|
HookSpecificOutput struct {
|
|
HookEventName Event `json:"hookEventName"`
|
|
AdditionalContext string `json:"additionalContext"`
|
|
} `json:"hookSpecificOutput"`
|
|
}
|
|
|
|
// ParseOutput extracts hook-specific context from stdout. Plain text is accepted
|
|
// for SessionStart compatibility; JSON output must identify the current event.
|
|
func ParseOutput(event Event, stdout string) (HookOutput, []string) {
|
|
stdout = strings.TrimSpace(stdout)
|
|
if stdout == "" {
|
|
return HookOutput{}, nil
|
|
}
|
|
if !strings.HasPrefix(stdout, "{") {
|
|
if event == SessionStart {
|
|
return HookOutput{AdditionalContext: stdout}, nil
|
|
}
|
|
return HookOutput{}, nil
|
|
}
|
|
var parsed hookJSONOutput
|
|
if err := json.Unmarshal([]byte(stdout), &parsed); err != nil {
|
|
return HookOutput{}, []string{fmt.Sprintf("hook %s returned invalid JSON stdout: %v", event, err)}
|
|
}
|
|
spec := parsed.HookSpecificOutput
|
|
if spec.HookEventName == "" && strings.TrimSpace(spec.AdditionalContext) == "" {
|
|
return HookOutput{}, nil
|
|
}
|
|
if spec.HookEventName != event {
|
|
return HookOutput{}, []string{fmt.Sprintf("hook output event %q does not match current event %q", spec.HookEventName, event)}
|
|
}
|
|
return HookOutput{AdditionalContext: strings.TrimSpace(spec.AdditionalContext)}, nil
|
|
}
|
|
|
|
// decideOutcome maps a spawn result to a verdict.
|
|
func decideOutcome(event Event, r SpawnResult) Decision {
|
|
switch {
|
|
case r.SpawnErr != nil:
|
|
return DecisionError
|
|
case r.TimedOut:
|
|
if IsBlocking(event) {
|
|
return DecisionBlock
|
|
}
|
|
return DecisionWarn
|
|
case r.ExitCode == 0:
|
|
return DecisionPass
|
|
case r.ExitCode == 2 && IsBlocking(event):
|
|
return DecisionBlock
|
|
default:
|
|
return DecisionWarn
|
|
}
|
|
}
|
|
|
|
// SpawnInput / SpawnResult / Spawner are the test seam around the real spawn.
|
|
type SpawnInput struct {
|
|
Command string
|
|
Cwd string
|
|
Env map[string]string
|
|
Stdin string
|
|
Timeout time.Duration
|
|
}
|
|
|
|
type SpawnResult struct {
|
|
ExitCode int
|
|
Stdout string
|
|
Stderr string
|
|
TimedOut bool
|
|
SpawnErr error
|
|
Truncated bool
|
|
}
|
|
|
|
type Spawner func(ctx context.Context, in SpawnInput) SpawnResult
|
|
|
|
// outputCapBytes bounds per-stream capture so a runaway child can't blow up the
|
|
// heap between spawn and timeout.
|
|
const outputCapBytes = 256 * 1024
|
|
|
|
// Run executes the hooks matching payload.Event (and, for tool events, the tool
|
|
// name), feeding each the JSON payload on stdin. It stops at the first block so
|
|
// a gating hook can prevent later hooks running against a phantom success.
|
|
func Run(ctx context.Context, payload Payload, hooks []ResolvedHook, spawner Spawner) Report {
|
|
if spawner == nil {
|
|
spawner = DefaultSpawner
|
|
}
|
|
event := payload.Event
|
|
stdinBytes, _ := json.Marshal(payload)
|
|
stdin := string(stdinBytes) + "\n"
|
|
|
|
report := Report{Event: event}
|
|
for _, h := range hooks {
|
|
if h.Event != event || !MatchesTool(h, payload.ToolName) {
|
|
continue
|
|
}
|
|
cwd := h.Cwd
|
|
if cwd == "" {
|
|
cwd = payload.Cwd
|
|
}
|
|
timeout := h.timeout()
|
|
start := time.Now()
|
|
r := runResolvedHook(ctx, h, SpawnInput{Command: h.Command, Cwd: cwd, Env: h.Env, Stdin: stdin, Timeout: timeout}, spawner)
|
|
decision := decideOutcome(event, r)
|
|
report.Outcomes = append(report.Outcomes, Outcome{
|
|
Hook: h,
|
|
Decision: decision,
|
|
ExitCode: r.ExitCode,
|
|
Stdout: r.Stdout,
|
|
Stderr: stderrFor(r, timeout),
|
|
TimedOut: r.TimedOut,
|
|
Truncated: r.Truncated,
|
|
Duration: time.Since(start),
|
|
})
|
|
if decision == DecisionBlock {
|
|
report.Blocked = true
|
|
break
|
|
}
|
|
}
|
|
return report
|
|
}
|
|
|
|
func runResolvedHook(ctx context.Context, h ResolvedHook, in SpawnInput, spawner Spawner) SpawnResult {
|
|
if h.Scope == ScopePlugin && h.ContextFile != "" {
|
|
return readContextFile(h.ContextFile)
|
|
}
|
|
return spawner(ctx, in)
|
|
}
|
|
|
|
func readContextFile(path string) SpawnResult {
|
|
body, err := fileencoding.ReadFileUTF8(path)
|
|
if err != nil {
|
|
return SpawnResult{ExitCode: -1, SpawnErr: err}
|
|
}
|
|
truncated := false
|
|
if len(body) > outputCapBytes {
|
|
body = body[:outputCapBytes]
|
|
truncated = true
|
|
}
|
|
return SpawnResult{ExitCode: 0, Stdout: string(body), Truncated: truncated}
|
|
}
|
|
|
|
// stderrFor returns the best human message for an outcome: real stderr, else a
|
|
// spawn-error message, else a timeout note.
|
|
func stderrFor(r SpawnResult, timeout time.Duration) string {
|
|
if r.Stderr != "" {
|
|
return r.Stderr
|
|
}
|
|
if r.SpawnErr != nil {
|
|
return r.SpawnErr.Error()
|
|
}
|
|
if r.TimedOut {
|
|
return fmt.Sprintf("hook timed out after %s", timeout)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// DefaultSpawner runs the command through the platform shell with the payload on
|
|
// stdin, capping captured output and honoring both the per-hook timeout and the
|
|
// parent context's cancellation.
|
|
func DefaultSpawner(ctx context.Context, in SpawnInput) SpawnResult {
|
|
cctx, cancel := context.WithTimeout(ctx, in.Timeout)
|
|
defer cancel()
|
|
|
|
cmd := spawnCommand(cctx, in.Command)
|
|
proc.HideWindow(cmd)
|
|
cmd.Dir = in.Cwd
|
|
env := secrets.ProcessEnv()
|
|
if len(in.Env) > 0 {
|
|
keys := make([]string, 0, len(in.Env))
|
|
for k := range in.Env {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
env = append(env, k+"="+in.Env[k])
|
|
}
|
|
}
|
|
cmd.Env = env
|
|
cmd.Stdin = strings.NewReader(in.Stdin)
|
|
var outBuf, errBuf cappedBuffer
|
|
cmd.Stdout = &outBuf
|
|
cmd.Stderr = &errBuf
|
|
// WaitDelay bounds Wait even if a grandchild keeps a pipe open after the
|
|
// shell is killed on timeout/cancel.
|
|
cmd.WaitDelay = 500 * time.Millisecond
|
|
|
|
err := cmd.Run()
|
|
res := SpawnResult{
|
|
ExitCode: -1,
|
|
Stdout: strings.TrimSpace(outBuf.String()),
|
|
Stderr: strings.TrimSpace(errBuf.String()),
|
|
Truncated: outBuf.truncated || errBuf.truncated,
|
|
}
|
|
switch {
|
|
case cctx.Err() == context.DeadlineExceeded:
|
|
res.TimedOut = true
|
|
case cctx.Err() == context.Canceled:
|
|
res.SpawnErr = cctx.Err()
|
|
case err != nil:
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
res.ExitCode = exitErr.ExitCode()
|
|
} else {
|
|
res.SpawnErr = err
|
|
}
|
|
default:
|
|
res.ExitCode = 0
|
|
}
|
|
return res
|
|
}
|
|
|
|
// spawnCommand picks the execution vehicle for a hook command. Commands run
|
|
// through the shell by default — that is the documented contract, and scripts
|
|
// may rely on shell expansion ($VAR, backticks). Direct exec (no shell) is
|
|
// used only where it is strictly better:
|
|
// - a command this call just repaired (its broken quoting means it never
|
|
// worked through a shell, so there is no expansion behavior to preserve);
|
|
// - on Windows, a recognized node -e stdin-hook command: `cmd /c` mangles
|
|
// quoted JS (&, %, nested quotes), which is the breakage this repair
|
|
// exists for, and cmd performs no POSIX-style $ expansion to preserve.
|
|
//
|
|
// POSIX commands that were already well-formed keep their shell semantics
|
|
// verbatim — normalizeStaticNodeEval's rendering escapes $ and backticks, so
|
|
// even repaired commands re-entering here behave identically under sh -c.
|
|
func spawnCommand(ctx context.Context, command string) *exec.Cmd {
|
|
if node, flag, script, ok := repairableNodeEvalArgs(command); ok {
|
|
return exec.CommandContext(ctx, node, flag, script)
|
|
}
|
|
if powershell, args, ok := repairablePowerShellFileArgs(command); ok {
|
|
return exec.CommandContext(ctx, powershell, args...)
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
if node, flag, script, ok := directNodeEvalArgs(command); ok {
|
|
return exec.CommandContext(ctx, node, flag, script)
|
|
}
|
|
}
|
|
name, args := shellInvocation(command)
|
|
return exec.CommandContext(ctx, name, args...)
|
|
}
|
|
|
|
func shellInvocation(command string) (string, []string) {
|
|
if runtime.GOOS == "windows" {
|
|
return "cmd", []string{"/c", command}
|
|
}
|
|
return "sh", []string{"-c", command}
|
|
}
|
|
|
|
// cappedBuffer is an io.Writer that stops storing after outputCapBytes and
|
|
// records that it truncated, but keeps reporting full writes so the child never
|
|
// sees a short-write error.
|
|
type cappedBuffer struct {
|
|
buf bytes.Buffer
|
|
truncated bool
|
|
}
|
|
|
|
func (c *cappedBuffer) Write(p []byte) (int, error) {
|
|
remaining := outputCapBytes - c.buf.Len()
|
|
if remaining <= 0 {
|
|
c.truncated = true
|
|
return len(p), nil
|
|
}
|
|
if len(p) > remaining {
|
|
c.buf.Write(p[:remaining])
|
|
c.truncated = true
|
|
return len(p), nil
|
|
}
|
|
c.buf.Write(p)
|
|
return len(p), nil
|
|
}
|
|
|
|
func (c *cappedBuffer) String() string { return c.buf.String() }
|
|
|
|
func reasonixHome(override string) string {
|
|
if override != "" {
|
|
return filepath.Join(override, SettingsDirname)
|
|
}
|
|
if dir := config.ReasonixHomeDir(); dir != "" {
|
|
return dir
|
|
}
|
|
if h, err := os.UserHomeDir(); err == nil {
|
|
return filepath.Join(h, SettingsDirname)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func legacyGlobalSettingsPath(homeDir string) string {
|
|
dir := legacyReasonixHome(homeDir)
|
|
if dir == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(dir, SettingsFilename)
|
|
}
|
|
|
|
func legacyTrustPath(homeDir string) string {
|
|
dir := legacyReasonixHome(homeDir)
|
|
if dir == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(dir, TrustFilename)
|
|
}
|
|
|
|
func legacyReasonixHome(override string) string {
|
|
if override != "" {
|
|
return ""
|
|
}
|
|
if config.IsolatedHomeDir() != "" {
|
|
return ""
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil || home == "" {
|
|
return ""
|
|
}
|
|
legacy := filepath.Join(home, SettingsDirname)
|
|
if sameCleanPath(legacy, reasonixHome("")) {
|
|
return ""
|
|
}
|
|
return legacy
|
|
}
|
|
|
|
func sameCleanPath(a, b string) bool {
|
|
if strings.TrimSpace(a) == "" || strings.TrimSpace(b) == "" {
|
|
return false
|
|
}
|
|
if aa, err := filepath.Abs(a); err == nil {
|
|
a = aa
|
|
}
|
|
if bb, err := filepath.Abs(b); err == nil {
|
|
b = bb
|
|
}
|
|
return filepath.Clean(a) == filepath.Clean(b)
|
|
}
|