17 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
cef246a34a chore(cli): typecheck clean (10 → 0)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- broker-actions: msg-status section header used out-of-scope `id`
  variable; was a real bug (renders "message undefined…" on the JSON
  path). Fixed to use the in-scope lookupId.
- exit-codes: add IO_ERROR (10) — referenced in three places by
  platform-actions but never declared.
- types/text-import.d.ts: declare wildcard `*.md` module so Bun's
  text-import attribute used by skill.ts typechecks.
- ipc/server: cast PeerSummary/SkillSummary through unknown before
  spreading into Record<string, unknown>.
- mcp/server: typed JSON.parse for SSE events.
- bridge/daemon-route: import path with .ts → .js (esm).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:23:55 +01:00
Alejandro Gutiérrez
f013436541 chore(broker): typecheck clean (77 → 0)
paid down the broker's accumulated type debt. zero behavioral changes,
purely type-system tightening:

- broker.ts: row extraction helper for postgres-js result vs pg shape;
  findMemberByPubkey defaultGroups null-coalescing.
- env.ts: zod default ordered before transform (zod v4 ordering).
- index.ts: typed JSON.parse for the tg/token, upload-auth, file-upload,
  member patch and mesh-settings handlers; export SelfEditablePolicy
  from member-api; added bodyVersion to WSSendMessage; added the
  disconnect/kick/ban/unban/list_bans message types to WSClientMessage;
  String(key) cast for neo4j record symbol-typed keys.
- jwt.ts, paths.ts, telegram-token.ts: typed JSON.parse results.
- service-manager.ts: typed package.json + MCP JSON-RPC reader.
- telegram-bridge.ts: typed WS message handler; missing log import;
  null-tolerant BridgeRow + skip rows missing memberId/displayName;
  typed e in catch.
- types.ts: bodyVersion on WSSendMessage, manifest on WSSkillData,
  five new admin message types (kick/disconnect/ban/unban/list_bans).
- packages/db/server.ts: drizzle constructor positional args + scoped
  ts-expect-error for the namespace-bag schema generic mismatch.

apps/broker/src/types.ts will eventually want a real audit pass to
catch every WS verb and surface the orphans, but this clears the path
for 1.30.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:22:09 +01:00
Alejandro Gutiérrez
6d981976c0 refactor(cli): drop CLAUDEMESH_SESSION_PRESENCE flag
per-session presence is small and uncomplicated enough that a rollback
flag isn't load-bearing. backwards compat is already covered at the
protocol layer — older brokers reply unknown_message_type to
session_hello and the SessionBrokerClient marks itself closed for that
mesh, which is the same outcome the flag would have given. removing
the flag, the helper, and the conditional from the registry hook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:12:11 +01:00
Alejandro Gutiérrez
f7d7d391c9 feat(cli): 1.30.0 — per-session broker presence
flips CLAUDEMESH_SESSION_PRESENCE default to ON. With the broker side
already shipped (the session_hello handler from earlier in this sprint
A wave), every claudemesh launch now gets its own long-lived broker
presence row owned by the daemon and identified by a per-launch
ephemeral keypair vouched by the member's stable key. Two sessions in
the same cwd finally see each other in peer list — the symptom users
have been hitting since 1.28.0 dropped the bridge tier.

Bumps roadmap: 1.30.0 = presence (was queued for 1.30/wizard); the
launch-wizard refactor moves to 1.31.0, setup wizard to 1.32.0, the
mesh→workspace rename to 1.33.0. Verification smoke documented in the
1.30.0 changelog entry.

Rollback: CLAUDEMESH_SESSION_PRESENCE=0 (also accepts "false"/"off").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:10:51 +01:00
Alejandro Gutiérrez
ff2aa8bf7c feat(cli): launch mints session keypair + parent attestation
claudemesh launch now also generates a per-launch ed25519 keypair and a
parent-vouched attestation (12h TTL), included in the body of POST
/v1/sessions/register under body.presence. The daemon stores it on
SessionInfo and, with CLAUDEMESH_SESSION_PRESENCE=1, opens a long-lived
broker WS so the session has its own presence row.

Also fixes a latent 1.29.0 bug: claudeSessionId was referenced before
its const declaration, hitting the TDZ → ReferenceError silently
swallowed by the surrounding catch. Net: the IPC session-token
registration has been failing every launch since 1.29.0, falling back
to user-level scope for every session. Hoisted the declaration up so
the registration actually runs.

The presence payload is forward-compat: older daemons ignore unknown
body fields, so 1.30.0 CLIs work fine against unupgraded daemons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:08:15 +01:00
Alejandro Gutiérrez
4d42185b0f test(cli): tolerate exit 2 in whoami --json golden
whoami --json exits with EXIT.AUTH_FAILED (=2) when not signed in.
The JSON output is the contract under test, valid regardless of exit
code — execSync was throwing on exit 2 so the assertion never ran.
Switch to spawnSync, accept {0,2}, parse stdout independently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:06:40 +01:00
Alejandro Gutiérrez
d62b3f45d2 feat(cli): sessionbrokerclient + registry hooks (flag-gated)
daemon-side half of 1.30.0 per-session broker presence. behind
CLAUDEMESH_SESSION_PRESENCE=1 (default OFF this cycle so the broker
side bakes before the flag flips).

- SessionBrokerClient (apps/cli/src/daemon/session-broker.ts) — slim
  WS that opens with session_hello, presence-only, no outbox drain.
- session-hello-sig.ts — signParentAttestation (12h TTL, ≤24h cap) and
  signSessionHello, mirroring the broker canonical formats.
- session-registry: optional presence field on SessionInfo;
  setRegistryHooks for onRegister/onDeregister callbacks. Hook errors
  are caught so they can never throttle registry mutations.
- IPC POST /v1/sessions/register accepts the presence material under
  body.presence (session_pubkey, session_secret_key, parent_attestation).
  Older callers without it stay scoped + supported.
- run.ts wires the registry hooks: on register, opens a SessionBrokerClient
  for the matching mesh; on deregister (explicit or reaper), closes it.
  Shutdown closes any remaining session WSes before the IPC server.

8 new unit tests cover registry lifecycle (replace/throw/presence
roundtrip) and signature canonical-bytes verification against libsodium.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:05:33 +01:00
Alejandro Gutiérrez
e688f66791 feat(broker): session_hello WS handler for per-launch presence
The 1.30.0 daemon-multiplexed presence flow needs a way for the daemon
to open a WS keyed on a per-launch ephemeral pubkey. This commit adds:

- WSSessionHelloMessage in types.ts (additive — older clients still use
  WSHelloMessage; older brokers reply with unknown_message_type so newer
  clients can fall back).
- handleSessionHello in index.ts: validates parentAttestation (TTL ≤24h,
  ed25519 by parent), session signature (skew + ed25519 by session),
  parent membership in mesh.member, and parentMemberId/pubkey coherence.
- Inserts a presence row keyed on sessionPubkey but member_id from the
  parent — member-targeted operations (revocation, send-by-member-pubkey)
  keep working unchanged.
- Broadcasts peer_joined to ALL siblings in the mesh, including the
  same-member ones (the regular hello path skips those to avoid self-
  spam, but session_hello explicitly wants sibling visibility).

Behavior parity tests will land alongside the daemon SessionBrokerClient.
The unit tests added in the previous commit cover the crypto layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:00:11 +01:00
Alejandro Gutiérrez
033a2d37e1 feat(broker): canonical session-hello + parent-attestation helpers
Adds the crypto primitives the 1.30.0 per-session broker presence flow
needs: canonicalSessionAttestation/canonicalSessionHello bytes, and
verifySessionAttestation/verifySessionHelloSignature with TTL bounds
(≤24h) plus standard ed25519 + skew checks.

10 unit tests cover the hostile cases — expired attestation, over-TTL,
wrong-key signing, tampered fields, and the "attacker captured the
attestation but doesn't hold the session secret key" scenario.

No wire changes yet — types and dispatch land in the next two commits.
Spec: .artifacts/specs/2026-05-04-per-session-presence.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 12:57:28 +01:00
Alejandro Gutiérrez
364178d95b docs(spec): per-session broker presence (queued for 1.30.0)
records the design for daemon-multiplexed broker presence — every
launched claude session gets its own long-lived presence row owned
by the daemon, identified by a per-launch ephemeral keypair vouched
by the member's stable keypair.

resolves the "two sibling sessions can't see each other in peer list"
gap that surfaced when the bridge tier was deleted in 1.28.0. covers
state machine, broker session_hello handler, parent-attestation
signing, ipc route extension, sequencing (broker first, daemon
flagged, cli third), compat with older builds, and verification
smoke.

~440 loc estimate across cli + daemon + broker. queued for 1.30.0
alongside the launch-wizard refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 12:47:31 +01:00
Alejandro Gutiérrez
f91871c71d docs(roadmap): record sprint A ships (1.26.0 through 1.29.0)
extend the v0.9.x section with a new "v1.26.0 → v1.29.0 — sprint A
toward v2" block listing what each release delivered. trim the
v2.0.0 section to just the remaining HKDF identity work; everything
else from the original v2 spec is now shipped.

queue 1.30.0 (launch wizard), 1.31.0 (setup wizard), 1.32.0 (full
workspace rename) as the explicit remaining items before HKDF
ships as 2.0.0 in its own sprint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 12:36:34 +01:00
Alejandro Gutiérrez
92cac16c91 feat(cli): 1.29.0 — per-session IPC tokens + auto-scoping
every claudemesh launch-spawned session now mints a 32-byte random
token, writes it under tmpdir (mode 0600), and registers it with the
daemon. cli invocations from inside that session inherit
CLAUDEMESH_IPC_TOKEN_FILE in env, attach the token via Authorization:
ClaudeMesh-Session <hex>, and the daemon resolves it to a SessionInfo.

server-side: every read route that filters by mesh now uses meshFromCtx —
explicit query/body wins, session default fills in when missing. write
routes follow the same pattern.

cli-side: peers.ts (and other multi-mesh-iterating verbs in future)
prefers session-token mesh over all joined meshes when the user didn't
pass --mesh explicitly.

backward-compatible in both directions — tokenless callers behave
exactly as before. registry is in-memory; daemon restart loses it but
the 30s reaper handles dead pids and most callers re-register on next
launch.

verified end-to-end: peer list with token returns 4 prueba1 peers,
without token returns 3 meshes' peers (aggregate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 12:33:06 +01:00
Alejandro Gutiérrez
81f0e4f7ac feat(cli): 1.28.0 — bridge deletion + daemon-policy flags
drop the orphaned bridge tier (~600 LoC). client/server/protocol
files deleted; tryBridge had returned null in production for seven
releases since the 1.24.0 mcp shim rewrite stopped opening the
sockets. each verb now has two paths: daemon (with 1.27.3's
auto-spawn) → cold ws.

add per-process daemon policy: --strict (error instead of cold
fallback) and --no-daemon (skip daemon entirely). enforcement at
withMesh so a single chokepoint covers every verb. env equivalents
CLAUDEMESH_STRICT_DAEMON / CLAUDEMESH_NO_DAEMON. flag wins.

net -394 loc; the daemon-up case ships ~600 loc lighter and the
fallback story is one tier simpler. first sprint A drop; per-session
ipc tokens and the wizard refactors follow in 1.29.0+.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 12:23:04 +01:00
Alejandro Gutiérrez
2b6cf2c14b feat(cli): self-healing daemon lifecycle
every daemon-routed verb now probes the ipc socket via /v1/version
(instead of trusting existsSync), cleans up stale sock/pid files left
by a crashed daemon, and auto-spawns a detached `claudemesh daemon up`
under a file-lock when the daemon is down. polls for liveness up to a
budget (3s for ad-hoc verbs, 10s for launch) before falling through to
cold path.

includes a per-process result cache (script doing 50 sends pays spawn
cost at most once), a 30s recently-failed marker (no thundering-herd
retries on crash-loop), a spawn-lock (concurrent invocations share one
attempt), and a recursion guard env var (nested cli calls inside the
daemon process skip auto-spawn).

fixes the stale-socket bug where launch's ensureDaemonRunning returned
early on a left-over socket file from a crashed daemon, silently
breaking the spawned claude session's mcp shim.

deferred to 1.28.0: --strict / --no-daemon flags, lazy-loading of
cold-path code, per-session ipc tokens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:17:32 +01:00
Alejandro Gutiérrez
8a5469a5df docs(skill): canonical fully-populated launch template
adds a kitchen-sink "every flag set explicitly" recipe under
wizard-free spawn templates, with a per-position annotation table.
agents copy this verbatim instead of stitching flags from the table
when spawning unattended sessions.

corrects two stale items: --system-prompt forwards to claude
--system-prompt (not --append-system-prompt), and -q is currently a
no-op (only --quiet is wired).

flags the 1.27.1 cutoff: all twelve launch flags are only end-to-end
wired from that version on; older builds silently dropped half of them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:15:28 +01:00
Alejandro Gutiérrez
e128a6ae5f fix(cli): wire missing launch flags through entrypoint
six flags declared on `LaunchFlags` were silently dropped at the CLI
layer — `--role`, `--groups`, `--message-mode`, `--system-prompt`,
`--continue`, and `--quiet`. each was honored inside `runLaunch` if it
arrived, but the four call sites in the entrypoint forwarded a hardcoded
5-key subset.

now forwarded at every entry: bare command, bare invite URL, the
launch/connect verb, and the new workspace launch alias. pure plumbing;
no behaviour change for users who weren't passing these flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:08:41 +01:00
Alejandro Gutiérrez
3753a6e137 feat(cli): 1.27.0 — state/memory through daemon + workspace alias
extend the daemon thin-client surface to two more verb families: state
get/set/list now routes through `/v1/state`, and remember/recall/forget
through `/v1/memory`. same warm-path pattern as 1.25.0 — try the unix
socket first, fall back to the cold ws path when the daemon is absent.
multi-mesh aware (aggregates on read, requires `--mesh` for writes
when ambiguous).

also ships an early `claudemesh workspace <verb>` alias surface — bare
teaser for the 1.28.0 mesh→workspace public rename. no-arg falls
through to launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:41:18 +01:00
49 changed files with 3495 additions and 673 deletions

View File

@@ -0,0 +1,282 @@
# Per-session broker presence — daemon-multiplexed
**Status:** spec, queued for 1.30.0 (alongside launch-wizard refactor).
**Owner:** alezmad
**Author:** Claude (Sprint A planning, 2026-05-04)
**Related:** `2026-05-04-v2-roadmap-completion.md` (Sprint A overview),
1.29.0 session-registry CHANGELOG entry.
## Problem
After 1.28.0 dropped the bridge tier, **launched `claude` sessions have
no persistent broker presence**. Only the daemon does.
Concretely: two `claudemesh launch` sessions in the same cwd, querying
`peer list` 2 s apart, **never see each other**. Each `claudemesh peer
list` opens a short-lived cold-path WS that creates a `presence` row
for the duration of the query and tears it down. The "this session"
row everyone sees in their own snapshot is created by the snapshot
itself; sibling sessions' queries miss it because their WS-lifetimes
don't overlap.
Confirmed empirically (2026-05-04, same-cwd ECIJA-Intranet test):
| Snapshot | timestamp | self pubkey | self `connectedAt` |
|---|---|---|---|
| Session A | 11:42:37Z | `61d96106cb499208` | 11:42:38Z (= query time) |
| Session B | 11:42:39Z | `ce77188aba02827d` | 11:42:38Z (= query time) |
Each saw 5 long-lived peers (the daemon and unrelated other sessions)
plus its own ephemeral row. Neither saw the other.
## Goal
Every launched `claude` session has a long-lived broker presence row
**owned by the daemon**, identified by the session's per-launch
keypair. Siblings see each other in `peer list` immediately and
continuously, not as snapshot artifacts.
## Non-goals
- Cross-machine session sync (waiting on 2.0.0 HKDF identity).
- Replacing the daemon's own presence row — the daemon stays as a
separate row for "the user on this machine, no specific session."
- Persistence of the session-presence link across daemon restarts —
daemon restart can be allowed to require launched sessions to
re-register (same compromise as the in-memory session registry from
1.29.0).
## Design
### State machine
The 1.29.0 session registry already tracks `Map<token, SessionInfo>`
inside the daemon. Extend it to own a per-session broker connection.
```
session lifecycle:
POST /v1/sessions/register
→ registry.set(token, info)
→ daemon.openSessionWs(info) ← NEW
→ broker creates presence row owned by session.pubkey
DELETE /v1/sessions/:token
→ registry.delete(token)
→ daemon.closeSessionWs(token) ← NEW
→ broker marks presence.disconnectedAt = now()
reaper (30 s tick): pid dead?
→ registry.delete(token)
→ daemon.closeSessionWs(token)
```
### Daemon-side: per-session `BrokerClient`
Today the daemon holds `Map<meshSlug, DaemonBrokerClient>` (one WS per
attached mesh). Add a parallel `Map<token, SessionBrokerClient>` for
the per-launch ephemeral connections.
`SessionBrokerClient` is the existing `BrokerClient` reused, configured
with the session's per-launch keypair instead of the member's stable
keypair. It registers presence (`presence_join`) and stays connected
until `closeSessionWs(token)` fires. It does **not** drain the outbox
— that's the member-keypair `DaemonBrokerClient`'s job. It only carries
presence + receives DMs targeted at the session pubkey.
### Broker-side: parent-vouched presence auth
Today's broker accepts hello-sig auth where:
- Caller signs the broker's nonce with their `mesh_member` keypair.
- Broker looks up `mesh_member.peer_pubkey == sig.pubkey`.
For per-session keypairs, the session pubkey is **not** in `mesh_member`
— it's freshly generated by `claudemesh launch`. We need a new
attestation flow:
```
hello {
type: "session_hello",
session_pubkey: <fresh keypair>,
parent_member_pubkey: <member keypair from config>,
display_name, cwd, role, groups,
parent_signature: ed25519_sign(member_priv,
"claudemesh-session/" || session_pubkey || "/" || nonce),
nonce_challenge: <broker nonce>,
}
```
Broker validates:
1. `parent_member_pubkey` exists in `mesh.member` for the target mesh.
2. `parent_signature` validates against `parent_member_pubkey` over the
canonical message above.
3. Broker inserts a presence row keyed on `session_pubkey` but
`member_id` pointing at the parent member's `mesh.member.id`.
This is the OAuth-style refresh-vs-access pattern: the parent member
key vouches "this ephemeral session pubkey belongs to me." The broker
binds the row to the parent member but uses the session pubkey for
routing (so DMs targeted at the session pubkey land at this WS).
### CLI-side: launch.ts produces the parent signature
`claudemesh launch` already mints the session keypair and writes the
session-token file. Extend it to also produce a `parent_signature`
that the daemon can present when opening the session WS:
```ts
const sessionPubkey = sessionKeypair.publicKey;
const parentSig = ed25519_sign(
mesh.secretKey,
Buffer.concat([
Buffer.from("claudemesh-session/"),
sessionPubkey,
Buffer.from("/"),
/* nonce comes from broker — handled at WS-connect time */
]),
);
```
Actually, the nonce is broker-issued at hello time, so the signature
needs to be produced fresh per WS-connect. Simpler approach: the
`POST /v1/sessions/register` body carries the *member secret key* (or
a derived signing capability) so the daemon can sign nonces on behalf
of the session.
That's a key-leak risk. Better: register carries a **pre-signed
attestation** good for a TTL window:
```
register body adds:
parent_attestation: {
session_pubkey: hex,
parent_member_pubkey: hex,
expires_at: ISO,
signature: ed25519_sign(member_priv,
"claudemesh-session-attest/" ||
session_pubkey || "/" ||
expires_at),
}
```
Daemon presents this attestation in `session_hello`; broker validates
expiry and signature, then issues a nonce challenge that the daemon
can satisfy with the session keypair (which IS held by the daemon
for the lifetime of the registration). Two-stage: parent vouches the
session; session signs the nonce.
### Registry persistence
For now, in-memory only (matching 1.29.0). Daemon restart drops all
session WSes; launched `claude` processes are responsible for
re-registering on next CLI invocation. Acceptable v1 behaviour;
revisit when sqlite persistence lands for the registry.
## Wire changes
### Broker
- New `session_hello` message type (additive; existing `hello` for
member auth unchanged).
- `presence` row schema unchanged — `member_id` still required, but
`session_pubkey` differs from member's stable pubkey.
- Validate `parent_attestation.expires_at <= now() + 24h` to bound
attestation reuse.
### Daemon
- New `SessionBrokerClient` factory — wraps `BrokerClient` with
session-mode hello.
- `Map<token, SessionBrokerClient>` alongside the existing
`Map<slug, DaemonBrokerClient>`.
- IPC routes:
- `POST /v1/sessions/register` — extend body schema with
`parent_attestation`.
- `DELETE /v1/sessions/:token` — close the session WS first, then
drop registry entry.
### CLI (`claudemesh launch`)
- Mint session keypair (today only writes the session token; need to
add ed25519 keypair generation per launch and write the privkey
alongside the token).
- Sign `parent_attestation` with the member key from the joined-mesh
config.
- POST register with both the new keypair and the attestation.
## LoC estimate
- Daemon `SessionBrokerClient` + registry hook: ~120 LoC.
- IPC route schema extension + validation: ~40 LoC.
- Broker `session_hello` handler + tests: ~140 LoC.
- CLI `claudemesh launch` keypair + attestation: ~60 LoC.
- Tests + smoke: ~80 LoC.
Total: **~440 LoC** across CLI + daemon + broker.
## Risks
| Risk | Mitigation |
|---|---|
| Member private key never leaves the user's machine, but the **attestation** (signed token) can be replayed within its TTL. | TTL bound 24h; refresh on launch; revocation path = drop the parent member's mesh enrollment (nuclear, but works). |
| Cascading WS connections — N launches = N+1 broker WSes per user. | Acceptable up to 10-20 concurrent sessions; if it ever becomes a problem, multiplex per-session at the protocol level (one WS, multiple presence rows). Out of scope for v1. |
| Daemon restart kills all session WSes — `peer list` from inside a launched session sees the remaining 5 peers but not its own siblings until they re-register. | Same as 1.29.0 registry. The registry could persist to sqlite later; for v1, accepted. |
| Broker schema cost: every new presence row has a different `session_pubkey`, growing the table faster. | Already accepted — broker prunes disconnected rows on a 30-day window. Per-session keys triple the row count at peak but stay within the prune budget. |
## Compatibility
- **Older brokers** can't validate `session_hello`. Sessions will
attempt the new hello, get back `unknown_message_type`, and fall
back to the existing member-keyed hello (no per-session presence,
but everything still works as 1.28.0). Add the broker change first,
let it deploy, then ship the CLI side.
- **Older CLIs** continue to work unchanged — they don't open
per-session WSes. They appear as ephemeral cold-path rows just like
today, and lose the symmetric-visibility property between siblings.
- **Backward visible:** users on 1.30.0+ on the same mesh as users on
≤1.29.x will see the older users as one row (their daemon) instead
of one row per session. Acceptable — opt-in to the new visibility
by upgrading.
## Sequencing
1. **Broker change ships first.** Add `session_hello` handler, deploy,
bake for ~24h. No CLI behaviour change yet.
2. **Daemon `SessionBrokerClient` ships next** behind a feature flag
(`CLAUDEMESH_SESSION_PRESENCE=1`). Manually test with two launched
sessions in the same cwd; verify both see each other.
3. **CLI keypair-mint + attestation in `launch.ts` ships last**, behind
the same flag.
4. Flip the flag default in 1.30.0 release; document rollback via env.
## Verification
End-to-end smoke (paste into 1.30.0's CHANGELOG):
```
$ # In two different shells, both cd ~/Desktop/foo:
$ claudemesh launch --name SessionA -y # shell 1
$ claudemesh launch --name SessionB -y # shell 2
$
$ # In a third shell:
$ claudemesh peer list --json --mesh foo | jq '.[] | {n: .displayName, c: .cwd}'
{ "n": "SessionA", "c": "/.../foo" } ← persistent, not query-induced
{ "n": "SessionB", "c": "/.../foo" }
$
$ # In SessionA's shell:
$ claudemesh peer list --mesh foo
should include SessionB.
$
$ # Kill SessionB (Ctrl-C in shell 2). Wait <30s.
$ claudemesh peer list --mesh foo
should NOT include SessionB (reaper closed its WS).
```
## Open questions
- Should the per-session WS also drain *its own* outbox subset, or stay
presence-only? Recommend presence-only for v1 — keeps state machines
simple, daemon's member-keyed WS handles all sends. Can be revisited
when per-session policy DSL ships.
- Should the parent attestation be revocable mid-session? Could add an
IPC route on the daemon. Out of scope for v1; revoke = drop the
whole member enrollment.

View File

@@ -1013,7 +1013,7 @@ export async function topicHistory(args: {
ORDER BY tm.created_at DESC, tm.id DESC ORDER BY tm.created_at DESC, tm.id DESC
LIMIT ${limit} LIMIT ${limit}
`); `);
const rows = (result.rows ?? result) as Array<{ const rows = ((result as unknown as { rows?: unknown[] }).rows ?? (result as unknown as unknown[])) as Array<{
id: string; id: string;
sender_member_id: string; sender_member_id: string;
sender_pubkey: string; sender_pubkey: string;
@@ -1442,7 +1442,7 @@ export async function recallMemory(
ORDER BY ts_rank(search_vector, plainto_tsquery('english', ${query})) DESC ORDER BY ts_rank(search_vector, plainto_tsquery('english', ${query})) DESC
LIMIT 20 LIMIT 20
`); `);
const rows = (result.rows ?? result) as Array<{ const rows = ((result as unknown as { rows?: unknown[] }).rows ?? (result as unknown as unknown[])) as Array<{
id: string; id: string;
content: string; content: string;
tags: string[]; tags: string[];
@@ -2010,7 +2010,7 @@ export async function getContext(
ORDER BY updated_at DESC ORDER BY updated_at DESC
LIMIT 20 LIMIT 20
`); `);
const rows = (result.rows ?? result) as Array<{ const rows = ((result as unknown as { rows?: unknown[] }).rows ?? (result as unknown as unknown[])) as Array<{
peer_name: string | null; peer_name: string | null;
summary: string; summary: string;
files_read: string[] | null; files_read: string[] | null;
@@ -2419,7 +2419,7 @@ export async function drainForMember(
SELECT * FROM claimed ORDER BY created_at ASC, id ASC SELECT * FROM claimed ORDER BY created_at ASC, id ASC
`); `);
const rows = (result.rows ?? result) as Array<{ const rows = ((result as unknown as { rows?: unknown[] }).rows ?? (result as unknown as unknown[])) as Array<{
id: string; id: string;
priority: string; priority: string;
nonce: string; nonce: string;
@@ -2665,7 +2665,11 @@ export async function findMemberByPubkey(
), ),
) )
.limit(1); .limit(1);
return row ?? null; if (!row) return null;
return {
...row,
defaultGroups: row.defaultGroups ?? [],
};
} }
// --- Mesh databases (per-mesh PostgreSQL schemas) --- // --- Mesh databases (per-mesh PostgreSQL schemas) ---
@@ -2719,7 +2723,7 @@ export async function meshQuery(
sql.raw(`SET LOCAL search_path TO "${schema}"`) sql.raw(`SET LOCAL search_path TO "${schema}"`)
); );
const result = await tx.execute(sql.raw(query)); const result = await tx.execute(sql.raw(query));
const rows = (result.rows ?? []) as Array<Record<string, unknown>>; const rows = ((result as unknown as { rows?: unknown[] }).rows ?? (result as unknown as unknown[])) as Array<Record<string, unknown>>;
const columns = rows.length > 0 ? Object.keys(rows[0]!) : []; const columns = rows.length > 0 ? Object.keys(rows[0]!) : [];
return { columns, rows, rowCount: rows.length }; return { columns, rows, rowCount: rows.length };
}); });
@@ -2762,7 +2766,7 @@ export async function meshSchema(
WHERE table_schema = ${schema} WHERE table_schema = ${schema}
ORDER BY table_name, ordinal_position ORDER BY table_name, ordinal_position
`); `);
const rows = (result.rows ?? result) as Array<{ const rows = ((result as unknown as { rows?: unknown[] }).rows ?? (result as unknown as unknown[])) as Array<{
table_name: string; table_name: string;
column_name: string; column_name: string;
data_type: string; data_type: string;

View File

@@ -138,6 +138,128 @@ export async function sealRootKeyToRecipient(params: {
export const HELLO_SKEW_MS = 60_000; export const HELLO_SKEW_MS = 60_000;
/** Maximum lifetime of a parent attestation (24h). */
export const SESSION_ATTESTATION_MAX_TTL_MS = 24 * 60 * 60 * 1000;
/**
* Canonical bytes for a parent-vouches-session attestation.
*
* The parent member signs this with their stable ed25519 secret key when
* minting an attestation in `claudemesh launch`. The broker recomputes
* the same string at session_hello time and verifies the signature
* against `parent_member_pubkey`.
*
* Format: `claudemesh-session-attest|<parent_pubkey>|<session_pubkey>|<expires_at_ms>`
*/
export function canonicalSessionAttestation(
parentMemberPubkey: string,
sessionPubkey: string,
expiresAt: number,
): string {
return `claudemesh-session-attest|${parentMemberPubkey}|${sessionPubkey}|${expiresAt}`;
}
/**
* Canonical bytes for the session_hello signature.
*
* The session keypair (held by the daemon for the lifetime of the
* registration) signs this fresh on every WS connect, proving liveness +
* possession of the session secret key. Without this stage, an attacker
* who captured an attestation could replay it from any machine.
*
* Format: `claudemesh-session-hello|<mesh_id>|<parent_pubkey>|<session_pubkey>|<timestamp_ms>`
*/
export function canonicalSessionHello(
meshId: string,
parentMemberPubkey: string,
sessionPubkey: string,
timestamp: number,
): string {
return `claudemesh-session-hello|${meshId}|${parentMemberPubkey}|${sessionPubkey}|${timestamp}`;
}
/**
* Validate a parent-vouches-session attestation: lifetime bound + signature.
* Returns `{ ok: true }` on success or `{ ok: false, reason }` on failure.
*
* The TTL ceiling (24h) bounds replay damage if an attestation leaks; the
* lower bound (already in the past) blocks reuse of expired ones.
*/
export async function verifySessionAttestation(args: {
parentMemberPubkey: string;
sessionPubkey: string;
expiresAt: number;
signature: string;
now?: number;
}): Promise<
| { ok: true }
| { ok: false; reason: "expired" | "ttl_too_long" | "bad_signature" | "malformed" }
> {
const now = args.now ?? Date.now();
if (!Number.isFinite(args.expiresAt)) {
return { ok: false, reason: "malformed" };
}
if (args.expiresAt <= now) {
return { ok: false, reason: "expired" };
}
if (args.expiresAt > now + SESSION_ATTESTATION_MAX_TTL_MS) {
return { ok: false, reason: "ttl_too_long" };
}
if (
!/^[0-9a-f]{64}$/i.test(args.parentMemberPubkey) ||
!/^[0-9a-f]{64}$/i.test(args.sessionPubkey) ||
!/^[0-9a-f]{128}$/i.test(args.signature)
) {
return { ok: false, reason: "malformed" };
}
const canonical = canonicalSessionAttestation(
args.parentMemberPubkey,
args.sessionPubkey,
args.expiresAt,
);
const ok = await verifyEd25519(canonical, args.signature, args.parentMemberPubkey);
return ok ? { ok: true } : { ok: false, reason: "bad_signature" };
}
/**
* Validate the session-side hello signature: timestamp skew + signature
* by the session keypair over canonical session-hello bytes.
*/
export async function verifySessionHelloSignature(args: {
meshId: string;
parentMemberPubkey: string;
sessionPubkey: string;
timestamp: number;
signature: string;
now?: number;
}): Promise<
| { ok: true }
| { ok: false; reason: "timestamp_skew" | "bad_signature" | "malformed" }
> {
const now = args.now ?? Date.now();
if (
!Number.isFinite(args.timestamp) ||
Math.abs(now - args.timestamp) > HELLO_SKEW_MS
) {
return { ok: false, reason: "timestamp_skew" };
}
if (
!/^[0-9a-f]{64}$/i.test(args.parentMemberPubkey) ||
!/^[0-9a-f]{64}$/i.test(args.sessionPubkey) ||
!/^[0-9a-f]{128}$/i.test(args.signature)
) {
return { ok: false, reason: "malformed" };
}
const canonical = canonicalSessionHello(
args.meshId,
args.parentMemberPubkey,
args.sessionPubkey,
args.timestamp,
);
const ok = await verifyEd25519(canonical, args.signature, args.sessionPubkey);
return ok ? { ok: true } : { ok: false, reason: "bad_signature" };
}
/** /**
* Verify a hello's ed25519 signature + timestamp skew. * Verify a hello's ed25519 signature + timestamp skew.
* Returns { ok: true } on success, or { ok: false, reason } describing * Returns { ok: true } on success, or { ok: false, reason } describing

View File

@@ -23,7 +23,7 @@ const envSchema = z.object({
MINIO_ENDPOINT: z.string().default("minio:9000"), MINIO_ENDPOINT: z.string().default("minio:9000"),
MINIO_ACCESS_KEY: z.string().default("claudemesh"), MINIO_ACCESS_KEY: z.string().default("claudemesh"),
MINIO_SECRET_KEY: z.string().default("changeme"), MINIO_SECRET_KEY: z.string().default("changeme"),
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"), MINIO_USE_SSL: z.enum(["true", "false", ""]).default("false").transform(v => v === "true"),
QDRANT_URL: z.string().default("http://qdrant:6333"), QDRANT_URL: z.string().default("http://qdrant:6333"),
NEO4J_URL: z.string().default("bolt://neo4j:7687"), NEO4J_URL: z.string().default("bolt://neo4j:7687"),
NEO4J_USER: z.string().default("neo4j"), NEO4J_USER: z.string().default("neo4j"),

View File

@@ -22,7 +22,7 @@ import { invite as inviteTable, mesh, meshMember, messageQueue, presence, schedu
import { user } from "@turbostarter/db/schema/auth"; import { user } from "@turbostarter/db/schema/auth";
import { handleCliSync, type CliSyncRequest } from "./cli-sync"; import { handleCliSync, type CliSyncRequest } from "./cli-sync";
import { generateId } from "@turbostarter/shared/utils"; import { generateId } from "@turbostarter/shared/utils";
import { updateMemberProfile, listMeshMembers, updateMeshSettings } from "./member-api"; import { updateMemberProfile, listMeshMembers, updateMeshSettings, type MemberUpdateRequest, type SelfEditablePolicy } from "./member-api";
import { import {
claimTask, claimTask,
completeTask, completeTask,
@@ -115,7 +115,7 @@ import { metrics, metricsToText } from "./metrics";
import { TokenBucket } from "./rate-limit"; import { TokenBucket } from "./rate-limit";
import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health"; import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health";
import { buildInfo } from "./build-info"; import { buildInfo } from "./build-info";
import { canonicalInvite, canonicalInviteV2, claimInviteV2Core as _claimInviteV2Core, sealRootKeyToRecipient, verifyHelloSignature, verifyInviteV2 } from "./crypto"; import { canonicalInvite, canonicalInviteV2, claimInviteV2Core as _claimInviteV2Core, sealRootKeyToRecipient, verifyHelloSignature, verifyInviteV2, verifySessionAttestation, verifySessionHelloSignature } from "./crypto";
// Alias for in-module callers; the public re-export below surfaces the // Alias for in-module callers; the public re-export below surfaces the
// same symbol without colliding with tests that import from index.ts. // same symbol without colliding with tests that import from index.ts.
const claimInviteV2Core = _claimInviteV2Core; const claimInviteV2Core = _claimInviteV2Core;
@@ -831,7 +831,12 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
req.on("data", (c: Buffer) => chunks.push(c)); req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => { req.on("end", () => {
try { try {
const body = JSON.parse(Buffer.concat(chunks).toString()); const body = JSON.parse(Buffer.concat(chunks).toString()) as {
meshId?: string;
memberId?: string;
pubkey?: string;
secretKey?: string;
};
const { meshId: tgMeshId, memberId: tgMemberId, pubkey: tgPubkey, secretKey: tgSecretKey } = body; const { meshId: tgMeshId, memberId: tgMemberId, pubkey: tgPubkey, secretKey: tgSecretKey } = body;
if (!tgMeshId || !tgMemberId || !tgPubkey || !tgSecretKey) { if (!tgMeshId || !tgMemberId || !tgPubkey || !tgSecretKey) {
writeJson(res, 400, { error: "meshId, memberId, pubkey, secretKey required" }); writeJson(res, 400, { error: "meshId, memberId, pubkey, secretKey required" });
@@ -1099,7 +1104,7 @@ function handleInviteClaimV2Post(
const raw = Buffer.concat(chunks).toString(); const raw = Buffer.concat(chunks).toString();
let payload: { recipient_x25519_pubkey?: string; display_name?: string }; let payload: { recipient_x25519_pubkey?: string; display_name?: string };
try { try {
payload = JSON.parse(raw); payload = JSON.parse(raw) as { recipient_x25519_pubkey?: string; display_name?: string };
} catch { } catch {
writeJson(res, 400, { error: "malformed" }); writeJson(res, 400, { error: "malformed" });
return; return;
@@ -1197,7 +1202,7 @@ async function handleUploadPost(
let tags: string[] = []; let tags: string[] = [];
if (tagsRaw) { if (tagsRaw) {
try { try {
tags = JSON.parse(tagsRaw); tags = JSON.parse(tagsRaw) as string[];
} catch { } catch {
tags = []; tags = [];
} }
@@ -1259,7 +1264,7 @@ async function handleUploadPost(
let fileKeys: Array<{ peerPubkey: string; sealedKey: string }> = []; let fileKeys: Array<{ peerPubkey: string; sealedKey: string }> = [];
if (encrypted && fileKeysRaw) { if (encrypted && fileKeysRaw) {
try { try {
fileKeys = JSON.parse(fileKeysRaw); fileKeys = JSON.parse(fileKeysRaw) as Array<{ peerPubkey: string; sealedKey: string }>;
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -1364,7 +1369,7 @@ function handleMemberPatchPost(req: IncomingMessage, res: ServerResponse, meshId
req.on("end", async () => { req.on("end", async () => {
if (aborted) return; if (aborted) return;
try { try {
const body = JSON.parse(Buffer.concat(chunks).toString()); const body = JSON.parse(Buffer.concat(chunks).toString()) as MemberUpdateRequest;
// Auth: callerMemberId from X-Member-Id header (dashboard or CLI provides this) // Auth: callerMemberId from X-Member-Id header (dashboard or CLI provides this)
const callerMemberId = req.headers["x-member-id"] as string | undefined; const callerMemberId = req.headers["x-member-id"] as string | undefined;
if (!callerMemberId) { writeJson(res, 401, { ok: false, error: "X-Member-Id header required" }); return; } if (!callerMemberId) { writeJson(res, 401, { ok: false, error: "X-Member-Id header required" }); return; }
@@ -1407,7 +1412,7 @@ function handleMeshSettingsPatch(req: IncomingMessage, res: ServerResponse, mesh
req.on("end", async () => { req.on("end", async () => {
if (aborted) return; if (aborted) return;
try { try {
const body = JSON.parse(Buffer.concat(chunks).toString()); const body = JSON.parse(Buffer.concat(chunks).toString()) as { selfEditable?: SelfEditablePolicy };
const callerMemberId = req.headers["x-member-id"] as string | undefined; const callerMemberId = req.headers["x-member-id"] as string | undefined;
if (!callerMemberId) { writeJson(res, 401, { ok: false, error: "X-Member-Id header required" }); return; } if (!callerMemberId) { writeJson(res, 401, { ok: false, error: "X-Member-Id header required" }); return; }
const result = await updateMeshSettings(meshId, callerMemberId, body); const result = await updateMeshSettings(meshId, callerMemberId, body);
@@ -1821,6 +1826,220 @@ async function handleHello(
}; };
} }
/**
* Authenticate + presence-register a per-launch session WebSocket.
*
* Two-stage proof: parent member's pre-signed attestation vouches the
* session pubkey, and the session keypair signs the hello timestamp to
* prove possession. The presence row is keyed on `sessionPubkey` but
* `member_id` points at the parent member, so member-targeted operations
* (revocation, send-by-member-pubkey) keep working unchanged.
*
* Spec: .artifacts/specs/2026-05-04-per-session-presence.md.
*/
async function handleSessionHello(
ws: WebSocket,
hello: Extract<WSClientMessage, { type: "session_hello" }>,
): Promise<{
presenceId: string;
memberDisplayName: string;
memberProfile?: unknown;
meshPolicy?: Record<string, unknown>;
} | null> {
// Shape checks. The crypto helpers also enforce these but bailing
// early gives a clearer error code on the wire.
if (!/^[0-9a-f]{64}$/.test(hello.sessionPubkey ?? "")) {
metrics.connectionsRejected.inc({ reason: "bad_session_pubkey" });
sendError(ws, "bad_session_pubkey", "sessionPubkey must be 64 lowercase hex chars");
ws.close(1008, "bad_session_pubkey");
return null;
}
if (!/^[0-9a-f]{64}$/.test(hello.parentMemberPubkey ?? "")) {
metrics.connectionsRejected.inc({ reason: "bad_parent_pubkey" });
sendError(ws, "bad_parent_pubkey", "parentMemberPubkey must be 64 lowercase hex chars");
ws.close(1008, "bad_parent_pubkey");
return null;
}
const att = hello.parentAttestation;
if (
!att ||
typeof att !== "object" ||
att.sessionPubkey !== hello.sessionPubkey ||
att.parentMemberPubkey !== hello.parentMemberPubkey
) {
metrics.connectionsRejected.inc({ reason: "attestation_mismatch" });
sendError(ws, "attestation_mismatch", "parentAttestation does not bind the claimed session+parent pubkeys");
ws.close(1008, "attestation_mismatch");
return null;
}
// Capacity check BEFORE touching DB.
const existing = connectionsPerMesh.get(hello.meshId) ?? 0;
if (existing >= env.MAX_CONNECTIONS_PER_MESH) {
metrics.connectionsRejected.inc({ reason: "capacity" });
log.warn("mesh at capacity (session_hello)", {
mesh_id: hello.meshId,
existing,
cap: env.MAX_CONNECTIONS_PER_MESH,
});
sendError(ws, "capacity", "mesh at connection capacity");
ws.close(1008, "capacity");
return null;
}
// 1. Parent attestation: TTL bounds + signature against parent pubkey.
const attCheck = await verifySessionAttestation({
parentMemberPubkey: hello.parentMemberPubkey,
sessionPubkey: hello.sessionPubkey,
expiresAt: att.expiresAt,
signature: att.signature,
});
if (!attCheck.ok) {
metrics.connectionsRejected.inc({ reason: `attestation_${attCheck.reason}` });
log.warn("session_hello attestation rejected", {
reason: attCheck.reason,
mesh_id: hello.meshId,
parent_pubkey: hello.parentMemberPubkey.slice(0, 12),
session_pubkey: hello.sessionPubkey.slice(0, 12),
});
sendError(ws, attCheck.reason, `attestation rejected: ${attCheck.reason}`);
ws.close(1008, attCheck.reason);
return null;
}
// 2. Session signature: timestamp skew + ed25519 against sessionPubkey.
const sigCheck = await verifySessionHelloSignature({
meshId: hello.meshId,
parentMemberPubkey: hello.parentMemberPubkey,
sessionPubkey: hello.sessionPubkey,
timestamp: hello.timestamp,
signature: hello.signature,
});
if (!sigCheck.ok) {
metrics.connectionsRejected.inc({ reason: `session_${sigCheck.reason}` });
log.warn("session_hello sig rejected", {
reason: sigCheck.reason,
mesh_id: hello.meshId,
session_pubkey: hello.sessionPubkey.slice(0, 12),
});
sendError(ws, sigCheck.reason, `session_hello rejected: ${sigCheck.reason}`);
ws.close(1008, sigCheck.reason);
return null;
}
// 3. Parent member must exist + be active in the claimed mesh.
const member = await findMemberByPubkey(hello.meshId, hello.parentMemberPubkey);
if (!member) {
const [revokedRow] = await db
.select({ displayName: meshMember.displayName, revokedAt: meshMember.revokedAt })
.from(meshMember)
.where(and(eq(meshMember.meshId, hello.meshId), eq(meshMember.peerPubkey, hello.parentMemberPubkey)))
.limit(1);
if (revokedRow?.revokedAt) {
metrics.connectionsRejected.inc({ reason: "revoked" });
const [m] = await db.select({ slug: mesh.slug, name: mesh.name }).from(mesh).where(eq(mesh.id, hello.meshId)).limit(1);
const meshLabel = m?.name || m?.slug || hello.meshId;
sendError(
ws,
"revoked",
`You've been removed from "${meshLabel}". Contact the mesh owner to rejoin.`,
);
ws.close(4002, "banned");
log.info("session_hello rejected: revoked parent", { mesh_id: hello.meshId, display_name: revokedRow.displayName });
return null;
}
metrics.connectionsRejected.inc({ reason: "unauthorized" });
sendError(ws, "unauthorized", "parent pubkey not found in mesh");
ws.close(1008, "unauthorized");
return null;
}
// The parentMemberId in the hello must match the member we resolved by
// pubkey — otherwise the daemon claims membership it doesn't have.
if (hello.parentMemberId && hello.parentMemberId !== member.id) {
metrics.connectionsRejected.inc({ reason: "parent_member_id_mismatch" });
sendError(ws, "parent_member_id_mismatch", "parentMemberId does not match parentMemberPubkey");
ws.close(1008, "parent_member_id_mismatch");
return null;
}
// Load mesh policy (best-effort; non-fatal).
let meshPolicy: Record<string, unknown> | undefined;
try {
const [m] = await db
.select({ selfEditable: mesh.selfEditable })
.from(mesh)
.where(eq(mesh.id, hello.meshId));
if (m?.selfEditable) meshPolicy = { selfEditable: m.selfEditable };
} catch { /* non-fatal */ }
const initialGroups = hello.groups ?? member.defaultGroups ?? [];
// Session-id dedup: if the same session_id is already connected, kick
// the ghost. Reconnect after a network blip lands here cleanly.
for (const [oldPid, oldConn] of connections) {
if (oldConn.meshId === hello.meshId && oldConn.sessionId === hello.sessionId) {
log.info("session_hello dedup", { old_presence: oldPid, session_id: hello.sessionId });
try { oldConn.ws.close(1000, "session_replaced"); } catch { /* already dead */ }
connections.delete(oldPid);
void disconnectPresence(oldPid);
}
}
const presenceId = await connectPresence({
memberId: member.id,
sessionId: hello.sessionId,
sessionPubkey: hello.sessionPubkey,
displayName: hello.displayName,
pid: hello.pid,
cwd: hello.cwd,
groups: initialGroups,
});
const effectiveDisplayName = hello.displayName || member.displayName;
connections.set(presenceId, {
ws,
meshId: hello.meshId,
memberId: member.id,
memberPubkey: hello.parentMemberPubkey,
sessionId: hello.sessionId,
sessionPubkey: hello.sessionPubkey,
displayName: effectiveDisplayName,
cwd: hello.cwd,
hostname: hello.hostname,
peerType: hello.peerType,
channel: hello.channel,
model: hello.model,
groups: initialGroups,
visible: true,
profile: {},
});
incMeshCount(hello.meshId);
void audit(hello.meshId, "peer_joined", member.id, effectiveDisplayName, {
pubkey: hello.parentMemberPubkey,
session_pubkey: hello.sessionPubkey,
groups: initialGroups,
via: "session_hello",
});
log.info("ws session_hello", {
mesh_id: hello.meshId,
member: effectiveDisplayName,
presence_id: presenceId,
session_id: hello.sessionId,
session_pubkey: hello.sessionPubkey.slice(0, 12),
});
// Drain any DMs queued for this session pubkey (or the parent member).
void maybePushQueuedMessages(presenceId);
return {
presenceId,
memberDisplayName: effectiveDisplayName,
memberProfile: {
roleTag: member.roleTag,
groups: member.defaultGroups ?? [],
messageMode: member.messageMode ?? "push",
},
meshPolicy,
};
}
async function handleSend( async function handleSend(
conn: PeerConn, conn: PeerConn,
msg: Extract<WSClientMessage, { type: "send" }>, msg: Extract<WSClientMessage, { type: "send" }>,
@@ -2171,6 +2390,53 @@ function handleConnection(ws: WebSocket): void {
try { try {
const msg = JSON.parse(raw.toString()) as WSClientMessage; const msg = JSON.parse(raw.toString()) as WSClientMessage;
const _reqId = (msg as any)._reqId as string | undefined; const _reqId = (msg as any)._reqId as string | undefined;
if (msg.type === "session_hello") {
const result = await handleSessionHello(ws, msg);
if (!result) return;
presenceId = result.presenceId;
try {
const ackPayload: Record<string, unknown> = {
type: "hello_ack",
presenceId: result.presenceId,
memberDisplayName: result.memberDisplayName,
memberProfile: result.memberProfile,
...(result.meshPolicy ? { meshPolicy: result.meshPolicy } : {}),
};
ws.send(JSON.stringify(ackPayload));
} catch {
/* ws closed during hello */
}
// Broadcast peer_joined to siblings — same shape as the regular
// hello path, so list_peers consumers don't need to special-case.
const joinedConn = connections.get(presenceId);
if (joinedConn) {
const joinMsg: WSPushMessage = {
type: "push",
subtype: "system",
event: "peer_joined",
eventData: {
name: result.memberDisplayName,
pubkey: joinedConn.sessionPubkey ?? joinedConn.memberPubkey,
groups: joinedConn.groups,
},
messageId: crypto.randomUUID(),
meshId: joinedConn.meshId,
senderPubkey: "system",
priority: "low",
nonce: "",
ciphertext: "",
createdAt: new Date().toISOString(),
};
for (const [pid, peer] of connections) {
if (pid === presenceId) continue;
if (peer.meshId !== joinedConn.meshId) continue;
// Same-member sibling sessions get the join — a per-launch
// session is meant to be visible to the user's other launches.
sendToPeer(pid, joinMsg);
}
}
return;
}
if (msg.type === "hello") { if (msg.type === "hello") {
const result = await handleHello(ws, msg); const result = await handleHello(ws, msg);
if (!result) return; if (!result) return;
@@ -3492,7 +3758,7 @@ function handleConnection(ws: WebSocket): void {
const gqRecords = gqResult.records.map((r) => { const gqRecords = gqResult.records.map((r) => {
const obj: Record<string, unknown> = {}; const obj: Record<string, unknown> = {};
for (const key of r.keys) { for (const key of r.keys) {
obj[key] = r.get(key); obj[String(key)] = r.get(key);
} }
return obj; return obj;
}); });
@@ -3527,7 +3793,7 @@ function handleConnection(ws: WebSocket): void {
const geRecords = geResult.records.map((r) => { const geRecords = geResult.records.map((r) => {
const obj: Record<string, unknown> = {}; const obj: Record<string, unknown> = {};
for (const key of r.keys) { for (const key of r.keys) {
obj[key] = r.get(key); obj[String(key)] = r.get(key);
} }
return obj; return obj;
}); });
@@ -3616,10 +3882,10 @@ function handleConnection(ws: WebSocket): void {
const [peers, stateEntries, memCount, fileCount, taskCounts, streams, tables] = await Promise.all([ const [peers, stateEntries, memCount, fileCount, taskCounts, streams, tables] = await Promise.all([
listPeersInMesh(conn.meshId), listPeersInMesh(conn.meshId),
listState(conn.meshId), listState(conn.meshId),
db.execute(sql`SELECT COUNT(*) as n FROM mesh.memory WHERE mesh_id = ${conn.meshId} AND forgotten_at IS NULL`).then(r => Number(((r.rows ?? r) as any[])[0]?.n ?? 0)), db.execute(sql`SELECT COUNT(*) as n FROM mesh.memory WHERE mesh_id = ${conn.meshId} AND forgotten_at IS NULL`).then(r => Number((((r as unknown as { rows?: unknown[] }).rows ?? (r as unknown as unknown[])) as any[])[0]?.n ?? 0)),
db.execute(sql`SELECT COUNT(*) as n FROM mesh.file WHERE mesh_id = ${conn.meshId} AND deleted_at IS NULL`).then(r => Number(((r.rows ?? r) as any[])[0]?.n ?? 0)), db.execute(sql`SELECT COUNT(*) as n FROM mesh.file WHERE mesh_id = ${conn.meshId} AND deleted_at IS NULL`).then(r => Number((((r as unknown as { rows?: unknown[] }).rows ?? (r as unknown as unknown[])) as any[])[0]?.n ?? 0)),
db.execute(sql`SELECT status, COUNT(*) as n FROM mesh.task WHERE mesh_id = ${conn.meshId} GROUP BY status`).then(r => { db.execute(sql`SELECT status, COUNT(*) as n FROM mesh.task WHERE mesh_id = ${conn.meshId} GROUP BY status`).then(r => {
const rows = (r.rows ?? r) as Array<{ status: string; n: string }>; const rows = (((r as unknown as { rows?: unknown[] }).rows ?? (r as unknown as unknown[]))) as Array<{ status: string; n: string }>;
const counts = { open: 0, claimed: 0, done: 0 }; const counts = { open: 0, claimed: 0, done: 0 };
for (const row of rows) counts[row.status as keyof typeof counts] = Number(row.n); for (const row of rows) counts[row.status as keyof typeof counts] = Number(row.n);
return counts; return counts;

View File

@@ -86,7 +86,7 @@ export async function verifySyncToken(
} }
// Decode header — must be HS256 // Decode header — must be HS256
const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64))); const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64))) as { alg?: string };
if (header.alg !== "HS256") { if (header.alg !== "HS256") {
return { ok: false, error: `unsupported algorithm: ${header.alg}` }; return { ok: false, error: `unsupported algorithm: ${header.alg}` };
} }

View File

@@ -31,7 +31,7 @@ export interface MemberPermissionUpdate {
export type MemberUpdateRequest = MemberProfileUpdate & MemberPermissionUpdate; export type MemberUpdateRequest = MemberProfileUpdate & MemberPermissionUpdate;
interface SelfEditablePolicy { export interface SelfEditablePolicy {
displayName: boolean; displayName: boolean;
roleTag: boolean; roleTag: boolean;
groups: boolean; groups: boolean;

View File

@@ -115,11 +115,11 @@ function lastAssistantHasToolUse(filePath: string): boolean {
if (!line) continue; if (!line) continue;
if (!line.includes('"assistant"')) continue; if (!line.includes('"assistant"')) continue;
try { try {
const d = JSON.parse(line); const d = JSON.parse(line) as { type?: string; message?: { content?: unknown } };
if (d.type !== "assistant") continue; if (d.type !== "assistant") continue;
const content = d.message?.content; const content = d.message?.content;
if (!Array.isArray(content)) continue; if (!Array.isArray(content)) continue;
return content.some((c: { type?: string }) => c.type === "tool_use"); return (content as Array<{ type?: string }>).some((c) => c.type === "tool_use");
} catch { } catch {
/* malformed line, skip */ /* malformed line, skip */
} }

View File

@@ -169,7 +169,7 @@ function detectEntry(
try { try {
const pkg = JSON.parse( const pkg = JSON.parse(
readFileSync(join(sourcePath, "package.json"), "utf-8"), readFileSync(join(sourcePath, "package.json"), "utf-8"),
); ) as { main?: string; bin?: string | Record<string, string> };
if (pkg.main) return { command: cmd, args: [pkg.main] }; if (pkg.main) return { command: cmd, args: [pkg.main] };
if (pkg.bin) { if (pkg.bin) {
const bin = const bin =
@@ -372,7 +372,7 @@ function spawnService(svc: ManagedService): void {
const rl = createInterface({ input: child.stdout! }); const rl = createInterface({ input: child.stdout! });
rl.on("line", (line) => { rl.on("line", (line) => {
try { try {
const msg = JSON.parse(line); const msg = JSON.parse(line) as { id?: string | number; error?: { message?: string }; result?: unknown };
if (msg.id && svc.pendingCalls.has(String(msg.id))) { if (msg.id && svc.pendingCalls.has(String(msg.id))) {
const pending = svc.pendingCalls.get(String(msg.id))!; const pending = svc.pendingCalls.get(String(msg.id))!;
clearTimeout(pending.timer); clearTimeout(pending.timer);

View File

@@ -13,6 +13,7 @@ import { Bot, InputFile } from "grammy";
import WebSocket from "ws"; import WebSocket from "ws";
import sodium from "libsodium-wrappers"; import sodium from "libsodium-wrappers";
import { validateTelegramConnectToken } from "./telegram-token"; import { validateTelegramConnectToken } from "./telegram-token";
import { log } from "./logger";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -22,11 +23,12 @@ export interface BridgeRow {
chatId: number; chatId: number;
meshId: string; meshId: string;
meshSlug?: string; meshSlug?: string;
memberId: string; /** memberId can be null until the bridge claims a mesh.member row. */
memberId: string | null;
pubkey: string; pubkey: string;
secretKey: string; secretKey: string;
displayName: string; displayName: string | null;
chatType: string; chatType: string | null;
chatTitle: string | null; chatTitle: string | null;
} }
@@ -228,7 +230,7 @@ class MeshConnection {
ws.on("message", async (raw) => { ws.on("message", async (raw) => {
try { try {
const msg = JSON.parse(raw.toString()); const msg = JSON.parse(raw.toString()) as Record<string, any>;
if (msg.type === "hello_ack") { if (msg.type === "hello_ack") {
clearTimeout(helloTimeout); clearTimeout(helloTimeout);
@@ -674,8 +676,8 @@ function createPushHandler(bot: Bot) {
for (const chatId of chatIds) { for (const chatId of chatIds) {
bot.api bot.api
.sendMessage(chatId, formatted) .sendMessage(chatId, formatted)
.catch((e) => { .catch((e: unknown) => {
console.error(`[tg-bridge] send to chat ${chatId} failed:`, e.message); console.error(`[tg-bridge] send to chat ${chatId} failed:`, e instanceof Error ? e.message : String(e));
}); });
} }
}; };
@@ -1729,11 +1731,12 @@ async function executeAiToolCall(
for (const meshId of meshIds) { for (const meshId of meshIds) {
const services = await listDbMeshServices(meshId); const services = await listDbMeshServices(meshId);
for (const s of services) { for (const s of services) {
const sx = s as Record<string, unknown>;
allServices.push({ allServices.push({
name: s.name, name: String(sx.name ?? ""),
type: s.type ?? "mcp", type: String(sx.type ?? "mcp"),
tools: s.tool_count ?? 0, tools: Number(sx.tool_count ?? 0),
status: s.status ?? "running", status: String(sx.status ?? "running"),
}); });
} }
} }
@@ -1841,6 +1844,9 @@ export async function bootTelegramBridge(
for (const [meshId, meshRows] of byMesh) { for (const [meshId, meshRows] of byMesh) {
const first = meshRows[0]!; const first = meshRows[0]!;
try { try {
// memberId/displayName come back from DB nullable; bridge only
// works once both are populated, so skip rows missing either.
if (!first.memberId || !first.displayName) continue;
await ensureMeshConnection( await ensureMeshConnection(
{ {
meshId, meshId,

View File

@@ -102,11 +102,11 @@ export function validateTelegramConnectToken(
if (!timingSafeEqual(a, b)) return null; if (!timingSafeEqual(a, b)) return null;
// Verify header algorithm // Verify header algorithm
const header = JSON.parse(base64urlDecode(headerB64)); const header = JSON.parse(base64urlDecode(headerB64)) as { alg?: string };
if (header.alg !== "HS256") return null; if (header.alg !== "HS256") return null;
// Decode and validate claims // Decode and validate claims
const claims: JwtClaims = JSON.parse(base64urlDecode(payloadB64)); const claims = JSON.parse(base64urlDecode(payloadB64)) as JwtClaims;
// Check subject // Check subject
if (claims.sub !== "telegram-connect") return null; if (claims.sub !== "telegram-connect") return null;

View File

@@ -90,6 +90,66 @@ export interface WSHelloMessage {
signature: string; signature: string;
} }
/**
* Client → broker: per-launch session hello, vouched by the parent member.
*
* Used by the daemon's per-session WebSocket connections (1.30.0+) so that
* each `claudemesh launch`-spawned session has its own long-lived presence
* row owned by an ephemeral session keypair. The parent member key vouches
* (out-of-band) that the session pubkey is theirs; the session keypair
* proves liveness on every connect.
*
* Two-stage proof:
* 1. `parentAttestation.signature` — ed25519 over
* `claudemesh-session-attest|<parent_pubkey>|<session_pubkey>|<expires_at_ms>`
* signed by the parent member's stable secret key. TTL ≤ 24h.
* 2. `signature` — ed25519 over
* `claudemesh-session-hello|<mesh_id>|<parent_pubkey>|<session_pubkey>|<timestamp>`
* signed by the session secret key (held by the daemon for the
* lifetime of the session registration).
*
* Older brokers don't recognize this message type and reply with
* `unknown_message_type`; clients fall back to the legacy `hello` flow.
*/
export interface WSSessionHelloMessage {
type: "session_hello";
/** Highest WS protocol version the client understands. */
protocolVersion?: number;
/** Optional feature strings the client supports. */
capabilities?: string[];
meshId: string;
/** Parent member's id (mesh.member.id) — used for revocation lookup. */
parentMemberId: string;
/** Parent member's stable ed25519 pubkey (hex), as found in mesh.member. */
parentMemberPubkey: string;
/** Per-launch ephemeral ed25519 pubkey (hex). Routes presence + DMs. */
sessionPubkey: string;
/** Pre-signed attestation by the parent member, presented per session. */
parentAttestation: {
sessionPubkey: string;
parentMemberPubkey: string;
/** Unix ms; broker rejects past or > now+24h. */
expiresAt: number;
signature: string;
};
/** Display name override for this session (optional, falls back to member). */
displayName?: string;
sessionId: string;
pid: number;
cwd: string;
hostname?: string;
peerType?: "ai" | "human" | "connector";
channel?: string;
model?: string;
groups?: Array<{ name: string; role?: string }>;
/** Initial role tag for the session. */
role?: string;
/** ms epoch; broker rejects if outside ±60s of its own clock. */
timestamp: number;
/** ed25519 signature (hex) by the SESSION secret key over canonical bytes. */
signature: string;
}
/** Client → broker: send an E2E-encrypted envelope to a target. */ /** Client → broker: send an E2E-encrypted envelope to a target. */
export interface WSSendMessage { export interface WSSendMessage {
type: "send"; type: "send";
@@ -110,6 +170,10 @@ export interface WSSendMessage {
* Server validates same-topic membership; FK is set null if parent * Server validates same-topic membership; FK is set null if parent
* later disappears. Ignored for non-topic targets. */ * later disappears. Ignored for non-topic targets. */
replyToId?: string; replyToId?: string;
/** Optional ciphertext-format version. 1 = v1 plaintext base64;
* 2 = v0.3.0 phase 3 per-topic encrypted body. Server passes this
* through verbatim into topic_message.body_version. */
bodyVersion?: number;
} }
/** Broker → client: an envelope addressed to this peer. */ /** Broker → client: an envelope addressed to this peer. */
@@ -1330,6 +1394,16 @@ export interface WSVaultGetMessage { type: "vault_get"; keys: string[]; _reqId?:
export interface WSWatchMessage { type: "watch"; url: string; mode?: "hash" | "json" | "status"; extract?: string; interval?: number; notify_on?: string; headers?: Record<string, string>; label?: string; _reqId?: string; } export interface WSWatchMessage { type: "watch"; url: string; mode?: "hash" | "json" | "status"; extract?: string; interval?: number; notify_on?: string; headers?: Record<string, string>; label?: string; _reqId?: string; }
/** Client → broker: stop watching. */ /** Client → broker: stop watching. */
export interface WSUnwatchMessage { type: "unwatch"; watchId: string; _reqId?: string; } export interface WSUnwatchMessage { type: "unwatch"; watchId: string; _reqId?: string; }
/** Client → broker: soft-disconnect a peer (1000; CLI auto-reconnects). */
export interface WSDisconnectMessage { type: "disconnect"; target?: string; stale?: number; all?: boolean; _reqId?: string; }
/** Client → broker: hard-kick a peer (4001; CLI exits). */
export interface WSKickMessage { type: "kick"; target?: string; stale?: number; all?: boolean; _reqId?: string; }
/** Client → broker: ban a member by pubkey or display name. */
export interface WSBanMessage { type: "ban"; target: string; reason?: string; _reqId?: string; }
/** Client → broker: lift a ban. */
export interface WSUnbanMessage { type: "unban"; target: string; _reqId?: string; }
/** Client → broker: list active bans on the caller's mesh. */
export interface WSListBansMessage { type: "list_bans"; _reqId?: string; }
/** Client → broker: list active watches. */ /** Client → broker: list active watches. */
export interface WSWatchListMessage { type: "watch_list"; _reqId?: string; } export interface WSWatchListMessage { type: "watch_list"; _reqId?: string; }
/** Broker → client: watch created acknowledgement. */ /** Broker → client: watch created acknowledgement. */
@@ -1341,6 +1415,7 @@ export interface WSWatchTriggeredMessage { type: "watch_triggered"; watchId: str
export type WSClientMessage = export type WSClientMessage =
| WSHelloMessage | WSHelloMessage
| WSSessionHelloMessage
| WSSendMessage | WSSendMessage
| WSSetStatusMessage | WSSetStatusMessage
| WSListPeersMessage | WSListPeersMessage
@@ -1433,7 +1508,12 @@ export type WSClientMessage =
| WSVaultGetMessage | WSVaultGetMessage
| WSWatchMessage | WSWatchMessage
| WSUnwatchMessage | WSUnwatchMessage
| WSWatchListMessage; | WSWatchListMessage
| WSDisconnectMessage
| WSKickMessage
| WSBanMessage
| WSUnbanMessage
| WSListBansMessage;
// --- Skill messages --- // --- Skill messages ---
@@ -1485,6 +1565,8 @@ export interface WSSkillDataMessage {
instructions: string; instructions: string;
tags: string[]; tags: string[];
author: string; author: string;
/** Optional opaque metadata stored alongside the skill body. */
manifest?: unknown;
createdAt: string; createdAt: string;
} | null; } | null;
_reqId?: string; _reqId?: string;

View File

@@ -0,0 +1,218 @@
/**
* Session-hello signature + parent-attestation verification.
*
* Two-stage proof:
* 1. Parent member signs `canonicalSessionAttestation` (long-lived, ≤24h
* TTL) — vouches that the session pubkey belongs to them.
* 2. Session keypair signs `canonicalSessionHello` per WS-connect — proves
* liveness + possession.
*
* The broker rejects on any: expired/over-TTL attestation, bad signature,
* timestamp skew, malformed hex, or a session signature made with the
* wrong key (covers the "attestation leaked, attacker tries to use it
* without the session secret key" case).
*/
import { beforeAll, describe, expect, test } from "vitest";
import sodium from "libsodium-wrappers";
import {
canonicalSessionAttestation,
canonicalSessionHello,
verifySessionAttestation,
verifySessionHelloSignature,
SESSION_ATTESTATION_MAX_TTL_MS,
HELLO_SKEW_MS,
} from "../src/crypto";
interface Keypair {
publicKey: string;
secretKey: string;
}
async function makeKeypair(): Promise<Keypair> {
await sodium.ready;
const kp = sodium.crypto_sign_keypair();
return {
publicKey: sodium.to_hex(kp.publicKey),
secretKey: sodium.to_hex(kp.privateKey),
};
}
function sign(canonical: string, secretKeyHex: string): string {
return sodium.to_hex(
sodium.crypto_sign_detached(
sodium.from_string(canonical),
sodium.from_hex(secretKeyHex),
),
);
}
describe("verifySessionAttestation", () => {
let parent: Keypair;
let session: Keypair;
beforeAll(async () => {
parent = await makeKeypair();
session = await makeKeypair();
});
test("valid attestation accepted", async () => {
const expiresAt = Date.now() + 60 * 60 * 1000;
const canonical = canonicalSessionAttestation(parent.publicKey, session.publicKey, expiresAt);
const signature = sign(canonical, parent.secretKey);
const result = await verifySessionAttestation({
parentMemberPubkey: parent.publicKey,
sessionPubkey: session.publicKey,
expiresAt,
signature,
});
expect(result.ok).toBe(true);
});
test("expired attestation rejected", async () => {
const expiresAt = Date.now() - 1_000;
const canonical = canonicalSessionAttestation(parent.publicKey, session.publicKey, expiresAt);
const signature = sign(canonical, parent.secretKey);
const result = await verifySessionAttestation({
parentMemberPubkey: parent.publicKey,
sessionPubkey: session.publicKey,
expiresAt,
signature,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("expired");
});
test("over-24h TTL rejected", async () => {
const expiresAt = Date.now() + SESSION_ATTESTATION_MAX_TTL_MS + 60_000;
const canonical = canonicalSessionAttestation(parent.publicKey, session.publicKey, expiresAt);
const signature = sign(canonical, parent.secretKey);
const result = await verifySessionAttestation({
parentMemberPubkey: parent.publicKey,
sessionPubkey: session.publicKey,
expiresAt,
signature,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("ttl_too_long");
});
test("attestation signed by wrong key rejected", async () => {
const other = await makeKeypair();
const expiresAt = Date.now() + 60 * 60 * 1000;
const canonical = canonicalSessionAttestation(parent.publicKey, session.publicKey, expiresAt);
// Sign with a different parent — verifier still checks against
// claimed parentMemberPubkey, so it should fail.
const signature = sign(canonical, other.secretKey);
const result = await verifySessionAttestation({
parentMemberPubkey: parent.publicKey,
sessionPubkey: session.publicKey,
expiresAt,
signature,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("bad_signature");
});
test("tampered session_pubkey fails (canonical mismatch)", async () => {
const expiresAt = Date.now() + 60 * 60 * 1000;
const canonical = canonicalSessionAttestation(parent.publicKey, session.publicKey, expiresAt);
const signature = sign(canonical, parent.secretKey);
const evil = await makeKeypair();
const result = await verifySessionAttestation({
parentMemberPubkey: parent.publicKey,
sessionPubkey: evil.publicKey, // claim a different session pubkey
expiresAt,
signature,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("bad_signature");
});
test("malformed hex rejected", async () => {
const expiresAt = Date.now() + 60 * 60 * 1000;
const result = await verifySessionAttestation({
parentMemberPubkey: "not-hex",
sessionPubkey: session.publicKey,
expiresAt,
signature: "a".repeat(128),
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("malformed");
});
});
describe("verifySessionHelloSignature", () => {
let parent: Keypair;
let session: Keypair;
beforeAll(async () => {
parent = await makeKeypair();
session = await makeKeypair();
});
test("valid session-hello signature accepted", async () => {
const meshId = "mesh-x";
const timestamp = Date.now();
const canonical = canonicalSessionHello(meshId, parent.publicKey, session.publicKey, timestamp);
const signature = sign(canonical, session.secretKey);
const result = await verifySessionHelloSignature({
meshId,
parentMemberPubkey: parent.publicKey,
sessionPubkey: session.publicKey,
timestamp,
signature,
});
expect(result.ok).toBe(true);
});
test("attacker without session secret key cannot forge session-hello", async () => {
// The hostile case: attacker captured a valid attestation but doesn't
// hold the session secret key. They try to sign session_hello with the
// parent's key — broker checks the signature against sessionPubkey,
// which fails because the parent didn't sign with the session key.
const meshId = "mesh-x";
const timestamp = Date.now();
const canonical = canonicalSessionHello(meshId, parent.publicKey, session.publicKey, timestamp);
const signature = sign(canonical, parent.secretKey); // wrong secret key
const result = await verifySessionHelloSignature({
meshId,
parentMemberPubkey: parent.publicKey,
sessionPubkey: session.publicKey,
timestamp,
signature,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("bad_signature");
});
test("timestamp skew rejected", async () => {
const timestamp = Date.now() - HELLO_SKEW_MS - 1_000;
const canonical = canonicalSessionHello("mesh-x", parent.publicKey, session.publicKey, timestamp);
const signature = sign(canonical, session.secretKey);
const result = await verifySessionHelloSignature({
meshId: "mesh-x",
parentMemberPubkey: parent.publicKey,
sessionPubkey: session.publicKey,
timestamp,
signature,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("timestamp_skew");
});
test("tampered meshId fails verification", async () => {
const timestamp = Date.now();
const canonical = canonicalSessionHello("mesh-A", parent.publicKey, session.publicKey, timestamp);
const signature = sign(canonical, session.secretKey);
const result = await verifySessionHelloSignature({
meshId: "mesh-B", // claim a different mesh
parentMemberPubkey: parent.publicKey,
sessionPubkey: session.publicKey,
timestamp,
signature,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("bad_signature");
});
});

View File

@@ -1,5 +1,332 @@
# Changelog # Changelog
## 1.30.0 (2026-05-04) — per-session broker presence
Sprint A Phase 3. Two `claudemesh launch` sessions in the same cwd now
see each other in `peer list`. Each launched session has a long-lived
broker presence row owned by the daemon, identified by a per-launch
ephemeral keypair vouched by the member's stable key (OAuth-refresh-vs-
access shape).
### What landed
- **broker `session_hello`** — new WS message type. Validates a
parent-vouched `parent_attestation` (≤24h TTL, ed25519 signature by
the parent member) plus a session-keyed signature on the hello
itself. Inserts a presence row keyed on `sessionPubkey` but
`member_id` from the parent, so member-targeted operations stay
unchanged. Older brokers reply `unknown_message_type` — newer clients
drop back to the previous behavior.
- **daemon `SessionBrokerClient`** — slim WS variant of
`DaemonBrokerClient`. Presence-only, no outbox drain. Lifetime tied
to a registry hook: register opens it, deregister/reaper closes it.
Reconnect with exponential backoff up to 30 s.
- **session-registry hooks** — `setRegistryHooks({ onRegister,
onDeregister })` in `apps/cli/src/daemon/session-registry.ts`. Hook
errors are caught so they never throttle the registry. SessionInfo
gains an optional `presence` field carrying the per-launch keypair
+ attestation.
- **IPC `POST /v1/sessions/register`** — accepts an optional
`presence` block on the body (`session_pubkey`, `session_secret_key`,
`parent_attestation`). Older payloads continue to work.
- **`claudemesh launch`** — generates an ed25519 session keypair and a
12 h parent attestation per launch (mesh secret key signs it),
forwards both to the daemon under `body.presence`. Per-session
presence is always on; older brokers that don't recognize
`session_hello` reply `unknown_message_type` and the daemon quietly
drops the per-session WS for that mesh — the regular member-keyed
WS still covers all functionality, the only loss is sibling-session
visibility on that mesh.
- **latent 1.29.0 bug fix** — `claudemesh launch` referenced
`claudeSessionId` before its `const` declaration further down the
file, hitting the temporal dead zone → `ReferenceError` silently
swallowed by the surrounding catch. Net: the IPC session-token
registration has been failing every launch since 1.29.0, falling
every session back to user-level scope. Hoisted the declaration up
so the registration actually runs.
### Sequencing
The broker side ships first and bakes for ~24 h. Older CLIs continue
working unchanged (no per-session WS), and the protocol is purely
additive on the wire.
### Verification (smoke)
In two shells, both `cd ~/Desktop/foo`:
```
$ claudemesh launch --name SessionA -y # shell 1
$ claudemesh launch --name SessionB -y # shell 2
```
In a third shell:
```
$ claudemesh peer list --json --mesh foo \
| jq '.[] | {n: .displayName, c: .cwd}'
{ "n": "SessionA", "c": "/.../foo" } ← persistent, not query-induced
{ "n": "SessionB", "c": "/.../foo" }
```
Inside SessionA, `peer list --mesh foo` now lists SessionB. Kill
SessionB; within ≤30 s the reaper drops it from `peer list`.
### Out of scope (deferred)
- **Attestation auto-refresh** — current 12 h TTL is comfortably
longer than typical sessions; if a session lives past the TTL and
the WS reconnects after expiry, the broker rejects with `expired`
and the SessionBrokerClient quiets. Workaround: `claudemesh launch`
again. Auto-refresh queued for 1.31.0+ alongside HKDF identity.
- **Per-session policy DSL** — the per-launch WS could carry
per-session capabilities later. Out of scope here.
- **Cross-machine session sync** — waits on 2.0.0 HKDF identity.
- **Launch-wizard refactor** — bumped to 1.31.0 to keep this release
scoped to presence.
## 1.29.0 (2026-05-04) — per-session IPC tokens + auto-scoping
Sprint A Phase 2. Every `claudemesh launch`-spawned session gets a
unique 32-byte cryptographic token that the daemon resolves on every
IPC call to identify which session is talking to it. CLI invocations
from inside that session auto-scope to its workspace instead of
aggregating across every joined mesh.
### What landed
- **`services/session/token.ts`** — mint random 32-byte token, write
to `<tmpdir>/session-token` (mode 0o600). Reader pulls from
`CLAUDEMESH_IPC_TOKEN_FILE` env (path, not value, to keep the secret
off `ps eww`). Optional `CLAUDEMESH_IPC_TOKEN` direct-value escape
hatch for tests.
- **`daemon/session-registry.ts`** — in-memory `Map<token,
SessionInfo>` keyed by token, secondary index by sessionId. 30 s
reaper drops entries whose pid is dead; 24 h hard TTL ceiling guards
forgotten sessions.
- **IPC routes** — `POST /v1/sessions/register`, `DELETE
/v1/sessions/:token`, `GET /v1/sessions/me`, `GET /v1/sessions`.
- **IPC auth middleware** — parses `Authorization: ClaudeMesh-Session
<hex>` and attaches the resolved `SessionInfo` to request context.
Layered on top of the existing local-token auth (used for TCP
loopback). Backward-compatible: tokenless callers behave exactly
as before.
- **`services/session/resolve.ts`** — CLI-side helper that asks the
daemon `GET /v1/sessions/me` once per process and caches the result.
Used by verbs that iterate meshes client-side.
- **`launch.ts`** — mints a token, registers it with the daemon, sets
`CLAUDEMESH_IPC_TOKEN_FILE` on the spawned `claude` env. Token file
lives in the same tmpdir as the session config; gets shredded on
cleanup. The daemon's reaper handles dead sessions.
- **`peers.ts`** — selection precedence is now `--mesh` flag → session
token's mesh → all joined meshes.
### Server-side scoping
Every read route that takes `?mesh=<slug>` (peers, state, memory,
skills) now uses a `meshFromCtx()` helper: explicit query/body wins,
session default fills in when missing. Write routes (set state,
remember, deregister, profile-update) follow the same pattern. Pass
`--mesh` to override.
### Verified end-to-end
| Setup | `peer list` returns |
|---|---|
| no token | 3 meshes' peers (aggregate, unchanged) |
| token registered for prueba1 | 4 peers, all `mesh: prueba1` |
### Out of scope (deferred)
- SQLite persistence for the registry — restart loses it; the reaper
(or callers re-registering) covers most cases.
- `SO_PEERCRED`-strict pid binding — needs a tiny native binding.
- Per-session policy DSL.
- Cross-machine session sync (waiting on 2.0.0 HKDF identity).
## 1.28.0 (2026-05-04) — bridge tier deletion + daemon-policy flags
First Sprint A drop on the way to v2 thin-client. Two structural changes:
### Bridge tier deletion
- `services/bridge/{client,server,protocol}.ts` removed (~600 LoC).
These were the per-mesh push-pipe sockets that the legacy MCP shim
used to hold open; the 1.24.0 shim rewrite stopped opening them but
the orphaned client kept being called as a "warm path" tier between
daemon and cold. `tryBridge()` always returned `null` in production
for the last seven releases — pure dead code.
- Each verb now has two paths only: **daemon (with auto-spawn)** →
**cold WS**. Same pattern shipped in 1.27.3, simpler to follow.
- `commands/{peers,send,broker-actions}.ts` — bridge-tier blocks
removed; orphaned `unambiguousMesh` helper removed from
broker-actions.
### `--no-daemon` and `--strict` flags
New per-process daemon policy:
| Flag | Behavior |
|---|---|
| (default) | probe → auto-spawn → retry → cold fallback |
| `--strict` | probe → auto-spawn → retry → **error** if all fail. No cold fallback. |
| `--no-daemon` | skip daemon entirely → straight to cold path. For sandboxed CI / scripts that don't want a daemon. |
Env equivalents: `CLAUDEMESH_STRICT_DAEMON=1`, `CLAUDEMESH_NO_DAEMON=1`.
Flag wins over env. `--no-daemon` and `--strict` are mutually
exclusive (`--no-daemon` wins if both passed).
Strict-mode enforcement lives at `withMesh` (the cold-path entry
point) so a single chokepoint covers every verb. Under `--strict`,
the lifecycle's misleading "using cold path" warning is suppressed
so the user sees one clean error instead of a confusing two-step.
### What's not in this release (planned for the rest of Sprint A)
- 1.29.0: per-session IPC tokens + auto-scoping
- 1.30.0: launch wizard refactor
- 1.31.0: setup wizard refactor
- 1.32.0: full mesh→workspace public-surface rename
- 2.0.0 (separate sprint): HKDF cross-machine identity (security-reviewed)
## 1.27.3 (2026-05-04) — self-healing daemon lifecycle
The CLI now auto-recovers from a dead daemon on every invocation
instead of silently mis-routing through a stale socket.
### What changed
- New `services/daemon/lifecycle.ts` — single helper that probes the
IPC socket via `/v1/version` (instead of trusting `existsSync`),
cleans up stale `daemon.sock` / `daemon.pid` files, and auto-spawns
a detached `claudemesh daemon up` under a file-lock when the daemon
is missing.
- Polls for socket liveness up to a budget (3 s for ad-hoc verbs,
10 s for `claudemesh launch`) before falling through.
- Recently-failed marker (`~/.claudemesh/daemon/.spawn-failure`,
30 s TTL) prevents thundering-herd retries when the daemon
crash-loops at startup.
- Spawn-lock (`~/.claudemesh/daemon/.spawn.lock`) ensures concurrent
CLI invocations share one spawn attempt instead of racing.
- Per-process result cache — a script doing 50 sends pays the spawn
cost at most once, not 50 times.
- Recursion guard via `CLAUDEMESH_INTERNAL_NO_AUTOSPAWN=1` env (set
on the spawned daemon's env) so nested CLI calls inside the daemon
process don't re-trigger spawn.
### User-visible behavior
- `peer list`, `send`, `state get`, etc. now restart the daemon
automatically when invoked while the daemon is down.
- One-line stderr info on auto-restart:
`[claudemesh] info daemon restarted automatically (took 615ms)`.
- Cold-path fallback fires only when auto-spawn fails or is
suppressed by the recently-failed marker; in those cases a `warn`
line points at the daemon log.
### Bug fixed
`claudemesh launch`'s `ensureDaemonRunning` previously checked only
`existsSync(SOCK_FILE)` and returned early on a stale socket left by
a crashed daemon — silently breaking new sessions. Now delegates to
the lifecycle helper which probes the socket and recovers.
### What's not in this patch
- `--strict` and `--no-daemon` flags (deferred to D in 1.28.0).
- Lazy-loading of cold-path code (deferred to 1.28.0).
- Per-session IPC tokens (deferred to 1.28.0 alongside D's
thin-client conversion).
## 1.27.2 (2026-05-04) — skill: full-flag launch templates
Documentation-only ship. `skills/claudemesh/SKILL.md` gains a canonical
"fully-populated spawn" recipe under "Wizard-free spawn templates" —
every flag set explicitly, with a per-position annotation table — so
agents and humans copy-paste a known-good kitchen-sink command instead
of stitching one together from the flag table.
Also corrects two pre-existing inaccuracies:
- `--system-prompt` was documented as forwarding to
`claude --append-system-prompt`. It actually forwards to
`claude --system-prompt` (overrides the default; pass a string, not a
path).
- `-q` was listed as a synonym for `--quiet`. The argv parser treats
short flags (`-X`) and long flags (`--xyz`) as separate keys; only
`--quiet` is wired. `-q` is currently a no-op.
Carries a note that all twelve launch flags are end-to-end wired only as
of `claudemesh-cli@1.27.1`.
## 1.27.1 (2026-05-04) — wire missing launch flags
Fixes a wiring bug in `apps/cli/src/entrypoints/cli.ts` where six flags
declared on `LaunchFlags` were silently dropped on the way to
`runLaunch`. They were honored *inside* `runLaunch` if they ever arrived,
but the four `runLaunch({...})` call sites in the CLI entrypoint each
forwarded a hardcoded 5-key subset (`mesh, name, join, yes, resume`).
Now forwarded at every entry point (bare command, bare invite URL,
`launch`/`connect`, `workspace launch`):
- `--role <r>` — sets session role; previously only settable via wizard.
- `--groups "frontend:lead,reviewers"` — comma-separated groups string.
- `--message-mode push|inbox|off` — message delivery mode.
- `--system-prompt <text>` — passes through to `claude`.
- `--continue` — passes through to `claude` to continue last session.
- `--quiet` — actually suppresses the wizard and banner now. Previously
it was a complete no-op flag at the CLI layer.
No internal logic changed; the launch internals already read these.
This is a pure plumbing fix.
## 1.27.0 (2026-05-04) — state + memory through the daemon, workspace alias
Two more verb families now route through the local daemon's IPC for the
warm path: `state get/set/list` and `remember/recall/forget`. Same
pattern as 1.25.0 for peers/skills — try the socket first (~1 ms warm),
fall back to the cold WS path when the daemon isn't running.
### What changed
- `claudemesh state get|set|list` route through `/v1/state` when the
daemon socket is present. `--mesh <slug>` forwards as a query/body
field. Single-mesh daemons auto-pick; multi-mesh daemons require
`--mesh` for `state set`.
- `claudemesh remember`, `claudemesh recall`, `claudemesh forget`
(and `claudemesh memory <sub>`) route through `/v1/memory`.
Aggregates across attached meshes for `recall`; requires `--mesh`
for `remember`/`forget` when ambiguous.
- New `claudemesh workspace <verb>` alias surface — early teaser for
the 1.28.0 mesh→workspace public rename. Mirrors `list`, `info`,
`create`, `join`, `delete`, `rename`, `share`, `launch`, `overview`.
No-arg `claudemesh workspace` falls through to `launch` (same as
bare `claudemesh`).
### IPC surface
- `GET /v1/state` — list (`?mesh=<slug>` filter) or single key lookup
(`?key=<k>&mesh=<slug>`). Returns 404 with `{ error: "state_not_found" }`
when missing.
- `POST /v1/state` — `{ key, value, mesh? }`. 400 + attached list when
multi-mesh and no `mesh` field.
- `GET /v1/memory?q=<query>&mesh=<slug>` — recall. Aggregates across
meshes, each match tagged with its `mesh` field.
- `POST /v1/memory` — `{ content, tags?, mesh? }`. Returns
`{ id, mesh }`.
- `DELETE /v1/memory/:id?mesh=<slug>` — forget.
- `ipc_features` gains `state` and `memory` keys.
### Why this matters
State and memory were the last verbs that opened a fresh broker WS on
every invocation. Now they reuse the daemon's existing connection — the
warm-path latency cliff (~150 ms cold WS handshake → ~1 ms IPC) extends
to two more flows agents poll heavily.
The `workspace` alias is cosmetic but lays the groundwork for 1.28.0's
documented rename without breaking anyone's muscle memory.
## 1.26.0 (2026-05-04) — multi-mesh daemon ## 1.26.0 (2026-05-04) — multi-mesh daemon
The daemon now attaches to **all joined meshes simultaneously** by The daemon now attaches to **all joined meshes simultaneously** by

View File

@@ -1,6 +1,6 @@
{ {
"name": "claudemesh-cli", "name": "claudemesh-cli",
"version": "1.26.0", "version": "1.30.0",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [ "keywords": [
"claude-code", "claude-code",

View File

@@ -80,15 +80,56 @@ Once `claudemesh install` has run (registers MCP entry + starts daemon service),
| `--groups "name:role,name2:role2,all"` | the group selection prompt | comma-separated `<groupname>:<role>` entries; the literal `all` joins `@all` | | `--groups "name:role,name2:role2,all"` | the group selection prompt | comma-separated `<groupname>:<role>` entries; the literal `all` joins `@all` |
| `--role <lead\|member\|observer>` | the role prompt | applied to all groups in `--groups` that didn't specify their own | | `--role <lead\|member\|observer>` | the role prompt | applied to all groups in `--groups` that didn't specify their own |
| `--message-mode <push\|inbox>` | the message-mode prompt | `push` (default) emits `<channel>` notifications mid-turn; `inbox` only buffers — quieter for headless agents | | `--message-mode <push\|inbox>` | the message-mode prompt | `push` (default) emits `<channel>` notifications mid-turn; `inbox` only buffers — quieter for headless agents |
| `--system-prompt <path>` | nothing — pure pass-through | forwarded to `claude --append-system-prompt` | | `--system-prompt <text>` | nothing — pure pass-through | forwarded to `claude --system-prompt` (overrides default; pass a string, not a path) |
| `--resume <session-id>` | nothing — pure pass-through | forwarded to `claude --resume` to continue a prior Claude Code session | | `--resume <session-id>` | nothing — pure pass-through | forwarded to `claude --resume` to continue a prior Claude Code session |
| `--continue` | nothing — pure pass-through | forwarded to `claude --continue` | | `--continue` | nothing — pure pass-through | forwarded to `claude --continue` (resumes the last session in this cwd) |
| `-y` / `--yes` | every confirmation prompt | including the "you'll skip ALL permission prompts" gate. **Use for autonomous agents; omit for shared/multi-person meshes.** | | `-y` / `--yes` | every confirmation prompt | including the "you'll skip ALL permission prompts" gate. **Use for autonomous agents; omit for shared/multi-person meshes.** |
| `-q` / `--quiet` | the welcome banner | useful when the spawning script wants clean stdout | | `--quiet` | the wizard + welcome banner | suppresses the launch wizard and banner. Combine with `-y` for true headless: `--quiet` alone won't bypass Claude's permission prompts, so a script using only `--quiet` will hang on the first tool call. |
| `--` | (separator) | everything after `--` is forwarded verbatim to `claude`. Example: `claudemesh launch --name X -y -- --resume abc123 --model opus` | | `--` | (separator) | everything after `--` is forwarded verbatim to `claude`. Example: `claudemesh launch --name X -y -- --resume abc123 --model opus` |
> **All twelve flags are end-to-end wired as of `claudemesh-cli@1.27.1`.** Earlier builds silently dropped `--role`, `--groups`, `--message-mode`, `--system-prompt`, `--continue`, and `--quiet` at the CLI entrypoint — they were declared but never reached `runLaunch`. If a script targets older versions, those flags are no-ops.
### Wizard-free spawn templates ### Wizard-free spawn templates
#### Canonical fully-populated spawn (every flag set explicitly)
The kitchen-sink form — copy, set every value, and the session boots without a single interactive prompt or banner. Use as a base when scripting from cron, hooks, CI, or another agent:
```bash
claudemesh launch \
--name "ci-bot" \
--mesh openclaw \
--role member \
--groups "frontend:lead,reviewers:observer,all" \
--message-mode inbox \
--system-prompt "$(cat ~/agents/ci-bot.md)" \
--quiet \
-y \
-- \
--model opus \
--resume "$LAST_SESSION_ID"
```
Annotated:
| Position | Value | Effect |
|---|---|---|
| `--name "ci-bot"` | identity | what peers see in `peer list` and `<channel from_name>` — pin so peers always see the same name across machines |
| `--mesh openclaw` | workspace | required when you have ≥2 joined meshes; safe to include even with 1 (becomes a no-op assertion) |
| `--role member` | session label | free-form tag used by group conventions; common values: `lead`, `member`, `observer`, `bot`, `oncall` |
| `--groups "frontend:lead,..."` | group memberships | comma-separated `<group>:<role>` pairs; bare `all` joins `@all` with no role |
| `--message-mode inbox` | delivery | `push` interrupts mid-turn (default); `inbox` buffers silently; `off` disables messages but keeps tool calls |
| `--system-prompt "..."` | claude system prompt | overrides Claude's default. Pass a string, not a path — wrap with `$(cat …)` if you keep prompts in files |
| `--quiet` | output | suppress the wizard and banner — clean stdout for the spawning script |
| `-y` | consent | skips every permission prompt (claudemesh's policy gate **and** Claude's `--dangerously-skip-permissions`). Required for true headless |
| `--` | separator | everything after is passed verbatim to `claude` |
| `--model opus` | claude flag | example claude-side override |
| `--resume "$LAST_SESSION_ID"` | claude flag | resume a prior Claude session inside this mesh identity |
**Rule of thumb:** for any unattended spawn, the minimum is `--name + --mesh + -y + --quiet`. Add `--system-prompt` to seed task context, `--message-mode inbox` to keep the bot quiet, and `--role` + `--groups` so peers know how to address it. Drop `--quiet` when a human is watching the script's stdout.
#### Trimmed templates
```bash ```bash
# Minimal — single joined mesh, fresh agent, autonomous: # Minimal — single joined mesh, fresh agent, autonomous:
claudemesh launch --name "Lug Nut" -y claudemesh launch --name "Lug Nut" -y
@@ -109,9 +150,9 @@ claudemesh launch --name "Mou" --mesh openclaw -y -- --resume abc123-...
# Quiet, headless, system-prompt loaded — for cron / hooks: # Quiet, headless, system-prompt loaded — for cron / hooks:
claudemesh launch --name "ci-bot" --mesh openclaw \ claudemesh launch --name "ci-bot" --mesh openclaw \
--system-prompt /path/to/ci-bot.md \ --system-prompt "$(cat ~/agents/ci-bot.md)" \
--message-mode inbox \ --message-mode inbox \
-q -y --quiet -y
``` ```
If any required flag is missing AND stdin is a TTY, `launch` falls back to its prompt for that single field. **In a non-TTY context (Bash tool, cron, AppleScript pipe), missing flags cause the verb to fail-closed — never silently use a default that affects identity.** If any required flag is missing AND stdin is a TTY, `launch` falls back to its prompt for that single field. **In a non-TTY context (Bash tool, cron, AppleScript pipe), missing flags cause the verb to fail-closed — never silently use a default that affects identity.**

View File

@@ -15,8 +15,7 @@
*/ */
import { withMesh } from "./connect.js"; import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js"; import { tryForgetViaDaemon } from "~/services/bridge/daemon-route.js";
import { tryBridge } from "~/services/bridge/client.js";
import { render } from "~/ui/render.js"; import { render } from "~/ui/render.js";
import { bold, clay, dim } from "~/ui/styles.js"; import { bold, clay, dim } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js"; import { EXIT } from "~/constants/exit-codes.js";
@@ -25,14 +24,6 @@ import { validateMessageId, renderValidationError } from "~/cli/validators.js";
type StateFlags = { mesh?: string; json?: boolean }; type StateFlags = { mesh?: string; json?: boolean };
type PeerStatus = "idle" | "working" | "dnd"; type PeerStatus = "idle" | "working" | "dnd";
/** Resolve unambiguous mesh slug for warm-path bridging. Returns null if
* the user has multiple joined meshes and didn't pick one. */
function unambiguousMesh(opts: StateFlags): string | null {
if (opts.mesh) return opts.mesh;
const config = readConfig();
return config.meshes.length === 1 ? config.meshes[0]!.slug : null;
}
// --- status --- // --- status ---
export async function runStatusSet(state: string, opts: StateFlags): Promise<number> { export async function runStatusSet(state: string, opts: StateFlags): Promise<number> {
@@ -42,21 +33,9 @@ export async function runStatusSet(state: string, opts: StateFlags): Promise<num
return EXIT.INVALID_ARGS; return EXIT.INVALID_ARGS;
} }
// Warm path // Bridge tier deleted in 1.28.0 (dead code; the orphaned warm-path
const meshSlug = unambiguousMesh(opts); // socket was never opened by anyone). Daemon route would belong here;
if (meshSlug) { // adding it for status/summary/visible is queued for 1.29.0.
const bridged = await tryBridge(meshSlug, "status_set", { status: state });
if (bridged !== null) {
if (bridged.ok) {
if (opts.json) console.log(JSON.stringify({ status: state }));
else render.ok(`status set to ${bold(state)}`);
return EXIT.SUCCESS;
}
render.err(bridged.error);
return EXIT.INTERNAL_ERROR;
}
}
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => { await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.setStatus(state as PeerStatus); await client.setStatus(state as PeerStatus);
}); });
@@ -73,21 +52,6 @@ export async function runSummary(text: string, opts: StateFlags): Promise<number
return EXIT.INVALID_ARGS; return EXIT.INVALID_ARGS;
} }
// Warm path
const meshSlug = unambiguousMesh(opts);
if (meshSlug) {
const bridged = await tryBridge(meshSlug, "summary", { summary: text });
if (bridged !== null) {
if (bridged.ok) {
if (opts.json) console.log(JSON.stringify({ summary: text }));
else render.ok("summary set", dim(text));
return EXIT.SUCCESS;
}
render.err(bridged.error);
return EXIT.INTERNAL_ERROR;
}
}
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => { await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.setSummary(text); await client.setSummary(text);
}); });
@@ -107,21 +71,6 @@ export async function runVisible(value: string | undefined, opts: StateFlags): P
return EXIT.INVALID_ARGS; return EXIT.INVALID_ARGS;
} }
// Warm path
const meshSlug = unambiguousMesh(opts);
if (meshSlug) {
const bridged = await tryBridge(meshSlug, "visible", { visible });
if (bridged !== null) {
if (bridged.ok) {
if (opts.json) console.log(JSON.stringify({ visible }));
else render.ok(visible ? "you are now visible to peers" : "you are now hidden", visible ? undefined : "direct messages still reach you");
return EXIT.SUCCESS;
}
render.err(bridged.error);
return EXIT.INTERNAL_ERROR;
}
}
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => { await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.setVisible(visible); await client.setVisible(visible);
}); });
@@ -173,6 +122,14 @@ export async function runForget(id: string | undefined, opts: StateFlags): Promi
render.err("Usage: claudemesh forget <memory-id>"); render.err("Usage: claudemesh forget <memory-id>");
return EXIT.INVALID_ARGS; return EXIT.INVALID_ARGS;
} }
// Daemon path first.
if (await tryForgetViaDaemon(id, opts.mesh)) {
if (opts.json) { console.log(JSON.stringify({ id, forgotten: true })); return EXIT.SUCCESS; }
render.ok(`forgot ${dim(id.slice(0, 8))}`);
return EXIT.SUCCESS;
}
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => { await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.forget(id); await client.forget(id);
}); });
@@ -237,7 +194,7 @@ export async function runMsgStatus(id: string | undefined, opts: StateFlags): Pr
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));
return EXIT.SUCCESS; return EXIT.SUCCESS;
} }
render.section(`message ${id.slice(0, 12)}`); render.section(`message ${lookupId.slice(0, 12)}`);
render.kv([ render.kv([
["target", result.targetSpec], ["target", result.targetSpec],
["delivered", result.delivered ? "yes" : "no"], ["delivered", result.delivered ? "yes" : "no"],

View File

@@ -10,6 +10,7 @@ import { createInterface } from "node:readline";
import { BrokerClient } from "~/services/broker/facade.js"; import { BrokerClient } from "~/services/broker/facade.js";
import { readConfig } from "~/services/config/facade.js"; import { readConfig } from "~/services/config/facade.js";
import type { JoinedMesh } from "~/services/config/facade.js"; import type { JoinedMesh } from "~/services/config/facade.js";
import { getDaemonPolicy } from "~/services/daemon/policy.js";
export interface ConnectOpts { export interface ConnectOpts {
/** Mesh slug to connect to. Auto-selects if only one mesh joined. */ /** Mesh slug to connect to. Auto-selects if only one mesh joined. */
@@ -46,6 +47,18 @@ export async function withMesh<T>(
opts: ConnectOpts, opts: ConnectOpts,
fn: (client: BrokerClient, mesh: JoinedMesh) => Promise<T>, fn: (client: BrokerClient, mesh: JoinedMesh) => Promise<T>,
): Promise<T> { ): Promise<T> {
// --strict gate: every cold-path verb funnels through here, so a single
// policy check covers the whole CLI surface. The daemon-routing helpers
// already returned null (auto-spawn failed); under --strict we refuse
// the cold-path fallback and exit loudly instead.
if (getDaemonPolicy().mode === "strict") {
console.error(
"\n ✘ daemon not reachable — --strict refuses cold-path fallback.\n" +
" run `claudemesh daemon up` (or `claudemesh doctor`) and retry.\n",
);
process.exit(1);
}
const config = readConfig(); const config = readConfig();
if (config.meshes.length === 0) { if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first."); console.error("No meshes joined. Run `claudemesh join <url>` first.");

View File

@@ -49,46 +49,28 @@ export interface LaunchFlags {
* *
* As of 1.24.0 the daemon owns the broker WS and feeds the MCP push-pipe * As of 1.24.0 the daemon owns the broker WS and feeds the MCP push-pipe
* over IPC SSE. If the socket is absent when Claude boots its MCP shim, * over IPC SSE. If the socket is absent when Claude boots its MCP shim,
* the shim bails (no fallback). So we probe for the socket here and, if * the shim bails (no fallback). Delegates to the shared lifecycle helper
* missing, spawn `claudemesh daemon up --mesh <slug>` in the background, * (services/daemon/lifecycle.ts) which probes the socket properly
* waiting briefly for the socket to appear. * (avoiding the stale-socket bug where existsSync was a false positive
* * after a daemon crash), spawns under a file-lock, and polls for liveness.
* Best-effort: if the daemon spawn fails, we surface the error and let
* the launch proceed — Claude Code will print the same "daemon not
* running" message and the user can fix it manually.
*/ */
async function ensureDaemonRunning(meshSlug: string, quiet: boolean): Promise<void> { async function ensureDaemonRunning(meshSlug: string, quiet: boolean): Promise<void> {
const { DAEMON_PATHS } = await import("~/daemon/paths.js"); const { ensureDaemonReady } = await import("~/services/daemon/lifecycle.js");
if (existsSync(DAEMON_PATHS.SOCK_FILE)) return; if (!quiet) render.info("ensuring claudemesh daemon is running…");
// Larger budget for `launch` — it's a one-shot flow where the user
if (!quiet) render.info("starting claudemesh daemon…"); // is actively waiting; cold node start + broker hello can take
const { spawn } = await import("node:child_process"); // longer than the default 3s budget for ad-hoc verbs.
const argv0 = process.argv[1] ?? "claudemesh"; const res = await ensureDaemonReady({ budgetMs: 10_000, mesh: meshSlug });
let binary = argv0; if (res.state === "up") {
if (/\.ts$/.test(binary) || /node_modules|src\/entrypoints/.test(binary)) { if (!quiet) render.ok("daemon already running");
try { return;
const { execSync } = await import("node:child_process");
binary = execSync("which claudemesh", { encoding: "utf8" }).trim();
} catch { binary = "claudemesh"; }
} }
const child = spawn(binary, ["daemon", "up", "--mesh", meshSlug], { if (res.state === "started") {
detached: true, if (!quiet) render.ok(`daemon ready (${res.durationMs}ms)`);
stdio: "ignore", return;
});
child.unref();
// Wait for the socket to appear. 10 s budget — covers cold node start +
// broker hello round-trip on slow links.
const start = Date.now();
while (Date.now() - start < 10_000) {
if (existsSync(DAEMON_PATHS.SOCK_FILE)) {
if (!quiet) render.ok("daemon ready");
return;
}
await new Promise((r) => setTimeout(r, 200));
} }
render.warn( render.warn(
"daemon failed to start within 10s", `daemon ${res.state}${res.reason ? `: ${res.reason}` : ""}`,
"Run `claudemesh daemon up --mesh " + meshSlug + "` manually, then re-launch.", "Run `claudemesh daemon up --mesh " + meshSlug + "` manually, then re-launch.",
); );
} }
@@ -671,6 +653,102 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
"utf-8", "utf-8",
); );
// 4b. Mint a per-session IPC token, persist it under tmpDir, and
// register it with the daemon. The token's path is exposed to
// the spawned claude (and all its descendants) via env so
// CLI invocations from inside the session auto-attribute to it.
//
// 1.30.0: also mint an ephemeral ed25519 session keypair and a
// parent-vouched attestation. The daemon uses these to open a
// long-lived broker WebSocket per session (presence row keyed on
// the session pubkey, member_id from the parent), so sibling
// sessions in the same mesh see each other in `peer list`.
//
// Session-id resolution: 1.29.0 referenced `claudeSessionId`
// before its `const` declaration further down the file, hitting
// the TDZ → ReferenceError swallowed by the surrounding catch.
// The IPC registration has been silently failing every launch
// since 1.29.0. Hoist the declaration up so it actually runs.
const isResume = args.resume !== null || args.continueSession;
const claudeSessionId = isResume ? undefined : randomUUID();
let sessionTokenFilePath: string | null = null;
let sessionTokenForCleanup: string | null = null;
try {
const { mintSessionToken, TOKEN_FILE_ENV } = await import("~/services/session/token.js");
const minted = mintSessionToken(tmpDir);
sessionTokenFilePath = minted.filePath;
sessionTokenForCleanup = minted.token;
// Per-session ephemeral keypair + parent attestation (1.30.0+).
// Older daemons ignore unknown body fields, so sending presence
// material always is forward-compatible.
let presencePayload: {
session_pubkey: string;
session_secret_key: string;
parent_attestation: {
session_pubkey: string;
parent_member_pubkey: string;
expires_at: number;
signature: string;
};
} | undefined;
try {
const { generateKeypair } = await import("~/services/crypto/facade.js");
const { signParentAttestation } = await import("~/services/broker/session-hello-sig.js");
const sessionKp = await generateKeypair();
const att = await signParentAttestation({
parentMemberPubkey: mesh.pubkey,
parentSecretKey: mesh.secretKey,
sessionPubkey: sessionKp.publicKey,
});
presencePayload = {
session_pubkey: sessionKp.publicKey,
session_secret_key: sessionKp.secretKey,
parent_attestation: {
session_pubkey: att.sessionPubkey,
parent_member_pubkey: att.parentMemberPubkey,
expires_at: att.expiresAt,
signature: att.signature,
},
};
} catch {
// Keypair / attestation failure — proceed without per-session
// presence. The session still registers; only the broker-side
// presence row is skipped.
}
// Register with the daemon. Best-effort: a daemon failure here
// means the session falls back to user-level scope, which is fine.
const { ipc } = await import("~/daemon/ipc/client.js");
const sessionIdForRegister = claudeSessionId ?? randomUUID();
await ipc({
method: "POST",
path: "/v1/sessions/register",
timeoutMs: 3_000,
body: {
token: minted.token,
session_id: sessionIdForRegister,
mesh: mesh.slug,
display_name: displayName,
pid: process.pid,
cwd: process.cwd(),
...(role ? { role } : {}),
...(parsedGroups.length > 0 ? { groups: parsedGroups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`) } : {}),
...(presencePayload ? { presence: presencePayload } : {}),
},
}).catch(() => null);
// Pin the env name on a global so the spawn block below can pick it up.
(process as unknown as { _claudemeshTokenEnv?: { name: string; value: string } })._claudemeshTokenEnv = {
name: TOKEN_FILE_ENV,
value: minted.filePath,
};
} catch {
// Token mint or registration failed — proceed without per-session
// attribution. CLI invocations from the session will still work,
// they'll just default to user-level scope.
}
// 5. Print summary banner (wizard already handled all interactive config). // 5. Print summary banner (wizard already handled all interactive config).
if (!args.quiet) { if (!args.quiet) {
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode); printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
@@ -744,10 +822,8 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
// passes -y / --yes. Without it, claudemesh tools still work because // passes -y / --yes. Without it, claudemesh tools still work because
// `claudemesh install` pre-approves them via allowedTools in settings.json. // `claudemesh install` pre-approves them via allowedTools in settings.json.
// This keeps permissions tight for multi-person meshes. // This keeps permissions tight for multi-person meshes.
// Session identity: --resume reuses existing session, otherwise generate new. // Session identity: claudeSessionId was generated above (4b) so the
// When resuming, Claude Code reuses the session ID so the mesh peer identity persists. // session-token registration could include it. Reuse here.
const isResume = args.resume !== null || args.continueSession;
const claudeSessionId = isResume ? undefined : randomUUID();
const claudeArgs = [ const claudeArgs = [
"--dangerously-load-development-channels", "--dangerously-load-development-channels",
@@ -792,7 +868,14 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8"); writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
} catch { /* best effort */ } } catch { /* best effort */ }
} }
// Ephemeral config dir // The token's session-token file lives inside tmpDir; rmSync below
// shreds the secret. The daemon's session reaper notices the
// launched session's pid is gone within 30s and drops the registry
// entry. Explicit DELETE on /v1/sessions is feasible only from an
// async exit hook, which adds complexity for ~30s of memory the
// reaper will reclaim anyway. Leaving as-is; revisit if the
// registry ever grows persistence.
// Ephemeral config dir (also drops the session-token file)
try { try {
rmSync(tmpDir, { recursive: true, force: true }); rmSync(tmpDir, { recursive: true, force: true });
} catch { /* best effort */ } } catch { /* best effort */ }
@@ -854,6 +937,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
CLAUDEMESH_CONFIG_DIR: tmpDir, CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName, CLAUDEMESH_DISPLAY_NAME: displayName,
...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}), ...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}),
...(sessionTokenFilePath ? { CLAUDEMESH_IPC_TOKEN_FILE: sessionTokenFilePath } : {}),
MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000", MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000",
MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000", MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000",
...(role ? { CLAUDEMESH_ROLE: role } : {}), ...(role ? { CLAUDEMESH_ROLE: role } : {}),

View File

@@ -14,7 +14,6 @@
import { withMesh } from "./connect.js"; import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js"; import { readConfig } from "~/services/config/facade.js";
import { tryBridge } from "~/services/bridge/client.js";
import { render } from "~/ui/render.js"; import { render } from "~/ui/render.js";
import { bold, dim, green, yellow } from "~/ui/styles.js"; import { bold, dim, green, yellow } from "~/ui/styles.js";
@@ -68,7 +67,10 @@ async function listPeersForMesh(slug: string): Promise<PeerRecord[]> {
const selfMemberPubkey = joined?.pubkey ?? null; const selfMemberPubkey = joined?.pubkey ?? null;
// Daemon path — preferred when running. Same routing pattern as send.ts: // Daemon path — preferred when running. Same routing pattern as send.ts:
// ~1 ms IPC round-trip; broker WS already warm in the daemon. // ~1 ms IPC round-trip; broker WS already warm in the daemon. The
// lifecycle helper inside tryListPeersViaDaemon auto-spawns the
// daemon if it's down and probes it for liveness — no separate bridge
// tier is needed any more (1.28.0).
try { try {
const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js"); const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js");
const dr = await tryListPeersViaDaemon(); const dr = await tryListPeersViaDaemon();
@@ -77,13 +79,8 @@ async function listPeersForMesh(slug: string): Promise<PeerRecord[]> {
} }
} catch { /* daemon route helper not available; fall through */ } } catch { /* daemon route helper not available; fall through */ }
// Try warm bridge path next. // Cold path — open our own WS. Reached only when the lifecycle helper
const bridged = await tryBridge(slug, "peers"); // could not bring the daemon up.
if (bridged && bridged.ok) {
const peers = bridged.result as PeerRecord[];
return peers.map((p) => annotateSelf(p, selfMemberPubkey, null));
}
// Cold path — open our own WS.
let result: PeerRecord[] = []; let result: PeerRecord[] = [];
await withMesh({ meshSlug: slug }, async (client) => { await withMesh({ meshSlug: slug }, async (client) => {
const all = (await client.listPeers()) as unknown as PeerRecord[]; const all = (await client.listPeers()) as unknown as PeerRecord[];
@@ -122,7 +119,19 @@ function annotateSelf(
export async function runPeers(flags: PeersFlags): Promise<void> { export async function runPeers(flags: PeersFlags): Promise<void> {
const config = readConfig(); const config = readConfig();
const slugs = flags.mesh ? [flags.mesh] : config.meshes.map((m) => m.slug);
// Mesh selection precedence:
// 1. explicit --mesh <slug> (always wins)
// 2. session-token mesh (when invoked from inside a launched session)
// 3. all joined meshes (default for bare shells)
let slugs: string[];
if (flags.mesh) {
slugs = [flags.mesh];
} else {
const { getSessionInfo } = await import("~/services/session/resolve.js");
const sess = await getSessionInfo();
slugs = sess ? [sess.mesh] : config.meshes.map((m) => m.slug);
}
if (slugs.length === 0) { if (slugs.length === 0) {
render.err("No meshes joined."); render.err("No meshes joined.");

View File

@@ -1,4 +1,5 @@
import { withMesh } from "./connect.js"; import { withMesh } from "./connect.js";
import { tryRecallViaDaemon } from "~/services/bridge/daemon-route.js";
import { render } from "~/ui/render.js"; import { render } from "~/ui/render.js";
import { bold, clay, dim } from "~/ui/styles.js"; import { bold, clay, dim } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js"; import { EXIT } from "~/constants/exit-codes.js";
@@ -11,6 +12,22 @@ export async function recall(
render.err("Usage: claudemesh recall <query>"); render.err("Usage: claudemesh recall <query>");
return EXIT.INVALID_ARGS; return EXIT.INVALID_ARGS;
} }
// Daemon path first.
const daemonMatches = await tryRecallViaDaemon(query, opts.mesh);
if (daemonMatches !== null) {
if (opts.json) { console.log(JSON.stringify(daemonMatches, null, 2)); return EXIT.SUCCESS; }
if (daemonMatches.length === 0) { render.info(dim("no memories found.")); return EXIT.SUCCESS; }
render.section(`memories (${daemonMatches.length})`);
for (const m of daemonMatches) {
const tags = m.tags.length ? dim(` [${m.tags.map((t) => clay(t)).join(dim(", "))}]`) : "";
process.stdout.write(` ${bold(m.id.slice(0, 8))}${tags}\n`);
process.stdout.write(` ${m.content}\n`);
process.stdout.write(` ${dim(m.rememberedBy + " · " + new Date(m.rememberedAt).toLocaleString())}\n\n`);
}
return EXIT.SUCCESS;
}
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => { return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const memories = await client.recall(query); const memories = await client.recall(query);

View File

@@ -1,4 +1,5 @@
import { withMesh } from "./connect.js"; import { withMesh } from "./connect.js";
import { tryRememberViaDaemon } from "~/services/bridge/daemon-route.js";
import { render } from "~/ui/render.js"; import { render } from "~/ui/render.js";
import { dim } from "~/ui/styles.js"; import { dim } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js"; import { EXIT } from "~/constants/exit-codes.js";
@@ -12,6 +13,18 @@ export async function remember(
return EXIT.INVALID_ARGS; return EXIT.INVALID_ARGS;
} }
const tags = opts.tags?.split(",").map((t) => t.trim()).filter(Boolean); const tags = opts.tags?.split(",").map((t) => t.trim()).filter(Boolean);
// Daemon path first.
const daemonRes = await tryRememberViaDaemon(content, tags, opts.mesh);
if (daemonRes) {
if (opts.json) {
console.log(JSON.stringify({ id: daemonRes.id, content, tags, mesh: daemonRes.mesh }));
return EXIT.SUCCESS;
}
render.ok("remembered", dim(daemonRes.id.slice(0, 8)));
return EXIT.SUCCESS;
}
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => { return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const id = await client.remember(content, tags); const id = await client.remember(content, tags);

View File

@@ -13,7 +13,6 @@
import { withMesh } from "./connect.js"; import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js"; import { readConfig } from "~/services/config/facade.js";
import { tryBridge } from "~/services/bridge/client.js";
import { trySendViaDaemon } from "~/services/bridge/daemon-route.js"; import { trySendViaDaemon } from "~/services/bridge/daemon-route.js";
import type { Priority } from "~/services/broker/facade.js"; import type { Priority } from "~/services/broker/facade.js";
import { render } from "~/ui/render.js"; import { render } from "~/ui/render.js";
@@ -82,34 +81,12 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
else render.err(`send failed (daemon): ${dr.error}`); else render.err(`send failed (daemon): ${dr.error}`);
process.exit(1); process.exit(1);
} }
// dr === null → daemon not running; fall through to bridge. // dr === null → daemon not running and lifecycle couldn't auto-
// spawn it; fall through to cold path. The orphaned bridge tier
// was removed in 1.28.0.
} }
// Warm path — only when mesh is unambiguous. // Cold path — open our own WS, encrypt locally, fire envelope.
if (meshSlug) {
const bridged = await tryBridge(meshSlug, "send", { to, message, priority });
if (bridged !== null) {
if (bridged.ok) {
const r = bridged.result as { messageId?: string };
if (flags.json) {
console.log(JSON.stringify({ ok: true, messageId: r.messageId, target: to }));
} else {
render.ok(`sent to ${to}`, r.messageId ? dim(r.messageId.slice(0, 8)) : undefined);
}
return;
}
// Bridge reachable but op failed — surface error, don't fall through.
if (flags.json) {
console.log(JSON.stringify({ ok: false, error: bridged.error }));
} else {
render.err(`send failed: ${bridged.error}`);
}
process.exit(1);
}
// bridged === null → bridge unreachable, fall through to cold path
}
// Cold path
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => { await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
let targetSpec = to; let targetSpec = to;
if (to.startsWith("#") && !/^#[0-9a-z_-]{20,}$/i.test(to)) { if (to.startsWith("#") && !/^#[0-9a-z_-]{20,}$/i.test(to)) {

View File

@@ -5,6 +5,7 @@
*/ */
import { withMesh } from "./connect.js"; import { withMesh } from "./connect.js";
import { tryGetStateViaDaemon, tryListStateViaDaemon, trySetStateViaDaemon } from "~/services/bridge/daemon-route.js";
import { render } from "~/ui/render.js"; import { render } from "~/ui/render.js";
import { bold, dim } from "~/ui/styles.js"; import { bold, dim } from "~/ui/styles.js";
@@ -14,6 +15,16 @@ export interface StateFlags {
} }
export async function runStateGet(flags: StateFlags, key: string): Promise<void> { export async function runStateGet(flags: StateFlags, key: string): Promise<void> {
// Daemon path first.
const daemonEntry = await tryGetStateViaDaemon(key, flags.mesh);
if (daemonEntry !== null) {
if (!daemonEntry) { render.info(dim("(not set)")); return; }
if (flags.json) { console.log(JSON.stringify(daemonEntry, null, 2)); return; }
const val = typeof daemonEntry.value === "string" ? daemonEntry.value : JSON.stringify(daemonEntry.value);
render.info(val);
render.info(dim(` set by ${daemonEntry.updatedBy} at ${new Date(daemonEntry.updatedAt).toLocaleString()}`));
return;
}
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => { await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const entry = await client.getState(key); const entry = await client.getState(key);
if (!entry) { if (!entry) {
@@ -38,6 +49,12 @@ export async function runStateSet(flags: StateFlags, key: string, value: string)
parsed = value; parsed = value;
} }
// Daemon path first.
const daemonOk = await trySetStateViaDaemon(key, parsed, flags.mesh);
if (daemonOk) {
render.ok(`${bold(key)} = ${JSON.stringify(parsed)}`);
return;
}
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => { await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
await client.setState(key, parsed); await client.setState(key, parsed);
render.ok(`${bold(key)} = ${JSON.stringify(parsed)}`); render.ok(`${bold(key)} = ${JSON.stringify(parsed)}`);
@@ -45,6 +62,19 @@ export async function runStateSet(flags: StateFlags, key: string, value: string)
} }
export async function runStateList(flags: StateFlags): Promise<void> { export async function runStateList(flags: StateFlags): Promise<void> {
// Daemon path first.
const daemonRows = await tryListStateViaDaemon(flags.mesh);
if (daemonRows !== null) {
if (flags.json) { console.log(JSON.stringify(daemonRows, null, 2)); return; }
if (daemonRows.length === 0) { render.info(dim("(no state)")); return; }
render.section(`state (${daemonRows.length})`);
for (const e of daemonRows) {
const val = typeof e.value === "string" ? e.value : JSON.stringify(e.value);
process.stdout.write(` ${bold(e.key)}: ${val}\n`);
process.stdout.write(` ${dim(e.updatedBy + " · " + new Date(e.updatedAt).toLocaleString())}\n`);
}
return;
}
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => { await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
const entries = await client.listState(); const entries = await client.listState();

View File

@@ -9,6 +9,7 @@ export const EXIT = {
PERMISSION_DENIED: 7, PERMISSION_DENIED: 7,
INTERNAL_ERROR: 8, INTERNAL_ERROR: 8,
CLAUDE_MISSING: 9, CLAUDE_MISSING: 9,
IO_ERROR: 10,
} as const; } as const;
export type ExitCode = (typeof EXIT)[keyof typeof EXIT]; export type ExitCode = (typeof EXIT)[keyof typeof EXIT];

View File

@@ -69,6 +69,21 @@ export interface SkillFull extends SkillSummary {
manifest?: unknown; manifest?: unknown;
} }
export interface StateRow {
key: string;
value: unknown;
updatedBy: string;
updatedAt: string;
}
export interface MemoryRow {
id: string;
content: string;
tags: string[];
rememberedBy: string;
rememberedAt: string;
}
const HELLO_ACK_TIMEOUT_MS = 5_000; const HELLO_ACK_TIMEOUT_MS = 5_000;
const SEND_ACK_TIMEOUT_MS = 15_000; const SEND_ACK_TIMEOUT_MS = 15_000;
const BACKOFF_CAPS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000]; const BACKOFF_CAPS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
@@ -91,6 +106,10 @@ export class DaemonBrokerClient {
private peerListResolvers = new Map<string, PendingPeerList>(); private peerListResolvers = new Map<string, PendingPeerList>();
private skillListResolvers = new Map<string, { resolve: (rows: SkillSummary[]) => void; timer: NodeJS.Timeout }>(); private skillListResolvers = new Map<string, { resolve: (rows: SkillSummary[]) => void; timer: NodeJS.Timeout }>();
private skillDataResolvers = new Map<string, { resolve: (row: SkillFull | null) => void; timer: NodeJS.Timeout }>(); private skillDataResolvers = new Map<string, { resolve: (row: SkillFull | null) => void; timer: NodeJS.Timeout }>();
private stateGetResolvers = new Map<string, { resolve: (row: StateRow | null) => void; timer: NodeJS.Timeout }>();
private stateListResolvers = new Map<string, { resolve: (rows: StateRow[]) => void; timer: NodeJS.Timeout }>();
private memoryStoreResolvers = new Map<string, { resolve: (id: string | null) => void; timer: NodeJS.Timeout }>();
private memoryRecallResolvers = new Map<string, { resolve: (rows: MemoryRow[]) => void; timer: NodeJS.Timeout }>();
private sessionPubkey: string | null = null; private sessionPubkey: string | null = null;
private sessionSecretKey: string | null = null; private sessionSecretKey: string | null = null;
private opens: Array<() => void> = []; private opens: Array<() => void> = [];
@@ -226,6 +245,50 @@ export class DaemonBrokerClient {
return; return;
} }
if (msg.type === "state_value" || msg.type === "state_data") {
const reqId = String(msg._reqId ?? "");
const pending = this.stateGetResolvers.get(reqId);
if (pending) {
this.stateGetResolvers.delete(reqId);
clearTimeout(pending.timer);
pending.resolve((msg.state ?? msg.row ?? null) as StateRow | null);
}
return;
}
if (msg.type === "state_list") {
const reqId = String(msg._reqId ?? "");
const pending = this.stateListResolvers.get(reqId);
if (pending) {
this.stateListResolvers.delete(reqId);
clearTimeout(pending.timer);
pending.resolve(Array.isArray(msg.entries) ? (msg.entries as StateRow[]) : []);
}
return;
}
if (msg.type === "memory_stored") {
const reqId = String(msg._reqId ?? "");
const pending = this.memoryStoreResolvers.get(reqId);
if (pending) {
this.memoryStoreResolvers.delete(reqId);
clearTimeout(pending.timer);
pending.resolve(typeof msg.memoryId === "string" ? msg.memoryId : null);
}
return;
}
if (msg.type === "memory_recall_result") {
const reqId = String(msg._reqId ?? "");
const pending = this.memoryRecallResolvers.get(reqId);
if (pending) {
this.memoryRecallResolvers.delete(reqId);
clearTimeout(pending.timer);
pending.resolve(Array.isArray(msg.matches) ? (msg.matches as MemoryRow[]) : []);
}
return;
}
if (msg.type === "push" || msg.type === "inbound") { if (msg.type === "push" || msg.type === "inbound") {
this.opts.onPush?.(msg); this.opts.onPush?.(msg);
return; return;
@@ -329,6 +392,76 @@ export class DaemonBrokerClient {
}); });
} }
/** Read a single shared state row. Null on disconnect / timeout / not-found. */
async getState(key: string, timeoutMs = 5_000): Promise<StateRow | null> {
if (this._status !== "open" || !this.ws) return null;
return new Promise<StateRow | null>((resolve) => {
const reqId = `sg-${++this.reqCounter}`;
const timer = setTimeout(() => {
if (this.stateGetResolvers.delete(reqId)) resolve(null);
}, timeoutMs);
this.stateGetResolvers.set(reqId, { resolve, timer });
try { this.ws!.send(JSON.stringify({ type: "get_state", key, _reqId: reqId })); }
catch { this.stateGetResolvers.delete(reqId); clearTimeout(timer); resolve(null); }
});
}
/** List all shared state rows in the mesh. */
async listState(timeoutMs = 5_000): Promise<StateRow[]> {
if (this._status !== "open" || !this.ws) return [];
return new Promise<StateRow[]>((resolve) => {
const reqId = `sl-${++this.reqCounter}`;
const timer = setTimeout(() => {
if (this.stateListResolvers.delete(reqId)) resolve([]);
}, timeoutMs);
this.stateListResolvers.set(reqId, { resolve, timer });
try { this.ws!.send(JSON.stringify({ type: "list_state", _reqId: reqId })); }
catch { this.stateListResolvers.delete(reqId); clearTimeout(timer); resolve([]); }
});
}
/** Set a shared state value. Fire-and-forget. */
setState(key: string, value: unknown): void {
if (this._status !== "open" || !this.ws) return;
try { this.ws.send(JSON.stringify({ type: "set_state", key, value })); }
catch { /* ignore */ }
}
/** Store a memory in the mesh. Returns the assigned id, or null on timeout. */
async remember(content: string, tags?: string[], timeoutMs = 5_000): Promise<string | null> {
if (this._status !== "open" || !this.ws) return null;
return new Promise<string | null>((resolve) => {
const reqId = `mr-${++this.reqCounter}`;
const timer = setTimeout(() => {
if (this.memoryStoreResolvers.delete(reqId)) resolve(null);
}, timeoutMs);
this.memoryStoreResolvers.set(reqId, { resolve, timer });
try { this.ws!.send(JSON.stringify({ type: "remember", content, tags, _reqId: reqId })); }
catch { this.memoryStoreResolvers.delete(reqId); clearTimeout(timer); resolve(null); }
});
}
/** Search memories by relevance. */
async recall(query: string, timeoutMs = 5_000): Promise<MemoryRow[]> {
if (this._status !== "open" || !this.ws) return [];
return new Promise<MemoryRow[]>((resolve) => {
const reqId = `mc-${++this.reqCounter}`;
const timer = setTimeout(() => {
if (this.memoryRecallResolvers.delete(reqId)) resolve([]);
}, timeoutMs);
this.memoryRecallResolvers.set(reqId, { resolve, timer });
try { this.ws!.send(JSON.stringify({ type: "recall", query, _reqId: reqId })); }
catch { this.memoryRecallResolvers.delete(reqId); clearTimeout(timer); resolve([]); }
});
}
/** Forget a memory by id. Fire-and-forget. */
forget(memoryId: string): void {
if (this._status !== "open" || !this.ws) return;
try { this.ws.send(JSON.stringify({ type: "forget", memoryId })); }
catch { /* ignore */ }
}
/** Set the daemon's profile (avatar/title/bio/capabilities). Fire-and-forget. */ /** Set the daemon's profile (avatar/title/bio/capabilities). Fire-and-forget. */
setProfile(profile: { avatar?: string; title?: string; bio?: string; capabilities?: string[] }): void { setProfile(profile: { avatar?: string; title?: string; bio?: string; capabilities?: string[] }): void {
if (this._status !== "open" || !this.ws) return; if (this._status !== "open" || !this.ws) return;

View File

@@ -2,6 +2,7 @@ import { request as httpRequest } from "node:http";
import { DAEMON_PATHS, DAEMON_TCP_HOST, DAEMON_TCP_DEFAULT_PORT } from "../paths.js"; import { DAEMON_PATHS, DAEMON_TCP_HOST, DAEMON_TCP_DEFAULT_PORT } from "../paths.js";
import { readLocalToken } from "../local-token.js"; import { readLocalToken } from "../local-token.js";
import { readSessionTokenFromEnv } from "~/services/session/token.js";
export interface IpcRequestOptions { export interface IpcRequestOptions {
method?: "GET" | "POST" | "PATCH" | "DELETE"; method?: "GET" | "POST" | "PATCH" | "DELETE";
@@ -44,6 +45,19 @@ export async function ipc<T = unknown>(opts: IpcRequestOptions): Promise<IpcResp
headers.authorization = `Bearer ${tok}`; headers.authorization = `Bearer ${tok}`;
} }
// Per-session token attribution. When the calling process has
// CLAUDEMESH_IPC_TOKEN_FILE set (a launched session and its
// descendants), attach the session token. The daemon's auth
// middleware resolves it to a SessionInfo and uses it for default-
// mesh scoping. Sent as a second Authorization header is not
// possible per HTTP semantics, so we layer: when both UDS and a
// session token exist, send the session token; the bearer remains
// only for TCP loopback callers.
if (!useTcp) {
const sessionTok = readSessionTokenFromEnv();
if (sessionTok) headers.authorization = `ClaudeMesh-Session ${sessionTok}`;
}
return new Promise<IpcResponse<T>>((resolve, reject) => { return new Promise<IpcResponse<T>>((resolve, reject) => {
const req = httpRequest( const req = httpRequest(
useTcp useTcp

View File

@@ -10,6 +10,10 @@ import { listOutbox, requeueDeadOrPending, type OutboxStatus } from "../db/outbo
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { bindSseStream, type EventBus } from "../events.js"; import { bindSseStream, type EventBus } from "../events.js";
import type { DaemonBrokerClient } from "../broker.js"; import type { DaemonBrokerClient } from "../broker.js";
import {
registerSession, deregisterByToken, resolveToken, listSessions, startReaper,
type SessionInfo,
} from "../session-registry.js";
import { VERSION } from "~/constants/urls.js"; import { VERSION } from "~/constants/urls.js";
/** /**
@@ -172,12 +176,28 @@ function makeHandler(opts: {
} }
} }
// Per-session token resolution. Layers on top of the machine-level
// local-token auth above: callers from inside a `claudemesh launch`-
// spawned session pass `Authorization: ClaudeMesh-Session <hex>`
// (instead of, or in addition to, Bearer over TCP) and we resolve
// it to a SessionInfo that downstream routes use for default-mesh
// scoping and attribution.
let session: SessionInfo | null = null;
{
const authz = req.headers.authorization ?? "";
const sm = /^ClaudeMesh-Session\s+([0-9a-f]{64})$/i.exec(authz.trim());
if (sm && sm[1]) session = resolveToken(sm[1].toLowerCase());
}
/** Pick mesh from explicit body/query first, then session default. */
const meshFromCtx = (explicit?: string | null): string | null =>
(explicit && explicit.trim()) ? explicit : (session?.mesh ?? null);
// Routing. // Routing.
if (req.method === "GET" && url.pathname === "/v1/version") { if (req.method === "GET" && url.pathname === "/v1/version") {
respond(res, 200, { respond(res, 200, {
daemon_version: VERSION, daemon_version: VERSION,
ipc_api: "v1", ipc_api: "v1",
ipc_features: ["version", "health", "send", "inbox", "events", "peers", "profile", "skills"], ipc_features: ["version", "health", "send", "inbox", "events", "peers", "profile", "skills", "state", "memory", "sessions"],
schema_version: 1, schema_version: 1,
}); });
return; return;
@@ -188,6 +208,102 @@ function makeHandler(opts: {
return; return;
} }
// Session registry routes (1.29.0)
if (req.method === "POST" && url.pathname === "/v1/sessions/register") {
try {
const body = await readJsonBody(req, 64 * 1024) as Record<string, unknown> | null;
if (!body) { respond(res, 400, { error: "missing body" }); return; }
const token = typeof body.token === "string" ? body.token : "";
if (!/^[0-9a-f]{64}$/i.test(token)) { respond(res, 400, { error: "token must be 64 hex chars" }); return; }
const sessionId = typeof body.session_id === "string" ? body.session_id : "";
const mesh = typeof body.mesh === "string" ? body.mesh : "";
const displayName = typeof body.display_name === "string" ? body.display_name : "";
const pid = typeof body.pid === "number" ? body.pid : 0;
if (!sessionId || !mesh || !displayName || !pid) {
respond(res, 400, { error: "session_id, mesh, display_name, pid all required" });
return;
}
const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
const role = typeof body.role === "string" ? body.role : undefined;
const groups = Array.isArray(body.groups)
? body.groups.filter((g): g is string => typeof g === "string")
: undefined;
// 1.30.0 — optional per-session presence material. Older CLIs
// omit this; the daemon's session-broker subsystem just won't
// open a per-session WS for those.
let presence: SessionInfo["presence"] | undefined;
const rawPresence = body.presence;
if (rawPresence && typeof rawPresence === "object") {
const p = rawPresence as Record<string, unknown>;
const sessionPubkey = typeof p.session_pubkey === "string" ? p.session_pubkey.toLowerCase() : "";
const sessionSecretKey = typeof p.session_secret_key === "string" ? p.session_secret_key.toLowerCase() : "";
const att = p.parent_attestation as Record<string, unknown> | undefined;
if (
/^[0-9a-f]{64}$/.test(sessionPubkey) &&
/^[0-9a-f]{128}$/.test(sessionSecretKey) &&
att && typeof att === "object" &&
typeof att.session_pubkey === "string" &&
typeof att.parent_member_pubkey === "string" &&
typeof att.expires_at === "number" &&
typeof att.signature === "string"
) {
presence = {
sessionPubkey,
sessionSecretKey,
parentAttestation: {
sessionPubkey: (att.session_pubkey as string).toLowerCase(),
parentMemberPubkey: (att.parent_member_pubkey as string).toLowerCase(),
expiresAt: att.expires_at as number,
signature: (att.signature as string).toLowerCase(),
},
};
} else {
opts.log("warn", "session_register_presence_malformed", { mesh });
}
}
const stored = registerSession({
token: token.toLowerCase(),
sessionId, mesh, displayName, pid, cwd, role, groups,
...(presence ? { presence } : {}),
});
opts.log("info", "session_registered", {
sessionId, mesh, pid,
presence: presence ? "yes" : "no",
});
respond(res, 200, {
ok: true,
registered_at: stored.registeredAt,
presence_accepted: !!presence,
});
} catch (e) {
respond(res, 400, { error: String(e) });
}
return;
}
if (req.method === "DELETE" && url.pathname.startsWith("/v1/sessions/")) {
const tail = url.pathname.slice("/v1/sessions/".length);
if (!/^[0-9a-f]{64}$/i.test(tail)) { respond(res, 400, { error: "invalid token" }); return; }
const ok = deregisterByToken(tail.toLowerCase());
respond(res, ok ? 200 : 404, { ok, token_prefix: tail.slice(0, 8) });
return;
}
if (req.method === "GET" && url.pathname === "/v1/sessions/me") {
if (!session) { respond(res, 401, { error: "no session token" }); return; }
const { token, ...redacted } = session;
respond(res, 200, { session: { ...redacted, token_prefix: token.slice(0, 8) } });
return;
}
if (req.method === "GET" && url.pathname === "/v1/sessions") {
const all = listSessions().map(({ token, ...rest }) => ({ ...rest, token_prefix: token.slice(0, 8) }));
respond(res, 200, { sessions: all });
return;
}
if (req.method === "GET" && url.pathname === "/v1/events") { if (req.method === "GET" && url.pathname === "/v1/events") {
if (!opts.bus) { if (!opts.bus) {
respond(res, 503, { error: "event bus not initialised" }); respond(res, 503, { error: "event bus not initialised" });
@@ -202,7 +318,7 @@ function makeHandler(opts: {
respond(res, 503, { error: "broker not initialised" }); respond(res, 503, { error: "broker not initialised" });
return; return;
} }
const filterMesh = url.searchParams.get("mesh") ?? undefined; const filterMesh = meshFromCtx(url.searchParams.get("mesh")) ?? undefined;
try { try {
// Aggregate across all attached meshes; each peer record gets a // Aggregate across all attached meshes; each peer record gets a
// `mesh` field so the caller can scope client-side. A single // `mesh` field so the caller can scope client-side. A single
@@ -212,7 +328,7 @@ function makeHandler(opts: {
if (filterMesh && filterMesh !== slug) continue; if (filterMesh && filterMesh !== slug) continue;
try { try {
const peers = await b.listPeers(); const peers = await b.listPeers();
for (const p of peers) all.push({ ...(p as Record<string, unknown>), mesh: slug }); for (const p of peers) all.push({ ...(p as unknown as Record<string, unknown>), mesh: slug });
} catch (e) { } catch (e) {
opts.log("warn", "ipc_peers_broker_failed", { mesh: slug, err: String(e) }); opts.log("warn", "ipc_peers_broker_failed", { mesh: slug, err: String(e) });
} }
@@ -224,20 +340,153 @@ function makeHandler(opts: {
return; return;
} }
if (req.method === "GET" && url.pathname === "/v1/state") {
if (!opts.brokers || opts.brokers.size === 0) {
respond(res, 503, { error: "broker not initialised" });
return;
}
const filterMesh = meshFromCtx(url.searchParams.get("mesh")) ?? undefined;
const key = url.searchParams.get("key");
try {
if (key) {
// Single key lookup. Walk attached meshes; first match wins
// (or ?mesh=<slug> scopes the search).
for (const [slug, b] of opts.brokers.entries()) {
if (filterMesh && filterMesh !== slug) continue;
const row = await b.getState(key).catch(() => null);
if (row) { respond(res, 200, { state: { ...row, mesh: slug } }); return; }
}
respond(res, 404, { error: "state_not_found", key });
return;
}
// No key — list all entries across attached meshes.
const all: Array<Record<string, unknown> & { mesh: string }> = [];
for (const [slug, b] of opts.brokers.entries()) {
if (filterMesh && filterMesh !== slug) continue;
const rows = await b.listState().catch(() => []);
for (const r of rows) all.push({ ...(r as unknown as Record<string, unknown>), mesh: slug });
}
respond(res, 200, { entries: all });
} catch (e) {
respond(res, 502, { error: "broker_unreachable", detail: String(e) });
}
return;
}
if (req.method === "POST" && url.pathname === "/v1/state") {
if (!opts.brokers || opts.brokers.size === 0) {
respond(res, 503, { error: "broker not initialised" });
return;
}
try {
const body = await readJsonBody(req, 256 * 1024) as Record<string, unknown> | null;
if (!body || typeof body.key !== "string") {
respond(res, 400, { error: "missing 'key' (string)" });
return;
}
const requested = meshFromCtx(typeof body.mesh === "string" ? body.mesh : null);
let chosen = requested;
if (!chosen && opts.brokers.size === 1) chosen = opts.brokers.keys().next().value as string;
if (!chosen) {
respond(res, 400, { error: "mesh_required", attached: [...opts.brokers.keys()] });
return;
}
const broker = opts.brokers.get(chosen);
if (!broker) { respond(res, 404, { error: "mesh_not_attached", mesh: chosen }); return; }
broker.setState(body.key, body.value);
respond(res, 200, { ok: true, key: body.key, mesh: chosen });
} catch (e) {
respond(res, 400, { error: String(e) });
}
return;
}
if (req.method === "GET" && url.pathname === "/v1/memory") {
if (!opts.brokers || opts.brokers.size === 0) {
respond(res, 503, { error: "broker not initialised" });
return;
}
const query = url.searchParams.get("q") ?? "";
const filterMesh = meshFromCtx(url.searchParams.get("mesh")) ?? undefined;
try {
const all: Array<Record<string, unknown> & { mesh: string }> = [];
for (const [slug, b] of opts.brokers.entries()) {
if (filterMesh && filterMesh !== slug) continue;
const rows = await b.recall(query).catch(() => []);
for (const r of rows) all.push({ ...(r as unknown as Record<string, unknown>), mesh: slug });
}
respond(res, 200, { matches: all });
} catch (e) {
respond(res, 502, { error: "broker_unreachable", detail: String(e) });
}
return;
}
if (req.method === "POST" && url.pathname === "/v1/memory") {
if (!opts.brokers || opts.brokers.size === 0) {
respond(res, 503, { error: "broker not initialised" });
return;
}
try {
const body = await readJsonBody(req, 256 * 1024) as Record<string, unknown> | null;
if (!body || typeof body.content !== "string") {
respond(res, 400, { error: "missing 'content' (string)" });
return;
}
const requested = meshFromCtx(typeof body.mesh === "string" ? body.mesh : null);
let chosen = requested;
if (!chosen && opts.brokers.size === 1) chosen = opts.brokers.keys().next().value as string;
if (!chosen) {
respond(res, 400, { error: "mesh_required", attached: [...opts.brokers.keys()] });
return;
}
const broker = opts.brokers.get(chosen);
if (!broker) { respond(res, 404, { error: "mesh_not_attached", mesh: chosen }); return; }
const tags = Array.isArray(body.tags) ? body.tags.filter((t) => typeof t === "string") as string[] : undefined;
const id = await broker.remember(body.content, tags);
if (!id) { respond(res, 502, { error: "remember_timeout" }); return; }
respond(res, 200, { id, mesh: chosen });
} catch (e) {
respond(res, 400, { error: String(e) });
}
return;
}
if (req.method === "DELETE" && url.pathname.startsWith("/v1/memory/")) {
if (!opts.brokers || opts.brokers.size === 0) {
respond(res, 503, { error: "broker not initialised" });
return;
}
const id = decodeURIComponent(url.pathname.slice("/v1/memory/".length));
if (!id) { respond(res, 400, { error: "missing memory id" }); return; }
const requested = url.searchParams.get("mesh");
let chosen = requested;
if (!chosen && opts.brokers.size === 1) chosen = opts.brokers.keys().next().value as string;
if (!chosen) {
respond(res, 400, { error: "mesh_required", attached: [...opts.brokers.keys()] });
return;
}
const broker = opts.brokers.get(chosen);
if (!broker) { respond(res, 404, { error: "mesh_not_attached", mesh: chosen }); return; }
broker.forget(id);
respond(res, 200, { ok: true, id, mesh: chosen });
return;
}
if (req.method === "GET" && url.pathname === "/v1/skills") { if (req.method === "GET" && url.pathname === "/v1/skills") {
if (!opts.brokers || opts.brokers.size === 0) { if (!opts.brokers || opts.brokers.size === 0) {
respond(res, 503, { error: "broker not initialised" }); respond(res, 503, { error: "broker not initialised" });
return; return;
} }
const query = url.searchParams.get("query") ?? undefined; const query = url.searchParams.get("query") ?? undefined;
const filterMesh = url.searchParams.get("mesh") ?? undefined; const filterMesh = meshFromCtx(url.searchParams.get("mesh")) ?? undefined;
try { try {
const all: Array<Record<string, unknown> & { mesh: string }> = []; const all: Array<Record<string, unknown> & { mesh: string }> = [];
for (const [slug, b] of opts.brokers.entries()) { for (const [slug, b] of opts.brokers.entries()) {
if (filterMesh && filterMesh !== slug) continue; if (filterMesh && filterMesh !== slug) continue;
try { try {
const skills = await b.listSkills(query); const skills = await b.listSkills(query);
for (const s of skills) all.push({ ...(s as Record<string, unknown>), mesh: slug }); for (const s of skills) all.push({ ...(s as unknown as Record<string, unknown>), mesh: slug });
} catch (e) { } catch (e) {
opts.log("warn", "ipc_skills_broker_failed", { mesh: slug, err: String(e) }); opts.log("warn", "ipc_skills_broker_failed", { mesh: slug, err: String(e) });
} }
@@ -256,7 +505,7 @@ function makeHandler(opts: {
} }
const name = decodeURIComponent(url.pathname.slice("/v1/skills/".length)); const name = decodeURIComponent(url.pathname.slice("/v1/skills/".length));
if (!name) { respond(res, 400, { error: "missing skill name" }); return; } if (!name) { respond(res, 400, { error: "missing skill name" }); return; }
const filterMesh = url.searchParams.get("mesh") ?? undefined; const filterMesh = meshFromCtx(url.searchParams.get("mesh")) ?? undefined;
try { try {
// First mesh that has the skill wins. With ?mesh=<slug>, only that // First mesh that has the skill wins. With ?mesh=<slug>, only that
// mesh is queried. // mesh is queried.
@@ -284,7 +533,7 @@ function makeHandler(opts: {
// present in the body or query, otherwise broadcast to all attached // present in the body or query, otherwise broadcast to all attached
// meshes (presence is per-mesh, but most users want consistent // meshes (presence is per-mesh, but most users want consistent
// presence across all of theirs). // presence across all of theirs).
const requested = (typeof body.mesh === "string" ? body.mesh : url.searchParams.get("mesh")) || null; const requested = meshFromCtx(typeof body.mesh === "string" ? body.mesh : url.searchParams.get("mesh"));
const targets = requested const targets = requested
? [opts.brokers.get(requested)].filter(Boolean) as DaemonBrokerClient[] ? [opts.brokers.get(requested)].filter(Boolean) as DaemonBrokerClient[]
: [...opts.brokers.values()]; : [...opts.brokers.values()];

View File

@@ -4,10 +4,12 @@ import { DAEMON_PATHS } from "./paths.js";
import { acquireSingletonLock, releaseSingletonLock } from "./lock.js"; import { acquireSingletonLock, releaseSingletonLock } from "./lock.js";
import { ensureLocalToken } from "./local-token.js"; import { ensureLocalToken } from "./local-token.js";
import { startIpcServer } from "./ipc/server.js"; import { startIpcServer } from "./ipc/server.js";
import { setRegistryHooks, startReaper, type SessionInfo } from "./session-registry.js";
import { openSqlite, type SqliteDb } from "./db/sqlite.js"; import { openSqlite, type SqliteDb } from "./db/sqlite.js";
import { migrateOutbox } from "./db/outbox.js"; import { migrateOutbox } from "./db/outbox.js";
import { migrateInbox } from "./db/inbox.js"; import { migrateInbox } from "./db/inbox.js";
import { DaemonBrokerClient } from "./broker.js"; import { DaemonBrokerClient } from "./broker.js";
import { SessionBrokerClient } from "./session-broker.js";
import { startDrainWorker, type DrainHandle } from "./drain.js"; import { startDrainWorker, type DrainHandle } from "./drain.js";
import { handleBrokerPush } from "./inbound.js"; import { handleBrokerPush } from "./inbound.js";
import { EventBus } from "./events.js"; import { EventBus } from "./events.js";
@@ -153,6 +155,57 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
let drain: DrainHandle | null = null; let drain: DrainHandle | null = null;
drain = startDrainWorker({ db: outboxDb, brokers }); drain = startDrainWorker({ db: outboxDb, brokers });
// 1.30.0 — per-session broker presence. Always on. Older CLIs that
// don't include `presence` material in the register body just won't
// get a session WS; the daemon's own member-keyed broker still
// covers them.
const sessionBrokers = new Map<string, SessionBrokerClient>();
setRegistryHooks({
onRegister: (info) => {
if (!info.presence) return;
const meshConfig = meshConfigs.get(info.mesh);
if (!meshConfig) {
process.stderr.write(JSON.stringify({
level: "warn", msg: "session_broker_no_mesh_config", mesh: info.mesh,
ts: new Date().toISOString(),
}) + "\n");
return;
}
// Drop any pre-existing session WS under this token (re-register).
const prior = sessionBrokers.get(info.token);
if (prior) {
sessionBrokers.delete(info.token);
prior.close().catch(() => { /* ignore */ });
}
const client = new SessionBrokerClient({
mesh: meshConfig,
sessionPubkey: info.presence.sessionPubkey,
sessionSecretKey: info.presence.sessionSecretKey,
parentAttestation: info.presence.parentAttestation,
sessionId: info.sessionId,
displayName: info.displayName,
...(info.role ? { role: info.role } : {}),
...(info.cwd ? { cwd: info.cwd } : {}),
pid: info.pid,
});
sessionBrokers.set(info.token, client);
client.connect().catch((err) =>
process.stderr.write(JSON.stringify({
level: "warn", msg: "session_broker_connect_failed",
mesh: info.mesh, err: String(err), ts: new Date().toISOString(),
}) + "\n"),
);
},
onDeregister: (info: SessionInfo) => {
const client = sessionBrokers.get(info.token);
if (!client) return;
sessionBrokers.delete(info.token);
client.close().catch(() => { /* ignore */ });
},
});
startReaper();
const ipc = startIpcServer({ const ipc = startIpcServer({
localToken, localToken,
tcpEnabled, tcpEnabled,
@@ -191,6 +244,10 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
for (const b of brokers.values()) { for (const b of brokers.values()) {
try { await b.close(); } catch { /* ignore */ } try { await b.close(); } catch { /* ignore */ }
} }
for (const b of sessionBrokers.values()) {
try { await b.close(); } catch { /* ignore */ }
}
sessionBrokers.clear();
await ipc.close(); await ipc.close();
try { outboxDb.close(); } catch { /* ignore */ } try { outboxDb.close(); } catch { /* ignore */ }
try { inboxDb.close(); } catch { /* ignore */ } try { inboxDb.close(); } catch { /* ignore */ }

View File

@@ -0,0 +1,205 @@
/**
* Per-launch session broker WebSocket.
*
* Owned by the daemon, one per registered session. Holds a long-lived
* presence row on the broker keyed on the session's ephemeral pubkey
* (rather than the parent member's stable pubkey). Sibling sessions —
* two `claudemesh launch` runs in the same cwd — finally see each other
* in `peer list` because their presence rows coexist instead of fighting
* over the same memberPubkey snapshot.
*
* Differences from `DaemonBrokerClient`:
* - Uses session_hello (1.30.0+ broker), with a parent-vouched
* attestation provided at construction time.
* - Does NOT drain the outbox — that stays the parent member-keyed
* DaemonBrokerClient's job. Keeps the responsibility split clean
* and avoids two clients fighting over the same outbox row.
* - Does NOT carry list_peers / state / memory RPCs. This client is
* presence-only (and inbound DM delivery for messages targeted at
* the session pubkey).
*
* Old brokers reply with `unknown_message_type` on session_hello — we
* surface that as a one-shot `error` event and the daemon decides
* whether to fall back. For 1.30.0 we just log + retry; the broker is
* expected to be deployed first.
*
* Spec: .artifacts/specs/2026-05-04-per-session-presence.md.
*/
import { hostname as osHostname } from "node:os";
import WebSocket from "ws";
import type { JoinedMesh } from "~/services/config/facade.js";
import { signSessionHello } from "~/services/broker/session-hello-sig.js";
export type SessionBrokerStatus = "connecting" | "open" | "closed" | "reconnecting";
export interface ParentAttestation {
sessionPubkey: string;
parentMemberPubkey: string;
/** Unix ms. Broker rejects > now+24h or already past. */
expiresAt: number;
signature: string;
}
export interface SessionBrokerOptions {
mesh: JoinedMesh;
/** Per-launch ephemeral keypair. */
sessionPubkey: string;
sessionSecretKey: string;
/** Parent-vouched attestation, signed by mesh.secretKey at launch time. */
parentAttestation: ParentAttestation;
/** Stable session_id from the launch (used for dedup on the broker). */
sessionId: string;
/** Display name override for this session. */
displayName?: string;
/** Initial groups. Format mirrors the regular hello. */
groups?: Array<{ name: string; role?: string }>;
/** Role tag (informational, not auth-bearing). */
role?: string;
/** Working directory (informational, surfaced in peer list). */
cwd?: string;
/** Pid of the launched session (NOT the daemon). */
pid: number;
onStatusChange?: (s: SessionBrokerStatus) => void;
log?: (level: "info" | "warn" | "error", msg: string, meta?: Record<string, unknown>) => void;
}
const HELLO_ACK_TIMEOUT_MS = 5_000;
const BACKOFF_CAPS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
export class SessionBrokerClient {
private ws: WebSocket | null = null;
private _status: SessionBrokerStatus = "closed";
private closed = false;
private reconnectAttempt = 0;
private reconnectTimer: NodeJS.Timeout | null = null;
private helloTimer: NodeJS.Timeout | null = null;
constructor(private opts: SessionBrokerOptions) {}
get status(): SessionBrokerStatus { return this._status; }
get meshSlug(): string { return this.opts.mesh.slug; }
get sessionPubkey(): string { return this.opts.sessionPubkey; }
private log = (level: "info" | "warn" | "error", msg: string, meta?: Record<string, unknown>) => {
(this.opts.log ?? defaultLog)(level, msg, {
mesh: this.opts.mesh.slug,
session_pubkey: this.opts.sessionPubkey.slice(0, 12),
...meta,
});
};
private setStatus(s: SessionBrokerStatus) {
if (this._status === s) return;
this._status = s;
this.opts.onStatusChange?.(s);
}
/** Open the WS, run session_hello, resolve once the broker accepts. */
async connect(): Promise<void> {
if (this.closed) throw new Error("client_closed");
if (this._status === "connecting" || this._status === "open") return;
this.setStatus("connecting");
const ws = new WebSocket(this.opts.mesh.brokerUrl);
this.ws = ws;
return new Promise<void>((resolve, reject) => {
ws.on("open", async () => {
try {
const { timestamp, signature } = await signSessionHello({
meshId: this.opts.mesh.meshId,
parentMemberPubkey: this.opts.mesh.pubkey,
sessionPubkey: this.opts.sessionPubkey,
sessionSecretKey: this.opts.sessionSecretKey,
});
ws.send(JSON.stringify({
type: "session_hello",
meshId: this.opts.mesh.meshId,
parentMemberId: this.opts.mesh.memberId,
parentMemberPubkey: this.opts.mesh.pubkey,
sessionPubkey: this.opts.sessionPubkey,
parentAttestation: this.opts.parentAttestation,
displayName: this.opts.displayName,
sessionId: this.opts.sessionId,
pid: this.opts.pid,
cwd: this.opts.cwd ?? process.cwd(),
hostname: osHostname(),
peerType: "ai" as const,
channel: "claudemesh-session",
...(this.opts.groups && this.opts.groups.length > 0 ? { groups: this.opts.groups } : {}),
...(this.opts.role ? { role: this.opts.role } : {}),
timestamp,
signature,
}));
this.helloTimer = setTimeout(() => {
this.log("warn", "session_hello_ack_timeout");
try { ws.close(); } catch { /* ignore */ }
reject(new Error("session_hello_ack_timeout"));
}, HELLO_ACK_TIMEOUT_MS);
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
});
ws.on("message", (raw) => {
let msg: Record<string, unknown>;
try { msg = JSON.parse(raw.toString()) as Record<string, unknown>; }
catch { return; }
if (msg.type === "hello_ack") {
if (this.helloTimer) clearTimeout(this.helloTimer);
this.helloTimer = null;
this.setStatus("open");
this.reconnectAttempt = 0;
resolve();
return;
}
if (msg.type === "error") {
// Older brokers respond with `unknown_message_type` to session_hello;
// surface that so the daemon can decide to skip per-session presence
// rather than churn through reconnects.
this.log("warn", "broker_error", { code: msg.code, message: msg.message });
if (msg.code === "unknown_message_type") {
this.closed = true;
}
return;
}
// push / inbound — presence-only client ignores them; the daemon's
// member-keyed client handles all DM decryption.
});
ws.on("close", (code, reason) => {
if (this.helloTimer) { clearTimeout(this.helloTimer); this.helloTimer = null; }
if (this.closed) { this.setStatus("closed"); return; }
this.setStatus("reconnecting");
const wait = BACKOFF_CAPS_MS[Math.min(this.reconnectAttempt, BACKOFF_CAPS_MS.length - 1)] ?? 30_000;
this.reconnectAttempt++;
this.log("info", "session_broker_reconnect_scheduled", { wait_ms: wait, code, reason: reason.toString("utf8") });
this.reconnectTimer = setTimeout(
() => this.connect().catch((err) => this.log("warn", "session_broker_reconnect_failed", { err: String(err) })),
wait,
);
if (this._status === "connecting") reject(new Error(`closed_before_hello_${code}`));
});
ws.on("error", (err) => this.log("warn", "session_broker_ws_error", { err: err.message }));
});
}
async close(): Promise<void> {
this.closed = true;
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
if (this.helloTimer) { clearTimeout(this.helloTimer); this.helloTimer = null; }
try { this.ws?.close(); } catch { /* ignore */ }
this.setStatus("closed");
}
}
function defaultLog(level: "info" | "warn" | "error", msg: string, meta?: Record<string, unknown>) {
const line = JSON.stringify({ level, msg, ...meta, ts: new Date().toISOString() });
if (level === "info") process.stdout.write(line + "\n");
else process.stderr.write(line + "\n");
}

View File

@@ -0,0 +1,146 @@
/**
* In-memory per-token session registry kept by the daemon.
*
* `claudemesh launch` POSTs `/v1/sessions/register` with the token it
* minted plus session metadata (sessionId, mesh, displayName, pid,
* cwd, role, groups). Subsequent CLI invocations from inside that
* session present the token via `Authorization: ClaudeMesh-Session
* <hex>` and the daemon's IPC auth middleware resolves it here in O(1).
*
* Lifecycle:
* - register replaces any prior entry under the same `sessionId`
* (handles re-launch and `--resume` flows cleanly).
* - reaper polls every 30 s and drops entries whose pid is dead.
* - hard ttl ceiling of 24 h is a leak guard for forgotten sessions.
*
* Persistence: in-memory only for v1. A daemon restart clears the
* registry — every launched session needs to re-register. That's fine
* for now because launch.ts re-registers on `ensureDaemonRunning`'s
* success path, and most ad-hoc CLI invocations from outside a launched
* session have no token to begin with.
*/
/**
* Optional per-launch presence material. Carried opaquely through the
* registry; the daemon's session-broker subsystem (1.30.0+) reads it to
* open a long-lived broker WebSocket per session. Absent on older CLIs
* — register accepts payloads without it for backward compat.
*/
export interface SessionPresence {
/** Hex ed25519 pubkey, 64 chars. */
sessionPubkey: string;
/** Hex ed25519 secret key (held in-memory only; never disk). */
sessionSecretKey: string;
/** Parent-member-signed attestation; see signParentAttestation. */
parentAttestation: {
sessionPubkey: string;
parentMemberPubkey: string;
expiresAt: number;
signature: string;
};
}
export interface SessionInfo {
token: string;
sessionId: string;
mesh: string;
displayName: string;
pid: number;
cwd?: string;
role?: string;
groups?: string[];
/** 1.30.0+: per-launch presence material. */
presence?: SessionPresence;
registeredAt: number;
}
/** Lifecycle callbacks invoked synchronously after registry mutation. */
export interface RegistryHooks {
onRegister?: (info: SessionInfo) => void;
onDeregister?: (info: SessionInfo) => void;
}
const TTL_MS = 24 * 60 * 60 * 1000;
const REAPER_INTERVAL_MS = 30 * 1000;
const byToken = new Map<string, SessionInfo>();
const bySessionId = new Map<string, string>();
const hooks: RegistryHooks = {};
let reaperHandle: NodeJS.Timeout | null = null;
export function startReaper(): void {
if (reaperHandle) return;
reaperHandle = setInterval(reapDead, REAPER_INTERVAL_MS).unref?.() ?? reaperHandle;
}
export function stopReaper(): void {
if (reaperHandle) { clearInterval(reaperHandle); reaperHandle = null; }
}
/**
* Wire daemon-level lifecycle hooks. Called once at daemon boot — passing
* `{}` clears them. Idempotent across calls so tests can re-bind.
*/
export function setRegistryHooks(next: RegistryHooks): void {
hooks.onRegister = next.onRegister;
hooks.onDeregister = next.onDeregister;
}
export function registerSession(info: Omit<SessionInfo, "registeredAt">): SessionInfo {
// Replace any prior entry under the same sessionId.
const priorToken = bySessionId.get(info.sessionId);
if (priorToken && priorToken !== info.token) {
const prior = byToken.get(priorToken);
if (prior) {
byToken.delete(priorToken);
try { hooks.onDeregister?.(prior); } catch { /* hook errors must never throttle the registry */ }
}
}
const stored: SessionInfo = { ...info, registeredAt: Date.now() };
byToken.set(info.token, stored);
bySessionId.set(info.sessionId, info.token);
try { hooks.onRegister?.(stored); } catch { /* see above */ }
return stored;
}
export function deregisterByToken(token: string): boolean {
const entry = byToken.get(token);
if (!entry) return false;
byToken.delete(token);
if (bySessionId.get(entry.sessionId) === token) bySessionId.delete(entry.sessionId);
try { hooks.onDeregister?.(entry); } catch { /* see above */ }
return true;
}
export function resolveToken(token: string): SessionInfo | null {
const entry = byToken.get(token);
if (!entry) return null;
if (Date.now() - entry.registeredAt > TTL_MS) {
deregisterByToken(token);
return null;
}
return entry;
}
export function listSessions(): SessionInfo[] {
return [...byToken.values()];
}
function reapDead(): void {
const dead: string[] = [];
for (const [token, info] of byToken.entries()) {
if (Date.now() - info.registeredAt > TTL_MS) { dead.push(token); continue; }
try { process.kill(info.pid, 0); } catch { dead.push(token); }
}
for (const t of dead) deregisterByToken(t);
}
/** Test helper. */
export function _resetRegistry(): void {
byToken.clear();
bySessionId.clear();
hooks.onRegister = undefined;
hooks.onDeregister = undefined;
}

View File

@@ -9,6 +9,7 @@ import { renderVersion } from "~/cli/output/version.js";
import { isInviteUrl, normaliseInviteUrl } from "~/utils/url.js"; import { isInviteUrl, normaliseInviteUrl } from "~/utils/url.js";
import { classifyInvocation } from "~/cli/policy-classify.js"; import { classifyInvocation } from "~/cli/policy-classify.js";
import { gate, type ApprovalMode } from "~/services/policy/index.js"; import { gate, type ApprovalMode } from "~/services/policy/index.js";
import { setDaemonPolicy, policyFromFlags } from "~/services/daemon/policy.js";
import { bold, clay, cyan, dim, orange } from "~/ui/styles.js"; import { bold, clay, cyan, dim, orange } from "~/ui/styles.js";
installSignalHandlers(); installSignalHandlers();
@@ -16,6 +17,11 @@ installErrorHandlers();
const { command, positionals, flags } = parseArgv(process.argv); const { command, positionals, flags } = parseArgv(process.argv);
// Resolve daemon policy once at boot — daemon-routing helpers read this
// instead of inspecting flags themselves. --no-daemon and --strict are
// mutually exclusive (--no-daemon wins if both are passed).
setDaemonPolicy(policyFromFlags(flags));
/** /**
* Resolve the coarse approval mode from CLI flags + env. * Resolve the coarse approval mode from CLI flags + env.
* --approval-mode <plan|read-only|write|yolo> explicit * --approval-mode <plan|read-only|write|yolo> explicit
@@ -67,7 +73,7 @@ USAGE
claudemesh <invite-url> join a mesh, then launch claudemesh <invite-url> join a mesh, then launch
claudemesh launch --name <n> --join <url> join + launch in one step claudemesh launch --name <n> --join <url> join + launch in one step
Mesh Mesh (alias: "workspace" — claudemesh workspace <verb> mirrors each)
claudemesh create <name> create a new mesh claudemesh create <name> create a new mesh
claudemesh join <url> join a mesh (accepts short /i/ or long /join/ link) claudemesh join <url> join a mesh (accepts short /i/ or long /join/ link)
claudemesh launch [slug] launch Claude Code on a mesh (alias: connect) claudemesh launch [slug] launch Claude Code on a mesh (alias: connect)
@@ -210,6 +216,8 @@ Flags
--policy <path> override policy file --policy <path> override policy file
-y, --yes skip confirmations (= --approval-mode yolo) -y, --yes skip confirmations (= --approval-mode yolo)
-q, --quiet suppress non-essential output -q, --quiet suppress non-essential output
--strict require daemon for broker-touching verbs (no cold-path fallback)
--no-daemon skip daemon entirely; open broker WS directly (CI / sandboxed scripts)
`; `;
/** /**
@@ -283,6 +291,12 @@ async function main(): Promise<void> {
join: normaliseInviteUrl(command), join: normaliseInviteUrl(command),
yes: !!flags.y || !!flags.yes, yes: !!flags.y || !!flags.yes,
resume: flags.resume as string | undefined, resume: flags.resume as string | undefined,
role: flags.role as string | undefined,
groups: flags.groups as string | undefined,
"message-mode": flags["message-mode"] as string | undefined,
"system-prompt": flags["system-prompt"] as string | undefined,
continue: !!flags.continue,
quiet: !!flags.quiet,
}, process.argv.slice(2)); }, process.argv.slice(2));
return; return;
} }
@@ -298,6 +312,12 @@ async function main(): Promise<void> {
name: flags.name as string | undefined, name: flags.name as string | undefined,
yes: !!flags.y || !!flags.yes, yes: !!flags.y || !!flags.yes,
resume: flags.resume as string | undefined, resume: flags.resume as string | undefined,
role: flags.role as string | undefined,
groups: flags.groups as string | undefined,
"message-mode": flags["message-mode"] as string | undefined,
"system-prompt": flags["system-prompt"] as string | undefined,
continue: !!flags.continue,
quiet: !!flags.quiet,
}, process.argv.slice(2)); }, process.argv.slice(2));
return; return;
} }
@@ -316,6 +336,12 @@ async function main(): Promise<void> {
join: flags.join as string, join: flags.join as string,
yes: !!flags.y || !!flags.yes, yes: !!flags.y || !!flags.yes,
resume: flags.resume as string, resume: flags.resume as string,
role: flags.role as string,
groups: flags.groups as string,
"message-mode": flags["message-mode"] as string,
"system-prompt": flags["system-prompt"] as string,
continue: !!flags.continue,
quiet: !!flags.quiet,
}, process.argv.slice(2)); }, process.argv.slice(2));
break; break;
} }
@@ -324,6 +350,37 @@ async function main(): Promise<void> {
case "delete": case "rm": { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[0] ?? "", { yes: !!flags.y || !!flags.yes })); break; } case "delete": case "rm": { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[0] ?? "", { yes: !!flags.y || !!flags.yes })); break; }
case "rename": { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[0] ?? "", positionals[1] ?? "")); break; } case "rename": { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[0] ?? "", positionals[1] ?? "")); break; }
case "share": case "invite": { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; } case "share": case "invite": { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; }
// workspace — alias surface for mesh-management verbs (v1.27.0 teaser; full
// rename arrives in 1.28.0). Each sub mirrors an existing top-level verb.
case "workspace": {
const sub = positionals[0];
if (!sub || sub === "launch" || sub === "connect" || sub === "open") {
const { runLaunch } = await import("~/commands/launch.js");
await runLaunch({
mesh: positionals[1] ?? flags.mesh as string,
name: flags.name as string,
join: flags.join as string,
yes: !!flags.y || !!flags.yes,
resume: flags.resume as string,
role: flags.role as string,
groups: flags.groups as string,
"message-mode": flags["message-mode"] as string,
"system-prompt": flags["system-prompt"] as string,
continue: !!flags.continue,
quiet: !!flags.quiet,
}, process.argv.slice(2));
}
else if (sub === "list" || sub === "ls") { const { runList } = await import("~/commands/list.js"); await runList(); }
else if (sub === "info") { const { runInfo } = await import("~/commands/info.js"); await runInfo({}); }
else if (sub === "create" || sub === "new") { const { newMesh } = await import("~/commands/new.js"); process.exit(await newMesh(positionals[1] ?? "", { json: !!flags.json })); }
else if (sub === "join" || sub === "add") { const { runJoin } = await import("~/commands/join.js"); await runJoin(positionals.slice(1)); }
else if (sub === "delete" || sub === "rm") { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[1] ?? "", { yes: !!flags.y || !!flags.yes })); }
else if (sub === "rename") { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[1] ?? "", positionals[2] ?? "")); }
else if (sub === "share" || sub === "invite") { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[1], { mesh: flags.mesh as string, json: !!flags.json })); }
else if (sub === "overview") { const { runMe } = await import("~/commands/me.js"); process.exit(await runMe({ mesh: flags.mesh as string, json: !!flags.json })); }
else { console.error("Usage: claudemesh workspace <list|info|create|join|delete|rename|share|launch|overview>"); process.exit(EXIT.INVALID_ARGS); }
break;
}
case "disconnect": { const { runDisconnect } = await import("~/commands/kick.js"); process.exit(await runDisconnect(positionals[0], { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); break; } case "disconnect": { const { runDisconnect } = await import("~/commands/kick.js"); process.exit(await runDisconnect(positionals[0], { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); break; }
case "kick": { const { runKick } = await import("~/commands/kick.js"); process.exit(await runKick(positionals[0], { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); break; } case "kick": { const { runKick } = await import("~/commands/kick.js"); process.exit(await runKick(positionals[0], { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); break; }
case "ban": { const { runBan } = await import("~/commands/ban.js"); process.exit(await runBan(positionals[0], { mesh: flags.mesh as string })); break; } case "ban": { const { runBan } = await import("~/commands/ban.js"); process.exit(await runBan(positionals[0], { mesh: flags.mesh as string })); break; }

View File

@@ -125,7 +125,7 @@ function subscribeEvents(onEvent: (e: DaemonEvent) => void): { close: () => void
} }
if (!dataLine) continue; if (!dataLine) continue;
try { try {
const parsed = JSON.parse(dataLine); const parsed = JSON.parse(dataLine) as Record<string, unknown>;
onEvent({ kind, ts: String(parsed.ts ?? ""), data: parsed }); onEvent({ kind, ts: String(parsed.ts ?? ""), data: parsed });
} catch { /* malformed event; skip */ } } catch { /* malformed event; skip */ }
} }

View File

@@ -1,114 +0,0 @@
/**
* Bridge client — CLI invocations dial the per-mesh Unix socket the
* MCP push-pipe holds open, so they reuse its warm WS instead of opening
* a fresh one (~5ms vs ~300-700ms).
*
* Usage from a command:
*
* const result = await tryBridge(meshSlug, "send", { to, message });
* if (result === null) { ...fall through to cold withMesh()... }
* else { ...warm path succeeded... }
*
* `tryBridge` returns null on:
* - socket file absent (no push-pipe running)
* - socket connect fails (push-pipe crashed without cleanup)
* - bridge timeout
* That null is the caller's signal to fall back to a cold WS connection
* via `withMesh`. So the bridge is purely an optimization — every verb
* still works without it.
*/
import { createConnection } from "node:net";
import { existsSync } from "node:fs";
import { randomUUID } from "node:crypto";
import {
socketPath,
frame,
LineParser,
type BridgeRequest,
type BridgeResponse,
type BridgeVerb,
} from "./protocol.js";
const DEFAULT_TIMEOUT_MS = 5_000;
/**
* Send one request and await the matching response. Returns:
* - { ok: true, result } on success
* - { ok: false, error } on bridge-reachable-but-broker-error
* - null on bridge-unreachable (caller should fall back to cold WS)
*/
export async function tryBridge(
meshSlug: string,
verb: BridgeVerb,
args: Record<string, unknown> = {},
timeoutMs: number = DEFAULT_TIMEOUT_MS,
): Promise<{ ok: true; result: unknown } | { ok: false; error: string } | null> {
const path = socketPath(meshSlug);
if (!existsSync(path)) return null;
return new Promise((resolve) => {
const id = randomUUID();
const req: BridgeRequest = { id, verb, args };
const parser = new LineParser();
let settled = false;
const finish = (
value: { ok: true; result: unknown } | { ok: false; error: string } | null,
): void => {
if (settled) return;
settled = true;
try { socket.destroy(); } catch {}
clearTimeout(timer);
resolve(value);
};
const socket = createConnection({ path });
const timer = setTimeout(() => {
finish(null); // timeout = bridge unreachable, fall back to cold path
}, timeoutMs);
socket.on("connect", () => {
try {
socket.write(frame(req));
} catch {
finish(null);
}
});
socket.on("data", (chunk) => {
const lines = parser.feed(chunk);
for (const line of lines) {
if (!line.trim()) continue;
let res: BridgeResponse;
try {
res = JSON.parse(line) as BridgeResponse;
} catch {
continue;
}
if (res.id !== id) continue; // not our response — keep reading
if (res.ok) finish({ ok: true, result: res.result });
else finish({ ok: false, error: res.error });
return;
}
});
socket.on("error", (err) => {
// ENOENT (file disappeared between existsSync and connect),
// ECONNREFUSED (stale socket), EPERM (permission), etc. — all mean
// bridge unreachable.
const code = (err as NodeJS.ErrnoException).code;
if (code === "ECONNREFUSED" || code === "ENOENT" || code === "EPERM") {
finish(null);
} else {
finish(null);
}
});
socket.on("close", () => {
// If we close without a response, treat as unreachable.
finish(null);
});
});
}

View File

@@ -1,19 +1,48 @@
// Try forwarding a send through the local daemon's IPC. Returns null if // Daemon-routed CLI helpers. Returns null when the daemon is unreachable
// the daemon isn't running or the daemon's mesh doesn't match the target // AND auto-spawn could not bring it up — caller is expected to fall back
// mesh — the caller falls back to the bridge or cold path. // to its cold-path WS or to error out under `--strict`.
//
import { existsSync } from "node:fs"; // Auto-recovery: when the daemon socket is missing or stale, every
// helper here calls into the lifecycle module which probes, spawns
// (under a lock), polls, and retries — so cold-path fallback only
// fires if auto-spawn failed. The lifecycle module caches its
// per-process result, so a script doing 50 sends pays the spawn cost
// at most once.
//
// 1.28.0: the orphaned bridge tier between daemon and cold paths was
// removed. Two paths only: daemon (with auto-spawn) → cold.
import { ipc } from "~/daemon/ipc/client.js"; import { ipc } from "~/daemon/ipc/client.js";
import { DAEMON_PATHS } from "~/daemon/paths.js"; import { ensureDaemonReady } from "~/services/daemon/lifecycle.js";
import { getDaemonPolicy } from "~/services/daemon/policy.js";
import { warnDaemonState } from "~/ui/warnings.js";
function meshQuery(mesh?: string): string {
return mesh ? `?mesh=${encodeURIComponent(mesh)}` : "";
}
/** Common entry: ensure the daemon is reachable, emitting a one-shot
* stderr warning describing what we did. Returns true when the daemon
* is now reachable, false when the caller should fall back.
*
* --no-daemon short-circuits to false; --strict's enforcement lives at
* the cold-path entry point (`withMesh` in commands/connect.ts) so a
* single chokepoint covers every verb. */
async function daemonReachable(): Promise<boolean> {
const policy = getDaemonPolicy();
if (policy.mode === "no-daemon") return false;
const res = await ensureDaemonReady({ noAutoSpawn: false });
warnDaemonState(res, {});
return res.state === "up" || res.state === "started";
}
/** Try fetching the peer list through the daemon (~1ms warm IPC). /** Try fetching the peer list through the daemon (~1ms warm IPC).
* Returns null when the daemon socket isn't present so the caller can * Returns null when the daemon socket isn't present so the caller can
* fall back to bridge / cold paths. */ * fall back to bridge / cold paths. */
export async function tryListPeersViaDaemon(): Promise<unknown[] | null> { export async function tryListPeersViaDaemon(mesh?: string): Promise<unknown[] | null> {
if (!existsSync(DAEMON_PATHS.SOCK_FILE)) return null; if (!(await daemonReachable())) return null;
try { try {
const res = await ipc<{ peers?: unknown[] }>({ path: "/v1/peers", timeoutMs: 3_000 }); const res = await ipc<{ peers?: unknown[] }>({ path: `/v1/peers${meshQuery(mesh)}`, timeoutMs: 3_000 });
if (res.status !== 200) return null; if (res.status !== 200) return null;
return Array.isArray(res.body.peers) ? res.body.peers : []; return Array.isArray(res.body.peers) ? res.body.peers : [];
} catch (err) { } catch (err) {
@@ -24,10 +53,10 @@ export async function tryListPeersViaDaemon(): Promise<unknown[] | null> {
} }
/** Try fetching mesh-published skills through the daemon. */ /** Try fetching mesh-published skills through the daemon. */
export async function tryListSkillsViaDaemon(): Promise<unknown[] | null> { export async function tryListSkillsViaDaemon(mesh?: string): Promise<unknown[] | null> {
if (!existsSync(DAEMON_PATHS.SOCK_FILE)) return null; if (!(await daemonReachable())) return null;
try { try {
const res = await ipc<{ skills?: unknown[] }>({ path: "/v1/skills", timeoutMs: 3_000 }); const res = await ipc<{ skills?: unknown[] }>({ path: `/v1/skills${meshQuery(mesh)}`, timeoutMs: 3_000 });
if (res.status !== 200) return null; if (res.status !== 200) return null;
return Array.isArray(res.body.skills) ? res.body.skills : []; return Array.isArray(res.body.skills) ? res.body.skills : [];
} catch (err) { } catch (err) {
@@ -38,11 +67,11 @@ export async function tryListSkillsViaDaemon(): Promise<unknown[] | null> {
} }
/** Try fetching one skill body through the daemon. */ /** Try fetching one skill body through the daemon. */
export async function tryGetSkillViaDaemon(name: string): Promise<unknown | null> { export async function tryGetSkillViaDaemon(name: string, mesh?: string): Promise<unknown | null> {
if (!existsSync(DAEMON_PATHS.SOCK_FILE)) return null; if (!(await daemonReachable())) return null;
try { try {
const res = await ipc<{ skill?: unknown }>({ const res = await ipc<{ skill?: unknown }>({
path: `/v1/skills/${encodeURIComponent(name)}`, path: `/v1/skills/${encodeURIComponent(name)}${meshQuery(mesh)}`,
timeoutMs: 3_000, timeoutMs: 3_000,
}); });
if (res.status === 404) return null; if (res.status === 404) return null;
@@ -51,6 +80,109 @@ export async function tryGetSkillViaDaemon(name: string): Promise<unknown | null
} catch { return null; } } catch { return null; }
} }
// --- state ---
export type StateEntry = {
key: string;
value: unknown;
updatedBy: string;
updatedAt: string;
mesh?: string;
};
/** Try reading a single state key through the daemon. Returns:
* - the entry when the daemon found it
* - undefined when the daemon ran but the key is unset (404)
* - null when the daemon socket isn't present (caller falls back) */
export async function tryGetStateViaDaemon(key: string, mesh?: string): Promise<StateEntry | undefined | null> {
if (!(await daemonReachable())) return null;
try {
const path = `/v1/state?key=${encodeURIComponent(key)}${mesh ? `&mesh=${encodeURIComponent(mesh)}` : ""}`;
const res = await ipc<{ state?: StateEntry; error?: string }>({ path, timeoutMs: 3_000 });
if (res.status === 404) return undefined;
if (res.status !== 200) return null;
return res.body.state ?? undefined;
} catch (err) {
const msg = String(err);
if (/ENOENT|ECONNREFUSED|ipc_timeout/.test(msg)) return null;
return null;
}
}
export async function tryListStateViaDaemon(mesh?: string): Promise<StateEntry[] | null> {
if (!(await daemonReachable())) return null;
try {
const res = await ipc<{ entries?: StateEntry[] }>({ path: `/v1/state${meshQuery(mesh)}`, timeoutMs: 3_000 });
if (res.status !== 200) return null;
return Array.isArray(res.body.entries) ? res.body.entries : [];
} catch (err) {
const msg = String(err);
if (/ENOENT|ECONNREFUSED|ipc_timeout/.test(msg)) return null;
return null;
}
}
export async function trySetStateViaDaemon(key: string, value: unknown, mesh?: string): Promise<boolean> {
if (!(await daemonReachable())) return false;
try {
const res = await ipc<{ ok?: boolean; error?: string }>({
method: "POST",
path: "/v1/state",
timeoutMs: 3_000,
body: { key, value, ...(mesh ? { mesh } : {}) },
});
return res.status === 200 && res.body.ok === true;
} catch { return false; }
}
// --- memory ---
export type MemoryEntry = {
id: string;
content: string;
tags: string[];
rememberedBy: string;
rememberedAt: string;
mesh?: string;
};
export async function tryRememberViaDaemon(content: string, tags?: string[], mesh?: string): Promise<{ id: string; mesh?: string } | null> {
if (!(await daemonReachable())) return null;
try {
const res = await ipc<{ id?: string; mesh?: string; error?: string }>({
method: "POST",
path: "/v1/memory",
timeoutMs: 5_000,
body: { content, ...(tags?.length ? { tags } : {}), ...(mesh ? { mesh } : {}) },
});
if (res.status !== 200 || !res.body.id) return null;
return { id: res.body.id, mesh: res.body.mesh };
} catch { return null; }
}
export async function tryRecallViaDaemon(query: string, mesh?: string): Promise<MemoryEntry[] | null> {
if (!(await daemonReachable())) return null;
try {
const path = `/v1/memory?q=${encodeURIComponent(query)}${mesh ? `&mesh=${encodeURIComponent(mesh)}` : ""}`;
const res = await ipc<{ matches?: MemoryEntry[] }>({ path, timeoutMs: 5_000 });
if (res.status !== 200) return null;
return Array.isArray(res.body.matches) ? res.body.matches : [];
} catch (err) {
const msg = String(err);
if (/ENOENT|ECONNREFUSED|ipc_timeout/.test(msg)) return null;
return null;
}
}
export async function tryForgetViaDaemon(id: string, mesh?: string): Promise<boolean> {
if (!(await daemonReachable())) return false;
try {
const path = `/v1/memory/${encodeURIComponent(id)}${meshQuery(mesh)}`;
const res = await ipc<{ ok?: boolean }>({ method: "DELETE", path, timeoutMs: 3_000 });
return res.status === 200 && res.body.ok === true;
} catch { return false; }
}
export type DaemonSendOk = { export type DaemonSendOk = {
ok: true; ok: true;
messageId: string; messageId: string;
@@ -72,7 +204,7 @@ export async function trySendViaDaemon(args: {
* right mesh by either flag or single-mesh-default. */ * right mesh by either flag or single-mesh-default. */
expectedMesh?: string; expectedMesh?: string;
}): Promise<DaemonSendResult | null> { }): Promise<DaemonSendResult | null> {
if (!existsSync(DAEMON_PATHS.SOCK_FILE)) return null; if (!(await daemonReachable())) return null;
try { try {
const res = await ipc<{ const res = await ipc<{

View File

@@ -1,93 +0,0 @@
/**
* Bridge protocol — wire format between the MCP push-pipe (server) and
* CLI invocations (client) over a per-mesh Unix domain socket.
*
* Why: every CLI op should reuse the warm WS the push-pipe already holds
* (~5ms) instead of opening its own (~300-700ms cold start). The bridge is
* the load-bearing piece of the CLI-first architecture — see
* .artifacts/specs/2026-05-02-architecture-north-star.md commitment #3.
*
* Wire format: line-delimited JSON. One JSON object per "\n"-terminated line.
* Each request carries an `id` string; the response echoes it.
*
* Socket path: ~/.claudemesh/sockets/<mesh-slug>.sock (mode 0600).
*
* Connection model: persistent. A CLI invocation opens, sends one or more
* requests, reads matching responses, then closes. Multiplexing via `id`
* means concurrent CLI calls don't have to serialize on the same socket
* (though current callers all do one round-trip and exit).
*/
import { homedir } from "node:os";
import { join } from "node:path";
export const PROTOCOL_VERSION = 1;
/** Socket path for a given mesh. Caller is responsible for ensuring the
* parent directory exists (`~/.claudemesh/sockets/`). */
export function socketPath(meshSlug: string): string {
return join(homedir(), ".claudemesh", "sockets", `${meshSlug}.sock`);
}
/** Directory holding all per-mesh sockets. Created with mode 0700 on push-pipe boot. */
export function socketDir(): string {
return join(homedir(), ".claudemesh", "sockets");
}
/**
* Verbs the bridge accepts. Keep this list narrow in 1.2.0 — three writes
* (send, summary, status), the read-shaped peers, plus ping for health.
* Expand in 1.3.0 once the bridge is proven.
*/
export type BridgeVerb =
| "ping"
| "peers"
| "send"
| "summary"
| "status_set"
| "visible";
export interface BridgeRequest {
id: string;
verb: BridgeVerb;
args?: Record<string, unknown>;
}
export interface BridgeResponseOk {
id: string;
ok: true;
result: unknown;
}
export interface BridgeResponseErr {
id: string;
ok: false;
error: string;
}
export type BridgeResponse = BridgeResponseOk | BridgeResponseErr;
/** Serialise a request/response to a single line ("\n"-terminated). */
export function frame(obj: BridgeRequest | BridgeResponse): string {
return JSON.stringify(obj) + "\n";
}
/**
* Stateful line-buffered parser. Pass each chunk from the socket via
* `feed`; collect completed lines from the returned array.
*/
export class LineParser {
private buf = "";
feed(chunk: Buffer | string): string[] {
this.buf += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
const lines: string[] = [];
let nl = this.buf.indexOf("\n");
while (nl !== -1) {
lines.push(this.buf.slice(0, nl));
this.buf = this.buf.slice(nl + 1);
nl = this.buf.indexOf("\n");
}
return lines;
}
}

View File

@@ -1,229 +0,0 @@
/**
* Bridge server — the MCP push-pipe runs one of these per connected mesh.
*
* Listens on a Unix domain socket at `~/.claudemesh/sockets/<mesh-slug>.sock`,
* accepts line-delimited JSON requests from CLI invocations, dispatches each
* request to the corresponding `BrokerClient` method, and writes the response
* back on the same line.
*
* Lifecycle:
* - `startBridgeServer(client)` is called from the MCP push-pipe boot path
* once the WS is connected (or even before — verbs that need an open WS
* will return an error).
* - On startup it `unlinks` any stale socket file (left by a crashed
* prior process), then `listen`s.
* - On shutdown (`stop()`) it closes the listener and unlinks the socket.
*
* Concurrency: each accepted connection gets its own line-buffered parser.
* Multiple in-flight requests are correlated by `id`; the server doesn't
* need to serialize because the underlying `BrokerClient` calls are
* `async` and non-blocking.
*
* Error model: malformed lines are dropped silently (don't tear down the
* socket). Unknown verbs return `{ok: false, error: "unknown verb"}`.
* Broker errors are wrapped into the `error` string.
*/
import { createServer, type Server, type Socket } from "node:net";
import { mkdirSync, unlinkSync, existsSync, chmodSync } from "node:fs";
import { dirname } from "node:path";
import type { BrokerClient } from "~/services/broker/facade.js";
import {
socketPath,
socketDir,
frame,
LineParser,
type BridgeRequest,
type BridgeResponse,
type BridgeVerb,
} from "./protocol.js";
export interface BridgeServer {
stop(): void;
path: string;
}
type PeerStatus = "idle" | "working" | "dnd";
/**
* Resolve a `to` string to a broker-friendly target spec. Mirrors what
* `commands/send.ts` does today — display name → pubkey, hex stays hex,
* `@group` and `*` pass through.
*/
async function resolveTarget(
client: BrokerClient,
to: string,
): Promise<{ ok: true; spec: string } | { ok: false; error: string }> {
if (to.startsWith("@") || to === "*" || /^[0-9a-f]{64}$/i.test(to)) {
return { ok: true, spec: to };
}
const peers = await client.listPeers();
const match = peers.find((p) => p.displayName.toLowerCase() === to.toLowerCase());
if (!match) {
return {
ok: false,
error: `peer "${to}" not found. online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`,
};
}
return { ok: true, spec: match.pubkey };
}
async function dispatch(
client: BrokerClient,
req: BridgeRequest,
): Promise<BridgeResponse> {
const args = req.args ?? {};
try {
switch (req.verb as BridgeVerb) {
case "ping": {
const peers = await client.listPeers();
return {
id: req.id,
ok: true,
result: {
mesh: client.meshSlug,
ws_status: client.status,
peers_online: peers.length,
push_buffer: client.pushHistory.length,
},
};
}
case "peers": {
const peers = await client.listPeers();
return { id: req.id, ok: true, result: peers };
}
case "send": {
const to = String(args.to ?? "");
const message = String(args.message ?? "");
const priority = (args.priority as "now" | "next" | "low" | undefined) ?? "next";
if (!to || !message) {
return { id: req.id, ok: false, error: "send: `to` and `message` required" };
}
const resolved = await resolveTarget(client, to);
if (!resolved.ok) return { id: req.id, ok: false, error: resolved.error };
const result = await client.send(resolved.spec, message, priority);
if (!result.ok) {
return { id: req.id, ok: false, error: result.error ?? "send failed" };
}
return {
id: req.id,
ok: true,
result: { messageId: result.messageId, target: resolved.spec },
};
}
case "summary": {
const text = String(args.summary ?? "");
if (!text) return { id: req.id, ok: false, error: "summary: `summary` required" };
await client.setSummary(text);
return { id: req.id, ok: true, result: { summary: text } };
}
case "status_set": {
const state = String(args.status ?? "") as PeerStatus;
if (!["idle", "working", "dnd"].includes(state)) {
return { id: req.id, ok: false, error: "status_set: must be idle | working | dnd" };
}
await client.setStatus(state);
return { id: req.id, ok: true, result: { status: state } };
}
case "visible": {
const visible = Boolean(args.visible);
await client.setVisible(visible);
return { id: req.id, ok: true, result: { visible } };
}
default:
return { id: req.id, ok: false, error: `unknown verb: ${req.verb}` };
}
} catch (err) {
return {
id: req.id,
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
function handleConnection(socket: Socket, client: BrokerClient): void {
const parser = new LineParser();
socket.on("data", (chunk) => {
const lines = parser.feed(chunk);
for (const line of lines) {
if (!line.trim()) continue;
let req: BridgeRequest;
try {
req = JSON.parse(line) as BridgeRequest;
} catch {
continue;
}
if (!req || typeof req !== "object" || !req.id || !req.verb) continue;
// Fire-and-await without blocking the read loop.
void dispatch(client, req).then((res) => {
try {
socket.write(frame(res));
} catch {
/* socket might have closed mid-flight; ignore */
}
});
}
});
socket.on("error", () => {
// Don't crash the push-pipe on per-connection errors.
});
}
/**
* Start the per-mesh bridge server. Returns a handle the caller stores so
* it can `stop()` on shutdown.
*
* Idempotent: if a socket file already exists, attempts to connect to it.
* If that connection succeeds, another live process owns it — return null.
* If it fails (ECONNREFUSED), the file is stale; unlink it and proceed.
*/
export function startBridgeServer(client: BrokerClient): BridgeServer | null {
const path = socketPath(client.meshSlug);
const dir = socketDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Last-writer-wins: unconditionally remove any existing socket file and
// bind fresh. A live process previously holding it keeps its already-
// accepted connections (sockets aren't path-based after connect), but
// new CLI dials hit the new server. In practice this only matters when
// two `claudemesh launch` invocations target the same mesh — rare, and
// either instance serving CLI requests is fine because both speak to
// the same broker.
if (existsSync(path)) {
try { unlinkSync(path); } catch {}
}
const server: Server = createServer((socket) => handleConnection(socket, client));
try {
server.listen(path);
} catch (err) {
process.stderr.write(`[claudemesh] bridge: failed to bind ${path}: ${String(err)}\n`);
return null;
}
server.on("error", (err) => {
process.stderr.write(`[claudemesh] bridge: ${String(err)}\n`);
});
// Tighten permissions so other users on the host can't dial in.
try { chmodSync(path, 0o600); } catch {}
let stopped = false;
return {
path,
stop(): void {
if (stopped) return;
stopped = true;
try { server.close(); } catch {}
try { unlinkSync(path); } catch {}
},
};
}

View File

@@ -0,0 +1,72 @@
/**
* CLI-side helpers for the per-session attestation flow.
*
* Two pieces:
* 1. `signParentAttestation` — `claudemesh launch` calls this with the
* member's stable secret key to mint a long-lived (≤24h) token that
* vouches for an ephemeral session pubkey. The attestation travels
* with the session-token registration to the daemon.
* 2. `signSessionHello` — the daemon's `SessionBrokerClient` calls this
* on every WS-connect to sign the canonical session-hello bytes with
* the session secret key (proves liveness + possession).
*
* Both formats mirror the broker's `canonicalSessionAttestation` /
* `canonicalSessionHello`. Drift will surface as `bad_signature` from
* the broker, never silent breakage.
*/
import { ensureSodium } from "~/services/crypto/keypair.js";
/** Default attestation lifetime — 12h leaves headroom under broker's 24h cap. */
export const DEFAULT_ATTESTATION_TTL_MS = 12 * 60 * 60 * 1000;
export interface ParentAttestation {
sessionPubkey: string;
parentMemberPubkey: string;
expiresAt: number;
signature: string;
}
/** Sign the parent-vouches-session attestation. */
export async function signParentAttestation(args: {
parentMemberPubkey: string;
parentSecretKey: string;
sessionPubkey: string;
/** Override the lifetime; default 12h. */
ttlMs?: number;
/** Override clock for tests. */
now?: number;
}): Promise<ParentAttestation> {
const s = await ensureSodium();
const expiresAt = (args.now ?? Date.now()) + (args.ttlMs ?? DEFAULT_ATTESTATION_TTL_MS);
const canonical = `claudemesh-session-attest|${args.parentMemberPubkey}|${args.sessionPubkey}|${expiresAt}`;
const sig = s.crypto_sign_detached(
s.from_string(canonical),
s.from_hex(args.parentSecretKey),
);
return {
sessionPubkey: args.sessionPubkey,
parentMemberPubkey: args.parentMemberPubkey,
expiresAt,
signature: s.to_hex(sig),
};
}
/** Sign the per-WS-connect session-hello bytes. */
export async function signSessionHello(args: {
meshId: string;
parentMemberPubkey: string;
sessionPubkey: string;
sessionSecretKey: string;
now?: number;
}): Promise<{ timestamp: number; signature: string }> {
const s = await ensureSodium();
const timestamp = args.now ?? Date.now();
const canonical =
`claudemesh-session-hello|${args.meshId}|${args.parentMemberPubkey}|${args.sessionPubkey}|${timestamp}`;
const sig = s.crypto_sign_detached(
s.from_string(canonical),
s.from_hex(args.sessionSecretKey),
);
return { timestamp, signature: s.to_hex(sig) };
}

View File

@@ -0,0 +1,243 @@
/**
* Daemon lifecycle helper — probe, auto-spawn, retry, fall-through.
*
* Every daemon-routed CLI verb passes through `ensureDaemonReady()` before
* its IPC call. The helper:
*
* 1. Probes the socket via a fast `/v1/version` IPC (~5-10 ms).
* 2. If the socket is missing OR present-but-stale, attempts a detached
* `claudemesh daemon up` spawn under a file-lock.
* 3. Polls for the new socket up to a budget (default 3s).
* 4. Returns a state describing what happened, so the caller can either
* proceed warm or fall back to the cold path with a clear warning.
*
* State machine:
* - "up" daemon was already running
* - "started" daemon was down; we spawned it; it came up
* - "down" daemon was down; auto-spawn skipped (e.g., recursion guard)
* - "spawn-failed" spawn attempted but socket never appeared within budget
* - "spawn-suppressed" recently-failed marker is fresh; skipped retry
*
* Stale-socket handling: if the socket file exists but the IPC probe
* fails (ECONNREFUSED / timeout), we treat the file as stale, remove
* it, and proceed as if the daemon were down. This fixes the prior bug
* where `existsSync(SOCK_FILE)` was a false positive after a daemon
* crash.
*
* Recursion guard: when we spawn the daemon we set
* `CLAUDEMESH_INTERNAL_NO_AUTOSPAWN=1` in its env so any nested CLI
* calls inside the daemon skip the auto-spawn check and avoid a loop.
*/
import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { ipc, IpcError } from "~/daemon/ipc/client.js";
import { DAEMON_PATHS } from "~/daemon/paths.js";
export type DaemonReadyState =
| "up"
| "started"
| "down"
| "spawn-failed"
| "spawn-suppressed";
export interface EnsureDaemonResult {
state: DaemonReadyState;
/** Total ms spent in this call (probe ± spawn ± poll). */
durationMs: number;
/** When state is `spawn-failed` or `spawn-suppressed`, a one-line reason. */
reason?: string;
}
export interface EnsureDaemonOpts {
/** Total budget for socket-appearance polling after spawn. Default 3000ms. */
budgetMs?: number;
/** Skip auto-spawn entirely. Used by `--no-daemon` and the recursion guard. */
noAutoSpawn?: boolean;
/** When auto-spawning a legacy single-mesh daemon, pin a slug. Omit for multi-mesh (default). */
mesh?: string;
}
const SPAWN_LOCK_FILE = () => join(DAEMON_PATHS.DAEMON_DIR, ".spawn.lock");
const SPAWN_FAIL_FILE = () => join(DAEMON_PATHS.DAEMON_DIR, ".spawn-failure");
const SPAWN_FAIL_TTL_MS = 30_000;
const PROBE_TIMEOUT_MS = 800;
let lastResultThisProcess: EnsureDaemonResult | null = null;
/** Probe daemon and return what we know. Cached per-process so a script
* with 50 sends doesn't re-spawn 50 times. */
export async function ensureDaemonReady(opts: EnsureDaemonOpts = {}): Promise<EnsureDaemonResult> {
if (lastResultThisProcess && (lastResultThisProcess.state === "up" || lastResultThisProcess.state === "started")) {
return lastResultThisProcess;
}
if (process.env.CLAUDEMESH_INTERNAL_NO_AUTOSPAWN === "1") {
opts = { ...opts, noAutoSpawn: true };
}
const result = await runEnsureDaemon(opts);
lastResultThisProcess = result;
return result;
}
/** Reset the per-process cache. Test helper. */
export function _resetDaemonReadyCache(): void {
lastResultThisProcess = null;
}
async function runEnsureDaemon(opts: EnsureDaemonOpts): Promise<EnsureDaemonResult> {
const t0 = Date.now();
// Step 1 — probe.
const probe = await probeDaemon();
if (probe === "up") return { state: "up", durationMs: Date.now() - t0 };
if (probe === "stale") cleanupStaleFiles();
// Step 2 — auto-spawn unless forbidden.
if (opts.noAutoSpawn) {
return { state: "down", durationMs: Date.now() - t0, reason: "auto-spawn disabled" };
}
if (recentSpawnFailureFresh()) {
return {
state: "spawn-suppressed",
durationMs: Date.now() - t0,
reason: `daemon failed to start within last ${Math.round(SPAWN_FAIL_TTL_MS / 1000)}s`,
};
}
// Step 3 — spawn detached.
const spawnRes = await spawnDaemon(opts);
if (spawnRes.ok) {
return { state: "started", durationMs: Date.now() - t0 };
}
// Step 4 — record failure for backoff and report.
markSpawnFailure();
return { state: "spawn-failed", durationMs: Date.now() - t0, reason: spawnRes.reason };
}
async function probeDaemon(): Promise<"up" | "absent" | "stale"> {
if (!existsSync(DAEMON_PATHS.SOCK_FILE)) return "absent";
try {
const res = await ipc<{ version?: string }>({ path: "/v1/version", timeoutMs: PROBE_TIMEOUT_MS });
if (res.status === 200) return "up";
return "stale";
} catch (err) {
if (err instanceof IpcError) return "stale";
const msg = String(err);
if (/ENOENT|ECONNREFUSED|ipc_timeout|EPIPE|ECONNRESET/.test(msg)) return "stale";
return "stale";
}
}
function cleanupStaleFiles(): void {
for (const p of [DAEMON_PATHS.SOCK_FILE, DAEMON_PATHS.PID_FILE]) {
try { unlinkSync(p); } catch { /* best-effort */ }
}
}
function recentSpawnFailureFresh(): boolean {
try {
const st = statSync(SPAWN_FAIL_FILE());
return Date.now() - st.mtimeMs < SPAWN_FAIL_TTL_MS;
} catch {
return false;
}
}
function markSpawnFailure(): void {
try { writeFileSync(SPAWN_FAIL_FILE(), String(Date.now()), { mode: 0o600 }); } catch { /* best-effort */ }
}
function clearSpawnFailure(): void {
try { unlinkSync(SPAWN_FAIL_FILE()); } catch { /* best-effort */ }
}
interface SpawnResult { ok: boolean; reason?: string; }
async function spawnDaemon(opts: EnsureDaemonOpts): Promise<SpawnResult> {
const lockResult = await acquireOrShareLock(opts);
if (lockResult === "wait-existing") {
// Another process is spawning; just wait for the socket to appear.
return await pollForSocket(opts.budgetMs ?? 3_000);
}
try {
const { spawn } = await import("node:child_process");
const binary = await resolveCliBinary();
const args = ["daemon", "up"];
if (opts.mesh) args.push("--mesh", opts.mesh);
const child = spawn(binary, args, {
detached: true,
stdio: "ignore",
env: { ...process.env, CLAUDEMESH_INTERNAL_NO_AUTOSPAWN: "1" },
});
child.unref();
const polled = await pollForSocket(opts.budgetMs ?? 3_000);
if (polled.ok) clearSpawnFailure();
return polled;
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
} finally {
releaseLock();
}
}
/** Acquire spawn lock. If another process holds it AND its pid is alive,
* return "wait-existing" so we share that spawn attempt. If the pid is
* dead, take over the lock. */
async function acquireOrShareLock(_opts: EnsureDaemonOpts): Promise<"acquired" | "wait-existing"> {
const lockPath = SPAWN_LOCK_FILE();
if (existsSync(lockPath)) {
try {
const pidStr = readFileSync(lockPath, "utf8").trim();
const pid = Number.parseInt(pidStr, 10);
if (Number.isFinite(pid) && pid > 0) {
try {
process.kill(pid, 0); // signal 0 = liveness probe
return "wait-existing";
} catch {
// Holder is dead — fall through to take over.
}
}
} catch { /* unreadable lock — take over */ }
}
try {
writeFileSync(lockPath, String(process.pid), { mode: 0o600 });
} catch { /* best-effort; lock is advisory */ }
return "acquired";
}
function releaseLock(): void {
try { unlinkSync(SPAWN_LOCK_FILE()); } catch { /* best-effort */ }
}
async function pollForSocket(budgetMs: number): Promise<SpawnResult> {
const start = Date.now();
while (Date.now() - start < budgetMs) {
if (existsSync(DAEMON_PATHS.SOCK_FILE)) {
// Don't just trust file presence — confirm it answers.
const probe = await probeDaemon();
if (probe === "up") return { ok: true };
}
await new Promise((r) => setTimeout(r, 150));
}
return { ok: false, reason: `socket did not appear within ${budgetMs}ms` };
}
/** Resolve the absolute path to the `claudemesh` binary the user is running.
* When invoked via tsx/bun in dev, fall back to the system `claudemesh`. */
async function resolveCliBinary(): Promise<string> {
const argv1 = process.argv[1] ?? "claudemesh";
if (/\.ts$/.test(argv1) || /node_modules|src\/entrypoints/.test(argv1)) {
try {
const { execSync } = await import("node:child_process");
return execSync("which claudemesh", { encoding: "utf8" }).trim() || "claudemesh";
} catch {
return "claudemesh";
}
}
return argv1;
}

View File

@@ -0,0 +1,42 @@
/**
* Per-process daemon policy — set once at CLI entry from --no-daemon /
* --strict / env var, then read by daemon-routing helpers.
*
* Modes:
* "auto" (default) probe → auto-spawn → retry → cold fallback
* "strict" probe → auto-spawn → retry → ERROR (no cold fallback)
* "no-daemon" skip daemon entirely → straight to cold path
*
* Env equivalents (for headless/CI use):
* CLAUDEMESH_STRICT_DAEMON=1 → strict
* CLAUDEMESH_NO_DAEMON=1 → no-daemon
*
* Flag wins over env when both are set.
*/
export type DaemonMode = "auto" | "strict" | "no-daemon";
export interface DaemonPolicy { mode: DaemonMode; }
let policy: DaemonPolicy = readEnvDefault();
function readEnvDefault(): DaemonPolicy {
if (process.env.CLAUDEMESH_NO_DAEMON === "1") return { mode: "no-daemon" };
if (process.env.CLAUDEMESH_STRICT_DAEMON === "1") return { mode: "strict" };
return { mode: "auto" };
}
export function setDaemonPolicy(mode: DaemonMode): void {
policy = { mode };
}
export function getDaemonPolicy(): DaemonPolicy {
return policy;
}
/** Pick a mode from parsed flags. CLI flags win over env. */
export function policyFromFlags(flags: Record<string, unknown>): DaemonMode {
if (flags["no-daemon"]) return "no-daemon";
if (flags.strict) return "strict";
return readEnvDefault().mode;
}

View File

@@ -0,0 +1,56 @@
/**
* CLI-side session resolver. Reads the session token from env, asks
* the daemon `GET /v1/sessions/me`, and caches the result for the
* lifetime of this CLI invocation.
*
* Used by verbs that iterate multiple meshes client-side (peer list,
* me, member list) so that, when invoked from inside a launched
* session, they auto-scope to that session's workspace instead of
* aggregating across every joined mesh.
*
* Returns null when:
* - no token in env (caller is outside a launched session, or
* bare `claudemesh` with no installed daemon).
* - token present but daemon doesn't recognize it (registry was
* reset by a daemon restart).
* - any IPC error (treat as "no scoping info, fall back to default
* behavior").
*/
import { ipc } from "~/daemon/ipc/client.js";
import { readSessionTokenFromEnv } from "./token.js";
export interface ResolvedSession {
sessionId: string;
mesh: string;
displayName: string;
pid: number;
cwd?: string;
role?: string;
groups?: string[];
}
let cached: ResolvedSession | null | undefined = undefined;
export async function getSessionInfo(): Promise<ResolvedSession | null> {
if (cached !== undefined) return cached;
const tok = readSessionTokenFromEnv();
if (!tok) { cached = null; return null; }
try {
const res = await ipc<{ session?: ResolvedSession }>({
path: "/v1/sessions/me",
timeoutMs: 1_500,
});
if (res.status !== 200 || !res.body.session) { cached = null; return null; }
cached = res.body.session;
return cached;
} catch {
cached = null;
return null;
}
}
/** Test helper. */
export function _resetSessionCache(): void {
cached = undefined;
}

View File

@@ -0,0 +1,53 @@
/**
* Per-session IPC tokens — mint, persist, read.
*
* Each `claudemesh launch` mints a 32-byte random token, writes it to
* `<tmpdir>/session-token` (mode 0o600), and exposes the path to the
* spawned `claude` via `CLAUDEMESH_IPC_TOKEN_FILE`. Subprocesses
* inheriting this env auto-attach the token to every IPC request via
* the `Authorization: ClaudeMesh-Session <hex>` header. The daemon's
* registry resolves the token to `{sessionId, mesh, displayName, pid,
* cwd, ...}` in O(1) and uses it for auto-scoping + attribution.
*
* Why a file path env var, not the value directly:
* `ps eww -p <pid>` shows env values to other processes of the same
* uid. The path leaks; the secret in mode-0600 files inside a
* mode-0700 tmpdir does not. Same trick OpenSSH uses for SSH_AUTH_SOCK.
*/
import { randomBytes } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
const ENV_TOKEN_FILE = "CLAUDEMESH_IPC_TOKEN_FILE";
export interface MintedToken {
token: string;
/** Filesystem path the token was written to. Pass via env to children. */
filePath: string;
}
/** Generate a fresh 64-hex token and write it under `dir`. */
export function mintSessionToken(dir: string, fileName = "session-token"): MintedToken {
const token = randomBytes(32).toString("hex");
const filePath = `${dir}/${fileName}`;
writeFileSync(filePath, token, { mode: 0o600 });
return { token, filePath };
}
/** Read a token from the path in CLAUDEMESH_IPC_TOKEN_FILE, if present.
* Falls back to a literal CLAUDEMESH_IPC_TOKEN env value (for testing).
* Returns null when neither is set or the file is unreadable. */
export function readSessionTokenFromEnv(env: NodeJS.ProcessEnv = process.env): string | null {
const direct = env.CLAUDEMESH_IPC_TOKEN;
if (direct && /^[0-9a-f]{64}$/i.test(direct)) return direct.toLowerCase();
const path = env[ENV_TOKEN_FILE];
if (!path) return null;
try {
if (!existsSync(path)) return null;
const raw = readFileSync(path, "utf8").trim();
if (/^[0-9a-f]{64}$/i.test(raw)) return raw.toLowerCase();
return null;
} catch { return null; }
}
export const TOKEN_FILE_ENV = ENV_TOKEN_FILE;

9
apps/cli/src/types/text-import.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/**
* Bun's text-import attribute lets us bake `.md` content into the bundle
* at build time. TypeScript doesn't know about the import attribute
* syntax for non-JS modules, so we declare the wildcard here.
*/
declare module "*.md" {
const content: string;
export default content;
}

View File

@@ -0,0 +1,60 @@
/**
* Once-per-process daemon-state warnings, routed to stderr.
*
* Suppressed under --quiet (caller responsibility — we never inspect
* argv). JSON callers should consult the result's `state` field
* directly and skip calling this helper.
*/
import type { EnsureDaemonResult } from "~/services/daemon/lifecycle.js";
import { getDaemonPolicy } from "~/services/daemon/policy.js";
import { dim } from "./styles.js";
let alreadyWarned = false;
export interface WarnDaemonOpts {
quiet?: boolean;
/** When true, emit nothing — the caller will surface the state in JSON. */
json?: boolean;
}
/** Print a single, severity-appropriate line to stderr describing the
* result of `ensureDaemonReady`. Returns whether anything was printed. */
export function warnDaemonState(
res: EnsureDaemonResult,
opts: WarnDaemonOpts = {},
): boolean {
if (alreadyWarned) return false;
if (opts.quiet || opts.json) return false;
if (res.state === "up") return false;
// Under --strict, the cold-path gate at `withMesh` will print its own
// refusal message — suppress the misleading "using cold path" hint
// here so the user sees a single, accurate error.
if (getDaemonPolicy().mode === "strict" && res.state !== "started") return false;
alreadyWarned = true;
const tag = (label: string) => `[claudemesh] ${label}`;
const hint = (s: string) => dim(s);
switch (res.state) {
case "started":
process.stderr.write(`${tag("info")} daemon restarted automatically ${hint(`(took ${res.durationMs}ms)`)}\n`);
return true;
case "down":
process.stderr.write(`${tag("info")} daemon not running — using cold path ${hint("(slower; run `claudemesh daemon up` for warm path)")}\n`);
return true;
case "spawn-suppressed":
process.stderr.write(`${tag("warn")} ${res.reason ?? "daemon failed to start recently"} — using cold path ${hint("(run `claudemesh doctor`)")}\n`);
return true;
case "spawn-failed":
process.stderr.write(`${tag("warn")} daemon spawn failed${res.reason ? `: ${res.reason}` : ""} — using cold path ${hint("(check ~/.claudemesh/daemon/daemon.log)")}\n`);
return true;
}
return false;
}
/** Reset the once-per-process latch. Test helper. */
export function _resetDaemonWarningLatch(): void {
alreadyWarned = false;
}

View File

@@ -1,14 +1,18 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { execSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { resolve } from "node:path"; import { resolve } from "node:path";
const CLI = resolve(__dirname, "../../dist/entrypoints/cli.js"); const CLI = resolve(__dirname, "../../dist/entrypoints/cli.js");
describe("golden: whoami --json", () => { describe("golden: whoami --json", () => {
it("outputs schema_version 1.0 when not signed in", () => { it("outputs schema_version 1.0 when not signed in", () => {
// `whoami --json` exits 2 (EXIT.AUTH_FAILED) when not signed in.
// The JSON is still valid output and the contract under test —
// capture stdout independently of exit code.
const env = { ...process.env, CLAUDEMESH_CONFIG_DIR: "/tmp/claudemesh-golden-test-" + Date.now() }; const env = { ...process.env, CLAUDEMESH_CONFIG_DIR: "/tmp/claudemesh-golden-test-" + Date.now() };
const output = execSync(`node ${CLI} whoami --json`, { encoding: "utf-8", env }).trim(); const result = spawnSync("node", [CLI, "whoami", "--json"], { encoding: "utf-8", env });
const json = JSON.parse(output); expect([0, 2]).toContain(result.status);
const json = JSON.parse(result.stdout.trim());
expect(json.schema_version).toBe("1.0"); expect(json.schema_version).toBe("1.0");
expect(json.signed_in).toBe(false); expect(json.signed_in).toBe(false);
}); });

View File

@@ -0,0 +1,88 @@
/**
* CLI-side session-hello signing.
*
* Roundtrip: the signatures we mint with the CLI helpers must match the
* canonical bytes the broker recomputes from the same fields. Drift here
* shows up as `bad_signature` on the broker — easier to catch in unit
* tests than in end-to-end flow.
*/
import { describe, expect, test } from "vitest";
import sodium from "libsodium-wrappers";
import {
signParentAttestation,
signSessionHello,
DEFAULT_ATTESTATION_TTL_MS,
} from "../../src/services/broker/session-hello-sig.js";
async function makeKeypair(): Promise<{ publicKey: string; secretKey: string }> {
await sodium.ready;
const kp = sodium.crypto_sign_keypair();
return {
publicKey: sodium.to_hex(kp.publicKey),
secretKey: sodium.to_hex(kp.privateKey),
};
}
describe("signParentAttestation", () => {
test("produces canonical bytes that verify against parent pubkey", async () => {
await sodium.ready;
const parent = await makeKeypair();
const session = await makeKeypair();
const att = await signParentAttestation({
parentMemberPubkey: parent.publicKey,
parentSecretKey: parent.secretKey,
sessionPubkey: session.publicKey,
});
expect(att.parentMemberPubkey).toBe(parent.publicKey);
expect(att.sessionPubkey).toBe(session.publicKey);
expect(att.signature).toMatch(/^[0-9a-f]{128}$/);
const canonical =
`claudemesh-session-attest|${parent.publicKey}|${session.publicKey}|${att.expiresAt}`;
const ok = sodium.crypto_sign_verify_detached(
sodium.from_hex(att.signature),
sodium.from_string(canonical),
sodium.from_hex(parent.publicKey),
);
expect(ok).toBe(true);
});
test("default TTL ≤24h cap", async () => {
const parent = await makeKeypair();
const session = await makeKeypair();
const now = 1_700_000_000_000;
const att = await signParentAttestation({
parentMemberPubkey: parent.publicKey,
parentSecretKey: parent.secretKey,
sessionPubkey: session.publicKey,
now,
});
expect(att.expiresAt).toBe(now + DEFAULT_ATTESTATION_TTL_MS);
expect(att.expiresAt - now).toBeLessThanOrEqual(24 * 60 * 60 * 1000);
});
});
describe("signSessionHello", () => {
test("signature verifies against session pubkey", async () => {
await sodium.ready;
const session = await makeKeypair();
const result = await signSessionHello({
meshId: "mesh-x",
parentMemberPubkey: "c".repeat(64),
sessionPubkey: session.publicKey,
sessionSecretKey: session.secretKey,
});
expect(result.signature).toMatch(/^[0-9a-f]{128}$/);
const canonical =
`claudemesh-session-hello|mesh-x|${"c".repeat(64)}|${session.publicKey}|${result.timestamp}`;
const ok = sodium.crypto_sign_verify_detached(
sodium.from_hex(result.signature),
sodium.from_string(canonical),
sodium.from_hex(session.publicKey),
);
expect(ok).toBe(true);
});
});

View File

@@ -0,0 +1,135 @@
/**
* Session-registry lifecycle hooks (1.30.0+).
*
* The daemon's session-broker subsystem subscribes to register/deregister
* events to open and close per-session WSes. Verifies:
* - hooks fire on register + deregister
* - replacing an entry under the same sessionId fires deregister(prior)
* followed by register(new)
* - reaper-triggered deregister fires the hook for dead pids
* - presence material round-trips through the registry
*/
import { afterEach, describe, expect, test, vi } from "vitest";
import {
_resetRegistry,
deregisterByToken,
registerSession,
resolveToken,
setRegistryHooks,
type SessionInfo,
} from "../../src/daemon/session-registry.js";
const PRESENCE = {
sessionPubkey: "a".repeat(64),
sessionSecretKey: "b".repeat(128),
parentAttestation: {
sessionPubkey: "a".repeat(64),
parentMemberPubkey: "c".repeat(64),
expiresAt: Date.now() + 60 * 60 * 1000,
signature: "d".repeat(128),
},
};
afterEach(() => {
_resetRegistry();
});
describe("session-registry hooks", () => {
test("onRegister fires on register", () => {
const onRegister = vi.fn();
const onDeregister = vi.fn();
setRegistryHooks({ onRegister, onDeregister });
registerSession({
token: "t".repeat(64),
sessionId: "sess-1",
mesh: "alpha",
displayName: "Alex",
pid: 12345,
presence: PRESENCE,
});
expect(onRegister).toHaveBeenCalledTimes(1);
expect(onDeregister).not.toHaveBeenCalled();
const arg = onRegister.mock.calls[0]![0] as SessionInfo;
expect(arg.sessionId).toBe("sess-1");
expect(arg.presence).toEqual(PRESENCE);
});
test("onDeregister fires on explicit deregister", () => {
const onRegister = vi.fn();
const onDeregister = vi.fn();
setRegistryHooks({ onRegister, onDeregister });
const token = "e".repeat(64);
registerSession({
token, sessionId: "sess-2", mesh: "alpha", displayName: "Alex",
pid: 12345,
});
onRegister.mockClear();
const ok = deregisterByToken(token);
expect(ok).toBe(true);
expect(onDeregister).toHaveBeenCalledTimes(1);
const arg = onDeregister.mock.calls[0]![0] as SessionInfo;
expect(arg.sessionId).toBe("sess-2");
});
test("re-registering same sessionId deregisters prior entry first", () => {
const onRegister = vi.fn();
const onDeregister = vi.fn();
setRegistryHooks({ onRegister, onDeregister });
const oldToken = "1".repeat(64);
const newToken = "2".repeat(64);
registerSession({
token: oldToken, sessionId: "sess-3", mesh: "alpha",
displayName: "Alex", pid: 12345,
});
expect(onRegister).toHaveBeenCalledTimes(1);
// Replace under same sessionId — prior must be torn down before new one.
registerSession({
token: newToken, sessionId: "sess-3", mesh: "alpha",
displayName: "Alex", pid: 12345,
});
expect(onDeregister).toHaveBeenCalledTimes(1);
expect(onRegister).toHaveBeenCalledTimes(2);
expect((onDeregister.mock.calls[0]![0] as SessionInfo).token).toBe(oldToken);
expect((onRegister.mock.calls[1]![0] as SessionInfo).token).toBe(newToken);
// Old token is unresolvable now.
expect(resolveToken(oldToken)).toBeNull();
expect(resolveToken(newToken)).toBeTruthy();
});
test("hooks tolerate throws (registry mutation still succeeds)", () => {
setRegistryHooks({
onRegister: () => { throw new Error("boom"); },
onDeregister: () => { throw new Error("boom"); },
});
const token = "f".repeat(64);
expect(() =>
registerSession({
token, sessionId: "sess-4", mesh: "alpha",
displayName: "Alex", pid: 12345,
}),
).not.toThrow();
expect(resolveToken(token)).toBeTruthy();
expect(() => deregisterByToken(token)).not.toThrow();
expect(resolveToken(token)).toBeNull();
});
test("presence is preserved through resolveToken", () => {
setRegistryHooks({});
const token = "9".repeat(64);
registerSession({
token, sessionId: "sess-5", mesh: "alpha",
displayName: "Alex", pid: 12345, presence: PRESENCE,
});
const got = resolveToken(token);
expect(got).not.toBeNull();
expect(got!.presence).toEqual(PRESENCE);
});
});

View File

@@ -223,43 +223,91 @@ The v0.9.0 foundation got promoted in three quick releases:
IPC accept time, drain is a forwarder. Adds `mesh`, `target_spec`, IPC accept time, drain is a forwarder. Adds `mesh`, `target_spec`,
`nonce`, `ciphertext`, `priority` columns to the outbox. `nonce`, `ciphertext`, `priority` columns to the outbox.
- **1.25.0** — CLI thin-client routing for `peer list`, - **1.25.0** — CLI thin-client routing for `peer list`,
`skill list`, `skill get`. Same daemon-first / bridge / cold-path `skill list`, `skill get`.
fallback shape as `trySendViaDaemon`.
- **1.25.0** — ambient mode: raw `claude` Just Works after - **1.25.0** — ambient mode: raw `claude` Just Works after
`claudemesh install`. No more `claudemesh launch` ceremony for the `claudemesh install`.
common case.
What this leaves on the v2.0.0 redesign roadmap is documented at What this leaves on the v2.0.0 redesign is documented at
`.artifacts/specs/2026-05-04-v2-roadmap-completion.md`: daemon `.artifacts/specs/2026-05-04-v2-roadmap-completion.md`.
multi-mesh, full CLI-to-thin-client conversion, mesh→workspace
rename, HKDF identity.
--- ---
## v2.0.0 — *the daemon redesign* ## v1.26.0 → v1.30.0 — *Sprint A toward v2* — *shipped*
The single largest architectural shift. Promotes the persistent The Sprint A push completed everything spec'd for v2.0.0 *except* HKDF
thing (the user's account + identity) to a persistent process (the identity (deferred for security review).
daemon), demotes the ephemeral thing (the Claude session) to a thin
client. **Half-shipped via 1.24.0 + 1.25.0; remainder spec'd at
`.artifacts/specs/2026-05-04-v2-roadmap-completion.md`.**
- **`claudemesh-daemon`** — long-lived per-user launchd / systemd - **1.26.0** — multi-mesh daemon. One process attaches to every joined
unit. One WebSocket per workspace, persistent across reboots and workspace simultaneously. Aggregate read routes (`/v1/peers`,
Claude restarts. Listens on `~/.claudemesh/sockets/<workspace>.sock`. `/v1/skills`) tag each record with its mesh; explicit `?mesh=<slug>`
- **HKDF-derived peer keypairs** — same identity across machines, narrows server-side. Outbox dispatch picks the right broker via the
no key copy ritual. Web sign-up = CLI sign-up = same crypto identity. `mesh` column.
- **Stateless CLI verbs** — every existing command becomes a thin - **1.27.0** — thin-client expansion to state + memory. `state get`,
socket client of the daemon. ~3000 LoC removed. `state set`, `state list`, `remember`, `recall`, `forget` all route
- **MCP server shrinks to ~50 LoC** — just a daemon-socket → through `/v1/state` and `/v1/memory`. First teaser of the
`experimental.claude/channel` adapter. `claudemesh workspace <verb>` alias surface.
- **`claudemesh launch` deprecated** — ambient mode means `claude` - **1.27.1** — wired six previously-dead launch flags through the CLI
works with no flags. Launch becomes a one-line alias that prints entrypoint (`--role`, `--groups`, `--message-mode`, `--system-prompt`,
"ambient mode now, just run `claude`." `--continue`, `--quiet`). Pure plumbing fix.
- **"Mesh" → "workspace" public surface** — DB tables keep - **1.27.2** — bundled `SKILL.md` gains a canonical fully-populated
`mesh_*` names for migration sanity. spawn template + per-flag annotation table for unattended scripting.
- **1.27.3** — self-healing daemon lifecycle. Every CLI verb probes
`/v1/version` (no more stale-socket false positives), auto-spawns a
detached `daemon up` under a file-lock when down, polls until live.
30 s recently-failed marker prevents thundering-herd retries.
- **1.28.0** — bridge tier deletion (~600 LoC dead code removed) +
per-process daemon policy: `--strict` (refuse cold fallback) and
`--no-daemon` (skip daemon entirely). Single chokepoint at
`withMesh`. Env equivalents.
- **1.29.0** — per-session IPC tokens. Every `claudemesh launch` mints
a 32-byte token under tmpdir mode-0600, registers it with the
daemon, exposes the path via `CLAUDEMESH_IPC_TOKEN_FILE` to children.
Daemon resolves `Authorization: ClaudeMesh-Session <hex>` to a
`SessionInfo`. CLI invocations from inside a launched session
auto-scope to its workspace instead of aggregating across all
joined meshes (verified: `peer list` returns 1 workspace's peers
with token, all 3 without). Server-side `meshFromCtx()` plumbing
on every read route.
- **1.30.0** — per-session broker presence. Two `claudemesh launch`
sessions in the same cwd finally see each other in `peer list`. Each
launched session has a long-lived broker presence row owned by the
daemon, identified by a per-launch ephemeral keypair vouched by the
member's stable key (OAuth-refresh-vs-access shape). Broker gains a
`session_hello` handler with parent-attestation TTL ≤24h + session-
signature checks; daemon adds a slim `SessionBrokerClient` and
registry lifecycle hooks. Also fixes a latent 1.29.0 TDZ bug where
`claudemesh launch`'s IPC session-token registration was silently
failing every run. Spec at
`.artifacts/specs/2026-05-04-per-session-presence.md`.
Spec: `.artifacts/specs/2026-05-02-roadmap.md`. What's left for true v2.0.0 (next sessions):
- **1.31.0** — launch wizard refactor (single render loop, daemon-as-
step probe panel, last-used persistence, drop `@ts-nocheck`).
- **1.32.0** — setup wizard refactor (state-detection snapshot, four-
branch flow, daemon install offer, post-join panel).
- **1.33.0** — full mesh→workspace public-surface rename in help/docs/
site; mesh aliases tagged deprecated; protocol/DB stay `mesh_*`.
---
## v2.0.0 — *HKDF cross-machine identity*
The remaining v2 promise after Sprint A: the user's account secret
derives a deterministic ed25519 keypair per workspace. Same identity
across laptop + desktop + server, no key copy ritual.
- **`HKDF(account_secret, info: "claudemesh/mesh/<mesh_id>/peer",
salt: <user_id>)`** — derived per-workspace.
- **Broker `account_secret` distribution** — vended on first
authenticated install over TLS. Needs design review on key
compromise recovery story.
- **Migration** — existing keypairs in config keep working. Opt-in
re-enrollment for users who want cross-machine sync.
- **Hello-sig protocol** — unchanged.
Reserved as its own sprint with an explicit security-review window.
Estimated 2-3 weeks.
--- ---

View File

@@ -5,4 +5,10 @@ import { env } from "./env";
import { schema } from "./schema"; import { schema } from "./schema";
const client = postgres(env.DATABASE_URL ?? ""); const client = postgres(env.DATABASE_URL ?? "");
export const db = drizzle({ client, schema, casing: "snake_case" }); // `schema` aggregates many `import * as <ns>` namespace bags. Drizzle's
// TSchema generic struggles with namespace-typed records — the runtime
// shape is correct but tsc can't unify the deeply-nested table/relation
// types against DrizzleConfig's overload set. ts-expect-error keeps the
// rest of the typecheck honest while documenting the known mismatch.
// @ts-expect-error drizzle TSchema generic narrowing
export const db = drizzle(client, { schema, casing: "snake_case" });