Files
clide/ptyc/README.md
T
jpmschweitzerandClaude 0412a2284a scaffold ptyc — C PTY-spawn helper (libc only)
One-shot helper that opens a PTY, forks a child, execvp's the given
argv with the slave as stdin/stdout/stderr, and hands the master fd
back to the caller over a unix socket via SCM_RIGHTS. Wire contract
is stdin JSON → stdout JSON + fd transfer per D-005; socket fd
defaults to 3 with PTYC_SOCK_FD override for language runtimes whose
subprocess machinery shuffles the low fd numbers.

Minimal JSON parser (no deps) scoped to the exact accepted shape.
Exec-failure pipe (CLOEXEC) reports child-side errors back without
zombies. Window size applied via TIOCSWINSZ before fork; child
becomes session leader + makes the slave its controlling TTY.

Root Makefile PTYX_PRESENT typo fixed → PTYC_PRESENT, and a
ptyc-test target added alongside ptyc-build / ptyc-clean. Eight
smoke tests pass (happy path, env replacement, cwd, window size,
bad argv, type errors, exec failure, unknown keys).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 22:27:20 +02:00

128 lines
4.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ptyc
Small POSIX helper that spawns a child process under a PTY and hands
the master fd back to its caller. Language-agnostic; usable from any
program that can fork a subprocess and receive a file descriptor over a
unix socket.
Clide uses it for every PTY it owns (terminal panes, Claude sessions,
tmux wrappers, LSP servers, debug adapters). See
[`D-005`](../decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer)
for the architectural rationale; ptyc is a peer of
[`pql`](https://github.com/postmeridiem/pql), not a clide subsystem.
## Build
```sh
make # produces bin/ptyc
make test # runs test_ptyc.sh against the built binary
make clean
```
No third-party dependencies. `CC`, `CFLAGS`, and `LDFLAGS` are
overrideable in the usual way.
## Wire contract
ptyc is a one-shot helper. The caller:
1. Creates a `socketpair(AF_UNIX, SOCK_STREAM, 0)`.
2. Launches `ptyc` as a subprocess, passing one end of the socket to
the child as **file descriptor 3** (the default) or whatever fd is
given in the `PTYC_SOCK_FD` environment variable. The other end of
the socket stays with the caller. The env-var override exists
because some language runtimes (Python's `subprocess` with
`stdout=PIPE`, for example) shuffle their own pipe fds through the
low numbers and it's cheaper for the caller to pick a higher fd
than to dup2 it down.
3. Writes the request as **JSON on stdin** and closes stdin (EOF
signals end of request).
4. Receives the master PTY fd over the socket via **`SCM_RIGHTS`**
ancillary data (with a single-byte `'x'` payload so the receiver
knows when to `recvmsg`).
5. Reads the success response from stdout (single line of JSON) and
reaps the exited `ptyc` process.
### Request
JSON object on stdin. All fields optional except `argv`.
```json
{
"argv": ["bash", "-l"],
"cwd": "/home/me/work",
"env": {"TERM": "xterm-256color", "LANG": "en_US.UTF-8"},
"cols": 80,
"rows": 24
}
```
- `argv` — required, non-empty array of strings. `argv[0]` is resolved
via `PATH`.
- `cwd` — optional. If omitted, the child inherits ptyc's cwd.
- `env` — optional object. If present, the child's environment is
**replaced** with exactly the keys given (ptyc does `clearenv()` and
then `putenv` per entry). If absent, the child inherits ptyc's
environment. This is a deliberate choice: the daemon is expected to
build the env it wants, not rely on a merge.
- `cols`, `rows` — optional. Default `80` × `24`. Applied via
`TIOCSWINSZ` before fork.
### Success response (stdout)
```json
{"ok":true,"pid":12345}
```
One line, trailing newline. The master PTY fd is already on the socket
by the time stdout is written. `pid` is the spawned child's PID — the
caller is responsible for `waitpid`'ing it when appropriate.
### Error response (stderr)
```json
{"ok":false,"error":"exec: No such file or directory","errno":2}
```
Written on stderr. No fd is sent. ptyc exits with a non-zero code.
### Exit codes
| Code | Meaning |
|------|---------|
| `0` | Success — fd sent, success response on stdout. |
| `1` | Bad request — JSON parse error, missing `argv`, bad field values. |
| `2` | Syscall failed — fork, exec, PTY open, `sendmsg`, etc. Check `errno` in the response. |
## Limits
Compile-time caps, deliberately small:
- `MAX_ARGV` = 64 entries
- `MAX_ENV` = 256 entries
- `MAX_INPUT` = 64 KiB request size
These are far above what any reasonable pane invocation needs; if you
hit them you're holding ptyc wrong. Edit the `#define`s in `ptyc.c` and
rebuild.
## Security notes
- The JSON parser is scoped to the shape above. It rejects anything
else. Strings support the standard `\"` `\\` `\/` `\b` `\f` `\n`
`\r` `\t` escapes and ASCII-range `\uXXXX`. Non-ASCII Unicode escapes
(and surrogate pairs) are rejected — the daemon is expected to emit
raw UTF-8 bytes.
- Input is trusted (daemon is local, same user). ptyc does not sanitise
argv or env beyond format-level checks — if the daemon asks ptyc to
exec `rm`, ptyc execs `rm`.
- ptyc does not `setuid` or `setgid`. It runs as the invoking user.
## Session persistence
ptyc is stateless and one-shot. Session persistence (survive app
restart) is the **caller's** concern. Clide achieves it by spawning
ptyc with `tmux new-session -A -s <name> -- <cmd>` for Claude panes;
tmux handles the persistence layer and ptyc just spawns tmux. See
`D-041` (Claude panes — one primary per repo, tmux-backed).