Skip to content

feat(PN-104): Entities per user — N entities under one unix user - #105

Open
dnacenta wants to merge 8 commits into
mainfrom
feat/PN-104-entities-per-user
Open

feat(PN-104): Entities per user — N entities under one unix user#105
dnacenta wants to merge 8 commits into
mainfrom
feat/PN-104-entities-per-user

Conversation

@dnacenta

@dnacenta dnacenta commented Sep 8, 2026

Copy link
Copy Markdown
Owner

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 spawned claude -p with 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 claude from inside the entity with RECALL_ECHO_HOME set, so Claude Code loads the entity's own CLAUDE.md, .claude/settings.json hooks 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/.claude any more. repair retires the old user-level symlinks. init/up/repair refuse 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

  • Provider runs inside the entity — ClaudeCodeProvider carries a required entity_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-local Claude Code bootstrap + repair — <entity>/.claude/settings.json merged per event, <entity>/.claude/rules/recall-echo.md entity-relative, <entity>/memory/.recall-echo.toml, relative memory/conversations link; verify/repair detect and remove legacy ~/.claude links into this entity and print user-level hooks lacking a root
  • Root guard — init, up, repair refuse euid 0; PULSE_NULL_ALLOW_ROOT=1 for CI
  • Flat layout + honoured ports — discovery accepts a directory whose children carry pulse-null.toml (legacy entities/ still recognised); init creates <base>/<name>; multi-entity boot binds the configured host/port, falling back to the registry port with a warning
  • README — "Multiple Entities" section: layout, one-unit-per-entity runtime, trust domain, root guard
  • Quality audit — wizards default new entities to the first free port from 3200 (stable configured ports), the TUI wizard refuses to overwrite an existing entity and surfaces bootstrap problems, entity names are validated, single-entity detection walks up like Config::load, duplicate names are skipped, verify compares hooks semantically; project-scope .claude/rules loading verified empirically (planted passphrase returned by claude -p, bare-dir control returned NONE)
  • Audit hardening (two security rounds) — an entity with no usable [security] secret stays 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_EXCL temp file with random name and 0600, fsync + rename); reads open once with O_NOFOLLOW and 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 characters

Verification

  • cargo fmt --check && cargo clippy --all-targets -- -D warnings -A dead_code && cargo test — 1027 tests, 32 new
  • Smoke as pulse with a throwaway HOME and a fake claude that logs its cwd and env: two entities bootstrapped under ~/pulse-null/{alpha,beta}, each settings.json carrying its own root, $HOME/.claude never created; /chat spawned claude with cwd = entity and RECALL_ECHO_HOME = entity; up from the install root bound 3977 + 3978 as configured; with 3978 held, beta fell back to 3202 with a warning naming both ports
  • Root guard smoke against the debug binary: init and up refuse before creating anything; the override reaches the wizard
  • Deployed /usr/local/bin/recall-echo is 4.3.0 and exits 0 with no transcript, so the old || true on archive-session is dropped deliberately

Spec drift

None. Two plan-level notes: increments 2 and 3 landed as one commit (shared file); the --system-prompt-file capability 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/entity to /home/pulse/pulse-null/echo with a compat symlink, point echo.service at the new WorkingDirectory, run pulse-null repair as 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 (shared create_entity for both wizards, declarative bootstrap item table, init through the discovery resolver, versioned generated files, repair --retire-legacy prompt, delegating .recall-echo.toml to recall-echo init).

🤖 Generated with Claude Code

https://claude.ai/code/session_01BXbs5SGKfcXviAULoGDyX7

dnacenta and others added 8 commits September 8, 2026 18:51
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
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.

Entities per user — N entities under one unix user

1 participant