diff --git a/bun.lock b/bun.lock index fe95c696..8134a42a 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "hra", "dependencies": { "@agentclientprotocol/sdk": "1.4.0", - "@hraness/local-custody": "0.5.1", + "@hraness/local-custody": "0.6.0", "@hraness/oh": "0.10.8", "@openai/codex": "0.153.2", "convex": "1.45.0", @@ -197,7 +197,7 @@ "@hraness/direct": ["@hraness/direct@github:hraness/direct#849f341", { "peerDependencies": { "agent-browser": "0.32.3", "react": ">=18 <20" }, "optionalPeers": ["agent-browser", "react"] }, "hraness-direct-849f341", "sha512-cvWcokL4EY871qpDMjaZm85pSMD3OjwmTAcN/xUgzeXDPeAt0S7+3gP0X/77MUA1hma5SoHiNJlKdnEXL3PVew=="], - "@hraness/local-custody": ["@hraness/local-custody@0.5.1", "", {}, "sha512-Rmtm6+d7nSXVhSMw1Fesa+t+w2UxtPKJe8qCC/wiacKz6eGZ7RI1OhIOYOnnPW6itNTcXfjFMJV6RRoY9RtAVg=="], + "@hraness/local-custody": ["@hraness/local-custody@0.6.0", "", {}, "sha512-vkAT2r8S98yilB4uWZyH0U1DCOzxUJ42hmDlA3IA0V/7YP6rEh4OKhnZCThabA4g1YzpetO8exTOlXx21taqwA=="], "@hraness/oh": ["@hraness/oh@0.10.8", "", { "peerDependencies": { "@libsql/client": ">=0.17.4 <1", "@suss/datalog": "0.20.0", "@tobilu/qmd": "2.5.3" }, "optionalPeers": ["@libsql/client", "@suss/datalog", "@tobilu/qmd"], "bin": { "oh": "dist/cli.js" } }, "sha512-rtm+D1pzlxTE84zAnIFq3dxQulU/HpstnaUYYW1Cl9HjNG1p+Z0Oy0oNTUQpxtYHh3XClDS2MLsV693WcUwOOA=="], diff --git a/package.json b/package.json index 8132e2f8..30cc6254 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ ], "dependencies": { "@agentclientprotocol/sdk": "1.4.0", - "@hraness/local-custody": "0.5.1", + "@hraness/local-custody": "0.6.0", "@hraness/oh": "0.10.8", "@openai/codex": "0.153.2", "convex": "1.45.0", diff --git a/scripts/package-policy.ts b/scripts/package-policy.ts index 4ed80cd9..337adcb6 100644 --- a/scripts/package-policy.ts +++ b/scripts/package-policy.ts @@ -131,9 +131,9 @@ export async function assertProductionPackageOnly( export async function assertReviewedReleaseInventory(packageRoot: string): Promise { const expected = Object.freeze({ - count: 234, - jsonBytes: 11_558, - sha256: "a6ddd4e33d97a857334e3fe682896b8ba1121305a82fc89bd0f98e2a8c914dc8", + count: 235, + jsonBytes: 11_607, + sha256: "f24680adad7934ba885afb0e8143acb5e61dda2f461acc52aaa91afb32c02f9d", }); const inventory: Array = []; const visit = async (path: string): Promise => { diff --git a/src/claude/account.ts b/src/claude/account.ts index 26229a73..bd80b7b3 100644 --- a/src/claude/account.ts +++ b/src/claude/account.ts @@ -137,7 +137,11 @@ async function readAccountMetadataDocument(path: string): Promise { // The personal-home path is user-controlled and can change between scans. // The stable read opens nonblocking so a FIFO swapped in before stat cannot // stall daemon admission, then re-proves identity and metadata after the - // bounded read. + // bounded read. A missing document must surface as a raw `ENOENT` + // `ErrnoException` so the projection reads null rather than stale, while a + // permissions failure keeps failing closed; the local-custody Rust engine + // reports both as `CustodyError` domain failures without the errno, so this + // read keeps the direct TypeScript import. let bytes: Buffer; try { bytes = (await readOwnedFileStable(path, ACCOUNT_DOCUMENT_MAX_BYTES)).bytes; diff --git a/src/daemon/AGENTS.md b/src/daemon/AGENTS.md index 26d16add..48a79578 100644 --- a/src/daemon/AGENTS.md +++ b/src/daemon/AGENTS.md @@ -1,6 +1,7 @@ # Contents - The daemon hosts the local command authority, long-running provider processes, and opaque session-memory lifecycle coordination. +- `custody-engine.ts` holds the one process-wide local-custody engine: it prefers the packaged Rust sidecar and falls back per operation. Custody checks whose callers branch on `ENOENT` keep the direct TypeScript imports because the sidecar reports a missing path as a `CustodyError`, not an `ErrnoException`. - One session has one exact provider binding at a time. A durable switch journal alone may replace that binding; existing effects and interactions retain their original authority. The service selects the captured provider's `SessionRuntimePort` for start, turns, steering, interrupt, projection reads and interactions. Provider facts use one neutral timeline vocabulary. - The Unix socket transports one bounded authenticated request at a time. - The explicit live-acceptance daemon composition exposes a structural observer for actual personal-provider children. Its acceptance implementation and bounded status policy live under `scripts/`; the reusable exact-child adapter lives under `src/claude/`. diff --git a/src/daemon/claude-host-tool-transport.ts b/src/daemon/claude-host-tool-transport.ts index b36a6643..395eed21 100644 --- a/src/daemon/claude-host-tool-transport.ts +++ b/src/daemon/claude-host-tool-transport.ts @@ -8,6 +8,7 @@ import type { ClaudeHostToolCallbackHandler, } from "../claude/index.ts"; import { ensurePrivateDirectory, type StatePaths } from "../storage/paths.ts"; +import { localCustodyEngine } from "./custody-engine.ts"; const CALLBACK_SOCKET_NAME = "claude-host-tools.sock"; const CALLBACK_REQUEST_MAX_BYTES = 4 * 1_024 * 1_024; @@ -25,10 +26,17 @@ export class ClaudeHostToolTransportShutdownTimeoutError extends Error { export const claudeHostToolCallbackSocketPath = (paths: StatePaths): string => join(paths.runtime, CALLBACK_SOCKET_NAME); +// The socket was just bound and chmodded, so a missing path is not a distinct +// outcome here; the custody engine's Rust sidecar owns this re-validation. const assertPrivateSocket = async (path: string): Promise => { - await assertOwnedPath(path, { kind: "socket", exactMode: 0o600 }); + const custody = await localCustodyEngine(); + await custody.assertOwnedPath(path, { kind: "socket", exactMode: 0o600 }); }; +// A missing endpoint must surface as a raw `ENOENT` `ErrnoException` so the +// stale-socket path is skipped; the custody engine reports a missing path as +// a `CustodyError` domain failure (sidecar code `stat`), so this check keeps +// the direct TypeScript import. const removeStaleSocket = async (path: string): Promise => { try { await assertOwnedPath(path, { kind: "socket" }); diff --git a/src/daemon/custody-engine.ts b/src/daemon/custody-engine.ts new file mode 100644 index 00000000..4723d444 --- /dev/null +++ b/src/daemon/custody-engine.ts @@ -0,0 +1,23 @@ +import { + loadLocalCustodyRustEngine, + type LocalCustodyRustEngine, +} from "@hraness/local-custody/custody-rust"; + +let enginePromise: Promise | undefined; + +/** + * The one process-wide local-custody engine. The loader probes the packaged + * Rust sidecar once; each delegated operation then prefers the sidecar and + * falls back to the TypeScript implementation operation by operation, with a + * bounded `local-custody-rust-fallback` stderr notice when it does. + * + * Only operations whose observable contract survives the engine qualify here: + * the sidecar reports a missing path as a `CustodyError` domain failure with + * a `stat`/`open` code, never an `ENOENT` `ErrnoException`, so custody checks + * whose callers branch on `error.code === "ENOENT"` keep the direct + * `@hraness/local-custody` imports instead of routing through this engine. + */ +export function localCustodyEngine(): Promise { + enginePromise ??= loadLocalCustodyRustEngine(); + return enginePromise; +} diff --git a/src/daemon/local-transport.ts b/src/daemon/local-transport.ts index 34e2ee37..95d3cada 100644 --- a/src/daemon/local-transport.ts +++ b/src/daemon/local-transport.ts @@ -4,7 +4,6 @@ import { createConnection, createServer, type Server, type Socket } from "node:n import { basename, dirname } from "node:path"; import { assertOwnedPath, readOwnedFileStable } from "@hraness/local-custody/private-paths"; -import { publishPrivateFile } from "@hraness/local-custody/atomic-publish"; import { commandEnvelopeSchema, commandResponseSchema, @@ -16,6 +15,7 @@ import { type LocalCommand, } from "../domain/contracts"; import { ensurePrivateDirectory, type StatePaths } from "../storage/paths"; +import { localCustodyEngine } from "./custody-engine"; const maximumRequestBytes = LOCAL_COMMAND_REQUEST_MAX_BYTES; const maximumResponseBytes = LOCAL_COMMAND_RESPONSE_MAX_BYTES; @@ -75,6 +75,12 @@ const boundedTimeoutMs = (value: number | undefined, fallback: number): number = return candidate; }; +// Every caller of this validator treats a raw `ENOENT` `ErrnoException` as +// "the endpoint is absent": stale-endpoint cleanup skips it and the client +// maps it onto `LocalDaemonUnavailableError`. The custody engine reports a +// missing path as a `CustodyError` domain failure (sidecar code `stat`), not +// `ENOENT`, so these checks keep the direct TypeScript import; the engine +// covers only operations where a missing path is not a distinct outcome. async function validateOwnedFile(path: string, kind: "file" | "socket", mode?: number): Promise { try { await assertOwnedPath(path, { kind, ...(mode === undefined ? {} : { exactMode: mode }) }); @@ -96,7 +102,8 @@ async function removeStaleEndpoint(paths: StatePaths): Promise { } async function publishCapability(paths: StatePaths, capability: string): Promise { - await publishPrivateFile(dirname(paths.capability), basename(paths.capability), `${capability}\n`); + const custody = await localCustodyEngine(); + await custody.publishPrivateFile(dirname(paths.capability), basename(paths.capability), `${capability}\n`); } const publicFailureCodes = [ diff --git a/src/install-preflight.test.ts b/src/install-preflight.test.ts index 96714af1..f73d8f4c 100644 --- a/src/install-preflight.test.ts +++ b/src/install-preflight.test.ts @@ -859,7 +859,7 @@ describe("transactional Oompa installer", () => { test("strips only dependency maps from the private installer fixture", () => { expect(sourcePackageManifest.dependencies).toEqual({ "@agentclientprotocol/sdk": "1.4.0", - "@hraness/local-custody": "0.5.1", + "@hraness/local-custody": "0.6.0", "@hraness/oh": "0.10.8", "@openai/codex": "0.153.2", convex: "1.45.0",