Skip to content

feat(app-server): unix-socket transport + daemon/attach advertisement - #5749

Open
Hmbown wants to merge 5 commits into
mainfrom
feat/daemon-unix-socket-20260830
Open

feat(app-server): unix-socket transport + daemon/attach advertisement#5749
Hmbown wants to merge 5 commits into
mainfrom
feat/daemon-unix-socket-20260830

Conversation

@Hmbown

@Hmbown Hmbown commented Aug 30, 2026

Copy link
Copy Markdown
Owner

What

App-server unix-socket transport and daemon/attach advertisement (desktop Phase 0 foundation).

Evidence

  • Adversarial review (2026-08-30): approve; daemon spawn → socket connect → round-trip → shutdown verified; socket permissions/traversal/stale-cleanup lens clean.

Unproven

  • Workspace CI on this PR.

No-Issue: desktop Phase 0 socket listener

CodeWhale Bot and others added 5 commits August 30, 2026 11:33
…im handshake

Desktop Phase 0, socket half (backlog #36; DESKTOP-APP-BRIEF §2/§5,
CORE-PROTOCOL §5 attachment styles). `daemon_socket` serves the existing
stdio JSON-RPC protocol verbatim over a user-private unix socket:

- path resolution under the Codewhale runtime dir (explicit > $CODEWHALE_HOME/run
  > $XDG_RUNTIME_DIR/codewhale > macOS Application Support > ~/.codewhale/run),
  kernel path-length check, dir 0700, socket 0600, peer-uid check on accept;
- stale-socket cleanup on start: dead socket reclaimed, live daemon refused
  (AlreadyRunning), non-socket file never touched (NotASocket);
- `daemon/attach` claim handshake: attach vs claim, exclusive owner slot that
  frees on disconnect, bundle-skew guard via expect_daemon_version, typed
  errors -32010..-32014; only the owner may `shutdown`, enforced in the loop
  (also mid-turn) so a guest can neither stop the daemon nor interrupt turns;
- Windows: named pipe reserved, bind/run return a typed UnsupportedPlatform
  error — no silent fallback;
- run_stdio_loop gains a per-connection policy (transport label + shutdown
  authority) and a typed exit reason so the daemon knows an owner asked it to
  stop; stdio behavior is unchanged.

Integration tests bind a daemon, connect over the socket, complete the
handshake, round-trip app/* requests through the shared dispatcher, exercise
guest/owner/relaunch claim semantics, version skew, stale cleanup, and clean
shutdown (socket removed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aDEyM2a4pPZ9qqMDrP5YX
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
… socket docs

Wire the daemon socket transport into the app-server subcommand (mutually
exclusive with --stdio/--http/--mobile) and document endpoint resolution, the
daemon/attach handshake, ownership rules, and the Windows named-pipe
reservation in docs/RUNTIME_API.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aDEyM2a4pPZ9qqMDrP5YX
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
… stay warning-free

ATTACH_REQUIRED_CODE / DAEMON_ALREADY_CLAIMED_CODE / DAEMON_VERSION_SKEW_CODE
and JsonRpcError::{attach_required, daemon_already_claimed,
daemon_version_skew} are only reached from daemon_socket::platform, which is
#[cfg(unix)]. On cfg(not(unix)) rustc flags them dead, and the workspace's
`warnings = "deny"` (plus CI's -Dwarnings) turns that into a build failure on
the Windows leg. Gate the three consts and ctors on unix; the remaining daemon
errors (not_daemon_owner, already_attached) are referenced unconditionally.

Proven with a scratch crate mirroring the shape: the pre-fix shape errors
with four dead-code diagnostics under --target x86_64-pc-windows-msvc, the
gated shape checks clean. A direct app-server Windows check is still blocked
on this host by ring's build script (missing Windows C headers).

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
…den socket/CLI tests

- `capabilities` on the unix-socket transport now lists `daemon/attach`
  right after `healthz`; the stdio pin (EXPECTED_CAPABILITY_METHODS) is
  untouched and a new unit test pins the socket variant.
- AlreadyRunning wording no longer claims the live listener is a codewhale
  daemon (it may be anything bound at an explicit --socket-path).
- Integration tests: pre-attach gating proven for capabilities, thread/list
  and prompt/run; post-attach capabilities checked; a still-attached guest
  is proven to receive EOF when the owner shuts the daemon down.
- CLI: --socket conflicts with --stdio/--http/--mobile are unit-tested, and
  --socket-path without --socket is proven to be a MissingRequiredArgument.
- docs/RUNTIME_API.md: capabilities note for the socket transport and the
  updated AlreadyRunning message.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-30T20:12:05.868087Z 67aee89 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 4m 17s —— View job


Review: feat/daemon-unix-socket-20260830 (PR #5749)

  • Gather context (diff, changed files, AGENTS.md contracts)
  • Review daemon_socket.rs (new, 954 lines)
  • Review lib.rs changes (+282/-61)
  • Review cli/src/lib.rs changes
  • Review tests in tests/daemon_socket.rs
  • Post findings

Note on verification: cargo invocations required approval I could not obtain non-interactively in this session, so I could not execute cargo test -p codewhale-app-server myself. The review below is based on careful manual reading of the full diff and the new test file; the PR body's own adversarial-review evidence covers the runtime verification I couldn't reproduce.

Summary

This is a clean, well-scoped Phase-0 addition: a unix-domain-socket transport that reuses the existing stdio JSON-RPC dispatcher (run_stdio_loop / dispatch_stdio_request_with_writer) behind a daemon/attach handshake, plus an owner/guest claim model. No correctness bugs found. Good reuse discipline throughout — it pulls codewhale_home_override/user_home from codewhale-paths instead of re-deriving them (crates/app-server/src/daemon_socket.rs:141-151), and reuses crate::shutdown_signal() rather than a second signal handler (crates/app-server/src/daemon_socket.rs:823).

Correctness

  • Ownership/registry logic (ConnectionRegistry::claim/remove, ConnectionGuard::drop) is sound: claim is exclusive, releases on disconnect free the slot, and a lost claim race rolls back the just-registered connection (daemon_socket.rs:314-323, 658-693). No .await is held across the std::sync::Mutex guard, so there's no deadlock risk.
  • shutdown authorization is centralized in run_stdio_loop/handle_line_during_turn via ShutdownAuthority; the per-method dispatch (lib.rs:2205) doesn't need its own check because non-owners never reach it. Single source of truth, no risk of the two getting out of sync.
  • Stale-socket handling (clear_stale_socket) correctly distinguishes "not a socket" (refused, never deleted) from "socket with nothing listening" (reclaimed) from "socket with a live listener" (refused). SocketFileGuard cleans up the file on every exit path, including early ? returns.
  • No unwrap/expect on fallible paths in production code; the daemon's own error type covers every failure mode (RuntimeDirUnavailable, PathTooLong, NotASocket, AlreadyRunning, ProbeTimedOut, etc.) instead of falling back silently.

Minor observations (non-blocking)

  1. Self-reported identity isn't cross-checked against peer_cred(). ClientIdentity (name/pid) in daemon/attach params is trusted as-is for the owner field shown to other clients (daemon_socket.rs:658-693), even though the connection's real uid/pid is already available from stream.peer_cred(). Not a security hole — the actual authorization boundary is the kernel-verified uid check in handle_connection (daemon_socket.rs:556-571) — but a client could report a misleading name/pid in the owner-identity display. Could optionally stash peer_cred().pid() alongside the self-reported one for a trustworthy audit trail.
  2. TOCTOU between the stale-socket probe and bind() (daemon_socket.rs:522-554474): if two daemons race to start, the loser gets a raw DaemonSocketError::Io from the second bind() rather than a typed AlreadyRunning, since the file already exists by the time it binds. Cosmetically inconsistent, but no double-bind/corruption risk — this is inherent to unix-socket start-up races and likely out of scope for Phase 0.

Tests

tests/daemon_socket.rs covers the important behaviors well: pre-attach method refusal, healthz-before-attach, claim/attach roles, claim race + daemon_already_claimed, owner-disconnect-frees-slot, guest-shutdown-refused, version skew, stale-socket reclaim, and non-socket-file refusal. The one gap is cross-uid rejection (hard to exercise in CI without a second uid/setuid harness), which is a reasonable thing to leave unverified for Phase 0.

Provider/model safety, security

Not applicable to this diff — no model/provider routing or prefix-based inference here. Secret handling (auth tokens, socket permissions 0600, directory 0700, peer-uid gating) all looks correct.
· branch feat/daemon-unix-socket-20260830

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 67aee8935f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +591 to +592
let exit = run_stdio_loop(&context.state, lines, writer, policy).await;
drop(guard);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Release ownership as soon as the socket closes

When the owner disconnects while a thread/message is still running, this await retains the ConnectionGuard until the turn finishes; run_stdio_loop merely sets stdin_open = false on EOF and continues awaiting the dispatch. If the turn is stalled and emits no further output, a relaunched desktop remains locked out with daemon_already_claimed, and falling back to guest attachment also prevents it from shutting the daemon down. Release the owner claim when EOF is observed rather than only after the active turn completes.

Useful? React with 👍 / 👎.

Comment thread crates/cli/src/lib.rs
Comment on lines +4579 to +4582
let outcome = runtime.block_on(run_daemon_socket(DaemonSocketOptions {
socket_path: args.socket_path,
config_path: args.config,
}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the top-level config for socket daemons

For codewhale --config /path/custom.toml app-server --socket, the dispatcher loads the global config and even uses it for telemetry, but this branch passes only the subcommand-level args.config, which is None. The daemon therefore silently starts from the default config instead of the explicitly selected file; combine this with cli.config as the surrounding telemetry path already does.

Useful? React with 👍 / 👎.

Comment on lines +543 to +544
Ok(Err(_refused)) => {
std::fs::remove_file(path).map_err(|source| DaemonSocketError::Io {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove sockets only after a refused connection

This branch treats every UnixStream::connect error as proof that the socket is stale. Transient errors such as a saturated Unix-socket backlog (EAGAIN) can occur while a listener is still alive, so a second startup can unlink the live daemon's endpoint and then create a split-brain listener. Only ConnectionRefused should authorize stale cleanup; other errors should leave the socket untouched and be reported.

Useful? React with 👍 / 👎.

Comment on lines +2136 to +2138
"app/capabilities" => {
dispatch_stdio_app_request(state, AppRequest::Capabilities, transport).await?
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Advertise the socket transport in app capabilities

On an attached socket connection, app/capabilities is now dispatched with AppTransport::Socket, but process_app_request ignores that argument and still returns the hard-coded value "transport": "stdio+http". Socket clients using this scoped capability method therefore receive a response claiming their active transport is unsupported; include unix-socket or derive the field from the supplied transport.

Useful? React with 👍 / 👎.

Comment on lines +143 to +145
let xdg_runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")
.filter(|value| !value.is_empty())
.map(PathBuf::from);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject relative XDG runtime directories

If XDG_RUNTIME_DIR is set to a relative value, this accepts it verbatim and resolves the daemon endpoint relative to the process working directory. The CLI and desktop shell can then discover different sockets depending on where each was launched, allowing multiple unintended daemons and making attach fail despite a live process. Treat a relative XDG runtime directory as invalid or unset, consistent with the repository's absolute-path validation for global environment overrides.

Useful? React with 👍 / 👎.

Comment on lines +487 to +488
let state = build_state_with_transport(options.config_path, None, AppTransport::Socket)
.map_err(DaemonSocketError::State)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist config changes from a default socket daemon

With the documented default invocation codewhale app-server --socket, options.config_path is None, so build_state_with_transport discards the resolved default config path and persist_config later treats every successful app/config/set or app/config/unset as an in-memory-only change. The socket client receives ok: true, but the setting disappears when this long-lived daemon restarts; retain the resolved store path for the daemon so advertised configuration mutations are durable.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant