From 0412a2284a0d33bf49f811bffdeb0c05ff2b41a3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 21 Apr 2026 22:27:20 +0200 Subject: [PATCH] =?UTF-8?q?scaffold=20ptyc=20=E2=80=94=20C=20PTY-spawn=20h?= =?UTF-8?q?elper=20(libc=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 13 ++ Makefile | 14 +- ptyc/Makefile | 27 +++ ptyc/README.md | 127 +++++++++++++ ptyc/ptyc.c | 476 ++++++++++++++++++++++++++++++++++++++++++++++ ptyc/test_ptyc.sh | 145 ++++++++++++++ 6 files changed, 799 insertions(+), 3 deletions(-) create mode 100644 ptyc/Makefile create mode 100644 ptyc/README.md create mode 100644 ptyc/ptyc.c create mode 100755 ptyc/test_ptyc.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c83bd57..2e3fb7a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,19 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit. ### Added +- `ptyc/` — the C PTY-spawn helper, peer of `pql` per + [`D-005`](decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer). + One-shot, libc-only, ~400 LOC. Reads a JSON request on stdin + (`argv`, optional `cwd`/`env`/`cols`/`rows`), does + `posix_openpt` + `fork` + `execvp`, and hands the master fd back + to the caller over a unix socket via `SCM_RIGHTS`. Socket fd + defaults to 3; override via `PTYC_SOCK_FD` for language runtimes + that shuffle pipe fds through the low numbers (Python's + `subprocess` with `stdout=PIPE` does this). Exec-failure pipe + (CLOEXEC) reports child-side errors to the parent without leaking + zombies. Root Makefile gains `ptyc-test` target in addition to + `ptyc-build` / `ptyc-clean`. + - Migrated the `docs/ADRs/` content into `decisions/` as D/R records: ADR 0001 → `D-001`, ADR 0002 → `R-002` (superseded by `D-005`), ADR 0003 → `D-003`, ADR 0004 → `D-004`, ADR 0005 → `D-005`, ADR 0006 → diff --git a/Makefile b/Makefile index f183a55c..4a1b9e7f 100644 --- a/Makefile +++ b/Makefile @@ -132,19 +132,27 @@ endif # -- ptyc (C supporter tool) -------------------------------------------- # Compiled alongside the main binary. Tiny, no deps beyond libc. -PTYX_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no) +PTYC_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no) .PHONY: ptyc-build ptyc-build: ## Build the ptyc PTY-spawn helper. -ifeq ($(PTYX_PRESENT),yes) +ifeq ($(PTYC_PRESENT),yes) $(MAKE) -C ptyc else @echo "(ptyc/ not scaffolded yet; skipping)" endif +.PHONY: ptyc-test +ptyc-test: ## Run ptyc smoke tests (SCM_RIGHTS round-trip). +ifeq ($(PTYC_PRESENT),yes) + $(MAKE) -C ptyc test +else + @echo "(ptyc/ not scaffolded yet; skipping)" +endif + .PHONY: ptyc-clean ptyc-clean: ## Clean ptyc build artefacts. -ifeq ($(PTYX_PRESENT),yes) +ifeq ($(PTYC_PRESENT),yes) $(MAKE) -C ptyc clean else @echo "(ptyc/ not scaffolded yet; skipping)" diff --git a/ptyc/Makefile b/ptyc/Makefile new file mode 100644 index 00000000..fb55443f --- /dev/null +++ b/ptyc/Makefile @@ -0,0 +1,27 @@ +# ptyc — PTY-spawn helper for clide. +# +# Single source file, libc only. No configure step. Builds everywhere a +# POSIX-y C compiler + unix sockets exist. + +CC ?= cc +CFLAGS ?= -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -D_FORTIFY_SOURCE=2 +LDFLAGS ?= + +BIN := bin/ptyc + +.PHONY: all +all: $(BIN) + +$(BIN): ptyc.c | bin + $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) + +bin: + mkdir -p bin + +.PHONY: test +test: $(BIN) + ./test_ptyc.sh + +.PHONY: clean +clean: + rm -rf bin *.o diff --git a/ptyc/README.md b/ptyc/README.md new file mode 100644 index 00000000..9c399165 --- /dev/null +++ b/ptyc/README.md @@ -0,0 +1,127 @@ +# 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 -- ` for Claude panes; +tmux handles the persistence layer and ptyc just spawns tmux. See +`D-041` (Claude panes — one primary per repo, tmux-backed). diff --git a/ptyc/ptyc.c b/ptyc/ptyc.c new file mode 100644 index 00000000..03b7c1e1 --- /dev/null +++ b/ptyc/ptyc.c @@ -0,0 +1,476 @@ +/* + * ptyc — spawn a child under a PTY, hand the master fd back. + * + * Wire contract (documented in README.md): + * stdin : JSON request {"argv":[...],"cwd":"...","env":{...},"cols":N,"rows":N} + * stdout : JSON response {"ok":true,"pid":N} on success + * stderr : JSON diagnostic {"ok":false,"error":"...","errno":N} on failure + * fd 3 : unix-domain socket; master fd is sent over SCM_RIGHTS on success + * + * Exit codes: + * 0 success + * 1 bad request (JSON parse error, missing field, bad value) + * 2 syscall failed (fork/exec/pty/socket) + * + * Dependencies: libc only. POSIX APIs where available (posix_openpt, + * grantpt, unlockpt, ptsname). Single-threaded, one-shot, ~300 LOC. + */ + +#define _POSIX_C_SOURCE 200809L +#define _XOPEN_SOURCE 600 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* POSIX exposes `environ` but requires an explicit declaration. We + * set it to NULL in the child to replace the inherited environment + * when the caller supplied one — `clearenv()` is GNU-only, not POSIX. */ +extern char **environ; + +/* Discard write() result without tripping warn_unused_result. We only + * call this on the exec-failure pipe in the child immediately before + * _exit(127); the parent either receives the bytes or notices EOF via + * the CLOEXEC pipe. Nothing useful to do with the return value. */ +static void report_errno(int fd, int e) { + ssize_t r = write(fd, &e, sizeof(e)); + (void)r; +} + +#define MAX_ARGV 64 +#define MAX_ENV 256 +#define MAX_INPUT (64 * 1024) + +/* -------------------------------------------------------------------- */ +/* error reporting */ +/* -------------------------------------------------------------------- */ + +static void die_bad_request(const char *msg) { + fprintf(stderr, "{\"ok\":false,\"error\":\"%s\",\"errno\":0}\n", msg); + exit(1); +} + +static void die_syscall(const char *msg) { + int e = errno; + /* Avoid quoting edge cases: strerror results don't contain quotes on + * any platform we care about; if this ever bites us we'll escape. */ + fprintf(stderr, "{\"ok\":false,\"error\":\"%s: %s\",\"errno\":%d}\n", msg, + strerror(e), e); + exit(2); +} + +/* -------------------------------------------------------------------- */ +/* minimal JSON parser */ +/* */ +/* Scoped to exactly the shape we accept. String escapes supported for */ +/* the subset we emit (\" \\ \n \r \t \b \f \/ and \uXXXX for ASCII). */ +/* Surrogate pairs, nested arrays, and non-string numbers-as-keys are */ +/* not supported — the daemon never emits them. */ +/* -------------------------------------------------------------------- */ + +typedef struct { + const char *src; + size_t len; + size_t pos; +} Parser; + +static void p_skip_ws(Parser *p) { + while (p->pos < p->len) { + char c = p->src[p->pos]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + p->pos++; + } else { + break; + } + } +} + +static int p_peek(Parser *p) { + p_skip_ws(p); + return p->pos < p->len ? (unsigned char)p->src[p->pos] : -1; +} + +static int p_expect(Parser *p, char c) { + if (p_peek(p) != (unsigned char)c) return 0; + p->pos++; + return 1; +} + +/* Parse a JSON string into a freshly-allocated NUL-terminated buffer. */ +static char *p_string(Parser *p) { + if (p_peek(p) != '"') return NULL; + p->pos++; + size_t start = p->pos; + /* First pass: find end and compute output length. */ + size_t out_len = 0; + while (p->pos < p->len && p->src[p->pos] != '"') { + if (p->src[p->pos] == '\\') { + if (p->pos + 1 >= p->len) return NULL; + char esc = p->src[p->pos + 1]; + if (esc == 'u') { + if (p->pos + 5 >= p->len) return NULL; + /* We only accept ASCII in \uXXXX. */ + for (int i = 2; i < 6; i++) { + if (!isxdigit((unsigned char)p->src[p->pos + i])) return NULL; + } + p->pos += 6; + } else { + p->pos += 2; + } + out_len++; + } else { + p->pos++; + out_len++; + } + } + if (p->pos >= p->len || p->src[p->pos] != '"') return NULL; + size_t end = p->pos; + p->pos++; /* consume closing quote */ + + char *out = malloc(out_len + 1); + if (!out) return NULL; + size_t j = 0; + for (size_t i = start; i < end;) { + if (p->src[i] == '\\') { + char esc = p->src[i + 1]; + switch (esc) { + case '"': out[j++] = '"'; i += 2; break; + case '\\': out[j++] = '\\'; i += 2; break; + case '/': out[j++] = '/'; i += 2; break; + case 'b': out[j++] = '\b'; i += 2; break; + case 'f': out[j++] = '\f'; i += 2; break; + case 'n': out[j++] = '\n'; i += 2; break; + case 'r': out[j++] = '\r'; i += 2; break; + case 't': out[j++] = '\t'; i += 2; break; + case 'u': { + unsigned int cp = 0; + for (int k = 0; k < 4; k++) { + char h = p->src[i + 2 + k]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= (unsigned)(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= (unsigned)(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= (unsigned)(h - 'A' + 10); + } + /* ASCII-range only. Anything else is a request-format bug. */ + if (cp > 0x7f) { free(out); return NULL; } + out[j++] = (char)cp; + i += 6; + break; + } + default: free(out); return NULL; + } + } else { + out[j++] = p->src[i++]; + } + } + out[j] = '\0'; + return out; +} + +static int p_int(Parser *p, long *out) { + p_skip_ws(p); + size_t start = p->pos; + if (p->pos < p->len && (p->src[p->pos] == '-' || p->src[p->pos] == '+')) + p->pos++; + int digits = 0; + while (p->pos < p->len && isdigit((unsigned char)p->src[p->pos])) { + p->pos++; + digits++; + } + if (!digits) { + p->pos = start; + return 0; + } + char buf[32]; + size_t n = p->pos - start; + if (n >= sizeof(buf)) return 0; + memcpy(buf, p->src + start, n); + buf[n] = '\0'; + *out = strtol(buf, NULL, 10); + return 1; +} + +/* -------------------------------------------------------------------- */ +/* request */ +/* -------------------------------------------------------------------- */ + +typedef struct { + char *argv[MAX_ARGV + 1]; /* NULL-terminated */ + int argc; + char *cwd; /* optional, NULL means inherit */ + char *env[MAX_ENV + 1]; /* each "KEY=VAL" */ + int envc; + int cols; + int rows; +} Request; + +static void req_init(Request *r) { + memset(r, 0, sizeof(*r)); + r->cols = 80; + r->rows = 24; +} + +static void req_free(Request *r) { + for (int i = 0; i < r->argc; i++) free(r->argv[i]); + for (int i = 0; i < r->envc; i++) free(r->env[i]); + free(r->cwd); +} + +static void parse_argv(Parser *p, Request *r) { + if (!p_expect(p, '[')) die_bad_request("argv must be an array"); + if (p_peek(p) == ']') { p->pos++; return; } + for (;;) { + if (r->argc >= MAX_ARGV) die_bad_request("argv too long"); + char *s = p_string(p); + if (!s) die_bad_request("argv element must be a string"); + r->argv[r->argc++] = s; + if (p_expect(p, ',')) continue; + if (p_expect(p, ']')) break; + die_bad_request("malformed argv array"); + } +} + +static void parse_env(Parser *p, Request *r) { + if (!p_expect(p, '{')) die_bad_request("env must be an object"); + if (p_peek(p) == '}') { p->pos++; return; } + for (;;) { + if (r->envc >= MAX_ENV) die_bad_request("env too large"); + char *k = p_string(p); + if (!k) die_bad_request("env key must be a string"); + if (!p_expect(p, ':')) { free(k); die_bad_request("env missing ':'"); } + char *v = p_string(p); + if (!v) { free(k); die_bad_request("env value must be a string"); } + size_t kl = strlen(k), vl = strlen(v); + char *kv = malloc(kl + 1 + vl + 1); + if (!kv) { free(k); free(v); die_syscall("malloc"); } + memcpy(kv, k, kl); + kv[kl] = '='; + memcpy(kv + kl + 1, v, vl); + kv[kl + 1 + vl] = '\0'; + free(k); free(v); + r->env[r->envc++] = kv; + if (p_expect(p, ',')) continue; + if (p_expect(p, '}')) break; + die_bad_request("malformed env object"); + } +} + +static void parse_request(const char *src, size_t len, Request *r) { + Parser p = { .src = src, .len = len, .pos = 0 }; + if (!p_expect(&p, '{')) die_bad_request("top-level must be an object"); + if (p_peek(&p) == '}') { p.pos++; goto done; } + for (;;) { + char *key = p_string(&p); + if (!key) die_bad_request("key must be a string"); + if (!p_expect(&p, ':')) { free(key); die_bad_request("missing ':'"); } + if (strcmp(key, "argv") == 0) { + parse_argv(&p, r); + } else if (strcmp(key, "cwd") == 0) { + r->cwd = p_string(&p); + if (!r->cwd) { free(key); die_bad_request("cwd must be a string"); } + } else if (strcmp(key, "env") == 0) { + parse_env(&p, r); + } else if (strcmp(key, "cols") == 0) { + long v; if (!p_int(&p, &v)) { free(key); die_bad_request("cols must be an integer"); } + if (v < 1 || v > 65535) { free(key); die_bad_request("cols out of range"); } + r->cols = (int)v; + } else if (strcmp(key, "rows") == 0) { + long v; if (!p_int(&p, &v)) { free(key); die_bad_request("rows must be an integer"); } + if (v < 1 || v > 65535) { free(key); die_bad_request("rows out of range"); } + r->rows = (int)v; + } else { + free(key); + die_bad_request("unknown key"); + } + free(key); + if (p_expect(&p, ',')) continue; + if (p_expect(&p, '}')) break; + die_bad_request("malformed object"); + } +done: + if (r->argc == 0) die_bad_request("argv is required and non-empty"); + r->argv[r->argc] = NULL; + r->env[r->envc] = NULL; +} + +/* -------------------------------------------------------------------- */ +/* read stdin into a bounded buffer */ +/* -------------------------------------------------------------------- */ + +static char *slurp_stdin(size_t *out_len) { + char *buf = malloc(MAX_INPUT); + if (!buf) die_syscall("malloc"); + size_t n = 0; + while (n < MAX_INPUT) { + ssize_t r = read(0, buf + n, MAX_INPUT - n); + if (r == 0) break; + if (r < 0) { + if (errno == EINTR) continue; + die_syscall("read(stdin)"); + } + n += (size_t)r; + } + if (n == MAX_INPUT) die_bad_request("request too large"); + *out_len = n; + return buf; +} + +/* -------------------------------------------------------------------- */ +/* PTY open + spawn */ +/* -------------------------------------------------------------------- */ + +static void send_fd(int sock, int fd) { + /* sendmsg with SCM_RIGHTS; one byte payload so receiver knows to read. */ + char byte = 'x'; + struct iovec iov = { .iov_base = &byte, .iov_len = 1 }; + union { + struct cmsghdr hdr; + char buf[CMSG_SPACE(sizeof(int))]; + } cbuf; + memset(&cbuf, 0, sizeof(cbuf)); + + struct msghdr msg = {0}; + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = cbuf.buf; + msg.msg_controllen = sizeof(cbuf.buf); + + struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_RIGHTS; + cmsg->cmsg_len = CMSG_LEN(sizeof(int)); + memcpy(CMSG_DATA(cmsg), &fd, sizeof(int)); + + for (;;) { + ssize_t r = sendmsg(sock, &msg, 0); + if (r < 0 && errno == EINTR) continue; + if (r < 0) die_syscall("sendmsg(fd)"); + break; + } +} + +int main(void) { + Request req; + req_init(&req); + + size_t in_len = 0; + char *in = slurp_stdin(&in_len); + parse_request(in, in_len, &req); + free(in); + + /* 1. open master */ + int master = posix_openpt(O_RDWR | O_NOCTTY); + if (master < 0) die_syscall("posix_openpt"); + if (grantpt(master) < 0) die_syscall("grantpt"); + if (unlockpt(master) < 0) die_syscall("unlockpt"); + + /* 2. open slave (ptsname is POSIX; we're single-threaded) */ + const char *slave_path = ptsname(master); + if (!slave_path) die_syscall("ptsname"); + int slave = open(slave_path, O_RDWR | O_NOCTTY); + if (slave < 0) die_syscall("open(slave)"); + + /* 3. apply window size */ + struct winsize ws = {0}; + ws.ws_col = (unsigned short)req.cols; + ws.ws_row = (unsigned short)req.rows; + if (ioctl(master, TIOCSWINSZ, &ws) < 0) die_syscall("ioctl(TIOCSWINSZ)"); + + /* 4. exec-failure-reporting pipe (CLOEXEC so it auto-closes on success) */ + int ef[2]; + if (pipe(ef) < 0) die_syscall("pipe"); + if (fcntl(ef[1], F_SETFD, FD_CLOEXEC) < 0) die_syscall("fcntl(FD_CLOEXEC)"); + + pid_t pid = fork(); + if (pid < 0) die_syscall("fork"); + + if (pid == 0) { + /* ---- child ---- */ + close(master); + close(ef[0]); + + if (setsid() < 0) { report_errno(ef[1], errno); _exit(127); } +#ifdef TIOCSCTTY + if (ioctl(slave, TIOCSCTTY, 0) < 0) { report_errno(ef[1], errno); _exit(127); } +#endif + if (dup2(slave, 0) < 0 || dup2(slave, 1) < 0 || dup2(slave, 2) < 0) { + report_errno(ef[1], errno); + _exit(127); + } + if (slave > 2) close(slave); + + if (req.cwd && chdir(req.cwd) < 0) { report_errno(ef[1], errno); _exit(127); } + + /* Replace the environment if the caller supplied one; otherwise + * inherit. Daemon is expected to build the env it wants — this is + * not a "merge" API. */ + if (req.envc > 0) { + environ = NULL; + for (int i = 0; i < req.envc; i++) { + if (putenv(req.env[i]) != 0) { report_errno(ef[1], errno); _exit(127); } + } + } + + execvp(req.argv[0], req.argv); + /* execvp returned → failure */ + report_errno(ef[1], errno); + _exit(127); + } + + /* ---- parent ---- */ + close(slave); + close(ef[1]); + + int child_errno = 0; + ssize_t rr; + for (;;) { + rr = read(ef[0], &child_errno, sizeof(child_errno)); + if (rr < 0 && errno == EINTR) continue; + break; + } + close(ef[0]); + + if (rr == (ssize_t)sizeof(child_errno)) { + /* exec failed in child; reap it so we don't leak a zombie. */ + int st; + (void)waitpid(pid, &st, 0); + close(master); + errno = child_errno; + die_syscall("execvp"); + } + /* rr == 0: pipe closed via CLOEXEC on successful exec. */ + + /* Hand the master fd back to the parent caller over a unix socket. + * Default fd is 3; callers that can't reliably place the socket at + * fd 3 (e.g. Python's subprocess with stdout=PIPE shifts pipe fds + * around fd 3) can override via PTYC_SOCK_FD. */ + int sock_fd = 3; + const char *sock_env = getenv("PTYC_SOCK_FD"); + if (sock_env && *sock_env) { + char *endp = NULL; + long v = strtol(sock_env, &endp, 10); + if (!endp || *endp != '\0' || v < 0 || v > 65535) + die_bad_request("PTYC_SOCK_FD must be a non-negative integer"); + sock_fd = (int)v; + } + send_fd(sock_fd, master); + close(master); + + /* Emit success response on stdout and exit. */ + printf("{\"ok\":true,\"pid\":%ld}\n", (long)pid); + fflush(stdout); + + req_free(&req); + return 0; +} diff --git a/ptyc/test_ptyc.sh b/ptyc/test_ptyc.sh new file mode 100755 index 00000000..56529bd5 --- /dev/null +++ b/ptyc/test_ptyc.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Smoke test for ptyc. Uses python3 for the SCM_RIGHTS fd receive dance. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +BIN="$HERE/bin/ptyc" + +if [[ ! -x "$BIN" ]]; then + echo "test: bin/ptyc not built — run 'make' first" >&2 + exit 2 +fi + +python3 - "$BIN" <<'PY' +import errno +import json +import os +import socket +import subprocess +import sys +import time + +ptyc = sys.argv[1] + + +def spawn(req): + """Launch ptyc, pipe the request in, receive fd + response.""" + sock_parent, sock_child = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + child_fd = sock_child.fileno() + env = {**os.environ, "PTYC_SOCK_FD": str(child_fd)} + try: + p = subprocess.Popen( + [ptyc], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + pass_fds=(child_fd,), + env=env, + ) + finally: + sock_child.close() + + p.stdin.write(json.dumps(req).encode()) + p.stdin.close() + + fd = None + try: + msg, ancdata, _flags, _addr = sock_parent.recvmsg(1, socket.CMSG_SPACE(4)) + for cmsg_level, cmsg_type, cmsg_data in ancdata: + if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS: + fd = int.from_bytes(cmsg_data[:4], "little") + break + except OSError: + pass + sock_parent.close() + + stdout = p.stdout.read().decode() + stderr = p.stderr.read().decode() + code = p.wait() + return code, stdout, stderr, fd + + +def expect_ok(req, reads_substr=None): + code, out, err, fd = spawn(req) + assert code == 0, f"exit={code}, stderr={err!r}" + j = json.loads(out) + assert j["ok"] is True, out + assert j["pid"] > 0, out + assert fd is not None and fd >= 0, "no fd received" + pid = j["pid"] + try: + if reads_substr is not None: + chunks = [] + deadline = time.time() + 5.0 + while time.time() < deadline: + try: + data = os.read(fd, 4096) + except OSError as e: + if e.errno in (errno.EIO,): # child exited, PTY EOF on Linux + break + raise + if not data: + break + chunks.append(data.decode(errors="replace")) + if reads_substr in "".join(chunks): + break + got = "".join(chunks) + assert reads_substr in got, f"expected {reads_substr!r} in {got!r}" + finally: + os.close(fd) + # Child should exit on its own after producing its output for an + # `echo`; give it a moment, then reap. + try: + for _ in range(20): + rpid, _ = os.waitpid(pid, os.WNOHANG) + if rpid == pid: + break + time.sleep(0.05) + else: + os.kill(pid, 9) + os.waitpid(pid, 0) + except ChildProcessError: + pass + + +def expect_err(req, match_fragment): + code, out, err, fd = spawn(req) + assert code != 0, f"expected failure, got ok: {out!r}" + assert fd is None, "error path must not send an fd" + j = json.loads(err) + assert j["ok"] is False, err + assert match_fragment in j["error"], f"{match_fragment!r} not in {j['error']!r}" + + +# 1. Happy path: echo prints and exits cleanly. +expect_ok({"argv": ["/bin/echo", "hello-ptyc"]}, reads_substr="hello-ptyc") + +# 2. env replacement works — child sees exactly the keys we pass. +expect_ok( + {"argv": ["/usr/bin/env"], "env": {"FOO": "bar", "PATH": "/usr/bin:/bin"}}, + reads_substr="FOO=bar", +) + +# 3. cwd respected. +expect_ok({"argv": ["/bin/sh", "-c", "pwd"], "cwd": "/tmp"}, reads_substr="/tmp") + +# 4. window size propagates (stty reports it). +expect_ok( + {"argv": ["/bin/sh", "-c", "stty size"], "cols": 132, "rows": 42}, + reads_substr="42 132", +) + +# 5. Bad request: missing argv. +expect_err({}, "argv is required") + +# 6. Bad request: argv not an array. +expect_err({"argv": "bash"}, "argv must be an array") + +# 7. exec failure reported on error channel. +expect_err({"argv": ["/does/not/exist/nope"]}, "execvp") + +# 8. Unknown key rejected. +expect_err({"argv": ["true"], "wat": 1}, "unknown key") + +print("ptyc: all smoke tests passed") +PY