From b9a9423eddaf8293a37197cd8cc3a3cebf612174 Mon Sep 17 00:00:00 2001 From: vilosource Date: Sat, 16 May 2026 07:29:55 +0300 Subject: [PATCH] feat(daemon): dual capability sockets + container topology (phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD red→green. Resolves the SO_PEERCRED deferral via the contract's own amendment process (§2.2 amended, with forcing rationale). Forcing constraint: Node exposes no public SO_PEERCRED API. Resolution keeps the security property (capability kernel-attested, never client-asserted) via TWO capability sockets — the parent DESIGN already sanctioned 'a different socket / capability', so this is principled, not ad-hoc: - operator socket 0600 (brain uid only) → operator capability - agent socket 0666 (bind-mounted into the container) → agent, capped (access != privilege: that socket only ever grants agent capability) - server.ts: optional socketMode + shared dispatcher (DRY reuse). - dual-socket.ts: DualSocketDaemon — one Dispatcher, two MykbDaemon listeners; 4 tests (capability-by-socket, 0600/0666 modes, shared brain, agent verify_entry → TRUST_DENIED over the wire). - main.ts: dual-socket production mode when MYKB_OPERATOR_SOCKET + MYKB_AGENT_SOCKET set; single-socket operator dev default retained. - deploy/mykbd.service: reference systemd unit (hardened, templated). - docs/v2-container-topology.md: RO-brain-mount + agent-socket-bind-mount topology + the viloforge-platform argo integration seam (the cross-repo manifest change is a deployment action, out of mykb-repo scope per the container-only scope + vafi-config-in-viloforge-platform fact). - contract §7.1 SO_PEERCRED row flipped ⏭→✅. Full suite 650/650 across 2 runs; tsc + lint clean. --- deploy/mykbd.service | 48 +++++++++++ docs/v2-container-topology.md | 91 +++++++++++++++++++++ docs/v2-protocol-contract-DESIGN.md | 11 ++- src/daemon/dual-socket.ts | 62 +++++++++++++++ src/daemon/main.ts | 46 ++++++++--- src/daemon/server.ts | 16 +++- tests/daemon/dual-socket.test.ts | 119 ++++++++++++++++++++++++++++ 7 files changed, 378 insertions(+), 15 deletions(-) create mode 100644 deploy/mykbd.service create mode 100644 docs/v2-container-topology.md create mode 100644 src/daemon/dual-socket.ts create mode 100644 tests/daemon/dual-socket.test.ts diff --git a/deploy/mykbd.service b/deploy/mykbd.service new file mode 100644 index 0000000..5cfb6b1 --- /dev/null +++ b/deploy/mykbd.service @@ -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 +# +# 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 diff --git a/docs/v2-container-topology.md b/docs/v2-container-topology.md new file mode 100644 index 0000000..a7d0344 --- /dev/null +++ b/docs/v2-container-topology.md @@ -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 +``` + +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@`. +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. diff --git a/docs/v2-protocol-contract-DESIGN.md b/docs/v2-protocol-contract-DESIGN.md index 7ac621a..864b6cf 100644 --- a/docs/v2-protocol-contract-DESIGN.md +++ b/docs/v2-protocol-contract-DESIGN.md @@ -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. 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. @@ -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 | diff --git a/src/daemon/dual-socket.ts b/src/daemon/dual-socket.ts new file mode 100644 index 0000000..3dd6003 --- /dev/null +++ b/src/daemon/dual-socket.ts @@ -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 { + await Promise.all([this.operator.listen(), this.agent.listen()]); + } + + async close(): Promise { + await Promise.all([this.operator.close(), this.agent.close()]); + } +} diff --git a/src/daemon/main.ts b/src/daemon/main.ts index 7d53af3..55b5118 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -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'); @@ -20,12 +27,33 @@ function defaultSocketPath(brainPath: string): string { return process.env.MYKB_SOCKET ?? path.join(brainPath, '.mykbd.sock'); } +interface Runnable { + listen(): Promise; + close(): Promise; +} + export async function main(): Promise { 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})`; + } + await daemon.listen(); - console.error(`mykbd listening on ${socketPath} (brain: ${brainPath})`); + console.error(banner); const shutdown = () => { daemon diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 0ec4d9e..748a78b 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -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 { @@ -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 { @@ -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); resolve(); }); diff --git a/tests/daemon/dual-socket.test.ts b/tests/daemon/dual-socket.test.ts new file mode 100644 index 0000000..f262d96 --- /dev/null +++ b/tests/daemon/dual-socket.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { withTempBrain } from '../helpers.js'; +import { initBrain } from '../../src/core/init.js'; +import { createArea } from '../../src/core/area.js'; +import { DualSocketDaemon } from '../../src/daemon/dual-socket.js'; +import { encodeFrame, FrameDecoder } from '../../src/daemon/transport.js'; + +// Phase 6 — dual capability sockets (contract §2.2 amended): which socket +// a connection arrives on determines its capability; the kernel enforces +// who may connect() to the 0600 operator socket. No SO_PEERCRED needed. + +let rpcId = 0; +function rpc(sock: string, method: string, params: Record) { + return new Promise<{ result?: unknown; error?: { data: { kind: string } } }>( + (resolve, reject) => { + const c = net.connect(sock); + const dec = new FrameDecoder(); + const id = ++rpcId; + c.on('connect', () => + c.write(encodeFrame(JSON.stringify({ jsonrpc: '2.0', id, method, params }))), + ); + c.on('data', (chunk: Buffer) => { + const f = dec.push(chunk); + if (f.length) { + c.end(); + resolve(JSON.parse(f[0])); + } + }); + c.on('error', reject); + }, + ); +} + +const daemons: DualSocketDaemon[] = []; +afterEach(async () => { + for (const d of daemons.splice(0)) await d.close(); +}); + +async function start(brainPath: string) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mykbd-dual-')); + const operatorSocketPath = path.join(dir, 'op.sock'); + const agentSocketPath = path.join(dir, 'agent.sock'); + const d = new DualSocketDaemon({ brainPath, operatorSocketPath, agentSocketPath }); + await d.listen(); + daemons.push(d); + return { d, operatorSocketPath, agentSocketPath }; +} + +describe('DualSocketDaemon — capability by socket', () => { + it('a connection on the operator socket has operator capability', async () => { + await withTempBrain(async (bp) => { + initBrain(bp); + createArea(bp, 'docker', 'Docker', 'c'); + const { operatorSocketPath } = await start(bp); + const add = (await rpc(operatorSocketPath, 'add_fact', { + area: 'docker', + text: 'op fact', + })) as { result: { id: string } }; + // verify_entry is operator-only (§5.2) — must succeed here. + const v = await rpc(operatorSocketPath, 'verify_entry', { + area: 'docker', + id: add.result.id, + }); + expect(v.result).toEqual({}); + }); + }); + + it('a connection on the agent socket has agent capability', async () => { + await withTempBrain(async (bp) => { + initBrain(bp); + createArea(bp, 'docker', 'Docker', 'c'); + const { operatorSocketPath, agentSocketPath } = await start(bp); + const add = (await rpc(operatorSocketPath, 'add_fact', { + area: 'docker', + text: 'shared fact', + })) as { result: { id: string } }; + // Same brain, but verify_entry over the AGENT socket → TRUST_DENIED. + const denied = await rpc(agentSocketPath, 'verify_entry', { + area: 'docker', + id: add.result.id, + }); + expect(denied.error?.data.kind).toBe('TRUST_DENIED'); + // …while a normal agent write still works on the agent socket. + const ok = await rpc(agentSocketPath, 'add_fact', { + area: 'docker', + text: 'agent fact', + }); + expect((ok as { result: { id: string } }).result.id).toMatch(/\w+/); + }); + }); + + it('the operator socket is created mode 0600; the agent socket is group/other-connectable', async () => { + await withTempBrain(async (bp) => { + initBrain(bp); + const { operatorSocketPath, agentSocketPath } = await start(bp); + expect(fs.statSync(operatorSocketPath).mode & 0o777).toBe(0o600); + // agent socket must be connectable by the container (different uid): + // at least group/other read+write. + expect(fs.statSync(agentSocketPath).mode & 0o066).not.toBe(0); + }); + }); + + it('both sockets share one brain (writes on one are visible on the other)', async () => { + await withTempBrain(async (bp) => { + initBrain(bp); + createArea(bp, 'docker', 'Docker', 'c'); + const { operatorSocketPath, agentSocketPath } = await start(bp); + await rpc(agentSocketPath, 'add_fact', { area: 'docker', text: 'via agent' }); + const load = (await rpc(operatorSocketPath, 'load_area', { + area: 'docker', + })) as { result: { entries: { text: string }[] } }; + expect(load.result.entries.map((e) => e.text)).toContain('via agent'); + }); + }); +});