Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions deploy/mykbd.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# mykbd — v2 privileged write channel daemon (systemd unit, reference)
#
# Phase 6 of docs/v2-privileged-write-channel-DESIGN.md. The daemon is the
# ONLY process with write capability to the brain on disk; the Pi
# container mounts the brain read-only and reaches the daemon solely
# through the bind-mounted agent socket.
#
# Install: copy to /etc/systemd/system/mykbd.service, adjust the
# placeholders, then: systemctl daemon-reload && systemctl enable --now mykbd
#
Comment on lines +8 to +10
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Service files present =="
fd -a --glob 'mykbd*.service' deploy

echo
echo "== Instance specifiers in unit =="
rg -n '%i|%I|%h' deploy/mykbd.service

echo
echo "== Install instructions referencing unit naming =="
rg -n 'Install:|systemctl enable --now|mykbd@|mykbd\.service' deploy/mykbd.service docs/v2-container-topology.md

Repository: vilosource/mykb

Length of output: 833


Update install instructions to use templated unit name, matching documented procedure.

The service file uses instance specifiers (%i on line 23, %h on lines 25, 29, 44) that require a templated unit, but the install comments on lines 8–9 reference the non-template name mykbd.service. This conflicts with the documented correct procedure in docs/v2-container-topology.md (lines 59–61), which already instructs installing as mykbd@.service and enabling with mykbd@<brainuser>. Inconsistent install instructions risk incorrect deployment.

Suggested fix
-# Install: copy to /etc/systemd/system/mykbd.service, adjust the
-# placeholders, then: systemctl daemon-reload && systemctl enable --now mykbd
+# Install: copy to /etc/systemd/system/mykbd@.service, adjust the
+# placeholders, then:
+#   systemctl daemon-reload
+#   systemctl enable --now mykbd@<brainuser>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Install: copy to /etc/systemd/system/mykbd.service, adjust the
# placeholders, then: systemctl daemon-reload && systemctl enable --now mykbd
#
# Install: copy to /etc/systemd/system/mykbd@.service, adjust the
# placeholders, then:
# systemctl daemon-reload
# systemctl enable --now mykbd@<brainuser>
#
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/mykbd.service` around lines 8 - 10, Update the top install comments to
reflect that this is a templated unit (it uses instance specifiers %i and %h) by
instructing users to install the file as mykbd@.service and enable/start it with
the instance name (e.g., systemctl daemon-reload && systemctl enable --now
mykbd@<brainuser>), and make sure the comment references the templated unit name
mykbd@.service to match the procedure in docs/v2-container-topology.md.

# See docs/v2-container-topology.md for the full topology + the
# viloforge-platform integration seam.

[Unit]
Description=mykbd — mykb privileged write-channel daemon
After=network.target

[Service]
Type=simple

# Runs as the BRAIN-OWNING user. This uid owns ~/.mykb and the operator
# socket (0600) — it is the kernel-attested "operator" capability (§2.2).
User=%i

Environment=MYKB_DIR=%h/.mykb
# Dual-socket production mode (contract §2.2 amended):
# operator socket: host-local, 0600, this user only
# agent socket: bind-mounted into the Pi container, 0666, agent-capped
Environment=MYKB_OPERATOR_SOCKET=%h/.mykb/.mykbd-operator.sock
Environment=MYKB_AGENT_SOCKET=/run/mykbd/agent.sock

# /run/mykbd is the host-side directory bind-mounted into the container.
RuntimeDirectory=mykbd
RuntimeDirectoryMode=0755

ExecStart=/usr/bin/node /opt/mykb/dist/daemon/main.js
Restart=on-failure
RestartSec=2

# Hardening: the daemon needs write to ~/.mykb and /run/mykbd only.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=%h/.mykb /run/mykbd
PrivateTmp=true

[Install]
WantedBy=multi-user.target
91 changes: 91 additions & 0 deletions docs/v2-container-topology.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# v2 Container Topology — Phase 6

> Status: design + reference artifacts. Phase 6 of
> `v2-privileged-write-channel-DESIGN.md` (issue #1).
>
> Scope boundary: this document + `deploy/mykbd.service` + the
> dual-socket daemon code are the **mykb-repo** side. The actual
> `vf-agents-pi` container wiring (the argo/product manifests that add the
> RO brain mount + the agent-socket bind-mount) lives in
> **`viloforge-platform`** (per the standing "vafi config in
> viloforge-platform" project fact) and is a *deployment* action, not
> mykb-repo code. The integration seam is specified in §4 so that change
> is mechanical.

## 1. The trust boundary, concretely

```
HOST (trusted) │ Pi CONTAINER (untrusted LLM)
~/.mykb/ ── owned by brain uid ────────┼── mounted READ-ONLY
│ │ bash > facts.jsonl → EROFS ✗
│ (only writer) │ write tool → EROFS ✗
▼ │ python -c 'open(w)' → EROFS ✗
mykbd (systemd, brain uid) │ MykbStore.append… ──┐
├─ operator socket 0600 ───────────┼── (NOT mounted in) │
│ host operator / kb CLI only │ │
└─ agent socket 0666 ───────────┼── bind-mounted in ◄───────┘
capability capped at 'agent' │ the ONLY writable path
```
Comment on lines +17 to +29
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language hint to the fenced block to satisfy markdownlint MD040.

Suggested fix
-```
+```text
 HOST (trusted)                          │ Pi CONTAINER (untrusted LLM)
 ...
-```
+```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 17-17: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/v2-container-topology.md` around lines 17 - 29, The fenced code block in
docs/v2-container-topology.md (the block starting with ``` and containing the
"HOST (trusted)                          │ Pi CONTAINER (untrusted LLM)"
diagram) needs a language hint to satisfy markdownlint MD040; change the opening
fence from ``` to ```text so the block becomes a labeled code fence (e.g.,
replace the opening ``` with ```text) and leave the closing ``` unchanged.


The brain is bind-mounted **read-only** into the container, so every
direct syscall path to a brain file returns `EROFS` — closing the
`bash-bypass-known-gap` (the acceptance criterion, Phase 7). The only
success path from inside the container is "speak L4 to the daemon over
the bind-mounted **agent** socket," which is validated and capability-
capped.

## 2. Capability without `SO_PEERCRED` (contract §2.2 amended)

Node has no public `SO_PEERCRED` API. Capability is therefore established
by **which socket** a connection arrives on, with the **kernel enforcing
who may `connect()`**:

| Socket | Mode | Reachable by | Capability |
|---|---|---|---|
| operator | `0600` | brain uid only (host operator, `kb` CLI) | `operator` — maintenance verbs, may assert `trust:'operator'` |
| agent | `0666` | anyone who can see the socket inode — i.e. only the container it is bind-mounted into | `agent` — mutation/read/workspace; asserted trust capped at `agent` |

`0666` on the agent socket is safe: **access is not privilege**. A
connection there can only ever obtain the `agent` capability; it cannot
call operator-only verbs nor forge `trust:'operator'` (dispatcher gate,
contract §5 / §3.1a). The socket is bind-mounted *only* into the trusted
Pi container, never exposed host-wide.

## 3. Host setup (reference)

1. Deploy the built daemon to `/opt/mykb` (`dist/`), Node ≥ the repo
engine.
2. Install `deploy/mykbd.service` as `/etc/systemd/system/mykbd@.service`
(templated on the brain user), adjust `MYKB_DIR` / paths.
3. `systemctl enable --now mykbd@<brainuser>`.
4. The agent socket is created in `RuntimeDirectory=/run/mykbd`
(`/run/mykbd/agent.sock`); that directory is what the container mounts.

## 4. viloforge-platform integration seam (the cross-repo change)

The Pi product manifest (`viloforge-platform/argo/products/vafi*`) needs
exactly two volume edits on the agents-pi pod/container:

1. **Brain, read-only**:
`~/.mykb` (host) → container brain path, `readOnly: true`.
2. **Agent socket, read-write** (a socket endpoint, not a brain file):
host `/run/mykbd/agent.sock` (or its dir) → the in-container path the
extension expects, and set `MYKB_SOCKET` (or `MYKB_STORE=rpc` +
`MYKB_SOCKET`) so `selectKnowledgeStore` (Phase 4/5) picks the RPC
store. No brain write mount.

Nothing else in the container changes: the extension and `kb` CLI already
auto-select the RPC store when the socket is present (Phases 4–5). When
that platform change lands, Phase 7's `bash-bypass-known-gap` flips
🐛→✅ in `experiments/tool-gating/EXPERIMENT.md`.

## 5. What is NOT in scope here

- The argo/manifest change itself (lands in viloforge-platform; §4 is the
spec for it).
- Host-mode `kb-pi` enforcement — explicitly out of v2 scope (parent
DESIGN §Scope); the host operator is trusted and uses the operator
socket / local store.
- Multi-host / cloud backends (parent DESIGN §Backend extensibility) —
the L2 Strategy seam exists; no second backend is built in v2.
11 changes: 9 additions & 2 deletions docs/v2-protocol-contract-DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ Decided: JSON-RPC 2.0 request/response objects, each frame length-prefixed (4-by
- Length-prefix rather than newline-delimited: brain content (entry `text`, journal bodies, artifact content) contains arbitrary newlines; a newline delimiter would require escaping the payload. A byte count does not.
- The `{ op, args, id }` "Command" framing the parent DESIGN's pattern table promises maps onto JSON-RPC's `{ method, params, id }` 1:1. `method` = the L4 verb (§5); `params` = a single named-object (never positional — positional params couple the wire to argument order, an LSP hazard when backends evolve).

### 2.2 Auth model → **OS peer credentials (`SO_PEERCRED`), single socket, capability derived per-connection**
### 2.2 Auth model → **OS-enforced capability; dual capability sockets (amended Phase 6)**

> **AMENDED 2026-05-16 (Phase 6), per this doc's own change process** ("deviations require a revision to this doc, not an ad-hoc code decision", §1). **Forcing constraint:** Node.js exposes **no public `SO_PEERCRED` API** — the connecting peer's uid is not readable from a `net.Socket` without a native addon, which is against the project's deps-minimal ethos. **Resolution:** keep the *security property* (capability is kernel-attested, never client-asserted) but realize it through **two capability sockets** instead of one socket + `SO_PEERCRED`:
>
> - **operator socket** — created mode `0600`, owned by the brain uid. The kernel permits only the brain-owning uid to `connect()`. A connection here ⇒ `operator` capability.
> - **agent socket** — the one bind-mounted into the Pi container (Phase 6 topology). A connection here ⇒ `agent` capability; asserted `trust` capped at `agent` (§3.1a).
>
> This is **not an ad-hoc deviation**: the parent DESIGN §Operator-vs-extension explicitly sanctioned "operator commands gated by a different token / **socket** / capability", and §2.2's own text below already said splitting is protocol-compatible. The kernel still attests identity (filesystem-perms `connect()` enforcement instead of `SO_PEERCRED` readout); the daemon still never trusts a client-asserted capability. The capability resolver remains the injected Strategy seam (`DaemonOptions.resolveCapability`) decided in Phase 2 — the dual-socket resolver is one implementation of it; a future native-`SO_PEERCRED` single-socket resolver could replace it with no contract change. Single-socket dev-mode (default → `operator`) is retained for the host operator / tests (parent DESIGN §Dev-mode). The original single-socket+`SO_PEERCRED` text is kept below for design history.
Comment on lines +38 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unify the contract to one auth model to avoid contradictory normative guidance.

This amendment is written as the active contract, but other normative sections still prescribe SO_PEERCRED behavior. Please mark the old SO_PEERCRED passages as historical/non-normative (or remove them) and update remaining references to dual-socket capability language to keep the contract self-consistent.

Also applies to: 301-301

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/v2-protocol-contract-DESIGN.md` around lines 38 - 45, Update the DESIGN
doc so the dual-socket approach is the canonical auth model: mark all prior
SO_PEERCRED text as historical/non-normative (or remove it), replace remaining
normative references to SO_PEERCRED with the dual-socket language (operator
socket / agent socket) and ensure references to the capability resolver point to
DaemonOptions.resolveCapability as the injection seam; keep the single-socket
dev-mode note as explicit dev/test-only behavior and retain the original
SO_PEERCRED description only in a clearly labeled "History" or "Non-normative"
subsection for design provenance.


Decided: day-1 is OS perms, but *refined* beyond the parent DESIGN's "socket file mode + bind-mount" because envelope-v2's `trust` field (§3.1) forces the daemon to distinguish operator-capable connections from extension connections.

Expand Down Expand Up @@ -291,7 +298,7 @@ The full testing pyramid (unit transport/validators → integration capability+h
| Error taxonomy (§6) | ✅ `src/daemon/errors.ts` — full kind→code table; produced/asserted by the dispatch + scenario suites |
| `hello` handshake (§4.4) | ✅ implemented as a normal verb; capability echoed |
| Capability enforcement (§2.2) | ✅ per-verb gate + trust-cap, tested both capabilities incl. over the live socket |
| **SO_PEERCRED resolver** | ⏭ **deferred to Phase 6.** Node exposes no public SO_PEERCRED API; faking it in the scaffold would be dishonest. Resolution is an injected Strategy seam (`DaemonOptions.resolveCapability`, DIP); the kernel-peer-uid resolver lands with the systemd/container topology where it belongs. Default = `operator` (trusted dev-mode, parent DESIGN §Dev-mode). |
| ~~SO_PEERCRED resolver~~ → **dual capability sockets** | ✅ **Resolved Phase 6** (§2.2 amended). Node has no public SO_PEERCRED API, so capability is established by *which socket* a connection arrives on, kernel-enforced by `connect()` perms: operator socket `0600` (brain uid) vs agent socket `0666` (container, capability-capped). `DualSocketDaemon` (`src/daemon/dual-socket.ts`), 4 tests; single-socket dev default retained. Reference `deploy/mykbd.service` + `docs/v2-container-topology.md`. |
| L4 verbs → L3/core (§5) | ✅ full §5 surface wired except `area_stats` (needs MykbStore-internal db handle) and `rebuild` (CLI-inlined logic) → honestly UNSUPPORTED_OP via the CONTRACT_VERBS set, not faked |
| **L2 StorageBackend Strategy contract suite** | ⏭ **deferred.** Parent DESIGN §L2 is explicit: "v2 day-1: LocalFsBackend only." The scaffold re-homes `src/core/*` directly (sanctioned re-homing, not rewriting); the reusable StorageBackend contract suite is extracted when a second backend (S3/NFS) is actually built (contract §8). |
| Scenario e2e over real socket (capstone) | ✅ `tests/daemon/server.scenario.test.ts`, 4 scenario tests; representative verb of each group as operator + operator-only verb denied to agent over the wire + socket mode 0600 + split-write reassembly |
Expand Down
62 changes: 62 additions & 0 deletions src/daemon/dual-socket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Dual capability sockets — `docs/v2-protocol-contract-DESIGN.md` §2.2
* (amended Phase 6).
*
* Node exposes no public `SO_PEERCRED` API, so capability is established
* by *which socket a connection arrives on*, with the kernel enforcing
* who may `connect()`:
*
* - **operator socket** — mode `0600`, owned by the brain uid. Only the
* brain-owning uid (the host operator) can connect. ⇒ `operator`.
* - **agent socket** — mode `0666`, bind-mounted into the Pi container
* (a different uid). ⇒ `agent`; asserted `trust` capped at `agent`.
*
* `0666` on the agent socket is safe by design: *access is not
* privilege*. A connection there only ever gets the `agent` capability —
* it cannot call operator-only verbs nor assert `trust:'operator'` (the
* dispatcher gate, §5/§3.1a). The socket is also only bind-mounted into
* the trusted container, never exposed host-wide.
*
* Both sockets share ONE Dispatcher (one process, the sole writer), so
* the daemon's single event loop serializes JSONL appends across them.
*/

import { MykbDaemon } from './server.js';
import { Dispatcher } from './dispatch.js';

export interface DualSocketOptions {
brainPath: string;
operatorSocketPath: string;
agentSocketPath: string;
}

export class DualSocketDaemon {
private readonly operator: MykbDaemon;
private readonly agent: MykbDaemon;

constructor(opts: DualSocketOptions) {
const dispatcher = new Dispatcher(opts.brainPath);
this.operator = new MykbDaemon({
brainPath: opts.brainPath,
socketPath: opts.operatorSocketPath,
resolveCapability: () => 'operator',
socketMode: 0o600,
dispatcher,
});
this.agent = new MykbDaemon({
brainPath: opts.brainPath,
socketPath: opts.agentSocketPath,
resolveCapability: () => 'agent',
socketMode: 0o666,
dispatcher,
});
}

async listen(): Promise<void> {
await Promise.all([this.operator.listen(), this.agent.listen()]);
}
Comment on lines +55 to +57
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make dual-socket startup atomic (rollback on partial listen failure).

If one listen() succeeds and the other fails, one daemon can remain active after startup failure. Add rollback so startup either fully succeeds or fully unwinds.

Suggested fix
   async listen(): Promise<void> {
-    await Promise.all([this.operator.listen(), this.agent.listen()]);
+    await this.operator.listen();
+    try {
+      await this.agent.listen();
+    } catch (e) {
+      await this.operator.close().catch(() => {});
+      throw e;
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async listen(): Promise<void> {
await Promise.all([this.operator.listen(), this.agent.listen()]);
}
async listen(): Promise<void> {
await this.operator.listen();
try {
await this.agent.listen();
} catch (e) {
await this.operator.close().catch(() => {});
throw e;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/daemon/dual-socket.ts` around lines 55 - 57, The current listen() calls
this.operator.listen() and this.agent.listen() concurrently and can leave one
daemon running if the other fails; change listen() to perform both starts
atomically by using Promise.allSettled (or sequential try/catch) to detect
partial success, and if one listen resolved while the other rejected call the
corresponding teardown on the started peer (e.g., this.operator.close() or
this.operator.stop()/dispose() and/or this.agent.close()/stop()/dispose()—choose
the actual cleanup method implemented) to roll back, then rethrow an aggregated
error so startup either fully succeeds or is fully unwound.


async close(): Promise<void> {
await Promise.all([this.operator.close(), this.agent.close()]);
}
}
46 changes: 37 additions & 9 deletions src/daemon/main.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
/**
* `mykbd` entrypoint — `docs/v2-protocol-contract-DESIGN.md` §4.1.
* `mykbd` entrypoint — `docs/v2-protocol-contract-DESIGN.md` §4.1 / §2.2.
*
* Dev-mode launcher (parent DESIGN §Dev-mode strategy: `npm run
* daemon:dev`). Production supervision (systemd) and the SO_PEERCRED
* capability resolver are Phase 6 deliverables; this entrypoint runs the
* daemon against the operator's own brain with the default (operator)
* capability, which is exactly the trusted dev-mode contract.
* Two modes:
*
* - **dev / single-socket** (default, `npm run daemon:dev`): one socket,
* `operator` capability — the trusted host-operator loop (parent
* DESIGN §Dev-mode).
* - **production / dual-socket**: set `MYKB_OPERATOR_SOCKET` +
* `MYKB_AGENT_SOCKET`. The operator socket (0600) is host-local; the
* agent socket (0666) is the one bind-mounted into the Pi container.
* Capability is decided by which socket the connection arrives on
* (§2.2 amended — no SO_PEERCRED needed). Run under systemd
* (`deploy/mykbd.service`); see `docs/v2-container-topology.md`.
*/

import * as os from 'node:os';
import * as path from 'node:path';
import { MykbDaemon } from './server.js';
import { DualSocketDaemon } from './dual-socket.js';

function defaultBrainPath(): string {
return process.env.MYKB_DIR ?? path.join(os.homedir(), '.mykb');
Expand All @@ -20,12 +27,33 @@ function defaultSocketPath(brainPath: string): string {
return process.env.MYKB_SOCKET ?? path.join(brainPath, '.mykbd.sock');
}

interface Runnable {
listen(): Promise<void>;
close(): Promise<void>;
}

export async function main(): Promise<void> {
const brainPath = defaultBrainPath();
const socketPath = defaultSocketPath(brainPath);
const daemon = new MykbDaemon({ brainPath, socketPath });
const op = process.env.MYKB_OPERATOR_SOCKET;
const ag = process.env.MYKB_AGENT_SOCKET;

let daemon: Runnable;
let banner: string;
if (op && ag) {
daemon = new DualSocketDaemon({
brainPath,
operatorSocketPath: op,
agentSocketPath: ag,
});
banner = `mykbd (dual-socket) operator=${op} agent=${ag} (brain: ${brainPath})`;
} else {
const socketPath = defaultSocketPath(brainPath);
daemon = new MykbDaemon({ brainPath, socketPath });
banner = `mykbd (single-socket, operator) ${socketPath} (brain: ${brainPath})`;
}
Comment on lines +37 to +53
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast when only one dual-socket env var is set.

Current logic silently drops to single-socket mode on partial config (MYKB_OPERATOR_SOCKET xor MYKB_AGENT_SOCKET), which can mask deployment errors.

Suggested fix
   const op = process.env.MYKB_OPERATOR_SOCKET;
   const ag = process.env.MYKB_AGENT_SOCKET;
+  if ((op && !ag) || (!op && ag)) {
+    throw new Error(
+      'Set both MYKB_OPERATOR_SOCKET and MYKB_AGENT_SOCKET for dual-socket mode',
+    );
+  }

   let daemon: Runnable;
   let banner: string;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const op = process.env.MYKB_OPERATOR_SOCKET;
const ag = process.env.MYKB_AGENT_SOCKET;
let daemon: Runnable;
let banner: string;
if (op && ag) {
daemon = new DualSocketDaemon({
brainPath,
operatorSocketPath: op,
agentSocketPath: ag,
});
banner = `mykbd (dual-socket) operator=${op} agent=${ag} (brain: ${brainPath})`;
} else {
const socketPath = defaultSocketPath(brainPath);
daemon = new MykbDaemon({ brainPath, socketPath });
banner = `mykbd (single-socket, operator) ${socketPath} (brain: ${brainPath})`;
}
const op = process.env.MYKB_OPERATOR_SOCKET;
const ag = process.env.MYKB_AGENT_SOCKET;
if ((op && !ag) || (!op && ag)) {
throw new Error(
'Set both MYKB_OPERATOR_SOCKET and MYKB_AGENT_SOCKET for dual-socket mode',
);
}
let daemon: Runnable;
let banner: string;
if (op && ag) {
daemon = new DualSocketDaemon({
brainPath,
operatorSocketPath: op,
agentSocketPath: ag,
});
banner = `mykbd (dual-socket) operator=${op} agent=${ag} (brain: ${brainPath})`;
} else {
const socketPath = defaultSocketPath(brainPath);
daemon = new MykbDaemon({ brainPath, socketPath });
banner = `mykbd (single-socket, operator) ${socketPath} (brain: ${brainPath})`;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/daemon/main.ts` around lines 37 - 53, The current startup logic silently
falls back to single-socket mode when only one of the dual-socket env vars
(MYKB_OPERATOR_SOCKET or MYKB_AGENT_SOCKET) is set; update the init in main.ts
to detect the XOR case (op defined XOR ag defined) and fail fast by
logging/throwing a clear error and exiting instead of creating a MykbDaemon;
keep the existing DualSocketDaemon creation when both op and ag are present and
the MykbDaemon/defaultSocketPath path when neither is present, but explicitly
abort when exactly one of op or ag is provided to surface misconfiguration.


await daemon.listen();
console.error(`mykbd listening on ${socketPath} (brain: ${brainPath})`);
console.error(banner);

const shutdown = () => {
daemon
Expand Down
16 changes: 12 additions & 4 deletions src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ export interface DaemonOptions {
socketPath: string;
/** Strategy seam for §2.2 capability resolution; default → 'operator'. */
resolveCapability?: (socket: net.Socket) => Capability;
/** Socket file mode; default 0600 (operator-only connect, §2.2). */
socketMode?: number;
/**
* Share one Dispatcher across listeners (used by DualSocketDaemon so
* both capability sockets serve the same brain through one process).
*/
dispatcher?: Dispatcher;
}

export class MykbDaemon {
Expand All @@ -38,7 +45,7 @@ export class MykbDaemon {

constructor(opts: DaemonOptions) {
this.opts = opts;
this.dispatcher = new Dispatcher(opts.brainPath);
this.dispatcher = opts.dispatcher ?? new Dispatcher(opts.brainPath);
}

listen(): Promise<void> {
Expand All @@ -51,9 +58,10 @@ export class MykbDaemon {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(this.opts.socketPath, () => {
// 0600: only the brain-owning uid may even connect (defence in
// depth; capability still derives from SO_PEERCRED, not the mode).
fs.chmodSync(this.opts.socketPath, 0o600);
// Default 0600: only the brain-owning uid may connect (the
// operator-capability kernel gate, §2.2 amended). The agent
// socket overrides this to be container-connectable.
fs.chmodSync(this.opts.socketPath, this.opts.socketMode ?? 0o600);
server.off('error', reject);
Comment on lines +61 to 65
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle chmodSync failures in listen() instead of throwing from callback.

A permission/path error here can throw out of the server.listen callback and bypass your promise rejection flow.

Suggested fix
       server.listen(this.opts.socketPath, () => {
-        // Default 0600: only the brain-owning uid may connect (the
-        // operator-capability kernel gate, §2.2 amended). The agent
-        // socket overrides this to be container-connectable.
-        fs.chmodSync(this.opts.socketPath, this.opts.socketMode ?? 0o600);
+        // Default 0600: only the brain-owning uid may connect (the
+        // operator-capability kernel gate, §2.2 amended). The agent
+        // socket overrides this to be container-connectable.
+        try {
+          fs.chmodSync(this.opts.socketPath, this.opts.socketMode ?? 0o600);
+        } catch (e) {
+          server.off('error', reject);
+          server.close(() => reject(e));
+          return;
+        }
         server.off('error', reject);
         resolve();
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Default 0600: only the brain-owning uid may connect (the
// operator-capability kernel gate, §2.2 amended). The agent
// socket overrides this to be container-connectable.
fs.chmodSync(this.opts.socketPath, this.opts.socketMode ?? 0o600);
server.off('error', reject);
// Default 0600: only the brain-owning uid may connect (the
// operator-capability kernel gate, §2.2 amended). The agent
// socket overrides this to be container-connectable.
try {
fs.chmodSync(this.opts.socketPath, this.opts.socketMode ?? 0o600);
} catch (e) {
server.off('error', reject);
server.close(() => reject(e));
return;
}
server.off('error', reject);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/daemon/server.ts` around lines 61 - 65, The chmodSync call inside the
server.listen callback can throw and escape the Promise rejection path; wrap
fs.chmodSync(this.opts.socketPath, this.opts.socketMode ?? 0o600) in a try/catch
inside the listen callback (or where the code currently calls fs.chmodSync), and
on error call reject(err) (and still call server.off('error', reject) before
rejecting) so the Promise is always settled via reject instead of throwing;
ensure the success path still calls server.off('error', reject) and resolves as
before.

resolve();
});
Expand Down
Loading