Skip to content

Commit 3cbeec4

Browse files
committed
feat: vibe-code transparent shell mode, most likely buggy but let's go anyway
1 parent ed204d5 commit 3cbeec4

File tree

4 files changed

+719
-11
lines changed

4 files changed

+719
-11
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,4 @@ __debug_bin*
5454
tmp/
5555
temp/
5656

57+
test_sh

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,35 @@ sudo mise setcap
6767
./process-tracer --help
6868
```
6969

70+
<!-- *AI SLOP* -->
71+
72+
## Environment Variable Configuration
73+
74+
All CLI flags have environment variable equivalents. CLI flags override environment variables.
75+
76+
```bash
77+
export PROCESS_TRACER_TRACE_ID='env["BUILD_ID"]'
78+
export PROCESS_TRACER_PARENT_ID='env["PARENT_SPAN"]'
79+
export PROCESS_TRACER_ATTRIBUTES='env=prod;region=us-east'
80+
./process-tracer -- command ...
81+
```
82+
83+
## Shell Mode
84+
85+
Symlink process-tracer as a shell for transparent wrapping:
86+
87+
```bash
88+
ln -s process-tracer bash
89+
export PROCESS_TRACER_TRACE_ID='trace123'
90+
./bash -c 'npm test' # All args pass through, no -- needed
91+
```
92+
93+
Shell resolution: `bash``/bin/bash`, `sh``/bin/sh`, `zsh``/bin/zsh`
94+
95+
Override: `export PROCESS_TRACER_SHELL_BINARY=/path/to/shell`
96+
97+
<!-- */AI SLOP* -->
98+
7099
## Expressions
71100

72101
The `-a` flag accepts any valid [expr](https://expr-lang.org/) expression.

internal/config/config.go

Lines changed: 319 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import (
66
"crypto/rand"
77
"encoding/hex"
88
"fmt"
9+
"os"
10+
"path/filepath"
911
"strings"
1012

13+
"github.com/caarlos0/env/v11"
1114
"github.com/urfave/cli/v3"
1215
)
1316

@@ -35,11 +38,298 @@ type Config struct {
3538
CustomAttributes []CustomAttribute
3639
}
3740

38-
// ParseArgs parses command-line arguments using urfave/cli and returns a Config.
39-
// Expected format: program_name [--trace-id <id>] [--parent-id <id>] [-a name=expr]... -- <command> [args...].
40-
// licenseText is displayed when --license flag is used.
41-
// version, commit, and buildDate are build-time injected values displayed with --version.
41+
// EnvConfig holds process-tracer configuration from environment variables.
42+
type EnvConfig struct {
43+
TraceID string `env:"PROCESS_TRACER_TRACE_ID"`
44+
ParentID string `env:"PROCESS_TRACER_PARENT_ID"`
45+
Attributes string `env:"PROCESS_TRACER_ATTRIBUTES"`
46+
Mode string `env:"PROCESS_TRACER_MODE" envDefault:"auto"`
47+
ShellBinary string `env:"PROCESS_TRACER_SHELL_BINARY"`
48+
}
49+
50+
// ParseEnvConfig parses process-tracer configuration from environment variables.
51+
func ParseEnvConfig() (*EnvConfig, error) {
52+
var cfg EnvConfig
53+
if err := env.Parse(&cfg); err != nil {
54+
return nil, fmt.Errorf("failed to parse process-tracer env config: %w", err)
55+
}
56+
return &cfg, nil
57+
}
58+
59+
// ParseAttributeString parses semicolon-separated NAME=EXPR pairs.
60+
// Format: "name1=expr1;name2=expr2;name3=expr3"
61+
func ParseAttributeString(attrStr string) ([]CustomAttribute, error) {
62+
if attrStr == "" {
63+
return nil, nil
64+
}
65+
66+
var attrs []CustomAttribute
67+
pairs := strings.Split(attrStr, ";")
68+
69+
for _, pair := range pairs {
70+
pair = strings.TrimSpace(pair)
71+
if pair == "" {
72+
continue
73+
}
74+
75+
// Split on first '=' to separate name from expression
76+
parts := strings.SplitN(pair, "=", 2)
77+
if len(parts) != 2 {
78+
return nil, fmt.Errorf("invalid attribute format %q: expected NAME=EXPR", pair)
79+
}
80+
81+
name := strings.TrimSpace(parts[0])
82+
expr := strings.TrimSpace(parts[1])
83+
84+
if name == "" {
85+
return nil, fmt.Errorf("attribute name cannot be empty in %q", pair)
86+
}
87+
if expr == "" {
88+
return nil, fmt.Errorf("attribute expression cannot be empty in %q", pair)
89+
}
90+
91+
attrs = append(attrs, CustomAttribute{
92+
Name: name,
93+
Expression: expr,
94+
})
95+
}
96+
97+
return attrs, nil
98+
}
99+
100+
// detectSymlinkMode determines if the binary is invoked via symlink.
101+
// Returns true if symlink mode should be used, false for direct CLI mode.
102+
func detectSymlinkMode(mode string) (bool, error) {
103+
// Check explicit override
104+
switch mode {
105+
case "direct":
106+
return false, nil
107+
case "symlink":
108+
return true, nil
109+
case "auto":
110+
// Continue to auto-detection
111+
case "":
112+
// Empty string means auto-detect (default)
113+
default:
114+
return false, fmt.Errorf("invalid PROCESS_TRACER_MODE: %s (must be auto, direct, or symlink)", mode)
115+
}
116+
117+
// Auto-detect: check if os.Args[0] is a symlink to ourselves
118+
if len(os.Args) == 0 {
119+
return false, nil
120+
}
121+
122+
selfPath, err := os.Executable()
123+
if err != nil {
124+
return false, nil // Can't determine, assume direct mode
125+
}
126+
127+
argsPath := os.Args[0]
128+
if !filepath.IsAbs(argsPath) {
129+
// Make it absolute
130+
argsPath, err = filepath.Abs(argsPath)
131+
if err != nil {
132+
return false, nil
133+
}
134+
}
135+
136+
// Resolve symlinks for os.Args[0]
137+
resolvedArgs, err := filepath.EvalSymlinks(argsPath)
138+
if err != nil {
139+
// Can't resolve, assume direct mode
140+
return false, nil
141+
}
142+
143+
resolvedSelf, err := filepath.EvalSymlinks(selfPath)
144+
if err != nil {
145+
return false, nil
146+
}
147+
148+
// If they resolve to the same path but os.Args[0] != resolved path, it's a symlink
149+
if resolvedArgs == resolvedSelf && argsPath != resolvedArgs {
150+
return true, nil
151+
}
152+
153+
return false, nil
154+
}
155+
156+
// isExecutable checks if a file exists and is executable.
157+
func isExecutable(path string) bool {
158+
info, err := os.Stat(path)
159+
if err != nil {
160+
return false
161+
}
162+
return !info.IsDir() && info.Mode()&0111 != 0
163+
}
164+
165+
// isSelfBinary checks if the given path points to the current executable.
166+
// Uses os.SameFile to handle symlinks and hardlinks correctly.
167+
func isSelfBinary(path string) (bool, error) {
168+
selfPath, err := os.Executable()
169+
if err != nil {
170+
return false, err
171+
}
172+
173+
// Resolve both to absolute paths
174+
absPath, err := filepath.Abs(path)
175+
if err != nil {
176+
return false, err
177+
}
178+
179+
absSelf, err := filepath.Abs(selfPath)
180+
if err != nil {
181+
return false, err
182+
}
183+
184+
// Get file info for both
185+
selfInfo, err := os.Stat(absSelf)
186+
if err != nil {
187+
return false, err
188+
}
189+
190+
pathInfo, err := os.Stat(absPath)
191+
if err != nil {
192+
return false, err
193+
}
194+
195+
// Compare using SameFile to handle symlinks/hardlinks
196+
return os.SameFile(selfInfo, pathInfo), nil
197+
}
198+
199+
// resolveShellBinary determines the actual shell binary to execute when running in shell mode.
200+
// Resolution order:
201+
// 1. PROCESS_TRACER_SHELL_BINARY env var (if set)
202+
// 2. Search PATH for binary matching symlink basename (excluding self)
203+
// 3. Check common locations: /bin/<name>, /usr/bin/<name>, /usr/local/bin/<name>
204+
// Returns absolute path to shell binary, or error if not found.
205+
func resolveShellBinary(symlinkName string, envOverride string) (string, error) {
206+
basename := filepath.Base(symlinkName)
207+
208+
// 1. Check environment variable override
209+
if envOverride != "" {
210+
if !isExecutable(envOverride) {
211+
return "", fmt.Errorf("PROCESS_TRACER_SHELL_BINARY=%q is not executable or does not exist", envOverride)
212+
}
213+
absPath, err := filepath.Abs(envOverride)
214+
if err != nil {
215+
return "", fmt.Errorf("failed to resolve PROCESS_TRACER_SHELL_BINARY path: %w", err)
216+
}
217+
return absPath, nil
218+
}
219+
220+
// 2. Search PATH for the binary
221+
pathEnv := os.Getenv("PATH")
222+
if pathEnv != "" {
223+
for _, dir := range filepath.SplitList(pathEnv) {
224+
candidate := filepath.Join(dir, basename)
225+
if !isExecutable(candidate) {
226+
continue
227+
}
228+
229+
// Skip if it's ourselves
230+
isSelf, err := isSelfBinary(candidate)
231+
if err != nil {
232+
// If we can't determine, skip this candidate
233+
continue
234+
}
235+
if isSelf {
236+
continue
237+
}
238+
239+
// Found a valid binary that's not us
240+
absPath, err := filepath.Abs(candidate)
241+
if err != nil {
242+
continue
243+
}
244+
return absPath, nil
245+
}
246+
}
247+
248+
// 3. Try common locations
249+
commonLocations := []string{
250+
filepath.Join("/bin", basename),
251+
filepath.Join("/usr/bin", basename),
252+
filepath.Join("/usr/local/bin", basename),
253+
}
254+
255+
for _, location := range commonLocations {
256+
if !isExecutable(location) {
257+
continue
258+
}
259+
260+
// Skip if it's ourselves
261+
isSelf, err := isSelfBinary(location)
262+
if err != nil || isSelf {
263+
continue
264+
}
265+
266+
return location, nil
267+
}
268+
269+
// 4. Not found - return helpful error
270+
return "", fmt.Errorf("could not resolve shell binary for %q\n\nTried:\n - PROCESS_TRACER_SHELL_BINARY environment variable (not set)\n - Searching PATH for %q (excluding process-tracer)\n - /bin/%s (not found)\n - /usr/bin/%s (not found)\n - /usr/local/bin/%s (not found)\n\nTo fix:\n 1. Install %s: apt-get install %s (or equivalent)\n 2. Or set: export PROCESS_TRACER_SHELL_BINARY=/path/to/%s",
271+
basename, basename, basename, basename, basename, basename, basename, basename)
272+
}
273+
274+
// parseSymlinkMode handles configuration when invoked via symlink.
275+
// Resolves the actual shell binary and passes all args to it.
276+
func parseSymlinkMode(args []string, envCfg *EnvConfig) (*Config, error) {
277+
symlinkName := args[0]
278+
279+
// Resolve shell binary
280+
shellBinary, err := resolveShellBinary(symlinkName, envCfg.ShellBinary)
281+
if err != nil {
282+
return nil, fmt.Errorf("failed to resolve shell binary for %q: %w",
283+
filepath.Base(symlinkName), err)
284+
}
285+
286+
// Parse custom attributes from environment
287+
customAttrs, err := ParseAttributeString(envCfg.Attributes)
288+
if err != nil {
289+
return nil, err
290+
}
291+
292+
return &Config{
293+
Command: shellBinary,
294+
Args: args[1:], // ALL args go to shell
295+
TraceID: envCfg.TraceID,
296+
ParentID: envCfg.ParentID,
297+
CustomAttributes: customAttrs,
298+
}, nil
299+
}
300+
301+
// ParseArgs is the main entry point for configuration parsing.
302+
// It handles both symlink mode (env vars only) and direct mode (CLI + env vars).
303+
// Maintained for backward compatibility - new code should use ParseConfig.
42304
func ParseArgs(args []string, licenseText string, version, commit, buildDate string) (*Config, error) {
305+
return ParseConfig(args, licenseText, version, commit, buildDate)
306+
}
307+
308+
// ParseConfig is the unified configuration parser.
309+
// It handles both symlink mode (env vars only) and direct mode (CLI + env vars).
310+
func ParseConfig(args []string, licenseText string, version, commit, buildDate string) (*Config, error) {
311+
// Parse environment configuration
312+
envCfg, err := ParseEnvConfig()
313+
if err != nil {
314+
return nil, fmt.Errorf("failed to parse environment config: %w", err)
315+
}
316+
317+
// Detect invocation mode
318+
isSymlinkMode, err := detectSymlinkMode(envCfg.Mode)
319+
if err != nil {
320+
return nil, err
321+
}
322+
323+
if isSymlinkMode {
324+
return parseSymlinkMode(args, envCfg)
325+
}
326+
327+
return parseDirectMode(args, envCfg, licenseText, version, commit, buildDate)
328+
}
329+
330+
// parseDirectMode handles configuration when invoked directly.
331+
// Parses CLI arguments and merges with environment variables (CLI overrides env).
332+
func parseDirectMode(args []string, envCfg *EnvConfig, licenseText string, version, commit, buildDate string) (*Config, error) {
43333
var traceID string
44334
var parentID string
45335
var customAttrs []CustomAttribute
@@ -129,16 +419,34 @@ func ParseArgs(args []string, licenseText string, version, commit, buildDate str
129419
return fmt.Errorf("no command specified\n\nUse '--' to separate options from the command to trace.\n\nExample: process-tracer -a env_name='env[\"ENVIRONMENT\"]' -- bash -c 'echo hello'")
130420
}
131421

132-
// Store config for return
133-
// Note: traceID and parentID remain empty strings if not provided by user
134-
// They will be treated as expressions to evaluate, or trigger auto-generation
135-
// if empty
422+
// Merge with environment config (CLI overrides)
423+
finalTraceID := traceID
424+
if finalTraceID == "" {
425+
finalTraceID = envCfg.TraceID
426+
}
427+
428+
finalParentID := parentID
429+
if finalParentID == "" {
430+
finalParentID = envCfg.ParentID
431+
}
432+
433+
// Parse env attributes and prepend (CLI attributes take precedence)
434+
var finalAttrs []CustomAttribute
435+
if envCfg.Attributes != "" {
436+
envAttrs, err := ParseAttributeString(envCfg.Attributes)
437+
if err != nil {
438+
return err
439+
}
440+
finalAttrs = append(finalAttrs, envAttrs...)
441+
}
442+
finalAttrs = append(finalAttrs, customAttrs...)
443+
136444
resultCfg = &Config{
137445
Command: cmdArgs[0],
138446
Args: cmdArgs[1:],
139-
TraceID: traceID,
140-
ParentID: parentID,
141-
CustomAttributes: customAttrs,
447+
TraceID: finalTraceID,
448+
ParentID: finalParentID,
449+
CustomAttributes: finalAttrs,
142450
}
143451

144452
return nil

0 commit comments

Comments
 (0)