socketpost

package
v0.20.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 4, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package socketpost is the inbox socket client of plan 6.7 and A.2: it writes one frame into a Claude Code session's `CLAUDE_CODE_MESSAGING_SOCKET` as the two NDJSON lines the harness reads — an auth line carrying the session's messaging token, then a user line carrying the content — and closes.

What success means (4.9, E0-9): EXACTLY "written and closed without error". The socket answers nothing, and an explicit native `crossSessionInbound: refuse` drops the post with no signal to either side, so nothing stronger is observable and nothing stronger is claimed; `injected` is this and only this. Note too that a write "succeeds" once the kernel has buffered it: a peer that never reads shows up only when the buffer is full, which a small frame never is.

What runs before the dial (U-19): the path must be absolute, no longer than the platform's sockaddr_un can carry, and Lstat must show a socket — not a symlink — owned by this uid with mode 0600. The stat function is injectable so tests can exercise the foreign-uid row without a second user. A refused path is never dialled.

What is never here: the token in an error, a log line or a file. Post takes it in a Target whose String, GoString and LogValue redact it, and the only place it is written is the auth line on the socket.

Index

Constants

View Source
const (
	// TypeAuth is the `type` of the first line.
	TypeAuth = "auth"
	// TypeUser is the `type` of the second line.
	TypeUser = "user"
	// RoleUser is the `message.role` of the second line.
	RoleUser = "user"
)

The socket protocol's fixed strings (A.2).

View Source
const (
	// DefaultDialTimeout bounds the connect.
	DefaultDialTimeout = 5 * time.Second
	// DefaultWriteTimeout bounds the two writes together.
	DefaultWriteTimeout = 5 * time.Second
)

The deadlines of 6.7: connect and write are each bounded at 5 s.

View Source
const (
	ReasonEmpty        = "empty"
	ReasonRelative     = "relative"
	ReasonPathTooLong  = "path_too_long"
	ReasonMissing      = "missing"
	ReasonStat         = "stat"
	ReasonSymlink      = "symlink"
	ReasonNotSocket    = "not_socket"
	ReasonMode         = "mode"
	ReasonForeignUID   = "foreign_uid"
	ReasonOwnerUnknown = "owner_unknown"
)

The reasons a pre-check reports in Error.Reason.

View Source
const MaxSocketPath = len(syscall.RawSockaddrUnix{}.Path) - 1

MaxSocketPath is the longest socket path the platform's sockaddr_un can carry, terminating NUL excluded: 103 bytes on darwin, 107 on linux. It is derived from the kernel structure the standard library dials with, so it is exactly the length Go's own dial would refuse with EINVAL; a longer path is refused here with a clear reason instead (6.7: "reported as not injected with a clear log line, never a crash").

View Source
const Network = "unix"

Network is the address family of the inbox socket.

Variables

View Source
var (
	ErrPrecheck   = errors.New("socketpost: pre-check refused the socket")
	ErrSocketGone = errors.New("socketpost: socket is gone")
	ErrTimeout    = errors.New("socketpost: timed out")
	ErrWrite      = errors.New("socketpost: connection or write failed")
)

The classification P3-5's watcher acts on (plan 6.7, U-20). Every failure Post returns is a *Error whose Kind selects one of the four sentinels below, so callers switch with errors.Is and never parse text:

  • ErrPrecheck: the path was refused before any dial (U-19). Nothing was connected; the socket is not this user's 0600 socket, or the path cannot be dialled at all. Log it and stop posting to that path.
  • ErrSocketGone: nothing is there — ENOENT at the pre-check or on dial, or ECONNREFUSED on dial. Re-read the registry for a new path (E0-5: `claude` never re-creates a socket it lost, so this is not a liveness signal, only "this path is dead").
  • ErrTimeout: the dial or the write hit its deadline, or the caller's context ended. Not injected; back off and retry.
  • ErrWrite: the connection or a write failed some other way (EPIPE, ECONNRESET, a refused non-gone dial, a close error). Not injected; back off and retry.

The wrapped error is the transport's own (a *net.OpError carrying the path) and never the content or the token: nothing this package returns or logs contains the token, and the tests grep for it.

Functions

func Post

func Post(ctx context.Context, target Target, content string, opts Options) error

Post writes content to the target's inbox socket and closes.

Sequence: the pre-checks of precheck (no dial on refusal); a dial with net.Dialer{Timeout}.DialContext; one write deadline covering both lines (the earlier of now+WriteTimeout and the context's deadline; a context cancelled after the dial does not interrupt a write in flight, the deadline bounds it); the auth line, then the user line, each serialised by encoding/json/v2 through one protocol.LineWriter — the codec escapes \n and \r inside strings, so content cannot produce a second physical line (U-17), while U+2028/U+2029 stay raw and valid; then Close. Nothing is read: the socket answers nothing.

A nil return means exactly "written and closed without error" (4.9). Every non-nil return is a *Error classified as ErrPrecheck, ErrSocketGone, ErrTimeout or ErrWrite, and never contains the token.

Types

type Error

type Error struct {
	// Kind selects the sentinel.
	Kind Kind
	// Reason is a fixed, value-free token naming the check or step that
	// failed (e.g. "symlink", "mode", "foreign_uid", "path_too_long",
	// "missing", "dial", "write_timeout", "epipe", "close").
	Reason string
	// Err is the underlying error, if any; never carries the token.
	Err error
}

An Error is one classified failure of Post.

func (*Error) Error

func (e *Error) Error() string

Error implements error: the sentinel's text, the reason, then the underlying error.

func (*Error) Is

func (e *Error) Is(target error) bool

Is matches the sentinel of the error's kind.

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap exposes the underlying error, so errors.Is(err, syscall.ENOENT) and friends see through the classification.

type Kind

type Kind int

A Kind is the classification of one failure; each maps to one sentinel.

const (
	KindPrecheck Kind = iota + 1
	KindSocketGone
	KindTimeout
	KindWrite
)

The four kinds, in the order the post proceeds.

func (Kind) Sentinel

func (k Kind) Sentinel() error

Sentinel returns the sentinel error errors.Is matches for the kind.

func (Kind) String

func (k Kind) String() string

String names the kind for logs.

type Options

type Options struct {
	// Stat is the pre-check's stat function; nil means os.Lstat.
	Stat func(string) (fs.FileInfo, error)
	// UID is the uid the socket must be owned by; nil means os.Getuid().
	UID *int
	// DialTimeout bounds the connect; zero means DefaultDialTimeout.
	DialTimeout time.Duration
	// WriteTimeout bounds the writes; zero means DefaultWriteTimeout.
	WriteTimeout time.Duration
	// Logger receives one debug line on success and one warn line on a
	// refusal or failure, scalar attributes only; nil discards them.
	Logger *slog.Logger
}

Options are the injectable side effects of Post. The zero value is the production configuration.

type Target

type Target struct {
	// Path is the absolute socket path.
	Path string
	// Token is the session's messaging token. It goes on the auth line
	// and nowhere else.
	Token string
}

A Target is one session's inbox: the socket path from the hook's environment or the registry, and the messaging token from the hook's environment only.

func (Target) GoString

func (t Target) GoString() string

GoString redacts too, so %#v is safe.

func (Target) LogValue

func (t Target) LogValue() slog.Value

LogValue renders only the path, so a Target handed to slog never carries the token whatever the handler.

func (Target) String

func (t Target) String() string

String renders the target with the token redacted, so a stray %v or %s cannot leak it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL