feat(PN-104): Entities per user — N entities under one unix user - #105
Open
dnacenta wants to merge 8 commits into
Open
feat(PN-104): Entities per user — N entities under one unix user#105dnacenta wants to merge 8 commits into
dnacenta wants to merge 8 commits into
Conversation
ClaudeCodeProvider carries a required entity_root. Every claude subprocess now runs with the entity as cwd and RECALL_ECHO_HOME set to it, so Claude Code loads the entity's own CLAUDE.md / .claude/ hooks and rules, and recall-echo resolves the entity's memory — independent of the daemon's cwd and of $HOME/.claude. The provider factories take the root explicitly instead of re-deriving it from the process cwd, which was wrong whenever one process served several entities. Refs: #104 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUzunvWZtVAnUG4U9KYuq8
… repair The wizard no longer touches $HOME/.claude. Each entity gets its own .claude/settings.json (recall-echo hooks carrying the entity root explicitly, merged per event so foreign hooks survive and ours appear exactly once), .claude/rules/recall-echo.md with entity-relative paths, memory/.recall-echo.toml where recall-echo actually reads it, and a relative memory/conversations link so the tree survives a move. The TUI wizard now runs the same bootstrap. Startup verify is per-entity and also reports leftover user-level symlinks into this entity; repair removes those and prints (never edits) user-level hooks lacking a root. The `|| true` guard on archive-session is gone — recall-echo 4.3 exits zero without a transcript. Increments 2 and 3 share one file, so they land as one commit. Refs: #104 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUzunvWZtVAnUG4U9KYuq8
An entity created or started by root ends up root-owned, and Claude Code rejects --dangerously-skip-permissions under root, so a claude-code entity can never reach its provider (seen live 2026-09-03). Both commands now refuse before touching the filesystem and say why; PULSE_NULL_ALLOW_ROOT=1 overrides for CI and smoke runs. Refs: #104 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUzunvWZtVAnUG4U9KYuq8
Entities are direct children of their home (~/pulse-null/<name>): init creates them there and discovery recognises a directory whose children carry pulse-null.toml, with the older entities/ subdirectory still accepted. In multi-entity boot each entity binds the host and port from its own config; when the port is taken it falls back to the registry's next free port and warns, naming both. README documents the layout, the one-unit-per-entity runtime, and the root guard. Refs: #104 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUzunvWZtVAnUG4U9KYuq8
Multi-entity boot binds the entity's configured host, but pulse-null.toml is entity-writable data: with no [security] secret set, a non-loopback host would put an unauthenticated /chat on the network. bind_host now falls back to 127.0.0.1 in that case and says so. The port probe binds the same host the listener will (SEC-013), so a conflict on another interface is no longer mistaken for a free port. Refs: #104 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUzunvWZtVAnUG4U9KYuq8
…s, quoted binary, anchored hook match repair now refuses to run as root like init and up. Every bootstrap write goes through write_regular_file (refuses symlinks, temp+rename) and every read through read_small_file (regular files only, 1 MiB cap), so a planted link cannot redirect a config write. Hook commands quote the recall-echo binary as well as the root and only accept a regular file as the binary (SEC-004); the recall-echo.toml path is TOML-escaped (SEC-005); the hook matcher is anchored to the first two tokens and strips at hook level so sibling hooks survive (SEC-008); dangling legacy links are left alone (SEC-009); a non-UTF-8 root is refused rather than mangled (SEC-011); repair output scrubs control characters (SEC-012); a serialisation failure is reported instead of truncating settings.json (SEC-014). Discovery ignores symlinked children and directories not owned by the running user (SEC-006). README states that entities under one unix user share a trust domain (SEC-007). Refs: #104 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUzunvWZtVAnUG4U9KYuq8
…uote-aware hook matching The symlink refusal only looked at the final path component. Now write_regular_file canonicalises the parent and requires it to resolve inside the entity, creates its temp file O_EXCL under a random name with mode 0600 (or the mode of the file it replaces), fsyncs, and cleans up on error; ensure() stops beneath a parent it refused, so a symlinked .claude or memory cannot redirect anything. read_small_file opens once with O_NOFOLLOW and reads through the handle. Hook self-recognition parses single quotes (including the '\'' escape) so a binary path with whitespace no longer duplicates hooks on every repair. A blank [security] secret counts as none, for the bind rule and for auth; the single-entity server applies the same loopback rule as multi-entity boot. Discovery ownership uses lstat. Display of bootstrap items scrubs control and bidi characters for every caller. Refs: #104 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUzunvWZtVAnUG4U9KYuq8
…validation, walk-up single-entity detection Both wizards now default a new entity's port to the first one from 3200 that no sibling claims (discovery::suggest_port, read leniently from each sibling's pulse-null.toml), so configured ports are stable instead of every entity starting at 3100 and falling back by boot order. The TUI wizard refuses to overwrite an existing entity, like the CLI wizard already did, and both validate the entity name before it becomes a directory. The TUI shows bootstrap items that need attention on its Done screen instead of discarding them. Single-entity detection walks up to the nearest pulse-null.toml, as Config::load does, so `up` from inside an entity's subdirectory runs that entity rather than every sibling. Duplicate entity names are skipped with a warning instead of shadowing each other in the registry. verify compares hooks semantically, so a foreign hook ordered after ours no longer reads as stale. A `consume` followed by a shell operator no longer counts as carrying a root. The test-only entity_root accessor is gated to tests — it was dead code under scripts/gate.sh, which is the real CI gate; the README now names the script instead of a looser command. Project-scope .claude/rules loading was verified empirically before keeping it: `claude -p` from a directory with a planted rule returned the planted passphrase; a bare-directory control did not. Refs: #104 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXbs5SGKfcXviAULoGDyX7
This was referenced Sep 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #104
Summary
pulse-null assumed one entity per unix user: the init wizard symlinked
$HOME/.claude/{ARCHIVE.md,EPHEMERAL.md,memories}into a single entity and wrote user-level recall-echo hooks with no entity root, while the claude-code provider spawnedclaude -pwith no working directory and no env. A second entity under the same user collided on~/.claude, which is what broke the Synth attempt on 2026-09-03 (run as root on top of that).This makes the entity directory the unit of Claude Code configuration. The provider runs
claudefrom inside the entity withRECALL_ECHO_HOMEset, so Claude Code loads the entity's ownCLAUDE.md,.claude/settings.jsonhooks and.claude/rules/, and recall-echo reads that entity's memory. The wizard (CLI and TUI) writes those files inside the entity, hooks carry an absolute--entity-root, and nothing touches$HOME/.claudeany more.repairretires the old user-level symlinks.init/up/repairrefuse to run as root. Entities live flat under an install root (~/pulse-null/<name>), and multi-entity boot honours each entity's configured host and port.Spec:
pulse-vault/pulse-null/specs/in-progress/entities-per-user-spec.md. Prerequisite for PN-91 (Synth on the Grok CLI).Increments
ClaudeCodeProvidercarries a requiredentity_root; every spawn sets cwd +RECALL_ECHO_HOME; factories take the root instead of re-deriving it from the process cwd (which was wrong for any process serving several entities, including the isolation marker)<entity>/.claude/settings.jsonmerged per event,<entity>/.claude/rules/recall-echo.mdentity-relative,<entity>/memory/.recall-echo.toml, relativememory/conversationslink;verify/repairdetect and remove legacy~/.claudelinks into this entity and print user-level hooks lacking a rootinit,up,repairrefuse euid 0;PULSE_NULL_ALLOW_ROOT=1for CIpulse-null.toml(legacyentities/still recognised);initcreates<base>/<name>; multi-entity boot binds the configured host/port, falling back to the registry port with a warningConfig::load, duplicate names are skipped,verifycompares hooks semantically; project-scope.claude/rulesloading verified empirically (planted passphrase returned byclaude -p, bare-dir control returned NONE)[security] secretstays on loopback in both single- and multi-entity mode; bootstrap writes never follow a symlink at any component (parent canonicalised and contained in the entity,O_EXCLtemp file with random name and 0600, fsync + rename); reads open once withO_NOFOLLOWand are capped; quoted binary in hook commands with quote-aware self-recognition; TOML-escaped path; anchored, hook-level matching that keeps sibling hooks; discovery ignores symlinked or foreign-owned children; printed paths are scrubbed of control/bidi charactersVerification
cargo fmt --check && cargo clippy --all-targets -- -D warnings -A dead_code && cargo test— 1027 tests, 32 newpulsewith a throwawayHOMEand a fakeclaudethat logs its cwd and env: two entities bootstrapped under~/pulse-null/{alpha,beta}, eachsettings.jsoncarrying its own root,$HOME/.claudenever created;/chatspawned claude with cwd = entity andRECALL_ECHO_HOME= entity;upfrom the install root bound 3977 + 3978 as configured; with 3978 held, beta fell back to 3202 with a warning naming both portsinitanduprefuse before creating anything; the override reaches the wizard/usr/local/bin/recall-echois 4.3.0 and exits 0 with no transcript, so the old|| trueonarchive-sessionis dropped deliberatelySpec drift
None. Two plan-level notes: increments 2 and 3 landed as one commit (shared file); the
--system-prompt-filecapability probe keeps no cwd because it exits during argv parsing.After merge
Deploy via
/update-pulse, then the ops phase from the spec: move/home/pulse/entityto/home/pulse/pulse-null/echowith a compat symlink, pointecho.serviceat the newWorkingDirectory, runpulse-null repairas pulse, strip the user-level hooks it reports, then create Synth as pulse.Follow-ups (not in this PR): plugin routes sit outside the auth layer (pre-existing); concurrent entity boot in multi-entity mode; recall-echo MCP registration without a baked
--entity-root; quality-audit refactors (sharedcreate_entityfor both wizards, declarative bootstrap item table,initthrough the discovery resolver, versioned generated files,repair --retire-legacyprompt, delegating.recall-echo.tomltorecall-echo init).🤖 Generated with Claude Code
https://claude.ai/code/session_01BXbs5SGKfcXviAULoGDyX7