Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,15 @@ The runtime descriptor lives in a per-invocation
temporary path and is removed on exit, so concurrent `pitot dev` sessions never
collide.

On Windows, `pitot dev` recognizes standard npm Node launchers for Codex and
Qwen, preserving their package entrypoint and arguments without invoking a
command shell. Custom `.cmd` wrappers are rejected with guidance to use
`-- node ABSOLUTE_ENTRYPOINT ARGS`. Prefer `-- COMMAND ARGS` for paths or
arguments containing spaces; `--exec` splits its value on whitespace.
When output is captured, an agent descendant that keeps an output pipe open
can cause an incomplete-output error after a two-second drain limit. This
releases the dev runtime; it does not terminate arbitrary descendant processes.

**3. Swap the agent.** The same project — the same Controller and the same
fragment — works with any other supported host whose hook is wired. Change only
`--host` and the agent command:
Expand Down
136 changes: 136 additions & 0 deletions cmd/pitot/dev_command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package main

import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)

const devWaitDelay = 2 * time.Second

func devCommand(host, program string, args []string) (string, []string, error) {
if runtime.GOOS != "windows" || (host != "codex" && host != "qwen") {
return program, args, nil
}
resolved, err := exec.LookPath(program)
if err != nil {
return "", nil, err
}
if !strings.EqualFold(filepath.Ext(resolved), ".cmd") {
return resolved, args, nil
}
node, entry, err := npmDevCommand(host, resolved)
if err != nil {
return "", nil, fmt.Errorf("unsupported %s launcher %q: %w; use -- node ABSOLUTE_ENTRYPOINT ARGS for an explicit Node invocation", host, resolved, err)
}
return node, append([]string{entry}, args...), nil
}

// npmDevCommand recognizes the complete plain-Node wrapper emitted by npm's
// cmd-shim. Unknown wrappers may have extra setup or flags, so never bypass them.
func npmDevCommand(host, shim string) (string, string, error) {
packages := map[string]string{"codex": "@openai/codex", "qwen": "@qwen-code/qwen-code"}
pkgName, ok := packages[host]
if !ok || !strings.EqualFold(filepath.Base(shim), host+".cmd") {
return "", "", fmt.Errorf("unrecognized command name")
}
shim, err := filepath.Abs(shim)
if err != nil {
return "", "", err
}
wrapper, err := readRegularFile(shim)
if err != nil {
return "", "", err
}
dir := filepath.Dir(shim)
packageRoot := filepath.Join(dir, "node_modules", filepath.FromSlash(pkgName))
if strings.EqualFold(filepath.Base(dir), ".bin") && strings.EqualFold(filepath.Base(filepath.Dir(dir)), "node_modules") {
packageRoot = filepath.Join(filepath.Dir(dir), filepath.FromSlash(pkgName))
}
data, err := readRegularFile(filepath.Join(packageRoot, "package.json"))
if err != nil {
return "", "", err
}
var manifest struct {
Name string `json:"name"`
Bin map[string]string `json:"bin"`
}
if err := json.Unmarshal(data, &manifest); err != nil {
return "", "", fmt.Errorf("invalid package manifest: %w", err)
}
target := manifest.Bin[host]
expectedTarget := map[string]string{"codex": "bin/codex.js", "qwen": "cli-entry.js"}[host]
if manifest.Name != pkgName || target != expectedTarget || !filepath.IsLocal(target) {
return "", "", fmt.Errorf("package name or bin target does not match %s", pkgName)
}
entry := filepath.Join(packageRoot, target)
switch strings.ToLower(filepath.Ext(entry)) {
case ".js", ".mjs", ".cjs":
default:
return "", "", fmt.Errorf("bin target must be a JavaScript file")
}
realRoot, err := filepath.EvalSymlinks(packageRoot)
if err != nil {
return "", "", err
}
realEntry, err := filepath.EvalSymlinks(entry)
if err != nil {
return "", "", err
}
inside, err := filepath.Rel(realRoot, realEntry)
if err != nil || !filepath.IsLocal(inside) {
return "", "", fmt.Errorf("bin target escapes package")
}
if err := requireRegularFile(entry); err != nil {
return "", "", err
}
relative, err := filepath.Rel(dir, entry)
if err != nil {
return "", "", err
}
relative = strings.ReplaceAll(relative, "/", `\`)
if strings.ReplaceAll(string(wrapper), "\r\n", "\n") != npmNodeShim(relative) {
return "", "", fmt.Errorf("wrapper is not a supported standard npm Node shim")
}
node := filepath.Join(dir, "node.exe")
if info, err := os.Stat(node); err == nil {
if !info.Mode().IsRegular() {
return "", "", fmt.Errorf("sibling node.exe is not a regular file")
}
} else if os.IsNotExist(err) {
node, err = exec.LookPath("node.exe")
if err != nil {
return "", "", err
}
} else {
return "", "", err
}
return node, entry, nil
}

func requireRegularFile(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("%s is not a regular file", path)
}
return nil
}

func readRegularFile(path string) ([]byte, error) {
if err := requireRegularFile(path); err != nil {
return nil, err
}
return os.ReadFile(path)
}

func npmNodeShim(target string) string {
return "@ECHO off\nGOTO start\n:find_dp0\nSET dp0=%~dp0\nEXIT /b\n:start\nSETLOCAL\nCALL :find_dp0\n\nIF EXIST \"%dp0%\\node.exe\" (\n SET \"_prog=%dp0%\\node.exe\"\n) ELSE (\n SET \"_prog=node\"\n SET PATHEXT=%PATHEXT:;.JS;=;%\n)\n\nendLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & \"%_prog%\" \"%dp0%\\" + target + "\" %*\n"
}
198 changes: 198 additions & 0 deletions cmd/pitot/dev_command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package main

import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)

// This fixture was generated by npm cmd-shim 8.0.0 from a plain Node shebang.
// Keep it independent of the production template matcher.
func writeNpmDevFixture(t *testing.T, host string, local bool) (shim, entry string) {
t.Helper()
base := filepath.Join(t.TempDir(), "tools with spaces 你好")
name, target := "@qwen-code/qwen-code", "cli-entry.js"
if host == "codex" {
name, target = "@openai/codex", "bin/codex.js"
}
dir := base
if local {
dir = filepath.Join(base, "node_modules", ".bin")
}
entry = filepath.Join(base, "node_modules", filepath.FromSlash(name), filepath.FromSlash(target))
mustWriteDevFile(t, entry, []byte("#!/usr/bin/env node\n"))
manifest, _ := json.Marshal(map[string]any{"name": name, "bin": map[string]string{host: target}})
mustWriteDevFile(t, filepath.Join(base, "node_modules", filepath.FromSlash(name), "package.json"), manifest)
template, err := os.ReadFile("testdata/npm-node.cmd")
if err != nil {
t.Fatal(err)
}
rel, err := filepath.Rel(dir, entry)
if err != nil {
t.Fatal(err)
}
wrapper := strings.ReplaceAll(string(template), `node_modules\@qwen-code\qwen-code\cli-entry.js`, strings.ReplaceAll(rel, "/", `\`))
shim = filepath.Join(dir, host+".cmd")
mustWriteDevFile(t, shim, []byte(wrapper))
mustWriteDevFile(t, filepath.Join(dir, "node.exe"), []byte("owned Node fixture"))
return shim, entry
}
func mustWriteDevFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0700); err != nil {
t.Fatal(err)
}
}

func TestNpmDevCommand(t *testing.T) {
for _, host := range []string{"codex", "qwen"} {
for _, local := range []bool{false, true} {
t.Run(host+map[bool]string{false: "-global", true: "-local"}[local], func(t *testing.T) {
shim, entry := writeNpmDevFixture(t, host, local)
node, got, err := npmDevCommand(host, shim)
if err != nil || got != entry || node != filepath.Join(filepath.Dir(shim), "node.exe") {
t.Fatalf("resolve: %q %q %v", node, got, err)
}
wrapper := readFileString(t, shim)
mustWriteDevFile(t, shim, []byte(strings.ReplaceAll(wrapper, "\r\n", "\n")))
if _, _, err := npmDevCommand(host, shim); err != nil {
t.Fatalf("LF: %v", err)
}
})
}
}
}

func TestNpmDevRejectsUnknownLaunchers(t *testing.T) {
for _, mode := range []string{"changed-bin", "custom-prelude", "custom-flags", "wrong-target", "bad-json", "wrong-name", "missing-bin", "escape-bin", "missing-entry", "entry-directory", "sibling-directory", "missing-node", "unknown-name"} {
t.Run(mode, func(t *testing.T) {
shim, entry := writeNpmDevFixture(t, "qwen", false)
manifest := filepath.Join(filepath.Dir(entry), "package.json")
switch mode {
case "changed-bin":
mustWriteDevFile(t, manifest, []byte(`{"name":"@qwen-code/qwen-code","bin":{"qwen":"%TARGET%.js"}}`))
mustWriteDevFile(t, filepath.Join(filepath.Dir(entry), "%TARGET%.js"), []byte("#!/usr/bin/env node\n"))
mustWriteDevFile(t, shim, []byte(strings.ReplaceAll(readFileString(t, shim), "cli-entry.js", "%TARGET%.js")))
case "custom-prelude":
mustWriteDevFile(t, shim, []byte("SET CUSTOM=1\r\n"+readFileString(t, shim)))
case "custom-flags":
mustWriteDevFile(t, shim, []byte(strings.ReplaceAll(readFileString(t, shim), `"%_prog%" `, `"%_prog%" --trace-warnings `)))
case "wrong-target":
mustWriteDevFile(t, shim, []byte(strings.ReplaceAll(readFileString(t, shim), "cli-entry.js", "other.js")))
case "bad-json":
mustWriteDevFile(t, manifest, []byte("{"))
case "wrong-name":
mustWriteDevFile(t, manifest, []byte(`{"name":"other","bin":{"qwen":"cli-entry.js"}}`))
case "missing-bin":
mustWriteDevFile(t, manifest, []byte(`{"name":"@qwen-code/qwen-code"}`))
case "escape-bin":
mustWriteDevFile(t, manifest, []byte(`{"name":"@qwen-code/qwen-code","bin":{"qwen":"../outside.js"}}`))
case "missing-entry":
if err := os.Remove(entry); err != nil {
t.Fatal(err)
}
case "entry-directory":
if err := os.Remove(entry); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(entry, 0700); err != nil {
t.Fatal(err)
}
case "sibling-directory":
node := filepath.Join(filepath.Dir(shim), "node.exe")
if err := os.Remove(node); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(node, 0700); err != nil {
t.Fatal(err)
}
case "missing-node":
if err := os.Remove(filepath.Join(filepath.Dir(shim), "node.exe")); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", t.TempDir())
case "unknown-name":
other := filepath.Join(filepath.Dir(shim), "custom.cmd")
mustWriteDevFile(t, other, []byte(readFileString(t, shim)))
shim = other
}
if _, _, err := npmDevCommand("qwen", shim); err == nil {
t.Fatal("accepted unsupported launcher")
}
})
}
}

func TestWindowsDevNpmArgv(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("native Windows executable resolution")
}
helper := buildDevAgent(t)
binary, err := os.ReadFile(helper)
if err != nil {
t.Fatal(err)
}
for _, host := range []string{"codex", "qwen"} {
for _, local := range []bool{false, true} {
for _, sibling := range []bool{false, true} {
name := host + map[bool]string{true: "-local", false: "-global"}[local] + map[bool]string{true: "-sibling", false: "-path"}[sibling]
t.Run(name, func(t *testing.T) {
shim, entry := writeNpmDevFixture(t, host, local)
nodePath := filepath.Join(filepath.Dir(shim), "node.exe")
pathDir := t.TempDir()
pathNode := filepath.Join(pathDir, "node.exe")
if sibling {
mustWriteDevFile(t, nodePath, binary)
// A broken PATH node must not replace the shim's sibling interpreter.
mustWriteDevFile(t, pathNode, []byte("not an executable"))
} else {
if err := os.Remove(nodePath); err != nil {
t.Fatal(err)
}
mustWriteDevFile(t, pathNode, binary)
}
t.Setenv("PATH", pathDir)
t.Setenv("PITOT_TEST_NODE", "1")
t.Setenv("PITOT_TEST_MODE", "record")
seen := t.TempDir()
t.Setenv("PITOT_TEST_SEEN", seen)
args := []string{"", "a b", `say "hi"`, `C:\path with space\`, "你好", "&|<>^%!()"}
executable, argv, err := devCommand(host, shim, args)
if err != nil {
t.Fatal(err)
}
expectedNode := pathNode
if sibling {
expectedNode = nodePath
}
if executable != expectedNode || argv[0] != entry {
t.Fatalf("wrong invocation: %q %#v", executable, argv)
}
if out, err := exec.CommandContext(context.Background(), executable, argv...).CombinedOutput(); err != nil {
t.Fatalf("launch: %v %s", err, out)
}
var got []string
if err := json.Unmarshal([]byte(readFileString(t, filepath.Join(seen, "args.json"))), &got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, args) {
t.Fatalf("argv: %#v, want %#v", got, args)
}
direct, directArgs, err := devCommand(host, expectedNode, args)
if err != nil || direct != expectedNode || !reflect.DeepEqual(directArgs, args) {
t.Fatalf("direct executable changed: %q %#v %v", direct, directArgs, err)
}
})
}
}
}
}
Loading
Loading