mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
a8af168304
* fix(cli): session liveness reads unknown, not dead, across a PID namespace (BUG-4090) The session registry's verdict probed the owner pid with kill(pid, 0), and a pid means something only inside one PID namespace. From a sandbox or container shell (bwrap --unshare-pid) every host pid is ESRCH, so `pad session list` read every live host session dead, and `pad session prune` - and the prune that follows every `pad session register` - deleted their records. Records now carry pid_ns, the namespace the owner pid was captured in, stamped only when that pid is signalable there. The reader compares: across a boundary the pid is not probed and a missing socket is not evidence, so the verdict is unknown (kept by prune, untrusted by the arm gate). Only a socket answering with a different identity stays dead. A row without pid_ns read from a non-init namespace keeps a positive pid probe and turns a negative one into unknown. Headless arm-state files carry pid_ns too. Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC * fix(cli): judge a session's socket by its mount namespace (BUG-4090 r1) The socket half of the liveness verdict now keys on the mount namespace the socket was seen in (mnt_ns, stamped only beside a socket stat'ed with its identity). A reader with another filesystem view gets unknown for a missing or different node; a row without mnt_ns keeps the old rule, so a container reading its own rows (or the Nix sandbox) behaves as before. The unverifiable prose now says "from here (this platform, or another namespace)", and the bwrap test gains a leg for a pre-fix, unstamped row. Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC * fix(cli): a foreign-namespace reader never manufactures dead; chat direction stamps mnt_ns (BUG-4090 r2) An unstamped row read from a non-init PID namespace now reads alive only on a start-token match: a mismatch, a vanished pid or a zombie at that number describes the reader's namespace, not the owner's, and is unknown. The chat-direction file, the third socket-identity writer, records its socket's mount namespace like the registry and the arm file. The help and CLAUDE.md no longer overclaim for rows written before this version. Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC * fix(cli): an unidentifiable namespace is a possible boundary on Linux (BUG-4090 r3) A reader that cannot read /proc/self/ns/{pid,mnt} (a sandbox with no procfs) no longer treats an unstamped row as same-namespace: its ESRCH or missing socket is unknown, not dead. A Linux writer in that position stamps pid_ns/mnt_ns with the sentinel 1, which no reader's namespace matches, so every reader treats the row as crossed. Off Linux, where namespaces do not exist, nothing changes. Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC * fix(cli): an empty start token is not a match for a foreign-namespace reader (BUG-4090 r4) An unstamped row with no proc_start, read from a non-init or unreadable PID namespace, read alive whenever any process answered at its number there. That can honour a headless arm or disarm on an unrelated process. With nothing to match, such a reader now says unknown; the bare-probe residual stays for same-namespace and init readers. Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC * test: namespace-correct session fixtures for runs outside the init pid namespace (BUG-4090) The Nix check runs go test in its build sandbox's PID namespace, where an unstamped registry row is judged by BUG-4090's foreign-reader rule. Three cmd/pad tests hand-wrote such rows and expected init-namespace verdicts. The two dead-pid fixtures are now stamped with the test's own pid_ns, so they mean "same namespace, dead pid" anywhere; the v1 legacy-row table assertion follows the test's namespace. internal/cli gains a seam test that drives the legacy row's verdict through init, non-init and unreadable readers on every runner. Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC
112 lines
3.2 KiB
Go
112 lines
3.2 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// TASK-4041, DOC-4036: this independent declaration is socket-scoped only.
|
|
// Reuse the owner stamps and bind direction to one explicit arm generation.
|
|
func chatDirectionPath() (string, error) {
|
|
socket := os.Getenv("CLAUDE_CODE_MESSAGING_SOCKET")
|
|
if socket == "" {
|
|
return "", fmt.Errorf("chat direction requires a session messaging socket")
|
|
}
|
|
dir, err := SessionsDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(dir, "chat-direction-dir-"+shortHash(socket)+".json"), nil
|
|
}
|
|
|
|
func WriteChatDirection() (string, error) {
|
|
path, err := chatDirectionPath()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
arm, _, err := readArmState()
|
|
if err != nil || arm == nil || arm.Generation == "" || SessionArmState() != LocalArmOn {
|
|
return "", fmt.Errorf("chat direction requires a fresh pad session arm")
|
|
}
|
|
socket := os.Getenv("CLAUDE_CODE_MESSAGING_SOCKET")
|
|
info, err := os.Stat(socket)
|
|
if err != nil {
|
|
return "", fmt.Errorf("chat session socket: %w", err)
|
|
}
|
|
cwd, _ := os.Getwd()
|
|
st := ArmState{Armed: true, Generation: arm.Generation, PID: os.Getpid(), Socket: socket, Cwd: cwd, StartedAt: time.Now().UTC().Format(time.RFC3339), SocketMtimeUnixNano: info.ModTime().UnixNano()}
|
|
if ino, dev, ok := statIdentity(info); ok {
|
|
st.SocketIno, st.SocketDev = ino, dev
|
|
// The socket's mount namespace, under the same visibility rule as
|
|
// the registry and the arm file (BUG-4090 r2): a reader with another
|
|
// view of the filesystem must not reap this on a missing node.
|
|
st.MountNamespace = namespaceStamp(mountNamespaceFn)
|
|
}
|
|
data, err := json.MarshalIndent(st, "", " ")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := atomicWriteFile(path, data, 0600); err != nil {
|
|
return "", err
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func readChatDirection(path string) (*ArmState, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var st ArmState
|
|
if err := json.Unmarshal(b, &st); err != nil {
|
|
return nil, err
|
|
}
|
|
return &st, nil
|
|
}
|
|
|
|
// ChatDirectionOn reads fresh on every call and requires explicit delivery consent.
|
|
func ChatDirectionOn() bool {
|
|
path, err := chatDirectionPath()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
st, err := readChatDirection(path)
|
|
if err != nil || !armStateWellFormed(st) || !st.Armed || st.Socket != os.Getenv("CLAUDE_CODE_MESSAGING_SOCKET") {
|
|
return false
|
|
}
|
|
switch armStateOwnerVerdict(st) {
|
|
case LivenessDead:
|
|
reapOwnedArmFile(path, func() (*ArmState, error) { return readChatDirection(path) })
|
|
return false
|
|
case LivenessAlive:
|
|
if SessionArmState() != LocalArmOn {
|
|
return false
|
|
}
|
|
arm, _, err := readArmState()
|
|
return err == nil && arm != nil && st.Generation != "" && st.Generation == arm.Generation
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func ClearChatDirection() error {
|
|
path, err := chatDirectionPath()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("could not remove chat direction file %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func VerifyChatDirectionHeld() error {
|
|
if !ChatDirectionOn() {
|
|
return fmt.Errorf("chat direction did not take; run pad session arm for delivery and retry in a live messaging session")
|
|
}
|
|
return nil
|
|
}
|