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
11 changes: 11 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Keeps the actions, pinned by commit, and the Go modules up to date.
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: gomod
directory: /
schedule:
interval: weekly
28 changes: 28 additions & 0 deletions .github/workflows/govulncheck.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Vulnerabilities

# Known vulnerabilities in the code dbgp calls, in its dependencies and in
# the standard library of the newest Go, which release builds use. Weekly
# too, since new ones are found in code that has not changed.
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "0 6 * * 1"

permissions:
contents: read

jobs:
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
check-latest: true
- name: govulncheck
run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...
50 changes: 37 additions & 13 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,37 @@ on:
types: [published]

permissions:
contents: write # needed to upload release assets
contents: write # to upload release assets
id-token: write # to sign the provenance attestations
attestations: write # to store them

jobs:
release:
name: Build & attach binaries
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
# Actions are pinned by commit, which a tag is not; Dependabot updates them.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
go-version-file: go.mod
persist-credentials: false
# The newest Go, not go.mod's minimum: the binaries carry its standard
# library, so each release gets every security fix made so far.
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
check-latest: true

- name: Check for known vulnerabilities
run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...

- name: Build and package all targets
env:
TAG: ${{ github.event.release.tag_name }}
run: |
mkdir -p dist build
# Outside the checkout, so the binaries are not stamped as built
# from modified sources.
dist="$RUNNER_TEMP/dist" build="$RUNNER_TEMP/build"
mkdir -p "$dist" "$build"
ver="${TAG#v}" # strip a leading "v" if present
ldflags="-s -w -X main.version=${ver}"
# Windows-on-ARM runs x64 under emulation, so one Windows build suffices.
Expand All @@ -35,26 +49,36 @@ jobs:

echo "building $osname/$arch"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$arch" \
go build -trimpath -ldflags "$ldflags" -o "build/$bin" ./cmd/dbgp
chmod +x "build/$bin"
go build -trimpath -ldflags "$ldflags" -o "$build/$bin" ./cmd/dbgp
chmod +x "$build/$bin"

# Package into an archive so the executable bit survives download
# (raw GitHub release assets lose Unix permissions).
base="dbgp-${osname}-${arch}"
if [ "$goos" = "windows" ]; then
(cd build && zip -q "../dist/${base}.zip" "$bin")
(cd "$build" && zip -q "$dist/${base}.zip" "$bin")
else
tar -C build -czf "dist/${base}.tar.gz" "$bin"
tar -C "$build" -czf "$dist/${base}.tar.gz" "$bin"
fi
rm -f "build/$bin"
rm -f "$build/$bin"
done
# Attach the install scripts too, so releases/latest/download/install.sh
# is a stable URL that serves the released version of the script.
cp scripts/install.sh scripts/install.ps1 dist/
ls -la dist
cp scripts/install.sh scripts/install.ps1 "$dist/"
# The install scripts check downloads against these.
(cd "$dist" && sha256sum -- * > "$RUNNER_TEMP/SHA256SUMS")
mv "$RUNNER_TEMP/SHA256SUMS" "$dist/"
cat "$dist/SHA256SUMS"

# Records that each file was built here, from this commit: verify one
# with gh attestation verify FILE --repo php-debugger/dbgp-client.
- name: Attest build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-checksums: ${{ runner.temp }}/dist/SHA256SUMS

- name: Attach binaries to the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG" dist/* --clobber
run: gh release upload "$TAG" "$RUNNER_TEMP"/dist/* --clobber
30 changes: 24 additions & 6 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- name: gofmt
Expand All @@ -39,8 +41,10 @@ jobs:
matrix:
php: ['8.2', '8.3', '8.4', '8.5']
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
# Self-contained PHP interpreter with PHP Debugger compiled in, from the
Expand Down Expand Up @@ -74,7 +78,9 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: install.sh
if: runner.os != 'Windows'
run: |
Expand All @@ -93,9 +99,21 @@ jobs:
if (-not (([Environment]::GetEnvironmentVariable("Path", "Machine") -split ';') -contains $dir)) {
throw "$dir is not on the system PATH"
}
# A chosen release, to a directory of its own.
# A chosen release, to a directory of its own: it goes on the
# user PATH, not the system one, although this runs as admin.
$env:INSTALL_DIR = Join-Path $env:RUNNER_TEMP "dbgp"
$env:VERSION = "0.1.0"
./scripts/install.ps1
$got = & (Join-Path $env:INSTALL_DIR "dbgp.exe") -version
if ($got -ne "dbgp 0.1.0") { throw "installed $got, want dbgp 0.1.0" }
function OnPath($scope, $dir) { ([Environment]::GetEnvironmentVariable("Path", $scope) -split ';') -contains $dir }
if (-not (OnPath "User" $env:INSTALL_DIR)) { throw "$env:INSTALL_DIR is not on the user PATH" }
if (OnPath "Machine" $env:INSTALL_DIR) { throw "$env:INSTALL_DIR is on the system PATH" }
# A relative directory goes on the PATH as a full path.
Push-Location $env:RUNNER_TEMP
$env:INSTALL_DIR = "relative-dbgp"
& (Join-Path $env:GITHUB_WORKSPACE "scripts/install.ps1")
Pop-Location
$full = Join-Path $env:RUNNER_TEMP "relative-dbgp"
if (-not (OnPath "User" $full)) { throw "$full is not on the user PATH" }
if (OnPath "User" "relative-dbgp") { throw "the relative directory is on the user PATH" }
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,16 @@ Windows (PowerShell as administrator; installs to `%ProgramFiles%\dbgp` and adds
powershell -c "irm https://github.com/php-debugger/dbgp-client/releases/latest/download/install.ps1 | iex"
```

Set `INSTALL_DIR` to install elsewhere, and `VERSION` (e.g. `0.1.1`) to install a release other than the latest. Run the script again to update. Builds are published for Linux and macOS (amd64 and arm64) and Windows (amd64); elsewhere, install with Go:
Set `INSTALL_DIR` to install elsewhere (on Windows, it is then added to your own PATH rather than the system one), and `VERSION` (e.g. `0.1.1`) to install a release other than the latest. Run the script again to update. Builds are published for Linux and macOS (amd64 and arm64) and Windows (amd64); elsewhere, install with Go:

```sh
go install github.com/php-debugger/dbgp-client/cmd/dbgp@latest
```

`dbgp -version` shows the installed version.

The install scripts check the download against the release's `SHA256SUMS` before installing it. Each release file also has a build provenance attestation, which shows it was built by this repository's release workflow; to check one you downloaded yourself, run `gh attestation verify FILE --repo php-debugger/dbgp-client`.

To be debugged, PHP needs [PHP Debugger](https://php-debugger.dev) or Xdebug: `php -v` shows which one it has.

## Debugging with AI agents
Expand All @@ -49,9 +51,9 @@ The agent starts the PHP code itself, as a script or a web request, and debugs i

You can optionally add some options after `dbgp mcp`:

- `-no-eval` leaves out the tools that run PHP code in the debugged script (evaluating expressions and changing variables), so the agent can only look.
- `-no-eval` turns off everything that runs PHP code in the debugged script: evaluating expressions, changing variables and breakpoint conditions, so the agent can only look.
- `-map LOCAL=REMOTE` maps a directory on this machine to where PHP sees it, for PHP in a container or on a server. The agent can also add mappings itself.
- `-addr` changes the address dbgp listens on for PHP, `localhost:9003` by default.
- `-php-port` changes the port dbgp listens on for PHP, 9003 by default. dbgp listens on every address of this machine, so PHP in a container or on another machine can connect too.

By default each agent session starts its own dbgp. To keep breakpoints and sessions between agent sessions, and share them between agents, run dbgp in the background:

Expand All @@ -61,7 +63,9 @@ dbgp mcp status # is it running?
dbgp mcp stop # stop it
```

While it runs, agents that start `dbgp mcp` use it instead, with the options it was started with. After updating dbgp, restart it to use the new version.
`dbgp mcp start` also takes `-mcp-port`, the port agents reach it on, 9004 by default. It listens on this machine only (127.0.0.1).

While it runs, agents that start `dbgp mcp` use it instead, with the options it was started with. Only you can use it: `dbgp mcp` connects with a token kept in a file that other users on the machine cannot read. After updating dbgp, restart it to use the new version.

`dbgp mcp -h` lists all the options.

Expand Down
26 changes: 26 additions & 0 deletions cmd/dbgp/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"sort"

"github.com/ergochat/readline"
Expand Down Expand Up @@ -53,6 +54,11 @@ func (t *terminalReader) ReadLine(prompt string) (string, error) {
// the returned reader's Stdout redraws the prompt, so messages that arrive
// while a line is being typed do not break it. historyFile may be empty.
func newTerminalReader(historyFile string) (*terminalReader, error) {
if historyFile != "" {
if err := privateHistory(historyFile); err != nil {
return nil, err
}
}
rl, err := readline.NewFromConfig(readlineConfig(historyFile))
if err != nil {
return nil, err
Expand Down Expand Up @@ -93,6 +99,26 @@ func completer() *readline.PrefixCompleter {
return readline.NewPrefixCompleter(items...)
}

// privateHistory keeps the history file readable by the user only: it
// holds what was typed, such as code to evaluate, which may include
// secrets. readline creates the file, and replaces it when it trims it,
// asking for mode 0666, so the files this process creates are made private
// (it creates no others), and an existing file is made private too.
func privateHistory(path string) error {
privateFiles()
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if info.Mode().IsRegular() && runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
return os.Chmod(path, 0o600)
}
return nil
}

// defaultHistoryFile is ~/.dbgp_history, or "" if there is no home.
func defaultHistoryFile() string {
home, err := os.UserHomeDir()
Expand Down
11 changes: 11 additions & 0 deletions cmd/dbgp/input_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//go:build !windows

package main

import "syscall"

// privateFiles makes the files this process creates from now on readable
// by the user only, whatever mode their creator asks for.
func privateFiles() {
syscall.Umask(0o077)
}
57 changes: 57 additions & 0 deletions cmd/dbgp/input_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//go:build !windows

package main

import (
"io"
"os"
"path/filepath"
"strings"
"syscall"
"testing"

"github.com/ergochat/readline"
)

// The history file stays readable by the user only: when readline creates
// it, when it was readable by others before, and when readline replaces it
// to trim it.
func TestHistoryFileIsPrivate(t *testing.T) {
defer syscall.Umask(syscall.Umask(0o022)) // a typical umask, restored after
long := strings.Repeat("eval $secret\n", 1001)
for name, existing := range map[string]string{"created": "", "existing": "eval $secret\n", "trimmed": long} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "history")
if existing != "" {
if err := os.WriteFile(path, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
}
if err := privateHistory(path); err != nil {
t.Fatal(err)
}
cfg := readlineConfig(path)
cfg.Stdin, cfg.Stdout, cfg.Stderr = io.NopCloser(strings.NewReader("")), io.Discard, io.Discard
rl, err := readline.NewFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
if err := rl.SaveToHistory("eval $token"); err != nil {
t.Fatal(err)
}
_ = rl.Close()

info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("history file mode %o, want 600", perm)
}
data, _ := os.ReadFile(path)
if lines := strings.Count(string(data), "\n"); name == "trimmed" && lines > cfg.HistoryLimit+1 {
t.Errorf("history has %d lines: readline did not trim it", lines)
}
})
}
}
5 changes: 5 additions & 0 deletions cmd/dbgp/input_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package main

// privateFiles does nothing: files in the user's profile are private to
// the user by default.
func privateFiles() {}
Loading
Loading